@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,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);
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * This helper:
9
9
  * 1. Collects GitHub slugs from `~/.agents/projects/*.yaml` (`repo` / `repos[].slug`).
10
- * 2. Canonicalizes each slug (`gh repo view` — `phnx-labs/agents-cli` lists
10
+ * 2. Canonicalizes each slug (REST `repos/{slug}` — `phnx-labs/agents-cli` lists
11
11
  * nothing; the live name is `phnx-labs/agi-cli`).
12
12
  * 3. Runs `gh pr list --repo <slug> --author @me` (cwd-independent).
13
13
  * 4. Keeps CI-green PRs with a merge-guard verdict (formal APPROVED review
@@ -27,7 +27,9 @@ export declare function projectRepoSlugs(defs: readonly ProjectDef[]): string[];
27
27
  /**
28
28
  * Resolve a slug to GitHub's current `nameWithOwner`. A renamed repo
29
29
  * (`phnx-labs/agents-cli` → `phnx-labs/agi-cli`) lists zero PRs under the old
30
- * name; `gh repo view` returns the live one. On error, keep the input slug.
30
+ * name; the REST repo read follows the rename redirect and returns the live one.
31
+ * REST + a 24h gh cache: a rename is rare, and every `projects prs` refresh asks.
32
+ * On error, keep the input slug.
31
33
  */
32
34
  export declare function canonicalizeRepo(slug: string, gh: GhExec): Promise<string>;
33
35
  interface ListedPr {
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * This helper:
9
9
  * 1. Collects GitHub slugs from `~/.agents/projects/*.yaml` (`repo` / `repos[].slug`).
10
- * 2. Canonicalizes each slug (`gh repo view` — `phnx-labs/agents-cli` lists
10
+ * 2. Canonicalizes each slug (REST `repos/{slug}` — `phnx-labs/agents-cli` lists
11
11
  * nothing; the live name is `phnx-labs/agi-cli`).
12
12
  * 3. Runs `gh pr list --repo <slug> --author @me` (cwd-independent).
13
13
  * 4. Keeps CI-green PRs with a merge-guard verdict (formal APPROVED review
@@ -65,12 +65,14 @@ export function projectRepoSlugs(defs) {
65
65
  /**
66
66
  * Resolve a slug to GitHub's current `nameWithOwner`. A renamed repo
67
67
  * (`phnx-labs/agents-cli` → `phnx-labs/agi-cli`) lists zero PRs under the old
68
- * name; `gh repo view` returns the live one. On error, keep the input slug.
68
+ * name; the REST repo read follows the rename redirect and returns the live one.
69
+ * REST + a 24h gh cache: a rename is rare, and every `projects prs` refresh asks.
70
+ * On error, keep the input slug.
69
71
  */
70
72
  export async function canonicalizeRepo(slug, gh) {
71
73
  try {
72
74
  const out = (await gh([
73
- 'repo', 'view', slug, '--json', 'nameWithOwner', '--jq', '.nameWithOwner',
75
+ 'api', `repos/${slug}`, '--cache', '24h', '--jq', '.full_name',
74
76
  ])).trim();
75
77
  return out || slug;
76
78
  }
@@ -23,13 +23,22 @@
23
23
  */
24
24
  import { type GhExec } from './pr-mergeable.js';
25
25
  import { type RollupItem } from './rest.js';
26
- import type { ProjectDef } from '../projects.js';
26
+ import { type ProjectDef } from '../projects.js';
27
27
  /** The author of a PR, as the menu renders it (login + avatar). */
28
28
  export interface ProjectPrAuthor {
29
29
  login: string;
30
30
  avatarUrl: string;
31
31
  }
32
- /** One open PR row. `checks`/`reviewDecision` are null unless the PR was enriched. */
32
+ /**
33
+ * Where a PR sits for a project whose repository is SHARED with another project
34
+ * (a monorepo): `project` touches the paths this project claims, `repo-wide`
35
+ * touches no sharing project's paths (root config, CI, docs). A PR that touches
36
+ * only another project's paths is not listed at all. Null when the repository is
37
+ * not shared, or this project claims all of it. Claims are {@link repoPathClaims},
38
+ * the same ownership that attributes a session's cwd.
39
+ */
40
+ export type ProjectPrScope = 'project' | 'repo-wide';
41
+ /** One open PR row. `checks`/`reviewDecision`/merge state are null unless the PR was enriched. */
33
42
  export interface ProjectPr {
34
43
  number: number;
35
44
  title: string;
@@ -37,12 +46,18 @@ export interface ProjectPr {
37
46
  isDraft: boolean;
38
47
  /** Upper-cased GitHub state — always `OPEN` here (only open PRs are listed). */
39
48
  state: string;
49
+ createdAt: string;
40
50
  updatedAt: string;
41
51
  author: ProjectPrAuthor;
42
52
  headRefName: string;
43
53
  baseRefName: string;
44
54
  headSha: string;
45
55
  body: string;
56
+ scope: ProjectPrScope | null;
57
+ /** Populated only for the `--number` PR: GitHub's mergeability (null while it computes). */
58
+ mergeable: boolean | null;
59
+ /** Populated only for the `--number` PR: `clean` | `unstable` | `blocked` | `behind` | `dirty` | `draft` | `unknown` … */
60
+ mergeableState: string | null;
46
61
  /** Populated only for the `--number` PR: the head-SHA status-check rollup. */
47
62
  checks: RollupItem[] | null;
48
63
  /** Populated only for the `--number` PR: GitHub's computed review decision. */
@@ -51,6 +66,8 @@ export interface ProjectPr {
51
66
  /** One repository's open PRs, or the error that stopped its fetch. */
52
67
  export interface ProjectRepoPrs {
53
68
  slug: string;
69
+ /** Other project definitions attached to this same repository. */
70
+ sharedWith: string[];
54
71
  pullRequests: ProjectPr[];
55
72
  /** Non-null when the fetch failed — the list is then NOT authoritative. */
56
73
  error: string | null;
@@ -61,6 +78,8 @@ export interface ProjectPrsEnvelope {
61
78
  name: string;
62
79
  linearProjectId: string | null;
63
80
  };
81
+ /** The authenticated GitHub login — what a "mine" filter compares `author.login` to. */
82
+ viewer: string | null;
64
83
  repositories: ProjectRepoPrs[];
65
84
  /** True when at least one repository fetch failed (its list is incomplete). */
66
85
  partial: boolean;
@@ -110,9 +129,45 @@ export declare function enrichPr(repo: string, pr: ProjectPr, gh?: GhExec): Prom
110
129
  * is not one of the project's attached repos (raw or canonical) is refused.
111
130
  */
112
131
  export declare function resolveTargetSlugs(def: ProjectDef, repo: string | undefined, gh: GhExec): Promise<string[]>;
132
+ /**
133
+ * Classify one PR of a shared repository by the files it changes. `own` are
134
+ * this project's prefixes, `others` every other sharing project's. Returns
135
+ * null when the PR belongs only to another project.
136
+ */
137
+ export declare function scopeForFiles(files: readonly string[], own: readonly string[], others: readonly string[]): ProjectPrScope | null;
138
+ /** The authenticated login, from a REST read gh caches for a day. */
139
+ export declare function fetchViewer(gh?: GhExec): Promise<string | null>;
113
140
  /**
114
141
  * Build the full envelope. `number` (which the command requires alongside
115
142
  * `repo`) fetches and enriches exactly that one PR; otherwise every target
116
143
  * repo's open PRs are listed with checks/reviewDecision left null.
144
+ *
145
+ * `peers` are every project definition. A repository attached to more than one
146
+ * project is scoped: when this project claims part of it ({@link repoPathClaims}),
147
+ * its list keeps only PRs that touch those paths (`scope: project`) or touch no
148
+ * sharing project's paths (`scope: repo-wide`).
149
+ */
150
+ export declare function buildProjectPrs(def: ProjectDef, opts?: ProjectPrsOptions, gh?: GhExec, peers?: readonly ProjectDef[]): Promise<ProjectPrsEnvelope>;
151
+ /** How a PR is merged; the default is the first of these the repository allows. */
152
+ export declare const MERGE_METHODS: readonly ["rebase", "squash", "merge"];
153
+ export type MergeMethod = (typeof MERGE_METHODS)[number];
154
+ /** The result of one `projects prs merge`. */
155
+ export interface ProjectPrMergeResult {
156
+ repo: string;
157
+ number: number;
158
+ method: MergeMethod;
159
+ merged: boolean;
160
+ /** The merge commit SHA GitHub reports. */
161
+ sha: string | null;
162
+ message: string;
163
+ }
164
+ /** The first of {@link MERGE_METHODS} the repository allows. */
165
+ export declare function defaultMergeMethod(repo: string, gh?: GhExec): Promise<MergeMethod>;
166
+ /**
167
+ * Merge one PR over REST (`PUT repos/{repo}/pulls/{n}/merge`), pinned to `sha`:
168
+ * GitHub refuses with 409 when the head moved since the caller looked, so a push
169
+ * that landed after the menu rendered is never merged unseen. Branch protection,
170
+ * required checks and reviews are GitHub's to enforce; their refusal comes back
171
+ * as `merged: false` with GitHub's own message.
117
172
  */
118
- export declare function buildProjectPrs(def: ProjectDef, opts?: ProjectPrsOptions, gh?: GhExec): Promise<ProjectPrsEnvelope>;
173
+ export declare function mergeProjectPr(repo: string, number: number, sha: string, method: MergeMethod | undefined, gh?: GhExec): Promise<ProjectPrMergeResult>;
@@ -21,14 +21,20 @@
21
21
  * - A per-repo fetch failure is reported as `repositories[].error` and flips
22
22
  * `partial` — it is NEVER relabeled as "zero open PRs".
23
23
  */
24
+ import * as fs from 'fs';
25
+ import * as path from 'path';
24
26
  import { ghExec, canonicalizeRepo, projectRepoSlugs } from './pr-mergeable.js';
25
27
  import { rollupForSha } from './rest.js';
28
+ import { repoPathClaims } from '../projects.js';
29
+ import { getCacheDir } from '../state.js';
30
+ import { atomicWriteFileSync } from '../fs-atomic.js';
26
31
  /** The jq projection that flattens a REST PR object into {@link ProjectPr}'s scalars. */
27
32
  const PR_JQ = '{number, title, url: .html_url, isDraft: (.draft // false), ' +
28
- 'state: (.state // "" | ascii_upcase), updatedAt: (.updated_at // ""), ' +
33
+ 'state: (.state // "" | ascii_upcase), createdAt: (.created_at // ""), updatedAt: (.updated_at // ""), ' +
29
34
  'login: (.user.login // ""), avatarUrl: (.user.avatar_url // ""), ' +
30
35
  'headRefName: (.head.ref // ""), baseRefName: (.base.ref // ""), ' +
31
- 'headSha: (.head.sha // ""), body: (.body // "")}';
36
+ 'headSha: (.head.sha // ""), body: (.body // ""), ' +
37
+ 'mergeable: .mergeable, mergeableState: .mergeable_state}';
32
38
  /** Parse newline-delimited JSON (gh `--jq` streams one object per line/page). */
33
39
  function parseNdjson(out) {
34
40
  const rows = [];
@@ -48,12 +54,16 @@ export function rowToProjectPr(row) {
48
54
  url: String(row.url ?? ''),
49
55
  isDraft: Boolean(row.isDraft),
50
56
  state: String(row.state ?? ''),
57
+ createdAt: String(row.createdAt ?? ''),
51
58
  updatedAt: String(row.updatedAt ?? ''),
52
59
  author: { login: String(row.login ?? ''), avatarUrl: String(row.avatarUrl ?? '') },
53
60
  headRefName: String(row.headRefName ?? ''),
54
61
  baseRefName: String(row.baseRefName ?? ''),
55
62
  headSha: String(row.headSha ?? ''),
56
63
  body: String(row.body ?? ''),
64
+ scope: null,
65
+ mergeable: typeof row.mergeable === 'boolean' ? row.mergeable : null,
66
+ mergeableState: typeof row.mergeableState === 'string' ? row.mergeableState : null,
57
67
  checks: null,
58
68
  reviewDecision: null,
59
69
  };
@@ -121,7 +131,7 @@ export async function enrichPr(repo, pr, gh = ghExec) {
121
131
  */
122
132
  export async function resolveTargetSlugs(def, repo, gh) {
123
133
  const raw = projectRepoSlugs([def]);
124
- // Canonicalize CONCURRENTLY — each `gh repo view` can take up to gh's 30s
134
+ // Canonicalize CONCURRENTLY — each repo read can take up to gh's 30s
125
135
  // timeout, so a sequential walk of N repos would stack N×30s and blow past a
126
136
  // native caller's bounded deadline. Repo count per project is small.
127
137
  const canonPairs = await Promise.all(raw.map(async (slug) => [slug, await canonicalizeRepo(slug, gh)]));
@@ -139,19 +149,145 @@ export async function resolveTargetSlugs(def, repo, gh) {
139
149
  }
140
150
  return [requested];
141
151
  }
152
+ /**
153
+ * Classify one PR of a shared repository by the files it changes. `own` are
154
+ * this project's prefixes, `others` every other sharing project's. Returns
155
+ * null when the PR belongs only to another project.
156
+ */
157
+ export function scopeForFiles(files, own, others) {
158
+ const under = (prefixes) => files.some((f) => prefixes.some((p) => f.startsWith(p)));
159
+ if (under(own))
160
+ return 'project';
161
+ if (under(others))
162
+ return null;
163
+ return 'repo-wide';
164
+ }
165
+ /**
166
+ * The files a PR changes, keyed by its head SHA. The list for a given head is
167
+ * immutable, so a cached entry never goes stale; a push mints a new key. Entries
168
+ * for heads that are no longer open in that repo are pruned on write.
169
+ */
170
+ class PrFilesCache {
171
+ file = path.join(getCacheDir(), 'project-pr-files.json');
172
+ entries = {};
173
+ dirty = false;
174
+ constructor() {
175
+ // A missing or unreadable cache only costs re-reads; it never fails a list.
176
+ try {
177
+ const parsed = JSON.parse(fs.readFileSync(this.file, 'utf-8'));
178
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
179
+ this.entries = parsed;
180
+ }
181
+ catch {
182
+ this.entries = {};
183
+ }
184
+ }
185
+ async files(slug, pr, gh) {
186
+ const key = `${slug}@${pr.headSha}`;
187
+ const hit = this.entries[key];
188
+ if (hit)
189
+ return hit;
190
+ // A rename counts on both sides: moving a file out of a project's path touches that project.
191
+ const out = await gh(['api', `repos/${slug}/pulls/${pr.number}/files?per_page=100`, '--paginate', '--jq', '.[] | .filename, (.previous_filename // empty)']);
192
+ const files = out.split('\n').map((l) => l.trim()).filter(Boolean);
193
+ this.entries[key] = files;
194
+ this.dirty = true;
195
+ return files;
196
+ }
197
+ prune(slug, openHeads) {
198
+ for (const key of Object.keys(this.entries)) {
199
+ const at = key.lastIndexOf('@');
200
+ if (key.slice(0, at) === slug && !openHeads.has(key.slice(at + 1))) {
201
+ delete this.entries[key];
202
+ this.dirty = true;
203
+ }
204
+ }
205
+ }
206
+ save() {
207
+ if (!this.dirty)
208
+ return;
209
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
210
+ atomicWriteFileSync(this.file, JSON.stringify(this.entries));
211
+ }
212
+ }
213
+ /** Run `fn` over `items` with at most `limit` in flight, preserving order. */
214
+ async function mapBounded(items, limit, fn) {
215
+ const results = new Array(items.length);
216
+ let next = 0;
217
+ const worker = async () => {
218
+ while (next < items.length) {
219
+ const i = next++;
220
+ results[i] = await fn(items[i]);
221
+ }
222
+ };
223
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
224
+ return results;
225
+ }
226
+ /** The authenticated login, from a REST read gh caches for a day. */
227
+ export async function fetchViewer(gh = ghExec) {
228
+ try {
229
+ return (await gh(['api', 'user', '--cache', '24h', '--jq', '.login'])).trim() || null;
230
+ }
231
+ catch {
232
+ return null;
233
+ }
234
+ }
235
+ /**
236
+ * The repository paths `def` claims, keyed by canonical slug. Canonicalization
237
+ * is cached, so this costs no network after the first run of the day.
238
+ */
239
+ async function canonicalClaims(def, gh) {
240
+ const out = new Map();
241
+ for (const { slug, prefix } of repoPathClaims(def)) {
242
+ const canonical = await canonicalizeRepo(slug, gh);
243
+ out.set(canonical, [...(out.get(canonical) ?? []), prefix]);
244
+ }
245
+ return out;
246
+ }
247
+ /** For each canonical slug, the other projects attached to it and the paths they claim. */
248
+ async function repoPeers(def, peers, gh) {
249
+ const bySlug = new Map();
250
+ await Promise.all(peers.filter((p) => p.name !== def.name).map(async (peer) => {
251
+ const [slugs, claims] = await Promise.all([
252
+ Promise.all(projectRepoSlugs([peer]).map((s) => canonicalizeRepo(s, gh))),
253
+ canonicalClaims(peer, gh),
254
+ ]);
255
+ for (const slug of new Set(slugs)) {
256
+ const entry = bySlug.get(slug) ?? { names: [], prefixes: [] };
257
+ entry.names.push(peer.name);
258
+ entry.prefixes.push(...(claims.get(slug) ?? []));
259
+ bySlug.set(slug, entry);
260
+ }
261
+ }));
262
+ for (const entry of bySlug.values())
263
+ entry.names.sort();
264
+ return bySlug;
265
+ }
142
266
  /**
143
267
  * Build the full envelope. `number` (which the command requires alongside
144
268
  * `repo`) fetches and enriches exactly that one PR; otherwise every target
145
269
  * repo's open PRs are listed with checks/reviewDecision left null.
270
+ *
271
+ * `peers` are every project definition. A repository attached to more than one
272
+ * project is scoped: when this project claims part of it ({@link repoPathClaims}),
273
+ * its list keeps only PRs that touch those paths (`scope: project`) or touch no
274
+ * sharing project's paths (`scope: repo-wide`).
146
275
  */
147
- export async function buildProjectPrs(def, opts = {}, gh = ghExec) {
276
+ export async function buildProjectPrs(def, opts = {}, gh = ghExec, peers = []) {
148
277
  const project = { name: def.name, linearProjectId: def.linear?.projectId ?? null };
149
278
  const slugs = await resolveTargetSlugs(def, opts.repo, gh);
279
+ if (slugs.length === 0)
280
+ return { project, viewer: null, repositories: [], partial: false };
281
+ const viewerRead = fetchViewer(gh);
282
+ const [shared, ownClaims] = await Promise.all([repoPeers(def, peers, gh), canonicalClaims(def, gh)]);
283
+ const filesCache = new PrFilesCache();
150
284
  // Fetch repos in PARALLEL so a native caller's overall deadline scales with the
151
285
  // slowest repo, not the sum — a serial multi-repo walk of 30s gh calls can blow
152
286
  // past a bounded caller timeout and lose the whole result. Repo count is small
153
287
  // (a project's attached repos), so this is a bounded fan-out, not unbounded.
154
288
  const repositories = await Promise.all(slugs.map(async (slug) => {
289
+ const peer = shared.get(slug);
290
+ const sharedWith = peer?.names ?? [];
155
291
  try {
156
292
  let pullRequests;
157
293
  if (opts.number !== undefined) {
@@ -160,14 +296,76 @@ export async function buildProjectPrs(def, opts = {}, gh = ghExec) {
160
296
  }
161
297
  else {
162
298
  pullRequests = await listOpenPrs(slug, gh);
299
+ const own = ownClaims.get(slug);
300
+ if (peer && own) {
301
+ filesCache.prune(slug, new Set(pullRequests.map((pr) => pr.headSha)));
302
+ const scopes = await mapBounded(pullRequests, 8, async (pr) => scopeForFiles(await filesCache.files(slug, pr, gh), own, peer.prefixes));
303
+ pullRequests = pullRequests
304
+ .map((pr, i) => ({ ...pr, scope: scopes[i] }))
305
+ .filter((pr) => pr.scope !== null);
306
+ }
163
307
  }
164
- return { slug, pullRequests, error: null };
308
+ return { slug, sharedWith, pullRequests, error: null };
165
309
  }
166
310
  catch (err) {
167
311
  // A fetch failure is reported, never relabeled as zero open PRs.
168
- return { slug, pullRequests: [], error: err instanceof Error ? err.message : String(err) };
312
+ return { slug, sharedWith, pullRequests: [], error: err instanceof Error ? err.message : String(err) };
169
313
  }
170
314
  }));
315
+ filesCache.save();
171
316
  const partial = repositories.some((r) => r.error !== null);
172
- return { project, repositories, partial };
317
+ return { project, viewer: await viewerRead, repositories, partial };
318
+ }
319
+ /** How a PR is merged; the default is the first of these the repository allows. */
320
+ export const MERGE_METHODS = ['rebase', 'squash', 'merge'];
321
+ /** The first of {@link MERGE_METHODS} the repository allows. */
322
+ export async function defaultMergeMethod(repo, gh = ghExec) {
323
+ const allowed = JSON.parse((await gh([
324
+ 'api', `repos/${repo}`, '--cache', '1h',
325
+ '--jq', '{rebase: .allow_rebase_merge, squash: .allow_squash_merge, merge: .allow_merge_commit}',
326
+ ])).trim());
327
+ const method = MERGE_METHODS.find((m) => allowed[m]);
328
+ if (!method)
329
+ throw new Error(`${repo} allows no merge method this token can see.`);
330
+ return method;
331
+ }
332
+ /**
333
+ * gh prints GitHub's refusal on stderr (`gh: Required status check … (HTTP 405)`),
334
+ * sometimes followed by a hint line (a 401 adds `try authenticating with: gh auth
335
+ * login`). Keep the line carrying the HTTP status, else the first line.
336
+ */
337
+ function ghFailure(err) {
338
+ const stderr = err?.stderr;
339
+ const text = typeof stderr === 'string' && stderr.trim() ? stderr : err instanceof Error ? err.message : String(err);
340
+ const lines = text.trim().split('\n').map((l) => l.trim()).filter(Boolean);
341
+ const line = lines.find((l) => /\(HTTP \d{3}\)/.test(l)) ?? lines[0] ?? text;
342
+ return line.replace(/^gh:\s*/, '');
343
+ }
344
+ /**
345
+ * Merge one PR over REST (`PUT repos/{repo}/pulls/{n}/merge`), pinned to `sha`:
346
+ * GitHub refuses with 409 when the head moved since the caller looked, so a push
347
+ * that landed after the menu rendered is never merged unseen. Branch protection,
348
+ * required checks and reviews are GitHub's to enforce; their refusal comes back
349
+ * as `merged: false` with GitHub's own message.
350
+ */
351
+ export async function mergeProjectPr(repo, number, sha, method, gh = ghExec) {
352
+ let chosen;
353
+ try {
354
+ chosen = method ?? await defaultMergeMethod(repo, gh);
355
+ }
356
+ catch (err) {
357
+ return { repo, number, method: method ?? MERGE_METHODS[0], merged: false, sha: null, message: ghFailure(err) };
358
+ }
359
+ let out;
360
+ try {
361
+ out = await gh([
362
+ 'api', '-X', 'PUT', `repos/${repo}/pulls/${number}/merge`,
363
+ '-f', `sha=${sha}`, '-f', `merge_method=${chosen}`, '--jq', '.sha',
364
+ ]);
365
+ }
366
+ catch (err) {
367
+ return { repo, number, method: chosen, merged: false, sha: null, message: ghFailure(err) };
368
+ }
369
+ // GitHub answers this endpoint 200 only once the PR is merged; anything else made gh exit non-zero.
370
+ return { repo, number, method: chosen, merged: true, sha: out.trim() || null, message: 'Merged' };
173
371
  }
@@ -15,13 +15,23 @@ export const cursorAdapter = {
15
15
  stripForeignConfigDir(result);
16
16
  },
17
17
  execPreModeArgs(ctx) {
18
- // A configured headless run is the workspace trust decision. Keep this
19
- // narrower than --yolo/-f, which also bypasses permission checks.
20
- return ctx.resolvedMode === 'edit' && !ctx.interactive ? ['--trust'] : undefined;
18
+ // A configured headless run is the workspace trust decision, in plan mode
19
+ // too: an untrusted cwd otherwise stops on Cursor's trust prompt with no one
20
+ // to answer it. Keep this narrower than --yolo/-f, which skip already passes
21
+ // and which also bypasses permission checks.
22
+ return ctx.resolvedMode !== 'skip' && !ctx.interactive ? ['--trust'] : undefined;
23
+ },
24
+ // Headless read-only runs use ask mode, not --plan. In plan mode Cursor
25
+ // delivers its answer through the createPlan tool, which `-p` text output
26
+ // never prints, so the run exits 0 with an empty stdout. Ask mode is equally
27
+ // read-only (it refuses file writes) and prints its answer. Interactive plan
28
+ // keeps --plan, where the TUI renders the plan.
29
+ execModeArgs(ctx) {
30
+ return ctx.resolvedMode === 'plan' && !ctx.interactive ? ['--mode', 'ask'] : undefined;
21
31
  },
22
32
  routineModeArgs(cmd, ctx) {
23
33
  if (ctx.mode === 'plan') {
24
- cmd.push('--plan');
34
+ cmd.push('--mode', 'ask', '--trust');
25
35
  }
26
36
  else if (ctx.mode === 'skip') {
27
37
  cmd.push('-f');
@@ -16,9 +16,10 @@ import type { AgentId } from './types.js';
16
16
  type HarnessIdentityKind = 'strong' | 'email' | 'opaque';
17
17
  /**
18
18
  * Durable worker credential, or `none` when the harness must log in per box.
19
- * Codex is both: an API key (portable, bills the API) OR a per-device
20
- * ChatGPT-plan device-auth login (the plan seat; never stored in the reserved
21
- * store because it is a rotating session).
19
+ * Codex and Grok are both: an API key (portable, bills the API) OR a
20
+ * per-device device-auth login (the subscription seat — ChatGPT plan,
21
+ * SuperGrok, X Premium+; never stored in the reserved store because it is a
22
+ * rotating session, so each box signs in for itself).
22
23
  */
23
24
  type HarnessWorkerKind = 'setup-token' | 'none' | `api-key:${string}` | `per-device${'' | `:${string}`}`;
24
25
  type HarnessWorker = HarnessWorkerKind | HarnessWorkerKind[];
@@ -6,7 +6,7 @@ export const HARNESS_AUTH = {
6
6
  // the OS defaults to. The device-code screen prints the URL + code instead,
7
7
  // so the human finishes it in the browser profile they choose — which is
8
8
  // what makes it usable over an SSH shell on a worker.
9
- grok: { login: ['login', '--device-auth'], status: null, identity: 'strong', worker: 'api-key:XAI_API_KEY', slotEnv: 'GROK_HOME' },
9
+ grok: { login: ['login', '--device-auth'], status: null, identity: 'strong', worker: ['api-key:XAI_API_KEY', 'per-device:device-auth'], slotEnv: 'GROK_HOME' },
10
10
  // auth.json has no email claim; identity is the sorted provider-id join
11
11
  // (`resolveOpenCodeAccountId`). NATIVE_ACCOUNT_CAPABILITIES.opencode.inspection
12
12
  // is already 'opaque'.
@@ -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 },