@phnx-labs/agents-cli 1.22.50 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,132 @@
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
+
3
130
  ## 1.22.50
4
131
 
5
132
  ### Fixed
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
  }
@@ -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) {
@@ -7,7 +7,7 @@ import { arch, platform, release } from 'node:os';
7
7
  import { createRequire } from 'node:module';
8
8
  import chalk from 'chalk';
9
9
  import { showUrl } from '../lib/open-url.js';
10
- const REPO = 'phnx-labs/agents-cli';
10
+ const REPO = 'phnx-labs/agi-cli';
11
11
  const DISCUSSION_BASE = `https://github.com/${REPO}/discussions/new`;
12
12
  const ISSUE_BASE = `https://github.com/${REPO}/issues/new`;
13
13
  function readCliVersion() {
@@ -27,8 +27,9 @@ function printStatus(s, opts = {}) {
27
27
  return;
28
28
  }
29
29
  console.log(` app installed ${s.installedApp ? chalk.gray(s.installedApp) : chalk.gray('no')}`);
30
- console.log(` installed version ${s.installedVersion ? chalk.gray(s.installedVersion) : chalk.gray('unknown')}`);
31
- console.log(` current version ${chalk.gray(s.currentVersion)}`);
30
+ console.log(` helper installed ${s.installedVersion ? chalk.gray(s.installedVersion) : chalk.gray('unknown')}`);
31
+ console.log(` helper available ${chalk.gray(s.currentVersion)}`);
32
+ console.log(` CLI version ${chalk.gray(s.cliVersion)}`);
32
33
  console.log(` bundle source ${s.source ? chalk.gray(s.source) : chalk.red('missing (cannot enable)')}`);
33
34
  console.log(` disabled by user ${yn(s.disabledByUser)}`);
34
35
  // Two copies of the INSTALLED bundle is the duplicate the user sees as two
@@ -80,8 +81,9 @@ function printSetupResult(r) {
80
81
  function printDoctorReport(r) {
81
82
  console.log(chalk.bold('AGI Menu doctor\n'));
82
83
  console.log(` install path ${r.installPath ? chalk.gray(r.installPath) : chalk.red('not installed')}`);
83
- console.log(` installed version ${r.installedVersion ? chalk.gray(r.installedVersion) : chalk.gray('unknown')}`);
84
- console.log(` CLI version ${chalk.gray(r.currentVersion)}${r.versionMatches ? '' : chalk.yellow(' (mismatch — `agents menubar setup` updates it)')}`);
84
+ console.log(` helper installed ${r.installedVersion ? chalk.gray(r.installedVersion) : chalk.gray('unknown')}`);
85
+ console.log(` helper available ${chalk.gray(r.currentVersion)}${r.versionMatches ? '' : chalk.yellow(' (newer helper available — `agents menubar setup` installs it)')}`);
86
+ console.log(` CLI version ${chalk.gray(r.cliVersion)}`);
85
87
  const identity = r.signingIdentity === 'developer-id'
86
88
  ? chalk.green('Developer ID (update-stable)')
87
89
  : r.signingIdentity === 'ad-hoc'
@@ -1,7 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { setHelpSections } from '../lib/help.js';
3
- import { syncTraces } from '../lib/traces/sync.js';
4
- import { readSyncLedger } from '../lib/traces/sync.js';
3
+ import { readSyncLedger, syncTraces } from '../lib/traces/sync.js';
5
4
  import { managedTracesBaseUrl, resolveTracesBackend } from '../lib/traces/backend.js';
6
5
  import { showUrl } from '../lib/open-url.js';
7
6
  import { DEFAULT_CF_BUNDLE, readCloudflareCreds } from '../lib/share/config.js';
@@ -93,7 +92,18 @@ async function handleSync(opts) {
93
92
  parts.push(chalk.dim(`${result.skipped} skipped`));
94
93
  }
95
94
  if (result.errors > 0) {
96
- parts.push(chalk.yellow(`${result.errors} errors`));
95
+ // Distinguish expected history (transcripts cleaned off disk) from genuine
96
+ // failures that will be retried, so the operator knows which need attention.
97
+ const detail = [];
98
+ if (result.transcriptUnavailable > 0) {
99
+ detail.push(`${result.transcriptUnavailable} transcripts no longer on disk`);
100
+ }
101
+ const retryable = result.parseFailed + result.uploadFailed;
102
+ if (retryable > 0) {
103
+ detail.push(`${retryable} parse/upload failures — will retry`);
104
+ }
105
+ parts.push(chalk.yellow(`${result.errors} errors`) +
106
+ (detail.length ? chalk.dim(` (${detail.join(' · ')})`) : ''));
97
107
  }
98
108
  if (parts.length === 0) {
99
109
  parts.push(chalk.dim('nothing new'));
@@ -108,7 +118,25 @@ async function handleStatus() {
108
118
  }
109
119
  const when = new Date(ledger.lastSyncMtime).toLocaleString();
110
120
  console.log(`Last sync: ${chalk.bold(when)}`);
111
- console.log(chalk.dim('Run `agents traces sync` to push new sessions.'));
121
+ const failures = ledger.failures ?? [];
122
+ if (failures.length > 0) {
123
+ const byKind = new Map();
124
+ for (const f of failures) {
125
+ const list = byKind.get(f.kind) ?? [];
126
+ list.push(f);
127
+ byKind.set(f.kind, list);
128
+ }
129
+ console.log(chalk.yellow(`\n${failures.length} unresolved failure${failures.length === 1 ? '' : 's'}:`));
130
+ for (const [kind, list] of byKind) {
131
+ const retry = kind === 'transcript-unavailable' ? chalk.dim(' (not retried)') : chalk.dim(' (retried each sync)');
132
+ console.log(` ${chalk.bold(list.length)} ${kind}${retry}`);
133
+ const example = list[0];
134
+ if (example?.detail) {
135
+ console.log(chalk.dim(` e.g. ${example.id.slice(0, 8)} · ${example.detail}`));
136
+ }
137
+ }
138
+ }
139
+ console.log(chalk.dim('\nRun `agents traces sync` to push new sessions.'));
112
140
  }
113
141
  async function handleOpen() {
114
142
  // M2 console — for now open the base URL which will route to the console once deployed.
@@ -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 {