@phnx-labs/agents-cli 1.22.117 → 1.22.118

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 (248) hide show
  1. package/CHANGELOG.md +235 -0
  2. package/dist/bootstrap.js +4 -11
  3. package/dist/commands/browser.d.ts +52 -70
  4. package/dist/commands/browser.js +192 -3562
  5. package/dist/commands/daemon.d.ts +39 -2
  6. package/dist/commands/daemon.js +72 -73
  7. package/dist/commands/events.js +1 -1
  8. package/dist/commands/exec.js +53 -1
  9. package/dist/commands/repo.js +13 -49
  10. package/dist/commands/route.js +5 -5
  11. package/dist/commands/routines.js +39 -12
  12. package/dist/commands/sessions-render.d.ts +1 -1
  13. package/dist/commands/sessions-render.js +0 -1
  14. package/dist/commands/sessions.test-fixture.d.ts +0 -1
  15. package/dist/commands/sessions.test-fixture.js +0 -37
  16. package/dist/commands/setup-browser.d.ts +17 -12
  17. package/dist/commands/setup-browser.js +52 -87
  18. package/dist/commands/setup-preferences.d.ts +0 -17
  19. package/dist/commands/setup-preferences.js +18 -49
  20. package/dist/commands/setup.js +9 -9
  21. package/dist/commands/ssh.js +10 -1
  22. package/dist/commands/sync.js +6 -44
  23. package/dist/commands/teams.js +0 -1
  24. package/dist/commands/update.d.ts +19 -0
  25. package/dist/commands/update.js +34 -6
  26. package/dist/commands/view.d.ts +12 -1
  27. package/dist/commands/view.js +47 -2
  28. package/dist/index.js +107 -26
  29. package/dist/lib/account-capabilities.js +0 -2
  30. package/dist/lib/account-catalog.d.ts +38 -0
  31. package/dist/lib/account-catalog.js +110 -26
  32. package/dist/lib/account-provider-registry.js +1 -1
  33. package/dist/lib/account-registry.d.ts +1 -1
  34. package/dist/lib/account-registry.js +36 -2
  35. package/dist/lib/accounting/rotate.js +22 -8
  36. package/dist/lib/accounting/usage-ingest.d.ts +6 -1
  37. package/dist/lib/accounting/usage-ingest.js +115 -45
  38. package/dist/lib/accounting/usage-sync.d.ts +134 -31
  39. package/dist/lib/accounting/usage-sync.js +227 -66
  40. package/dist/lib/accounting/usage.d.ts +16 -3
  41. package/dist/lib/accounting/usage.js +26 -8
  42. package/dist/lib/accounts/slots.d.ts +8 -0
  43. package/dist/lib/accounts/slots.js +17 -0
  44. package/dist/lib/add-dir.js +0 -1
  45. package/dist/lib/agent-cli-commands.js +0 -1
  46. package/dist/lib/agent-spec/agents.d.ts +6 -7
  47. package/dist/lib/agent-spec/agents.js +6 -60
  48. package/dist/lib/auth-health.d.ts +69 -1
  49. package/dist/lib/auth-health.js +133 -4
  50. package/dist/lib/browser/context.d.ts +82 -0
  51. package/dist/lib/browser/context.js +88 -0
  52. package/dist/lib/browser/paths.d.ts +25 -0
  53. package/dist/lib/browser/paths.js +51 -0
  54. package/dist/lib/browser/record.d.ts +38 -0
  55. package/dist/lib/browser/record.js +57 -0
  56. package/dist/lib/browser/sessions-list.js +1 -2
  57. package/dist/lib/browser-client.d.ts +158 -0
  58. package/dist/lib/browser-client.js +234 -0
  59. package/dist/lib/channels/owner-sink.d.ts +1 -18
  60. package/dist/lib/channels/owner-sink.js +29 -82
  61. package/dist/lib/channels/providers/rush.d.ts +7 -7
  62. package/dist/lib/channels/providers/rush.js +127 -57
  63. package/dist/lib/claude-statusline.d.ts +1 -1
  64. package/dist/lib/claude-statusline.js +15 -3
  65. package/dist/lib/commands.js +1 -1
  66. package/dist/lib/crabbox/lease.js +1 -1
  67. package/dist/lib/crabbox/runtimes.js +1 -3
  68. package/dist/lib/daemon/account-state-daemon-service.js +1 -0
  69. package/dist/lib/daemon/auth-sync-service.d.ts +21 -18
  70. package/dist/lib/daemon/auth-sync-service.js +37 -43
  71. package/dist/lib/daemon/daemon.d.ts +25 -14
  72. package/dist/lib/daemon/daemon.js +92 -154
  73. package/dist/lib/daemon/runner.d.ts +2 -2
  74. package/dist/lib/daemon/runner.js +32 -9
  75. package/dist/lib/daemon/self-update-service.d.ts +35 -34
  76. package/dist/lib/daemon/self-update-service.js +40 -36
  77. package/dist/lib/daemon/service.d.ts +12 -8
  78. package/dist/lib/daemon/supervisor.d.ts +65 -54
  79. package/dist/lib/daemon/supervisor.js +136 -171
  80. package/dist/lib/daemon/testdata/process-view-start.js +5 -2
  81. package/dist/lib/daemon/usage-sync-service.d.ts +21 -30
  82. package/dist/lib/daemon/usage-sync-service.js +46 -78
  83. package/dist/lib/daemon-health.d.ts +22 -5
  84. package/dist/lib/daemon-health.js +47 -3
  85. package/dist/lib/daemon-services.d.ts +1 -1
  86. package/dist/lib/daemon-services.js +0 -10
  87. package/dist/lib/daemon-ticks.js +4 -2
  88. package/dist/lib/devices/doctor-findings.js +8 -11
  89. package/dist/lib/devices/harness-inventory.js +14 -5
  90. package/dist/lib/exec-account-home.js +5 -0
  91. package/dist/lib/exec.js +0 -11
  92. package/dist/lib/feed/events.js +0 -1
  93. package/dist/lib/feed/tool-activity.js +5 -2
  94. package/dist/lib/fleet-shared-state.d.ts +45 -0
  95. package/dist/lib/fleet-shared-state.js +78 -9
  96. package/dist/lib/fs-atomic.js +25 -0
  97. package/dist/lib/harness-auth-capabilities.js +0 -1
  98. package/dist/lib/helper-versions.js +1 -1
  99. package/dist/lib/hooks/cache.d.ts +7 -0
  100. package/dist/lib/hooks/cache.js +27 -2
  101. package/dist/lib/hooks/install.js +7 -1
  102. package/dist/lib/hosts/ready.d.ts +38 -8
  103. package/dist/lib/hosts/ready.js +62 -14
  104. package/dist/lib/installations/active-check.d.ts +27 -0
  105. package/dist/lib/installations/active-check.js +61 -0
  106. package/dist/lib/installations/migrate.js +6 -3
  107. package/dist/lib/installations/shims.js +9 -7
  108. package/dist/lib/installations/update.js +21 -15
  109. package/dist/lib/installations/versions.js +12 -1
  110. package/dist/lib/manifest.js +1 -1
  111. package/dist/lib/models.js +0 -2
  112. package/dist/lib/open-url.js +28 -54
  113. package/dist/lib/profiles.js +0 -2
  114. package/dist/lib/refresh.js +1 -1
  115. package/dist/lib/routine-readiness.d.ts +35 -0
  116. package/dist/lib/routine-readiness.js +74 -16
  117. package/dist/lib/rules/compile.js +6 -0
  118. package/dist/lib/sandbox.js +0 -2
  119. package/dist/lib/secrets-policy.d.ts +89 -11
  120. package/dist/lib/secrets-policy.js +175 -28
  121. package/dist/lib/session/db.d.ts +1 -1
  122. package/dist/lib/session/db.js +65 -18
  123. package/dist/lib/session/discover.js +1 -283
  124. package/dist/lib/session/mirror.d.ts +4 -3
  125. package/dist/lib/session/mirror.js +4 -3
  126. package/dist/lib/session/parse.d.ts +0 -2
  127. package/dist/lib/session/parse.js +1 -138
  128. package/dist/lib/session/throughput.d.ts +1 -3
  129. package/dist/lib/session/throughput.js +0 -20
  130. package/dist/lib/session/types.d.ts +2 -2
  131. package/dist/lib/session/types.js +2 -2
  132. package/dist/lib/sessions-client.d.ts +60 -1
  133. package/dist/lib/sessions-client.js +227 -4
  134. package/dist/lib/setup-tool-status.js +5 -10
  135. package/dist/lib/signin-badge.d.ts +3 -2
  136. package/dist/lib/signin-badge.js +6 -3
  137. package/dist/lib/smart-launch.js +4 -1
  138. package/dist/lib/staleness/detectors/commands.d.ts +1 -1
  139. package/dist/lib/staleness/detectors/hooks.d.ts +1 -1
  140. package/dist/lib/staleness/detectors/mcp.d.ts +1 -1
  141. package/dist/lib/staleness/detectors/permissions.d.ts +1 -1
  142. package/dist/lib/staleness/detectors/plugins.d.ts +1 -1
  143. package/dist/lib/staleness/detectors/rules.d.ts +1 -1
  144. package/dist/lib/staleness/detectors/skills.d.ts +1 -1
  145. package/dist/lib/staleness/detectors/subagents.d.ts +1 -1
  146. package/dist/lib/staleness/detectors/workflows.d.ts +1 -1
  147. package/dist/lib/staleness/writers/commands.d.ts +1 -1
  148. package/dist/lib/staleness/writers/hooks.d.ts +1 -1
  149. package/dist/lib/staleness/writers/mcp.d.ts +1 -1
  150. package/dist/lib/staleness/writers/permissions.d.ts +1 -1
  151. package/dist/lib/staleness/writers/plugins.d.ts +1 -1
  152. package/dist/lib/staleness/writers/rules.d.ts +1 -1
  153. package/dist/lib/staleness/writers/skills.d.ts +1 -1
  154. package/dist/lib/staleness/writers/sources.js +5 -0
  155. package/dist/lib/staleness/writers/subagents.d.ts +1 -1
  156. package/dist/lib/staleness/writers/workflows.d.ts +1 -1
  157. package/dist/lib/state.d.ts +6 -4
  158. package/dist/lib/state.js +7 -17
  159. package/dist/lib/teams/parsers.d.ts +1 -1
  160. package/dist/lib/teams/parsers.js +1 -136
  161. package/dist/lib/teams/placement-probe.js +50 -15
  162. package/dist/lib/teams/scheduler.d.ts +7 -0
  163. package/dist/lib/types.d.ts +10 -1
  164. package/dist/lib/types.js +1 -1
  165. package/dist/lib/view-types.d.ts +32 -0
  166. package/dist/lib/watchdog/read.js +0 -1
  167. package/dist/lib/watchdog/rotate.js +1 -2
  168. package/dist/lib/watchdog/runner.js +2 -2
  169. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  170. package/dist/lib/watchdog/watchdogTail.js +0 -31
  171. package/package.json +1 -1
  172. package/scripts/postinstall.js +8 -8
  173. package/dist/browser.d.ts +0 -2
  174. package/dist/browser.js +0 -17
  175. package/dist/commands/browser-picker.d.ts +0 -18
  176. package/dist/commands/browser-picker.js +0 -97
  177. package/dist/lib/browser/arc-discovery.d.ts +0 -52
  178. package/dist/lib/browser/arc-discovery.js +0 -197
  179. package/dist/lib/browser/arc-dom.d.ts +0 -14
  180. package/dist/lib/browser/arc-dom.js +0 -121
  181. package/dist/lib/browser/caller-identity.d.ts +0 -40
  182. package/dist/lib/browser/caller-identity.js +0 -175
  183. package/dist/lib/browser/cdp.d.ts +0 -51
  184. package/dist/lib/browser/cdp.js +0 -254
  185. package/dist/lib/browser/chrome.d.ts +0 -141
  186. package/dist/lib/browser/chrome.js +0 -704
  187. package/dist/lib/browser/chromium-discovery.d.ts +0 -28
  188. package/dist/lib/browser/chromium-discovery.js +0 -96
  189. package/dist/lib/browser/devices.d.ts +0 -34
  190. package/dist/lib/browser/devices.js +0 -61
  191. package/dist/lib/browser/domain-skills.d.ts +0 -71
  192. package/dist/lib/browser/domain-skills.js +0 -195
  193. package/dist/lib/browser/drivers/arc.d.ts +0 -40
  194. package/dist/lib/browser/drivers/arc.js +0 -276
  195. package/dist/lib/browser/drivers/firefox.d.ts +0 -99
  196. package/dist/lib/browser/drivers/firefox.js +0 -377
  197. package/dist/lib/browser/drivers/local.d.ts +0 -55
  198. package/dist/lib/browser/drivers/local.js +0 -266
  199. package/dist/lib/browser/drivers/ssh.d.ts +0 -120
  200. package/dist/lib/browser/drivers/ssh.js +0 -467
  201. package/dist/lib/browser/editor.d.ts +0 -3
  202. package/dist/lib/browser/editor.js +0 -50
  203. package/dist/lib/browser/ffmpeg.d.ts +0 -12
  204. package/dist/lib/browser/ffmpeg.js +0 -184
  205. package/dist/lib/browser/firefox-discovery.d.ts +0 -69
  206. package/dist/lib/browser/firefox-discovery.js +0 -162
  207. package/dist/lib/browser/har.d.ts +0 -85
  208. package/dist/lib/browser/har.js +0 -77
  209. package/dist/lib/browser/hygiene.d.ts +0 -97
  210. package/dist/lib/browser/hygiene.js +0 -153
  211. package/dist/lib/browser/index.d.ts +0 -5
  212. package/dist/lib/browser/index.js +0 -5
  213. package/dist/lib/browser/input.d.ts +0 -7
  214. package/dist/lib/browser/input.js +0 -92
  215. package/dist/lib/browser/ipc.d.ts +0 -204
  216. package/dist/lib/browser/ipc.js +0 -1453
  217. package/dist/lib/browser/login-detection.d.ts +0 -87
  218. package/dist/lib/browser/login-detection.js +0 -274
  219. package/dist/lib/browser/profiles.d.ts +0 -422
  220. package/dist/lib/browser/profiles.js +0 -1157
  221. package/dist/lib/browser/refs.d.ts +0 -89
  222. package/dist/lib/browser/refs.js +0 -191
  223. package/dist/lib/browser/registry.d.ts +0 -70
  224. package/dist/lib/browser/registry.js +0 -252
  225. package/dist/lib/browser/remote-control.d.ts +0 -59
  226. package/dist/lib/browser/remote-control.js +0 -85
  227. package/dist/lib/browser/resolve-target.d.ts +0 -66
  228. package/dist/lib/browser/resolve-target.js +0 -264
  229. package/dist/lib/browser/runtime-state.d.ts +0 -291
  230. package/dist/lib/browser/runtime-state.js +0 -584
  231. package/dist/lib/browser/secret-ref.d.ts +0 -10
  232. package/dist/lib/browser/secret-ref.js +0 -14
  233. package/dist/lib/browser/service.d.ts +0 -812
  234. package/dist/lib/browser/service.js +0 -4693
  235. package/dist/lib/browser/stream.d.ts +0 -17
  236. package/dist/lib/browser/stream.js +0 -72
  237. package/dist/lib/browser/task-index.d.ts +0 -78
  238. package/dist/lib/browser/task-index.js +0 -199
  239. package/dist/lib/browser/types.d.ts +0 -647
  240. package/dist/lib/browser/types.js +0 -61
  241. package/dist/lib/browser/upload.d.ts +0 -29
  242. package/dist/lib/browser/upload.js +0 -298
  243. package/dist/lib/daemon/browser-ipc-service.d.ts +0 -20
  244. package/dist/lib/daemon/browser-ipc-service.js +0 -46
  245. package/dist/lib/daemon/browser-task-reap-service.d.ts +0 -14
  246. package/dist/lib/daemon/browser-task-reap-service.js +0 -26
  247. package/dist/lib/fleet-shared-repo-sync.d.ts +0 -55
  248. package/dist/lib/fleet-shared-repo-sync.js +0 -490
@@ -1,11 +1,17 @@
1
1
  /**
2
- * Non-secret daemon state exchanged through the already fleet-synced user repo.
2
+ * Non-secret daemon state exchanged over SSH between fleet devices (PHNX-4116).
3
3
  *
4
- * Each device owns exactly one file under `~/.agents/devices/<device>/`, so
5
- * normal `agents repo push/pull user` merges the fleet without devices dialing
6
- * one another on every daemon tick. OAuth/setup-token values never belong here:
7
- * auth publishes only a readiness verdict; the existing encrypted SSH transport
8
- * remains the one path that may carry secret material.
4
+ * Each device owns exactly one file under `~/.agents/devices/<device>/`
5
+ * (`daemon-state.json`, UNTRACKED in the user repo). A device writes its OWN
6
+ * file locally; a PEER's file on this box is written only by the usage-sync
7
+ * exchange — a headed daemon dials each peer with `agents __usage-ingest
8
+ * --reply`, sending its own envelope on stdin and writing the peer's reply
9
+ * envelope here stamped with `receivedAt`. The envelope used to ride the user
10
+ * repo as a tracked file, which turned the shared store into 18k `chore(devices)`
11
+ * commits and wedged every clone behind a `git fetch` that timed out; git now
12
+ * carries only human-authored resources. OAuth/setup-token values never belong
13
+ * here: auth publishes only a readiness verdict; the existing encrypted SSH
14
+ * bundle push remains the one path that may carry secret material.
9
15
  */
10
16
  import * as fs from 'node:fs';
11
17
  import * as fsp from 'node:fs/promises';
@@ -23,11 +29,19 @@ export function fleetSharedStatePath(device, userAgentsDir = getUserAgentsDir())
23
29
  function isRecord(value) {
24
30
  return !!value && typeof value === 'object' && !Array.isArray(value);
25
31
  }
26
- function parseFleetSharedDeviceState(raw, owner) {
27
- const parsed = JSON.parse(raw);
28
- if (!isRecord(parsed) || parsed.version !== FLEET_SHARED_STATE_VERSION || parsed.device !== owner) {
32
+ /**
33
+ * Validate an already-parsed envelope. `owner` pins the `device` field when the
34
+ * caller knows whose envelope this must be (a file under `devices/<owner>/`);
35
+ * an envelope arriving over the exchange names its own owner, so the caller
36
+ * passes none and checks `device` against the dialed peer afterwards.
37
+ */
38
+ export function parseFleetSharedDeviceStateEnvelope(parsed, owner) {
39
+ if (!isRecord(parsed) || parsed.version !== FLEET_SHARED_STATE_VERSION || typeof parsed.device !== 'string' || !parsed.device) {
29
40
  throw new Error('unrecognized shared-state envelope');
30
41
  }
42
+ if (owner !== undefined && parsed.device !== owner)
43
+ throw new Error('unrecognized shared-state envelope');
44
+ assertValidDeviceName(parsed.device);
31
45
  const usage = parsed.usage;
32
46
  if (usage !== undefined && (!isRecord(usage) || !isRecord(usage.rows))) {
33
47
  throw new Error('unrecognized usage snapshot');
@@ -41,8 +55,18 @@ function parseFleetSharedDeviceState(raw, owner) {
41
55
  if (sessions !== undefined && (!isRecord(sessions) || !Array.isArray(sessions.rows))) {
42
56
  throw new Error('unrecognized session mirror');
43
57
  }
58
+ const accounts = parsed.accounts;
59
+ if (accounts !== undefined && (!isRecord(accounts) || !Array.isArray(accounts.rows))) {
60
+ throw new Error('unrecognized account verdict rows');
61
+ }
62
+ if (parsed.receivedAt !== undefined && (typeof parsed.receivedAt !== 'number' || !Number.isFinite(parsed.receivedAt))) {
63
+ throw new Error('unrecognized receivedAt');
64
+ }
44
65
  return parsed;
45
66
  }
67
+ function parseFleetSharedDeviceState(raw, owner) {
68
+ return parseFleetSharedDeviceStateEnvelope(JSON.parse(raw), owner);
69
+ }
46
70
  /**
47
71
  * Merge one daemon-owned field into this device's shared file under a real
48
72
  * inter-process lock. Stable serialization avoids dirtying the user repo when
@@ -63,6 +87,8 @@ function mergeFleetState(currentRaw, device, patch) {
63
87
  ...(patch.usage !== undefined ? { usage: patch.usage } : {}),
64
88
  ...(patch.auth !== undefined ? { auth: patch.auth } : {}),
65
89
  ...(patch.sessions !== undefined ? { sessions: patch.sessions } : {}),
90
+ ...(patch.accounts !== undefined ? { accounts: patch.accounts } : {}),
91
+ ...(patch.receivedAt !== undefined ? { receivedAt: patch.receivedAt } : {}),
66
92
  version: FLEET_SHARED_STATE_VERSION,
67
93
  device,
68
94
  };
@@ -138,3 +164,46 @@ export function readFleetSharedDeviceStates(userAgentsDir = getUserAgentsDir())
138
164
  }
139
165
  return { states, errors };
140
166
  }
167
+ /**
168
+ * This device's own envelope as it stands on disk — what the usage-sync exchange
169
+ * sends to peers and what `__usage-ingest --reply` prints back. A device that has
170
+ * never published anything yields the bare `{version, device}` envelope, so a
171
+ * peer still learns the device exists and answered.
172
+ */
173
+ export function readOwnFleetSharedDeviceState(device, userAgentsDir = getUserAgentsDir()) {
174
+ const file = fleetSharedStatePath(device, userAgentsDir);
175
+ let raw = '';
176
+ try {
177
+ raw = fs.readFileSync(file, 'utf-8');
178
+ }
179
+ catch { /* never published → bare envelope */ }
180
+ if (!raw.trim())
181
+ return { version: FLEET_SHARED_STATE_VERSION, device };
182
+ const state = parseFleetSharedDeviceState(raw, device);
183
+ // `receivedAt` is a receiver-side stamp; the owner never advertises one.
184
+ const { receivedAt: _receivedAt, ...own } = state;
185
+ return own;
186
+ }
187
+ /**
188
+ * Store a PEER's envelope, received over the SSH exchange, in that peer's file on
189
+ * this box, stamped with `receivedAt`. Merges field-by-field onto whatever the
190
+ * file already holds, so a partial envelope (a placement probe sends usage only)
191
+ * refreshes that field without erasing the peer's last auth verdict, session
192
+ * digests, or account rows. The five readers of `devices/<peer>/daemon-state.json`
193
+ * (usage merge, poller claims, auth verdicts, session mirror, account catalog)
194
+ * are unchanged by the transport swap. Async because it runs on the daemon's
195
+ * usage-sync tick once per peer reply: the sync lock would `sleepSync` the event
196
+ * loop for up to 30 s under contention (see {@link updateFleetSharedDeviceStateAsync}).
197
+ */
198
+ export async function storePeerFleetSharedDeviceState(state, userAgentsDir = getUserAgentsDir(), receivedAt = Date.now()) {
199
+ const patch = { receivedAt };
200
+ if (state.usage !== undefined)
201
+ patch.usage = state.usage;
202
+ if (state.auth !== undefined)
203
+ patch.auth = state.auth;
204
+ if (state.sessions !== undefined)
205
+ patch.sessions = state.sessions;
206
+ if (state.accounts !== undefined)
207
+ patch.accounts = state.accounts;
208
+ return updateFleetSharedDeviceStateAsync(state.device, patch, userAgentsDir);
209
+ }
@@ -106,6 +106,21 @@ export function withFileLock(filePath, fn, opts = {}) {
106
106
  }
107
107
  catch (err) {
108
108
  lastError = err;
109
+ // proper-lockfile breaks stale locks with rmdir, which fails with ENOTDIR
110
+ // when a prior crash left a regular file instead of the expected directory.
111
+ // Remove the stale file so the next attempt can succeed. Match
112
+ // proper-lockfile's own path resolution: realpath when the option is on
113
+ // (the default), raw path when it is off.
114
+ if (err instanceof Error && err.code === 'ENOTDIR') {
115
+ try {
116
+ const base = (opts.realpath ?? true) ? fs.realpathSync(filePath) : filePath;
117
+ const lockPath = `${base}.lock`;
118
+ const st = fs.statSync(lockPath);
119
+ if (st.isFile())
120
+ fs.unlinkSync(lockPath);
121
+ }
122
+ catch { /* best effort */ }
123
+ }
109
124
  if (Date.now() >= deadline)
110
125
  break;
111
126
  const backoff = Math.min(LOCK_RETRY_MIN_MS * (attempt + 1), LOCK_RETRY_MAX_MS);
@@ -175,6 +190,16 @@ export async function withFileLockAsync(filePath, fn, opts = {}) {
175
190
  }
176
191
  catch (err) {
177
192
  lastError = err;
193
+ if (err instanceof Error && err.code === 'ENOTDIR') {
194
+ try {
195
+ const base = (opts.realpath ?? true) ? fs.realpathSync(filePath) : filePath;
196
+ const lockPath = `${base}.lock`;
197
+ const st = fs.statSync(lockPath);
198
+ if (st.isFile())
199
+ fs.unlinkSync(lockPath);
200
+ }
201
+ catch { /* best effort */ }
202
+ }
178
203
  if (Date.now() >= deadline)
179
204
  break;
180
205
  const backoff = Math.min(LOCK_RETRY_MIN_MS * (attempt + 1), LOCK_RETRY_MAX_MS);
@@ -17,7 +17,6 @@ export const HARNESS_AUTH = {
17
17
  kimi: { login: null, status: null, identity: 'opaque', worker: 'none', slotEnv: 'KIMI_CODE_HOME' },
18
18
  antigravity: { login: null, status: null, identity: 'opaque', worker: 'none', slotEnv: null },
19
19
  droid: { login: null, status: null, identity: 'opaque', worker: 'api-key:FACTORY_API_KEY', slotEnv: null },
20
- gemini: { login: null, status: null, identity: 'email', worker: 'none', slotEnv: null },
21
20
  copilot: { login: null, status: null, identity: 'opaque', worker: 'none', slotEnv: 'COPILOT_HOME' },
22
21
  openclaw: { login: null, status: null, identity: 'opaque', worker: 'none', slotEnv: null },
23
22
  amp: { login: null, status: null, identity: 'opaque', worker: 'none', slotEnv: null },
@@ -41,7 +41,7 @@
41
41
  * it would claim a distribution responsibility agents-cli no longer has.
42
42
  */
43
43
  export const HELPER_RELEASES = {
44
- menubar: { tagPrefix: 'menubar', floor: '1.5.0' },
44
+ menubar: { tagPrefix: 'menubar', floor: '1.6.1' },
45
45
  };
46
46
  /** The release tag for one helper at one version, e.g. `menubar/v1.0.0`. */
47
47
  export function helperTag(helper, version) {
@@ -45,6 +45,13 @@ export declare function generateHookShim(args: {
45
45
  cache?: HookCacheConfig | null;
46
46
  matches?: HookMatches;
47
47
  paths?: HookShimPaths;
48
+ /**
49
+ * Deny the tool call (exit 2) when the source script is missing instead of
50
+ * failing with 127, which every harness reads as "allow". Set for
51
+ * PreToolUse hooks: a guard whose script vanished (version pruned, sync
52
+ * rewriting the hooks dir) must not silently wave tool calls through.
53
+ */
54
+ failClosed?: boolean;
48
55
  }): string;
49
56
  /**
50
57
  * Remove a hook's shim. Called by the registrar's garbage collection when a
@@ -119,7 +119,7 @@ export function generateHookShim(args) {
119
119
  const logsDir = args.paths?.logsDir ?? getLogsDir();
120
120
  const perfDir = args.paths?.perfDir ?? getPerfDir();
121
121
  const shimPath = resolveContainedHookShimPath(shimsDir, args.name);
122
- const content = renderShim(args.name, args.scriptPath, args.cache ?? null, args.matches, { cacheDir, logsDir, perfDir });
122
+ const content = renderShim(args.name, args.scriptPath, args.cache ?? null, args.matches, { cacheDir, logsDir, perfDir }, args.failClosed === true);
123
123
  fs.mkdirSync(shimsDir, { recursive: true });
124
124
  let existing = null;
125
125
  if (fs.existsSync(shimPath)) {
@@ -363,7 +363,7 @@ exit "$EXIT"`;
363
363
  * a gate-only pass-through: it forwards stdin to the script and its stdout back,
364
364
  * with no cache read/write.
365
365
  */
366
- function renderShim(name, scriptPath, cache, matches, paths) {
366
+ function renderShim(name, scriptPath, cache, matches, paths, failClosed = false) {
367
367
  const ttl = cache ? (typeof cache.ttl === 'number' ? cache.ttl : (parseDuration(cache.ttl) ?? 0)) : 0;
368
368
  const key = cache?.key ?? 'global';
369
369
  const prefetch = cache?.prefetch ?? 'none';
@@ -394,6 +394,7 @@ TTL=${ttl}
394
394
  PREFETCH=${q(prefetch)}
395
395
  KEY_MODE=${q(key)}
396
396
  MATCHES_JSON=${q(matchesJson)}
397
+ FAIL_CLOSED=${failClosed ? 1 : 0}
397
398
 
398
399
  mkdir -p "$CACHE_DIR" "$LOGS_DIR" "$PERF_DIR"
399
400
 
@@ -458,6 +459,30 @@ if [ -n "$MATCHES_JSON" ]; then
458
459
  exit 0
459
460
  fi
460
461
  fi
462
+ # A source that is not on disk cannot be executed: bash exits 127, and the
463
+ # harnesses that share the exit-2 deny contract (Claude Code, Codex, Droid)
464
+ # read anything else as "allow" -- a guard silently disabled. Deny instead and
465
+ # name the repair, but only for a PreToolUse firing that the matches: gate
466
+ # above let through: one shim serves every event a hook declares, so a
467
+ # SessionStart or Stop leg of the same hook must not wedge a session, and a
468
+ # fire the predicates would have skipped must not turn into a denial. Other
469
+ # shims keep their existing path (a cached hook still serves its cache).
470
+ if [ "$FAIL_CLOSED" = 1 ] && [ ! -f "$SOURCE" ]; then
471
+ _EVT="$(printf '%s' "$STDIN_PAYLOAD" | "$PY" -c 'import json, sys
472
+ try:
473
+ d = json.load(sys.stdin)
474
+ except Exception:
475
+ d = {}
476
+ print(d.get("hook_event_name") or d.get("hookEventName") or "")' 2>/dev/null || true)"
477
+ if [ -z "$_EVT" ] || [ "$_EVT" = PreToolUse ]; then
478
+ _TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
479
+ printf '{"ts":"%s","event":"hook.fire","hook":"%s","ms":0,"cache":"missing-source","exit":2}\\n' \\
480
+ "$_TS" "$HOOK_NAME" >>"$LOGS_DIR/events-$(date -u +%Y-%m-%d).jsonl" 2>/dev/null || true
481
+ printf '%s: hook source is missing (%s); refusing the tool call unchecked (fail-closed). Run: agents hooks sync\\n' \\
482
+ "$HOOK_NAME" "$SOURCE" >&2
483
+ exit 2
484
+ fi
485
+ fi
461
486
  ${cache ? CACHE_TAIL : PASSTHROUGH_TAIL}
462
487
  `;
463
488
  }
@@ -363,7 +363,13 @@ function resolveHookCommand(name, hookDef, resolveScript) {
363
363
  // script when predicates don't hold) and, when `cache:` is set, layers the
364
364
  // cache/timing machinery on top; with neither, it is a pure pass-through
365
365
  // timing wrapper.
366
- return toPortableCommand(generateHookShim({ name, scriptPath, cache, matches }));
366
+ return toPortableCommand(generateHookShim({
367
+ name,
368
+ scriptPath,
369
+ cache,
370
+ matches,
371
+ failClosed: hookDef.events.includes('PreToolUse'),
372
+ }));
367
373
  }
368
374
  /**
369
375
  * Extensions that are NEVER hooks — docs, configuration, plain data. A file
@@ -57,8 +57,19 @@ export interface ReadyProbe {
57
57
  * `$LASTEXITCODE` (no `printf`/`||`); `parseReadyProbe` keys off the sentinel
58
58
  * substring, so the missing leading newline vs the POSIX `printf '\n…'` form is
59
59
  * absorbed by its `.trim()`. Pure/exported so both branches are unit-testable.
60
+ *
61
+ * With `ingestUsage`, the probe first runs `agents __usage-ingest` on the
62
+ * dispatcher's daemon-state envelope, which the caller pipes to stdin
63
+ * (PHNX-4116): the chosen worker then holds the dispatcher's current usage
64
+ * numbers at dispatch instead of waiting for the next 15-minute usage-sync
65
+ * tick. The ingest writes nothing to stdout (only `--reply` does), so the
66
+ * version/sentinel/listing shape `parseReadyProbe` reads is unchanged. On
67
+ * Windows the `agents.ps1` shim drops ssh-piped stdin, so PowerShell reads it
68
+ * into a temp file and hands the verb `--from <path>`.
60
69
  */
61
- export declare function buildReadyProbeCommand(os?: string): string;
70
+ export declare function buildReadyProbeCommand(os?: string, opts?: {
71
+ ingestUsage?: boolean;
72
+ }): string;
62
73
  export declare function readyProbe(target: string, os?: string, extraSshArgs?: string[]): ReadyProbe;
63
74
  /** Pure parser for `readyProbe` output (unit-tested without ssh). */
64
75
  export declare function parseReadyProbe(stdout: string): ReadyProbe;
@@ -92,6 +103,12 @@ interface ViewAgentAccountEligibility {
92
103
  signedIn: boolean | undefined;
93
104
  /** At least one account can launch immediately or enter the harness login flow. */
94
105
  pickerEligible: boolean | undefined;
106
+ /**
107
+ * The remote box's own aggregate exclusion reason when it is not signed in
108
+ * (`runReady.reason` — e.g. `all signed_out`), for the operator-facing
109
+ * placement error. Undefined when ready, absent, or on an older remote CLI.
110
+ */
111
+ reason?: string;
95
112
  }
96
113
  /**
97
114
  * Read the two account gates automatic placement needs from `agents view
@@ -99,13 +116,26 @@ interface ViewAgentAccountEligibility {
99
116
  * the login flow, but it must not route to a device whose every signed-in
100
117
  * account is throttled.
101
118
  *
102
- * The sign-in gate reads the per-version `launchable` field — the strict
103
- * per-version launch truth (`isLaunchableSignedIn`) — so a remote box is judged
104
- * by the SAME launchability the local candidate uses (`collectRunCandidates` →
105
- * `isLaunchableSignedIn`), not the display `signedIn` that inherits the
106
- * active/global HOME login and passes a box that dies at spawn (PHNX-3466). An
107
- * older remote CLI omits `launchable`, so it falls back to `signedIn` — the
108
- * pre-fix behavior, so a rolling fleet does not regress.
119
+ * ONE readiness gate, computed on the box that runs (PHNX-4116). The remote box
120
+ * publishes `runReady` — the router's own `collectRunCandidates` →
121
+ * `readinessFromCandidate` verdict over its native slots AND version homes — so
122
+ * this reads that answer rather than re-deriving freshness here. The
123
+ * dispatching box re-deriving from the per-version `versions[]` list was the bug:
124
+ * that list enumerates version homes, misses the account slots a run picks from,
125
+ * and applied a 40-minute usage-freshness refusal a synced-only worker could
126
+ * never satisfy, turning a usage-sync lag into "no ready device" while
127
+ * `--device <name>` on the same box launched fine.
128
+ *
129
+ * An older remote CLI omits `runReady`; the one-release fallback derives the
130
+ * verdict from the per-version list the same way the pre-PHNX-4116 dispatcher
131
+ * did: the strict `launchable` signal (`isLaunchableSignedIn`, PHNX-3466) for
132
+ * sign-in, MINUS a FRESH throttle (`rate_limited`/`out_of_credits` captured
133
+ * within {@link USAGE_STALE_REFUSAL_MAX_AGE_MS}) or a FRESH dead auth verdict
134
+ * (checked within {@link AUTH_PROBE_MAX_AGE_MS}, {@link isDeadVerdict}). A stale
135
+ * reading is UNVERIFIED, not disqualifying (PHNX-4116/#3700), so a usage-sync
136
+ * lag never bars an old-CLI worker `--device <name>` would launch fine — but a
137
+ * throttled-but-launchable worker no longer reads `signedIn: true` and slips
138
+ * into `--device auto`'s pick during a rolling upgrade.
109
139
  */
110
140
  export declare function viewAgentAccountEligibility(view: string, agent: string, now?: number): ViewAgentAccountEligibility;
111
141
  export declare function viewAgentSignedIn(view: string, agent: string): boolean | undefined;
@@ -102,15 +102,31 @@ const READY_MARKER = '@@AGENTS_READY@@';
102
102
  * `$LASTEXITCODE` (no `printf`/`||`); `parseReadyProbe` keys off the sentinel
103
103
  * substring, so the missing leading newline vs the POSIX `printf '\n…'` form is
104
104
  * absorbed by its `.trim()`. Pure/exported so both branches are unit-testable.
105
+ *
106
+ * With `ingestUsage`, the probe first runs `agents __usage-ingest` on the
107
+ * dispatcher's daemon-state envelope, which the caller pipes to stdin
108
+ * (PHNX-4116): the chosen worker then holds the dispatcher's current usage
109
+ * numbers at dispatch instead of waiting for the next 15-minute usage-sync
110
+ * tick. The ingest writes nothing to stdout (only `--reply` does), so the
111
+ * version/sentinel/listing shape `parseReadyProbe` reads is unchanged. On
112
+ * Windows the `agents.ps1` shim drops ssh-piped stdin, so PowerShell reads it
113
+ * into a temp file and hands the verb `--from <path>`.
105
114
  */
106
- export function buildReadyProbeCommand(os) {
115
+ export function buildReadyProbeCommand(os, opts = {}) {
107
116
  if (remoteShellFor(os) === 'powershell') {
108
- const script = `${POWERSHELL_PROGRESS_SILENCE}; ` +
117
+ const ingest = opts.ingestUsage
118
+ ? '$in = [Console]::In.ReadToEnd(); $tmp = $null; ' +
119
+ 'try { $tmp = [System.IO.Path]::GetTempFileName(); [System.IO.File]::WriteAllText($tmp, $in); ' +
120
+ 'agents __usage-ingest --from $tmp 2>$null } ' +
121
+ 'finally { if ($tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } }; '
122
+ : '';
123
+ const script = `${POWERSHELL_PROGRESS_SILENCE}; ${ingest}` +
109
124
  `agents --version 2>$null; Write-Output "${READY_MARKER}"; ` +
110
125
  `agents view --json 2>$null; if ($LASTEXITCODE -ne 0) { agents list 2>$null }`;
111
126
  return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
112
127
  }
113
- const script = `agents --version 2>/dev/null; printf '\\n${READY_MARKER}\\n'; ` +
128
+ const script = `${opts.ingestUsage ? 'agents __usage-ingest 2>/dev/null; ' : ''}` +
129
+ `agents --version 2>/dev/null; printf '\\n${READY_MARKER}\\n'; ` +
114
130
  `agents view --json 2>/dev/null || agents list 2>/dev/null`;
115
131
  return `bash -lc ${shellQuote(script)}`;
116
132
  }
@@ -207,13 +223,26 @@ export function missingPinnedVersionMessage(hostName, agent, version, installed)
207
223
  * the login flow, but it must not route to a device whose every signed-in
208
224
  * account is throttled.
209
225
  *
210
- * The sign-in gate reads the per-version `launchable` field — the strict
211
- * per-version launch truth (`isLaunchableSignedIn`) — so a remote box is judged
212
- * by the SAME launchability the local candidate uses (`collectRunCandidates` →
213
- * `isLaunchableSignedIn`), not the display `signedIn` that inherits the
214
- * active/global HOME login and passes a box that dies at spawn (PHNX-3466). An
215
- * older remote CLI omits `launchable`, so it falls back to `signedIn` — the
216
- * pre-fix behavior, so a rolling fleet does not regress.
226
+ * ONE readiness gate, computed on the box that runs (PHNX-4116). The remote box
227
+ * publishes `runReady` — the router's own `collectRunCandidates` →
228
+ * `readinessFromCandidate` verdict over its native slots AND version homes — so
229
+ * this reads that answer rather than re-deriving freshness here. The
230
+ * dispatching box re-deriving from the per-version `versions[]` list was the bug:
231
+ * that list enumerates version homes, misses the account slots a run picks from,
232
+ * and applied a 40-minute usage-freshness refusal a synced-only worker could
233
+ * never satisfy, turning a usage-sync lag into "no ready device" while
234
+ * `--device <name>` on the same box launched fine.
235
+ *
236
+ * An older remote CLI omits `runReady`; the one-release fallback derives the
237
+ * verdict from the per-version list the same way the pre-PHNX-4116 dispatcher
238
+ * did: the strict `launchable` signal (`isLaunchableSignedIn`, PHNX-3466) for
239
+ * sign-in, MINUS a FRESH throttle (`rate_limited`/`out_of_credits` captured
240
+ * within {@link USAGE_STALE_REFUSAL_MAX_AGE_MS}) or a FRESH dead auth verdict
241
+ * (checked within {@link AUTH_PROBE_MAX_AGE_MS}, {@link isDeadVerdict}). A stale
242
+ * reading is UNVERIFIED, not disqualifying (PHNX-4116/#3700), so a usage-sync
243
+ * lag never bars an old-CLI worker `--device <name>` would launch fine — but a
244
+ * throttled-but-launchable worker no longer reads `signedIn: true` and slips
245
+ * into `--device auto`'s pick during a rolling upgrade.
217
246
  */
218
247
  export function viewAgentAccountEligibility(view, agent, now = Date.now()) {
219
248
  try {
@@ -221,11 +250,25 @@ export function viewAgentAccountEligibility(view, agent, now = Date.now()) {
221
250
  const row = rows.find((candidate) => candidate.agent?.toLowerCase() === agent.toLowerCase());
222
251
  if (!row)
223
252
  return { signedIn: undefined, pickerEligible: undefined };
253
+ const runReady = row.runReady;
254
+ if (runReady && typeof runReady.ready === 'boolean') {
255
+ // A signed-out / revoked account is picker-eligible even when not ready —
256
+ // launching it IS the login flow. A throttled account is not.
257
+ const accounts = runReady.accounts ?? [];
258
+ const pickerEligible = runReady.ready
259
+ || accounts.some((a) => a.reason === 'signed_out' || a.reason === 'revoked');
260
+ return {
261
+ signedIn: runReady.ready,
262
+ pickerEligible,
263
+ reason: runReady.ready ? undefined : runReady.reason,
264
+ };
265
+ }
266
+ // One-release fallback for an older remote CLI without `runReady`.
224
267
  const verdicts = (row.versions ?? []).flatMap((version) => {
225
268
  if (typeof version.signedIn !== 'boolean')
226
269
  return [];
227
270
  // Prefer the strict per-version launch signal; fall back to the display
228
- // `signedIn` for an older remote CLI that does not emit `launchable`.
271
+ // `signedIn` for a CLI old enough to omit `launchable` too.
229
272
  const launchable = typeof version.launchable === 'boolean' ? version.launchable : version.signedIn;
230
273
  const usageCapturedAt = version.usageCapturedAt ? Date.parse(version.usageCapturedAt) : Number.NaN;
231
274
  const usageFresh = Number.isFinite(usageCapturedAt)
@@ -240,9 +283,14 @@ export function viewAgentAccountEligibility(view, agent, now = Date.now()) {
240
283
  && version.authVerdict !== undefined
241
284
  && authFresh
242
285
  && isDeadVerdict(version.authVerdict);
243
- const freshnessKnown = version.usageCapturedAt !== undefined || version.authCheckedAt !== undefined;
244
- const fresh = !freshnessKnown || (usageFresh && authFresh);
245
- const ready = launchable && fresh && !authBlocked && !throttled;
286
+ // A stale usage/auth reading makes the account UNVERIFIED, not unusable:
287
+ // the number is ignored (`throttled`/`authBlocked` only trust a fresh one)
288
+ // and the remote `agents run` weights an unverified account at the floor.
289
+ // Refusing the whole device on a stale reading turned a fleet-wide
290
+ // usage-sync lag into "no healthy device can run claude" while
291
+ // `--device <name>` on the same box launched fine (PHNX-4116/#3700). Only a
292
+ // FRESH throttle or a FRESH dead auth verdict disqualifies.
293
+ const ready = launchable && !authBlocked && !throttled;
246
294
  return [{ ready, pickerEligible: ready || !launchable || authBlocked }];
247
295
  });
248
296
  if (verdicts.length === 0)
@@ -25,6 +25,14 @@ import type { Installation } from './types.js';
25
25
  */
26
26
  export interface ProcessSnapshot {
27
27
  listCommandLines(): Promise<string[]>;
28
+ /** Optional richer listing (pid, elapsed, tty) for naming a blocking process. */
29
+ listProcessRows?(): Promise<ProcessRow[]>;
30
+ }
31
+ export interface ProcessRow {
32
+ pid?: number;
33
+ elapsed?: string;
34
+ tty?: string;
35
+ args: string;
28
36
  }
29
37
  export declare const realProcessSnapshot: ProcessSnapshot;
30
38
  /**
@@ -46,3 +54,22 @@ export declare function installationLooksActive(installation: Pick<Installation,
46
54
  * update to a harness this check simply failed to observe.
47
55
  */
48
56
  export declare function isInstallationLikelyActive(installation: Pick<Installation, 'agent' | 'label'>, snapshot?: ProcessSnapshot): Promise<boolean>;
57
+ export interface InstallationActivity {
58
+ active: boolean;
59
+ /** A launch lease is held (a launch is starting and may not be in the process table yet). */
60
+ lease: boolean;
61
+ /** Live processes naming this installation's directory, one line each. */
62
+ processes: string[];
63
+ /** The process scan failed; `active` is the fail-closed default. */
64
+ scanError?: string;
65
+ }
66
+ /**
67
+ * Same verdict as {@link isInstallationLikelyActive}, plus WHAT is holding the
68
+ * installation, so a refused update can name the session to finish instead of
69
+ * "in use; retry later" (PHNX-4116 follow-up: an operator on yosemite-s0 had a
70
+ * one-hour-old resumed session on another tty and no way to see it from the
71
+ * refusal).
72
+ */
73
+ export declare function describeInstallationActivity(installation: Pick<Installation, 'agent' | 'label'>, snapshot?: ProcessSnapshot): Promise<InstallationActivity>;
74
+ /** The one-line refusal an update prints for a busy installation. */
75
+ export declare function formatInUseDeferral(name: string, activity: InstallationActivity): string;
@@ -22,6 +22,16 @@ import { promisify } from 'util';
22
22
  import { getVersionDir } from './store.js';
23
23
  import { hasLiveLaunchLease } from './shims.js';
24
24
  const execFileAsync = promisify(execFile);
25
+ async function listProcessRowsPosix() {
26
+ const { stdout } = await execFileAsync('ps', ['-Ao', 'pid=,etime=,tty=,args='], {
27
+ timeout: 5_000,
28
+ maxBuffer: 16 * 1024 * 1024,
29
+ });
30
+ return stdout.split('\n').flatMap((line) => {
31
+ const m = /^\s*(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/.exec(line);
32
+ return m ? [{ pid: Number(m[1]), elapsed: m[2], tty: m[3], args: m[4] }] : [];
33
+ });
34
+ }
25
35
  async function listCommandLinesPosix() {
26
36
  try {
27
37
  const { stdout } = await execFileAsync('ps', ['-Ao', 'args'], {
@@ -49,6 +59,9 @@ async function listCommandLinesWindows() {
49
59
  }
50
60
  export const realProcessSnapshot = {
51
61
  listCommandLines: () => (process.platform === 'win32' ? listCommandLinesWindows() : listCommandLinesPosix()),
62
+ listProcessRows: async () => (process.platform === 'win32'
63
+ ? (await listCommandLinesWindows()).map((args) => ({ args }))
64
+ : listProcessRowsPosix()),
52
65
  };
53
66
  /**
54
67
  * Does any live process reference this installation's own directory? Matches
@@ -82,3 +95,51 @@ export async function isInstallationLikelyActive(installation, snapshot = realPr
82
95
  return true;
83
96
  }
84
97
  }
98
+ /** `ps` prints `?` (Linux), `??` (macOS) or `-` for a process with no controlling terminal. */
99
+ const NO_TTY_MARKERS = new Set(['?', '??', '-']);
100
+ function shortenArgs(args, versionDir) {
101
+ // Strip the long install path so the line reads as the command the user ran.
102
+ const trimmed = args.replace(versionDir, '…').replace(/\/node_modules\/\.bin\//, '/');
103
+ return trimmed.length > 110 ? `${trimmed.slice(0, 107)}...` : trimmed;
104
+ }
105
+ /**
106
+ * Same verdict as {@link isInstallationLikelyActive}, plus WHAT is holding the
107
+ * installation, so a refused update can name the session to finish instead of
108
+ * "in use; retry later" (PHNX-4116 follow-up: an operator on yosemite-s0 had a
109
+ * one-hour-old resumed session on another tty and no way to see it from the
110
+ * refusal).
111
+ */
112
+ export async function describeInstallationActivity(installation, snapshot = realProcessSnapshot) {
113
+ const lease = hasLiveLaunchLease(installation.agent, installation.label);
114
+ const versionDir = getVersionDir(installation.agent, installation.label);
115
+ try {
116
+ const rows = snapshot.listProcessRows
117
+ ? await snapshot.listProcessRows()
118
+ : (await snapshot.listCommandLines()).map((args) => ({ args }));
119
+ const processes = rows
120
+ .filter((row) => row.args.includes(versionDir))
121
+ .map((row) => {
122
+ const meta = [row.pid !== undefined ? `pid ${row.pid}` : null, row.elapsed ? `up ${row.elapsed}` : null, row.tty && !NO_TTY_MARKERS.has(row.tty) ? row.tty : null]
123
+ .filter((part) => part !== null)
124
+ .join(', ');
125
+ return meta ? `${meta}: ${shortenArgs(row.args, versionDir)}` : shortenArgs(row.args, versionDir);
126
+ });
127
+ return { active: lease || processes.length > 0, lease, processes };
128
+ }
129
+ catch (err) {
130
+ return { active: true, lease, processes: [], scanError: err instanceof Error ? err.message : String(err) };
131
+ }
132
+ }
133
+ /** The one-line refusal an update prints for a busy installation. */
134
+ export function formatInUseDeferral(name, activity) {
135
+ if (activity.scanError)
136
+ return `${name}: could not confirm nothing is running (${activity.scanError}); not updating.`;
137
+ if (activity.processes.length > 0) {
138
+ const list = activity.processes.length === 1
139
+ ? activity.processes[0]
140
+ : activity.processes.map((p) => `\n ${p}`).join('');
141
+ return `${name} is in use by ${activity.processes.length === 1 ? 'a running process' : `${activity.processes.length} running processes`} — ${list}. `
142
+ + 'Finish that session (agents sessions stop <id> for an agents session), then retry.';
143
+ }
144
+ return `${name} has a launch in flight; retry once it has started or exited.`;
145
+ }
@@ -17,10 +17,13 @@ import { enabledRoutineNames, replaceEnabledRoutines } from '../routine-activati
17
17
  import { evaluateActivationReadiness } from '../routine-readiness.js';
18
18
  import { migrateDeviceConfigStores } from '../devices/config-migration.js';
19
19
  import { detrackViaGitExclude } from '../project-resources.js';
20
- // Two constants only, never the read/write API — migrations still operate on raw
21
- // YAML so they never take the meta lock or prime the meta cache mid-migration.
22
- import { LEGACY_DEFAULT_BROWSER_PROFILE_NAME } from '../browser/profiles.js';
20
+ // Migrations operate on raw YAML so they never take the meta lock or prime the
21
+ // meta cache mid-migration.
23
22
  import { META_HEADER as DEVICE_META_HEADER } from '../state.js';
23
+ /** The pre-device-scope central browser profile name this one-shot migration
24
+ * folds into the device doc. Inlined (the in-repo browser engine that defined it
25
+ * was removed with PHNX-4101); the legacy on-disk name is unchanged. */
26
+ const LEGACY_DEFAULT_BROWSER_PROFILE_NAME = 'default';
24
27
  import { COMPILED_HEADER_PROJECT } from '../rules/compile.js';
25
28
  const HOME = process.env.HOME ?? os.homedir();
26
29
  const USER_DIR = path.join(HOME, '.agents');
@@ -2189,15 +2189,17 @@ export function listShimFileNames() {
2189
2189
  }
2190
2190
  /**
2191
2191
  * Legacy command shims that are wrong even when their baked install is alive.
2192
- * `secrets` (PHNX-3989), `sessions` (PHNX-4012) and `computer` (PHNX-4075): the
2193
- * shim `exec`s `agents <name>`, which is a passthrough to the STANDALONE binary —
2194
- * so the shim re-enters itself (the 1.22.85 fork bomb). Pruned unconditionally.
2192
+ * `secrets` (PHNX-3989), `sessions` (PHNX-4012), `computer` (PHNX-4075) and
2193
+ * `browser` (PHNX-4101): the shim `exec`s `agents <name>`, which is a passthrough
2194
+ * to the STANDALONE binary — so the shim re-enters itself (the 1.22.85 fork
2195
+ * bomb) and, sitting first on PATH, shadows the real `@phnx-labs/<name>-cli`
2196
+ * binary a user types directly. Pruned unconditionally.
2195
2197
  *
2196
- * `computer` is the sharpest of the three, because agents-cli USED to publish a
2197
- * `computer` bin of its own: a machine that installed an older CLI has a real
2198
- * shim on PATH under the name the standalone engine now owns.
2198
+ * `computer` and `browser` are the sharpest, because agents-cli USED to publish
2199
+ * (`computer`) or shim (`browser`) a bin of its own: a machine that installed an
2200
+ * older CLI has a real shim on PATH under the name the standalone engine now owns.
2199
2201
  */
2200
- const LEGACY_SHIMS_ALWAYS_PRUNED = new Set(['secrets', 'sessions', 'computer', 'pty']);
2202
+ const LEGACY_SHIMS_ALWAYS_PRUNED = new Set(['secrets', 'sessions', 'computer', 'pty', 'browser']);
2201
2203
  /**
2202
2204
  * Prune a stale, orphaned shim: one that is NOT a managed agent shim and NOT a user
2203
2205
  * alias, whose baked `AGENTS_BIN` points at an install that no longer exists. These