@phnx-labs/agents-cli 1.22.114 → 1.22.115

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 (176) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +22 -102
  3. package/dist/browser.js +0 -0
  4. package/dist/cli/command-registry.js +0 -7
  5. package/dist/commands/accounts.d.ts +3 -77
  6. package/dist/commands/accounts.js +13 -366
  7. package/dist/commands/audit.js +1 -1
  8. package/dist/commands/auth.js +0 -2
  9. package/dist/commands/browser.d.ts +53 -0
  10. package/dist/commands/browser.js +199 -9
  11. package/dist/commands/doctor.d.ts +2 -3
  12. package/dist/commands/doctor.js +0 -11
  13. package/dist/commands/events.d.ts +1 -3
  14. package/dist/commands/events.js +4 -7
  15. package/dist/commands/exec.d.ts +0 -1
  16. package/dist/commands/exec.js +2 -7
  17. package/dist/commands/feed-watch.js +52 -8
  18. package/dist/commands/focus.js +1 -1
  19. package/dist/commands/go.d.ts +0 -17
  20. package/dist/commands/go.js +2 -19
  21. package/dist/commands/logs.js +1 -1
  22. package/dist/commands/mcp.js +8 -83
  23. package/dist/commands/memory.js +4 -47
  24. package/dist/commands/message.js +4 -4
  25. package/dist/commands/plugins.js +1 -93
  26. package/dist/commands/repo.js +0 -44
  27. package/dist/commands/resume.js +4 -1
  28. package/dist/commands/secrets-passthrough.js +2 -2
  29. package/dist/commands/send.d.ts +4 -4
  30. package/dist/commands/send.js +6 -51
  31. package/dist/commands/sessions-backup-setup.js +1 -1
  32. package/dist/commands/sessions-resume.js +0 -1
  33. package/dist/commands/sessions-share.d.ts +5 -7
  34. package/dist/commands/sessions-share.js +98 -49
  35. package/dist/commands/sessions.js +1 -8
  36. package/dist/commands/setup-browser.js +18 -2
  37. package/dist/commands/setup-computer.js +20 -6
  38. package/dist/commands/setup-secrets.js +24 -4
  39. package/dist/commands/setup-terminal.d.ts +3 -0
  40. package/dist/commands/setup-terminal.js +22 -0
  41. package/dist/commands/setup.d.ts +1 -1
  42. package/dist/commands/setup.js +20 -10
  43. package/dist/commands/skills.js +0 -8
  44. package/dist/commands/ssh.d.ts +6 -0
  45. package/dist/commands/ssh.js +92 -233
  46. package/dist/commands/sync.js +14 -5
  47. package/dist/commands/teams.js +1 -1
  48. package/dist/commands/traces.js +1 -1
  49. package/dist/lib/accounts/add.d.ts +0 -5
  50. package/dist/lib/accounts/add.js +1 -7
  51. package/dist/lib/artifacts-client.d.ts +20 -0
  52. package/dist/lib/artifacts-client.js +46 -0
  53. package/dist/lib/auth-mint.js +2 -2
  54. package/dist/lib/browser/runtime-state.d.ts +55 -0
  55. package/dist/lib/browser/runtime-state.js +99 -18
  56. package/dist/lib/browser/service.js +21 -1
  57. package/dist/lib/cli-resources.js +3 -1
  58. package/dist/lib/cloud/dispatch.js +1 -1
  59. package/dist/lib/cloudflare/creds.d.ts +10 -0
  60. package/dist/lib/cloudflare/creds.js +46 -0
  61. package/dist/lib/cloudflare/provision.d.ts +35 -0
  62. package/dist/lib/cloudflare/provision.js +144 -0
  63. package/dist/lib/computer/sessions-list.d.ts +55 -0
  64. package/dist/lib/computer/sessions-list.js +168 -1
  65. package/dist/lib/daemon/daemon.js +8 -1
  66. package/dist/lib/daemon/feed-stream-service.d.ts +23 -0
  67. package/dist/lib/daemon/feed-stream-service.js +40 -0
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/devices/connect.d.ts +49 -5
  71. package/dist/lib/devices/connect.js +169 -21
  72. package/dist/lib/feed/envelope.d.ts +79 -0
  73. package/dist/lib/feed/envelope.js +23 -0
  74. package/dist/lib/feed/events.d.ts +6 -0
  75. package/dist/lib/feed/events.js +8 -0
  76. package/dist/lib/feed/hub-server.d.ts +86 -0
  77. package/dist/lib/feed/hub-server.js +334 -0
  78. package/dist/lib/feed/hub.d.ts +95 -0
  79. package/dist/lib/feed/hub.js +255 -0
  80. package/dist/lib/feed/tool-activity.d.ts +108 -0
  81. package/dist/lib/feed/tool-activity.js +313 -0
  82. package/dist/lib/feed/tools.d.ts +198 -0
  83. package/dist/lib/feed/tools.js +265 -0
  84. package/dist/lib/feed/watch.d.ts +50 -50
  85. package/dist/lib/feed/watch.js +147 -16
  86. package/dist/lib/format.d.ts +1 -1
  87. package/dist/lib/format.js +1 -1
  88. package/dist/lib/git.d.ts +0 -16
  89. package/dist/lib/git.js +0 -58
  90. package/dist/lib/helper-versions.js +1 -1
  91. package/dist/lib/hosts/remote-cmd.d.ts +51 -1
  92. package/dist/lib/hosts/remote-cmd.js +125 -8
  93. package/dist/lib/hosts/remote-cmd.test-fixture.d.ts +2 -0
  94. package/dist/lib/hosts/remote-cmd.test-fixture.js +22 -0
  95. package/dist/lib/mcp.js +17 -11
  96. package/dist/lib/probe.d.ts +4 -1
  97. package/dist/lib/probe.js +5 -2
  98. package/dist/lib/pwsh.d.ts +33 -0
  99. package/dist/lib/pwsh.js +56 -0
  100. package/dist/lib/redact.d.ts +8 -0
  101. package/dist/lib/redact.js +11 -0
  102. package/dist/lib/refresh.d.ts +6 -2
  103. package/dist/lib/refresh.js +92 -72
  104. package/dist/lib/secrets-cli.d.ts +11 -0
  105. package/dist/lib/secrets-cli.js +30 -0
  106. package/dist/lib/secrets-client.js +3 -2
  107. package/dist/lib/session/detached.d.ts +7 -0
  108. package/dist/lib/session/detached.js +29 -0
  109. package/dist/lib/session/remote/peer-stream.d.ts +24 -2
  110. package/dist/lib/session/remote/peer-stream.js +33 -6
  111. package/dist/lib/session/sync/backend.d.ts +3 -3
  112. package/dist/lib/session/sync/backend.js +3 -3
  113. package/dist/lib/session/sync/provision.d.ts +1 -1
  114. package/dist/lib/session/sync/provision.js +2 -2
  115. package/dist/lib/sessions-client.js +0 -3
  116. package/dist/lib/setup-tool-install.d.ts +3 -0
  117. package/dist/lib/setup-tool-install.js +26 -0
  118. package/dist/lib/setup-tool-status.d.ts +22 -0
  119. package/dist/lib/setup-tool-status.js +215 -0
  120. package/dist/lib/share-runtime.d.ts +11 -0
  121. package/dist/lib/share-runtime.js +63 -0
  122. package/dist/lib/smart-launch.d.ts +1 -5
  123. package/dist/lib/smart-launch.js +3 -11
  124. package/dist/lib/ssh-exec.d.ts +44 -0
  125. package/dist/lib/ssh-exec.js +119 -0
  126. package/dist/lib/startup/command-registry.d.ts +6 -4
  127. package/dist/lib/startup/command-registry.js +10 -7
  128. package/dist/lib/state.js +2 -2
  129. package/dist/lib/storage/selection.d.ts +2 -2
  130. package/dist/lib/storage/selection.js +2 -2
  131. package/dist/lib/sync-umbrella.d.ts +5 -0
  132. package/dist/lib/sync-umbrella.js +18 -10
  133. package/dist/lib/traces/backend.d.ts +1 -2
  134. package/dist/lib/traces/backend.js +1 -2
  135. package/dist/lib/traces/provision.d.ts +1 -1
  136. package/dist/lib/traces/provision.js +2 -2
  137. package/dist/lib/types.d.ts +8 -6
  138. package/package.json +2 -3
  139. package/dist/commands/artifacts-setup.d.ts +0 -53
  140. package/dist/commands/artifacts-setup.js +0 -161
  141. package/dist/commands/artifacts.d.ts +0 -18
  142. package/dist/commands/artifacts.js +0 -58
  143. package/dist/commands/attach.d.ts +0 -12
  144. package/dist/commands/attach.js +0 -86
  145. package/dist/commands/auth-mint.d.ts +0 -12
  146. package/dist/commands/auth-mint.js +0 -108
  147. package/dist/commands/reconnect.d.ts +0 -46
  148. package/dist/commands/reconnect.js +0 -115
  149. package/dist/commands/share.d.ts +0 -293
  150. package/dist/commands/share.js +0 -1424
  151. package/dist/lib/share/analytics.d.ts +0 -13
  152. package/dist/lib/share/analytics.js +0 -45
  153. package/dist/lib/share/backend.d.ts +0 -120
  154. package/dist/lib/share/backend.js +0 -176
  155. package/dist/lib/share/capture.d.ts +0 -31
  156. package/dist/lib/share/capture.js +0 -174
  157. package/dist/lib/share/config.d.ts +0 -72
  158. package/dist/lib/share/config.js +0 -211
  159. package/dist/lib/share/delete.d.ts +0 -123
  160. package/dist/lib/share/delete.js +0 -173
  161. package/dist/lib/share/html.d.ts +0 -20
  162. package/dist/lib/share/html.js +0 -88
  163. package/dist/lib/share/http-error.d.ts +0 -53
  164. package/dist/lib/share/http-error.js +0 -65
  165. package/dist/lib/share/og.d.ts +0 -26
  166. package/dist/lib/share/og.js +0 -84
  167. package/dist/lib/share/provision.d.ts +0 -127
  168. package/dist/lib/share/provision.js +0 -285
  169. package/dist/lib/share/publish.d.ts +0 -379
  170. package/dist/lib/share/publish.js +0 -818
  171. package/dist/lib/share/worker-template.d.ts +0 -27
  172. package/dist/lib/share/worker-template.js +0 -2424
  173. package/dist/lib/storage/index.d.ts +0 -14
  174. package/dist/lib/storage/index.js +0 -14
  175. package/dist/lib/storage/visibility.d.ts +0 -82
  176. package/dist/lib/storage/visibility.js +0 -99
@@ -144,10 +144,59 @@ export declare function listAllProfileSnapshots(): ProfileSnapshot[];
144
144
  * alone — we'd rather leak than wrongly kill a user-owned process that
145
145
  * happens to share metadata.
146
146
  */
147
+ /**
148
+ * Clean up what a DEAD daemon genuinely left behind — which is not the browser.
149
+ *
150
+ * A live local browser outliving its daemon is the NORMAL case, not an orphan.
151
+ * `BrowserService.shutdown` deliberately closes only CDP and leaves the browser
152
+ * running, and on macOS the spawn is detached so the daemon's own positive-pid
153
+ * `killTree` reaches the daemon alone. This reaper used to read "the recorded
154
+ * `daemonPid` has exited" as "everything it recorded is garbage" and SIGTERM the
155
+ * browser — so replacing the daemon (a menubar activation, an upgrade, a crash
156
+ * restart) killed a browser the user was looking at, and `clearProfileRuntime`
157
+ * then removed the `port`/meta that {@link readProfileRuntime} needs, so even a
158
+ * survivor could not be re-attached and a second browser would launch beside it.
159
+ *
160
+ * The three cases are now decided independently:
161
+ *
162
+ * - **A live local browser/electron is preserved untouched** — not signalled, not
163
+ * cleared, and its `daemonPid` deliberately NOT rewritten. Ownership is adopted
164
+ * only after an identity-checked attach succeeds (`adoptProfileRuntimeOwner`,
165
+ * called from the reuse branch in `BrowserService.connectProfile`), so nothing
166
+ * claims a browser it has not proven it can talk to. Re-running this is a no-op.
167
+ * - **A dead browser record is cleared**, with no signal sent: there is nothing
168
+ * alive to signal, and the stale pid/port would otherwise mislead the next
169
+ * attach.
170
+ * - **A stale tunnel IS reaped.** A local `ssh -L` cannot outlive its purpose, so
171
+ * a dead owner does make it an orphan. This is the one thing the old code was
172
+ * right about; it just shared a code path with the browser.
173
+ *
174
+ * The accepted trade: a genuinely leaked browser from a long-dead daemon is no
175
+ * longer force-killed here. Idle/abandoned cleanup has its own owner in
176
+ * `browser/hygiene.ts` (session-dead + idle), which the daemon's periodic tick and
177
+ * `agents browser prune` both call — that is the right place for it, because it can
178
+ * tell "idle and abandoned" from "in use right now" and this function cannot.
179
+ */
147
180
  export declare function reapOrphanedProcesses(): {
148
181
  reaped: number;
149
182
  details: string[];
150
183
  };
184
+ /**
185
+ * Take ownership of a runtime record after an identity-checked attach succeeded.
186
+ *
187
+ * Called from the reuse branch in `BrowserService.connectProfile`, which has just
188
+ * probed `/json/version` and run `verifyBrowserIdentity` against the profile's
189
+ * expected browser family — so by this point the process on the recorded port is
190
+ * proven to be ours. Only then may a new service claim it.
191
+ *
192
+ * Rewrites `daemonPid` ALONE. Every other field — `pid`, `port`, `command`,
193
+ * `userDataDir`, `spawnedAt`, `kind`, `tunnelPid` — is the original launch's
194
+ * metadata and stays exactly as recorded: this is an ownership handover, not a
195
+ * re-registration, and rewriting the rest would lose the provenance the reaper and
196
+ * the hygiene pass both read. A record that has since disappeared is left alone
197
+ * rather than recreated from a partial view.
198
+ */
199
+ export declare function adoptProfileRuntimeOwner(profileName: string, daemonPid?: number): boolean;
151
200
  /**
152
201
  * True when ANY cache dir belonging to this profile still has a live browser or
153
202
  * SSH tunnel, or an open task. The composite naming (`<name>@<endpoint>`) means
@@ -233,4 +282,10 @@ export declare function buildProfilePrunePlan(): Promise<PrunePlan>;
233
282
  * went, matching what `--dry-run` printed.
234
283
  */
235
284
  export declare function pruneProfiles(plan: PrunePlan): Promise<PrunePlan>;
285
+ /**
286
+ * The executable/image name the live `pid` is running, or null if it can't be
287
+ * determined. The process-listing API differs per OS: Windows has no `ps`, so
288
+ * we query `tasklist` (CSV image name in column 1); POSIX uses `ps -o comm=`.
289
+ */
290
+ export declare function liveProcessCommand(pid: number): string | null;
236
291
  export {};
@@ -258,6 +258,39 @@ function readTaskCount(dir) {
258
258
  * alone — we'd rather leak than wrongly kill a user-owned process that
259
259
  * happens to share metadata.
260
260
  */
261
+ /**
262
+ * Clean up what a DEAD daemon genuinely left behind — which is not the browser.
263
+ *
264
+ * A live local browser outliving its daemon is the NORMAL case, not an orphan.
265
+ * `BrowserService.shutdown` deliberately closes only CDP and leaves the browser
266
+ * running, and on macOS the spawn is detached so the daemon's own positive-pid
267
+ * `killTree` reaches the daemon alone. This reaper used to read "the recorded
268
+ * `daemonPid` has exited" as "everything it recorded is garbage" and SIGTERM the
269
+ * browser — so replacing the daemon (a menubar activation, an upgrade, a crash
270
+ * restart) killed a browser the user was looking at, and `clearProfileRuntime`
271
+ * then removed the `port`/meta that {@link readProfileRuntime} needs, so even a
272
+ * survivor could not be re-attached and a second browser would launch beside it.
273
+ *
274
+ * The three cases are now decided independently:
275
+ *
276
+ * - **A live local browser/electron is preserved untouched** — not signalled, not
277
+ * cleared, and its `daemonPid` deliberately NOT rewritten. Ownership is adopted
278
+ * only after an identity-checked attach succeeds (`adoptProfileRuntimeOwner`,
279
+ * called from the reuse branch in `BrowserService.connectProfile`), so nothing
280
+ * claims a browser it has not proven it can talk to. Re-running this is a no-op.
281
+ * - **A dead browser record is cleared**, with no signal sent: there is nothing
282
+ * alive to signal, and the stale pid/port would otherwise mislead the next
283
+ * attach.
284
+ * - **A stale tunnel IS reaped.** A local `ssh -L` cannot outlive its purpose, so
285
+ * a dead owner does make it an orphan. This is the one thing the old code was
286
+ * right about; it just shared a code path with the browser.
287
+ *
288
+ * The accepted trade: a genuinely leaked browser from a long-dead daemon is no
289
+ * longer force-killed here. Idle/abandoned cleanup has its own owner in
290
+ * `browser/hygiene.ts` (session-dead + idle), which the daemon's periodic tick and
291
+ * `agents browser prune` both call — that is the right place for it, because it can
292
+ * tell "idle and abandoned" from "in use right now" and this function cannot.
293
+ */
261
294
  export function reapOrphanedProcesses() {
262
295
  const root = getBrowserRuntimeDir();
263
296
  if (!fs.existsSync(root))
@@ -275,28 +308,76 @@ export function reapOrphanedProcesses() {
275
308
  // Owning daemon still alive — leave its kids alone.
276
309
  if (isProcessAlive(meta.daemonPid))
277
310
  continue;
278
- // Kill what the dead daemon left behind. Best-effort.
279
- const kill = (pid, label) => {
280
- if (!pid || pid === 0)
281
- return;
282
- // Only kill if it matches the recorded command — guards against
283
- // pid reuse handing us an unrelated process to murder.
284
- if (meta.command && !matchesCommand(pid, meta.command) &&
285
- !matchesCommand(pid, 'ssh'))
286
- return;
287
- try {
288
- process.kill(pid, 'SIGTERM');
289
- reaped++;
290
- details.push(`reaped ${label ?? 'pid'} ${pid} (profile ${profileName})`);
311
+ // A local `ssh -L` whose owner is gone is genuinely orphaned: nothing is
312
+ // driving it and it cannot be re-adopted, so it is the one process to signal.
313
+ // Matched against the recorded command (or `ssh`) so a recycled pid is not
314
+ // mistaken for our tunnel.
315
+ if (meta.tunnelPid && meta.tunnelPid !== 0) {
316
+ const cmdOk = !meta.command
317
+ || matchesCommand(meta.tunnelPid, meta.command)
318
+ || matchesCommand(meta.tunnelPid, 'ssh');
319
+ if (cmdOk) {
320
+ try {
321
+ process.kill(meta.tunnelPid, 'SIGTERM');
322
+ reaped++;
323
+ details.push(`reaped tunnel ${meta.tunnelPid} (profile ${profileName})`);
324
+ }
325
+ catch { /* already gone */ }
291
326
  }
292
- catch { /* already gone */ }
293
- };
294
- kill(meta.pid, 'browser');
295
- kill(meta.tunnelPid, 'tunnel');
327
+ }
328
+ // A tunnel-kind record has no local browser of its own, so clearing it is the
329
+ // whole cleanup. `pid` on such a record is the REMOTE browser (normally 0) and
330
+ // is never ours to signal.
331
+ if (meta.kind === 'tunnel') {
332
+ clearProfileRuntime(profileName);
333
+ continue;
334
+ }
335
+ // `isProcessAlive` re-checks the recorded command, so a recycled pid does not
336
+ // read as our browser. A record with no pid at all is stale by definition.
337
+ const liveBrowser = !!meta.pid && meta.pid !== 0 && isProcessAlive(meta.pid, meta.command);
338
+ if (liveBrowser) {
339
+ details.push(`preserved live browser ${meta.pid} (profile ${profileName})`);
340
+ continue;
341
+ }
342
+ // Dead browser: clear the stale record. No signal — there is nothing there.
296
343
  clearProfileRuntime(profileName);
344
+ if (meta.pid)
345
+ details.push(`cleared dead browser record ${meta.pid} (profile ${profileName})`);
297
346
  }
298
347
  return { reaped, details };
299
348
  }
349
+ /**
350
+ * Take ownership of a runtime record after an identity-checked attach succeeded.
351
+ *
352
+ * Called from the reuse branch in `BrowserService.connectProfile`, which has just
353
+ * probed `/json/version` and run `verifyBrowserIdentity` against the profile's
354
+ * expected browser family — so by this point the process on the recorded port is
355
+ * proven to be ours. Only then may a new service claim it.
356
+ *
357
+ * Rewrites `daemonPid` ALONE. Every other field — `pid`, `port`, `command`,
358
+ * `userDataDir`, `spawnedAt`, `kind`, `tunnelPid` — is the original launch's
359
+ * metadata and stays exactly as recorded: this is an ownership handover, not a
360
+ * re-registration, and rewriting the rest would lose the provenance the reaper and
361
+ * the hygiene pass both read. A record that has since disappeared is left alone
362
+ * rather than recreated from a partial view.
363
+ */
364
+ export function adoptProfileRuntimeOwner(profileName, daemonPid = process.pid) {
365
+ const meta = readProfileRuntimeMeta(profileName);
366
+ if (!meta)
367
+ return false;
368
+ if (meta.daemonPid === daemonPid)
369
+ return true;
370
+ const dir = getProfileRuntimeDir(profileName);
371
+ try {
372
+ fs.writeFileSync(path.join(dir, META_FILE), JSON.stringify({ ...meta, daemonPid }));
373
+ return true;
374
+ }
375
+ catch {
376
+ // Losing the handover is not worth failing an otherwise good attach; the next
377
+ // startup simply preserves the browser again and re-attaches.
378
+ return false;
379
+ }
380
+ }
300
381
  /**
301
382
  * True when ANY cache dir belonging to this profile still has a live browser or
302
383
  * SSH tunnel, or an open task. The composite naming (`<name>@<endpoint>`) means
@@ -478,7 +559,7 @@ function matchesCommand(pid, expectedCommand) {
478
559
  * determined. The process-listing API differs per OS: Windows has no `ps`, so
479
560
  * we query `tasklist` (CSV image name in column 1); POSIX uses `ps -o comm=`.
480
561
  */
481
- function liveProcessCommand(pid) {
562
+ export function liveProcessCommand(pid) {
482
563
  try {
483
564
  if (process.platform === 'win32') {
484
565
  const out = execFileSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'], {
@@ -8,7 +8,7 @@ import { assertRemoteControlAllowedForRequest } from './remote-control.js';
8
8
  import { connectLocal } from './drivers/local.js';
9
9
  import { connectSSH, shellQuote } from './drivers/ssh.js';
10
10
  import { adoptLegacyRuntimeIfLocal, isLegacyEndpointKey, resolveBrowserTarget, shouldForkProfile, } from './resolve-target.js';
11
- import { clearProfileRuntime, listProfileCacheDirs, readProfileRuntimeMeta, isProcessAlive } from './runtime-state.js';
11
+ import { adoptProfileRuntimeOwner, clearProfileRuntime, listProfileCacheDirs, readProfileRuntimeMeta, isProcessAlive } from './runtime-state.js';
12
12
  import { resolveDomainSkill } from './domain-skills.js';
13
13
  import { generateTaskId, generateShortId, asConnectionKey, parseConnectionKey, keyBelongsToProfile, } from './types.js';
14
14
  import { reapAbandonedTasks, resolveLiveIdentities, taskOwnerIsGone, } from './hygiene.js';
@@ -1212,6 +1212,8 @@ export class BrowserService {
1212
1212
  await this.saveTaskState(task.profile, conn.tasks);
1213
1213
  return { tabId: resolvedTabId };
1214
1214
  }
1215
+ requireCdp(conn, 'enumerate');
1216
+ await conn.cdp.send('Target.activateTarget', { targetId: task.tabs[resolvedTabId] });
1215
1217
  task.currentTabId = resolvedTabId;
1216
1218
  await this.saveTaskState(task.profile, conn.tasks);
1217
1219
  return { tabId: resolvedTabId };
@@ -2771,6 +2773,12 @@ export class BrowserService {
2771
2773
  await cdp.connect(wsUrl);
2772
2774
  await this.enableDomains(cdp);
2773
2775
  const tasks = this.loadTaskState(key);
2776
+ // The attach is now PROVEN: the stored port answered /json/version and
2777
+ // `verifyBrowserIdentity` matched the profile's browser family. Only here
2778
+ // may this service claim a browser the startup reaper deliberately left
2779
+ // alone — it rewrites `daemonPid` only, keeping the original launch's
2780
+ // metadata. An ambiguous identity throws above and adopts nothing.
2781
+ adoptProfileRuntimeOwner(key);
2774
2782
  return {
2775
2783
  backend: 'cdp',
2776
2784
  cdp,
@@ -3398,6 +3406,10 @@ export class BrowserService {
3398
3406
  const port = parseEndpointUrl(resolved.target)?.port ?? 9600;
3399
3407
  try {
3400
3408
  const ff = await connectFirefox(profile, key, port, { profileDir: profile.userDataDir });
3409
+ // A served port that accepted a fresh BiDi session is this profile's own
3410
+ // Firefox, so claim the record for this daemon (see the local-CDP claim
3411
+ // below for why adoption waits for a proven attach).
3412
+ adoptProfileRuntimeOwner(key);
3401
3413
  const tasks = this.loadTaskState(key);
3402
3414
  for (const [k, t] of diskTasks) {
3403
3415
  if (!tasks.has(k))
@@ -3476,6 +3488,14 @@ export class BrowserService {
3476
3488
  const cdp = new CDPClient();
3477
3489
  await cdp.connect(wsUrl);
3478
3490
  await this.enableDomains(cdp);
3491
+ // This is the path a replacement daemon takes to recover a browser its
3492
+ // predecessor launched, so it is where ownership actually transfers. Only
3493
+ // now — after `verifyBrowserIdentity` confirmed the browser family on the
3494
+ // recorded port and CDP is attached — is it proven that this record
3495
+ // describes a browser we are driving. `adoptProfileRuntimeOwner` rewrites
3496
+ // `daemonPid` alone; an ambiguous identity throws above and adopts
3497
+ // nothing, leaving the record (and the browser) untouched.
3498
+ adoptProfileRuntimeOwner(key);
3479
3499
  const tasks = this.loadTaskState(key);
3480
3500
  for (const [k, t] of diskTasks) {
3481
3501
  if (!tasks.has(k))
@@ -23,6 +23,7 @@ import * as yaml from 'yaml';
23
23
  import { listResources, resolveResource } from './resources.js';
24
24
  import { probeCapture } from './probe.js';
25
25
  import { composeWin32CommandLine } from './platform/index.js';
26
+ import { execFileShellSpec } from './platform/exec.js';
26
27
  import { localBinDir } from './platform/posixpath.js';
27
28
  // ─── Validation primitives ───────────────────────────────────────────────────
28
29
  /** Token allowed inside `check:` strings — letters, digits, underscore, dot, slash, dash. */
@@ -457,7 +458,8 @@ export function buildInstallCommand(method) {
457
458
  function runInstallMethod(method) {
458
459
  if ('npm' in method) {
459
460
  assertNpmPackage(method.npm);
460
- const r = spawnSync('npm', ['install', '-g', method.npm], { stdio: 'inherit' });
461
+ const invocation = execFileShellSpec('npm', ['install', '-g', method.npm]);
462
+ const r = spawnSync(invocation.command, invocation.args, { stdio: 'inherit', shell: invocation.shell });
461
463
  if (r.status !== 0) {
462
464
  throw new Error(`npm install -g ${method.npm} exited with status ${r.status ?? 'unknown'}`);
463
465
  }
@@ -16,7 +16,7 @@ import { insertTask, updateTaskStatus } from './store.js';
16
16
  import { renderStream } from './stream.js';
17
17
  import { MissingTargetError, MAX_IMAGES_PER_DISPATCH } from './types.js';
18
18
  import { emit } from '../feed/events.js';
19
- import { shareRuntimeEnv } from '../share/config.js';
19
+ import { shareRuntimeEnv } from '../share-runtime.js';
20
20
  /** Map a supported image file extension to its wire mimeType. Rejects anything else. */
21
21
  function imageMimeFromPath(file) {
22
22
  const ext = path.extname(file).toLowerCase();
@@ -0,0 +1,10 @@
1
+ export declare const DEFAULT_CF_BUNDLE = "cloudflare";
2
+ /** Cloudflare API credentials for provisioning, read from `cloudflare` (or a
3
+ * user-named bundle). Fuzzy-matches key names so it works across bundle layouts. */
4
+ export declare function readCloudflareCreds(bundle?: string, override?: {
5
+ apiToken?: string;
6
+ accountId?: string;
7
+ }): {
8
+ apiToken: string;
9
+ accountId: string;
10
+ };
@@ -0,0 +1,46 @@
1
+ // Cloudflare API credential glue, shared by the commands that provision or read
2
+ // Cloudflare-backed endpoints (`agents traces`, `agents sessions backup`).
3
+ //
4
+ // This used to live in `lib/share/config.ts` alongside the embedded artifact
5
+ // share engine. The share engine moved out to `@phnx-labs/artifacts-cli`
6
+ // (PHNX-3992); `readCloudflareCreds` stayed because it is a generic Cloudflare
7
+ // util that `traces`/`sessions backup` borrow, unrelated to sharing.
8
+ import { bundleExistsSync as bundleExists, readAndResolveBundleEnvSync as readAndResolveBundleEnv, } from '../secrets-client.js';
9
+ export const DEFAULT_CF_BUNDLE = 'cloudflare';
10
+ /** Cloudflare API credentials for provisioning, read from `cloudflare` (or a
11
+ * user-named bundle). Fuzzy-matches key names so it works across bundle layouts. */
12
+ export function readCloudflareCreds(bundle = DEFAULT_CF_BUNDLE, override) {
13
+ // Explicit --token/--account bypass the bundle entirely (robust escape hatch).
14
+ if (override?.apiToken) {
15
+ return { apiToken: override.apiToken, accountId: override.accountId ?? '' };
16
+ }
17
+ // Check existence first: resolving a missing bundle through the process client
18
+ // surfaces an opaque transport code (e.g. LOCKED), never naming the bundle the
19
+ // user typed — so name it here.
20
+ if (!bundleExists(bundle)) {
21
+ throw new Error(`The '${bundle}' bundle does not exist. ` +
22
+ `Pass credentials directly with --token <t> [--account <id>], or create it: ` +
23
+ `agents secrets add ${bundle} CLOUDFLARE_API_TOKEN`);
24
+ }
25
+ const { env } = readAndResolveBundleEnv(bundle, {
26
+ caller: 'cloudflare',
27
+ // Setup is still a read; only `agents secrets unlock` may authenticate.
28
+ agentOnly: true,
29
+ });
30
+ const find = (re) => {
31
+ for (const [k, v] of Object.entries(env))
32
+ if (re.test(k) && v)
33
+ return v;
34
+ return '';
35
+ };
36
+ const apiToken = env.CLOUDFLARE_API_TOKEN || env.CF_API_TOKEN || find(/API[_-]?TOKEN|(?:^|_)TOKEN$/i);
37
+ const accountId = override?.accountId || env.CLOUDFLARE_ACCOUNT_ID || env.CF_ACCOUNT_ID || find(/ACCOUNT[_-]?ID/i);
38
+ if (!apiToken) {
39
+ const keys = Object.keys(env);
40
+ throw new Error(`No Cloudflare API token in the '${bundle}' bundle ` +
41
+ `(keys present: ${keys.length ? keys.join(', ') : 'none'}). ` +
42
+ `Pass it directly with --token <t> [--account <id>], or add it: ` +
43
+ `agents secrets add ${bundle} CLOUDFLARE_API_TOKEN`);
44
+ }
45
+ return { apiToken, accountId };
46
+ }
@@ -0,0 +1,35 @@
1
+ /** One compiled non-JS Worker module (e.g. WASM) uploaded alongside the script. */
2
+ export interface WorkerModule {
3
+ name: string;
4
+ contentType: string;
5
+ contents: Uint8Array<ArrayBuffer>;
6
+ }
7
+ /** A Worker script plus any extra modules to upload with it. */
8
+ export interface WorkerBundle {
9
+ script: string;
10
+ modules: WorkerModule[];
11
+ }
12
+ export interface CloudflareRequest {
13
+ apiToken: string;
14
+ method: string;
15
+ pathname: string;
16
+ body?: unknown;
17
+ form?: FormData;
18
+ }
19
+ export type CloudflareRequester = <T = unknown>(request: CloudflareRequest) => Promise<T>;
20
+ interface ProvisionOptions {
21
+ request?: CloudflareRequester;
22
+ }
23
+ /** Create the R2 bucket (idempotent). */
24
+ export declare function createBucket(apiToken: string, accountId: string, name: string, opts?: ProvisionOptions): Promise<void>;
25
+ /** Upload the module Worker with an R2 binding (`BUCKET`). Secrets are set via the Workers Secrets API. */
26
+ export declare function deployWorker(apiToken: string, accountId: string, workerName: string, worker: string | WorkerBundle, bucketName: string, opts?: ProvisionOptions): Promise<void>;
27
+ /** Add/update a secret_text binding using Cloudflare's Workers Secrets API. */
28
+ export declare function putWorkerSecret(apiToken: string, accountId: string, workerName: string, name: string, text: string, opts?: ProvisionOptions): Promise<void>;
29
+ /** Enable the free `*.workers.dev` route for the script, and return the account subdomain. */
30
+ export declare function enableWorkersDev(apiToken: string, accountId: string, workerName: string, opts?: ProvisionOptions): Promise<string>;
31
+ /** Resolve a zone id for a domain the token can see, or null if not owned/visible. */
32
+ export declare function findZoneId(apiToken: string, domain: string, opts?: ProvisionOptions): Promise<string | null>;
33
+ /** Map a custom hostname (e.g. `share.getrush.ai`) to the Worker via Workers Custom Domains. */
34
+ export declare function addCustomDomain(apiToken: string, accountId: string, workerName: string, zoneId: string, hostname: string, opts?: ProvisionOptions): Promise<void>;
35
+ export {};
@@ -0,0 +1,144 @@
1
+ // Generic Cloudflare orchestration — plain `fetch` against the CF REST API (the
2
+ // repo has no CF wrapper). Create an R2 bucket, upload a module Worker with an R2
3
+ // binding, set secrets via the Workers Secrets API, enable the free
4
+ // `*.workers.dev` subdomain, and map a custom domain when the token owns the zone.
5
+ //
6
+ // This was `lib/share/provision.ts`. The artifact share engine moved out to
7
+ // `@phnx-labs/artifacts-cli` (PHNX-3992), but `agents traces` provisions its own
8
+ // isolated Cloudflare-backed store with the SAME primitives — so the generic
9
+ // layer relocated here (share-specific lifecycle/collab/managed helpers left
10
+ // with the share engine). `traces/provision.ts` is now the only consumer.
11
+ const CF_API = 'https://api.cloudflare.com/client/v4';
12
+ async function cf(apiToken, method, pathname, body, form) {
13
+ const headers = { authorization: `Bearer ${apiToken}` };
14
+ let payload;
15
+ if (form) {
16
+ payload = form; // fetch sets multipart boundary
17
+ }
18
+ else if (body !== undefined) {
19
+ headers['content-type'] = 'application/json';
20
+ payload = JSON.stringify(body);
21
+ }
22
+ const res = await fetch(`${CF_API}${pathname}`, { method, headers, body: payload });
23
+ const json = (await res.json().catch(() => ({})));
24
+ if (!res.ok || json.success === false) {
25
+ const msg = (json.errors ?? []).map((e) => `${e.code ?? ''} ${e.message ?? ''}`.trim()).join('; ') ||
26
+ res.statusText;
27
+ throw new Error(`Cloudflare ${method} ${pathname} failed (${res.status}): ${msg}`);
28
+ }
29
+ return json.result;
30
+ }
31
+ const defaultCloudflareRequester = (request) => cf(request.apiToken, request.method, request.pathname, request.body, request.form);
32
+ /** True if the CF error looks like "the thing already exists" (idempotent create). */
33
+ function isAlreadyExists(e) {
34
+ return /already exists|duplicate|10004|10014/i.test(String(e));
35
+ }
36
+ /** Create the R2 bucket (idempotent). */
37
+ export async function createBucket(apiToken, accountId, name, opts = {}) {
38
+ const request = opts.request ?? defaultCloudflareRequester;
39
+ try {
40
+ await request({
41
+ apiToken,
42
+ method: 'POST',
43
+ pathname: `/accounts/${accountId}/r2/buckets`,
44
+ body: { name },
45
+ });
46
+ }
47
+ catch (e) {
48
+ if (!isAlreadyExists(e))
49
+ throw e;
50
+ }
51
+ }
52
+ /** Upload the module Worker with an R2 binding (`BUCKET`). Secrets are set via the Workers Secrets API. */
53
+ export async function deployWorker(apiToken, accountId, workerName, worker, bucketName, opts = {}) {
54
+ const request = opts.request ?? defaultCloudflareRequester;
55
+ const metadata = {
56
+ main_module: 'worker.js',
57
+ compatibility_date: '2024-11-06',
58
+ bindings: [
59
+ { type: 'r2_bucket', name: 'BUCKET', bucket_name: bucketName },
60
+ ],
61
+ };
62
+ const form = new FormData();
63
+ form.set('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
64
+ const bundle = typeof worker === 'string' ? { script: worker, modules: [] } : worker;
65
+ form.set('worker.js', new Blob([bundle.script], { type: 'application/javascript+module' }), 'worker.js');
66
+ for (const module of bundle.modules) {
67
+ form.set(module.name, new Blob([module.contents], { type: module.contentType }), module.name);
68
+ }
69
+ await request({
70
+ apiToken,
71
+ method: 'PUT',
72
+ pathname: `/accounts/${accountId}/workers/scripts/${workerName}`,
73
+ form,
74
+ });
75
+ }
76
+ /** Add/update a secret_text binding using Cloudflare's Workers Secrets API. */
77
+ export async function putWorkerSecret(apiToken, accountId, workerName, name, text, opts = {}) {
78
+ const request = opts.request ?? defaultCloudflareRequester;
79
+ await request({
80
+ apiToken,
81
+ method: 'PUT',
82
+ pathname: `/accounts/${accountId}/workers/scripts/${workerName}/secrets`,
83
+ body: { name, text, type: 'secret_text' },
84
+ });
85
+ }
86
+ /** Enable the free `*.workers.dev` route for the script, and return the account subdomain. */
87
+ export async function enableWorkersDev(apiToken, accountId, workerName, opts = {}) {
88
+ const request = opts.request ?? defaultCloudflareRequester;
89
+ await request({
90
+ apiToken,
91
+ method: 'POST',
92
+ pathname: `/accounts/${accountId}/workers/scripts/${workerName}/subdomain`,
93
+ body: {
94
+ enabled: true,
95
+ previews_enabled: false,
96
+ },
97
+ });
98
+ const sub = await request({
99
+ apiToken,
100
+ method: 'GET',
101
+ pathname: `/accounts/${accountId}/workers/subdomain`,
102
+ });
103
+ if (!sub?.subdomain) {
104
+ throw new Error('No workers.dev subdomain on this account yet — register one at dash.cloudflare.com → Workers → Subdomain, then re-run.');
105
+ }
106
+ return sub.subdomain;
107
+ }
108
+ /** Resolve a zone id for a domain the token can see, or null if not owned/visible. */
109
+ export async function findZoneId(apiToken, domain, opts = {}) {
110
+ const request = opts.request ?? defaultCloudflareRequester;
111
+ // Try the exact name, then the registrable parent (share.getrush.ai -> getrush.ai).
112
+ const candidates = [domain, domain.split('.').slice(-2).join('.')];
113
+ for (const name of candidates) {
114
+ const zones = await request({
115
+ apiToken,
116
+ method: 'GET',
117
+ pathname: `/zones?name=${encodeURIComponent(name)}`,
118
+ }).catch(() => []);
119
+ if (zones?.length)
120
+ return zones[0].id;
121
+ }
122
+ return null;
123
+ }
124
+ /** Map a custom hostname (e.g. `share.getrush.ai`) to the Worker via Workers Custom Domains. */
125
+ export async function addCustomDomain(apiToken, accountId, workerName, zoneId, hostname, opts = {}) {
126
+ const request = opts.request ?? defaultCloudflareRequester;
127
+ try {
128
+ await request({
129
+ apiToken,
130
+ method: 'PUT',
131
+ pathname: `/accounts/${accountId}/workers/domains`,
132
+ body: {
133
+ zone_id: zoneId,
134
+ hostname,
135
+ service: workerName,
136
+ environment: 'production',
137
+ },
138
+ });
139
+ }
140
+ catch (e) {
141
+ if (!isAlreadyExists(e))
142
+ throw e;
143
+ }
144
+ }
@@ -26,7 +26,61 @@ export interface ComputerAction {
26
26
  agent?: string;
27
27
  machineId?: string;
28
28
  hostname?: string;
29
+ /**
30
+ * The file a successful `screenshot` action actually wrote.
31
+ *
32
+ * Recorded by the standalone engine only AFTER the write succeeded, so its
33
+ * presence is proof the file existed — which is why nothing here ever derives
34
+ * a path from the verb. An action from before the producer carried this field
35
+ * has no capture, and that is reported honestly as none rather than guessed at
36
+ * from an output flag that may never have been written.
37
+ */
38
+ capture?: {
39
+ path: string;
40
+ kind: 'screenshot';
41
+ name: string;
42
+ bytes?: number;
43
+ };
29
44
  }
45
+ /**
46
+ * The standalone engine's OWN action ledger.
47
+ *
48
+ * `agents computer` is a thin consumer of the standalone `computer` engine
49
+ * (PHNX-4075), and that engine ALWAYS appends every action it performs to
50
+ * `<cache>/computer/actions/<day>.jsonl` — independently of whether agents-cli
51
+ * was in the call at all. So a `computer` command the operator ran directly
52
+ * appears ONLY here: it never reached `recordComputerAction`, so it is in
53
+ * neither the feed event ledger nor `computer_sessions`. Reading only those two
54
+ * meant the actions an operator actually performed were invisible to every
55
+ * agents-cli surface.
56
+ */
57
+ export declare function standaloneComputerActionsDir(): string;
58
+ /**
59
+ * Read the standalone ledger, newest day first, bounded by `limit` actions.
60
+ *
61
+ * Bounded by construction: days are read newest-first and reading stops as soon
62
+ * as the budget is met, so a box with months of history costs the same as one
63
+ * with a day of it.
64
+ */
65
+ export declare function listStandaloneComputerActions(opts?: {
66
+ limit?: number;
67
+ dir?: string;
68
+ observer?: string;
69
+ }): ComputerAction[];
70
+ /**
71
+ * Union the two ledgers, preferring the standalone record for any run present in
72
+ * both.
73
+ *
74
+ * Dedupe keys on `invocationId`, NOT on a timestamp or pid. When a command is
75
+ * forwarded through `agents computer`, BOTH stores receive it — and the
76
+ * forwarding rewrites `ts` and `pid` on the way through, so the two copies of one
77
+ * action do not agree on either. `invocationId` is minted by the engine and
78
+ * echoed unchanged, which makes it the only field that identifies the same run in
79
+ * both files. A legacy feed record with no invocationId cannot be matched to
80
+ * anything, so it is kept: dropping it would lose history the standalone ledger
81
+ * never had.
82
+ */
83
+ export declare function mergeComputerActionSources(standalone: ComputerAction[], legacy: ComputerAction[]): ComputerAction[];
30
84
  /** Read `computer.action` events straight from the durable event ledger,
31
85
  * newest first, bounded by `limit`. Malformed/legacy records (missing
32
86
  * `command` or `pid`, or an unparseable `ts`) are skipped, never thrown —
@@ -94,6 +148,7 @@ export declare function groupIntoComputerRuns(actions: ComputerAction[], resolve
94
148
  export declare function buildComputerSessionRows(opts?: {
95
149
  limit?: number;
96
150
  machine?: string;
151
+ observer?: string;
97
152
  }): ComputerRunRow[];
98
153
  /**
99
154
  * Search predicate for the interactive picker: task text, machine/remote