@phnx-labs/agents-cli 1.22.101 → 1.22.103

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 (36) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/bootstrap.js +15 -0
  3. package/dist/commands/accounts.js +1 -1
  4. package/dist/commands/menubar.js +4 -1
  5. package/dist/commands/ssh.js +12 -4
  6. package/dist/commands/view.d.ts +0 -2
  7. package/dist/commands/view.js +9 -28
  8. package/dist/lib/account-catalog.d.ts +24 -0
  9. package/dist/lib/account-catalog.js +45 -15
  10. package/dist/lib/accounting/usage.d.ts +7 -0
  11. package/dist/lib/accounting/usage.js +25 -0
  12. package/dist/lib/auth-health.d.ts +17 -4
  13. package/dist/lib/auth-health.js +60 -22
  14. package/dist/lib/auto-pull-worker.js +38 -1
  15. package/dist/lib/devices/harness-inventory.d.ts +4 -0
  16. package/dist/lib/devices/harness-inventory.js +3 -1
  17. package/dist/lib/feed/pr-status.d.ts +18 -0
  18. package/dist/lib/feed/pr-status.js +43 -5
  19. package/dist/lib/feed/watch.d.ts +4 -1
  20. package/dist/lib/feed/watch.js +57 -25
  21. package/dist/lib/helper-versions.js +1 -1
  22. package/dist/lib/menubar/install-menubar.d.ts +85 -0
  23. package/dist/lib/menubar/install-menubar.js +171 -6
  24. package/dist/lib/menubar/resolve-version.d.ts +82 -0
  25. package/dist/lib/menubar/resolve-version.js +133 -0
  26. package/dist/lib/profiles.js +13 -2
  27. package/dist/lib/secrets-client.d.ts +20 -0
  28. package/dist/lib/secrets-client.js +42 -19
  29. package/dist/lib/self-heal/checks/menubar-helper.d.ts +2 -0
  30. package/dist/lib/self-heal/checks/menubar-helper.js +21 -0
  31. package/dist/lib/self-heal/registry.js +3 -0
  32. package/dist/lib/self-heal/types.d.ts +1 -1
  33. package/dist/lib/session/active.d.ts +1 -1
  34. package/dist/lib/session/active.js +7 -0
  35. package/dist/lib/session/state.d.ts +7 -0
  36. package/package.json +1 -1
@@ -27,6 +27,7 @@ import { compareVersions } from '../agent-spec/primitives.js';
27
27
  import { namespacedServiceLabel, serviceManifestHomeEnv, serviceManagerRegistrationAllowed } from '../service-manifest.js';
28
28
  import { downloadMenubarHelperApp, menubarHelperCacheDir } from './download-menubar.js';
29
29
  import { helperFloor } from '../helper-versions.js';
30
+ import { cachedMenubarVersion, resolveMenubarVersion } from './resolve-version.js';
30
31
  const APP_BUNDLE_NAME = 'MenubarHelper.app';
31
32
  const INSTALL_DIR_NAME = 'agents-cli';
32
33
  const SERVICE_LABEL_BASE = 'com.phnx-labs.agents-menubar';
@@ -171,8 +172,30 @@ export function menubarServiceInstalled() {
171
172
  * binary: `import.meta.url` is a virtual `/$bunfs/` path, so the sibling
172
173
  * candidates above can't see the on-disk bundle; recover it via the
173
174
  * `agents` launcher symlink.
175
+ * 4. the verified download cache for the RELEASE this machine has resolved
176
+ * (`cachedMenubarVersion`: the newest published build it last saw, never
177
+ * below the floor, the floor itself before any resolution) — the npm
178
+ * tarball ships no bundle (PHNX-4036), so on an `npm i -g` machine this is
179
+ * the only source there is. It is filled by `agents menubar setup` or by
180
+ * the detached background worker (`prefetchMenubarHelper`), never here:
181
+ * this resolver stays network-free so the startup self-heal stays cheap.
182
+ * Last so a bundle that ships with the build always wins; keyed by the
183
+ * resolved version so an older cached release is never picked up.
184
+ *
185
+ * `shippedAppPath` is candidates 1–3 alone: a bundle that ships WITH this
186
+ * install, which CLI upgrades own. The auto-update pass replaces a cached
187
+ * release (candidate 4) but never a shipped bundle.
174
188
  */
175
189
  function sourceAppPath() {
190
+ const shipped = shippedAppPath();
191
+ if (shipped)
192
+ return shipped;
193
+ const cached = cachedReleaseBundlePath();
194
+ if (fs.existsSync(cached))
195
+ return cached;
196
+ return null;
197
+ }
198
+ function shippedAppPath() {
176
199
  const candidates = [];
177
200
  try {
178
201
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -198,6 +221,30 @@ function sourceAppPath() {
198
221
  }
199
222
  return null;
200
223
  }
224
+ /** Where a downloaded copy of the floor release sits once fetched and verified. */
225
+ export function cachedFloorBundlePath() {
226
+ return path.join(menubarHelperCacheDir(helperFloor('menubar')), APP_BUNDLE_NAME);
227
+ }
228
+ /**
229
+ * Where the downloaded copy of the RESOLVED release sits (`cachedMenubarVersion`,
230
+ * so the floor's cache dir until this machine has resolved something newer).
231
+ * The startup self-heal installs from here, network-free.
232
+ */
233
+ export function cachedReleaseBundlePath() {
234
+ return path.join(menubarHelperCacheDir(cachedMenubarVersion()), APP_BUNDLE_NAME);
235
+ }
236
+ /**
237
+ * The release version an explicit install should fetch: the newest published
238
+ * build, but never below what this Mac already runs — a release deleted after
239
+ * it was installed must not roll the helper back through `setup`/`enable`.
240
+ */
241
+ export async function menubarVersionToInstall(opts = {}) {
242
+ const resolved = await resolveMenubarVersion({ force: opts.force });
243
+ const installed = readInstalledMenubarStamp();
244
+ if (installed?.source === 'release' && compareVersions(installed.helperVersion, resolved) > 0)
245
+ return installed.helperVersion;
246
+ return resolved;
247
+ }
201
248
  /** Resolve the compiled CLI entry (dist/index.js) so the helper can exec node directly. */
202
249
  function resolveCliEntry() {
203
250
  try {
@@ -503,9 +550,11 @@ function startMenubarServiceFromSource(opts = {}) {
503
550
  export async function enableMenubarService(opts = { clearOptOut: true }) {
504
551
  if (!onDarwin())
505
552
  return false;
506
- let src = sourceAppPath();
553
+ // A shipped bundle wins; otherwise fetch the newest published build (a cache
554
+ // hit when the background prefetch already has it), never below what runs.
555
+ let src = shippedAppPath();
507
556
  if (!src)
508
- src = await downloadMenubarHelperApp(helperFloor('menubar'));
557
+ src = await downloadMenubarHelperApp(await menubarVersionToInstall());
509
558
  return startMenubarServiceFromSource({ ...opts, sourceAppPath: src });
510
559
  }
511
560
  /** Drop the sticky `agents menubar disable` sentinel. */
@@ -553,8 +602,10 @@ export function stampVersionLabel(stamp) {
553
602
  function availableStamp() {
554
603
  const src = sourceAppPath();
555
604
  // No local bundle means the release path: what would be installed is the
556
- // helper version the floor names.
557
- return src ? stampFor(src) : { source: 'release', helperVersion: helperFloor('menubar') };
605
+ // newest published helper this machine has resolved (cached, day-old at
606
+ // most), never below the floor — and the floor itself before the first
607
+ // resolution or offline.
608
+ return src ? stampFor(src) : { source: 'release', helperVersion: cachedMenubarVersion() };
558
609
  }
559
610
  /** The helper version this install would put on disk right now, for display. */
560
611
  function availableHelperLabel() {
@@ -640,6 +691,41 @@ function menubarSetupStale() {
640
691
  execExists: fs.existsSync(installedExecutablePath()),
641
692
  });
642
693
  }
694
+ /**
695
+ * Pure decision (no I/O): should the detached background worker download the
696
+ * floor release into the cache so the next startup self-heal has a source?
697
+ *
698
+ * The self-heal (`installMenubarLaunchAgentOnUpgrade`) is deliberately
699
+ * network-free, and the npm tarball ships no bundle, so without this step an
700
+ * `npm i -g` Mac keeps whatever helper it has — a crashing 1.1.2, a dead 0.1.0 —
701
+ * until someone runs `agents menubar setup` by hand. The worker fetches only
702
+ * when the fetch would change something: nothing else can act as a source, the
703
+ * user has not opted out, and either no service exists yet (the auto-enable the
704
+ * bootstrap promises) or the installed helper is older than the floor.
705
+ */
706
+ export function menubarHelperPrefetchNeeded(opts) {
707
+ if (!opts.darwin || opts.disabledByUser || opts.hasSource)
708
+ return false;
709
+ return !opts.serviceInstalled || opts.stale;
710
+ }
711
+ /**
712
+ * Background half of the release-path self-heal: download + verify the floor
713
+ * release into the cache when `menubarHelperPrefetchNeeded` says so. Returns
714
+ * the cached path, or null when nothing was fetched. Run from the detached
715
+ * auto-pull worker; the foreground CLI never waits on it.
716
+ */
717
+ export async function prefetchMenubarHelper() {
718
+ const needed = menubarHelperPrefetchNeeded({
719
+ darwin: onDarwin(),
720
+ disabledByUser: menubarDisabledByUser(),
721
+ hasSource: Boolean(sourceAppPath()),
722
+ serviceInstalled: menubarServiceInstalled(),
723
+ stale: menubarSetupStale(),
724
+ });
725
+ if (!needed)
726
+ return null;
727
+ return downloadMenubarHelperApp(await menubarVersionToInstall());
728
+ }
643
729
  /**
644
730
  * Pure decision (no I/O): did THIS heal actually replace bundle content a live
645
731
  * process could be running stale, as opposed to a plist-only repoint?
@@ -1037,10 +1123,10 @@ export async function runMenubarSetup() {
1037
1123
  // notarized release asset for this CLI version. Verified (sha256 + codesign +
1038
1124
  // Team + designated-requirement pin + notarization) before install; the
1039
1125
  // cached copy is the source for `ensureMenubarAppInstalled` below.
1040
- let src = sourceAppPath();
1126
+ let src = shippedAppPath();
1041
1127
  if (!src) {
1042
1128
  try {
1043
- src = await downloadMenubarHelperApp(helperFloor('menubar'));
1129
+ src = await downloadMenubarHelperApp(await menubarVersionToInstall({ force: true }));
1044
1130
  }
1045
1131
  catch (e) {
1046
1132
  step('bundle', 'failed', `no AGI Menu bundle ships with this install, and the release-asset download failed: ${e.message}`);
@@ -1305,3 +1391,82 @@ export function buildMenubarDoctorReport() {
1305
1391
  accessibilityHintNeeded: signingIdentity === 'ad-hoc' || staleRunningProcess.some((p) => p.stale),
1306
1392
  };
1307
1393
  }
1394
+ /**
1395
+ * Bring an installed release helper up to the newest published build, without
1396
+ * a human running `agents menubar setup`.
1397
+ *
1398
+ * Runs from the daemon's self-heal tick and right after `agents upgrade`. It
1399
+ * only ever moves a RELEASE install forward: a machine whose helper came from a
1400
+ * local build (a dev checkout) keeps it, a machine that never enabled the menu
1401
+ * bar or opted out is left alone, and the ownership contest that bounds
1402
+ * multi-install churn (`mayInstallMenubarHelper`) still applies. The bundle is
1403
+ * downloaded and verified by the same path every install uses (sha256,
1404
+ * codesign, Team, designated-requirement pin, notarization), swapped
1405
+ * atomically under the install lock at the same path and identity — which is
1406
+ * what keeps the Accessibility grant — and the running helper is restarted
1407
+ * from the new binary (`restartMenubarHelperAfterSwap`; launchd's KeepAlive
1408
+ * relaunches it within seconds). `dryRun` reports what would happen. Never
1409
+ * throws.
1410
+ */
1411
+ /**
1412
+ * Pure (no I/O): whether an auto-update pass should proceed, and why not.
1413
+ * `shipped` is a bundle that ships WITH this install (never a downloaded
1414
+ * release cache) — CLI upgrades own that one. Returns null to proceed.
1415
+ */
1416
+ export function menubarUpdateSkipReason(opts) {
1417
+ if (!opts.darwin)
1418
+ return 'macOS only';
1419
+ if (opts.disabledByUser)
1420
+ return 'the menu bar is disabled (agents menubar disable)';
1421
+ if (!opts.serviceInstalled)
1422
+ return 'the menu bar is not installed on this Mac';
1423
+ if (opts.shipped)
1424
+ return 'this install ships its own helper bundle; the startup self-heal owns it';
1425
+ if (!opts.installed || opts.installed.source !== 'release') {
1426
+ return `installed helper is ${stampVersionLabel(opts.installed) ?? 'unstamped'}, not a release`;
1427
+ }
1428
+ return null;
1429
+ }
1430
+ /** Pure (no I/O): what the pass does given the installed and available versions. */
1431
+ export function menubarUpdateOutcome(installed, available) {
1432
+ return compareVersions(available, installed) > 0 ? 'updated' : 'current';
1433
+ }
1434
+ export async function updateMenubarHelperIfNewer(opts = {}) {
1435
+ const installedStamp = readInstalledMenubarStamp();
1436
+ const installed = stampVersionLabel(installedStamp);
1437
+ const skip = (detail, available = cachedMenubarVersion()) => ({ outcome: 'skipped', installed, available, detail });
1438
+ const reason = menubarUpdateSkipReason({
1439
+ darwin: onDarwin(),
1440
+ disabledByUser: menubarDisabledByUser(),
1441
+ serviceInstalled: menubarServiceInstalled(),
1442
+ shipped: Boolean(shippedAppPath()),
1443
+ installed: installedStamp,
1444
+ });
1445
+ if (reason)
1446
+ return skip(reason);
1447
+ const release = installedStamp;
1448
+ const available = await resolveMenubarVersion({ force: opts.force });
1449
+ if (menubarUpdateOutcome(release.helperVersion, available) === 'current') {
1450
+ return { outcome: 'current', installed, available, detail: `AGI Menu ${installed} is the newest published build` };
1451
+ }
1452
+ if (!mayHealMenubar(false))
1453
+ return skip(`another install owns the helper; it will update on its own cooldown`, available);
1454
+ if (opts.dryRun)
1455
+ return { outcome: 'updated', installed, available, detail: `would update AGI Menu ${installed} → ${available}` };
1456
+ try {
1457
+ const src = await downloadMenubarHelperApp(available);
1458
+ const exec = ensureMenubarAppInstalled({ forceReinstall: true, sourceAppPath: src });
1459
+ if (!exec)
1460
+ return { outcome: 'failed', installed, available, detail: 'the verified bundle could not be installed' };
1461
+ try {
1462
+ fs.writeFileSync(installedVersionMarkerPath(), JSON.stringify(stampFor(src)));
1463
+ }
1464
+ catch { /* best effort */ }
1465
+ stampMenubarHeal();
1466
+ restartMenubarHelperAfterSwap(process.getuid?.() ?? 0, liveMenubarProcesses().own);
1467
+ return { outcome: 'updated', installed, available, detail: `AGI Menu ${installed} → ${available}` };
1468
+ }
1469
+ catch (e) {
1470
+ return { outcome: 'failed', installed, available, detail: e.message };
1471
+ }
1472
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Which menu-bar helper build to install: the newest published release at or
3
+ * above this CLI's floor.
4
+ *
5
+ * `helper-versions.ts` records a FLOOR (the build this CLI was tested against)
6
+ * and promised that resolution "may pick a NEWER build; it must never pick an
7
+ * older one". Until now nothing resolved upward: every install path downloaded
8
+ * the floor, so a helper fix reached a machine only after a CLI release bumped
9
+ * the floor and the user ran `agents menubar setup`. This module is the
10
+ * missing half — discovery — and it is what makes the helper auto-update.
11
+ *
12
+ * Discovery reads the public release list of `phnx-labs/agi-cli` and keeps
13
+ * only tags shaped `menubar/v<x.y.z>` that carry the helper asset AND its
14
+ * sha256 sidecar (a half-uploaded release is not a candidate). The answer is
15
+ * cached for a day under the CLI's cache dir so the sync startup path and the
16
+ * daemon read a file, never the network; the floor stays the offline answer
17
+ * whenever the network or the cache cannot do better. The verified download
18
+ * (`downloadMenubarHelperApp`: sha256, codesign, Team, designated-requirement
19
+ * pin, notarization) is unchanged — resolution only chooses the version.
20
+ */
21
+ /** The repo whose releases carry `menubar/v*` tags (same as the download URL). */
22
+ export declare const MENUBAR_RELEASES_API = "https://api.github.com/repos/phnx-labs/agi-cli/releases?per_page=100";
23
+ /** How long a resolved answer is trusted before the release list is re-read. */
24
+ export declare const MENUBAR_RESOLVE_TTL_MS: number;
25
+ /** One release as the resolver sees it — the subset of the GitHub shape it reads. */
26
+ export interface ReleaseCandidate {
27
+ tagName: string;
28
+ assets: string[];
29
+ draft?: boolean;
30
+ prerelease?: boolean;
31
+ }
32
+ export interface MenubarResolveCache {
33
+ /** Epoch ms of the release-list read this answer came from. */
34
+ checkedAt: number;
35
+ /** The newest published helper version at that time (never below the floor then). */
36
+ version: string;
37
+ }
38
+ /**
39
+ * Pure: the newest candidate at or above `floor`, else the floor. A tag must be
40
+ * exactly `menubar/v<x.y.z>` (no pre-release suffix), not a draft or
41
+ * pre-release, and carry both the zip and its `.sha256`.
42
+ */
43
+ export declare function pickNewestMenubarVersion(candidates: ReleaseCandidate[], floor: string): string;
44
+ /** Where the resolved answer lives: beside the helper's own download cache. */
45
+ export declare function menubarResolveCachePath(): string;
46
+ export declare function readMenubarResolveCache(file: string): MenubarResolveCache | null;
47
+ /**
48
+ * The cached answer when it is usable offline: at or above the floor, of any
49
+ * age. Sync and network-free — this is what the startup self-heal and the
50
+ * status/doctor displays read.
51
+ */
52
+ export declare function cachedMenubarVersion(opts?: {
53
+ floor?: string;
54
+ cacheFile?: string;
55
+ }): string;
56
+ type FetchLike = (input: string, init?: {
57
+ headers?: Record<string, string>;
58
+ signal?: AbortSignal;
59
+ }) => Promise<{
60
+ ok: boolean;
61
+ status: number;
62
+ json(): Promise<unknown>;
63
+ }>;
64
+ /** Read the release list. Unauthenticated: release metadata is public and this runs at most once a day per machine. */
65
+ export declare function fetchMenubarReleaseCandidates(fetchImpl?: FetchLike): Promise<ReleaseCandidate[]>;
66
+ /**
67
+ * The helper version to install now: the newest published build >= floor.
68
+ *
69
+ * Reads the day-old cache first; re-reads the release list when the cache is
70
+ * missing, stale, or `force` is set; falls back to the cached answer, then the
71
+ * floor, when the network cannot answer. Never throws, never returns a version
72
+ * below the floor.
73
+ */
74
+ export declare function resolveMenubarVersion(opts?: {
75
+ floor?: string;
76
+ cacheFile?: string;
77
+ now?: number;
78
+ ttlMs?: number;
79
+ force?: boolean;
80
+ fetchImpl?: FetchLike;
81
+ }): Promise<string>;
82
+ export {};
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Which menu-bar helper build to install: the newest published release at or
3
+ * above this CLI's floor.
4
+ *
5
+ * `helper-versions.ts` records a FLOOR (the build this CLI was tested against)
6
+ * and promised that resolution "may pick a NEWER build; it must never pick an
7
+ * older one". Until now nothing resolved upward: every install path downloaded
8
+ * the floor, so a helper fix reached a machine only after a CLI release bumped
9
+ * the floor and the user ran `agents menubar setup`. This module is the
10
+ * missing half — discovery — and it is what makes the helper auto-update.
11
+ *
12
+ * Discovery reads the public release list of `phnx-labs/agi-cli` and keeps
13
+ * only tags shaped `menubar/v<x.y.z>` that carry the helper asset AND its
14
+ * sha256 sidecar (a half-uploaded release is not a candidate). The answer is
15
+ * cached for a day under the CLI's cache dir so the sync startup path and the
16
+ * daemon read a file, never the network; the floor stays the offline answer
17
+ * whenever the network or the cache cannot do better. The verified download
18
+ * (`downloadMenubarHelperApp`: sha256, codesign, Team, designated-requirement
19
+ * pin, notarization) is unchanged — resolution only chooses the version.
20
+ */
21
+ import * as fs from 'node:fs';
22
+ import * as path from 'node:path';
23
+ import { getCacheDir } from '../state.js';
24
+ import { compareVersions } from '../agent-spec/primitives.js';
25
+ import { helperFloor } from '../helper-versions.js';
26
+ import { getCliVersion } from '../version.js';
27
+ import { MENUBAR_HELPER_ASSET } from './download-menubar.js';
28
+ /** The repo whose releases carry `menubar/v*` tags (same as the download URL). */
29
+ export const MENUBAR_RELEASES_API = 'https://api.github.com/repos/phnx-labs/agi-cli/releases?per_page=100';
30
+ /** How long a resolved answer is trusted before the release list is re-read. */
31
+ export const MENUBAR_RESOLVE_TTL_MS = 24 * 60 * 60 * 1000;
32
+ const TAG = /^menubar\/v(\d+\.\d+\.\d+)$/;
33
+ /**
34
+ * Pure: the newest candidate at or above `floor`, else the floor. A tag must be
35
+ * exactly `menubar/v<x.y.z>` (no pre-release suffix), not a draft or
36
+ * pre-release, and carry both the zip and its `.sha256`.
37
+ */
38
+ export function pickNewestMenubarVersion(candidates, floor) {
39
+ let best = floor;
40
+ for (const c of candidates) {
41
+ if (c.draft || c.prerelease)
42
+ continue;
43
+ const m = TAG.exec(c.tagName);
44
+ if (!m)
45
+ continue;
46
+ if (!c.assets.includes(MENUBAR_HELPER_ASSET) || !c.assets.includes(`${MENUBAR_HELPER_ASSET}.sha256`))
47
+ continue;
48
+ if (compareVersions(m[1], best) > 0)
49
+ best = m[1];
50
+ }
51
+ return best;
52
+ }
53
+ /** Where the resolved answer lives: beside the helper's own download cache. */
54
+ export function menubarResolveCachePath() {
55
+ return path.join(getCacheDir(), 'menubar', 'latest.json');
56
+ }
57
+ export function readMenubarResolveCache(file) {
58
+ try {
59
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
60
+ if (typeof parsed.checkedAt === 'number' && typeof parsed.version === 'string' && /^\d+\.\d+\.\d+$/.test(parsed.version)) {
61
+ return { checkedAt: parsed.checkedAt, version: parsed.version };
62
+ }
63
+ }
64
+ catch { /* absent or unreadable: no cache */ }
65
+ return null;
66
+ }
67
+ function writeMenubarResolveCache(file, cache) {
68
+ try {
69
+ fs.mkdirSync(path.dirname(file), { recursive: true });
70
+ const tmp = `${file}.${process.pid}.tmp`;
71
+ fs.writeFileSync(tmp, JSON.stringify(cache));
72
+ fs.renameSync(tmp, file);
73
+ }
74
+ catch { /* a cache that cannot be written is just a cache miss next time */ }
75
+ }
76
+ /**
77
+ * The cached answer when it is usable offline: at or above the floor, of any
78
+ * age. Sync and network-free — this is what the startup self-heal and the
79
+ * status/doctor displays read.
80
+ */
81
+ export function cachedMenubarVersion(opts = {}) {
82
+ const floor = opts.floor ?? helperFloor('menubar');
83
+ const cache = readMenubarResolveCache(opts.cacheFile ?? menubarResolveCachePath());
84
+ return cache && compareVersions(cache.version, floor) >= 0 ? cache.version : floor;
85
+ }
86
+ /** Read the release list. Unauthenticated: release metadata is public and this runs at most once a day per machine. */
87
+ export async function fetchMenubarReleaseCandidates(fetchImpl = fetch) {
88
+ const res = await fetchImpl(MENUBAR_RELEASES_API, {
89
+ headers: {
90
+ Accept: 'application/vnd.github+json',
91
+ 'X-GitHub-Api-Version': '2022-11-28',
92
+ 'User-Agent': `agents-cli/${getCliVersion()}`,
93
+ },
94
+ signal: AbortSignal.timeout(5_000),
95
+ });
96
+ if (!res.ok)
97
+ throw new Error(`release list: HTTP ${res.status}`);
98
+ const body = (await res.json());
99
+ if (!Array.isArray(body))
100
+ throw new Error('release list: not an array');
101
+ return body.map((r) => ({
102
+ tagName: r.tag_name ?? '',
103
+ draft: r.draft,
104
+ prerelease: r.prerelease,
105
+ assets: (r.assets ?? []).map((a) => a.name ?? ''),
106
+ }));
107
+ }
108
+ /**
109
+ * The helper version to install now: the newest published build >= floor.
110
+ *
111
+ * Reads the day-old cache first; re-reads the release list when the cache is
112
+ * missing, stale, or `force` is set; falls back to the cached answer, then the
113
+ * floor, when the network cannot answer. Never throws, never returns a version
114
+ * below the floor.
115
+ */
116
+ export async function resolveMenubarVersion(opts = {}) {
117
+ const floor = opts.floor ?? helperFloor('menubar');
118
+ const file = opts.cacheFile ?? menubarResolveCachePath();
119
+ const now = opts.now ?? Date.now();
120
+ const ttl = opts.ttlMs ?? MENUBAR_RESOLVE_TTL_MS;
121
+ const cached = readMenubarResolveCache(file);
122
+ if (cached && !opts.force && now - cached.checkedAt < ttl && compareVersions(cached.version, floor) >= 0) {
123
+ return cached.version;
124
+ }
125
+ try {
126
+ const version = pickNewestMenubarVersion(await fetchMenubarReleaseCandidates(opts.fetchImpl), floor);
127
+ writeMenubarResolveCache(file, { checkedAt: now, version });
128
+ return version;
129
+ }
130
+ catch {
131
+ return cached && compareVersions(cached.version, floor) >= 0 ? cached.version : floor;
132
+ }
133
+ }
@@ -11,7 +11,7 @@ import * as crypto from 'node:crypto';
11
11
  import * as yaml from 'yaml';
12
12
  import { ALL_AGENT_IDS } from './agents.js';
13
13
  import { getUserAgentsDir } from './state.js';
14
- import { deleteKeychainTokenSync, getKeychainTokenSync, hasKeychainTokenSync, isSecretsClientError, profileKeychainItem } from './secrets-client.js';
14
+ import { deleteKeychainTokenSync, getKeychainTokenSync, hasKeychainTokenSync, isSecretsClientError, isSecretsTransportError, profileKeychainItem, } from './secrets-client.js';
15
15
  import { getPreset } from './profiles-presets.js';
16
16
  import { MODEL_TIERS, isTierToken } from './model-tiers.js';
17
17
  import { addAccount, findAccount, resolveCredentialAccount } from './account-registry.js';
@@ -250,7 +250,18 @@ export function profileAuthLabel(profile) {
250
250
  return `${provider} ${maskToken(token)}`;
251
251
  }
252
252
  if (profile.auth) {
253
- return `${provider} ${hasKeychainTokenSync(profile.auth.keychainItem) ? 'stored' : 'missing'}`;
253
+ // A status row, like the provider-account rows: a wedged or missing
254
+ // standalone degrades this one label instead of aborting the whole render.
255
+ let stored;
256
+ try {
257
+ stored = hasKeychainTokenSync(profile.auth.keychainItem);
258
+ }
259
+ catch (err) {
260
+ if (isSecretsTransportError(err))
261
+ return `${provider} unavailable`;
262
+ throw err;
263
+ }
264
+ return `${provider} ${stored ? 'stored' : 'missing'}`;
254
265
  }
255
266
  return provider;
256
267
  }
@@ -5,6 +5,24 @@ import type { SecretsBundle, SecretsBackend, ResolveBundleOptions, WriteBundleOp
5
5
  * independent client legitimately re-declares rather than imports.
6
6
  */
7
7
  export declare const PROTOCOL_VERSION = 1;
8
+ /**
9
+ * The synchronous path serves the surfaces that resolve secrets before they can
10
+ * continue — `agents view`, the account-catalog rows, and the account listing and
11
+ * setup-token read on the `agents run` launch path. Those must never hang for the
12
+ * standalone's own 60s deadline (the exact hang PHNX-3989 hit when the child ran
13
+ * under Bun), so the sync serve carries a hard bound below it instead of the
14
+ * async path's 65s. The bound covers a COLD PROCESS, not just the operation: every
15
+ * request spawns `secrets __serve` afresh, so it pays a Node boot plus the
16
+ * standalone's module load before the tens-of-milliseconds op runs: ~0.3s on a
17
+ * quiet box, 0.4–2.6s and occasionally more on a desktop at load average ~100
18
+ * (measured 2026-09-12), where the previous 3s bound turned load into a failed
19
+ * launch (`secrets request failed: spawnSync sh ETIMEDOUT` from `agents run`).
20
+ * 30s absorbs that boot under load and still fails a broken or wedged standalone
21
+ * well before its 60s self-deadline. It is NOT a fallback to the embedded engine
22
+ * (DIST-1); the standalone stays the only implementation. Exported so the
23
+ * real-standalone test can assert a round-trip beats the bound.
24
+ */
25
+ export declare const SYNC_SERVE_TIMEOUT_MS = 30000;
8
26
  export interface SecretsContext {
9
27
  /** Bundle allowlist; absent ⇒ full trust (the local agents client today). */
10
28
  allowedBundles?: string[];
@@ -111,6 +129,8 @@ export declare function secretsRequest<T = unknown>(op: string, args?: unknown[]
111
129
  export declare function secretsRequestSync<T = unknown>(op: string, args?: unknown[], context?: SecretsContext): T;
112
130
  /** Test hook: forget the cached binary + handshake so a new env is re-resolved. */
113
131
  export declare function _resetSecretsClientForTest(): void;
132
+ /** Shorten the sync bound for a test that plants a hanging standalone; reset restores it. */
133
+ export declare function _setSyncServeTimeoutForTest(ms: number): void;
114
134
  export declare function readAndResolveBundleEnv(name: string, opts?: ResolveBundleOptions, context?: SecretsContext): Promise<{
115
135
  bundle: SecretsBundle;
116
136
  env: Record<string, string>;
@@ -25,9 +25,10 @@
25
25
  * is load-bearing: the standalone wraps fd 3 in a `net.Socket`, and a Socket
26
26
  * over a NAMED FIFO reads the request but never fires EOF on macOS, so the
27
27
  * older FIFO wiring hung the read loop for the full timeout. Bounded to
28
- * `SYNC_SERVE_TIMEOUT_MS` so a broken standalone fails fast, never for the
29
- * server's 60s deadline. POSIX only; Windows fails loud pointing at the
30
- * async path.
28
+ * `SYNC_SERVE_TIMEOUT_MS` (30s — sized for a cold Node boot of the
29
+ * standalone on a loaded box, see the constant) so a broken standalone
30
+ * fails before the server's 60s deadline. POSIX only; Windows fails loud
31
+ * pointing at the async path.
31
32
  *
32
33
  * State root (MIG-1): the standalone selects its state root from `SECRETS_HOME`.
33
34
  * agents-cli points it at the user agents dir (`~/.agents`) by default so the
@@ -59,20 +60,25 @@ const MAX_PROTOCOL_BYTES = 8 * 1024 * 1024;
59
60
  /** Just over the server's own 60s deadline, so the server times out first. */
60
61
  const SERVE_TIMEOUT_MS = 65_000;
61
62
  /**
62
- * The synchronous path only serves read-only STATUS surfaces — `agents view`,
63
- * the account-catalog rows, and run-config / account-rotation resolution on the
64
- * `agents run` hot path. Those must never hang the whole render or launch on a
65
- * missing or unreachable standalone, so the sync serve carries a short, hard
66
- * bound instead of the async path's 65s: a broken `secrets` fails loud in a few
67
- * seconds and the caller renders the rest of its output (or launches on the
68
- * native login) with one clear line, rather than sitting for the standalone's
69
- * own 60s deadline (the exact 60s hang PHNX-3989 hit when the child ran under
70
- * Bun). A real sync op (a handshake, a bundle list, one item read) completes in
71
- * tens of milliseconds, so this is ~100x headroom. It is NOT a fallback to the
72
- * embedded engine (DIST-1); the standalone stays the only implementation, it
73
- * just fails fast.
63
+ * The synchronous path serves the surfaces that resolve secrets before they can
64
+ * continue — `agents view`, the account-catalog rows, and the account listing and
65
+ * setup-token read on the `agents run` launch path. Those must never hang for the
66
+ * standalone's own 60s deadline (the exact hang PHNX-3989 hit when the child ran
67
+ * under Bun), so the sync serve carries a hard bound below it instead of the
68
+ * async path's 65s. The bound covers a COLD PROCESS, not just the operation: every
69
+ * request spawns `secrets __serve` afresh, so it pays a Node boot plus the
70
+ * standalone's module load before the tens-of-milliseconds op runs: ~0.3s on a
71
+ * quiet box, 0.4–2.6s and occasionally more on a desktop at load average ~100
72
+ * (measured 2026-09-12), where the previous 3s bound turned load into a failed
73
+ * launch (`secrets request failed: spawnSync sh ETIMEDOUT` from `agents run`).
74
+ * 30s absorbs that boot under load and still fails a broken or wedged standalone
75
+ * well before its 60s self-deadline. It is NOT a fallback to the embedded engine
76
+ * (DIST-1); the standalone stays the only implementation. Exported so the
77
+ * real-standalone test can assert a round-trip beats the bound.
74
78
  */
75
- const SYNC_SERVE_TIMEOUT_MS = 3_000;
79
+ export const SYNC_SERVE_TIMEOUT_MS = 30_000;
80
+ /** Test seam: a hang test plants a never-answering standalone and must not wait 30s. */
81
+ let syncServeTimeoutMs = SYNC_SERVE_TIMEOUT_MS;
76
82
  /** Serialize `Map`s the way the server's `decodeWire` expects to receive them. */
77
83
  export function encodeWire(value) {
78
84
  if (value instanceof Map) {
@@ -413,13 +419,25 @@ function serveOnceSync(op, args, context) {
413
419
  input: request,
414
420
  stdio: ['pipe', 'pipe', 'inherit'],
415
421
  env: buildServeEnv(),
416
- timeout: SYNC_SERVE_TIMEOUT_MS,
422
+ timeout: syncServeTimeoutMs,
417
423
  maxBuffer: MAX_PROTOCOL_BYTES + 4096,
418
424
  });
419
425
  if (result.error) {
420
426
  const err = result.error;
421
- const code = err.code === 'ETIMEDOUT' ? 'TIMEOUT' : 'SPAWN_FAILED';
422
- throw new SecretsClientError(code, `secrets request failed: ${err.message}`);
427
+ if (err.code === 'ETIMEDOUT') {
428
+ throw new SecretsClientError('TIMEOUT', `the standalone \`secrets\` CLI did not answer within ${Math.round(syncServeTimeoutMs / 1000)}s ` +
429
+ `(${[command, ...prefix, '__serve'].join(' ')}). The machine may be too loaded to boot it in time, or the install is broken: ` +
430
+ 'check with `secrets --version`.');
431
+ }
432
+ // A standalone that exits before draining fd 3 leaves `spawnSync`'s stdin
433
+ // write with EPIPE, yet its exit status and everything it wrote to fd 4 are
434
+ // still returned. Like the async path (`input.on('error', () => {})`), the
435
+ // outcome is whatever reached fd 4 — an empty or non-JSON answer is the
436
+ // diagnostic, not the errno. Seen on the GitHub-hosted runner, where a
437
+ // planted `exit 0` standalone exits faster than the request is written.
438
+ if (err.code !== 'EPIPE') {
439
+ throw new SecretsClientError('SPAWN_FAILED', `secrets request failed: ${err.message}`);
440
+ }
423
441
  }
424
442
  const raw = result.stdout ?? Buffer.alloc(0);
425
443
  if (raw.length > MAX_PROTOCOL_BYTES) {
@@ -483,6 +501,11 @@ export function _resetSecretsClientForTest() {
483
501
  handshakeReady = false;
484
502
  handshakePromise = null;
485
503
  requestCounter = 0;
504
+ syncServeTimeoutMs = SYNC_SERVE_TIMEOUT_MS;
505
+ }
506
+ /** Shorten the sync bound for a test that plants a hanging standalone; reset restores it. */
507
+ export function _setSyncServeTimeoutForTest(ms) {
508
+ syncServeTimeoutMs = ms;
486
509
  }
487
510
  // --- typed wrappers -------------------------------------------------------
488
511
  //
@@ -0,0 +1,2 @@
1
+ import type { HealCheck } from '../types.js';
2
+ export declare const menubarHelperCheck: HealCheck;
@@ -0,0 +1,21 @@
1
+ // menubar-helper check — keeps the installed AGI Menu on the newest published
2
+ // build. Discovery + trigger for the helper's auto-update: the daemon runs this
3
+ // on the periodic cadence; the same call runs right after `agents upgrade`.
4
+ // Detect-only under dryRun (what `agents doctor` shows); the repair is the
5
+ // verified download + atomic swap + restart in `updateMenubarHelperIfNewer`.
6
+ import { resultOf } from '../types.js';
7
+ export const menubarHelperCheck = {
8
+ id: 'menubar-helper',
9
+ title: 'AGI Menu helper is the newest published build',
10
+ platforms: ['darwin'],
11
+ cadence: 'periodic',
12
+ async run(ctx) {
13
+ const { updateMenubarHelperIfNewer } = await import('../../menubar/install-menubar.js');
14
+ const r = await updateMenubarHelperIfNewer({ dryRun: ctx.dryRun });
15
+ switch (r.outcome) {
16
+ case 'updated': return resultOf([r.detail], []);
17
+ case 'failed': return resultOf([], [`AGI Menu ${r.installed ?? '?'} → ${r.available}: ${r.detail}`]);
18
+ default: return resultOf([], []);
19
+ }
20
+ },
21
+ };
@@ -11,6 +11,7 @@ import { shimsCheck } from './checks/shims.js';
11
11
  import { shadowingCheck } from './checks/shadowing.js';
12
12
  import { pathCheck } from './checks/path.js';
13
13
  import { installStagingCheck } from './checks/install-staging.js';
14
+ import { menubarHelperCheck } from './checks/menubar-helper.js';
14
15
  // Order matters: cheap structural fixes (shims, shadow adoption, PATH, generated
15
16
  // hook wrappers) before the heavier resource reconciliation, so a freshly-
16
17
  // repaired shim is in place first.
@@ -24,6 +25,8 @@ export const HEAL_CHECKS = [
24
25
  hookManifestCheck,
25
26
  resourcesCheck,
26
27
  installStagingCheck,
28
+ // Last and network-touching: the menu-bar helper's auto-update (macOS only).
29
+ menubarHelperCheck,
27
30
  ];
28
31
  /** Run the selected checks, isolating per-check failures. */
29
32
  export async function runSelfHeal(opts = {}) {