@phnx-labs/agents-cli 1.22.23 → 1.22.24

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 (88) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +5 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/attach.d.ts +2 -0
  5. package/dist/commands/attach.js +32 -7
  6. package/dist/commands/defaults.js +2 -0
  7. package/dist/commands/doctor.js +5 -0
  8. package/dist/commands/exec.js +80 -28
  9. package/dist/commands/feed.d.ts +18 -0
  10. package/dist/commands/feed.js +44 -1
  11. package/dist/commands/focus.d.ts +34 -3
  12. package/dist/commands/focus.js +300 -56
  13. package/dist/commands/go.d.ts +14 -1
  14. package/dist/commands/go.js +49 -5
  15. package/dist/commands/insights.js +6 -2
  16. package/dist/commands/inspect.js +39 -5
  17. package/dist/commands/menubar.js +6 -1
  18. package/dist/commands/models.js +1 -0
  19. package/dist/commands/modes.d.ts +12 -0
  20. package/dist/commands/modes.js +147 -0
  21. package/dist/commands/secrets-sync.js +11 -13
  22. package/dist/commands/secrets.d.ts +2 -0
  23. package/dist/commands/secrets.js +54 -17
  24. package/dist/commands/sessions-browser.d.ts +35 -0
  25. package/dist/commands/sessions-browser.js +140 -14
  26. package/dist/commands/sessions-resume.d.ts +2 -0
  27. package/dist/commands/sessions-resume.js +26 -11
  28. package/dist/commands/sessions.d.ts +10 -0
  29. package/dist/commands/sessions.js +34 -59
  30. package/dist/commands/setup-secrets.js +1 -1
  31. package/dist/commands/sync.js +246 -42
  32. package/dist/commands/view.js +2 -0
  33. package/dist/index.js +2 -1
  34. package/dist/lib/agent-modes.d.ts +49 -0
  35. package/dist/lib/agent-modes.js +70 -0
  36. package/dist/lib/doctor-diff.d.ts +3 -0
  37. package/dist/lib/doctor-diff.js +15 -13
  38. package/dist/lib/event-stream.d.ts +3 -1
  39. package/dist/lib/event-stream.js +14 -1
  40. package/dist/lib/exec.d.ts +12 -0
  41. package/dist/lib/exec.js +88 -9
  42. package/dist/lib/hooks/cache.js +36 -3
  43. package/dist/lib/hooks.d.ts +19 -7
  44. package/dist/lib/hooks.js +88 -38
  45. package/dist/lib/hosts/session-index.d.ts +4 -0
  46. package/dist/lib/hosts/session-index.js +7 -0
  47. package/dist/lib/manifest.d.ts +12 -2
  48. package/dist/lib/manifest.js +60 -5
  49. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  50. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +5 -1
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  53. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  54. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  55. package/dist/lib/menubar/install-menubar.js +128 -6
  56. package/dist/lib/refresh.d.ts +5 -0
  57. package/dist/lib/refresh.js +37 -33
  58. package/dist/lib/resource-inventory.d.ts +79 -0
  59. package/dist/lib/resource-inventory.js +122 -0
  60. package/dist/lib/resources.js +8 -5
  61. package/dist/lib/run-defaults.d.ts +2 -0
  62. package/dist/lib/run-defaults.js +23 -2
  63. package/dist/lib/runner.js +22 -17
  64. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  65. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  66. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  67. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  68. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  69. package/dist/lib/secrets/bundles.js +157 -65
  70. package/dist/lib/secrets/filestore.d.ts +5 -3
  71. package/dist/lib/secrets/filestore.js +12 -8
  72. package/dist/lib/secrets/index.js +34 -8
  73. package/dist/lib/secrets/reaper.d.ts +10 -0
  74. package/dist/lib/secrets/reaper.js +50 -15
  75. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  76. package/dist/lib/secrets/sync-passphrase.js +78 -0
  77. package/dist/lib/session/recovery.d.ts +37 -0
  78. package/dist/lib/session/recovery.js +95 -0
  79. package/dist/lib/shims.d.ts +2 -2
  80. package/dist/lib/shims.js +24 -5
  81. package/dist/lib/startup/command-registry.d.ts +1 -0
  82. package/dist/lib/startup/command-registry.js +2 -0
  83. package/dist/lib/sync-umbrella.d.ts +5 -0
  84. package/dist/lib/sync-umbrella.js +5 -4
  85. package/dist/lib/tmux/session.d.ts +4 -2
  86. package/dist/lib/tmux/session.js +5 -5
  87. package/dist/lib/types.d.ts +2 -0
  88. package/package.json +1 -1
@@ -11,6 +11,10 @@
11
11
  * issued from, so the run appears in that project's session listing like any
12
12
  * local run. The `[host/<name>]` label mirrors the cloud path's
13
13
  * `[cloud/<status>]` convention.
14
+ *
15
+ * `machine` is the dispatch host, not this box. Both the transcript and the
16
+ * version-isolated harness home live there; omitting it makes the SQLite upsert
17
+ * infer this box from the empty file path and sends recovery to the wrong home.
14
18
  */
15
19
  import * as fs from 'fs';
16
20
  import { upsertSession } from '../session/db.js';
@@ -18,6 +22,7 @@ import { isSessionTrackedAgent } from '../session/types.js';
18
22
  import { localLogPath, updateTask } from './tasks.js';
19
23
  import { parseSessionIdMarker } from './session-marker.js';
20
24
  import { deriveShortId } from '../session/short-id.js';
25
+ import { normalizeHost } from '../machine-id.js';
21
26
  /**
22
27
  * Build the SessionMeta for a host-dispatched run. Returns null when the run has
23
28
  * no captured session id (nothing stable to key/resume on) or its agent isn't a
@@ -38,6 +43,7 @@ export function hostSessionMeta(task, ctx) {
38
43
  // Remote transcript — no local file. Empty file_path is the sentinel the DB
39
44
  // stale-filter treats as "always live" (see module doc).
40
45
  filePath: '',
46
+ machine: normalizeHost(task.host),
41
47
  topic: ctx.prompt.split('\n')[0]?.slice(0, 120) || undefined,
42
48
  // The run's `--name` seeds the label (resolves `agents sessions <name>` and
43
49
  // `agents hosts logs <name>`); an unnamed host run falls back to the
@@ -109,6 +115,7 @@ export function registerInteractiveHostSession(ctx) {
109
115
  timestamp: ctx.createdAt ?? new Date().toISOString(),
110
116
  cwd: ctx.cwd,
111
117
  filePath: '',
118
+ machine: normalizeHost(ctx.host),
112
119
  label: ctx.name || `[host/${ctx.host}]`,
113
120
  }, '');
114
121
  }
@@ -3,8 +3,18 @@ import type { Manifest } from './types.js';
3
3
  export declare const MANIFEST_FILENAME = "agents.yaml";
4
4
  /** Parse a YAML string into a typed Manifest object. */
5
5
  export declare function parseManifest(content: string): Manifest;
6
- /** Serialize a Manifest object to a YAML string with 2-space indentation. */
7
- export declare function serializeManifest(manifest: Manifest): string;
6
+ /**
7
+ * Serialize a Manifest to YAML WITHOUT destroying hand-written comments.
8
+ *
9
+ * Plain `yaml.stringify(manifest)` drops every comment, so `agents mcp add`
10
+ * (and every other writeManifest caller) used to clobber annotations in
11
+ * agents.yaml. Matching `serializeCentral` in state.ts: when existing file
12
+ * text is provided, parse it into a `yaml.Document` (comments + key order
13
+ * preserved) and edit only keys that actually changed. Untouched keys and
14
+ * their comments stay byte-stable. Falls back to plain stringify when there
15
+ * is no existing document yet.
16
+ */
17
+ export declare function serializeManifest(manifest: Manifest, existingContent?: string | null): string;
8
18
  /** Read and parse agents.yaml from a directory. Returns null if the file does not exist. */
9
19
  export declare function readManifest(repoPath: string): Manifest | null;
10
20
  /** Write a Manifest object to agents.yaml in the given directory. */
@@ -16,9 +16,48 @@ const manifestLockDepth = new Map();
16
16
  export function parseManifest(content) {
17
17
  return yaml.parse(content);
18
18
  }
19
- /** Serialize a Manifest object to a YAML string with 2-space indentation. */
20
- export function serializeManifest(manifest) {
21
- return yaml.stringify(manifest, { indent: 2 });
19
+ /**
20
+ * Serialize a Manifest to YAML WITHOUT destroying hand-written comments.
21
+ *
22
+ * Plain `yaml.stringify(manifest)` drops every comment, so `agents mcp add`
23
+ * (and every other writeManifest caller) used to clobber annotations in
24
+ * agents.yaml. Matching `serializeCentral` in state.ts: when existing file
25
+ * text is provided, parse it into a `yaml.Document` (comments + key order
26
+ * preserved) and edit only keys that actually changed. Untouched keys and
27
+ * their comments stay byte-stable. Falls back to plain stringify when there
28
+ * is no existing document yet.
29
+ */
30
+ export function serializeManifest(manifest, existingContent) {
31
+ const entries = Object.entries(manifest).filter(([, v]) => v !== undefined);
32
+ const isEmpty = entries.length === 0;
33
+ if (existingContent == null || existingContent.trim() === '') {
34
+ return isEmpty ? '' : yaml.stringify(manifest, { indent: 2 });
35
+ }
36
+ const doc = yaml.parseDocument(existingContent);
37
+ const current = doc.toJSON() ?? {};
38
+ let changed = false;
39
+ for (const [k, v] of entries) {
40
+ if (JSON.stringify(current[k]) !== JSON.stringify(v)) {
41
+ doc.set(k, v);
42
+ changed = true;
43
+ }
44
+ }
45
+ // Full-document write: callers do read-modify-write, so keys absent from the
46
+ // new manifest are intentional removals (e.g. clearing beta).
47
+ for (const k of Object.keys(current)) {
48
+ const next = manifest[k];
49
+ if (!(k in manifest) || next === undefined) {
50
+ doc.delete(k);
51
+ changed = true;
52
+ }
53
+ }
54
+ // Nothing changed → keep the file byte-identical (comments intact).
55
+ if (!changed)
56
+ return existingContent;
57
+ // Force BLOCK style: an existing flow root (e.g. legacy `{}`) would otherwise
58
+ // make edited nodes render flow. collectionStyle pins the whole doc block
59
+ // while parseDocument still preserves comments + key ordering.
60
+ return isEmpty ? '' : doc.toString({ collectionStyle: 'block' });
22
61
  }
23
62
  /** Read and parse agents.yaml from a directory. Returns null if the file does not exist. */
24
63
  export function readManifest(repoPath) {
@@ -55,8 +94,24 @@ function withManifestLock(filePath, fn) {
55
94
  /** Write a Manifest object to agents.yaml in the given directory. */
56
95
  export function writeManifest(repoPath, manifest) {
57
96
  const manifestPath = safeJoin(repoPath, MANIFEST_FILENAME);
58
- const content = serializeManifest(manifest);
59
- withManifestLock(manifestPath, () => atomicWriteFileSync(manifestPath, content));
97
+ withManifestLock(manifestPath, () => {
98
+ let existing = null;
99
+ try {
100
+ existing = fs.readFileSync(manifestPath, 'utf-8');
101
+ }
102
+ catch {
103
+ /* first write — no file yet (or empty lock target) */
104
+ }
105
+ // ensureLockTarget may have created an empty file for the lock path.
106
+ if (existing !== null && existing.trim() === '')
107
+ existing = null;
108
+ const content = serializeManifest(manifest, existing);
109
+ // Skip the atomic rewrite when nothing changed so comments stay byte-stable
110
+ // and concurrent readers never see a no-op churn.
111
+ if (existing !== null && content === existing)
112
+ return;
113
+ atomicWriteFileSync(manifestPath, content);
114
+ });
60
115
  }
61
116
  /** Create a Manifest with sensible defaults for a fresh agents repo. */
62
117
  export function createDefaultManifest() {
@@ -7,7 +7,11 @@
7
7
  <key>CFBundleIdentifier</key>
8
8
  <string>com.phnx-labs.agents-menubar</string>
9
9
  <key>CFBundleName</key>
10
- <string>Agents Menu Bar</string>
10
+ <string>AGI Menu</string>
11
+ <key>CFBundleDisplayName</key>
12
+ <string>AGI Menu</string>
13
+ <key>CFBundleIconFile</key>
14
+ <string>AppIcon</string>
11
15
  <key>CFBundlePackageType</key>
12
16
  <string>APPL</string>
13
17
  <key>CFBundleShortVersionString</key>
@@ -3,9 +3,22 @@
3
3
  <plist version="1.0">
4
4
  <dict>
5
5
  <key>files</key>
6
- <dict/>
6
+ <dict>
7
+ <key>Resources/AppIcon.icns</key>
8
+ <data>
9
+ jOjZVimcFRHoP2VPzgn8uM8mjkA=
10
+ </data>
11
+ </dict>
7
12
  <key>files2</key>
8
- <dict/>
13
+ <dict>
14
+ <key>Resources/AppIcon.icns</key>
15
+ <dict>
16
+ <key>hash2</key>
17
+ <data>
18
+ GFvSLeNYJ3ASxW3OzcuiB4aID0gZUBkpozmWZkbTFNw=
19
+ </data>
20
+ </dict>
21
+ </dict>
9
22
  <key>rules</key>
10
23
  <dict>
11
24
  <key>^Resources/</key>
@@ -111,6 +111,54 @@ export declare function menubarPlistNeedsRepoint(opts: {
111
111
  * upgrade migration won't re-enable it.
112
112
  */
113
113
  export declare function disableMenubarService(): void;
114
+ /**
115
+ * Which install is allowed to (re)install the shared helper.
116
+ *
117
+ * The helper lives at ONE path in Application Support, but any number of
118
+ * agents-cli copies can be present on a box and every one of them runs the
119
+ * startup self-heal. The version stamp and the plist's baked `AGENTS_ENTRY` each
120
+ * record whichever copy acted last, so without an ownership rule every copy
121
+ * reads the others' marks as drift and recopies the bundle over them. Recopying
122
+ * replaces the executable under the live helper and kills it; launchd
123
+ * `KeepAlive` restarts it; the next copy repeats it. Measured on one box: a new
124
+ * pid every 5-15s, 578 launches in the helper's log, a status item that never
125
+ * stayed visible, and `agents menubar status` still reporting `running: yes`
126
+ * because a pid always existed (#2109).
127
+ *
128
+ * There is deliberately NO content comparison here. Comparing the shipped helper
129
+ * against the installed one cannot distinguish "real upgrade" from "another
130
+ * install's copy": the helper is rebuilt, re-signed and re-notarized on every
131
+ * release (`menubar/scripts/build.sh` via `release.sh`), so consecutive releases
132
+ * ship byte-different bundles from identical Swift source — 1.22.20/21/22 all
133
+ * have the same 2876288-byte executable and three different sha256s AND three
134
+ * different CDHashes. Any digest gate therefore reports "changed" for exactly
135
+ * the skew case it was meant to exempt.
136
+ *
137
+ * So ownership decides instead: the plist's `AGENTS_ENTRY` names the owner, and
138
+ * only the owner may reinstall. A non-owner takes over only once the recorded
139
+ * owner is gone from disk, which is what makes the rule converge — a dead
140
+ * install cannot hold the helper hostage, and a live one cannot be fought over.
141
+ * A same-install upgrade keeps its entry path, so `npm update` still installs
142
+ * the new helper normally. Pure so the truth table is unit-testable.
143
+ */
144
+ export declare function mayInstallMenubarHelper(opts: {
145
+ /** `AGENTS_ENTRY` baked into the installed plist — the recorded owner. */
146
+ plistEntry: string | null;
147
+ /** `resolveCliEntry()` for the install now running `agents`. */
148
+ activeEntry: string | null;
149
+ /** Whether `plistEntry` still exists on disk. */
150
+ ownerEntryExists: boolean;
151
+ /** The App Support helper executable is absent — a repair, not a contest. */
152
+ helperExecMissing: boolean;
153
+ /** Installed copy is ad-hoc while the shipped source is Developer ID. */
154
+ needsDevIdHeal: boolean;
155
+ /** ms since the last self-heal reinstall, or null if none is recorded. */
156
+ msSinceLastHeal: number | null;
157
+ /** How long a non-owner waits before it may take over. */
158
+ cooldownMs: number;
159
+ /** This install's OWN shipped bundle is Developer-ID signed (not ad-hoc/dev). */
160
+ sourceIsDeveloperId: boolean;
161
+ }): boolean;
114
162
  /**
115
163
  * Startup self-heal, run on every darwin CLI invocation (see src/index.ts).
116
164
  * No-ops cheaply (a couple of existsSync + a tiny file read) unless work is
@@ -121,8 +169,10 @@ export declare function disableMenubarService(): void;
121
169
  *
122
170
  * Without the staleness re-enable, `npm update` refreshed the CLI but left the
123
171
  * menu bar running the previous release's helper binary on a possibly-stale
124
- * plist. No-ops if: not darwin, the user opted out, or no helper bundle ships.
125
- * Best-effort never throws into startup.
172
+ * plist. Everything past the ownership gate is unchanged; the gate is what stops
173
+ * coexisting installs reinstalling over each other forever (#2109). No-ops if:
174
+ * not darwin, the user opted out, or no helper bundle ships. Best-effort — never
175
+ * throws into startup.
126
176
  */
127
177
  export declare function installMenubarLaunchAgentOnUpgrade(): void;
128
178
  /** One step of `agents menubar setup`, and how it came out. */
@@ -468,6 +468,115 @@ export function disableMenubarService() {
468
468
  }
469
469
  catch { /* best effort */ }
470
470
  }
471
+ /**
472
+ * Which install is allowed to (re)install the shared helper.
473
+ *
474
+ * The helper lives at ONE path in Application Support, but any number of
475
+ * agents-cli copies can be present on a box and every one of them runs the
476
+ * startup self-heal. The version stamp and the plist's baked `AGENTS_ENTRY` each
477
+ * record whichever copy acted last, so without an ownership rule every copy
478
+ * reads the others' marks as drift and recopies the bundle over them. Recopying
479
+ * replaces the executable under the live helper and kills it; launchd
480
+ * `KeepAlive` restarts it; the next copy repeats it. Measured on one box: a new
481
+ * pid every 5-15s, 578 launches in the helper's log, a status item that never
482
+ * stayed visible, and `agents menubar status` still reporting `running: yes`
483
+ * because a pid always existed (#2109).
484
+ *
485
+ * There is deliberately NO content comparison here. Comparing the shipped helper
486
+ * against the installed one cannot distinguish "real upgrade" from "another
487
+ * install's copy": the helper is rebuilt, re-signed and re-notarized on every
488
+ * release (`menubar/scripts/build.sh` via `release.sh`), so consecutive releases
489
+ * ship byte-different bundles from identical Swift source — 1.22.20/21/22 all
490
+ * have the same 2876288-byte executable and three different sha256s AND three
491
+ * different CDHashes. Any digest gate therefore reports "changed" for exactly
492
+ * the skew case it was meant to exempt.
493
+ *
494
+ * So ownership decides instead: the plist's `AGENTS_ENTRY` names the owner, and
495
+ * only the owner may reinstall. A non-owner takes over only once the recorded
496
+ * owner is gone from disk, which is what makes the rule converge — a dead
497
+ * install cannot hold the helper hostage, and a live one cannot be fought over.
498
+ * A same-install upgrade keeps its entry path, so `npm update` still installs
499
+ * the new helper normally. Pure so the truth table is unit-testable.
500
+ */
501
+ export function mayInstallMenubarHelper(opts) {
502
+ // Repairs are never gated: a missing binary or a broken signing identity leaves
503
+ // the menu bar dead or re-prompting for Accessibility, and no other install can
504
+ // be "fighting" for a bundle that isn't there. Blocking these behind ownership
505
+ // is what turned the first version of this gate into a silent stuck state.
506
+ if (opts.helperExecMissing || opts.needsDevIdHeal)
507
+ return true;
508
+ // Can't resolve which install we are (a dev/tsx run) — never churn the plist.
509
+ if (!opts.activeEntry)
510
+ return false;
511
+ // No owner recorded yet (fresh or pre-`AGENTS_ENTRY` plist) — adopt it.
512
+ if (!opts.plistEntry)
513
+ return true;
514
+ if (opts.plistEntry === opts.activeEntry)
515
+ return true; // we are the owner
516
+ if (!opts.ownerEntryExists)
517
+ return true; // the recorded owner is gone
518
+ // A foreign install while the owner still exists. Refusing outright bounds the
519
+ // loop but strands the user when the recorded owner is a stale copy that simply
520
+ // still sits on disk (an old nvm node dir) while their daily driver upgrades:
521
+ // that install would never heal again. So it may take over, but only once per
522
+ // cooldown — which turns an every-invocation storm into at most one restart per
523
+ // cooldown while keeping every install able to make progress.
524
+ //
525
+ // Except an ad-hoc/dev-signed copy, which never seizes a healthy helper on a
526
+ // timer. `scripts/install.sh` deliberately puts a dev build beside the npm
527
+ // global, and its bundle cannot be notarized; letting it win the timed takeover
528
+ // would recopy an ad-hoc bundle over a good Developer-ID one, and Gatekeeper
529
+ // then rejects the result as "damaged" and AppKit crashes at launch (RUSH-2134)
530
+ // — trading a cosmetic loop for a broken menu bar. It can still take over when
531
+ // the owner is genuinely gone (above), which is the case that must not deadlock.
532
+ if (!opts.sourceIsDeveloperId)
533
+ return false;
534
+ return opts.msSinceLastHeal === null || opts.msSinceLastHeal >= opts.cooldownMs;
535
+ }
536
+ /**
537
+ * How long a non-owner install waits before it may take the helper over. Long
538
+ * enough that a multi-install box restarts the helper at most once an hour
539
+ * instead of every few seconds; short enough that a user who switched installs
540
+ * gets their upgrade without hunting for `agents menubar setup`.
541
+ */
542
+ const MENUBAR_TAKEOVER_COOLDOWN_MS = 60 * 60 * 1000;
543
+ /** Timestamp of the last self-heal reinstall, next to the version stamp. */
544
+ function lastHealMarkerPath() {
545
+ return path.join(installDir(), '.menubar-last-heal');
546
+ }
547
+ function msSinceLastMenubarHeal() {
548
+ try {
549
+ const t = Number(fs.readFileSync(lastHealMarkerPath(), 'utf-8').trim());
550
+ if (!Number.isFinite(t))
551
+ return null;
552
+ return Math.max(0, Date.now() - t);
553
+ }
554
+ catch {
555
+ return null;
556
+ }
557
+ }
558
+ function stampMenubarHeal() {
559
+ try {
560
+ fs.mkdirSync(installDir(), { recursive: true });
561
+ fs.writeFileSync(lastHealMarkerPath(), String(Date.now()));
562
+ }
563
+ catch { /* best effort */ }
564
+ }
565
+ /** Whether this install may (re)install the helper (see `mayInstallMenubarHelper`). */
566
+ function mayHealMenubar(needsDevIdHeal) {
567
+ const plistEntry = readPlistEnvValue('AGENTS_ENTRY');
568
+ const src = sourceAppPath();
569
+ return mayInstallMenubarHelper({
570
+ plistEntry,
571
+ activeEntry: resolveCliEntry(),
572
+ ownerEntryExists: Boolean(plistEntry) && fs.existsSync(plistEntry),
573
+ helperExecMissing: !fs.existsSync(installedExecutablePath()),
574
+ needsDevIdHeal,
575
+ msSinceLastHeal: msSinceLastMenubarHeal(),
576
+ cooldownMs: MENUBAR_TAKEOVER_COOLDOWN_MS,
577
+ sourceIsDeveloperId: Boolean(src) && hasDeveloperIdSignature(src),
578
+ });
579
+ }
471
580
  /**
472
581
  * Startup self-heal, run on every darwin CLI invocation (see src/index.ts).
473
582
  * No-ops cheaply (a couple of existsSync + a tiny file read) unless work is
@@ -478,8 +587,10 @@ export function disableMenubarService() {
478
587
  *
479
588
  * Without the staleness re-enable, `npm update` refreshed the CLI but left the
480
589
  * menu bar running the previous release's helper binary on a possibly-stale
481
- * plist. No-ops if: not darwin, the user opted out, or no helper bundle ships.
482
- * Best-effort never throws into startup.
590
+ * plist. Everything past the ownership gate is unchanged; the gate is what stops
591
+ * coexisting installs reinstalling over each other forever (#2109). No-ops if:
592
+ * not darwin, the user opted out, or no helper bundle ships. Best-effort — never
593
+ * throws into startup.
483
594
  */
484
595
  export function installMenubarLaunchAgentOnUpgrade() {
485
596
  try {
@@ -495,12 +606,23 @@ export function installMenubarLaunchAgentOnUpgrade() {
495
606
  }
496
607
  // Re-enable (recopy helper + rewrite plist) when the version drifted OR the
497
608
  // plist's baked interpreter/entry no longer point at the install now running
498
- // `agents` — the dual-install skew a version bump alone can't catch — OR the
609
+ // `agents` — e.g. the owner moved between node interpreters — OR the
499
610
  // installed copy is still ad-hoc while the shipped source is Developer ID
500
611
  // (older heal path; Accessibility re-prompts until the identity is restored).
501
- if (menubarSetupStale() || menubarSetupNeedsRepoint() || installedNeedsDevIdHeal()) {
502
- enableMenubarService({ clearOptOut: false });
503
- }
612
+ const needsDevIdHeal = installedNeedsDevIdHeal();
613
+ if (!(menubarSetupStale() || menubarSetupNeedsRepoint() || needsDevIdHeal))
614
+ return;
615
+ // ...but a copy that does not own the helper only gets to act on that drift
616
+ // once per cooldown. Without the gate every coexisting install recopies the
617
+ // bundle on every invocation, killing the live helper on a loop (#2109).
618
+ if (!mayHealMenubar(needsDevIdHeal))
619
+ return;
620
+ // Stamp only a heal that actually happened. `enableMenubarService` returns
621
+ // false without installing when the bundle fails the Gatekeeper check, and
622
+ // stamping first would spend the shared cooldown on a no-op — locking every
623
+ // non-owner out for another hour while nothing had been fixed.
624
+ if (enableMenubarService({ clearOptOut: false }))
625
+ stampMenubarHeal();
504
626
  }
505
627
  catch {
506
628
  /* never block startup on the menu bar */
@@ -17,6 +17,11 @@ export interface RefreshOptions {
17
17
  skipPrompts?: boolean;
18
18
  /** Skip CLI version install/upgrade from agents.yaml. */
19
19
  skipClis?: boolean;
20
+ /**
21
+ * Suppress human progress lines on stdout. Required for machine consumers
22
+ * (`agents sync --json` / fleet fan-out) so stdout stays a single JSON object.
23
+ */
24
+ quiet?: boolean;
20
25
  }
21
26
  /**
22
27
  * Re-materialize local state from declared configuration: install CLI versions,
@@ -29,7 +29,7 @@ import { isPromptCancelled } from '../commands/utils.js';
29
29
  * hook reads from a fixed path so it survives version upgrades. If the root
30
30
  * file doesn't exist yet but an agent-scoped one does, hoist the first one found.
31
31
  */
32
- function migratePromptcutsToRoot(agentsDir) {
32
+ function migratePromptcutsToRoot(agentsDir, quiet = false) {
33
33
  const rootPath = path.join(agentsDir, 'promptcuts.yaml');
34
34
  if (fs.existsSync(rootPath))
35
35
  return;
@@ -39,7 +39,8 @@ function migratePromptcutsToRoot(agentsDir) {
39
39
  if (fs.existsSync(legacyPath)) {
40
40
  try {
41
41
  fs.renameSync(legacyPath, rootPath);
42
- console.log(chalk.gray(`Moved ${dir}/promptcuts.yaml → promptcuts.yaml (repo root)`));
42
+ if (!quiet)
43
+ console.log(chalk.gray(`Moved ${dir}/promptcuts.yaml → promptcuts.yaml (repo root)`));
43
44
  return;
44
45
  }
45
46
  catch {
@@ -56,16 +57,19 @@ function migratePromptcutsToRoot(agentsDir) {
56
57
  * Idempotent — safe to run repeatedly. No network operations.
57
58
  */
58
59
  export async function refresh(options = {}) {
59
- const { agentFilter, skipPrompts = false, skipClis = false } = options;
60
+ const { agentFilter, skipPrompts = false, skipClis = false, quiet = false } = options;
60
61
  const agentsDir = getUserAgentsDir();
61
- migratePromptcutsToRoot(agentsDir);
62
+ // Gate every human progress line so --json / fleet fan-out can parse stdout.
63
+ const log = (...args) => { if (!quiet)
64
+ console.log(...args); };
65
+ migratePromptcutsToRoot(agentsDir, quiet);
62
66
  const manifest = readManifest(agentsDir);
63
67
  if (!manifest) {
64
- console.log(chalk.gray(`No ${MANIFEST_FILENAME} found`));
68
+ log(chalk.gray(`No ${MANIFEST_FILENAME} found`));
65
69
  }
66
70
  // 1. Install/upgrade CLI versions from agents.yaml
67
71
  if (!skipClis && manifest?.agents) {
68
- console.log(chalk.bold('\nCLI Versions:\n'));
72
+ log(chalk.bold('\nCLI Versions:\n'));
69
73
  const cliAgents = Object.keys(manifest.agents);
70
74
  for (const agentId of cliAgents) {
71
75
  if (agentFilter && agentId !== agentFilter)
@@ -94,7 +98,7 @@ export async function refresh(options = {}) {
94
98
  }
95
99
  // 2. Register MCP servers
96
100
  if (manifest?.mcp && Object.keys(manifest.mcp).length > 0) {
97
- console.log(chalk.bold('\nMCP Servers:\n'));
101
+ log(chalk.bold('\nMCP Servers:\n'));
98
102
  for (const [name, config] of Object.entries(manifest.mcp)) {
99
103
  if (!config.command || config.transport === 'http')
100
104
  continue;
@@ -109,7 +113,7 @@ export async function refresh(options = {}) {
109
113
  const label = result.version
110
114
  ? `${agentLabel(result.agentId)}@${result.version}`
111
115
  : agentLabel(result.agentId);
112
- console.log(` ${chalk.green('+')} ${name} -> ${label}`);
116
+ log(` ${chalk.green('+')} ${name} -> ${label}`);
113
117
  }
114
118
  }
115
119
  }
@@ -147,13 +151,13 @@ export async function refresh(options = {}) {
147
151
  forceFullSync = true;
148
152
  }
149
153
  else if (!hasAnySynced) {
150
- console.log(chalk.yellow(`\n${agentLabel(agentId)}@${defaultVer} has no synced resources.`));
154
+ log(chalk.yellow(`\n${agentLabel(agentId)}@${defaultVer} has no synced resources.`));
151
155
  const userSelection = await promptResourceSelection(agentId);
152
156
  if (userSelection)
153
157
  selection = userSelection;
154
158
  }
155
159
  else if (hasNewResources(newResources, agentId, defaultVer)) {
156
- console.log(chalk.cyan(`\n${agentLabel(agentId)}@${defaultVer}:`));
160
+ log(chalk.cyan(`\n${agentLabel(agentId)}@${defaultVer}:`));
157
161
  const userSelection = await promptNewResourceSelection(agentId, newResources, defaultVer);
158
162
  if (userSelection)
159
163
  selection = userSelection;
@@ -184,13 +188,13 @@ export async function refresh(options = {}) {
184
188
  const verNote = versionsToSync.length > 1
185
189
  ? chalk.gray(` (${versionsToSync.length} versions)`)
186
190
  : '';
187
- console.log(chalk.green(` Synced: ${[...kinds].join(', ')}`) + verNote);
191
+ log(chalk.green(` Synced: ${[...kinds].join(', ')}`) + verNote);
188
192
  }
189
193
  }
190
194
  }
191
195
  catch (err) {
192
196
  if (isPromptCancelled(err)) {
193
- console.log(chalk.gray('Skipped resource selection'));
197
+ log(chalk.gray('Skipped resource selection'));
194
198
  }
195
199
  else {
196
200
  throw err;
@@ -215,24 +219,24 @@ export async function refresh(options = {}) {
215
219
  const result = registerHooksToSettings(agentId, home, hookManifest);
216
220
  hookRegistered += result.registered.length;
217
221
  for (const error of result.errors) {
218
- console.log(chalk.yellow(` Hook warning: ${error}`));
222
+ log(chalk.yellow(` Hook warning: ${error}`));
219
223
  }
220
224
  }
221
225
  }
222
226
  if (hookRegistered > 0) {
223
- console.log(chalk.green(`\nRegistered ${hookRegistered} hook lifecycle event(s)`));
227
+ log(chalk.green(`\nRegistered ${hookRegistered} hook lifecycle event(s)`));
224
228
  }
225
229
  }
226
230
  // 5. Auto-add shims to PATH
227
231
  if (!isShimsInPath()) {
228
232
  const pathResult = addShimsToPath();
229
233
  if (pathResult.success && !pathResult.alreadyPresent) {
230
- console.log(chalk.green(`\nAdded shims to ${pathResult.location}`));
231
- console.log(chalk.gray(pathResult.reloadHint));
234
+ log(chalk.green(`\nAdded shims to ${pathResult.location}`));
235
+ log(chalk.gray(pathResult.reloadHint));
232
236
  }
233
237
  else if (!pathResult.success) {
234
- console.log(chalk.yellow('\nCould not auto-add shims to PATH:'));
235
- console.log(chalk.gray(getPathSetupInstructions()));
238
+ log(chalk.yellow('\nCould not auto-add shims to PATH:'));
239
+ log(chalk.gray(getPathSetupInstructions()));
236
240
  }
237
241
  }
238
242
  // 6. Prompt for missing default versions
@@ -273,63 +277,63 @@ export async function refresh(options = {}) {
273
277
  setGlobalDefault(agentId, version);
274
278
  const symlinkResult = await switchConfigSymlink(agentId, version);
275
279
  if (!symlinkResult.success) {
276
- console.log(chalk.yellow(`Warning: ${symlinkResult.error}`));
280
+ log(chalk.yellow(`Warning: ${symlinkResult.error}`));
277
281
  }
278
282
  else if (symlinkResult.backupPath) {
279
- console.log(chalk.gray(`Backed up existing config to: ${symlinkResult.backupPath}`));
283
+ log(chalk.gray(`Backed up existing config to: ${symlinkResult.backupPath}`));
280
284
  }
281
285
  switchHomeFileSymlinks(agentId, version);
282
- console.log(chalk.green(`Set ${agentLabel(agent.id)}@${version} as default`));
286
+ log(chalk.green(`Set ${agentLabel(agent.id)}@${version} as default`));
283
287
  }
284
288
  }
285
289
  // 7. Install declared host-CLIs
286
290
  try {
287
291
  const { statuses, errors } = listCliStatus(process.cwd());
288
292
  for (const err of errors) {
289
- console.log(chalk.yellow(` CLI manifest parse error: ${err.file}: ${err.reason}`));
293
+ log(chalk.yellow(` CLI manifest parse error: ${err.file}: ${err.reason}`));
290
294
  }
291
295
  const missing = statuses.filter((s) => !s.installed);
292
296
  if (missing.length > 0) {
293
- console.log(chalk.bold('\nDeclared CLIs missing from this host:'));
297
+ log(chalk.bold('\nDeclared CLIs missing from this host:'));
294
298
  for (const s of missing) {
295
299
  const method = selectInstallMethod(s.manifest);
296
300
  const action = method ? describeMethod(method) : chalk.red('no compatible install method');
297
- console.log(` ${chalk.cyan(s.manifest.name.padEnd(20))} ${chalk.gray(action)}`);
301
+ log(` ${chalk.cyan(s.manifest.name.padEnd(20))} ${chalk.gray(action)}`);
298
302
  }
299
- console.log('');
303
+ log('');
300
304
  if (!skipPrompts) {
301
305
  const proceed = await confirm({ message: `Install ${missing.length} missing CLI(s) now?`, default: true });
302
306
  if (proceed) {
303
307
  for (const s of missing) {
304
- console.log(chalk.bold(`\n→ ${s.manifest.name}`));
308
+ log(chalk.bold(`\n→ ${s.manifest.name}`));
305
309
  const result = installCli(s.manifest);
306
310
  if (result.error) {
307
- console.log(chalk.red(` ${result.error}`));
311
+ log(chalk.red(` ${result.error}`));
308
312
  continue;
309
313
  }
310
314
  if (result.installed) {
311
- console.log(chalk.green(` installed`));
315
+ log(chalk.green(` installed`));
312
316
  if (s.manifest.postInstall) {
313
- console.log(chalk.gray(s.manifest.postInstall.trim().split('\n').map((l) => ' ' + l).join('\n')));
317
+ log(chalk.gray(s.manifest.postInstall.trim().split('\n').map((l) => ' ' + l).join('\n')));
314
318
  }
315
319
  }
316
320
  else {
317
- console.log(chalk.yellow(` install ran but \`${describeCheck(s.manifest.check)}\` still fails`));
321
+ log(chalk.yellow(` install ran but \`${describeCheck(s.manifest.check)}\` still fails`));
318
322
  }
319
323
  }
320
324
  }
321
325
  else {
322
- console.log(chalk.gray(`Skipped. Run 'agents cli install' later.`));
326
+ log(chalk.gray(`Skipped. Run 'agents cli install' later.`));
323
327
  }
324
328
  }
325
329
  else {
326
- console.log(chalk.gray(`Run 'agents cli install' to install them.`));
330
+ log(chalk.gray(`Run 'agents cli install' to install them.`));
327
331
  }
328
332
  }
329
333
  }
330
334
  catch (err) {
331
335
  if (!isPromptCancelled(err)) {
332
- console.log(chalk.yellow(`CLI install skipped: ${err.message}`));
336
+ log(chalk.yellow(`CLI install skipped: ${err.message}`));
333
337
  }
334
338
  }
335
339
  }