@phnx-labs/agents-cli 1.22.37 → 1.22.38

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 (50) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +8 -8
  3. package/dist/bin/agents +0 -0
  4. package/dist/bootstrap.js +1 -0
  5. package/dist/commands/artifacts-setup.d.ts +53 -0
  6. package/dist/commands/{setup-share.js → artifacts-setup.js} +59 -13
  7. package/dist/commands/artifacts.d.ts +18 -0
  8. package/dist/commands/artifacts.js +58 -0
  9. package/dist/commands/browser.js +2 -0
  10. package/dist/commands/exec.js +1 -1
  11. package/dist/commands/models.js +67 -0
  12. package/dist/commands/setup.js +5 -5
  13. package/dist/commands/share.d.ts +20 -7
  14. package/dist/commands/share.js +74 -75
  15. package/dist/lib/browser/hygiene.d.ts +90 -0
  16. package/dist/lib/browser/hygiene.js +146 -0
  17. package/dist/lib/browser/ipc.js +12 -0
  18. package/dist/lib/browser/service.d.ts +75 -1
  19. package/dist/lib/browser/service.js +201 -11
  20. package/dist/lib/browser/types.d.ts +44 -1
  21. package/dist/lib/git.d.ts +1 -1
  22. package/dist/lib/git.js +1 -1
  23. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  24. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  25. package/dist/lib/routines.d.ts +3 -1
  26. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  27. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  28. package/dist/lib/share/analytics.js +1 -1
  29. package/dist/lib/share/capture.d.ts +1 -1
  30. package/dist/lib/share/capture.js +3 -3
  31. package/dist/lib/share/config.d.ts +2 -2
  32. package/dist/lib/share/config.js +5 -5
  33. package/dist/lib/share/delete.js +3 -3
  34. package/dist/lib/share/provision.js +3 -3
  35. package/dist/lib/share/publish.d.ts +1 -1
  36. package/dist/lib/share/publish.js +3 -3
  37. package/dist/lib/share/worker-template.d.ts +12 -1
  38. package/dist/lib/share/worker-template.js +13 -2
  39. package/dist/lib/startup/command-registry.d.ts +10 -2
  40. package/dist/lib/startup/command-registry.js +16 -7
  41. package/dist/lib/tmux/orphan-reap.d.ts +15 -19
  42. package/dist/lib/tmux/orphan-reap.js +15 -21
  43. package/dist/lib/tmux/session.js +4 -3
  44. package/dist/lib/triggers/handlers.js +10 -0
  45. package/dist/lib/triggers/webhook.js +10 -0
  46. package/dist/lib/types.d.ts +2 -2
  47. package/package.json +1 -1
  48. package/dist/commands/set.d.ts +0 -15
  49. package/dist/commands/set.js +0 -79
  50. package/dist/commands/setup-share.d.ts +0 -17
@@ -1,5 +1,9 @@
1
- // `agents share` — publish an HTML file to your own Cloudflare R2 behind a tiny
2
- // Worker, and get a shareable link (~$0). See apps/cli/docs/share.md.
1
+ // `agents artifacts share` — publish an HTML file to your own Cloudflare R2
2
+ // behind a tiny Worker, and get a shareable link (~$0). See apps/cli/docs/share.md.
3
+ //
4
+ // Registered under the `artifacts` group by commands/artifacts.ts; the
5
+ // provisioning door lives beside it at `agents artifacts setup`
6
+ // (commands/artifacts-setup.ts), which calls `runShareProvision` below.
3
7
  import { existsSync } from 'node:fs';
4
8
  import chalk from 'chalk';
5
9
  import { DEFAULT_BUCKET_NAME, DEFAULT_CF_BUNDLE, DEFAULT_SHARE_DOMAIN, DEFAULT_WORKER_NAME, generateWriteToken, readCloudflareCreds, readShareConfig, readWriteToken, readWriteTokenEnv, readWriteTokenFromBundle, storeWriteToken, writeShareConfig, } from '../lib/share/config.js';
@@ -35,10 +39,10 @@ export function shareTemplateStatus(cfg) {
35
39
  }
36
40
  /** Shown whenever the deployed Worker has no `?format=json` listing route — an
37
41
  * endpoint provisioned before this feature. Points at the RUSH-2449 update path
38
- * (`agents share update`) instead of letting the caller hit a 404 or an HTML body
39
- * and get a confusing parse error. */
40
- const OUTDATED_TEMPLATE_HINT = 'Your deployed share Worker has no machine-readable listing route — it predates `agents share list`. ' +
41
- 'Run `agents share update` to deploy the current Worker template, then retry (`agents share status` shows whether an update is due).';
42
+ * (`agents artifacts share update`) instead of letting the caller hit a 404 or an
43
+ * HTML body and get a confusing parse error. */
44
+ const OUTDATED_TEMPLATE_HINT = 'Your deployed share Worker has no machine-readable listing route — it predates `agents artifacts share list`. ' +
45
+ 'Run `agents artifacts share update` to deploy the current Worker template, then retry (`agents artifacts share status` shows whether an update is due).';
42
46
  async function defaultListingFetch(url) {
43
47
  const res = await fetch(url, { headers: { accept: 'application/json' } });
44
48
  return { status: res.status, contentType: res.headers.get('content-type') ?? '', body: await res.text() };
@@ -80,7 +84,7 @@ export function parseShareListing(user, body) {
80
84
  export async function runShareList(opts = {}) {
81
85
  const cfg = opts.config ?? readShareConfig();
82
86
  if (!cfg) {
83
- throw new Error("Not set up yet. Run 'agents share setup' (provision your own endpoint) or 'agents share join' (use an existing one).");
87
+ throw new Error("Not set up yet. Run 'agents artifacts setup' (provision your own endpoint) or 'agents artifacts share join' (use an existing one).");
84
88
  }
85
89
  // A known-stale template can't have the listing route — say so before any network
86
90
  // call. 'unknown' (provisioned before templateHash tracking) is attempted, then
@@ -100,14 +104,14 @@ export async function runShareList(opts = {}) {
100
104
  // endpoint predates the listing route entirely. The recorded templateHash
101
105
  // disambiguates: a 'current' template HAS the route, so its 404 means an empty
102
106
  // namespace ("nothing published"); otherwise the route may be absent, so point
103
- // at `agents share update`.
107
+ // at `agents artifacts share update`.
104
108
  if (templateStatus === 'current') {
105
109
  return { user, count: 0, objects: [] };
106
110
  }
107
111
  throw new Error(OUTDATED_TEMPLATE_HINT);
108
112
  }
109
113
  if (res.status !== 200) {
110
- throw new Error(`Listing failed (${res.status}) for ${listUrl}. Check the endpoint is reachable, or that 'agents share setup' completed.`);
114
+ throw new Error(`Listing failed (${res.status}) for ${listUrl}. Check the endpoint is reachable, or that 'agents artifacts setup' completed.`);
111
115
  }
112
116
  if (!/application\/json/i.test(res.contentType)) {
113
117
  // A 200 that isn't JSON means the old Worker ignored ?format=json and served
@@ -157,8 +161,8 @@ export function formatShareDeleteResult(result, json = false) {
157
161
  }
158
162
  return lines.join('\n');
159
163
  }
160
- /** Shared handler for `agents share delete <targets...>` and the top-level
161
- * `agents unshare <targets...>` alias. Deletes each target independently and
164
+ /** Shared handler for `agents artifacts share delete <targets...>` and the
165
+ * top-level `agents unshare <targets...>` alias. Deletes each target independently and
162
166
  * continues past a failed one (rm-style), reporting all results and exiting
163
167
  * non-zero if any target failed to verify as gone.
164
168
  *
@@ -200,10 +204,10 @@ function registerShareDeleteOptions(cmd) {
200
204
  }
201
205
  const SHARE_DELETE_EXAMPLES = `
202
206
  # Delete by full URL — also takes down the sibling OG cover
203
- agents share delete https://share.agents-cli.sh/octocat/my-plan-a1b2
207
+ agents artifacts share delete https://share.agents-cli.sh/octocat/my-plan-a1b2
204
208
 
205
209
  # Delete by <user>/<slug>, or a bare slug in your own namespace
206
- agents share delete octocat/my-plan-a1b2
210
+ agents artifacts share delete octocat/my-plan-a1b2
207
211
  agents unshare my-plan-a1b2
208
212
 
209
213
  # Several at once
@@ -220,17 +224,23 @@ const SHARE_DELETE_NOTES = `
220
224
  Worker's DELETE is idempotent and returns {"ok":true} even for a key that was
221
225
  never there, so that response alone is never proof of a takedown.
222
226
 
223
- agents share delete === agents unshare (same command, different name).
227
+ agents artifacts share delete === agents unshare (same command, different name).
224
228
  `;
225
- export function registerShareCommands(program) {
226
- const shareCmd = program
229
+ /**
230
+ * Register the `share` subtree under its parent group — `agents artifacts share`
231
+ * (see commands/artifacts.ts). The top-level `agents unshare` alias is a sibling
232
+ * registration on the ROOT program, so it is {@link registerUnshareCommand}, not
233
+ * part of this subtree.
234
+ */
235
+ export function registerShareCommands(artifactsCmd) {
236
+ const shareCmd = artifactsCmd
227
237
  .command('share')
228
238
  .description('Publish an HTML file to your own Cloudflare R2 and get a shareable link (~$0).')
229
239
  .argument('[file]', 'file to publish (HTML or any static asset)')
230
240
  .option('--slug <slug>', 'custom URL slug under your namespace (default: <project>-<feature>-<hash>)')
231
241
  .option('--github-user <user>', 'GitHub username for the share namespace (default: resolved from gh/git config)')
232
242
  .option('--expire <spec>', "auto-expire (default 30d). e.g. 12h, 30d, 2026-08-01, or 'never'")
233
- .option('--unlisted', 'hide from the public gallery and `agents share list` (direct URL still works)')
243
+ .option('--unlisted', 'hide from the public gallery and `agents artifacts share list` (direct URL still works)')
234
244
  .option('--private', 'alias of --unlisted')
235
245
  .option('--force', 'publish even when the file contains emails or credential-shaped strings')
236
246
  .option('--no-cover', 'skip the OG preview image (HTML pages get one by default)')
@@ -266,30 +276,30 @@ export function registerShareCommands(program) {
266
276
  setHelpSections(shareCmd, {
267
277
  examples: `
268
278
  # Publish an HTML file — auto OG cover, default 30d expiry, shareable link
269
- agents share ./out/plan.html
279
+ agents artifacts share ./out/plan.html
270
280
 
271
281
  # Hide from the public gallery (direct URL still works) and expire sooner
272
- agents share ./out/report.html --unlisted --expire 12h
282
+ agents artifacts share ./out/report.html --unlisted --expire 12h
273
283
 
274
284
  # Permanent public page (opt out of the default 30d expiry)
275
- agents share ./out/landing.html --slug landing --expire never
285
+ agents artifacts share ./out/landing.html --slug landing --expire never
276
286
 
277
287
  # Custom slug, expiring in 7 days
278
- agents share ./out/report.html --slug q3-report --expire 7d
288
+ agents artifacts share ./out/report.html --slug q3-report --expire 7d
279
289
  ${SHARE_DELETE_EXAMPLES}
280
290
  # One-time setup (or join an existing endpoint)
281
- agents share setup
282
- agents share join https://share.agents-cli.sh
291
+ agents artifacts setup
292
+ agents artifacts share join https://share.agents-cli.sh
283
293
 
284
294
  # Push a worker-template.ts change out to an already-provisioned endpoint
285
- agents share update
295
+ agents artifacts share update
286
296
  `,
287
297
  notes: `
288
298
  Default expiry is 30d so an accidental publish decays. Pass --expire never for
289
299
  a permanent link. --unlisted / --private hides the page from the public gallery
290
- and agents share list; the direct URL is still world-readable (unlisted, not
291
- secret). A pre-publish scan refuses emails and credential-shaped strings
292
- unless --force is passed.
300
+ and agents artifacts share list; the direct URL is still world-readable
301
+ (unlisted, not secret). A pre-publish scan refuses emails and credential-shaped
302
+ strings unless --force is passed.
293
303
  ${SHARE_DELETE_NOTES}
294
304
  `,
295
305
  });
@@ -300,32 +310,6 @@ ${SHARE_DELETE_NOTES}
300
310
  shareDeleteCmd.action(async (targets, opts) => {
301
311
  await runShareDelete(targets, opts);
302
312
  });
303
- const unshareCmd = registerShareDeleteOptions(program
304
- .command('unshare <targets...>')
305
- .description('Alias of `agents share delete` — take down a published page (and by default its OG cover).'));
306
- setHelpSections(unshareCmd, { examples: SHARE_DELETE_EXAMPLES, notes: SHARE_DELETE_NOTES });
307
- unshareCmd.action(async (targets, opts) => {
308
- await runShareDelete(targets, opts);
309
- });
310
- shareCmd
311
- .command('setup')
312
- .description('One-time: provision an R2 bucket + Worker on your Cloudflare and save the config.')
313
- .option('--bundle <name>', 'secrets bundle holding the Cloudflare API token', DEFAULT_CF_BUNDLE)
314
- .option('--worker <name>', 'Worker name', DEFAULT_WORKER_NAME)
315
- .option('--bucket <name>', 'R2 bucket name', DEFAULT_BUCKET_NAME)
316
- .option('--account <id>', 'Cloudflare account id (else read from the bundle / prompt)')
317
- .option('--token <t>', 'Cloudflare API token (else read from the --bundle)')
318
- .option('--domain <host>', `custom domain to map (default: ${DEFAULT_SHARE_DOMAIN}; workers.dev if zone is not visible)`)
319
- .option('--analytics-token <token>', 'Cloudflare Web Analytics token to inject into published HTML pages')
320
- .action(async (opts) => {
321
- try {
322
- await runShareProvision(opts);
323
- }
324
- catch (e) {
325
- console.error(chalk.red(e.message));
326
- process.exitCode = 1;
327
- }
328
- });
329
313
  shareCmd
330
314
  .command('join')
331
315
  .description('Use an existing synced share endpoint and write token (no provisioning).')
@@ -370,15 +354,15 @@ ${SHARE_DELETE_NOTES}
370
354
  setHelpSections(shareUpdateCmd, {
371
355
  examples: `
372
356
  # Push a worker-template.ts change out to your already-provisioned endpoint
373
- agents share update
357
+ agents artifacts share update
374
358
 
375
359
  # Force a re-deploy even though the template hash already matches
376
- agents share update --force
360
+ agents artifacts share update --force
377
361
  `,
378
362
  notes: `
379
- Reuses the existing account/worker/bucket from 'agents share status' and the
363
+ Reuses the existing account/worker/bucket from 'agents artifacts share status' and the
380
364
  existing write token — it never re-provisions a bucket, touches routes, or
381
- regenerates the token. See 'agents share status' for whether an update is due.
365
+ regenerates the token. See 'agents artifacts share status' for whether an update is due.
382
366
  `,
383
367
  });
384
368
  shareCmd
@@ -387,7 +371,7 @@ ${SHARE_DELETE_NOTES}
387
371
  .action(async () => {
388
372
  const cfg = readShareConfig();
389
373
  if (!cfg) {
390
- console.log(chalk.dim("Not configured. Run 'agents share setup' or 'agents share join'."));
374
+ console.log(chalk.dim("Not configured. Run 'agents artifacts setup' or 'agents artifacts share join'."));
391
375
  return;
392
376
  }
393
377
  console.log(`${chalk.bold('endpoint')} ${chalk.green(cfg.baseUrl)}`);
@@ -399,8 +383,8 @@ ${SHARE_DELETE_NOTES}
399
383
  const templateLabel = templateStatus === 'current'
400
384
  ? chalk.green('current')
401
385
  : templateStatus === 'outdated'
402
- ? chalk.yellow('outdated — run `agents share update`')
403
- : chalk.dim("unknown — provisioned before version tracking; run `agents share update` to adopt it");
386
+ ? chalk.yellow('outdated — run `agents artifacts share update`')
387
+ : chalk.dim("unknown — provisioned before version tracking; run `agents artifacts share update` to adopt it");
404
388
  console.log(`${chalk.bold('template')} ${templateLabel}`);
405
389
  });
406
390
  const shareListCmd = shareCmd
@@ -421,21 +405,21 @@ ${SHARE_DELETE_NOTES}
421
405
  setHelpSections(shareListCmd, {
422
406
  examples: `
423
407
  # Everything you've published, newest first
424
- agents share list
408
+ agents artifacts share list
425
409
 
426
410
  # Machine-readable — e.g. pull every still-public URL with jq
427
- agents share list --json | jq -r '.objects[].url'
411
+ agents artifacts share list --json | jq -r '.objects[].url'
428
412
 
429
413
  # List another namespace
430
- agents share list --github-user octocat
414
+ agents artifacts share list --github-user octocat
431
415
  `,
432
416
  notes: `
433
417
  Lists the ACTIVE pages in your namespace — expired links and the sibling .png OG
434
418
  covers are omitted (it mirrors the public gallery). It reads the endpoint's JSON
435
419
  listing route, which ships with the current Worker template. If your deployed
436
- Worker predates this feature the command says so and points you at 'agents share
437
- update' (RUSH-2449) rather than returning a wrong or empty result — see 'agents
438
- share status' for whether an update is due.
420
+ Worker predates this feature the command says so and points you at 'agents
421
+ artifacts share update' (RUSH-2449) rather than returning a wrong or empty result
422
+ — see 'agents artifacts share status' for whether an update is due.
439
423
  `,
440
424
  });
441
425
  shareCmd
@@ -444,7 +428,7 @@ ${SHARE_DELETE_NOTES}
444
428
  .action(async () => {
445
429
  const cfg = readShareConfig();
446
430
  if (!cfg) {
447
- console.log(chalk.dim("Not configured. Run 'agents share setup' or 'agents share join'."));
431
+ console.log(chalk.dim("Not configured. Run 'agents artifacts setup' or 'agents artifacts share join'."));
448
432
  return;
449
433
  }
450
434
  if (!analyticsEnabled(cfg)) {
@@ -464,9 +448,24 @@ ${SHARE_DELETE_NOTES}
464
448
  }
465
449
  });
466
450
  }
451
+ /**
452
+ * Register the top-level `agents unshare <targets...>` alias of
453
+ * `agents artifacts share delete`. It takes the ROOT program (not the artifacts
454
+ * group) because it is deliberately a top-level convenience verb — taking a page
455
+ * down is the one artifact action typed often enough to keep at the root.
456
+ */
457
+ export function registerUnshareCommand(program) {
458
+ const unshareCmd = registerShareDeleteOptions(program
459
+ .command('unshare <targets...>')
460
+ .description('Alias of `agents artifacts share delete` — take down a published page (and by default its OG cover).'));
461
+ setHelpSections(unshareCmd, { examples: SHARE_DELETE_EXAMPLES, notes: SHARE_DELETE_NOTES });
462
+ unshareCmd.action(async (targets, opts) => {
463
+ await runShareDelete(targets, opts);
464
+ });
465
+ }
467
466
  /** Provision a fresh R2 bucket + Worker on the user's Cloudflare and persist the
468
- * endpoint config + write token. Shared by `agents share setup` and the unified
469
- * `agents setup share` wizard. */
467
+ * endpoint config + write token. Shared by both modes of `agents artifacts setup`
468
+ * (the flag-driven provision and the interactive wizard). */
470
469
  export async function runShareProvision(opts) {
471
470
  const { default: ora } = await import('ora');
472
471
  const { input } = await import('@inquirer/prompts');
@@ -521,7 +520,7 @@ export async function runShareProvision(opts) {
521
520
  writeShareConfig(cfg);
522
521
  storeWriteToken(token);
523
522
  console.log(chalk.green(`\nShare endpoint ready → ${chalk.bold(baseUrl)}`));
524
- console.log(chalk.dim('Publish with: ') + chalk.cyan('agents share <file>'));
523
+ console.log(chalk.dim('Publish with: ') + chalk.cyan('agents artifacts share <file>'));
525
524
  console.log(chalk.dim(`Fleet: push the token with 'agents secrets export share --host <box>' and pull config with 'agents repo pull'.`));
526
525
  }
527
526
  catch (e) {
@@ -539,7 +538,7 @@ export async function runShareProvision(opts) {
539
538
  export async function runShareUpdate(opts = {}) {
540
539
  const cfg = readShareConfig();
541
540
  if (!cfg) {
542
- throw new Error("Not configured. Run 'agents share setup' (to provision) or 'agents share join' first.");
541
+ throw new Error("Not configured. Run 'agents artifacts setup' (to provision) or 'agents artifacts share join' first.");
543
542
  }
544
543
  const { apiToken, accountId: acctFromBundle } = readCloudflareCreds(opts.bundle ?? DEFAULT_CF_BUNDLE, {
545
544
  apiToken: opts.token,
@@ -568,14 +567,14 @@ function cleanHostname(domain) {
568
567
  }
569
568
  }
570
569
  /** Join an existing share endpoint (no provisioning): prompt for the endpoint
571
- * details + write token and persist them. Shared by `agents share join` and the
572
- * unified `agents setup share` wizard. */
570
+ * details + write token and persist them. Shared by `agents artifacts share join`
571
+ * and the `agents artifacts setup` wizard. */
573
572
  export async function runShareJoin(baseUrl, opts = {}) {
574
573
  const { password, input } = await import('@inquirer/prompts');
575
574
  const existing = readShareConfig();
576
575
  const clean = baseUrl?.replace(/\/+$/, '');
577
576
  if (!clean && !existing) {
578
- throw new Error("No synced share endpoint found. Pull config first with 'agents repo pull', or pass the endpoint URL: agents share join <baseUrl>.");
577
+ throw new Error("No synced share endpoint found. Pull config first with 'agents repo pull', or pass the endpoint URL: agents artifacts share join <baseUrl>.");
579
578
  }
580
579
  let cfg;
581
580
  if (existing && (!clean || clean === existing.baseUrl)) {
@@ -608,5 +607,5 @@ export async function runShareJoin(baseUrl, opts = {}) {
608
607
  throw new Error('A write token is required to join.');
609
608
  writeShareConfig(cfg);
610
609
  storeWriteToken(token);
611
- console.log(chalk.green(`Joined ${chalk.bold(cfg.baseUrl)} — publish with `) + chalk.cyan('agents share <file>'));
610
+ console.log(chalk.green(`Joined ${chalk.bold(cfg.baseUrl)} — publish with `) + chalk.cyan('agents artifacts share <file>'));
612
611
  }
@@ -0,0 +1,90 @@
1
+ import { type PidSessionEntry } from '../session/pid-registry.js';
2
+ import type { ReapResult, Task } from './types.js';
3
+ /** Default idle window before an untouched task is reaped. */
4
+ export declare const DEFAULT_IDLE_MS: number;
5
+ /**
6
+ * The slice of `BrowserService` the reaper needs. Structural, so a test drives
7
+ * it with a stub and `BrowserService` satisfies it without an import — which
8
+ * also keeps this module off the service's import cycle.
9
+ */
10
+ export interface ReapableService {
11
+ listTasks(): Array<{
12
+ profile: string;
13
+ task: Task;
14
+ }>;
15
+ recordStatus(taskName: string): Promise<{
16
+ recording: boolean;
17
+ }>;
18
+ stop(taskName: string): Promise<{
19
+ ok: boolean;
20
+ profile?: string;
21
+ }>;
22
+ }
23
+ /**
24
+ * Injectable liveness sources. Same shape as `feed-post.ts`
25
+ * (`input.listEntries ?? listPidSessionEntries`, feed-post.ts:119) — the
26
+ * defaults are the real registry and the real process table, so a test can
27
+ * substitute them without mocking a module.
28
+ */
29
+ export interface ReapDeps {
30
+ listEntries?: () => PidSessionEntry[];
31
+ pidAlive?: (pid: number, startedAtMs?: number) => boolean;
32
+ sessionIdOfPid?: (pid: number) => string | undefined;
33
+ sessionLiveOnProcessTable?: (sessionId: string) => Promise<boolean>;
34
+ }
35
+ export interface ReapOptions {
36
+ /** Idle window in ms. Default {@link DEFAULT_IDLE_MS}. */
37
+ idleMs?: number;
38
+ /** Clock, injectable for tests. Default `Date.now()`. */
39
+ now?: number;
40
+ /** Report what would be closed without closing anything. */
41
+ dryRun?: boolean;
42
+ deps?: ReapDeps;
43
+ }
44
+ export interface LiveIdentities {
45
+ sessions: Set<string>;
46
+ launches: Set<string>;
47
+ }
48
+ /**
49
+ * Session and launch ids belonging to a process that is alive right now.
50
+ *
51
+ * Built from the per-pid launch registry, filtered to live pids —
52
+ * `isPidAlive(pid, startedAtMs)` rather than a bare existence check, so a pid
53
+ * the OS recycled onto an unrelated process does not read as a live agent.
54
+ * An entry with no recorded `sessionId` still contributes one when the live
55
+ * process carries `--session-id` on its argv, which is the RUSH-2384 recovery
56
+ * path the registry itself documents (pid-registry.ts:102-107).
57
+ */
58
+ export declare function resolveLiveIdentities(deps?: ReapDeps): LiveIdentities;
59
+ /**
60
+ * True when the task's owner is PROVABLY gone. The bar is proof, not absence of
61
+ * evidence, because being wrong here closes a working agent's tabs.
62
+ *
63
+ * Only a `sessionId` can carry that proof. It has two independent sources — the
64
+ * per-pid launch registry, and a live process carrying `--session-id <id>` in
65
+ * its argv — so a session the registry missed is still caught by the process
66
+ * table. The registry misses constantly: a wrapper pid exits, a prune sweeps
67
+ * the entry, or the agent was never launched via `agents run`
68
+ * (pid-registry.ts:102-107, RUSH-2384).
69
+ *
70
+ * A `launchId` has NO second source — the registry is its only witness, and
71
+ * that witness is the unreliable one. So a task carrying only a `launchId` is
72
+ * never session-reaped, exactly like a task carrying no identity at all; both
73
+ * fall through to the idle rule. This is not a corner case: `launchId` is
74
+ * minted for every run (`exec.ts` `resolveLaunchId`) while `AGENT_SESSION_ID`
75
+ * is Claude-only and skipped on resume, so treating a missing registry entry as
76
+ * proof of death would close the tabs of every live codex/droid/grok run whose
77
+ * launch pid had already exited.
78
+ *
79
+ * A live `launchId` still RESCUES a task whose `sessionId` looks dead — proof of
80
+ * life needs only one witness, unlike proof of death.
81
+ */
82
+ export declare function taskOwnerIsGone(task: Task, live: LiveIdentities, deps?: ReapDeps): Promise<boolean>;
83
+ /**
84
+ * Stop every task whose owner is gone or that has sat untouched past `idleMs`.
85
+ *
86
+ * Returns what it closed and how many it left alone. A task that is mid-
87
+ * recording is always left alone: reaping it would truncate a capture the user
88
+ * asked for, and an in-flight recording is itself proof the task is in use.
89
+ */
90
+ export declare function reapAbandonedTasks(service: ReapableService, opts?: ReapOptions): Promise<ReapResult>;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Abandoned browser-task reaper (RUSH-2622).
3
+ *
4
+ * `agents browser done` / `stop` already close a task's tabs, but agents
5
+ * routinely never call them — the run ends, the process exits, and the task's
6
+ * tabs stay open in the profile window forever. Over a day of fleet activity
7
+ * that is dozens of leftover tabs in Comet/Chrome.
8
+ *
9
+ * This closes the loop from the other end: a periodic pass that finds tasks
10
+ * nobody is driving any more and stops them.
11
+ *
12
+ * Two independent reasons, both conservative:
13
+ *
14
+ * - `session-dead` — the task recorded WHICH agent session (`sessionId`) or
15
+ * WHICH run (`launchId`) started it, and neither is alive on this host.
16
+ * That is the honest end of the task: the thing that would have called
17
+ * `done` no longer exists.
18
+ * - `idle` — nothing has touched the task for `idleMs` (default 30 minutes).
19
+ * The catch-all for a task with no recorded identity (a human running
20
+ * `agents browser start` by hand) or an agent that stalled without exiting.
21
+ *
22
+ * Closing always goes through `BrowserService.stop`, never a direct
23
+ * `Target.closeTarget`, so history, the session cache, the target cache, and
24
+ * forked-profile teardown all stay on the one code path that already handles
25
+ * them. Two things this deliberately never does: touch a tab that is not in
26
+ * `task.tabs` (a tab the user opened themselves is not ours to close), and kill
27
+ * the profile window or the browser process because one task went idle.
28
+ */
29
+ import { isPidAlive, isSessionIdLiveOnProcessTable } from '../session/active.js';
30
+ import { listPidSessionEntries, sessionIdFromLivePid } from '../session/pid-registry.js';
31
+ /** Default idle window before an untouched task is reaped. */
32
+ export const DEFAULT_IDLE_MS = 30 * 60_000;
33
+ /**
34
+ * Session and launch ids belonging to a process that is alive right now.
35
+ *
36
+ * Built from the per-pid launch registry, filtered to live pids —
37
+ * `isPidAlive(pid, startedAtMs)` rather than a bare existence check, so a pid
38
+ * the OS recycled onto an unrelated process does not read as a live agent.
39
+ * An entry with no recorded `sessionId` still contributes one when the live
40
+ * process carries `--session-id` on its argv, which is the RUSH-2384 recovery
41
+ * path the registry itself documents (pid-registry.ts:102-107).
42
+ */
43
+ export function resolveLiveIdentities(deps = {}) {
44
+ const listEntries = deps.listEntries ?? listPidSessionEntries;
45
+ const alive = deps.pidAlive ?? isPidAlive;
46
+ const sessionIdOf = deps.sessionIdOfPid ?? sessionIdFromLivePid;
47
+ const sessions = new Set();
48
+ const launches = new Set();
49
+ for (const entry of listEntries()) {
50
+ if (!alive(entry.pid, entry.startedAtMs))
51
+ continue;
52
+ const sessionId = entry.sessionId ?? sessionIdOf(entry.pid);
53
+ if (sessionId)
54
+ sessions.add(sessionId);
55
+ if (entry.launchId)
56
+ launches.add(entry.launchId);
57
+ }
58
+ return { sessions, launches };
59
+ }
60
+ /**
61
+ * True when the task's owner is PROVABLY gone. The bar is proof, not absence of
62
+ * evidence, because being wrong here closes a working agent's tabs.
63
+ *
64
+ * Only a `sessionId` can carry that proof. It has two independent sources — the
65
+ * per-pid launch registry, and a live process carrying `--session-id <id>` in
66
+ * its argv — so a session the registry missed is still caught by the process
67
+ * table. The registry misses constantly: a wrapper pid exits, a prune sweeps
68
+ * the entry, or the agent was never launched via `agents run`
69
+ * (pid-registry.ts:102-107, RUSH-2384).
70
+ *
71
+ * A `launchId` has NO second source — the registry is its only witness, and
72
+ * that witness is the unreliable one. So a task carrying only a `launchId` is
73
+ * never session-reaped, exactly like a task carrying no identity at all; both
74
+ * fall through to the idle rule. This is not a corner case: `launchId` is
75
+ * minted for every run (`exec.ts` `resolveLaunchId`) while `AGENT_SESSION_ID`
76
+ * is Claude-only and skipped on resume, so treating a missing registry entry as
77
+ * proof of death would close the tabs of every live codex/droid/grok run whose
78
+ * launch pid had already exited.
79
+ *
80
+ * A live `launchId` still RESCUES a task whose `sessionId` looks dead — proof of
81
+ * life needs only one witness, unlike proof of death.
82
+ */
83
+ export async function taskOwnerIsGone(task, live, deps = {}) {
84
+ if (!task.sessionId)
85
+ return false;
86
+ if (task.launchId && live.launches.has(task.launchId))
87
+ return false;
88
+ if (live.sessions.has(task.sessionId))
89
+ return false;
90
+ const onProcessTable = deps.sessionLiveOnProcessTable ?? ((id) => isSessionIdLiveOnProcessTable(id));
91
+ return !(await onProcessTable(task.sessionId));
92
+ }
93
+ /**
94
+ * Stop every task whose owner is gone or that has sat untouched past `idleMs`.
95
+ *
96
+ * Returns what it closed and how many it left alone. A task that is mid-
97
+ * recording is always left alone: reaping it would truncate a capture the user
98
+ * asked for, and an in-flight recording is itself proof the task is in use.
99
+ */
100
+ export async function reapAbandonedTasks(service, opts = {}) {
101
+ const idleMs = opts.idleMs ?? DEFAULT_IDLE_MS;
102
+ // Fail loud rather than reap everything. A caller-supplied `0` survives `??`
103
+ // and would close every task including one created a millisecond ago; a
104
+ // non-numeric value makes every `>=` comparison false and silently disables
105
+ // idle reaping. Both are worse than an error.
106
+ if (!Number.isFinite(idleMs) || idleMs <= 0) {
107
+ throw new Error(`idleMs must be a positive number of milliseconds, got ${String(idleMs)}`);
108
+ }
109
+ const now = opts.now ?? Date.now();
110
+ const deps = opts.deps ?? {};
111
+ const live = resolveLiveIdentities(deps);
112
+ const closed = [];
113
+ let skipped = 0;
114
+ for (const { profile, task } of service.listTasks()) {
115
+ if ((await service.recordStatus(task.name)).recording) {
116
+ skipped++;
117
+ continue;
118
+ }
119
+ let reason;
120
+ if (await taskOwnerIsGone(task, live, deps)) {
121
+ reason = 'session-dead';
122
+ }
123
+ else if (now - (task.lastActionAt ?? task.createdAt) >= idleMs) {
124
+ reason = 'idle';
125
+ }
126
+ if (!reason) {
127
+ skipped++;
128
+ continue;
129
+ }
130
+ if (opts.dryRun) {
131
+ closed.push({ task: task.name, profile, reason });
132
+ continue;
133
+ }
134
+ const result = await service.stop(task.name);
135
+ if (result.ok) {
136
+ closed.push({ task: task.name, profile: result.profile ?? profile, reason });
137
+ }
138
+ else {
139
+ // `stop` reports not-found only when the task already left the map — a
140
+ // concurrent `done`, or a profile torn down mid-pass. Nothing was closed,
141
+ // so it is not reported as closed.
142
+ skipped++;
143
+ }
144
+ }
145
+ return { closed, skipped };
146
+ }
@@ -340,6 +340,7 @@ export class BrowserIPCServer {
340
340
  url: request.url,
341
341
  endpointName: request.endpoint,
342
342
  skipDomainSkill: request.skipDomainSkill,
343
+ fresh: request.fresh,
343
344
  actor: request.actor,
344
345
  launchId: request.launchId,
345
346
  sessionId: request.sessionId,
@@ -352,6 +353,17 @@ export class BrowserIPCServer {
352
353
  skill: result.skill,
353
354
  };
354
355
  }
356
+ // The out-of-process seam onto the abandoned-task reaper: the daemon
357
+ // owns the live BrowserService, so a CLI verb (`agents browser gc`) can
358
+ // only reach `reapAbandoned` through IPC. The daemon's own periodic tick
359
+ // calls the service method directly.
360
+ case 'gc': {
361
+ const reaped = await this.service.reapAbandoned({
362
+ idleMs: request.idleMinutes !== undefined ? request.idleMinutes * 60_000 : undefined,
363
+ dryRun: request.dryRun,
364
+ });
365
+ return { ok: true, reaped };
366
+ }
355
367
  case 'done': {
356
368
  if (!request.task) {
357
369
  return { ok: false, error: 'Task required' };