@phnx-labs/agents-cli 1.20.34 → 1.20.36

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 (291) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/LICENSE +185 -21
  3. package/README.md +13 -5
  4. package/dist/commands/audit.d.ts +14 -0
  5. package/dist/commands/audit.js +68 -0
  6. package/dist/commands/browser.js +82 -8
  7. package/dist/commands/check.d.ts +15 -0
  8. package/dist/commands/check.js +84 -0
  9. package/dist/commands/cloud.js +143 -3
  10. package/dist/commands/computer-actions.d.ts +15 -0
  11. package/dist/commands/computer-actions.js +120 -22
  12. package/dist/commands/computer.js +81 -0
  13. package/dist/commands/cost.js +17 -12
  14. package/dist/commands/daemon.js +4 -1
  15. package/dist/commands/doctor.d.ts +1 -0
  16. package/dist/commands/doctor.js +54 -97
  17. package/dist/commands/events.d.ts +16 -0
  18. package/dist/commands/events.js +173 -0
  19. package/dist/commands/exec.d.ts +19 -0
  20. package/dist/commands/exec.js +283 -12
  21. package/dist/commands/hosts.js +37 -29
  22. package/dist/commands/inspect.d.ts +2 -0
  23. package/dist/commands/inspect.js +62 -16
  24. package/dist/commands/lock.d.ts +12 -0
  25. package/dist/commands/lock.js +70 -0
  26. package/dist/commands/logs.d.ts +17 -0
  27. package/dist/commands/logs.js +139 -0
  28. package/dist/commands/message.d.ts +15 -0
  29. package/dist/commands/message.js +56 -0
  30. package/dist/commands/models.d.ts +3 -0
  31. package/dist/commands/models.js +27 -8
  32. package/dist/commands/permissions.js +9 -2
  33. package/dist/commands/repo.d.ts +34 -0
  34. package/dist/commands/repo.js +243 -65
  35. package/dist/commands/resource-view.d.ts +20 -0
  36. package/dist/commands/resource-view.js +90 -28
  37. package/dist/commands/routines.js +101 -5
  38. package/dist/commands/secrets-migrate.js +106 -57
  39. package/dist/commands/secrets.d.ts +31 -18
  40. package/dist/commands/secrets.js +196 -91
  41. package/dist/commands/serve.d.ts +10 -0
  42. package/dist/commands/serve.js +37 -0
  43. package/dist/commands/sessions-inject.d.ts +14 -0
  44. package/dist/commands/sessions-inject.js +111 -0
  45. package/dist/commands/sessions-picker.d.ts +2 -0
  46. package/dist/commands/sessions-picker.js +24 -3
  47. package/dist/commands/sessions-resume.d.ts +2 -0
  48. package/dist/commands/sessions-resume.js +221 -0
  49. package/dist/commands/sessions-tail.d.ts +10 -0
  50. package/dist/commands/sessions-tail.js +11 -0
  51. package/dist/commands/sessions.d.ts +143 -1
  52. package/dist/commands/sessions.js +628 -80
  53. package/dist/commands/setup.js +4 -2
  54. package/dist/commands/sync.d.ts +3 -1
  55. package/dist/commands/sync.js +156 -4
  56. package/dist/commands/teams.js +217 -0
  57. package/dist/commands/versions.js +2 -4
  58. package/dist/commands/view.d.ts +3 -0
  59. package/dist/commands/view.js +19 -8
  60. package/dist/commands/wallet.d.ts +6 -0
  61. package/dist/commands/wallet.js +22 -5
  62. package/dist/commands/watchdog.d.ts +18 -0
  63. package/dist/commands/watchdog.js +238 -0
  64. package/dist/index.js +82 -32
  65. package/dist/lib/agents.js +30 -9
  66. package/dist/lib/audit/log.d.ts +92 -0
  67. package/dist/lib/audit/log.js +177 -0
  68. package/dist/lib/auto-pull.js +2 -1
  69. package/dist/lib/browser/cdp.js +5 -1
  70. package/dist/lib/browser/chrome.d.ts +10 -0
  71. package/dist/lib/browser/chrome.js +18 -7
  72. package/dist/lib/browser/drivers/ssh.d.ts +8 -0
  73. package/dist/lib/browser/drivers/ssh.js +77 -16
  74. package/dist/lib/browser/har.d.ts +84 -0
  75. package/dist/lib/browser/har.js +77 -0
  76. package/dist/lib/browser/ipc.d.ts +3 -0
  77. package/dist/lib/browser/ipc.js +37 -12
  78. package/dist/lib/browser/profiles.d.ts +1 -1
  79. package/dist/lib/browser/profiles.js +8 -10
  80. package/dist/lib/browser/refs.d.ts +65 -0
  81. package/dist/lib/browser/refs.js +73 -1
  82. package/dist/lib/browser/runtime-state.js +1 -0
  83. package/dist/lib/browser/service.d.ts +38 -2
  84. package/dist/lib/browser/service.js +183 -23
  85. package/dist/lib/browser/types.d.ts +14 -1
  86. package/dist/lib/budget/live-cloud.d.ts +42 -0
  87. package/dist/lib/budget/live-cloud.js +79 -0
  88. package/dist/lib/budget/live-team.d.ts +31 -0
  89. package/dist/lib/budget/live-team.js +115 -0
  90. package/dist/lib/cloud/codex.js +4 -0
  91. package/dist/lib/cloud/rush.d.ts +12 -1
  92. package/dist/lib/cloud/rush.js +13 -3
  93. package/dist/lib/cloud/types.d.ts +9 -0
  94. package/dist/lib/computer/dispatch.d.ts +8 -0
  95. package/dist/lib/computer/dispatch.js +125 -0
  96. package/dist/lib/computer/loop.d.ts +62 -0
  97. package/dist/lib/computer/loop.js +98 -0
  98. package/dist/lib/computer/model.d.ts +44 -0
  99. package/dist/lib/computer/model.js +157 -0
  100. package/dist/lib/concurrency.d.ts +19 -0
  101. package/dist/lib/concurrency.js +33 -0
  102. package/dist/lib/daemon.d.ts +92 -0
  103. package/dist/lib/daemon.js +222 -18
  104. package/dist/lib/devices/connect.d.ts +3 -2
  105. package/dist/lib/devices/connect.js +5 -3
  106. package/dist/lib/devices/registry.d.ts +7 -0
  107. package/dist/lib/devices/registry.js +24 -0
  108. package/dist/lib/devices/tailscale.js +1 -1
  109. package/dist/lib/drift.d.ts +52 -0
  110. package/dist/lib/drift.js +112 -0
  111. package/dist/lib/events.d.ts +10 -3
  112. package/dist/lib/events.js +101 -24
  113. package/dist/lib/exec.d.ts +17 -0
  114. package/dist/lib/exec.js +100 -12
  115. package/dist/lib/git.d.ts +27 -0
  116. package/dist/lib/git.js +56 -1
  117. package/dist/lib/hooks/cache.d.ts +6 -0
  118. package/dist/lib/hooks/cache.js +54 -12
  119. package/dist/lib/hooks.d.ts +27 -0
  120. package/dist/lib/hooks.js +135 -10
  121. package/dist/lib/hosts/dispatch.d.ts +15 -0
  122. package/dist/lib/hosts/dispatch.js +45 -13
  123. package/dist/lib/hosts/logs.d.ts +16 -0
  124. package/dist/lib/hosts/logs.js +74 -0
  125. package/dist/lib/hosts/option.js +1 -1
  126. package/dist/lib/hosts/passthrough.js +3 -1
  127. package/dist/lib/hosts/progress.d.ts +66 -0
  128. package/dist/lib/hosts/progress.js +125 -17
  129. package/dist/lib/hosts/ready.d.ts +51 -7
  130. package/dist/lib/hosts/ready.js +98 -25
  131. package/dist/lib/hosts/reconcile.d.ts +53 -0
  132. package/dist/lib/hosts/reconcile.js +81 -0
  133. package/dist/lib/hosts/registry.d.ts +19 -2
  134. package/dist/lib/hosts/registry.js +58 -2
  135. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  136. package/dist/lib/hosts/remote-cmd.js +70 -1
  137. package/dist/lib/hosts/remote-os.d.ts +17 -0
  138. package/dist/lib/hosts/remote-os.js +30 -0
  139. package/dist/lib/hosts/session-index.d.ts +34 -0
  140. package/dist/lib/hosts/session-index.js +56 -0
  141. package/dist/lib/hosts/tasks.d.ts +22 -0
  142. package/dist/lib/hosts/tasks.js +29 -0
  143. package/dist/lib/lock.d.ts +93 -0
  144. package/dist/lib/lock.js +207 -0
  145. package/dist/lib/loop.js +16 -1
  146. package/dist/lib/machine-id.d.ts +21 -0
  147. package/dist/lib/machine-id.js +26 -0
  148. package/dist/lib/mailbox-target.d.ts +36 -0
  149. package/dist/lib/mailbox-target.js +45 -0
  150. package/dist/lib/mailbox.d.ts +47 -0
  151. package/dist/lib/mailbox.js +194 -0
  152. package/dist/lib/mcp.d.ts +5 -0
  153. package/dist/lib/mcp.js +24 -8
  154. package/dist/lib/migrate.d.ts +19 -0
  155. package/dist/lib/migrate.js +134 -26
  156. package/dist/lib/overdue.js +3 -0
  157. package/dist/lib/permissions.d.ts +4 -0
  158. package/dist/lib/permissions.js +35 -0
  159. package/dist/lib/picker.d.ts +26 -0
  160. package/dist/lib/picker.js +127 -0
  161. package/dist/lib/platform/exec.d.ts +46 -0
  162. package/dist/lib/platform/exec.js +74 -0
  163. package/dist/lib/platform/process.d.ts +31 -0
  164. package/dist/lib/platform/process.js +34 -1
  165. package/dist/lib/platform/winpath.js +2 -0
  166. package/dist/lib/plugin-marketplace.d.ts +30 -0
  167. package/dist/lib/plugin-marketplace.js +215 -2
  168. package/dist/lib/plugins.d.ts +5 -0
  169. package/dist/lib/plugins.js +61 -10
  170. package/dist/lib/profiles.d.ts +25 -0
  171. package/dist/lib/profiles.js +22 -6
  172. package/dist/lib/pty-client.js +2 -1
  173. package/dist/lib/rotate.d.ts +61 -0
  174. package/dist/lib/rotate.js +52 -0
  175. package/dist/lib/routines.d.ts +40 -2
  176. package/dist/lib/routines.js +66 -8
  177. package/dist/lib/runner.d.ts +11 -2
  178. package/dist/lib/runner.js +49 -7
  179. package/dist/lib/scheduler.js +6 -1
  180. package/dist/lib/secrets/bundles.d.ts +60 -4
  181. package/dist/lib/secrets/bundles.js +131 -12
  182. package/dist/lib/secrets/filestore.d.ts +3 -0
  183. package/dist/lib/secrets/filestore.js +42 -16
  184. package/dist/lib/secrets/index.d.ts +43 -2
  185. package/dist/lib/secrets/index.js +102 -3
  186. package/dist/lib/secrets/mcp.d.ts +93 -0
  187. package/dist/lib/secrets/mcp.js +205 -0
  188. package/dist/lib/secrets/remote.js +12 -5
  189. package/dist/lib/secrets/sync.js +83 -4
  190. package/dist/lib/secrets/windows.js +14 -3
  191. package/dist/lib/serve/data.d.ts +81 -0
  192. package/dist/lib/serve/data.js +91 -0
  193. package/dist/lib/serve/page.d.ts +7 -0
  194. package/dist/lib/serve/page.js +140 -0
  195. package/dist/lib/serve/server.d.ts +46 -0
  196. package/dist/lib/serve/server.js +115 -0
  197. package/dist/lib/session/active.d.ts +61 -0
  198. package/dist/lib/session/active.js +196 -17
  199. package/dist/lib/session/discover.d.ts +37 -0
  200. package/dist/lib/session/discover.js +204 -27
  201. package/dist/lib/session/inject.d.ts +18 -0
  202. package/dist/lib/session/inject.js +21 -0
  203. package/dist/lib/session/parse.d.ts +6 -0
  204. package/dist/lib/session/parse.js +286 -2
  205. package/dist/lib/session/pid-registry.d.ts +22 -0
  206. package/dist/lib/session/pid-registry.js +104 -0
  207. package/dist/lib/session/provenance.d.ts +14 -2
  208. package/dist/lib/session/provenance.js +39 -8
  209. package/dist/lib/session/remote-active.d.ts +26 -0
  210. package/dist/lib/session/remote-active.js +153 -0
  211. package/dist/lib/session/remote-list.d.ts +51 -0
  212. package/dist/lib/session/remote-list.js +213 -0
  213. package/dist/lib/session/remote.d.ts +7 -1
  214. package/dist/lib/session/remote.js +18 -8
  215. package/dist/lib/session/render.js +1 -1
  216. package/dist/lib/session/sync/config.d.ts +1 -7
  217. package/dist/lib/session/sync/config.js +4 -11
  218. package/dist/lib/session/types.d.ts +18 -1
  219. package/dist/lib/session/types.js +1 -1
  220. package/dist/lib/shims.d.ts +44 -6
  221. package/dist/lib/shims.js +143 -57
  222. package/dist/lib/ssh-exec.d.ts +26 -3
  223. package/dist/lib/ssh-exec.js +47 -3
  224. package/dist/lib/ssh-tunnel.d.ts +24 -5
  225. package/dist/lib/ssh-tunnel.js +62 -63
  226. package/dist/lib/staleness/writers/hooks.js +1 -1
  227. package/dist/lib/startup/command-registry.d.ts +8 -0
  228. package/dist/lib/startup/command-registry.js +17 -1
  229. package/dist/lib/state.d.ts +13 -0
  230. package/dist/lib/state.js +103 -9
  231. package/dist/lib/sync-umbrella.d.ts +14 -7
  232. package/dist/lib/sync-umbrella.js +17 -9
  233. package/dist/lib/teams/forEach.d.ts +110 -0
  234. package/dist/lib/teams/forEach.js +186 -0
  235. package/dist/lib/teams/index.d.ts +1 -0
  236. package/dist/lib/teams/index.js +1 -0
  237. package/dist/lib/teams/pr-watch.d.ts +226 -0
  238. package/dist/lib/teams/pr-watch.js +371 -0
  239. package/dist/lib/teams/registry.js +25 -9
  240. package/dist/lib/teams/supervisor.d.ts +14 -1
  241. package/dist/lib/teams/supervisor.js +19 -0
  242. package/dist/lib/teams/worktree.d.ts +9 -0
  243. package/dist/lib/teams/worktree.js +32 -0
  244. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  245. package/dist/lib/terminal/backends/ghostty.js +69 -0
  246. package/dist/lib/terminal/backends/index.d.ts +19 -0
  247. package/dist/lib/terminal/backends/index.js +31 -0
  248. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  249. package/dist/lib/terminal/backends/iterm.js +62 -0
  250. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  251. package/dist/lib/terminal/backends/tmux.js +23 -0
  252. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  253. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  254. package/dist/lib/terminal/engine.d.ts +39 -0
  255. package/dist/lib/terminal/engine.js +54 -0
  256. package/dist/lib/terminal/index.d.ts +17 -0
  257. package/dist/lib/terminal/index.js +8 -0
  258. package/dist/lib/terminal/inject.d.ts +204 -0
  259. package/dist/lib/terminal/inject.js +247 -0
  260. package/dist/lib/terminal/policy.d.ts +11 -0
  261. package/dist/lib/terminal/policy.js +11 -0
  262. package/dist/lib/terminal/quote.d.ts +11 -0
  263. package/dist/lib/terminal/quote.js +13 -0
  264. package/dist/lib/terminal/resolve.d.ts +64 -0
  265. package/dist/lib/terminal/resolve.js +90 -0
  266. package/dist/lib/terminal/shell.d.ts +6 -0
  267. package/dist/lib/terminal/shell.js +23 -0
  268. package/dist/lib/terminal/transport.d.ts +15 -0
  269. package/dist/lib/terminal/transport.js +40 -0
  270. package/dist/lib/terminal/types.d.ts +59 -0
  271. package/dist/lib/terminal/types.js +13 -0
  272. package/dist/lib/triggers/webhook.d.ts +85 -0
  273. package/dist/lib/triggers/webhook.js +141 -0
  274. package/dist/lib/types.d.ts +7 -0
  275. package/dist/lib/versions.d.ts +30 -3
  276. package/dist/lib/versions.js +129 -18
  277. package/dist/lib/watchdog/index.d.ts +3 -0
  278. package/dist/lib/watchdog/index.js +5 -0
  279. package/dist/lib/watchdog/read.d.ts +35 -0
  280. package/dist/lib/watchdog/read.js +149 -0
  281. package/dist/lib/watchdog/runner.d.ts +127 -0
  282. package/dist/lib/watchdog/runner.js +322 -0
  283. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  284. package/dist/lib/watchdog/watchdog.js +166 -0
  285. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  286. package/dist/lib/watchdog/watchdogTail.js +154 -0
  287. package/dist/lib/whats-new.d.ts +9 -0
  288. package/dist/lib/whats-new.js +35 -0
  289. package/dist/lib/workflows.d.ts +166 -0
  290. package/dist/lib/workflows.js +193 -0
  291. package/package.json +5 -4
@@ -11,7 +11,7 @@ import { connectSSH, shellQuote } from './drivers/ssh.js';
11
11
  import { clearProfileRuntime } from './runtime-state.js';
12
12
  import { resolveDomainSkill } from './domain-skills.js';
13
13
  import { generateTaskId, generateShortId, generateTaskName, } from './types.js';
14
- import { getRefs, resolveRefToCoords } from './refs.js';
14
+ import { getRefs, resolveRefToCoords, describeRefs, healRef } from './refs.js';
15
15
  import { clickAtCoords, hoverAtCoords, scrollAtCoords, typeText, pressKey, focusNode } from './input.js';
16
16
  import { typeEditorText } from './editor.js';
17
17
  import { detectUploadPattern, stageUploadFile, uploadToDropTarget, uploadToFileInput, uploadViaFileChooser, } from './upload.js';
@@ -412,12 +412,23 @@ export class BrowserService {
412
412
  return this.stop(taskName);
413
413
  }
414
414
  async stopProfile(profileName) {
415
- const conn = this.connections.get(profileName);
416
- if (conn) {
415
+ // Connections are keyed by the composite `<profile>@<endpoint>` (see start()),
416
+ // but callers pass the bare profile name (or, occasionally, an exact composite).
417
+ // A plain `connections.get(profileName)` therefore missed every real remote
418
+ // connection, so `cleanup()` never ran — leaving the SSH tunnel and, on
419
+ // Windows, the WMI-spawned browser orphaned on the remote host after every
420
+ // `browser stop --profile` (#559). Match the exact key AND every
421
+ // `<profileName>@<endpoint>` composite so all of a profile's live connections
422
+ // are torn down.
423
+ const keys = [...this.connections.keys()].filter((k) => k === profileName || k.startsWith(`${profileName}@`));
424
+ for (const key of keys) {
425
+ const conn = this.connections.get(key);
426
+ if (!conn)
427
+ continue;
417
428
  conn.cdp.close();
418
429
  killChrome(conn.pid);
419
430
  conn.cleanup?.();
420
- this.connections.delete(profileName);
431
+ this.connections.delete(key);
421
432
  }
422
433
  const runtimeDir = getProfileRuntimeDir(profileName);
423
434
  const pidFile = path.join(runtimeDir, 'pid');
@@ -661,6 +672,31 @@ export class BrowserService {
661
672
  { width: 0, height: 0 };
662
673
  return { path: finalPath, bytes: buffer.length, width: dims.width, height: dims.height };
663
674
  }
675
+ /**
676
+ * Export the current tab as a PDF via CDP `Page.printToPDF`. Reuses the
677
+ * screenshot session + tab resolution so `--tab`, path sandboxing, and the
678
+ * auto-path (`sessions/<task>/<ts>.pdf`) all behave identically to
679
+ * `screenshot`. `printBackground: true` matches Chrome's default print flow
680
+ * — without it, dark-mode pages render on a blank sheet.
681
+ */
682
+ async printToPdf(taskId, tabHint, outputPath) {
683
+ const { conn, task } = await this.findTask(taskId);
684
+ const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
685
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
686
+ const target = await this.getTarget(conn, cdpTargetId);
687
+ if (!target) {
688
+ throw new Error(`Tab ${shortId} not found`);
689
+ }
690
+ const sessionId = await this.getSessionId(conn, target.targetId);
691
+ const { data } = (await conn.cdp.send('Page.printToPDF', { printBackground: true, preferCSSPageSize: true }, sessionId));
692
+ const buffer = Buffer.from(data, 'base64');
693
+ const sessionsDir = path.join(getBrowserRuntimeDir(), 'sessions', task.name);
694
+ const automaticPath = path.join(sessionsDir, `${Date.now()}.pdf`);
695
+ const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
696
+ await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
697
+ await fs.promises.writeFile(finalPath, buffer);
698
+ return { path: finalPath, bytes: buffer.length };
699
+ }
664
700
  // ─── Recording ──────────────────────────────────────────────────────────────
665
701
  //
666
702
  // CDP `Page.startScreencast` emits a JPEG frame per `everyNthFrame`. We pipe
@@ -693,17 +729,25 @@ export class BrowserService {
693
729
  const outputPath = path.join(recordingsDir, `${Date.now()}.webm`);
694
730
  // Resolve ffmpeg lazily so non-recording paths don't pay the import cost.
695
731
  const { spawn } = await import('child_process');
732
+ // CDP `Page.startScreencast` delivers frames at a VARIABLE cadence (paints
733
+ // are event-driven, not clocked). Feeding those into a fixed `-framerate`
734
+ // input made ffmpeg assume every frame was 1/fps apart, so a page that
735
+ // painted slower/faster than `fps` played back at the wrong speed. Instead
736
+ // stamp each frame with its wall-clock arrival time
737
+ // (`-use_wallclock_as_timestamps 1`) and keep those timings on output
738
+ // (`-vsync vfr`), so playback duration matches the real capture.
696
739
  const ffmpeg = spawn('ffmpeg', [
697
740
  '-loglevel', 'error',
698
741
  '-f', 'image2pipe',
699
- '-framerate', String(fps),
742
+ '-use_wallclock_as_timestamps', '1',
700
743
  '-i', '-',
701
744
  '-c:v', 'libvpx-vp9',
702
745
  '-b:v', '1M',
703
746
  '-pix_fmt', 'yuv420p',
747
+ '-vsync', 'vfr',
704
748
  '-y',
705
749
  outputPath,
706
- ], { stdio: ['pipe', 'ignore', 'pipe'] });
750
+ ], { stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
707
751
  // Wait for the spawn to confirm (or fail) before we wire CDP frames into a
708
752
  // dead pipe. ENOENT from a missing ffmpeg surfaces here as a real error
709
753
  // instead of a silently empty .webm.
@@ -724,9 +768,16 @@ export class BrowserService {
724
768
  ffmpeg.once('error', onError);
725
769
  ffmpeg.once('spawn', onSpawn);
726
770
  });
727
- // Surface ffmpeg's own diagnostics (encoder error, etc.) in case stderr
728
- // arrives after spawn.
729
- ffmpeg.stderr?.on('data', () => { });
771
+ // Capture ffmpeg's own diagnostics (encoder error, bad codec, etc.) so a
772
+ // failing encode is DIAGNOSABLE at recordStop instead of being discarded.
773
+ // Cap the buffer so a chatty ffmpeg can't grow it unbounded.
774
+ let stderrBuf = '';
775
+ ffmpeg.stderr?.on('data', (d) => {
776
+ stderrBuf += d.toString();
777
+ if (stderrBuf.length > 64 * 1024)
778
+ stderrBuf = stderrBuf.slice(-64 * 1024);
779
+ });
780
+ const ffmpegStderr = () => stderrBuf;
730
781
  ffmpeg.on('error', () => { });
731
782
  // 30 fps is CDP's screencast cap; everyNthFrame = round(30/fps).
732
783
  const everyNthFrame = Math.max(1, Math.round(30 / fps));
@@ -752,6 +803,7 @@ export class BrowserService {
752
803
  durationMs,
753
804
  maxBytes,
754
805
  ffmpeg,
806
+ ffmpegStderr,
755
807
  sessionId,
756
808
  conn,
757
809
  frameHandler,
@@ -795,27 +847,56 @@ export class BrowserService {
795
847
  catch {
796
848
  // session may already be gone
797
849
  }
798
- // Close ffmpeg stdin so it flushes the output file cleanly.
799
- await new Promise((resolve) => {
800
- rec.ffmpeg.on('exit', () => resolve());
850
+ // Close ffmpeg stdin so it flushes the output file cleanly, and observe how
851
+ // it exits. A non-zero exit means the encode failed — the recording must
852
+ // NOT be reported as success. If ffmpeg hangs, KILL it (don't just abandon
853
+ // the promise, which leaked the process) and treat the recording as failed.
854
+ const finalize = await new Promise((resolve) => {
855
+ let done = false;
856
+ const settle = (r) => {
857
+ if (done)
858
+ return;
859
+ done = true;
860
+ clearTimeout(timer);
861
+ resolve(r);
862
+ };
863
+ const timer = setTimeout(() => {
864
+ try {
865
+ rec.ffmpeg.kill('SIGKILL');
866
+ }
867
+ catch { /* already gone */ }
868
+ settle({ code: null, timedOut: true });
869
+ }, 5000);
870
+ rec.ffmpeg.once('exit', (code) => settle({ code, timedOut: false }));
801
871
  try {
802
872
  rec.ffmpeg.stdin?.end();
803
873
  }
804
874
  catch {
805
- resolve();
875
+ // stdin already closed; wait for exit or the hard timeout above.
806
876
  }
807
- setTimeout(resolve, 5000); // Hard timeout if ffmpeg hangs
808
877
  });
878
+ const durationMs = Date.now() - rec.startedAt;
879
+ // Drop the recording from the map before any throw, so a failed stop
880
+ // doesn't wedge the task in a permanent "already recording" state.
881
+ this.recordings.delete(taskId);
882
+ if (finalize.timedOut) {
883
+ throw new Error(`ffmpeg did not exit within 5s while finalizing the recording; killed it. ` +
884
+ `The recording at ${rec.outputPath} is incomplete.`);
885
+ }
886
+ if (finalize.code !== 0) {
887
+ const err = rec.ffmpegStderr().trim();
888
+ throw new Error(`ffmpeg exited abnormally (code ${finalize.code}) while finalizing the recording at ` +
889
+ `${rec.outputPath}; the file is likely corrupt or empty.` +
890
+ (err ? ` ffmpeg: ${err.slice(-800)}` : ''));
891
+ }
809
892
  let bytes = 0;
810
893
  try {
811
894
  const st = await fs.promises.stat(rec.outputPath);
812
895
  bytes = st.size;
813
896
  }
814
897
  catch {
815
- // ffmpeg may have failed; file missing
898
+ // ffmpeg exited 0 but the file is missing — still a failed recording.
816
899
  }
817
- const durationMs = Date.now() - rec.startedAt;
818
- this.recordings.delete(taskId);
819
900
  return { path: rec.outputPath, bytes, durationMs, reason };
820
901
  }
821
902
  async recordStatus(taskId) {
@@ -824,7 +905,6 @@ export class BrowserService {
824
905
  return { recording: false };
825
906
  return { recording: true, path: rec.outputPath, elapsedMs: Date.now() - rec.startedAt };
826
907
  }
827
- refsCache = new Map();
828
908
  async refs(taskId, tabHint, opts = {}) {
829
909
  const { conn, task } = await this.findTask(taskId);
830
910
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
@@ -833,7 +913,51 @@ export class BrowserService {
833
913
  if (!target)
834
914
  throw new Error(`Tab ${shortId} not found`);
835
915
  const sessionId = await this.getSessionId(conn, target.targetId);
836
- return getRefs(conn.cdp, sessionId, opts);
916
+ const result = await getRefs(conn.cdp, sessionId, opts);
917
+ // Snapshot the stable descriptors AND the opts they were numbered against
918
+ // so a later click/type can self-heal a drifted ref by rebuilding with the
919
+ // same filter. `refs()` is the sole owner of this cache — actions read it,
920
+ // never overwrite it. Persist to tasks.json so it survives a daemon
921
+ // restart.
922
+ this.cacheRefDescriptors(task, shortId, result.nodeMap, result.opts);
923
+ await this.saveTaskState(task.profile, conn.tasks);
924
+ return { refs: result.refs, nodeMap: result.nodeMap };
925
+ }
926
+ /** Record the last ref listing (descriptors + opts) for a tab into state. */
927
+ cacheRefDescriptors(task, shortId, nodeMap, opts) {
928
+ if (!task.refDescriptors)
929
+ task.refDescriptors = {};
930
+ task.refDescriptors[shortId] = { descriptors: describeRefs(nodeMap), opts };
931
+ }
932
+ /**
933
+ * Re-resolve a caller-supplied ref against a freshly-built node map, healing
934
+ * it back to the right element when the integer ref has drifted since the
935
+ * cached `refs` listing. Shared by `click` and `type` so both self-heal
936
+ * identically. Returns the ref to act on plus, when a heal occurred, the
937
+ * {@link HealInfo} to surface. Throws when the cached element is gone.
938
+ */
939
+ resolveHealedRef(snapshot, nodeMap, ref) {
940
+ const cached = snapshot?.descriptors.find((d) => d.ref === ref);
941
+ if (!cached)
942
+ return { targetRef: ref };
943
+ const fresh = nodeMap.get(ref);
944
+ const stillMatches = fresh !== undefined &&
945
+ fresh.role === cached.role &&
946
+ fresh.name === cached.name &&
947
+ fresh.backendNodeId !== undefined;
948
+ if (stillMatches)
949
+ return { targetRef: ref };
950
+ const newRef = healRef(cached, nodeMap);
951
+ if (newRef === null) {
952
+ throw new Error(`Ref ${ref} (${cached.role} "${cached.name}") could not be re-resolved: ` +
953
+ `no matching element on the current page. Re-run 'browser refs' to ` +
954
+ `refresh the ref numbers, or act by position with 'browser click --at X,Y'.`);
955
+ }
956
+ if (newRef === ref)
957
+ return { targetRef: ref };
958
+ console.error(`[browser] self-healed ref ${ref} -> ${newRef} (${cached.role} "${cached.name}") — ` +
959
+ `cached descriptor re-matched after the ref drifted`);
960
+ return { targetRef: newRef, healed: { from: ref, to: newRef, role: cached.role, name: cached.name } };
837
961
  }
838
962
  async click(taskId, ref, tabHint) {
839
963
  const { conn, task } = await this.findTask(taskId);
@@ -843,8 +967,38 @@ export class BrowserService {
843
967
  if (!target)
844
968
  throw new Error(`Tab ${shortId} not found`);
845
969
  const sessionId = await this.getSessionId(conn, target.targetId);
846
- const { nodeMap } = await getRefs(conn.cdp, sessionId, { interactive: false, limit: 1000 });
847
- const { x, y } = await resolveRefToCoords(conn.cdp, sessionId, nodeMap, ref);
970
+ // Rebuild the node map with the SAME opts the cached listing was numbered
971
+ // against, so the caller's ref lands on the element they saw in `browser
972
+ // refs`. Rebuilding with a different filter (the old interactive:false)
973
+ // renumbers every ref and defeats self-heal on the second click. Default
974
+ // to the user-facing interactive numbering when no listing was cached yet.
975
+ const snapshot = task.refDescriptors?.[shortId];
976
+ const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
977
+ const { nodeMap } = await getRefs(conn.cdp, sessionId, buildOpts);
978
+ // Self-heal: the integer ref is positional and drifts on re-render. If the
979
+ // fresh node at this position no longer matches the cached descriptor,
980
+ // re-resolve (by attrs/proximity-tie-broken role+name) BEFORE clicking the
981
+ // wrong element. The cache is owned by refs() and NOT rewritten here — the
982
+ // caller's ref numbers stay anchored to the listing they came from.
983
+ const { targetRef, healed } = this.resolveHealedRef(snapshot, nodeMap, ref);
984
+ const { x, y } = await resolveRefToCoords(conn.cdp, sessionId, nodeMap, targetRef);
985
+ await clickAtCoords(conn.cdp, sessionId, x, y);
986
+ return healed ? { healed } : {};
987
+ }
988
+ /**
989
+ * Click raw viewport coordinates, bypassing ref resolution entirely. Backs
990
+ * `browser click --at X,Y` — the escape hatch when the accessibility tree
991
+ * exposes no usable ref (canvas apps, custom-drawn UI) and the caller has
992
+ * located the target from a screenshot.
993
+ */
994
+ async clickAt(taskId, x, y, tabHint) {
995
+ const { conn, task } = await this.findTask(taskId);
996
+ const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
997
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
998
+ const target = await this.getTarget(conn, cdpTargetId);
999
+ if (!target)
1000
+ throw new Error(`Tab ${shortId} not found`);
1001
+ const sessionId = await this.getSessionId(conn, target.targetId);
848
1002
  await clickAtCoords(conn.cdp, sessionId, x, y);
849
1003
  }
850
1004
  async type(taskId, ref, text, tabHint, clear) {
@@ -855,8 +1009,14 @@ export class BrowserService {
855
1009
  if (!target)
856
1010
  throw new Error(`Tab ${shortId} not found`);
857
1011
  const sessionId = await this.getSessionId(conn, target.targetId);
858
- const { nodeMap } = await getRefs(conn.cdp, sessionId, { interactive: false, limit: 1000 });
859
- const node = nodeMap.get(ref);
1012
+ // Same self-healing story as click(): rebuild against the cached listing's
1013
+ // opts so refs line up with what the user saw, then heal a drifted ref
1014
+ // before typing into the wrong field.
1015
+ const snapshot = task.refDescriptors?.[shortId];
1016
+ const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
1017
+ const { nodeMap } = await getRefs(conn.cdp, sessionId, buildOpts);
1018
+ const { targetRef } = this.resolveHealedRef(snapshot, nodeMap, ref);
1019
+ const node = nodeMap.get(targetRef);
860
1020
  if (!node)
861
1021
  throw new Error(`Ref ${ref} not found`);
862
1022
  if (node.editor) {
@@ -78,6 +78,15 @@ export interface Task {
78
78
  currentTabId?: string;
79
79
  createdAt: number;
80
80
  pid: number;
81
+ /**
82
+ * Per-tab snapshot of the last ref listing captured for that tab
83
+ * (shortId -> {descriptors, opts}). Persisted to tasks.json so a later
84
+ * `click`/`type <ref>` can self-heal a drifted ref — the cached `opts` let
85
+ * the action rebuild its node map with the SAME accessibility filter the
86
+ * listing was numbered against. Owned by `refs()`; actions never overwrite
87
+ * it. See RefSnapshot / RefDescriptor.
88
+ */
89
+ refDescriptors?: Record<string, import('./refs.js').RefSnapshot>;
81
90
  }
82
91
  export interface TabInfo {
83
92
  id: string;
@@ -118,7 +127,7 @@ export interface HistoricalTask {
118
127
  domains: string[];
119
128
  tabCount: number;
120
129
  }
121
- export type IPCAction = 'start' | 'record-start' | 'record-stop' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download' | 'upload' | 'getAppLogs' | 'version';
130
+ export type IPCAction = 'start' | 'record-start' | 'record-stop' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'pdf' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download' | 'upload' | 'getAppLogs' | 'version';
122
131
  export interface IPCRequest {
123
132
  action: IPCAction;
124
133
  task?: string;
@@ -129,6 +138,8 @@ export interface IPCRequest {
129
138
  expr?: string;
130
139
  path?: string;
131
140
  ref?: number;
141
+ atX?: number;
142
+ atY?: number;
132
143
  text?: string;
133
144
  key?: string;
134
145
  scrollX?: number;
@@ -194,6 +205,8 @@ export interface IPCResponse {
194
205
  height?: number;
195
206
  refs?: string;
196
207
  nodes?: RefNodeJson[];
208
+ /** Human-readable note surfaced to the CLI (e.g. a self-heal notice on click). */
209
+ message?: string;
197
210
  port?: number;
198
211
  pid?: number;
199
212
  fps?: number;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Live budget kill-switch for `agents cloud` dispatch (issue #399).
3
+ *
4
+ * Follow-up to #346, which shipped a pre-flight budget gate for cloud runs but
5
+ * no live mid-run enforcement — a runaway cloud task would keep spending until
6
+ * the user hit Ctrl-C. This wires the same `makeLiveSpendWatcher` the local
7
+ * headless run uses (src/lib/exec.ts) into the cloud SSE stream: each `usage`
8
+ * event from the provider feeds the watcher, and on breach we call
9
+ * `provider.cancel(taskId)` to terminate server-side.
10
+ *
11
+ * Wrapper shape mirrors `renderStream`'s input — it takes an
12
+ * `AsyncIterable<CloudEvent>` and yields the same events downstream. Dormant
13
+ * (returns the source stream unchanged) when no caps are configured.
14
+ */
15
+ import type { CloudProvider, CloudEvent } from '../cloud/types.js';
16
+ import { type BreachInfo } from './enforce.js';
17
+ /** Result surface exposed to the caller so it can act on a mid-stream breach. */
18
+ export interface CloudBudgetGate {
19
+ /** True once a cap crossed and cancel() was invoked. */
20
+ breached(): boolean;
21
+ breach(): BreachInfo | null;
22
+ }
23
+ /**
24
+ * Wrap a cloud event stream with a live budget watcher. On a `usage` event we
25
+ * feed the shared watcher; on the first breach we call `provider.cancel(taskId)`
26
+ * and forward a synthetic `status:'cancelled'` + `error` frame so the renderer
27
+ * shows why the task stopped. Returns null when no caps are configured; callers
28
+ * fall through to the raw stream in that case.
29
+ */
30
+ export declare function wrapStreamWithBudgetGate(args: {
31
+ provider: CloudProvider;
32
+ taskId: string;
33
+ /** Project attribution key — repo slug for Rush, or cwd. */
34
+ project: string;
35
+ /** Agent the dispatch runs under (for per_agent cap accounting). */
36
+ agent: string;
37
+ /** cwd used to resolve the effective budget config. */
38
+ cwd?: string;
39
+ }): {
40
+ wrap: (source: AsyncIterable<CloudEvent>) => AsyncIterable<CloudEvent>;
41
+ gate: CloudBudgetGate;
42
+ } | null;
@@ -0,0 +1,79 @@
1
+ import { capsFromConfig, makeLiveSpendWatcher, } from './enforce.js';
2
+ import { resolveBudgetConfig, hasAnyCap } from './config.js';
3
+ import { loadLedger, localDay, spendForDay, spendForProject } from './ledger.js';
4
+ /**
5
+ * Wrap a cloud event stream with a live budget watcher. On a `usage` event we
6
+ * feed the shared watcher; on the first breach we call `provider.cancel(taskId)`
7
+ * and forward a synthetic `status:'cancelled'` + `error` frame so the renderer
8
+ * shows why the task stopped. Returns null when no caps are configured; callers
9
+ * fall through to the raw stream in that case.
10
+ */
11
+ export function wrapStreamWithBudgetGate(args) {
12
+ const cfg = resolveBudgetConfig(args.cwd);
13
+ if (!hasAnyCap(cfg))
14
+ return null;
15
+ const today = localDay();
16
+ const entries = loadLedger();
17
+ const caps = capsFromConfig(cfg, {
18
+ daySpend: spendForDay(today, entries),
19
+ projectSpend: spendForProject(args.project, entries),
20
+ });
21
+ let firstBreach = null;
22
+ const watcher = makeLiveSpendWatcher({
23
+ caps,
24
+ onBreach: (b) => {
25
+ firstBreach = b;
26
+ },
27
+ });
28
+ async function* wrap(source) {
29
+ try {
30
+ for await (const event of source) {
31
+ if (event.type === 'usage') {
32
+ watcher.feedUsage({
33
+ agent: args.agent,
34
+ model: event.model,
35
+ inputTokens: event.inputTokens ?? 0,
36
+ outputTokens: event.outputTokens ?? 0,
37
+ });
38
+ if (watcher.breached() && firstBreach) {
39
+ // Cancel server-side FIRST so we stop the meter; then surface the
40
+ // breach to the renderer as an error + cancelled status so the CLI
41
+ // exits with a visible reason (not a silent stream close).
42
+ try {
43
+ await args.provider.cancel(args.taskId);
44
+ }
45
+ catch (err) {
46
+ // Best-effort — even if cancel fails, break the stream: the
47
+ // caller sees the error frame and can retry manually.
48
+ yield {
49
+ type: 'error',
50
+ message: `[budget] cap ${firstBreach.cap} exceeded ($${firstBreach.spend.toFixed(2)} > $${firstBreach.limit.toFixed(2)}); cancel FAILED: ${err.message}`,
51
+ timestamp: new Date().toISOString(),
52
+ };
53
+ yield { type: 'status', status: 'cancelled', timestamp: new Date().toISOString() };
54
+ return;
55
+ }
56
+ yield {
57
+ type: 'error',
58
+ message: `[budget] cap ${firstBreach.cap} exceeded ($${firstBreach.spend.toFixed(2)} > $${firstBreach.limit.toFixed(2)}) — cancelled cloud task ${args.taskId}`,
59
+ timestamp: new Date().toISOString(),
60
+ };
61
+ yield { type: 'status', status: 'cancelled', timestamp: new Date().toISOString() };
62
+ return;
63
+ }
64
+ }
65
+ yield event;
66
+ }
67
+ }
68
+ finally {
69
+ watcher.dispose();
70
+ }
71
+ }
72
+ return {
73
+ wrap,
74
+ gate: {
75
+ breached: () => watcher.breached(),
76
+ breach: () => firstBreach,
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,31 @@
1
+ import type { AgentManager } from '../teams/agents.js';
2
+ import { type BreachInfo } from './enforce.js';
3
+ /** Public surface of the team-scoped budget watcher. */
4
+ export interface TeamBudgetWatcher {
5
+ /** Feed any new usage events from every running teammate's stdout.log. */
6
+ poll(): Promise<void>;
7
+ /** True once any cap has been crossed (mirrors LiveSpendWatcher). */
8
+ breached(): boolean;
9
+ /** The first breach seen, or null if none yet. */
10
+ breach(): BreachInfo | null;
11
+ /** Release references and stop tapping streams. Idempotent. */
12
+ dispose(): void;
13
+ }
14
+ /**
15
+ * Build a team-scoped budget watcher for the given team. Returns null when the
16
+ * effective budget config has no caps — matching the local watcher's dormant
17
+ * contract so callers pay nothing when the feature is off.
18
+ *
19
+ * The watcher is seeded with prior-day and prior-project spend from the ledger
20
+ * so a partway-through day counts today's earlier runs against `per_day`.
21
+ * Each `poll()` reads new bytes from every running teammate's stdout.log and
22
+ * feeds parsed usage events into the shared watcher — `onBreach` fires exactly
23
+ * once, mirroring `makeLiveSpendWatcher`.
24
+ */
25
+ export declare function createTeamBudgetWatcher(args: {
26
+ manager: AgentManager;
27
+ team: string;
28
+ /** Project/cwd used to (a) resolve budget config and (b) seed project spend. */
29
+ cwd: string;
30
+ onBreach: (breach: BreachInfo) => void;
31
+ }): TeamBudgetWatcher | null;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Live budget kill-switch for `agents teams` supervisor (issue #399).
3
+ *
4
+ * Follow-up to #346, which wired `makeLiveSpendWatcher` into local `agents run`
5
+ * only. Teams spawns each teammate as its own `agents run --headless --json`
6
+ * process, so per-teammate `per_run` caps already fire from those child
7
+ * watchers. What was still missing was aggregate cross-teammate enforcement
8
+ * (`per_project` / `per_day` / `per_agent` combined), because the ledger only
9
+ * gets written on child close — a long-running teammate would blow the shared
10
+ * cap without any single child watcher noticing.
11
+ *
12
+ * This module reuses `makeLiveSpendWatcher` verbatim: on each supervisor wave
13
+ * we tail every running teammate's stdout.log through `extractUsageEvents`
14
+ * (the same primitive the local run path uses in src/lib/exec.ts), feed the
15
+ * usage events into ONE shared watcher, and expose `breached()` so the
16
+ * supervisor can call `stopByTask` to terminate the whole team.
17
+ *
18
+ * Dormant (returns null) when no caps are configured — same zero-cost contract
19
+ * as the local watcher.
20
+ */
21
+ import * as fs from 'fs';
22
+ import { capsFromConfig, extractUsageEvents, makeLiveSpendWatcher, } from './enforce.js';
23
+ import { resolveBudgetConfig, hasAnyCap } from './config.js';
24
+ import { loadLedger, localDay, spendForDay, spendForProject } from './ledger.js';
25
+ /**
26
+ * Build a team-scoped budget watcher for the given team. Returns null when the
27
+ * effective budget config has no caps — matching the local watcher's dormant
28
+ * contract so callers pay nothing when the feature is off.
29
+ *
30
+ * The watcher is seeded with prior-day and prior-project spend from the ledger
31
+ * so a partway-through day counts today's earlier runs against `per_day`.
32
+ * Each `poll()` reads new bytes from every running teammate's stdout.log and
33
+ * feeds parsed usage events into the shared watcher — `onBreach` fires exactly
34
+ * once, mirroring `makeLiveSpendWatcher`.
35
+ */
36
+ export function createTeamBudgetWatcher(args) {
37
+ const cfg = resolveBudgetConfig(args.cwd);
38
+ if (!hasAnyCap(cfg))
39
+ return null;
40
+ const today = localDay();
41
+ const entries = loadLedger();
42
+ const caps = capsFromConfig(cfg, {
43
+ daySpend: spendForDay(today, entries),
44
+ projectSpend: spendForProject(args.cwd, entries),
45
+ });
46
+ let firstBreach = null;
47
+ const watcher = makeLiveSpendWatcher({
48
+ caps,
49
+ onBreach: (b) => {
50
+ firstBreach = b;
51
+ args.onBreach(b);
52
+ },
53
+ });
54
+ const cursors = new Map();
55
+ let disposed = false;
56
+ return {
57
+ async poll() {
58
+ if (disposed || watcher.breached())
59
+ return;
60
+ const teammates = await args.manager.listByTask(args.team);
61
+ for (const agent of teammates) {
62
+ // Only tap running local teammates: PENDING has no output yet, cloud
63
+ // teammates emit through a different channel (their own SSE stream),
64
+ // and terminal states produce no new bytes.
65
+ if (agent.status !== 'running')
66
+ continue;
67
+ if (agent.cloudProvider)
68
+ continue;
69
+ const stdoutPath = await agent.getStdoutPath();
70
+ let stat;
71
+ try {
72
+ stat = fs.statSync(stdoutPath);
73
+ }
74
+ catch {
75
+ continue; // File not yet created — nothing to read.
76
+ }
77
+ const cursor = cursors.get(agent.agentId) ?? { offset: 0, pending: '' };
78
+ if (stat.size <= cursor.offset) {
79
+ cursors.set(agent.agentId, cursor);
80
+ continue;
81
+ }
82
+ const fd = fs.openSync(stdoutPath, 'r');
83
+ try {
84
+ const toRead = stat.size - cursor.offset;
85
+ const buffer = Buffer.alloc(toRead);
86
+ const bytesRead = fs.readSync(fd, buffer, 0, toRead, cursor.offset);
87
+ const chunk = buffer.toString('utf-8', 0, bytesRead);
88
+ cursor.offset += bytesRead;
89
+ const { events, rest } = extractUsageEvents(chunk, cursor.pending, undefined, agent.agentType);
90
+ cursor.pending = rest;
91
+ for (const ev of events) {
92
+ watcher.feedUsage(ev);
93
+ if (watcher.breached())
94
+ break;
95
+ }
96
+ }
97
+ finally {
98
+ fs.closeSync(fd);
99
+ }
100
+ cursors.set(agent.agentId, cursor);
101
+ if (watcher.breached())
102
+ return;
103
+ }
104
+ },
105
+ breached: () => watcher.breached(),
106
+ breach: () => firstBreach,
107
+ dispose() {
108
+ if (disposed)
109
+ return;
110
+ disposed = true;
111
+ watcher.dispose();
112
+ cursors.clear();
113
+ },
114
+ };
115
+ }
@@ -104,6 +104,10 @@ export class CodexCloudProvider {
104
104
  cancel: false,
105
105
  message: false,
106
106
  multiRepo: false,
107
+ // `codex cloud exec --env <id> <prompt>` is the only dispatch surface the
108
+ // codex CLI exposes — it has no flag to attach images or ride-along skills,
109
+ // and the env bundles its own context at creation time. Both stay false
110
+ // until the upstream CLI grows an attachment surface.
107
111
  skills: false,
108
112
  images: false,
109
113
  };
@@ -4,7 +4,7 @@
4
4
  * Auth: reads the session token from ~/.rush/user.yaml (written by `rush login`).
5
5
  * Requires the Rush GitHub App installed on the target repo.
6
6
  */
7
- import type { CloudProvider, CloudTask, CloudTaskStatus, CloudEvent, DispatchOptions, ProviderCapabilities } from './types.js';
7
+ import type { CloudProvider, CloudTask, CloudTaskStatus, CloudEvent, DispatchOptions, ProviderCapabilities, ImageAttachment, SkillRef } from './types.js';
8
8
  export declare const RUSH_CONSENT_PATH: string;
9
9
  export declare function hasRushUploadConsent(accountFingerprint: string, opts?: DispatchOptions, consentPath?: string): boolean;
10
10
  /** One version's entry in the account manifest sent on every dispatch. */
@@ -63,6 +63,17 @@ export declare function buildDispatchBody(input: {
63
63
  }>;
64
64
  accountManifest?: AccountManifest | null;
65
65
  accountTokens?: AccountTokenEntry[] | null;
66
+ /**
67
+ * Skill ride-alongs so the cloud pod isn't context-blind. Forwarded verbatim
68
+ * as `skills` so the Factory Floor can mount them by id/version before the
69
+ * agent runs. Omitted when empty.
70
+ */
71
+ skills?: SkillRef[] | null;
72
+ /**
73
+ * Base64 image attachments for vision dispatch. Sliced to
74
+ * MAX_IMAGES_PER_DISPATCH — extras are dropped, never sent. Omitted when empty.
75
+ */
76
+ images?: ImageAttachment[] | null;
66
77
  }): Record<string, unknown>;
67
78
  /** A single account registered in Rush Cloud's multi-account rotation pool. */
68
79
  export interface RemoteAccount {