@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.
@@ -88,6 +88,9 @@ export function parseConfigKey(key) {
88
88
  if (raw === 'browser.viewer') {
89
89
  return { scope: 'browser', property: 'viewer' };
90
90
  }
91
+ if (raw === 'browser.device') {
92
+ return { scope: 'browser', property: 'device' };
93
+ }
91
94
  if (raw === 'project.root') {
92
95
  return { scope: 'project', property: 'root' };
93
96
  }
@@ -110,7 +113,7 @@ export function parseConfigKey(key) {
110
113
  throw new Error(`Invalid auto config key '${key}'. Use auto.pool.`);
111
114
  }
112
115
  if (raw.startsWith('browser.')) {
113
- throw new Error(`Invalid browser config key '${key}'. Use browser.profile or browser.viewer.`);
116
+ throw new Error(`Invalid browser config key '${key}'. Use browser.profile, browser.viewer, or browser.device.`);
114
117
  }
115
118
  if (raw.startsWith('project.')) {
116
119
  throw new Error(`Invalid project config key '${key}'. Use project.root.`);
@@ -149,7 +152,7 @@ export function listKnownConfigKeys() {
149
152
  for (const tier of MODEL_TIERS) {
150
153
  keys.push(`run.<agent@version>.tier.${tier}`);
151
154
  }
152
- keys.push('interactive.host', 'auto.pool', 'browser.profile', 'browser.viewer', 'project.root');
155
+ keys.push('interactive.host', 'auto.pool', 'browser.profile', 'browser.viewer', 'browser.device', 'project.root');
153
156
  for (const prop of DEVICE_CONFIG_PROPERTIES) {
154
157
  keys.push(`devices.<name>.${prop}`);
155
158
  }
@@ -202,6 +205,10 @@ export function configKeyStorageHint(parsed) {
202
205
  case 'auto':
203
206
  return 'config.autoPool';
204
207
  case 'browser': {
208
+ if (parsed.property === 'device') {
209
+ // User scope: one value in the central agents.yaml that syncs fleet-wide.
210
+ return 'config.defaultBrowserDevice (central agents.yaml; syncs fleet-wide)';
211
+ }
205
212
  const yamlKey = parsed.property === 'viewer' ? 'browserViewer' : 'defaultBrowserProfile';
206
213
  return parsed.device
207
214
  ? `devices/${parsed.device}/agents.yaml config.${yamlKey}`
@@ -94,6 +94,29 @@ export const CONFIG_KEYS = [
94
94
  type: 'string',
95
95
  description: 'Browser profile `agents browser start` resolves to without --profile (set via `agents browser use`).',
96
96
  },
97
+ {
98
+ name: 'browser.device',
99
+ yamlKey: 'defaultBrowserDevice',
100
+ // user scope, so a SINGLE value in the central agents.yaml syncs to every box:
101
+ // the fleet's browser hub. A worker with this set forwards its browser drives
102
+ // to the hub (as if `--device <hub>` was passed) with no per-command flag, so
103
+ // every agent shares the hub's one logged-in browser. The hub names itself, so
104
+ // there it resolves to a self-host and runs locally — which is why one synced
105
+ // value is safe. Unset = drive this box's own browser (today's behavior).
106
+ scope: 'user',
107
+ type: 'string',
108
+ description: 'Fleet browser hub: the device whose browser `agents browser` drive verbs target by default, with no --device. ' +
109
+ 'The hub itself runs locally; every other box forwards to it. Unset = each box drives its own browser.',
110
+ validate: (v) => {
111
+ try {
112
+ assertValidDeviceName(v);
113
+ return null;
114
+ }
115
+ catch (err) {
116
+ return err?.message ?? String(err);
117
+ }
118
+ },
119
+ },
97
120
  {
98
121
  name: 'agents.max-concurrent',
99
122
  yamlKey: 'maxAgents',
@@ -13,7 +13,7 @@ export declare const FACTORY_PROJECTS: readonly [{
13
13
  readonly repo: "phnx-labs/rush-cli";
14
14
  }, {
15
15
  readonly name: "Agents CLI";
16
- readonly repo: "phnx-labs/agents-cli";
16
+ readonly repo: "phnx-labs/agi-cli";
17
17
  }, {
18
18
  readonly name: "Linear CLI";
19
19
  readonly repo: "phnx-labs/linear-cli";
@@ -25,7 +25,7 @@ export const FACTORY_PROJECTS = [
25
25
  { name: 'Prix', repo: 'phnx-labs/prix' },
26
26
  { name: 'Rush App', repo: 'phnx-labs/rush' },
27
27
  { name: 'Rush CLI', repo: 'phnx-labs/rush-cli' },
28
- { name: 'Agents CLI', repo: 'phnx-labs/agents-cli' },
28
+ { name: 'Agents CLI', repo: 'phnx-labs/agi-cli' },
29
29
  { name: 'Linear CLI', repo: 'phnx-labs/linear-cli' },
30
30
  ];
31
31
  const defaults = () => ({
@@ -16,8 +16,18 @@
16
16
  * lands for both helpers at once.
17
17
  */
18
18
  import { type HelperName } from './helper-versions.js';
19
- /** GitHub repo whose `v<version>` releases carry the helper assets. */
20
- export declare const HELPER_RELEASE_REPO = "phnx-labs/agents-cli";
19
+ /**
20
+ * GitHub repo whose `<helper>/v<version>` releases carry the helper assets.
21
+ *
22
+ * This is the SLUG, which is not the npm package name: the package is still
23
+ * `@phnx-labs/agents-cli`, but the repository was renamed to `agi-cli`. The old
24
+ * slug kept working only because GitHub redirects a renamed repo, which is a
25
+ * poor thing to hang signed-binary delivery on — a redirect is one re-created
26
+ * repo away from resolving somewhere else. What actually protects the download
27
+ * is the sha256 + codesign + designated-requirement + Team ID verification
28
+ * below; this just stops relying on the redirect.
29
+ */
30
+ export declare const HELPER_RELEASE_REPO = "phnx-labs/agi-cli";
21
31
  /** Apple Developer ID Team every helper must be signed by ("Developer ID
22
32
  * Application: Muqit Nawaz"). Defense in depth on top of `spctl` notarization. */
23
33
  export declare const EXPECTED_TEAM_ID = "2HTP252L87";
@@ -23,8 +23,18 @@ import { pipeline } from 'node:stream/promises';
23
23
  import { getCacheDir } from './state.js';
24
24
  import { parseSha256Asset, sha256File } from './sha256-asset.js';
25
25
  import { helperTag } from './helper-versions.js';
26
- /** GitHub repo whose `v<version>` releases carry the helper assets. */
27
- export const HELPER_RELEASE_REPO = 'phnx-labs/agents-cli';
26
+ /**
27
+ * GitHub repo whose `<helper>/v<version>` releases carry the helper assets.
28
+ *
29
+ * This is the SLUG, which is not the npm package name: the package is still
30
+ * `@phnx-labs/agents-cli`, but the repository was renamed to `agi-cli`. The old
31
+ * slug kept working only because GitHub redirects a renamed repo, which is a
32
+ * poor thing to hang signed-binary delivery on — a redirect is one re-created
33
+ * repo away from resolving somewhere else. What actually protects the download
34
+ * is the sha256 + codesign + designated-requirement + Team ID verification
35
+ * below; this just stops relying on the redirect.
36
+ */
37
+ export const HELPER_RELEASE_REPO = 'phnx-labs/agi-cli';
28
38
  /** Apple Developer ID Team every helper must be signed by ("Developer ID
29
39
  * Application: Muqit Nawaz"). Defense in depth on top of `spctl` notarization. */
30
40
  export const EXPECTED_TEAM_ID = '2HTP252L87';
@@ -298,7 +298,7 @@ function foldUserHooksYamlIntoAgentsYaml() {
298
298
  meta.hooks = merged;
299
299
  const header = `# agents-cli metadata
300
300
  # Auto-generated - do not edit manually
301
- # https://github.com/phnx-labs/agents-cli
301
+ # https://github.com/phnx-labs/agi-cli
302
302
 
303
303
  `;
304
304
  try {
@@ -414,7 +414,7 @@ function foldBrowserProfilesIntoAgentsYaml() {
414
414
  meta.browser = merged;
415
415
  const header = `# agents-cli metadata
416
416
  # Auto-generated - do not edit manually
417
- # https://github.com/phnx-labs/agents-cli
417
+ # https://github.com/phnx-labs/agi-cli
418
418
 
419
419
  `;
420
420
  try {
@@ -1641,7 +1641,7 @@ function migrateVersionResourcesToPatterns() {
1641
1641
  }
1642
1642
  }
1643
1643
  if (changed) {
1644
- const META_HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agents-cli\n# yaml-language-server: $schema=https://raw.githubusercontent.com/phnx-labs/agents-cli/main/cli/schema/agents-yaml.schema.json\n\n';
1644
+ const META_HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agi-cli\n# yaml-language-server: $schema=https://raw.githubusercontent.com/phnx-labs/agi-cli/main/cli/schema/agents-yaml.schema.json\n\n';
1645
1645
  fs.writeFileSync(metaFile, META_HEADER + yaml.stringify(meta), 'utf-8');
1646
1646
  console.error('Migrated agents.yaml versions: entries to pattern format');
1647
1647
  }
@@ -1668,7 +1668,7 @@ function migrateSplitDeviceLocalMeta() {
1668
1668
  const agents = meta.agents;
1669
1669
  const versions = meta.versions;
1670
1670
  const hasLocal = (!!agents && Object.keys(agents).length > 0) || (!!versions && Object.keys(versions).length > 0);
1671
- const HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agents-cli\n\n';
1671
+ const HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agi-cli\n\n';
1672
1672
  // Only rewrite central when it actually carries machine-local fields — a
1673
1673
  // machine whose agents.yaml is already portable-only is left untouched.
1674
1674
  if (hasLocal) {
@@ -35,6 +35,26 @@ export declare const MENUBAR_HELPER_EXECUTABLE_NAME = "AGI Menu";
35
35
  * hermetic test fork's own teardown boots out the operator's live helper.
36
36
  */
37
37
  export declare function serviceLabel(): string;
38
+ /**
39
+ * What the installed helper IS — deliberately not the CLI's version.
40
+ *
41
+ * `source` matters because the two install paths have different notions of
42
+ * "changed": a release bundle is identified by its helper version, while a local
43
+ * dev build has none (menubar/scripts/build.sh hardcodes CFBundleShortVersionString),
44
+ * so it is identified by the source path + mtime it was copied from.
45
+ */
46
+ /** Version label for a bundle that has none of its own (a local dev build). */
47
+ export declare const LOCAL_BUILD_LABEL = "local";
48
+ export type MenubarStamp = {
49
+ source: 'release';
50
+ helperVersion: string;
51
+ } | {
52
+ source: 'local';
53
+ sourceStamp: string;
54
+ } | {
55
+ source: 'legacy';
56
+ raw: string;
57
+ };
38
58
  /**
39
59
  * Absolute path to the installed menu-bar helper executable if it exists on
40
60
  * disk, else null. The desktop notifier (notify-desktop.ts) routes daemon
@@ -136,16 +156,50 @@ export declare function enableMenubarService(opts?: {
136
156
  clearOptOut?: boolean;
137
157
  }): Promise<boolean>;
138
158
  /**
139
- * Pure staleness decision (no I/O) so the truth table is unit-testable. The
140
- * installed service is stale when the helper binary is gone, or when it was
141
- * installed by a different CLI version than the one now running — a version
142
- * change is the signal that the plist's baked interpreter/entry/bundle paths
143
- * and the helper binary itself may have drifted. A null installedVersion
144
- * (pre-stamp install) counts as stale so old installs get re-stamped once.
159
+ * Render a stamp as a comparable/displayable version string.
160
+ *
161
+ * A local build has no version of its own, so it reports `local`; the ownership
162
+ * contest treats that as "not comparable" and falls through to its owner arm,
163
+ * which is the correct outcome — a dev build must never win a version contest
164
+ * against a release.
165
+ */
166
+ export declare function stampVersionLabel(stamp: MenubarStamp | null): string | null;
167
+ /**
168
+ * Identify the bundle about to be installed, for the stamp.
169
+ *
170
+ * A bundle is a RELEASE iff it sits under the helper's own download cache —
171
+ * asked of `menubarHelperCacheDir`, not pattern-matched out of the path. A regex
172
+ * for `/v<x.y.z>/` gets this wrong in both directions: a checkout living under
173
+ * any directory that happens to contain a version-shaped segment reads as a
174
+ * release, and a release cache laid out differently reads as local. Either
175
+ * misclassification flips `source` between invocations, and a kind change is
176
+ * unconditionally stale — which is a reinstall loop.
177
+ */
178
+ export declare function stampFor(resolvedSourceAppPath: string): MenubarStamp;
179
+ /**
180
+ * The helper version a path denotes, iff it is inside that version's cache dir.
181
+ * Returns null for anything else — including a version-shaped path that is not
182
+ * actually the cache.
183
+ */
184
+ export declare function releaseVersionOfCachedBundle(appPath: string, cacheDirFor?: (v: string) => string): string | null;
185
+ /**
186
+ * Pure staleness decision (no I/O) so the truth table is unit-testable.
187
+ *
188
+ * This compares the HELPER axis, not the CLI's. It used to compare the installed
189
+ * stamp against `getCliVersion()`, which was wrong in both directions once the
190
+ * helpers gained their own version line: every CLI release made an unchanged
191
+ * helper look stale and reinstalled it (the #2109 restart storm), while a
192
+ * genuinely newer helper at the same CLI version never looked stale at all.
193
+ *
194
+ * Stale when: the executable is gone; nothing is stamped; the stamp predates the
195
+ * JSON format (`legacy` — re-stamped once); the install KIND changed
196
+ * (local <-> release), since a dev build and a release bundle are not
197
+ * interchangeable; a release install whose available helper version is newer;
198
+ * or a local install whose source path or mtime moved.
145
199
  */
146
200
  export declare function isMenubarStale(opts: {
147
- installedVersion: string | null;
148
- currentVersion: string;
201
+ installed: MenubarStamp | null;
202
+ available: MenubarStamp;
149
203
  execExists: boolean;
150
204
  }): boolean;
151
205
  /**
@@ -354,8 +408,12 @@ export interface MenubarStatus {
354
408
  platform: string;
355
409
  source: string | null;
356
410
  installedApp: string | null;
411
+ /** The installed HELPER's version — not the CLI's. `local` for a dev build. */
357
412
  installedVersion: string | null;
413
+ /** The helper version this install would put on disk right now. */
358
414
  currentVersion: string;
415
+ /** The CLI's own version, reported separately so the two are never conflated. */
416
+ cliVersion: string;
359
417
  stale: boolean;
360
418
  serviceInstalled: boolean;
361
419
  running: boolean;
@@ -370,8 +428,13 @@ export declare function getMenubarStatus(): MenubarStatus;
370
428
  export interface MenubarDoctorReport {
371
429
  platform: string;
372
430
  installPath: string | null;
431
+ /** The installed HELPER's version — not the CLI's. `local` for a dev build. */
373
432
  installedVersion: string | null;
433
+ /** The helper version this install would put on disk right now. */
374
434
  currentVersion: string;
435
+ /** The CLI's own version, reported separately so the two are never conflated. */
436
+ cliVersion: string;
437
+ /** installed helper vs available helper. Never compares the CLI's version. */
375
438
  versionMatches: boolean;
376
439
  /** `unknown` on non-darwin or when nothing is installed to inspect. */
377
440
  signingIdentity: 'developer-id' | 'ad-hoc' | 'unknown';
@@ -25,7 +25,7 @@ import { getCliVersion, resolveAgentsBin, resolveInstalledLayout } from '../vers
25
25
  import { copyAppBundle, withInstallLock } from '../app-bundle-install.js';
26
26
  import { compareVersions } from '../agent-spec/primitives.js';
27
27
  import { namespacedServiceLabel, serviceManifestHomeEnv, serviceManagerRegistrationAllowed } from '../service-manifest.js';
28
- import { downloadMenubarHelperApp } from './download-menubar.js';
28
+ import { downloadMenubarHelperApp, menubarHelperCacheDir } from './download-menubar.js';
29
29
  import { helperFloor } from '../helper-versions.js';
30
30
  const APP_BUNDLE_NAME = 'MenubarHelper.app';
31
31
  const INSTALL_DIR_NAME = 'agents-cli';
@@ -89,13 +89,42 @@ function installedAppPath() {
89
89
  function installedVersionMarkerPath() {
90
90
  return path.join(installDir(), '.menubar-version');
91
91
  }
92
- function readInstalledMenubarVersion() {
92
+ /**
93
+ * What the installed helper IS — deliberately not the CLI's version.
94
+ *
95
+ * `source` matters because the two install paths have different notions of
96
+ * "changed": a release bundle is identified by its helper version, while a local
97
+ * dev build has none (menubar/scripts/build.sh hardcodes CFBundleShortVersionString),
98
+ * so it is identified by the source path + mtime it was copied from.
99
+ */
100
+ /** Version label for a bundle that has none of its own (a local dev build). */
101
+ export const LOCAL_BUILD_LABEL = 'local';
102
+ /**
103
+ * Read the stamp, tolerating the pre-JSON format.
104
+ *
105
+ * Older installs wrote a bare version string — and wrote the CLI's version into
106
+ * it, which is the bug this replaces. Such a stamp cannot be compared on the
107
+ * helper axis at all, so it reports `legacy` and is treated as stale exactly
108
+ * once, which re-stamps it in the new format. That mirrors the existing
109
+ * null-is-stale rule and is why the migration cannot loop.
110
+ */
111
+ function readInstalledMenubarStamp() {
112
+ let raw;
93
113
  try {
94
- return fs.readFileSync(installedVersionMarkerPath(), 'utf-8').trim() || null;
114
+ raw = fs.readFileSync(installedVersionMarkerPath(), 'utf-8').trim();
95
115
  }
96
116
  catch {
97
117
  return null;
98
118
  }
119
+ if (!raw)
120
+ return null;
121
+ try {
122
+ const parsed = JSON.parse(raw);
123
+ if (parsed && (parsed.source === 'release' || parsed.source === 'local'))
124
+ return parsed;
125
+ }
126
+ catch { /* fall through to legacy */ }
127
+ return { source: 'legacy', raw };
99
128
  }
100
129
  /** Executable inside the installed bundle. */
101
130
  function installedExecutablePath() {
@@ -425,7 +454,17 @@ export function restartMenubarHelperAfterSwap(uid, ownProcesses, exec = execFile
425
454
  function startMenubarServiceFromSource(opts = {}) {
426
455
  if (!onDarwin())
427
456
  return false;
428
- const exec = ensureMenubarAppInstalled({ forceReinstall: true, sourceAppPath: opts.sourceAppPath });
457
+ // Resolve the source HERE, once, and hand the SAME value to the installer and
458
+ // the stamp. Passing `opts.sourceAppPath` to both let them disagree: the
459
+ // installer falls back to `sourceAppPath()` internally, so on the self-heal
460
+ // path (which passes nothing) it would install a LOCAL build while the stamp
461
+ // recorded a release version. The next invocation then computed `local`, saw a
462
+ // kind mismatch, and reinstalled — every time, forever. That is the #2109
463
+ // storm this whole change exists to stop, so the two must read one variable.
464
+ const src = opts.sourceAppPath ?? sourceAppPath();
465
+ if (!src)
466
+ return false;
467
+ const exec = ensureMenubarAppInstalled({ forceReinstall: true, sourceAppPath: src });
429
468
  if (!exec)
430
469
  return false;
431
470
  // Never bootstrap a helper macOS will reject at launch: an invalid signature
@@ -441,7 +480,7 @@ function startMenubarServiceFromSource(opts = {}) {
441
480
  }
442
481
  if (opts.clearOptOut)
443
482
  clearMenubarOptOut();
444
- installAndStartService(exec);
483
+ installAndStartService(exec, stampFor(src));
445
484
  return true;
446
485
  }
447
486
  /**
@@ -481,33 +520,121 @@ function clearMenubarOptOut() {
481
520
  * particular is what the upgrade self-heal reads to decide staleness, and a
482
521
  * path that skipped it would make every later `agents` invocation reinstall.
483
522
  */
484
- function installAndStartService(exec) {
523
+ function installAndStartService(exec, stamp) {
485
524
  const plist = servicePlistPath();
486
525
  fs.mkdirSync(path.dirname(plist), { recursive: true });
487
526
  fs.writeFileSync(plist, generateServicePlist(exec));
488
527
  restartMenubarLaunchAgent(process.getuid?.() ?? 0, plist);
489
528
  try {
490
- fs.writeFileSync(installedVersionMarkerPath(), getCliVersion());
529
+ fs.writeFileSync(installedVersionMarkerPath(), JSON.stringify(stamp));
530
+ }
531
+ catch { /* best effort */ }
532
+ }
533
+ /**
534
+ * Render a stamp as a comparable/displayable version string.
535
+ *
536
+ * A local build has no version of its own, so it reports `local`; the ownership
537
+ * contest treats that as "not comparable" and falls through to its owner arm,
538
+ * which is the correct outcome — a dev build must never win a version contest
539
+ * against a release.
540
+ */
541
+ export function stampVersionLabel(stamp) {
542
+ if (!stamp)
543
+ return null;
544
+ if (stamp.source === 'release')
545
+ return stamp.helperVersion;
546
+ if (stamp.source === 'legacy')
547
+ return null;
548
+ return LOCAL_BUILD_LABEL;
549
+ }
550
+ /** What this install would put on disk right now, as a stamp. */
551
+ function availableStamp() {
552
+ const src = sourceAppPath();
553
+ // No local bundle means the release path: what would be installed is the
554
+ // helper version the floor names.
555
+ return src ? stampFor(src) : { source: 'release', helperVersion: helperFloor('menubar') };
556
+ }
557
+ /** The helper version this install would put on disk right now, for display. */
558
+ function availableHelperLabel() {
559
+ return stampVersionLabel(availableStamp()) ?? LOCAL_BUILD_LABEL;
560
+ }
561
+ /**
562
+ * Identify the bundle about to be installed, for the stamp.
563
+ *
564
+ * A bundle is a RELEASE iff it sits under the helper's own download cache —
565
+ * asked of `menubarHelperCacheDir`, not pattern-matched out of the path. A regex
566
+ * for `/v<x.y.z>/` gets this wrong in both directions: a checkout living under
567
+ * any directory that happens to contain a version-shaped segment reads as a
568
+ * release, and a release cache laid out differently reads as local. Either
569
+ * misclassification flips `source` between invocations, and a kind change is
570
+ * unconditionally stale — which is a reinstall loop.
571
+ */
572
+ export function stampFor(resolvedSourceAppPath) {
573
+ const version = releaseVersionOfCachedBundle(resolvedSourceAppPath);
574
+ if (version)
575
+ return { source: 'release', helperVersion: version };
576
+ let mtime = 0;
577
+ try {
578
+ mtime = fs.statSync(resolvedSourceAppPath).mtimeMs;
491
579
  }
492
580
  catch { /* best effort */ }
581
+ return { source: 'local', sourceStamp: `${resolvedSourceAppPath}@${mtime}` };
582
+ }
583
+ /**
584
+ * The helper version a path denotes, iff it is inside that version's cache dir.
585
+ * Returns null for anything else — including a version-shaped path that is not
586
+ * actually the cache.
587
+ */
588
+ export function releaseVersionOfCachedBundle(appPath, cacheDirFor = menubarHelperCacheDir) {
589
+ // Try EVERY version-shaped segment, not just the leftmost. A cached bundle
590
+ // under a home or mount path that itself contains an unrelated `vX.Y.Z`
591
+ // (an nvm dir, a versioned volume) would otherwise match that first segment,
592
+ // fail the prefix check, and be misclassified as a local build.
593
+ const resolved = path.resolve(appPath);
594
+ for (const m of appPath.matchAll(/v(\d+\.\d+\.\d+)/g)) {
595
+ const expected = path.resolve(cacheDirFor(m[1]));
596
+ if (resolved === expected || resolved.startsWith(expected + path.sep))
597
+ return m[1];
598
+ }
599
+ return null;
493
600
  }
494
601
  /**
495
- * Pure staleness decision (no I/O) so the truth table is unit-testable. The
496
- * installed service is stale when the helper binary is gone, or when it was
497
- * installed by a different CLI version than the one now running — a version
498
- * change is the signal that the plist's baked interpreter/entry/bundle paths
499
- * and the helper binary itself may have drifted. A null installedVersion
500
- * (pre-stamp install) counts as stale so old installs get re-stamped once.
602
+ * Pure staleness decision (no I/O) so the truth table is unit-testable.
603
+ *
604
+ * This compares the HELPER axis, not the CLI's. It used to compare the installed
605
+ * stamp against `getCliVersion()`, which was wrong in both directions once the
606
+ * helpers gained their own version line: every CLI release made an unchanged
607
+ * helper look stale and reinstalled it (the #2109 restart storm), while a
608
+ * genuinely newer helper at the same CLI version never looked stale at all.
609
+ *
610
+ * Stale when: the executable is gone; nothing is stamped; the stamp predates the
611
+ * JSON format (`legacy` — re-stamped once); the install KIND changed
612
+ * (local <-> release), since a dev build and a release bundle are not
613
+ * interchangeable; a release install whose available helper version is newer;
614
+ * or a local install whose source path or mtime moved.
501
615
  */
502
616
  export function isMenubarStale(opts) {
503
617
  if (!opts.execExists)
504
618
  return true;
505
- return opts.installedVersion !== opts.currentVersion;
619
+ const { installed, available } = opts;
620
+ if (!installed)
621
+ return true;
622
+ if (installed.source === 'legacy')
623
+ return true;
624
+ if (installed.source !== available.source)
625
+ return true;
626
+ if (installed.source === 'release' && available.source === 'release') {
627
+ return compareVersions(available.helperVersion, installed.helperVersion) > 0;
628
+ }
629
+ if (installed.source === 'local' && available.source === 'local') {
630
+ return installed.sourceStamp !== available.sourceStamp;
631
+ }
632
+ return true;
506
633
  }
507
634
  function menubarSetupStale() {
508
635
  return isMenubarStale({
509
- installedVersion: readInstalledMenubarVersion(),
510
- currentVersion: getCliVersion(),
636
+ installed: readInstalledMenubarStamp(),
637
+ available: availableStamp(),
511
638
  execExists: fs.existsSync(installedExecutablePath()),
512
639
  });
513
640
  }
@@ -662,7 +789,14 @@ export function mayInstallMenubarHelper(opts) {
662
789
  // running, nothing deadlocks.
663
790
  if (!opts.ownerEntryExists)
664
791
  return opts.sourceIsDeveloperId;
665
- if (opts.installedVersion && opts.currentVersion) {
792
+ // `local` is a KIND marker, not a version — a dev build has none. Comparing it
793
+ // with compareVersions would order it against real semver arbitrarily and let a
794
+ // dev build win (or lose) a contest it should not enter. When either side is a
795
+ // local build the version arm is skipped entirely and the owner arm decides,
796
+ // which is the correct outcome: ownership, not version, distinguishes them.
797
+ const comparableVersions = opts.installedVersion && opts.currentVersion &&
798
+ opts.installedVersion !== LOCAL_BUILD_LABEL && opts.currentVersion !== LOCAL_BUILD_LABEL;
799
+ if (comparableVersions) {
666
800
  const versionOrder = compareVersions(opts.currentVersion, opts.installedVersion);
667
801
  if (versionOrder > 0)
668
802
  return opts.sourceIsDeveloperId;
@@ -730,8 +864,8 @@ function mayHealMenubar(needsDevIdHeal) {
730
864
  ownerEntryExists: Boolean(plistEntry) && fs.existsSync(plistEntry),
731
865
  helperExecMissing: !fs.existsSync(installedExecutablePath()),
732
866
  needsDevIdHeal,
733
- installedVersion: readInstalledMenubarVersion(),
734
- currentVersion: getCliVersion(),
867
+ installedVersion: stampVersionLabel(readInstalledMenubarStamp()),
868
+ currentVersion: availableHelperLabel(),
735
869
  msSinceLastHeal: msSinceLastMenubarHeal(),
736
870
  cooldownMs: MENUBAR_TAKEOVER_COOLDOWN_MS,
737
871
  sourceIsDeveloperId: Boolean(src) && hasDeveloperIdSignature(src),
@@ -931,7 +1065,24 @@ export async function runMenubarSetup() {
931
1065
  step('bundle', 'failed', 'could not install the helper bundle');
932
1066
  return { steps, configured: false, status: getMenubarStatus() };
933
1067
  }
934
- step('bundle', before.installedVersion === getCliVersion() ? 'ok' : 'changed', `${installedAppPath()} (${getCliVersion()})`);
1068
+ // The helper axis here too: this label read "ok" only when the installed
1069
+ // helper's stamp happened to equal the CLI's version, which after this change
1070
+ // is never true on a release install.
1071
+ //
1072
+ // Compare the STAMPS, not their labels. `stampVersionLabel` collapses every
1073
+ // local build to the literal 'local', discarding the mtime — so a real dev
1074
+ // rebuild (which the surrounding logic does correctly reinstall) would print
1075
+ // "ok" and hide that anything changed. That is the same collapse already
1076
+ // excluded in `versionMatches` and `mayInstallMenubarHelper`; this was the
1077
+ // third site and the only one still reading through the lossy label.
1078
+ const bundleStamp = readInstalledMenubarStamp();
1079
+ // Reuse the file's canonical comparator rather than a second, serialization
1080
+ // -based one: `JSON.stringify` was sound only because both stamps come from
1081
+ // `stampFor`, which is an implicit key-order dependency with no reason to
1082
+ // exist when isMenubarStale already answers exactly this question.
1083
+ const bundleUnchanged = bundleStamp !== null &&
1084
+ !isMenubarStale({ installed: bundleStamp, available: availableStamp(), execExists: true });
1085
+ step('bundle', bundleUnchanged ? 'ok' : 'changed', `${installedAppPath()} (${stampVersionLabel(bundleStamp) ?? 'unknown'})`);
935
1086
  if (!(codesignVerifies(installedAppPath()) && gatekeeperAssesses(installedAppPath()))) {
936
1087
  step('signature', 'failed', 'not notarized/valid on this machine — refusing to start it (Gatekeeper rejects an ' +
937
1088
  'un-notarized helper as "damaged"). Upgrade to a notarized build of agents-cli.');
@@ -941,7 +1092,7 @@ export async function runMenubarSetup() {
941
1092
  // Clear the sticky opt-out: running `setup` is an explicit request for the
942
1093
  // menu bar, so a stale `menubar disable` must not silently win.
943
1094
  clearMenubarOptOut();
944
- installAndStartService(exec);
1095
+ installAndStartService(exec, stampFor(src ?? undefined));
945
1096
  step('login item', before.serviceInstalled ? 'ok' : 'changed', `${serviceLabel()} — starts at login, restarts if it dies`);
946
1097
  // launchd's bootstrap+kickstart is asynchronous; give the status item a beat
947
1098
  // to claim the lock before counting instances, or `setup` reports zero on a
@@ -1043,8 +1194,9 @@ export function getMenubarStatus() {
1043
1194
  platform: process.platform,
1044
1195
  source: sourceAppPath(),
1045
1196
  installedApp: fs.existsSync(dest) ? dest : null,
1046
- installedVersion: readInstalledMenubarVersion(),
1047
- currentVersion: getCliVersion(),
1197
+ installedVersion: stampVersionLabel(readInstalledMenubarStamp()),
1198
+ currentVersion: availableHelperLabel(),
1199
+ cliVersion: getCliVersion(),
1048
1200
  stale: onDarwin() && serviceInstalled && menubarSetupStale(),
1049
1201
  serviceInstalled,
1050
1202
  running: own.length > 0,
@@ -1105,6 +1257,7 @@ export function buildMenubarDoctorReport() {
1105
1257
  installPath: null,
1106
1258
  installedVersion: null,
1107
1259
  currentVersion: getCliVersion(),
1260
+ cliVersion: getCliVersion(),
1108
1261
  versionMatches: false,
1109
1262
  signingIdentity: 'unknown',
1110
1263
  running: false,
@@ -1137,7 +1290,13 @@ export function buildMenubarDoctorReport() {
1137
1290
  installPath: appPath,
1138
1291
  installedVersion: status.installedVersion,
1139
1292
  currentVersion: status.currentVersion,
1140
- versionMatches: status.installedVersion === status.currentVersion,
1293
+ cliVersion: status.cliVersion,
1294
+ // Two local builds are not a version "mismatch" — neither carries a version.
1295
+ // Reporting one told the user to run `setup` for a difference that does not
1296
+ // exist, which is what made the old hint fire forever.
1297
+ versionMatches: status.installedVersion === LOCAL_BUILD_LABEL && status.currentVersion === LOCAL_BUILD_LABEL
1298
+ ? true
1299
+ : status.installedVersion === status.currentVersion,
1141
1300
  signingIdentity,
1142
1301
  running: status.running,
1143
1302
  staleRunningProcess,
@@ -8,8 +8,8 @@
8
8
  * repeats. It is a plain inline line — not a toast, not a nag — and stays out
9
9
  * of the way of non-interactive, CI, quiet, and JSON output.
10
10
  */
11
- /** Canonical GitHub repo the nudge points at. */
12
- export declare const REPO_URL = "https://github.com/phnx-labs/agents-cli";
11
+ /** Canonical GitHub repo the nudge points at (renamed from agents-cli). */
12
+ export declare const REPO_URL = "https://github.com/phnx-labs/agi-cli";
13
13
  /** Has the star nudge already been shown on this machine? */
14
14
  export declare function hasShownStarNudge(): boolean;
15
15
  /** Inputs to the pure show/skip decision (kept side-effect free for testing). */
@@ -12,8 +12,8 @@ import * as fs from 'fs';
12
12
  import * as path from 'path';
13
13
  import chalk from 'chalk';
14
14
  import { getRuntimeStateDir } from './state.js';
15
- /** Canonical GitHub repo the nudge points at. */
16
- export const REPO_URL = 'https://github.com/phnx-labs/agents-cli';
15
+ /** Canonical GitHub repo the nudge points at (renamed from agents-cli). */
16
+ export const REPO_URL = 'https://github.com/phnx-labs/agi-cli';
17
17
  /** Sentinel written the first (and only) time the nudge is shown. */
18
18
  function nudgeSentinelPath() {
19
19
  return path.join(getRuntimeStateDir(), 'star-nudge-shown');
@@ -31,7 +31,7 @@ export declare function getHumansFilePath(): string;
31
31
  * file against `schema/agents-yaml.schema.json`. Exported so
32
32
  * `lib/devices/config-migration.ts` rewrites a device doc with the same header.
33
33
  */
34
- export declare const META_HEADER = "# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agents-cli\n# yaml-language-server: $schema=https://raw.githubusercontent.com/phnx-labs/agents-cli/main/cli/schema/agents-yaml.schema.json\n\n";
34
+ export declare const META_HEADER = "# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agi-cli\n# yaml-language-server: $schema=https://raw.githubusercontent.com/phnx-labs/agi-cli/main/cli/schema/agents-yaml.schema.json\n\n";
35
35
  /** Root of the system data directory (~/.agents/.system/). */
36
36
  export declare function getAgentsDir(): string;
37
37
  /** Root of the system data directory (~/.agents/.system/). */
package/dist/lib/state.js CHANGED
@@ -167,8 +167,8 @@ const USER_PROMPTCUTS_FILE = path.join(USER_AGENTS_DIR, 'hooks', 'promptcuts.yam
167
167
  */
168
168
  export const META_HEADER = `# agents-cli metadata
169
169
  # Auto-generated - do not edit manually
170
- # https://github.com/phnx-labs/agents-cli
171
- # yaml-language-server: $schema=https://raw.githubusercontent.com/phnx-labs/agents-cli/main/cli/schema/agents-yaml.schema.json
170
+ # https://github.com/phnx-labs/agi-cli
171
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/phnx-labs/agi-cli/main/cli/schema/agents-yaml.schema.json
172
172
 
173
173
  `;
174
174
  // ─── Root getters ─────────────────────────────────────────────────────────────