@phnx-labs/agents-cli 1.22.117 → 1.22.119

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 (271) hide show
  1. package/CHANGELOG.md +301 -0
  2. package/dist/bootstrap.js +4 -11
  3. package/dist/commands/accounts.js +7 -4
  4. package/dist/commands/browser.d.ts +52 -70
  5. package/dist/commands/browser.js +192 -3562
  6. package/dist/commands/daemon.d.ts +39 -2
  7. package/dist/commands/daemon.js +72 -73
  8. package/dist/commands/events.js +1 -1
  9. package/dist/commands/exec.js +53 -1
  10. package/dist/commands/projects.js +92 -7
  11. package/dist/commands/repo.js +13 -49
  12. package/dist/commands/route.js +5 -5
  13. package/dist/commands/routines.js +39 -12
  14. package/dist/commands/sessions-render.d.ts +1 -1
  15. package/dist/commands/sessions-render.js +0 -1
  16. package/dist/commands/sessions.test-fixture.d.ts +0 -1
  17. package/dist/commands/sessions.test-fixture.js +0 -37
  18. package/dist/commands/setup-browser.d.ts +17 -12
  19. package/dist/commands/setup-browser.js +52 -87
  20. package/dist/commands/setup-preferences.d.ts +0 -17
  21. package/dist/commands/setup-preferences.js +18 -49
  22. package/dist/commands/setup.js +9 -9
  23. package/dist/commands/ssh.js +10 -1
  24. package/dist/commands/sync.js +6 -44
  25. package/dist/commands/teams.js +0 -1
  26. package/dist/commands/update.d.ts +19 -0
  27. package/dist/commands/update.js +34 -6
  28. package/dist/commands/view.d.ts +12 -1
  29. package/dist/commands/view.js +47 -2
  30. package/dist/index.js +107 -26
  31. package/dist/lib/account-capabilities.js +0 -2
  32. package/dist/lib/account-catalog.d.ts +38 -0
  33. package/dist/lib/account-catalog.js +110 -26
  34. package/dist/lib/account-provider-registry.js +1 -1
  35. package/dist/lib/account-registry.d.ts +1 -1
  36. package/dist/lib/account-registry.js +36 -2
  37. package/dist/lib/accounting/rotate.js +22 -8
  38. package/dist/lib/accounting/usage-ingest.d.ts +6 -1
  39. package/dist/lib/accounting/usage-ingest.js +115 -45
  40. package/dist/lib/accounting/usage-sync.d.ts +134 -31
  41. package/dist/lib/accounting/usage-sync.js +227 -66
  42. package/dist/lib/accounting/usage.d.ts +16 -3
  43. package/dist/lib/accounting/usage.js +26 -8
  44. package/dist/lib/accounts/add.d.ts +3 -1
  45. package/dist/lib/accounts/add.js +24 -13
  46. package/dist/lib/accounts/slots.d.ts +8 -0
  47. package/dist/lib/accounts/slots.js +17 -0
  48. package/dist/lib/add-dir.js +0 -1
  49. package/dist/lib/agent-cli-commands.js +0 -1
  50. package/dist/lib/agent-spec/agents.d.ts +6 -7
  51. package/dist/lib/agent-spec/agents.js +7 -60
  52. package/dist/lib/auth-health.d.ts +69 -1
  53. package/dist/lib/auth-health.js +133 -4
  54. package/dist/lib/browser/context.d.ts +82 -0
  55. package/dist/lib/browser/context.js +88 -0
  56. package/dist/lib/browser/paths.d.ts +25 -0
  57. package/dist/lib/browser/paths.js +51 -0
  58. package/dist/lib/browser/record.d.ts +38 -0
  59. package/dist/lib/browser/record.js +57 -0
  60. package/dist/lib/browser/sessions-list.js +1 -2
  61. package/dist/lib/browser-client.d.ts +158 -0
  62. package/dist/lib/browser-client.js +234 -0
  63. package/dist/lib/channels/owner-sink.d.ts +1 -18
  64. package/dist/lib/channels/owner-sink.js +29 -82
  65. package/dist/lib/channels/providers/rush.d.ts +7 -7
  66. package/dist/lib/channels/providers/rush.js +127 -57
  67. package/dist/lib/claude-account-token.d.ts +7 -1
  68. package/dist/lib/claude-account-token.js +26 -4
  69. package/dist/lib/claude-statusline.d.ts +1 -1
  70. package/dist/lib/claude-statusline.js +15 -3
  71. package/dist/lib/commands.js +1 -1
  72. package/dist/lib/crabbox/lease.js +1 -1
  73. package/dist/lib/crabbox/runtimes.js +1 -3
  74. package/dist/lib/daemon/account-state-daemon-service.js +1 -0
  75. package/dist/lib/daemon/auth-sync-service.d.ts +21 -18
  76. package/dist/lib/daemon/auth-sync-service.js +37 -43
  77. package/dist/lib/daemon/daemon.d.ts +25 -14
  78. package/dist/lib/daemon/daemon.js +92 -154
  79. package/dist/lib/daemon/runner.d.ts +2 -2
  80. package/dist/lib/daemon/runner.js +32 -9
  81. package/dist/lib/daemon/self-update-service.d.ts +35 -34
  82. package/dist/lib/daemon/self-update-service.js +40 -36
  83. package/dist/lib/daemon/service.d.ts +12 -8
  84. package/dist/lib/daemon/supervisor.d.ts +65 -54
  85. package/dist/lib/daemon/supervisor.js +136 -171
  86. package/dist/lib/daemon/testdata/process-view-start.js +5 -2
  87. package/dist/lib/daemon/usage-sync-service.d.ts +21 -30
  88. package/dist/lib/daemon/usage-sync-service.js +46 -78
  89. package/dist/lib/daemon-health.d.ts +22 -5
  90. package/dist/lib/daemon-health.js +47 -3
  91. package/dist/lib/daemon-services.d.ts +1 -1
  92. package/dist/lib/daemon-services.js +0 -10
  93. package/dist/lib/daemon-ticks.js +4 -2
  94. package/dist/lib/devices/doctor-findings.js +8 -11
  95. package/dist/lib/devices/harness-inventory.js +14 -5
  96. package/dist/lib/exec-account-home.js +5 -0
  97. package/dist/lib/exec.js +10 -13
  98. package/dist/lib/feed/events.js +0 -1
  99. package/dist/lib/feed/tool-activity.js +5 -2
  100. package/dist/lib/fleet-shared-state.d.ts +45 -0
  101. package/dist/lib/fleet-shared-state.js +78 -9
  102. package/dist/lib/fs-atomic.js +25 -0
  103. package/dist/lib/github/pr-mergeable.d.ts +4 -2
  104. package/dist/lib/github/pr-mergeable.js +5 -3
  105. package/dist/lib/github/project-prs.d.ts +58 -3
  106. package/dist/lib/github/project-prs.js +205 -7
  107. package/dist/lib/harness/adapters/cursor.js +14 -4
  108. package/dist/lib/harness-auth-capabilities.d.ts +4 -3
  109. package/dist/lib/harness-auth-capabilities.js +1 -2
  110. package/dist/lib/helper-versions.js +1 -1
  111. package/dist/lib/hooks/cache.d.ts +7 -0
  112. package/dist/lib/hooks/cache.js +27 -2
  113. package/dist/lib/hooks/install.js +7 -1
  114. package/dist/lib/hosts/ready.d.ts +38 -8
  115. package/dist/lib/hosts/ready.js +62 -14
  116. package/dist/lib/installations/active-check.d.ts +27 -0
  117. package/dist/lib/installations/active-check.js +61 -0
  118. package/dist/lib/installations/migrate.js +6 -3
  119. package/dist/lib/installations/shims.d.ts +1 -1
  120. package/dist/lib/installations/shims.js +32 -12
  121. package/dist/lib/installations/store.d.ts +9 -0
  122. package/dist/lib/installations/store.js +24 -0
  123. package/dist/lib/installations/update.js +21 -15
  124. package/dist/lib/installations/versions.js +15 -3
  125. package/dist/lib/manifest.js +1 -1
  126. package/dist/lib/models.js +0 -2
  127. package/dist/lib/open-url.js +28 -54
  128. package/dist/lib/profiles.js +0 -2
  129. package/dist/lib/projects.d.ts +16 -0
  130. package/dist/lib/projects.js +31 -0
  131. package/dist/lib/refresh.js +1 -1
  132. package/dist/lib/routine-readiness.d.ts +35 -0
  133. package/dist/lib/routine-readiness.js +74 -16
  134. package/dist/lib/rules/compile.js +6 -0
  135. package/dist/lib/sandbox.js +0 -2
  136. package/dist/lib/secrets-policy.d.ts +89 -11
  137. package/dist/lib/secrets-policy.js +175 -28
  138. package/dist/lib/session/active.d.ts +17 -6
  139. package/dist/lib/session/active.js +105 -49
  140. package/dist/lib/session/db.d.ts +1 -1
  141. package/dist/lib/session/db.js +65 -18
  142. package/dist/lib/session/discover.js +1 -283
  143. package/dist/lib/session/mirror.d.ts +4 -3
  144. package/dist/lib/session/mirror.js +4 -3
  145. package/dist/lib/session/parse.d.ts +0 -2
  146. package/dist/lib/session/parse.js +1 -138
  147. package/dist/lib/session/pid-registry.d.ts +6 -3
  148. package/dist/lib/session/pid-registry.js +18 -8
  149. package/dist/lib/session/remote/watch.d.ts +1 -1
  150. package/dist/lib/session/remote/watch.js +7 -3
  151. package/dist/lib/session/throughput.d.ts +1 -3
  152. package/dist/lib/session/throughput.js +0 -20
  153. package/dist/lib/session/types.d.ts +2 -2
  154. package/dist/lib/session/types.js +2 -2
  155. package/dist/lib/sessions-client.d.ts +60 -1
  156. package/dist/lib/sessions-client.js +227 -4
  157. package/dist/lib/setup-tool-status.js +5 -10
  158. package/dist/lib/signin-badge.d.ts +3 -2
  159. package/dist/lib/signin-badge.js +6 -3
  160. package/dist/lib/smart-launch.js +4 -1
  161. package/dist/lib/staleness/detectors/commands.d.ts +1 -1
  162. package/dist/lib/staleness/detectors/hooks.d.ts +1 -1
  163. package/dist/lib/staleness/detectors/mcp.d.ts +1 -1
  164. package/dist/lib/staleness/detectors/permissions.d.ts +1 -1
  165. package/dist/lib/staleness/detectors/plugins.d.ts +1 -1
  166. package/dist/lib/staleness/detectors/rules.d.ts +1 -1
  167. package/dist/lib/staleness/detectors/skills.d.ts +1 -1
  168. package/dist/lib/staleness/detectors/subagents.d.ts +1 -1
  169. package/dist/lib/staleness/detectors/workflows.d.ts +1 -1
  170. package/dist/lib/staleness/writers/commands.d.ts +1 -1
  171. package/dist/lib/staleness/writers/hooks.d.ts +1 -1
  172. package/dist/lib/staleness/writers/mcp.d.ts +1 -1
  173. package/dist/lib/staleness/writers/permissions.d.ts +1 -1
  174. package/dist/lib/staleness/writers/plugins.d.ts +1 -1
  175. package/dist/lib/staleness/writers/rules.d.ts +1 -1
  176. package/dist/lib/staleness/writers/skills.d.ts +1 -1
  177. package/dist/lib/staleness/writers/sources.js +5 -0
  178. package/dist/lib/staleness/writers/subagents.d.ts +1 -1
  179. package/dist/lib/staleness/writers/workflows.d.ts +1 -1
  180. package/dist/lib/state.d.ts +6 -4
  181. package/dist/lib/state.js +7 -17
  182. package/dist/lib/teams/parsers.d.ts +1 -1
  183. package/dist/lib/teams/parsers.js +1 -136
  184. package/dist/lib/teams/placement-probe.js +50 -15
  185. package/dist/lib/teams/scheduler.d.ts +7 -0
  186. package/dist/lib/types.d.ts +16 -1
  187. package/dist/lib/types.js +1 -1
  188. package/dist/lib/view-types.d.ts +32 -0
  189. package/dist/lib/watchdog/read.js +0 -1
  190. package/dist/lib/watchdog/rotate.js +1 -2
  191. package/dist/lib/watchdog/runner.js +2 -2
  192. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  193. package/dist/lib/watchdog/watchdogTail.js +0 -31
  194. package/package.json +1 -1
  195. package/scripts/postinstall.js +8 -8
  196. package/dist/browser.d.ts +0 -2
  197. package/dist/browser.js +0 -17
  198. package/dist/commands/browser-picker.d.ts +0 -18
  199. package/dist/commands/browser-picker.js +0 -97
  200. package/dist/lib/browser/arc-discovery.d.ts +0 -52
  201. package/dist/lib/browser/arc-discovery.js +0 -197
  202. package/dist/lib/browser/arc-dom.d.ts +0 -14
  203. package/dist/lib/browser/arc-dom.js +0 -121
  204. package/dist/lib/browser/caller-identity.d.ts +0 -40
  205. package/dist/lib/browser/caller-identity.js +0 -175
  206. package/dist/lib/browser/cdp.d.ts +0 -51
  207. package/dist/lib/browser/cdp.js +0 -254
  208. package/dist/lib/browser/chrome.d.ts +0 -141
  209. package/dist/lib/browser/chrome.js +0 -704
  210. package/dist/lib/browser/chromium-discovery.d.ts +0 -28
  211. package/dist/lib/browser/chromium-discovery.js +0 -96
  212. package/dist/lib/browser/devices.d.ts +0 -34
  213. package/dist/lib/browser/devices.js +0 -61
  214. package/dist/lib/browser/domain-skills.d.ts +0 -71
  215. package/dist/lib/browser/domain-skills.js +0 -195
  216. package/dist/lib/browser/drivers/arc.d.ts +0 -40
  217. package/dist/lib/browser/drivers/arc.js +0 -276
  218. package/dist/lib/browser/drivers/firefox.d.ts +0 -99
  219. package/dist/lib/browser/drivers/firefox.js +0 -377
  220. package/dist/lib/browser/drivers/local.d.ts +0 -55
  221. package/dist/lib/browser/drivers/local.js +0 -266
  222. package/dist/lib/browser/drivers/ssh.d.ts +0 -120
  223. package/dist/lib/browser/drivers/ssh.js +0 -467
  224. package/dist/lib/browser/editor.d.ts +0 -3
  225. package/dist/lib/browser/editor.js +0 -50
  226. package/dist/lib/browser/ffmpeg.d.ts +0 -12
  227. package/dist/lib/browser/ffmpeg.js +0 -184
  228. package/dist/lib/browser/firefox-discovery.d.ts +0 -69
  229. package/dist/lib/browser/firefox-discovery.js +0 -162
  230. package/dist/lib/browser/har.d.ts +0 -85
  231. package/dist/lib/browser/har.js +0 -77
  232. package/dist/lib/browser/hygiene.d.ts +0 -97
  233. package/dist/lib/browser/hygiene.js +0 -153
  234. package/dist/lib/browser/index.d.ts +0 -5
  235. package/dist/lib/browser/index.js +0 -5
  236. package/dist/lib/browser/input.d.ts +0 -7
  237. package/dist/lib/browser/input.js +0 -92
  238. package/dist/lib/browser/ipc.d.ts +0 -204
  239. package/dist/lib/browser/ipc.js +0 -1453
  240. package/dist/lib/browser/login-detection.d.ts +0 -87
  241. package/dist/lib/browser/login-detection.js +0 -274
  242. package/dist/lib/browser/profiles.d.ts +0 -422
  243. package/dist/lib/browser/profiles.js +0 -1157
  244. package/dist/lib/browser/refs.d.ts +0 -89
  245. package/dist/lib/browser/refs.js +0 -191
  246. package/dist/lib/browser/registry.d.ts +0 -70
  247. package/dist/lib/browser/registry.js +0 -252
  248. package/dist/lib/browser/remote-control.d.ts +0 -59
  249. package/dist/lib/browser/remote-control.js +0 -85
  250. package/dist/lib/browser/resolve-target.d.ts +0 -66
  251. package/dist/lib/browser/resolve-target.js +0 -264
  252. package/dist/lib/browser/runtime-state.d.ts +0 -291
  253. package/dist/lib/browser/runtime-state.js +0 -584
  254. package/dist/lib/browser/secret-ref.d.ts +0 -10
  255. package/dist/lib/browser/secret-ref.js +0 -14
  256. package/dist/lib/browser/service.d.ts +0 -812
  257. package/dist/lib/browser/service.js +0 -4693
  258. package/dist/lib/browser/stream.d.ts +0 -17
  259. package/dist/lib/browser/stream.js +0 -72
  260. package/dist/lib/browser/task-index.d.ts +0 -78
  261. package/dist/lib/browser/task-index.js +0 -199
  262. package/dist/lib/browser/types.d.ts +0 -647
  263. package/dist/lib/browser/types.js +0 -61
  264. package/dist/lib/browser/upload.d.ts +0 -29
  265. package/dist/lib/browser/upload.js +0 -298
  266. package/dist/lib/daemon/browser-ipc-service.d.ts +0 -20
  267. package/dist/lib/daemon/browser-ipc-service.js +0 -46
  268. package/dist/lib/daemon/browser-task-reap-service.d.ts +0 -14
  269. package/dist/lib/daemon/browser-task-reap-service.js +0 -26
  270. package/dist/lib/fleet-shared-repo-sync.d.ts +0 -55
  271. package/dist/lib/fleet-shared-repo-sync.js +0 -490
@@ -1,21 +1,38 @@
1
1
  /**
2
- * Fleet distribution of identity-keyed Claude usage through the user repo.
2
+ * Fleet distribution of identity-keyed Claude usage over SSH (PHNX-4116).
3
3
  *
4
- * A headed device can read authoritative usage; a worker's setup-token cannot.
5
- * Headed daemons therefore publish one snapshot into their conflict-free
6
- * `~/.agents/devices/<device>/daemon-state.json`. The daemon's bounded Git
7
- * transport delivers those files automatically; workers merge newest-wins.
8
- * There is deliberately no device-to-device SSH in this module or its tick.
4
+ * A headed device can read authoritative usage; a worker's setup-token cannot
5
+ * (credential-management.md invariant 5: a worker never polls
6
+ * `/api/oauth/usage`). So the headed daemon's `usage-sync` tick dials every
7
+ * dialable peer in parallel with `agents __usage-ingest --reply`, sending this
8
+ * box's own daemon-state envelope on stdin; the peer merges the usage rows into
9
+ * its cache newest-wins and prints its own envelope back, which the headed box
10
+ * stores in `~/.agents/devices/<peer>/daemon-state.json` stamped `receivedAt`.
11
+ * Workers never initiate. A peer that times out is skipped this tick and never
12
+ * blocks another. Only usage rows, verdicts, and session digests move here —
13
+ * never a credential (invariant 7: roles never cross).
14
+ *
15
+ * This replaced a git exchange over the fleet-synced user repo: every daemon
16
+ * committed its own file each tick, so the shared store reached 1.1 GiB /
17
+ * 18,358 commits with 100% of the last 2,000 being `chore(devices): publish
18
+ * <device> daemon state`, and a worker's clone fell 10k commits behind with
19
+ * `git fetch timed out` on every tick — a valid setup-token could not be
20
+ * scheduled because a repo was bloated. Git now carries human-authored
21
+ * resources only; `devices/<device>/daemon-state.json` is untracked.
9
22
  */
10
- import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole } from '../device-config.js';
11
- import { readFleetSharedDeviceStates, updateFleetSharedDeviceStateAsync, } from '../fleet-shared-state.js';
23
+ import { isHeadedDeviceRole, selfConfiguredDeviceRole } from '../device-config.js';
24
+ import { FLEET_SHARED_STATE_VERSION, parseFleetSharedDeviceStateEnvelope, readFleetSharedDeviceStates, readOwnFleetSharedDeviceState, storePeerFleetSharedDeviceState, updateFleetSharedDeviceStateAsync, } from '../fleet-shared-state.js';
12
25
  import { getUserAgentsDir } from '../state.js';
13
26
  import { machineId, normalizeHost } from '../session/sync/config.js';
14
27
  import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, } from './usage.js';
15
- /** Cadence of the usage-sync git tick — the trust window for a `sync` snapshot. */
28
+ /** Cadence of the usage-sync tick — the trust window for a `sync` snapshot. */
16
29
  export const USAGE_SYNC_INTERVAL_MS = 15 * 60_000;
17
30
  /** Re-publish an unchanged windowed row at least this often so workers' 15-min trust does not lapse. */
18
31
  export const USAGE_PUBLISH_HEARTBEAT_MS = 10 * 60_000;
32
+ /** Per-peer SSH budget for one exchange; a peer past it is skipped this tick. */
33
+ export const USAGE_EXCHANGE_PEER_TIMEOUT_MS = 20_000;
34
+ /** Marks the start of the JSON reply on the peer's stdout, so login-shell noise before it is ignored. */
35
+ export const FLEET_STATE_REPLY_MARKER = '@@AGENTS_FLEET_STATE@@';
19
36
  function usageMeterSignature(row) {
20
37
  return JSON.stringify({
21
38
  windows: row.windows,
@@ -28,7 +45,7 @@ function usageMeterSignature(row) {
28
45
  /**
29
46
  * Keep the previously published `capturedAt` when meters have not moved and
30
47
  * the last publish is still inside the heartbeat. Stops statusline re-renders
31
- * from dirtying the shared store (and taking the git lock) every few seconds.
48
+ * from rewriting the own-state file every few seconds.
32
49
  */
33
50
  export function mergeUsageRowsForPublish(previous, next, nowMs = Date.now()) {
34
51
  if (!previous)
@@ -47,7 +64,7 @@ export function mergeUsageRowsForPublish(previous, next, nowMs = Date.now()) {
47
64
  }
48
65
  return out;
49
66
  }
50
- /** Publish this headed device's stable usage snapshot into its owned store file. */
67
+ /** Publish this headed device's stable usage snapshot into its own state file. */
51
68
  export async function publishUsageSnapshotToSharedStore(options = {}) {
52
69
  const result = {
53
70
  published: false,
@@ -84,68 +101,212 @@ export async function publishUsageSnapshotToSharedStore(options = {}) {
84
101
  return result;
85
102
  }
86
103
  /**
87
- * Publish a changed snapshot immediately and exchange the shared repo so
88
- * workers see it without waiting for the 15-minute tick. No-ops when the
89
- * serialized store is unchanged (statusline re-renders with the same windows).
104
+ * Refresh every field this device owns in its own state file: the usage
105
+ * snapshot (headed only), the session digests, and the reserved-auth readiness
106
+ * verdict. Run before the envelope is sent (the headed tick) or printed back
107
+ * (`__usage-ingest --reply`) so a peer always receives current fields. Each
108
+ * publisher is independent: one failing is reported in `errors` and the others
109
+ * still land. All three writers are the async, non-blocking variants
110
+ * (`updateFleetSharedDeviceStateAsync` underneath).
90
111
  */
91
- export async function pushUsageSnapshotNow(options = {}) {
92
- const published = await publishUsageSnapshotToSharedStore(options);
93
- if (!published.changed)
94
- return { published };
95
- const { syncFleetSharedStateRepo } = await import('../fleet-shared-repo-sync.js');
96
- const transport = await syncFleetSharedStateRepo({
97
- userAgentsDir: options.userAgentsDir,
98
- device: options.device,
99
- timeoutMs: options.timeoutMs,
100
- lockPath: options.lockPath,
101
- });
102
- return { published, transport };
112
+ export async function publishOwnFleetState(options = {}) {
113
+ const errors = [];
114
+ const usage = await publishUsageSnapshotToSharedStore(options);
115
+ if (usage.error)
116
+ errors.push(`usage: ${usage.error}`);
117
+ let mirror = { changed: false, count: 0, error: null };
118
+ try {
119
+ const { publishSessionMirrorToSharedStore } = await import('../session/mirror.js');
120
+ const published = await publishSessionMirrorToSharedStore({ userAgentsDir: options.userAgentsDir, device: options.device });
121
+ mirror = { changed: published.changed, count: published.count, error: published.error };
122
+ }
123
+ catch (err) {
124
+ mirror = { changed: false, count: 0, error: err.message };
125
+ }
126
+ if (mirror.error)
127
+ errors.push(`sessions: ${mirror.error}`);
128
+ let auth = { status: 'unknown', error: null };
129
+ try {
130
+ const { publishReservedAuthVerdict } = await import('../secrets-policy.js');
131
+ const verdict = await publishReservedAuthVerdict({ userAgentsDir: options.userAgentsDir, localName: options.device });
132
+ auth = { status: verdict.status, error: verdict.error };
133
+ }
134
+ catch (err) {
135
+ auth = { status: 'unknown', error: err.message };
136
+ }
137
+ if (auth.error)
138
+ errors.push(`auth: ${auth.error}`);
139
+ return { usage, mirror, auth, errors };
140
+ }
141
+ /** This device's exchange envelope, from its own state file (or usage-only from the cache). */
142
+ export function buildFleetStatePayload(options = {}) {
143
+ const device = options.device ?? machineId();
144
+ if (options.usageOnly) {
145
+ const state = { version: FLEET_SHARED_STATE_VERSION, device };
146
+ if (isHeadedDeviceRole(options.role ?? selfConfiguredDeviceRole())) {
147
+ const rows = exportClaudeUsageCacheRows(options.cachePath);
148
+ if (Object.keys(rows).length > 0)
149
+ state.usage = { rows };
150
+ }
151
+ return { v: 2, state };
152
+ }
153
+ const state = readOwnFleetSharedDeviceState(device, options.userAgentsDir);
154
+ return { v: 2, state, ...(options.errors?.length ? { errors: options.errors } : {}) };
103
155
  }
104
- function capturedAtMs(row) {
105
- if (!row.capturedAt)
106
- return null;
107
- const ms = Date.parse(row.capturedAt);
108
- return Number.isFinite(ms) ? ms : null;
156
+ /** Serialize the reply as the peer prints it: marker line, then one JSON line. */
157
+ export function formatFleetStateReply(payload) {
158
+ return `${FLEET_STATE_REPLY_MARKER}\n${JSON.stringify(payload)}\n`;
109
159
  }
110
160
  /**
111
- * Read headed publishers from the local user-repo checkout and merge one
112
- * newest-wins batch into the worker cache. No network or subprocess is touched.
161
+ * Parse what a peer sent — a v2 exchange envelope, or the legacy v1 bare-rows
162
+ * envelope from an older headed peer. Throws a clear message on anything else;
163
+ * the caller decides whether that is exit code 2 (the receiver) or a per-peer
164
+ * error (the headed tick).
113
165
  */
114
- export function consumeUsageSnapshotsFromSharedStore(options = {}) {
115
- const result = { sources: [], merged: 0, skipped: null, errors: [] };
116
- if ((options.role ?? selfConfiguredDeviceRole()) !== 'worker') {
117
- result.skipped = 'this device is not a worker';
118
- return result;
166
+ export function parseFleetStateExchangeInput(raw) {
167
+ let parsed;
168
+ try {
169
+ parsed = JSON.parse(raw);
119
170
  }
120
- const read = readFleetSharedDeviceStates(options.userAgentsDir ?? getUserAgentsDir());
121
- result.errors.push(...read.errors);
122
- const self = normalizeHost(options.device ?? machineId());
123
- const roles = options.roles ?? listConfiguredDeviceRoles(read.states.map((state) => state.device));
124
- const rows = {};
125
- for (const state of read.states) {
126
- if (normalizeHost(state.device) === self || !isHeadedDeviceRole(roles[state.device]) || !state.usage)
171
+ catch {
172
+ throw new Error('malformed JSON payload');
173
+ }
174
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
175
+ throw new Error('unrecognized usage-sync payload shape');
176
+ }
177
+ const record = parsed;
178
+ if (record.v === 2) {
179
+ const state = parseFleetSharedDeviceStateEnvelope(record.state);
180
+ const errors = Array.isArray(record.errors) ? record.errors.filter((e) => typeof e === 'string') : undefined;
181
+ return { v: 2, state, ...(errors?.length ? { errors } : {}) };
182
+ }
183
+ if (record.v === 1) {
184
+ const rows = record.rows;
185
+ // `typeof [] === 'object'` — an array is NOT a rows map.
186
+ if (typeof rows !== 'object' || rows === null || Array.isArray(rows)) {
187
+ throw new Error('unrecognized usage-sync payload shape');
188
+ }
189
+ return { v: 1, rows: rows };
190
+ }
191
+ throw new Error('unrecognized usage-sync payload shape');
192
+ }
193
+ /** Extract the reply envelope from a peer's stdout (text before the marker is login-shell noise). */
194
+ export function parseFleetStateReply(stdout) {
195
+ const idx = stdout.lastIndexOf(FLEET_STATE_REPLY_MARKER);
196
+ if (idx === -1) {
197
+ throw new Error(stdout.trim()
198
+ ? 'no reply envelope on stdout (peer agents-cli predates `__usage-ingest --reply`?)'
199
+ : 'empty reply (peer agents-cli predates `__usage-ingest --reply`?)');
200
+ }
201
+ const body = stdout.slice(idx + FLEET_STATE_REPLY_MARKER.length).trim();
202
+ const payload = parseFleetStateExchangeInput(body);
203
+ if (payload.v !== 2)
204
+ throw new Error('reply is not a v2 exchange envelope');
205
+ return payload;
206
+ }
207
+ /**
208
+ * Rows as the receiver stores them: provenance `sync` (so the trust window is
209
+ * `USAGE_SYNC_TRUST_MS`, not the local-capture bar) and the poller pinned to the
210
+ * device that actually read the endpoint, defaulting to the sender.
211
+ */
212
+ function stampSyncRows(rows, sender) {
213
+ const out = {};
214
+ for (const [identity, incoming] of Object.entries(rows)) {
215
+ if (!incoming || !Array.isArray(incoming.windows) || incoming.windows.length === 0)
127
216
  continue;
128
- result.sources.push(state.device);
129
- for (const [identity, incoming] of Object.entries(state.usage.rows)) {
130
- if (!incoming || !Array.isArray(incoming.windows) || incoming.windows.length === 0)
131
- continue;
132
- const stamped = {
133
- ...incoming,
134
- freshnessSource: 'sync',
135
- pollerDevice: incoming.pollerDevice ?? state.device,
136
- };
137
- const current = rows[identity];
138
- if (!current) {
139
- rows[identity] = stamped;
140
- continue;
217
+ out[identity] = { ...incoming, freshnessSource: 'sync', pollerDevice: incoming.pollerDevice ?? sender };
218
+ }
219
+ return out;
220
+ }
221
+ /**
222
+ * Take a peer's envelope in: store it as that peer's file (stamped `receivedAt`)
223
+ * and merge its usage rows into this box's cache newest-wins. The same step runs
224
+ * on both ends — the worker on the pushed envelope, the headed box on the reply.
225
+ * Both writes take their file lock asynchronously: this runs on the daemon tick
226
+ * once per peer, and a `sleepSync` lock there freezes every other service.
227
+ */
228
+ export async function applyPeerFleetState(state, options = {}) {
229
+ const self = normalizeHost(options.device ?? machineId());
230
+ if (normalizeHost(state.device) === self) {
231
+ throw new Error(`peer envelope names this device (${state.device}); refusing to overwrite the own state file`);
232
+ }
233
+ const receivedAt = options.receivedAt ?? Date.now();
234
+ const write = await storePeerFleetSharedDeviceState(state, options.userAgentsDir ?? getUserAgentsDir(), receivedAt);
235
+ const merged = state.usage
236
+ ? await ingestPeerClaudeUsageRows(stampSyncRows(state.usage.rows, state.device), options.cachePath)
237
+ : 0;
238
+ return { path: write.path, merged, receivedAt };
239
+ }
240
+ /** The remote command one exchange runs on a peer, in the peer's shell dialect. */
241
+ export async function buildFleetStateExchangeCommand(os) {
242
+ const { buildRemoteAgentsInvocation, buildWindowsStdinAgentsCommand, remoteShellFor } = await import('../hosts/remote-cmd.js');
243
+ // The Windows `agents.ps1` shim does not forward ssh-piped stdin, so the
244
+ // payload goes through a temp file and `--from <path>` there.
245
+ return remoteShellFor(os) === 'powershell'
246
+ ? buildWindowsStdinAgentsCommand(['__usage-ingest', '--reply'])
247
+ : buildRemoteAgentsInvocation(['__usage-ingest', '--reply'], undefined, os);
248
+ }
249
+ async function sshDial(timeoutMs) {
250
+ const { sshExecAsync } = await import('../ssh-exec.js');
251
+ const { sshTargetFor, deviceIdentityArgs } = await import('../devices/connect.js');
252
+ return (peer, remoteCmd, input) => sshExecAsync(sshTargetFor(peer), remoteCmd, {
253
+ input,
254
+ timeoutMs,
255
+ extraSshArgs: deviceIdentityArgs(peer),
256
+ });
257
+ }
258
+ async function defaultPeers(self) {
259
+ const { isDialableDevice, loadDevicesSync } = await import('../devices/registry.js');
260
+ return Object.values(loadDevicesSync())
261
+ .filter((d) => normalizeHost(d.name) !== self && isDialableDevice(d))
262
+ .sort((a, b) => a.name.localeCompare(b.name));
263
+ }
264
+ /**
265
+ * The headed publisher's fan-out: dial every peer in parallel, each under its
266
+ * own timeout, and apply each reply as it arrives. Never throws for a peer — a
267
+ * timeout, an old CLI with no `--reply`, or a reply naming the wrong device is
268
+ * that peer's `error` and the others are unaffected. A non-headed device skips:
269
+ * workers never initiate (they only answer), so a worker's tick is a no-op here.
270
+ */
271
+ export async function exchangeFleetStateWithPeers(options = {}) {
272
+ const device = options.device ?? machineId();
273
+ const self = normalizeHost(device);
274
+ const peers = options.peers ?? await defaultPeers(self);
275
+ if (peers.length === 0)
276
+ return { skipped: 'no dialable peer in the device registry', outcomes: [] };
277
+ const timeoutMs = options.timeoutMs ?? USAGE_EXCHANGE_PEER_TIMEOUT_MS;
278
+ const dial = options.dial ?? await sshDial(timeoutMs);
279
+ const payload = options.payload ?? buildFleetStatePayload({ device, userAgentsDir: options.userAgentsDir });
280
+ const input = JSON.stringify(payload);
281
+ const outcomes = await Promise.all(peers.map(async (peer) => {
282
+ const outcome = { device: peer.name, delivered: false, receivedAt: null, merged: 0, error: null, peerErrors: [] };
283
+ try {
284
+ const remoteCmd = await buildFleetStateExchangeCommand(peer.shell === 'powershell' ? 'windows' : undefined);
285
+ const res = await dial(peer, remoteCmd, input);
286
+ if (res.timedOut)
287
+ throw new Error(`timed out after ${Math.round(timeoutMs / 1000)}s`);
288
+ if (res.code !== 0) {
289
+ const detail = res.stderr.trim().split('\n').pop() ?? '';
290
+ throw new Error(`exited ${res.code ?? 'without a status'}${detail ? `: ${detail}` : ''}`);
291
+ }
292
+ const reply = parseFleetStateReply(res.stdout);
293
+ if (normalizeHost(reply.state.device) !== normalizeHost(peer.name)) {
294
+ throw new Error(`reply names device '${reply.state.device}', expected '${peer.name}'`);
141
295
  }
142
- const incomingMs = capturedAtMs(stamped);
143
- const currentMs = capturedAtMs(current);
144
- if (incomingMs !== null && (currentMs === null || incomingMs > currentMs))
145
- rows[identity] = stamped;
296
+ const applied = await applyPeerFleetState(reply.state, {
297
+ device,
298
+ userAgentsDir: options.userAgentsDir,
299
+ cachePath: options.cachePath,
300
+ });
301
+ outcome.delivered = true;
302
+ outcome.receivedAt = applied.receivedAt;
303
+ outcome.merged = applied.merged;
304
+ outcome.peerErrors = reply.errors ?? [];
146
305
  }
147
- }
148
- result.sources.sort();
149
- result.merged = ingestPeerClaudeUsageRows(rows, options.cachePath);
150
- return result;
306
+ catch (err) {
307
+ outcome.error = err.message;
308
+ }
309
+ return outcome;
310
+ }));
311
+ return { skipped: null, outcomes };
151
312
  }
@@ -494,6 +494,17 @@ export interface FormatUsageSummaryOpts {
494
494
  export declare function viewUsageSummaryOptions(agentId: AgentId, signedIn: boolean, usageInfo: UsageInfo | undefined, maxWindows: number | undefined, version?: string): FormatUsageSummaryOpts;
495
495
  /** Format a one-line usage summary with compact bars for inline display. */
496
496
  export declare function formatUsageSummary(plan: string | null, snapshot: UsageSnapshot | null, planWidth?: number, opts?: FormatUsageSummaryOpts): string;
497
+ /**
498
+ * The snapshot's windows that are still LIVE — reset time not yet passed. A
499
+ * window past its `resetsAt` has rolled over: its `usedPercent` is a reading of
500
+ * the PREVIOUS period, not a live throttle (PHNX-4116). Both the throttle
501
+ * verdict ({@link deriveUsageStatusFromSnapshot}) and the displayed
502
+ * `usedPercent` (`devices/harness-inventory.ts`) MUST select from this SAME set,
503
+ * or a maxed-then-reset account reads `available` yet shows a stale 99% next to
504
+ * its "ready" verdict (#3705). Callers still narrow further (dropping
505
+ * `sonnet_week`, the model sub-limit); this is only the reset-rollover cut.
506
+ */
507
+ export declare function liveUsageWindows(snapshot: UsageSnapshot, now?: number): UsageWindow[];
497
508
  /**
498
509
  * Derive an account's real throttle state from its live usage windows — the
499
510
  * single signal both the `agents view` badge and run-rotation eligibility share
@@ -513,7 +524,7 @@ export declare function formatUsageSummary(plan: string | null, snapshot: UsageS
513
524
  * The model-specific `sonnet_week` sub-limit is excluded: hitting it throttles
514
525
  * one model, not the account, so it shouldn't flip the whole row to throttled.
515
526
  */
516
- export declare function deriveUsageStatusFromSnapshot(snapshot: UsageSnapshot | null | undefined): 'available' | 'rate_limited' | null;
527
+ export declare function deriveUsageStatusFromSnapshot(snapshot: UsageSnapshot | null | undefined, now?: number): 'available' | 'rate_limited' | null;
517
528
  /** A prior sample of one window's utilization, for burn-rate projection. */
518
529
  interface UsagePriorSample {
519
530
  /** Epoch ms the prior snapshot was captured. */
@@ -768,7 +779,9 @@ export declare function exportClaudeUsageCacheRows(cachePath?: string): Record<s
768
779
  * a fresher row another peer (or, on a headed receiver, the local status-line)
769
780
  * already wrote. An incoming row with no `capturedAt` cannot prove it is newer, so
770
781
  * it never displaces an existing timestamped row. Returns the count updated.
771
- * Locked + atomic like every other cache writer.
782
+ * Locked + atomic like every other cache writer, but through the ASYNC lock: it
783
+ * runs on the daemon's usage-sync tick once per peer reply (PHNX-4116), where
784
+ * the sync lock's `sleepSync` would freeze every other service under contention.
772
785
  *
773
786
  * Deliberately NOT role-gated on the receiver. "Consume only on worker/unmarked"
774
787
  * is a SENDER-side optimization (don't waste a push on a headed peer that reads
@@ -778,7 +791,7 @@ export declare function exportClaudeUsageCacheRows(cachePath?: string): Record<s
778
791
  * runs now shows a usage bar), so gating here on the receiver's own — laggier —
779
792
  * view of its role would only reject legitimate data.
780
793
  */
781
- export declare function ingestPeerClaudeUsageRows(rows: Record<string, CachedUsageSnapshot>, cachePath?: string): number;
794
+ export declare function ingestPeerClaudeUsageRows(rows: Record<string, CachedUsageSnapshot>, cachePath?: string): Promise<number>;
782
795
  /**
783
796
  * Persist a Claude tokens/credits exhaustion (`out of usage credits` / `monthly
784
797
  * spend limit`) from a real run. Unlike a rate/session limit this does NOT reset
@@ -24,7 +24,7 @@ import { resolveClaudeSetupToken } from '../claude-account-token.js';
24
24
  import { formatBackoffRemaining, noteUsageRateLimited, usageRateLimitedUntil, } from '../usage-backoff.js';
25
25
  import { getCacheDir } from '../state.js';
26
26
  import { mapBounded } from '../concurrency.js';
27
- import { atomicWriteFileSync, ensureLockTarget, withFileLock } from '../fs-atomic.js';
27
+ import { atomicWriteFileSync, ensureLockTarget, withFileLock, withFileLockAsync } from '../fs-atomic.js';
28
28
  import { withRefreshLease } from '../refresh-coordinator.js';
29
29
  import { padToWidth } from '../session/width.js';
30
30
  const execFileAsync = promisify(execFile);
@@ -723,6 +723,19 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
723
723
  }
724
724
  return parts.join(' ');
725
725
  }
726
+ /**
727
+ * The snapshot's windows that are still LIVE — reset time not yet passed. A
728
+ * window past its `resetsAt` has rolled over: its `usedPercent` is a reading of
729
+ * the PREVIOUS period, not a live throttle (PHNX-4116). Both the throttle
730
+ * verdict ({@link deriveUsageStatusFromSnapshot}) and the displayed
731
+ * `usedPercent` (`devices/harness-inventory.ts`) MUST select from this SAME set,
732
+ * or a maxed-then-reset account reads `available` yet shows a stale 99% next to
733
+ * its "ready" verdict (#3705). Callers still narrow further (dropping
734
+ * `sonnet_week`, the model sub-limit); this is only the reset-rollover cut.
735
+ */
736
+ export function liveUsageWindows(snapshot, now = Date.now()) {
737
+ return snapshot.windows.filter((window) => !(window.resetsAt && window.resetsAt.getTime() <= now));
738
+ }
726
739
  /**
727
740
  * Derive an account's real throttle state from its live usage windows — the
728
741
  * single signal both the `agents view` badge and run-rotation eligibility share
@@ -742,7 +755,7 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
742
755
  * The model-specific `sonnet_week` sub-limit is excluded: hitting it throttles
743
756
  * one model, not the account, so it shouldn't flip the whole row to throttled.
744
757
  */
745
- export function deriveUsageStatusFromSnapshot(snapshot) {
758
+ export function deriveUsageStatusFromSnapshot(snapshot, now = Date.now()) {
746
759
  if (!snapshot)
747
760
  return null;
748
761
  if (snapshot.unavailable) {
@@ -750,14 +763,17 @@ export function deriveUsageStatusFromSnapshot(snapshot) {
750
763
  // clears it. session_limit blocks only until its reset time.
751
764
  if (snapshot.unavailable.reason === 'out_of_credits')
752
765
  return 'rate_limited';
753
- if (snapshot.unavailable.resetsAt && snapshot.unavailable.resetsAt.getTime() > Date.now()) {
766
+ if (snapshot.unavailable.resetsAt && snapshot.unavailable.resetsAt.getTime() > now) {
754
767
  return 'rate_limited';
755
768
  }
756
769
  }
757
770
  if (snapshot.windows.length === 0)
758
771
  return null;
759
- const blocking = snapshot.windows.filter((window) => window.key !== 'sonnet_week');
760
- const windows = blocking.length > 0 ? blocking : snapshot.windows;
772
+ const live = liveUsageWindows(snapshot, now);
773
+ if (live.length === 0)
774
+ return 'available';
775
+ const blocking = live.filter((window) => window.key !== 'sonnet_week');
776
+ const windows = blocking.length > 0 ? blocking : live;
761
777
  const maxUsed = Math.max(...windows.map((window) => window.usedPercent));
762
778
  return maxUsed >= 100 ? 'rate_limited' : 'available';
763
779
  }
@@ -1897,7 +1913,9 @@ function parseCapturedAtMs(capturedAt) {
1897
1913
  * a fresher row another peer (or, on a headed receiver, the local status-line)
1898
1914
  * already wrote. An incoming row with no `capturedAt` cannot prove it is newer, so
1899
1915
  * it never displaces an existing timestamped row. Returns the count updated.
1900
- * Locked + atomic like every other cache writer.
1916
+ * Locked + atomic like every other cache writer, but through the ASYNC lock: it
1917
+ * runs on the daemon's usage-sync tick once per peer reply (PHNX-4116), where
1918
+ * the sync lock's `sleepSync` would freeze every other service under contention.
1901
1919
  *
1902
1920
  * Deliberately NOT role-gated on the receiver. "Consume only on worker/unmarked"
1903
1921
  * is a SENDER-side optimization (don't waste a push on a headed peer that reads
@@ -1907,14 +1925,14 @@ function parseCapturedAtMs(capturedAt) {
1907
1925
  * runs now shows a usage bar), so gating here on the receiver's own — laggier —
1908
1926
  * view of its role would only reject legitimate data.
1909
1927
  */
1910
- export function ingestPeerClaudeUsageRows(rows, cachePath = getClaudeUsageCachePath()) {
1928
+ export async function ingestPeerClaudeUsageRows(rows, cachePath = getClaudeUsageCachePath()) {
1911
1929
  const incoming = Object.entries(rows).filter(([, row]) => row && Array.isArray(row.windows) && row.windows.length > 0);
1912
1930
  if (incoming.length === 0)
1913
1931
  return 0;
1914
1932
  let merged = 0;
1915
1933
  try {
1916
1934
  ensureLockTarget(cachePath, '{}');
1917
- withFileLock(cachePath, () => {
1935
+ await withFileLockAsync(cachePath, () => {
1918
1936
  const cache = readClaudeUsageCacheFile(cachePath);
1919
1937
  for (const [key, row] of incoming) {
1920
1938
  const prior = cache[key];
@@ -128,6 +128,8 @@ export declare function runAdd(agent: AgentId, name: string | undefined, opts: A
128
128
  * Drive one `agents accounts login <harness>#<name>`: re-auth into the SAME
129
129
  * slot (re-running add's steps 4–8), re-minting the worker credential and
130
130
  * re-syncing. On a per-device harness (worker `none`) any box may run it —
131
- * that IS how such a box logs in.
131
+ * that IS how such a box logs in. `--per-device` extends that to a dual-path
132
+ * harness (codex, grok): this box signs the account's subscription seat in for
133
+ * itself through the device-code flow instead of using a portable key.
132
134
  */
133
135
  export declare function runLogin(agent: AgentId, name: string, opts: AddOptions, runners?: AddRunners): Promise<AddResult>;
@@ -73,6 +73,12 @@ export function workerApiKeyEnv(agent) {
73
73
  function hasPortableWorkerKind(agent) {
74
74
  return harnessWorkerKinds(agent).some((k) => k === 'setup-token' || k.startsWith('api-key:'));
75
75
  }
76
+ function hasPerDeviceWorkerKind(agent) {
77
+ return harnessWorkerKinds(agent).some((k) => k.startsWith('per-device'));
78
+ }
79
+ function perDeviceRefusal(agent) {
80
+ return `--per-device is only valid for a harness with a per-device worker path; ${agent} is provisioned from ${workerProvisioningHint(agent)}.`;
81
+ }
76
82
  /** Whether `accounts add` can drive this harness (nameable, version-scoped, a real login command). */
77
83
  export function addSupported(agent) {
78
84
  const cap = nativeAccountCapability(agent);
@@ -232,9 +238,8 @@ async function mintWorkerCredential(agent, account, home, opts, runners, warning
232
238
  return { outcome: 'skipped', provisioning: 'per-device' };
233
239
  }
234
240
  if (opts.perDevice) {
235
- if (!kinds.some((k) => k.startsWith('per-device'))) {
236
- throw new Error(`--per-device is only valid for a harness with a per-device worker path (codex); ${agent} is provisioned from ${workerProvisioningHint(agent)}.`);
237
- }
241
+ if (!hasPerDeviceWorkerKind(agent))
242
+ throw new Error(perDeviceRefusal(agent));
238
243
  return { outcome: 'per-device', provisioning: 'per-device' };
239
244
  }
240
245
  const { seedReservedStoreKey, seedReservedAuthToken, workerCredentialStoreKey, } = await import('../auth-mint.js');
@@ -260,7 +265,7 @@ async function mintWorkerCredential(agent, account, home, opts, runners, warning
260
265
  let value = opts.apiKey?.trim();
261
266
  if (!value) {
262
267
  if (!runners.promptApiKey) {
263
- throw new Error(`${agent}'s worker credential is an ${env} API key. Pass --api-key <key> (or --no-worker-token to skip, --per-device for a codex ChatGPT-plan account).`);
268
+ throw new Error(`${agent}'s worker credential is an ${env} API key. Pass --api-key <key> (or --no-worker-token to skip, or --per-device for a subscription seat when the harness has one).`);
264
269
  }
265
270
  const entered = await runners.promptApiKey(agent, env);
266
271
  if (entered === null) {
@@ -290,8 +295,8 @@ export async function runAdd(agent, name, opts, runners) {
290
295
  // before the lock, slot, install, or browser — no side effects on a worker.
291
296
  assertAddAllowedOnThisDevice(agent, name);
292
297
  const kinds = harnessWorkerKinds(agent);
293
- if (opts.perDevice && !kinds.some((k) => k.startsWith('per-device'))) {
294
- throw new Error(`--per-device is only valid for a harness with a per-device worker path (codex); ${agent} is provisioned from ${workerProvisioningHint(agent)}.`);
298
+ if (opts.perDevice && !hasPerDeviceWorkerKind(agent)) {
299
+ throw new Error(perDeviceRefusal(agent));
295
300
  }
296
301
  // The ambient-token refusal is checked up front (not at the mint step) so the
297
302
  // user doesn't complete a browser login only to be refused at the end.
@@ -410,13 +415,18 @@ async function _runAddLocked(agent, name, opts, lock, runners) {
410
415
  * Drive one `agents accounts login <harness>#<name>`: re-auth into the SAME
411
416
  * slot (re-running add's steps 4–8), re-minting the worker credential and
412
417
  * re-syncing. On a per-device harness (worker `none`) any box may run it —
413
- * that IS how such a box logs in.
418
+ * that IS how such a box logs in. `--per-device` extends that to a dual-path
419
+ * harness (codex, grok): this box signs the account's subscription seat in for
420
+ * itself through the device-code flow instead of using a portable key.
414
421
  */
415
422
  export async function runLogin(agent, name, opts, runners) {
416
423
  // A harness with a portable worker credential authenticates workers from that
417
424
  // credential, so the interactive re-login stays headed-only. A per-device
418
- // harness (kimi) is logged in per box BY DESIGN — any role may run this.
419
- if (hasPortableWorkerKind(agent))
425
+ // harness (kimi), or a per-device login of a dual-path harness, is logged in
426
+ // per box BY DESIGN — any role may run it.
427
+ if (opts.perDevice && !hasPerDeviceWorkerKind(agent))
428
+ throw new Error(perDeviceRefusal(agent));
429
+ if (hasPortableWorkerKind(agent) && !opts.perDevice)
420
430
  assertAddAllowedOnThisDevice(agent, name);
421
431
  if (!opts.noWorkerToken && harnessWorkerKinds(agent).includes('setup-token')) {
422
432
  const refusal = ambientTokenRefusal(agent, opts.env ?? process.env);
@@ -437,6 +447,7 @@ export async function runLogin(agent, name, opts, runners) {
437
447
  async function _runLoginLocked(agent, name, opts, lock, runners) {
438
448
  const run = runners ?? await defaultAddRunners();
439
449
  lock.assertHeld();
450
+ const perDevice = !!opts.perDevice || !hasPortableWorkerKind(agent);
440
451
  const meta = readMeta();
441
452
  const account = findAddAccount(agent, name, meta);
442
453
  if (!account) {
@@ -452,7 +463,7 @@ async function _runLoginLocked(agent, name, opts, lock, runners) {
452
463
  const slot = ensureSlot(agent, account.id);
453
464
  recordSlot(account.id, {
454
465
  ...slot,
455
- authMode: hasPortableWorkerKind(agent) ? 'native' : 'per-device',
466
+ authMode: perDevice ? 'per-device' : 'native',
456
467
  });
457
468
  }
458
469
  // Pre-launch guard: refuse to launch a login into a slot signed in as a
@@ -481,8 +492,8 @@ async function _runLoginLocked(agent, name, opts, lock, runners) {
481
492
  const warnings = [];
482
493
  let outcome = 'per-device';
483
494
  let ref;
484
- let provisioning = account.provisioning ?? (hasPortableWorkerKind(agent) ? 'portable' : 'per-device');
485
- if (hasPortableWorkerKind(agent)) {
495
+ let provisioning = perDevice ? 'per-device' : (account.provisioning ?? 'portable');
496
+ if (!perDevice) {
486
497
  if (opts.apiKey && workerApiKeyEnv(agent)) {
487
498
  // Explicit rotation of the stored API key.
488
499
  const minted = await mintWorkerCredential(agent, account, home, opts, run, warnings);
@@ -520,7 +531,7 @@ async function _runLoginLocked(agent, name, opts, lock, runners) {
520
531
  recordSlot(account.id, {
521
532
  accountId: account.id,
522
533
  slotDir: home,
523
- authMode: hasPortableWorkerKind(agent) ? 'native' : 'per-device',
534
+ authMode: perDevice ? 'per-device' : 'native',
524
535
  verdict: 'live',
525
536
  checkedAt: new Date().toISOString(),
526
537
  });
@@ -2,6 +2,14 @@ import type { AgentId, DeviceAccountSlot, Meta } from '../types.js';
2
2
  export type { DeviceAccountSlot };
3
3
  export declare function slotDir(harness: AgentId, accountId: string): string;
4
4
  export declare function readSlots(meta: Pick<Meta, 'deviceAccounts'>): Record<string, DeviceAccountSlot>;
5
+ /**
6
+ * Remove slot records from THIS box's device doc (`deviceAccounts.slots`).
7
+ * Symmetric with {@link recordSlot}. The slot DIRECTORY is not touched — a
8
+ * caller that also wants the on-disk home gone removes it separately; a stale
9
+ * worker slot keeps its dir because `.claude/projects` holds transcripts
10
+ * (PHNX-4116). A no-op for an empty list or an id with no record.
11
+ */
12
+ export declare function dropSlots(accountIds: readonly string[]): void;
5
13
  export declare function recordSlot(accountId: string, slot: DeviceAccountSlot): void;
6
14
  export interface SlotProjection {
7
15
  accountId: string;
@@ -35,6 +35,23 @@ export function slotDir(harness, accountId) {
35
35
  export function readSlots(meta) {
36
36
  return { ...(meta.deviceAccounts?.slots ?? {}) };
37
37
  }
38
+ /**
39
+ * Remove slot records from THIS box's device doc (`deviceAccounts.slots`).
40
+ * Symmetric with {@link recordSlot}. The slot DIRECTORY is not touched — a
41
+ * caller that also wants the on-disk home gone removes it separately; a stale
42
+ * worker slot keeps its dir because `.claude/projects` holds transcripts
43
+ * (PHNX-4116). A no-op for an empty list or an id with no record.
44
+ */
45
+ export function dropSlots(accountIds) {
46
+ if (accountIds.length === 0)
47
+ return;
48
+ updateMeta((current) => {
49
+ const slots = { ...current.deviceAccounts?.slots };
50
+ for (const id of accountIds)
51
+ delete slots[id];
52
+ return { ...current, deviceAccounts: { ...current.deviceAccounts, slots } };
53
+ });
54
+ }
38
55
  export function recordSlot(accountId, slot) {
39
56
  if (slot.accountId !== accountId) {
40
57
  throw new Error(`recordSlot accountId mismatch: key '${accountId}' vs slot.accountId '${slot.accountId}'.`);
@@ -32,7 +32,6 @@ export const ADD_DIR_STRATEGY = {
32
32
  grok: 'grok-sandbox',
33
33
  // No multi-root CLI surface today (single --dir / project path).
34
34
  opencode: 'none',
35
- gemini: 'none',
36
35
  openclaw: 'none',
37
36
  copilot: 'none',
38
37
  amp: 'none',
@@ -11,7 +11,6 @@
11
11
  export const AGENT_CLI_COMMANDS = [
12
12
  'claude',
13
13
  'codex',
14
- 'gemini',
15
14
  'cursor-agent',
16
15
  'opencode',
17
16
  'openclaw',