@phnx-labs/agents-cli 1.22.43 → 1.22.45

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 (164) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +14 -12
  3. package/dist/bootstrap.js +3 -6
  4. package/dist/cli/command-registry.d.ts +0 -7
  5. package/dist/cli/command-registry.js +1 -21
  6. package/dist/commands/accounts.d.ts +1 -9
  7. package/dist/commands/accounts.js +12 -90
  8. package/dist/commands/apply.d.ts +7 -10
  9. package/dist/commands/apply.js +15 -35
  10. package/dist/commands/artifacts.js +4 -5
  11. package/dist/commands/audit.d.ts +6 -6
  12. package/dist/commands/audit.js +12 -12
  13. package/dist/commands/beta.d.ts +7 -1
  14. package/dist/commands/beta.js +16 -9
  15. package/dist/commands/commands.js +1 -1
  16. package/dist/commands/doctor.js +1 -4
  17. package/dist/commands/events.d.ts +2 -2
  18. package/dist/commands/events.js +6 -5
  19. package/dist/commands/exec.d.ts +1 -1
  20. package/dist/commands/exec.js +8 -4
  21. package/dist/commands/factory.d.ts +1 -1
  22. package/dist/commands/fleet-capture.d.ts +1 -1
  23. package/dist/commands/fleet-capture.js +4 -5
  24. package/dist/commands/harness.js +1 -49
  25. package/dist/commands/hooks.js +1 -1
  26. package/dist/commands/insights.d.ts +1 -1
  27. package/dist/commands/insights.js +85 -156
  28. package/dist/commands/menubar.js +58 -1
  29. package/dist/commands/output.js +2 -2
  30. package/dist/commands/prune.js +5 -4
  31. package/dist/commands/routines.test-fixture.d.ts +86 -0
  32. package/dist/commands/routines.test-fixture.js +345 -0
  33. package/dist/commands/sessions.test-fixture.d.ts +24 -0
  34. package/dist/commands/sessions.test-fixture.js +224 -0
  35. package/dist/commands/setup.js +5 -1
  36. package/dist/commands/share.d.ts +9 -10
  37. package/dist/commands/share.js +68 -50
  38. package/dist/commands/skills.js +1 -1
  39. package/dist/commands/ssh.js +15 -62
  40. package/dist/commands/subagents.js +1 -1
  41. package/dist/commands/view.js +5 -2
  42. package/dist/lib/analytics/mix-commands.d.ts +6 -28
  43. package/dist/lib/analytics/mix-commands.js +14 -40
  44. package/dist/lib/audit/log.d.ts +2 -2
  45. package/dist/lib/audit/log.js +2 -2
  46. package/dist/lib/beta.js +1 -1
  47. package/dist/lib/browser/domain-skills.d.ts +26 -6
  48. package/dist/lib/browser/domain-skills.js +81 -43
  49. package/dist/lib/daemon/daemon.js +11 -0
  50. package/dist/lib/daemon/daemon.test-fixture.d.ts +24 -0
  51. package/dist/lib/daemon/daemon.test-fixture.js +71 -0
  52. package/dist/lib/daemon-ticks.d.ts +38 -1
  53. package/dist/lib/daemon-ticks.js +52 -5
  54. package/dist/lib/device-config.js +1 -2
  55. package/dist/lib/devices/discovery-policy.d.ts +3 -3
  56. package/dist/lib/devices/discovery-policy.js +3 -3
  57. package/dist/lib/devices/fleet.d.ts +4 -8
  58. package/dist/lib/devices/fleet.js +3 -15
  59. package/dist/lib/devices/health-report.js +3 -3
  60. package/dist/lib/devices/pool.d.ts +0 -6
  61. package/dist/lib/devices/pool.js +0 -6
  62. package/dist/lib/devices/registry.d.ts +0 -15
  63. package/dist/lib/devices/registry.js +0 -9
  64. package/dist/lib/devices/stats-cache.d.ts +12 -0
  65. package/dist/lib/devices/stats-cache.js +24 -5
  66. package/dist/lib/fleet/capture.d.ts +1 -1
  67. package/dist/lib/fleet/manifest.js +1 -1
  68. package/dist/lib/fleet/remote-login.d.ts +2 -2
  69. package/dist/lib/fleet/remote-login.js +3 -4
  70. package/dist/lib/hosts/dispatch.d.ts +2 -0
  71. package/dist/lib/hosts/dispatch.js +8 -1
  72. package/dist/lib/hosts/passthrough.js +0 -1
  73. package/dist/lib/hosts/providers/devices.js +1 -13
  74. package/dist/lib/hosts/ready.d.ts +12 -3
  75. package/dist/lib/hosts/ready.js +27 -12
  76. package/dist/lib/hosts/registry.d.ts +4 -5
  77. package/dist/lib/hosts/registry.js +3 -7
  78. package/dist/lib/installations/versions.js +21 -2
  79. package/dist/lib/menubar/install-menubar.d.ts +109 -0
  80. package/dist/lib/menubar/install-menubar.js +199 -4
  81. package/dist/lib/remote-agents-json.js +1 -7
  82. package/dist/lib/routines-placement.d.ts +1 -1
  83. package/dist/lib/routines-placement.js +1 -1
  84. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  85. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
  86. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  87. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
  88. package/dist/lib/session/active.d.ts +4 -16
  89. package/dist/lib/session/active.js +2 -6
  90. package/dist/lib/session/db.d.ts +13 -98
  91. package/dist/lib/session/db.js +4 -11
  92. package/dist/lib/session/discover.d.ts +5 -81
  93. package/dist/lib/session/discover.js +5 -5
  94. package/dist/lib/session/remote/remote-bundle.d.ts +12 -0
  95. package/dist/lib/session/remote/remote-bundle.js +61 -0
  96. package/dist/lib/session/remote/remote-list.d.ts +148 -0
  97. package/dist/lib/session/remote/remote-list.js +607 -0
  98. package/dist/lib/session/remote/remote.d.ts +132 -0
  99. package/dist/lib/session/remote/remote.js +314 -0
  100. package/dist/lib/session/remote/watch.d.ts +101 -0
  101. package/dist/lib/session/remote/watch.js +241 -0
  102. package/dist/lib/session/remote-bundle.d.ts +1 -12
  103. package/dist/lib/session/remote-bundle.js +3 -61
  104. package/dist/lib/session/remote-list.d.ts +1 -148
  105. package/dist/lib/session/remote-list.js +3 -607
  106. package/dist/lib/session/remote.d.ts +1 -132
  107. package/dist/lib/session/remote.js +3 -314
  108. package/dist/lib/session/watch.d.ts +1 -101
  109. package/dist/lib/session/watch.js +3 -242
  110. package/dist/lib/share/config.d.ts +12 -2
  111. package/dist/lib/share/config.js +47 -11
  112. package/dist/lib/share/delete.js +1 -1
  113. package/dist/lib/share/publish.js +1 -1
  114. package/dist/lib/share/worker-template.js +2 -2
  115. package/dist/lib/smart-launch.d.ts +4 -4
  116. package/dist/lib/smart-launch.js +8 -18
  117. package/dist/lib/staleness/index.d.ts +3 -1
  118. package/dist/lib/staleness/index.js +21 -1
  119. package/dist/lib/staleness/types.d.ts +11 -0
  120. package/dist/lib/staleness/writers/commands.js +13 -4
  121. package/dist/lib/staleness/writers/hooks.js +3 -1
  122. package/dist/lib/staleness/writers/mcp.d.ts +0 -7
  123. package/dist/lib/staleness/writers/mcp.js +16 -1
  124. package/dist/lib/staleness/writers/rules.js +1 -1
  125. package/dist/lib/staleness/writers/skills.js +3 -1
  126. package/dist/lib/staleness/writers/subagents.d.ts +0 -9
  127. package/dist/lib/staleness/writers/subagents.js +18 -1
  128. package/dist/lib/staleness/writers/types.d.ts +10 -0
  129. package/dist/lib/startup/command-registry.d.ts +8 -1
  130. package/dist/lib/startup/command-registry.js +21 -6
  131. package/dist/lib/startup/root-command.d.ts +18 -1
  132. package/dist/lib/startup/root-command.js +18 -1
  133. package/dist/lib/state.js +7 -0
  134. package/dist/lib/teams/placement-probe.js +27 -12
  135. package/dist/lib/teams/scheduler.d.ts +3 -1
  136. package/dist/lib/types.d.ts +1 -1
  137. package/dist/lib/view-types.d.ts +3 -0
  138. package/package.json +2 -3
  139. package/dist/bin/agents +0 -0
  140. package/dist/commands/auth.d.ts +0 -9
  141. package/dist/commands/auth.js +0 -108
  142. package/dist/commands/org.d.ts +0 -6
  143. package/dist/commands/org.js +0 -175
  144. package/dist/commands/serve.d.ts +0 -10
  145. package/dist/commands/serve.js +0 -68
  146. package/dist/commands/trends.d.ts +0 -10
  147. package/dist/commands/trends.js +0 -12
  148. package/dist/lib/entitlement.d.ts +0 -31
  149. package/dist/lib/entitlement.js +0 -137
  150. package/dist/lib/prix-account.d.ts +0 -158
  151. package/dist/lib/prix-account.js +0 -214
  152. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  153. package/dist/lib/serve/control.d.ts +0 -95
  154. package/dist/lib/serve/control.js +0 -260
  155. package/dist/lib/serve/data.d.ts +0 -81
  156. package/dist/lib/serve/data.js +0 -91
  157. package/dist/lib/serve/page.d.ts +0 -7
  158. package/dist/lib/serve/page.js +0 -140
  159. package/dist/lib/serve/server.d.ts +0 -80
  160. package/dist/lib/serve/server.js +0 -145
  161. package/dist/lib/serve/stream.d.ts +0 -43
  162. package/dist/lib/serve/stream.js +0 -116
  163. package/dist/lib/serve/token.d.ts +0 -35
  164. package/dist/lib/serve/token.js +0 -85
@@ -24,7 +24,7 @@ import { normalizeHost } from '../machine-id.js';
24
24
  import { readMeta } from '../state.js';
25
25
  import { isSshConfigHost } from './ssh-config.js';
26
26
  import { resolveRemoteOsSync } from './remote-os.js';
27
- import { loadDevices, isControlDevice } from '../devices/registry.js';
27
+ import { loadDevices } from '../devices/registry.js';
28
28
  import { resolveDeviceProfile } from '../devices/resolve-profile.js';
29
29
  import { isDeviceAuto, resolveDeviceAffinity } from '../smart-launch.js';
30
30
  import { localMachineId } from '../session/origin-machine.js';
@@ -148,7 +148,7 @@ function literalHost(token, host, user) {
148
148
  * all resolve the same way regardless of which subcommand called.
149
149
  *
150
150
  * Non-throwing: returns null on an injection-guard failure or an unresolved bare
151
- * token. Device-only refusals (control device, password auth) are NOT applied
151
+ * token. The device-only refusal (password auth) is NOT applied
152
152
  * here — that is the dispatch wrapper's job ({@link resolveHost}), so the fan-out
153
153
  * and `agents ssh` paths, which handle those cases differently, aren't forced
154
154
  * into a dispatch verdict.
@@ -240,8 +240,7 @@ export async function listAllHosts() {
240
240
  * caller consumes (`run --device`, the generic passthrough, teams placement, the
241
241
  * cloud host provider, doctor, funnel, remote secrets). Grammar and merge come
242
242
  * from {@link matchHost}; on top, this layer applies the device-only dispatch
243
- * refusals so they hold even when an inline overlay shadows the device:
244
- * - a control device (a paired iPhone) can never be a dispatch target;
243
+ * refusal so it holds even when an inline overlay shadows the device:
245
244
  * - a password-auth device throws the typed {@link DeviceOffloadUnsupportedError}
246
245
  * (offload rides `BatchMode=yes` ssh, which can't answer a prompt);
247
246
  * - an addressless device has nothing to dial.
@@ -254,9 +253,6 @@ export async function resolveHost(name) {
254
253
  if (!host)
255
254
  return null;
256
255
  if (host.device) {
257
- if (isControlDevice(host.device)) {
258
- throw new Error(`Device "${host.device.name}" is a control device (a cockpit), not an executor — it can't run agents. Dispatch to a worker device instead.`);
259
- }
260
256
  if (host.device.auth.method === 'password') {
261
257
  throw new DeviceOffloadUnsupportedError(host.device.name);
262
258
  }
@@ -2491,6 +2491,10 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2491
2491
  const commandsAlsoAsSkills = shouldAlsoInstallCommandAsSkill(agent, version);
2492
2492
  const commandsInstallAsSkills = commandsAsSkills || commandsAlsoAsSkills;
2493
2493
  let writtenCommands = [];
2494
+ // Artifact paths the writers report (WriteResult.paths), persisted to the
2495
+ // manifest as `writtenTargets` after a full sync so isStale can flag a
2496
+ // deleted artifact as stale (#2398).
2497
+ const writtenTargets = [];
2494
2498
  if (commandsToSync.length > 0 && commandsWriter) {
2495
2499
  // Agents that replace native command files with skills (codex >= 0.117.0,
2496
2500
  // kimi) must have their legacy command dir removed, or files written by an
@@ -2503,6 +2507,8 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2503
2507
  }
2504
2508
  const r = commandsWriter.write({ version, versionHome, selection: commandsToSync, cwd });
2505
2509
  writtenCommands = r.synced;
2510
+ if (r.paths)
2511
+ writtenTargets.push(...r.paths);
2506
2512
  result.commands = r.synced.length > 0;
2507
2513
  }
2508
2514
  // Orphan-sweep stale top-level command files from previous syncs under a
@@ -2510,8 +2516,9 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2510
2516
  // not pass an explicit `selection`. Callers that pass explicit selections
2511
2517
  // are using the incremental/additive API (sync exactly these; leave others
2512
2518
  // alone), so the sweep would be a contract violation there. The
2513
- // cross-project leak always comes from the no-selection shim auto-sync at
2514
- // launch.
2519
+ // cross-project leak comes from no-selection full syncs run under different
2520
+ // cwds (`agents sync` / `agents refresh`; the shim's `agents sync --launch`
2521
+ // skips version-home reconciliation and never reaches this path).
2515
2522
  if (!userPassedSelection && commandsWriter && !shouldInstallCommandAsSkill(agent, version)) {
2516
2523
  const commandsTargetSweep = path.join(agentDir, agentConfig.commandsSubdir);
2517
2524
  if (fs.existsSync(commandsTargetSweep)) {
@@ -2570,6 +2577,8 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2570
2577
  else if (skillsWriter) {
2571
2578
  if (skillsToSync.length > 0) {
2572
2579
  const r = skillsWriter.write({ version, versionHome, selection: skillsToSync, cwd });
2580
+ if (r.paths)
2581
+ writtenTargets.push(...r.paths);
2573
2582
  result.skills = r.synced.length > 0;
2574
2583
  }
2575
2584
  // Orphan-sweep stale skill directories from previous syncs under a
@@ -2625,6 +2634,8 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2625
2634
  }
2626
2635
  }
2627
2636
  }
2637
+ if (r.paths)
2638
+ writtenTargets.push(...r.paths);
2628
2639
  result.hooks = r.synced.length > 0;
2629
2640
  hookManifest = selectHookManifest(parseHookManifest(), hooksToSync);
2630
2641
  }
@@ -2651,6 +2662,8 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2651
2662
  : null;
2652
2663
  const preset = overridePreset || activeRulesPreset() || getActiveRulesPreset(agent, version);
2653
2664
  const r = rulesWriter.write({ version, versionHome, selection: { preset }, cwd });
2665
+ if (r.paths)
2666
+ writtenTargets.push(...r.paths);
2654
2667
  result.memory.push(...r.synced);
2655
2668
  // rulesPreset is tracked separately via setActiveRulesPreset.
2656
2669
  }
@@ -2728,6 +2741,8 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2728
2741
  const mcpToSync = mcpToSyncAll.filter(n => !untrustedProjectMcpNames.has(n));
2729
2742
  if (mcpToSync.length > 0 && mcpWriter) {
2730
2743
  const r = mcpWriter.write({ version, versionHome, selection: mcpToSync, cwd });
2744
+ if (r.paths)
2745
+ writtenTargets.push(...r.paths);
2731
2746
  result.mcp = r.synced;
2732
2747
  if (r.errors?.length)
2733
2748
  result.declined.push(...r.errors.map((e) => `mcp: ${e}`));
@@ -2749,6 +2764,8 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2749
2764
  }
2750
2765
  if (subagentsToSync.length > 0 && subagentsWriter) {
2751
2766
  const r = subagentsWriter.write({ version, versionHome, selection: subagentsToSync, cwd });
2767
+ if (r.paths)
2768
+ writtenTargets.push(...r.paths);
2752
2769
  result.subagents.push(...r.synced);
2753
2770
  // Orphan-sweep for Claude only — see comment on commands/skills sweep
2754
2771
  // for the no-selection guard. OpenClaw stores subagents as siblings of
@@ -2855,6 +2872,8 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2855
2872
  const previous = loadManifest(agent, version);
2856
2873
  const manifest = buildSyncManifest(agent, version, cwd, previous);
2857
2874
  manifest.writtenCommands = writtenCommands;
2875
+ // Deduped + sorted so repeated full syncs write byte-identical manifests.
2876
+ manifest.writtenTargets = Array.from(new Set(writtenTargets)).sort();
2858
2877
  saveManifest(agent, version, manifest);
2859
2878
  }
2860
2879
  return result;
@@ -75,6 +75,33 @@ export declare function generateServicePlist(execPath: string): string;
75
75
  export declare function restartMenubarLaunchAgent(uid: number, plist: string, exec?: (cmd: string, args: readonly string[], opts: {
76
76
  stdio: ['ignore', 'ignore', 'ignore'];
77
77
  }) => Buffer): void;
78
+ /**
79
+ * Force a running helper off the binary that was just swapped underneath it.
80
+ *
81
+ * `installMenubarLaunchAgentOnUpgrade()` calls this after a heal actually
82
+ * replaces the on-disk bundle (a version bump or the ad-hoc -> Developer ID
83
+ * transition — see `menubarHealReplacedBundle`). `restartMenubarLaunchAgent`
84
+ * (run moments earlier via `installAndStartService`) already attempts
85
+ * bootout+bootstrap+kickstart, but that targets the GUI launchd domain and
86
+ * fails silently — `launchctl kickstart -k gui/<uid>/<label>` prints "Could not
87
+ * find service ... in domain for user gui" — from a shell with no Aqua session,
88
+ * which is the ordinary case for `agents` invoked from a terminal/tmux/ssh
89
+ * session rather than literally the login item (verified live). When that
90
+ * happens the plist and on-disk bundle are current but the LIVE process is
91
+ * still the old binary, so it requests Accessibility under the OLD code
92
+ * identity and the grant never sticks.
93
+ *
94
+ * `kickstart -k` (force-restart, unlike the bare kickstart already tried) is
95
+ * attempted first since it is the clean launchd-native path when it works. If
96
+ * launchd has no record of the service in this context either, fall back to
97
+ * ending the specific pid(s) already confirmed to be running the installed
98
+ * bundle (`ownProcesses`, resolved by the caller via `liveMenubarProcesses`) —
99
+ * never a broad pkill. The launchd plist has `KeepAlive=true`, so the ended
100
+ * process is relaunched from the swapped binary within seconds.
101
+ */
102
+ export declare function restartMenubarHelperAfterSwap(uid: number, ownProcesses: readonly MenubarProcess[], exec?: (cmd: string, args: readonly string[], opts: {
103
+ stdio: ['ignore', 'ignore', 'ignore'];
104
+ }) => Buffer, kill?: (pid: number) => void): void;
78
105
  /**
79
106
  * Install + start the menu-bar helper as a launchd user service (idempotent).
80
107
  * Clears the sticky opt-out, installs the .app, writes the plist, and
@@ -97,6 +124,23 @@ export declare function isMenubarStale(opts: {
97
124
  currentVersion: string;
98
125
  execExists: boolean;
99
126
  }): boolean;
127
+ /**
128
+ * Pure decision (no I/O): did THIS heal actually replace bundle content a live
129
+ * process could be running stale, as opposed to a plist-only repoint?
130
+ *
131
+ * `stale` (version bump or missing exec) and `needsDevIdHeal` (ad-hoc ->
132
+ * Developer ID) both mean the shipped `.app` differs from what is already
133
+ * installed — the exact condition under which a running helper can be left on
134
+ * an old code identity after `enableMenubarService` swaps the bundle. A
135
+ * repoint-only heal (`menubarSetupNeedsRepoint()` alone, same version, same
136
+ * signing identity) does NOT count: RUSH-3005 already owns the churn from
137
+ * mixed-Node-interpreter repoints, and restarting the helper on every one of
138
+ * those would double that churn rather than fix this bug.
139
+ */
140
+ export declare function menubarHealReplacedBundle(opts: {
141
+ stale: boolean;
142
+ needsDevIdHeal: boolean;
143
+ }): boolean;
100
144
  /**
101
145
  * Pure re-point decision (no I/O): the plist's baked interpreter/entry no longer
102
146
  * match the install that is now running `agents`. This catches DUAL-INSTALL skew
@@ -110,6 +154,7 @@ export declare function isMenubarStale(opts: {
110
154
  export declare function menubarPlistNeedsRepoint(opts: {
111
155
  plistEntry: string | null;
112
156
  plistNode: string | null;
157
+ plistNodeExists: boolean;
113
158
  activeEntry: string | null;
114
159
  activeNode: string | null;
115
160
  }): boolean;
@@ -185,6 +230,31 @@ export declare function mayInstallMenubarHelper(opts: {
185
230
  * throws into startup.
186
231
  */
187
232
  export declare function installMenubarLaunchAgentOnUpgrade(): void;
233
+ /**
234
+ * Pure decision (no I/O): run the one-time `tccutil reset Accessibility` only
235
+ * on a real ad-hoc -> Developer ID transition, and only once ever. A machine
236
+ * that was always Developer ID never accumulated a dead TCC row under an
237
+ * ad-hoc identity, so resetting there would just force a needless re-prompt;
238
+ * a machine that already migrated must not be reset again on every heal.
239
+ */
240
+ export declare function shouldMigrateMenubarTcc(opts: {
241
+ needsDevIdHeal: boolean;
242
+ alreadyMigrated: boolean;
243
+ }): boolean;
244
+ /**
245
+ * Reset the stale Accessibility grant TCC recorded against the pre-migration
246
+ * ad-hoc identity, then stamp the marker so this never runs again on this
247
+ * machine. Targets `SERVICE_LABEL_BASE` — the bundle's actual
248
+ * `CFBundleIdentifier`/codesign `--identifier` (menubar/scripts/build.sh) that
249
+ * TCC keys grants to — not the namespaced `serviceLabel()`, which exists only
250
+ * to keep launchd job labels apart under a redirected HOME (RUSH-2639) and is
251
+ * never the app's real identity. Best-effort: a missing/failing `tccutil`
252
+ * (older macOS, a sandboxed test HOME) must never block the heal that just
253
+ * fixed the signing identity itself.
254
+ */
255
+ export declare function resetMenubarAccessibilityTcc(exec?: (cmd: string, args: readonly string[], opts: {
256
+ stdio: ['ignore', 'ignore', 'ignore'];
257
+ }) => Buffer): void;
188
258
  /** One step of `agents menubar setup`, and how it came out. */
189
259
  export interface SetupStep {
190
260
  /** What was configured. */
@@ -272,3 +342,42 @@ export interface MenubarStatus {
272
342
  disabledByUser: boolean;
273
343
  }
274
344
  export declare function getMenubarStatus(): MenubarStatus;
345
+ /** Read-only diagnostic for `agents menubar doctor` — probes, never mutates. */
346
+ export interface MenubarDoctorReport {
347
+ platform: string;
348
+ installPath: string | null;
349
+ installedVersion: string | null;
350
+ currentVersion: string;
351
+ versionMatches: boolean;
352
+ /** `unknown` on non-darwin or when nothing is installed to inspect. */
353
+ signingIdentity: 'developer-id' | 'ad-hoc' | 'unknown';
354
+ running: boolean;
355
+ /** A live helper pid started before the installed bundle's on-disk mtime —
356
+ * it is running the binary that has since been swapped underneath it. */
357
+ staleRunningProcess: PidStaleness[];
358
+ /** Grant likely needs to be re-made: an ad-hoc identity breaks on every
359
+ * update, or a live process is confirmed to predate the current bundle. */
360
+ accessibilityHintNeeded: boolean;
361
+ }
362
+ /** One live pid checked against the bundle's on-disk mtime. */
363
+ export interface PidStaleness {
364
+ pid: number;
365
+ stale: boolean;
366
+ }
367
+ /**
368
+ * Pure comparison (no I/O) behind the stale-process check: a helper pid that
369
+ * started before the bundle on disk was last written is still running the
370
+ * PREVIOUS binary — the exact condition `restartMenubarHelperAfterSwap` exists
371
+ * to fix. Millisecond epoch timestamps in, so the truth table is unit-testable
372
+ * without a live process or filesystem.
373
+ */
374
+ export declare function isMenubarProcessStaleAgainstBundle(pidStartedAtMs: number, bundleMtimeMs: number): boolean;
375
+ /**
376
+ * `agents menubar doctor` — everything a human needs to tell "why did
377
+ * Accessibility ask again" apart from "the helper is genuinely broken":
378
+ * install path, version skew, whether the signing identity is update-stable,
379
+ * and whether a live process predates the bundle it's supposed to be running.
380
+ * Read-only: no install, no restart, no TCC reset — `agents menubar setup`
381
+ * remains the command that fixes what this reports.
382
+ */
383
+ export declare function buildMenubarDoctorReport(): MenubarDoctorReport;
@@ -353,6 +353,45 @@ export function restartMenubarLaunchAgent(uid, plist, exec = execFileSync) {
353
353
  }
354
354
  catch { /* best effort */ }
355
355
  }
356
+ /**
357
+ * Force a running helper off the binary that was just swapped underneath it.
358
+ *
359
+ * `installMenubarLaunchAgentOnUpgrade()` calls this after a heal actually
360
+ * replaces the on-disk bundle (a version bump or the ad-hoc -> Developer ID
361
+ * transition — see `menubarHealReplacedBundle`). `restartMenubarLaunchAgent`
362
+ * (run moments earlier via `installAndStartService`) already attempts
363
+ * bootout+bootstrap+kickstart, but that targets the GUI launchd domain and
364
+ * fails silently — `launchctl kickstart -k gui/<uid>/<label>` prints "Could not
365
+ * find service ... in domain for user gui" — from a shell with no Aqua session,
366
+ * which is the ordinary case for `agents` invoked from a terminal/tmux/ssh
367
+ * session rather than literally the login item (verified live). When that
368
+ * happens the plist and on-disk bundle are current but the LIVE process is
369
+ * still the old binary, so it requests Accessibility under the OLD code
370
+ * identity and the grant never sticks.
371
+ *
372
+ * `kickstart -k` (force-restart, unlike the bare kickstart already tried) is
373
+ * attempted first since it is the clean launchd-native path when it works. If
374
+ * launchd has no record of the service in this context either, fall back to
375
+ * ending the specific pid(s) already confirmed to be running the installed
376
+ * bundle (`ownProcesses`, resolved by the caller via `liveMenubarProcesses`) —
377
+ * never a broad pkill. The launchd plist has `KeepAlive=true`, so the ended
378
+ * process is relaunched from the swapped binary within seconds.
379
+ */
380
+ export function restartMenubarHelperAfterSwap(uid, ownProcesses, exec = execFileSync, kill = endProcess) {
381
+ const reg = serviceManagerRegistrationAllowed();
382
+ if (!reg.allowed)
383
+ return;
384
+ const target = `gui/${uid}/${serviceLabel()}`;
385
+ const opts = { stdio: ['ignore', 'ignore', 'ignore'] };
386
+ try {
387
+ exec('launchctl', ['kickstart', '-k', target], opts);
388
+ return;
389
+ }
390
+ catch {
391
+ for (const p of ownProcesses)
392
+ kill(p.pid);
393
+ }
394
+ }
356
395
  /**
357
396
  * Install + start the menu-bar helper as a launchd user service (idempotent).
358
397
  * Clears the sticky opt-out, installs the .app, writes the plist, and
@@ -425,6 +464,22 @@ function menubarSetupStale() {
425
464
  execExists: fs.existsSync(installedExecutablePath()),
426
465
  });
427
466
  }
467
+ /**
468
+ * Pure decision (no I/O): did THIS heal actually replace bundle content a live
469
+ * process could be running stale, as opposed to a plist-only repoint?
470
+ *
471
+ * `stale` (version bump or missing exec) and `needsDevIdHeal` (ad-hoc ->
472
+ * Developer ID) both mean the shipped `.app` differs from what is already
473
+ * installed — the exact condition under which a running helper can be left on
474
+ * an old code identity after `enableMenubarService` swaps the bundle. A
475
+ * repoint-only heal (`menubarSetupNeedsRepoint()` alone, same version, same
476
+ * signing identity) does NOT count: RUSH-3005 already owns the churn from
477
+ * mixed-Node-interpreter repoints, and restarting the helper on every one of
478
+ * those would double that churn rather than fix this bug.
479
+ */
480
+ export function menubarHealReplacedBundle(opts) {
481
+ return opts.stale || opts.needsDevIdHeal;
482
+ }
428
483
  /**
429
484
  * Pure re-point decision (no I/O): the plist's baked interpreter/entry no longer
430
485
  * match the install that is now running `agents`. This catches DUAL-INSTALL skew
@@ -440,7 +495,12 @@ export function menubarPlistNeedsRepoint(opts) {
440
495
  return false; // can't resolve the running install — don't churn
441
496
  if (opts.plistEntry !== opts.activeEntry)
442
497
  return true;
443
- if (opts.activeNode && opts.plistNode !== opts.activeNode)
498
+ // The entry path owns the helper. The same installed CLI may be invoked through
499
+ // several compatible Node interpreters (nvm, Homebrew, a parent process's
500
+ // process.execPath). Re-pointing merely because those valid interpreter paths
501
+ // differ makes every invocation replace and relaunch the shared helper. Keep the
502
+ // recorded interpreter until it disappears; then repoint to the active one.
503
+ if (opts.activeNode && (!opts.plistNode || !opts.plistNodeExists))
444
504
  return true;
445
505
  return false;
446
506
  }
@@ -457,9 +517,11 @@ function readPlistEnvValue(key) {
457
517
  }
458
518
  /** True when the installed plist points at a different install than the active one. */
459
519
  function menubarSetupNeedsRepoint() {
520
+ const plistNode = readPlistEnvValue('AGENTS_NODE');
460
521
  return menubarPlistNeedsRepoint({
461
522
  plistEntry: readPlistEnvValue('AGENTS_ENTRY'),
462
- plistNode: readPlistEnvValue('AGENTS_NODE'),
523
+ plistNode,
524
+ plistNodeExists: Boolean(plistNode) && fs.existsSync(plistNode),
463
525
  activeEntry: resolveCliEntry(),
464
526
  activeNode: process.execPath,
465
527
  });
@@ -650,7 +712,8 @@ export function installMenubarLaunchAgentOnUpgrade() {
650
712
  // installed copy is still ad-hoc while the shipped source is Developer ID
651
713
  // (older heal path; Accessibility re-prompts until the identity is restored).
652
714
  const needsDevIdHeal = installedNeedsDevIdHeal();
653
- if (!(menubarSetupStale() || menubarSetupNeedsRepoint() || needsDevIdHeal))
715
+ const stale = menubarSetupStale();
716
+ if (!(stale || menubarSetupNeedsRepoint() || needsDevIdHeal))
654
717
  return;
655
718
  // ...but a copy that does not own the helper only gets to act on that drift
656
719
  // once per cooldown. Without the gate every coexisting install recopies the
@@ -661,8 +724,22 @@ export function installMenubarLaunchAgentOnUpgrade() {
661
724
  // false without installing when the bundle fails the Gatekeeper check, and
662
725
  // stamping first would spend the shared cooldown on a no-op — locking every
663
726
  // non-owner out for another hour while nothing had been fixed.
664
- if (enableMenubarService({ clearOptOut: false }))
727
+ if (enableMenubarService({ clearOptOut: false })) {
665
728
  stampMenubarHeal();
729
+ // One-time: the ad-hoc -> Developer ID transition leaves a dead TCC row
730
+ // under the old identity (tccutil on zion reset it 11 times across
731
+ // machines that made this jump) — clear it so the fresh grant sticks.
732
+ if (shouldMigrateMenubarTcc({ needsDevIdHeal, alreadyMigrated: menubarTccAlreadyMigrated() })) {
733
+ resetMenubarAccessibilityTcc();
734
+ }
735
+ // Only a REAL content swap (version bump or the Dev-ID transition) can
736
+ // leave a running process on stale code — a plist-only repoint (handled
737
+ // above by menubarSetupNeedsRepoint alone) is RUSH-3005's churn to own,
738
+ // not this heal's to restart on top of.
739
+ if (menubarHealReplacedBundle({ stale, needsDevIdHeal })) {
740
+ restartMenubarHelperAfterSwap(process.getuid?.() ?? 0, liveMenubarProcesses().own);
741
+ }
742
+ }
666
743
  }
667
744
  catch {
668
745
  /* never block startup on the menu bar */
@@ -677,6 +754,46 @@ function installedNeedsDevIdHeal() {
677
754
  return false;
678
755
  return hasDeveloperIdSignature(src);
679
756
  }
757
+ /** Marker written once the one-time ad-hoc -> Developer ID TCC migration has
758
+ * run on this machine, next to the version/heal stamps. */
759
+ function tccMigrationMarkerPath() {
760
+ return path.join(installDir(), '.menubar-tcc-migrated');
761
+ }
762
+ function menubarTccAlreadyMigrated() {
763
+ return fs.existsSync(tccMigrationMarkerPath());
764
+ }
765
+ /**
766
+ * Pure decision (no I/O): run the one-time `tccutil reset Accessibility` only
767
+ * on a real ad-hoc -> Developer ID transition, and only once ever. A machine
768
+ * that was always Developer ID never accumulated a dead TCC row under an
769
+ * ad-hoc identity, so resetting there would just force a needless re-prompt;
770
+ * a machine that already migrated must not be reset again on every heal.
771
+ */
772
+ export function shouldMigrateMenubarTcc(opts) {
773
+ return opts.needsDevIdHeal && !opts.alreadyMigrated;
774
+ }
775
+ /**
776
+ * Reset the stale Accessibility grant TCC recorded against the pre-migration
777
+ * ad-hoc identity, then stamp the marker so this never runs again on this
778
+ * machine. Targets `SERVICE_LABEL_BASE` — the bundle's actual
779
+ * `CFBundleIdentifier`/codesign `--identifier` (menubar/scripts/build.sh) that
780
+ * TCC keys grants to — not the namespaced `serviceLabel()`, which exists only
781
+ * to keep launchd job labels apart under a redirected HOME (RUSH-2639) and is
782
+ * never the app's real identity. Best-effort: a missing/failing `tccutil`
783
+ * (older macOS, a sandboxed test HOME) must never block the heal that just
784
+ * fixed the signing identity itself.
785
+ */
786
+ export function resetMenubarAccessibilityTcc(exec = execFileSync) {
787
+ try {
788
+ exec('tccutil', ['reset', 'Accessibility', SERVICE_LABEL_BASE], { stdio: ['ignore', 'ignore', 'ignore'] });
789
+ }
790
+ catch { /* best effort — a missing/failing tccutil must not block the heal */ }
791
+ try {
792
+ fs.mkdirSync(installDir(), { recursive: true });
793
+ fs.writeFileSync(tccMigrationMarkerPath(), new Date().toISOString());
794
+ }
795
+ catch { /* best effort */ }
796
+ }
680
797
  /**
681
798
  * Decide which live helper processes must be ended so exactly one status item
682
799
  * survives. Pure so the choice is unit-testable without a live menu bar.
@@ -867,3 +984,81 @@ export function getMenubarStatus() {
867
984
  disabledByUser: menubarDisabledByUser(),
868
985
  };
869
986
  }
987
+ /**
988
+ * Pure comparison (no I/O) behind the stale-process check: a helper pid that
989
+ * started before the bundle on disk was last written is still running the
990
+ * PREVIOUS binary — the exact condition `restartMenubarHelperAfterSwap` exists
991
+ * to fix. Millisecond epoch timestamps in, so the truth table is unit-testable
992
+ * without a live process or filesystem.
993
+ */
994
+ export function isMenubarProcessStaleAgainstBundle(pidStartedAtMs, bundleMtimeMs) {
995
+ return pidStartedAtMs < bundleMtimeMs;
996
+ }
997
+ /** Wall-clock start time of a live pid via `ps`, or null if it can't be read. */
998
+ function pidStartTimeMs(pid) {
999
+ const r = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
1000
+ stdio: ['ignore', 'pipe', 'ignore'],
1001
+ encoding: 'utf-8',
1002
+ });
1003
+ if (r.status !== 0)
1004
+ return null;
1005
+ const raw = (r.stdout || '').trim();
1006
+ if (!raw)
1007
+ return null;
1008
+ const t = new Date(raw).getTime();
1009
+ return Number.isNaN(t) ? null : t;
1010
+ }
1011
+ /**
1012
+ * `agents menubar doctor` — everything a human needs to tell "why did
1013
+ * Accessibility ask again" apart from "the helper is genuinely broken":
1014
+ * install path, version skew, whether the signing identity is update-stable,
1015
+ * and whether a live process predates the bundle it's supposed to be running.
1016
+ * Read-only: no install, no restart, no TCC reset — `agents menubar setup`
1017
+ * remains the command that fixes what this reports.
1018
+ */
1019
+ export function buildMenubarDoctorReport() {
1020
+ if (!onDarwin()) {
1021
+ return {
1022
+ platform: process.platform,
1023
+ installPath: null,
1024
+ installedVersion: null,
1025
+ currentVersion: getCliVersion(),
1026
+ versionMatches: false,
1027
+ signingIdentity: 'unknown',
1028
+ running: false,
1029
+ staleRunningProcess: [],
1030
+ accessibilityHintNeeded: false,
1031
+ };
1032
+ }
1033
+ const status = getMenubarStatus();
1034
+ const appPath = status.installedApp;
1035
+ const signingIdentity = appPath
1036
+ ? (hasDeveloperIdSignature(appPath) ? 'developer-id' : 'ad-hoc')
1037
+ : 'unknown';
1038
+ let staleRunningProcess = [];
1039
+ if (appPath && status.instances.length > 0) {
1040
+ try {
1041
+ const bundleMtimeMs = fs.statSync(installedExecutablePath()).mtimeMs;
1042
+ staleRunningProcess = status.instances
1043
+ .map((p) => {
1044
+ const startedAt = pidStartTimeMs(p.pid);
1045
+ return startedAt === null
1046
+ ? null
1047
+ : { pid: p.pid, stale: isMenubarProcessStaleAgainstBundle(startedAt, bundleMtimeMs) };
1048
+ })
1049
+ .filter((p) => p !== null);
1050
+ }
1051
+ catch { /* exec vanished mid-check — report no staleness rather than throw */ }
1052
+ }
1053
+ return {
1054
+ platform: process.platform,
1055
+ installPath: appPath,
1056
+ installedVersion: status.installedVersion,
1057
+ currentVersion: status.currentVersion,
1058
+ versionMatches: status.installedVersion === status.currentVersion,
1059
+ signingIdentity,
1060
+ running: status.running,
1061
+ staleRunningProcess,
1062
+ accessibilityHintNeeded: signingIdentity === 'ad-hoc' || staleRunningProcess.some((p) => p.stale),
1063
+ };
1064
+ }
@@ -12,7 +12,7 @@ import chalk from 'chalk';
12
12
  import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote, REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator, } from './ssh-exec.js';
13
13
  import { deviceIdentityArgs, sshTargetFor } from './devices/connect.js';
14
14
  import { resolveExplicitTargets } from './devices/resolve-target.js';
15
- import { loadDevices, isControlDevice, isDialableDevice } from './devices/registry.js';
15
+ import { loadDevices, isDialableDevice } from './devices/registry.js';
16
16
  import { remoteShellFor, buildWindowsAgentsCommand, stripClixml } from './hosts/remote-cmd.js';
17
17
  import { machineId, normalizeHost } from './machine-id.js';
18
18
  const REMOTE_TIMEOUT_MS = 12_000;
@@ -117,12 +117,6 @@ export async function gatherRemoteAgentsJson(options, deps = {}) {
117
117
  continue;
118
118
  if (normalizeHost(device.name) === self)
119
119
  continue;
120
- // Control-only devices (a phone/tablet cockpit) drive the fleet but never
121
- // run agents — never dial them, whatever their platform reads as. Keyed on
122
- // role, not platform, so this holds even for a control device that carries
123
- // a real OS value (mirrors the skip in session/remote-list.ts).
124
- if (isControlDevice(device))
125
- continue;
126
120
  if (!['windows', 'linux', 'macos'].includes(device.platform))
127
121
  continue;
128
122
  try {
@@ -29,7 +29,7 @@ export type PlacementTarget = {
29
29
  * `config.devices` is the *firing* allowlist only (which daemon may fire the
30
30
  * job). It is intentionally NOT used as an execution pool filter — otherwise
31
31
  * the double-fire pin (`devices: [self]`) would collapse fleet placement to
32
- * always-local. Control / offline / no-address devices are never chosen.
32
+ * always-local. Offline / no-address devices are never chosen.
33
33
  */
34
34
  export declare function pickFleetDevice(_config?: Pick<JobConfig, 'devices'>, platform?: DevicePlatform): string | null;
35
35
  /**
@@ -23,7 +23,7 @@ import { planFleetTargets } from './devices/fleet.js';
23
23
  * `config.devices` is the *firing* allowlist only (which daemon may fire the
24
24
  * job). It is intentionally NOT used as an execution pool filter — otherwise
25
25
  * the double-fire pin (`devices: [self]`) would collapse fleet placement to
26
- * always-local. Control / offline / no-address devices are never chosen.
26
+ * always-local. Offline / no-address devices are never chosen.
27
27
  */
28
28
  export function pickFleetDevice(_config, platform) {
29
29
  let reg;
@@ -8,8 +8,6 @@
8
8
  <string>Agents CLI</string>
9
9
  <key>CFBundleDisplayName</key>
10
10
  <string>Agents CLI</string>
11
- <key>CFBundleIconFile</key>
12
- <string>AppIcon</string>
13
11
  <key>CFBundleExecutable</key>
14
12
  <string>Agents CLI</string>
15
13
  <key>CFBundlePackageType</key>
@@ -3,21 +3,9 @@
3
3
  <plist version="1.0">
4
4
  <dict>
5
5
  <key>files</key>
6
- <dict>
7
- <key>Resources/AppIcon.icns</key>
8
- <data>
9
- DFq5H08EkhgWIC3UvGMR9B58BZw=
10
- </data>
11
- </dict>
6
+ <dict/>
12
7
  <key>files2</key>
13
8
  <dict>
14
- <key>Resources/AppIcon.icns</key>
15
- <dict>
16
- <key>hash2</key>
17
- <data>
18
- mBSjM6jlvN7J1jQowsvqTl7CHM0pur0e6qsHMuzk5k0=
19
- </data>
20
- </dict>
21
9
  <key>embedded.provisionprofile</key>
22
10
  <dict>
23
11
  <key>hash2</key>