@phnx-labs/agents-cli 1.20.62 → 1.20.64

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 (114) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +19 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +96 -28
  6. package/dist/commands/feed.d.ts +4 -0
  7. package/dist/commands/feed.js +27 -8
  8. package/dist/commands/funnel.d.ts +5 -0
  9. package/dist/commands/funnel.js +62 -0
  10. package/dist/commands/hosts.js +42 -0
  11. package/dist/commands/lease.d.ts +23 -0
  12. package/dist/commands/lease.js +201 -0
  13. package/dist/commands/mailboxes.d.ts +20 -0
  14. package/dist/commands/mailboxes.js +390 -0
  15. package/dist/commands/repo.d.ts +4 -4
  16. package/dist/commands/repo.js +30 -19
  17. package/dist/commands/routines.js +92 -29
  18. package/dist/commands/sessions-export.d.ts +2 -0
  19. package/dist/commands/sessions-export.js +279 -0
  20. package/dist/commands/sessions-import.d.ts +2 -0
  21. package/dist/commands/sessions-import.js +230 -0
  22. package/dist/commands/sessions-sync.d.ts +1 -0
  23. package/dist/commands/sessions-sync.js +16 -2
  24. package/dist/commands/sessions.js +12 -1
  25. package/dist/commands/setup.js +9 -0
  26. package/dist/commands/ssh.js +170 -5
  27. package/dist/commands/sync-provision.d.ts +23 -0
  28. package/dist/commands/sync-provision.js +107 -0
  29. package/dist/commands/usage.d.ts +2 -0
  30. package/dist/commands/usage.js +7 -2
  31. package/dist/commands/view.d.ts +1 -1
  32. package/dist/commands/webhook.d.ts +9 -0
  33. package/dist/commands/webhook.js +93 -0
  34. package/dist/index.js +7 -2
  35. package/dist/lib/agents.d.ts +44 -0
  36. package/dist/lib/agents.js +85 -35
  37. package/dist/lib/browser/drivers/ssh.js +19 -2
  38. package/dist/lib/browser/ipc.js +5 -4
  39. package/dist/lib/browser/profiles.d.ts +13 -0
  40. package/dist/lib/browser/profiles.js +17 -0
  41. package/dist/lib/browser/service.d.ts +12 -1
  42. package/dist/lib/browser/service.js +48 -13
  43. package/dist/lib/browser/sessions-list.d.ts +40 -0
  44. package/dist/lib/browser/sessions-list.js +190 -0
  45. package/dist/lib/comms-render.d.ts +37 -0
  46. package/dist/lib/comms-render.js +89 -0
  47. package/dist/lib/crabbox/cli.d.ts +72 -0
  48. package/dist/lib/crabbox/cli.js +158 -9
  49. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  50. package/dist/lib/crabbox/runtimes.js +24 -0
  51. package/dist/lib/daemon.js +8 -1
  52. package/dist/lib/devices/fleet.d.ts +62 -0
  53. package/dist/lib/devices/fleet.js +128 -0
  54. package/dist/lib/devices/health.d.ts +77 -0
  55. package/dist/lib/devices/health.js +186 -0
  56. package/dist/lib/funnel.d.ts +5 -0
  57. package/dist/lib/funnel.js +23 -0
  58. package/dist/lib/git.d.ts +21 -5
  59. package/dist/lib/git.js +64 -14
  60. package/dist/lib/hosts/credentials.d.ts +28 -0
  61. package/dist/lib/hosts/credentials.js +48 -0
  62. package/dist/lib/hosts/dispatch.d.ts +25 -0
  63. package/dist/lib/hosts/dispatch.js +68 -2
  64. package/dist/lib/hosts/passthrough.d.ts +13 -10
  65. package/dist/lib/hosts/passthrough.js +119 -29
  66. package/dist/lib/mailbox-gc.js +4 -16
  67. package/dist/lib/mailbox.d.ts +39 -0
  68. package/dist/lib/mailbox.js +112 -0
  69. package/dist/lib/migrate.d.ts +12 -0
  70. package/dist/lib/migrate.js +55 -1
  71. package/dist/lib/paths.d.ts +13 -0
  72. package/dist/lib/paths.js +26 -4
  73. package/dist/lib/routines.d.ts +50 -12
  74. package/dist/lib/routines.js +82 -27
  75. package/dist/lib/runner.js +255 -13
  76. package/dist/lib/sandbox.d.ts +9 -1
  77. package/dist/lib/sandbox.js +11 -2
  78. package/dist/lib/session/bundle.d.ts +150 -0
  79. package/dist/lib/session/bundle.js +189 -0
  80. package/dist/lib/session/remote-bundle.d.ts +12 -0
  81. package/dist/lib/session/remote-bundle.js +61 -0
  82. package/dist/lib/session/sync/agents.d.ts +56 -6
  83. package/dist/lib/session/sync/agents.js +0 -0
  84. package/dist/lib/session/sync/config.d.ts +8 -0
  85. package/dist/lib/session/sync/config.js +6 -1
  86. package/dist/lib/session/sync/manifest.d.ts +14 -3
  87. package/dist/lib/session/sync/manifest.js +4 -0
  88. package/dist/lib/session/sync/provision.d.ts +49 -0
  89. package/dist/lib/session/sync/provision.js +91 -0
  90. package/dist/lib/session/sync/sync.d.ts +26 -2
  91. package/dist/lib/session/sync/sync.js +192 -69
  92. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  93. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  94. package/dist/lib/ssh-tunnel.js +13 -1
  95. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  96. package/dist/lib/staleness/detectors/subagents.js +5 -192
  97. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  98. package/dist/lib/staleness/writers/subagents.js +11 -102
  99. package/dist/lib/startup/command-registry.d.ts +4 -0
  100. package/dist/lib/startup/command-registry.js +16 -0
  101. package/dist/lib/state.d.ts +10 -2
  102. package/dist/lib/state.js +14 -2
  103. package/dist/lib/subagents-registry.d.ts +85 -0
  104. package/dist/lib/subagents-registry.js +393 -0
  105. package/dist/lib/subagents.d.ts +8 -8
  106. package/dist/lib/subagents.js +32 -663
  107. package/dist/lib/sync-umbrella.d.ts +1 -0
  108. package/dist/lib/sync-umbrella.js +14 -3
  109. package/dist/lib/triggers/webhook.d.ts +70 -27
  110. package/dist/lib/triggers/webhook.js +264 -43
  111. package/dist/lib/types.d.ts +9 -0
  112. package/dist/lib/usage.d.ts +42 -3
  113. package/dist/lib/usage.js +162 -22
  114. package/package.json +1 -1
@@ -4,7 +4,7 @@ import * as path from 'path';
4
4
  import { execFile } from 'child_process';
5
5
  import { promisify } from 'util';
6
6
  import { BrowserCdpConnectionError, CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity, } from './cdp.js';
7
- import { getProfile, getProfileRuntimeDir, getBrowserRuntimeDir, listProfiles, extractConfiguredPort, resolveEndpoint, } from './profiles.js';
7
+ import { getProfile, getProfileRuntimeDir, getProfileDownloadsDir, getProfileSessionsDir, getBrowserRuntimeDir, listProfiles, extractConfiguredPort, resolveEndpoint, } from './profiles.js';
8
8
  import { killChrome, getRunningChromeInfo, launchBrowser, allocatePort } from './chrome.js';
9
9
  import { connectLocal } from './drivers/local.js';
10
10
  import { connectSSH, shellQuote } from './drivers/ssh.js';
@@ -308,7 +308,9 @@ export class BrowserService {
308
308
  }
309
309
  else if (!conn) {
310
310
  conn = await this.connectProfile(effectiveProfile, resolved.target);
311
+ conn.profileName = composite;
311
312
  this.connections.set(composite, conn);
313
+ await this.applyDefaultDownloadBehavior(conn, composite);
312
314
  }
313
315
  // Browsers launch with --no-startup-window (session-cookie persistence,
314
316
  // see launchBrowser), so a bare `start` with no --url would otherwise
@@ -645,7 +647,7 @@ export class BrowserService {
645
647
  return result.result.value;
646
648
  }
647
649
  async screenshot(taskId, tabHint, outputPath, quality = 'compressed') {
648
- const { conn, task } = await this.findTask(taskId);
650
+ const { conn, task, profileName } = await this.findTask(taskId);
649
651
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
650
652
  const cdpTargetId = this.getCdpTargetId(task, shortId);
651
653
  const target = await this.getTarget(conn, cdpTargetId);
@@ -678,7 +680,7 @@ export class BrowserService {
678
680
  }
679
681
  extension = 'jpg';
680
682
  }
681
- const sessionsDir = path.join(getBrowserRuntimeDir(), 'sessions', task.name);
683
+ const sessionsDir = getProfileSessionsDir(profileName, task.name);
682
684
  const automaticPath = path.join(sessionsDir, `${Date.now()}.${extension}`);
683
685
  const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
684
686
  await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
@@ -695,7 +697,7 @@ export class BrowserService {
695
697
  * — without it, dark-mode pages render on a blank sheet.
696
698
  */
697
699
  async printToPdf(taskId, tabHint, outputPath) {
698
- const { conn, task } = await this.findTask(taskId);
700
+ const { conn, task, profileName } = await this.findTask(taskId);
699
701
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
700
702
  const cdpTargetId = this.getCdpTargetId(task, shortId);
701
703
  const target = await this.getTarget(conn, cdpTargetId);
@@ -705,7 +707,7 @@ export class BrowserService {
705
707
  const sessionId = await this.getSessionId(conn, target.targetId);
706
708
  const { data } = (await conn.cdp.send('Page.printToPDF', { printBackground: true, preferCSSPageSize: true }, sessionId));
707
709
  const buffer = Buffer.from(data, 'base64');
708
- const sessionsDir = path.join(getBrowserRuntimeDir(), 'sessions', task.name);
710
+ const sessionsDir = getProfileSessionsDir(profileName, task.name);
709
711
  const automaticPath = path.join(sessionsDir, `${Date.now()}.pdf`);
710
712
  const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
711
713
  await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
@@ -723,7 +725,7 @@ export class BrowserService {
723
725
  if (this.recordings.has(taskId)) {
724
726
  throw new Error(`Task "${taskId}" is already recording. Call record stop first.`);
725
727
  }
726
- const { conn, task } = await this.findTask(taskId);
728
+ const { conn, task, profileName } = await this.findTask(taskId);
727
729
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
728
730
  const cdpTargetId = this.getCdpTargetId(task, shortId);
729
731
  const target = await this.getTarget(conn, cdpTargetId);
@@ -739,7 +741,7 @@ export class BrowserService {
739
741
  throw new Error('--duration must be between 1 and 3600 seconds');
740
742
  if (maxMb < 1 || maxMb > 500)
741
743
  throw new Error('--max-mb must be between 1 and 500');
742
- const recordingsDir = path.join(getBrowserRuntimeDir(), 'sessions', task.name, 'recordings');
744
+ const recordingsDir = path.join(getProfileSessionsDir(profileName, task.name), 'recordings');
743
745
  await fs.promises.mkdir(recordingsDir, { recursive: true });
744
746
  const outputPath = path.join(recordingsDir, `${Date.now()}.webm`);
745
747
  // Resolve ffmpeg lazily so non-recording paths don't pay the import cost.
@@ -1481,20 +1483,50 @@ export class BrowserService {
1481
1483
  throw new Error(`Wait condition "${type}:${value}" not met within ${timeout}ms`);
1482
1484
  }
1483
1485
  // ─── Downloads ───────────────────────────────────────────────────────────────
1486
+ /**
1487
+ * Point the browser's default download destination at the profile's downloads
1488
+ * dir, browser-global, at connect time. Without this a download the agent never
1489
+ * explicitly routed (`browser download --path`) falls to Chromium's own default
1490
+ * — for an attached user browser, wherever that browser was last configured,
1491
+ * which is how downloads used to escape into random locations. Sent on the root
1492
+ * session (no sessionId) so every current and future tab inherits it. Best
1493
+ * effort: a remote CDP endpoint that doesn't expose the Browser domain must not
1494
+ * fail the whole connect.
1495
+ */
1496
+ async applyDefaultDownloadBehavior(conn, profileName) {
1497
+ const downloadPath = getProfileDownloadsDir(profileName);
1498
+ try {
1499
+ await fs.promises.mkdir(downloadPath, { recursive: true });
1500
+ await conn.cdp.send('Browser.setDownloadBehavior', {
1501
+ behavior: 'allow',
1502
+ downloadPath,
1503
+ eventsEnabled: true,
1504
+ });
1505
+ }
1506
+ catch {
1507
+ // Best effort: a remote CDP endpoint (ssh://, ws(s)://) may not expose the
1508
+ // Browser domain. Downloads then keep the endpoint's own default; the connect
1509
+ // must still succeed.
1510
+ }
1511
+ }
1484
1512
  async setDownloadPath(taskId, downloadPath, tabHint) {
1485
- const { conn, task } = await this.findTask(taskId);
1513
+ const { conn, task, profileName } = await this.findTask(taskId);
1486
1514
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1487
1515
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1488
1516
  const target = await this.getTarget(conn, cdpTargetId);
1489
1517
  if (!target)
1490
1518
  throw new Error(`Tab ${shortId} not found`);
1491
1519
  const sessionId = await this.getSessionId(conn, target.targetId);
1520
+ // No explicit --path: fall back to the profile's downloads dir, the same
1521
+ // destination already set browser-global at connect (applyDefaultDownloadBehavior).
1522
+ const resolvedPath = downloadPath ?? getProfileDownloadsDir(profileName);
1523
+ await fs.promises.mkdir(resolvedPath, { recursive: true });
1492
1524
  await conn.cdp.send('Browser.setDownloadBehavior', {
1493
1525
  behavior: 'allow',
1494
- downloadPath,
1526
+ downloadPath: resolvedPath,
1495
1527
  eventsEnabled: true,
1496
1528
  }, sessionId);
1497
- this.pendingDownloads.set(taskId, { path: downloadPath, completed: false });
1529
+ this.pendingDownloads.set(taskId, { path: resolvedPath, completed: false });
1498
1530
  conn.cdp.on('Browser.downloadProgress', (params) => {
1499
1531
  if (params.state === 'completed') {
1500
1532
  const dl = this.pendingDownloads.get(taskId);
@@ -1504,6 +1536,7 @@ export class BrowserService {
1504
1536
  }
1505
1537
  }
1506
1538
  });
1539
+ return resolvedPath;
1507
1540
  }
1508
1541
  async waitForDownload(taskId, timeout = 60000) {
1509
1542
  const start = Date.now();
@@ -1590,11 +1623,13 @@ export class BrowserService {
1590
1623
  pid,
1591
1624
  electron: true,
1592
1625
  targetFilter: profile.targetFilter,
1626
+ profileName: forkName,
1593
1627
  forkedFrom: profile.name,
1594
1628
  tasks: new Map(),
1595
1629
  sessionCache: new Map(),
1596
1630
  };
1597
1631
  this.connections.set(forkName, connection);
1632
+ await this.applyDefaultDownloadBehavior(connection, forkName);
1598
1633
  return { forkName, connection };
1599
1634
  }
1600
1635
  /**
@@ -1776,12 +1811,12 @@ export class BrowserService {
1776
1811
  if (!task) {
1777
1812
  throw new Error(`Task "${taskId}" not found on profile "${profileName}"`);
1778
1813
  }
1779
- return { conn, task };
1814
+ return { conn, task, profileName };
1780
1815
  }
1781
- for (const [, conn] of this.connections) {
1816
+ for (const [key, conn] of this.connections) {
1782
1817
  const task = conn.tasks.get(taskId);
1783
1818
  if (task) {
1784
- return { conn, task };
1819
+ return { conn, task, profileName: conn.profileName ?? key };
1785
1820
  }
1786
1821
  }
1787
1822
  throw new Error(`Task "${taskId}" not found`);
@@ -0,0 +1,40 @@
1
+ export type ArtifactKind = 'screenshot' | 'pdf' | 'recording' | 'download';
2
+ export interface BrowserArtifact {
3
+ kind: ArtifactKind;
4
+ /** Owning task for session captures; undefined for downloads. */
5
+ task?: string;
6
+ name: string;
7
+ path: string;
8
+ bytes: number;
9
+ mtimeMs: number;
10
+ }
11
+ export interface ProfileArtifacts {
12
+ profile: string;
13
+ artifacts: BrowserArtifact[];
14
+ }
15
+ /** Every capture for one profile, newest first. */
16
+ export declare function listProfileArtifacts(profile: string): BrowserArtifact[];
17
+ /**
18
+ * Captures grouped by profile. With `only` set, returns just that profile (even
19
+ * when empty); otherwise every profile dir on disk that has at least one capture.
20
+ */
21
+ export declare function listBrowserSessions(only?: string): ProfileArtifacts[];
22
+ /** Human table for the CLI. Returns lines (no trailing newline). */
23
+ export declare function renderBrowserSessions(groups: ProfileArtifacts[]): string;
24
+ /**
25
+ * Resolve `--open <sel>`: `latest` (newest across the groups) or a filename
26
+ * substring match. Returns the absolute path, or null if nothing matched.
27
+ */
28
+ export declare function resolveArtifact(groups: ProfileArtifacts[], selector: string): string | null;
29
+ /** Open a file in the OS default app. Returns true on success. */
30
+ export declare function openArtifact(filePath: string): boolean;
31
+ /**
32
+ * Shared CLI action for `agents browser sessions` and `agents sessions --browser`.
33
+ * `open` is the Commander value for `--open [selector]`: undefined when the flag
34
+ * is absent, `true` when passed bare (defaults to 'latest'), or the selector string.
35
+ */
36
+ export declare function runBrowserSessions(opts: {
37
+ profile?: string;
38
+ open?: string | boolean;
39
+ json?: boolean;
40
+ }): void;
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Read-only listing of a browser profile's on-disk captures — screenshots, PDFs,
3
+ * recordings (`<profile>/sessions/<task>/`) and downloads (`<profile>/downloads/`).
4
+ * Reads straight from `.cache/browser/<profile>/`, so it works whether or not the
5
+ * browser daemon is running. Backs both `agents browser sessions` and the
6
+ * `agents sessions --browser` alias.
7
+ */
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { spawnSync } from 'child_process';
11
+ import { getBrowserRuntimeDir, getProfileRuntimeDir } from './profiles.js';
12
+ import { formatRelativeTime } from '../session/relative-time.js';
13
+ const EXT_KIND = {
14
+ '.png': 'screenshot',
15
+ '.jpg': 'screenshot',
16
+ '.jpeg': 'screenshot',
17
+ '.webp': 'screenshot',
18
+ '.pdf': 'pdf',
19
+ '.webm': 'recording',
20
+ };
21
+ function statSafe(p) {
22
+ try {
23
+ return fs.statSync(p);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ function walkFiles(dir) {
30
+ let out = [];
31
+ let entries;
32
+ try {
33
+ entries = fs.readdirSync(dir, { withFileTypes: true });
34
+ }
35
+ catch {
36
+ return out;
37
+ }
38
+ for (const e of entries) {
39
+ const full = path.join(dir, e.name);
40
+ if (e.isDirectory())
41
+ out = out.concat(walkFiles(full));
42
+ else if (e.isFile())
43
+ out.push(full);
44
+ }
45
+ return out;
46
+ }
47
+ /** Every capture for one profile, newest first. */
48
+ export function listProfileArtifacts(profile) {
49
+ const root = getProfileRuntimeDir(profile);
50
+ const artifacts = [];
51
+ const sessionsRoot = path.join(root, 'sessions');
52
+ let taskDirs = [];
53
+ try {
54
+ taskDirs = fs.readdirSync(sessionsRoot, { withFileTypes: true });
55
+ }
56
+ catch { /* none */ }
57
+ for (const t of taskDirs) {
58
+ if (!t.isDirectory())
59
+ continue;
60
+ for (const file of walkFiles(path.join(sessionsRoot, t.name))) {
61
+ const kind = EXT_KIND[path.extname(file).toLowerCase()];
62
+ if (!kind)
63
+ continue;
64
+ const st = statSafe(file);
65
+ if (!st)
66
+ continue;
67
+ artifacts.push({ kind, task: t.name, name: path.basename(file), path: file, bytes: st.size, mtimeMs: st.mtimeMs });
68
+ }
69
+ }
70
+ for (const file of walkFiles(path.join(root, 'downloads'))) {
71
+ const st = statSafe(file);
72
+ if (!st)
73
+ continue;
74
+ artifacts.push({ kind: 'download', name: path.basename(file), path: file, bytes: st.size, mtimeMs: st.mtimeMs });
75
+ }
76
+ artifacts.sort((a, b) => b.mtimeMs - a.mtimeMs);
77
+ return artifacts;
78
+ }
79
+ /**
80
+ * Captures grouped by profile. With `only` set, returns just that profile (even
81
+ * when empty); otherwise every profile dir on disk that has at least one capture.
82
+ */
83
+ export function listBrowserSessions(only) {
84
+ let profiles;
85
+ if (only) {
86
+ profiles = [only];
87
+ }
88
+ else {
89
+ try {
90
+ profiles = fs.readdirSync(getBrowserRuntimeDir(), { withFileTypes: true })
91
+ .filter((e) => e.isDirectory() && e.name !== 'sessions')
92
+ .map((e) => e.name)
93
+ .sort();
94
+ }
95
+ catch {
96
+ profiles = [];
97
+ }
98
+ }
99
+ return profiles
100
+ .map((p) => ({ profile: p, artifacts: listProfileArtifacts(p) }))
101
+ .filter((r) => !!only || r.artifacts.length > 0);
102
+ }
103
+ function formatBytes(n) {
104
+ if (n < 1024)
105
+ return `${n} B`;
106
+ const units = ['KB', 'MB', 'GB'];
107
+ let val = n / 1024;
108
+ let i = 0;
109
+ while (val >= 1024 && i < units.length - 1) {
110
+ val /= 1024;
111
+ i++;
112
+ }
113
+ return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`;
114
+ }
115
+ /** Human table for the CLI. Returns lines (no trailing newline). */
116
+ export function renderBrowserSessions(groups) {
117
+ if (groups.length === 0)
118
+ return 'No browser profiles found.';
119
+ const lines = [];
120
+ for (const g of groups) {
121
+ const counts = { screenshot: 0, pdf: 0, recording: 0, download: 0 };
122
+ for (const a of g.artifacts)
123
+ counts[a.kind]++;
124
+ lines.push(`${g.profile} ` +
125
+ `screenshots ${counts.screenshot} pdfs ${counts.pdf} recordings ${counts.recording} downloads ${counts.download}`);
126
+ if (g.artifacts.length === 0) {
127
+ lines.push(' (no captures yet)');
128
+ continue;
129
+ }
130
+ for (const a of g.artifacts) {
131
+ const when = formatRelativeTime(new Date(a.mtimeMs).toISOString());
132
+ const where = a.kind === 'download' ? 'downloads/' : `sessions/${a.task}/`;
133
+ lines.push(` ${when.padEnd(12)} ${a.name.padEnd(28)} ${formatBytes(a.bytes).padStart(8)} ${where}`);
134
+ }
135
+ }
136
+ return lines.join('\n');
137
+ }
138
+ /**
139
+ * Resolve `--open <sel>`: `latest` (newest across the groups) or a filename
140
+ * substring match. Returns the absolute path, or null if nothing matched.
141
+ */
142
+ export function resolveArtifact(groups, selector) {
143
+ const all = groups.flatMap((g) => g.artifacts).sort((a, b) => b.mtimeMs - a.mtimeMs);
144
+ if (all.length === 0)
145
+ return null;
146
+ if (selector === 'latest')
147
+ return all[0].path;
148
+ const hit = all.find((a) => a.name === selector) ?? all.find((a) => a.name.includes(selector));
149
+ return hit ? hit.path : null;
150
+ }
151
+ /** Open a file in the OS default app. Returns true on success. */
152
+ export function openArtifact(filePath) {
153
+ const openers = process.platform === 'darwin'
154
+ ? [['open', [filePath]]]
155
+ : process.platform === 'win32'
156
+ ? [['cmd', ['/c', 'start', '""', filePath]]]
157
+ : [['xdg-open', [filePath]], ['gnome-open', [filePath]]];
158
+ for (const [cmd, args] of openers) {
159
+ if (spawnSync(cmd, args, { stdio: 'ignore' }).status === 0)
160
+ return true;
161
+ }
162
+ return false;
163
+ }
164
+ /**
165
+ * Shared CLI action for `agents browser sessions` and `agents sessions --browser`.
166
+ * `open` is the Commander value for `--open [selector]`: undefined when the flag
167
+ * is absent, `true` when passed bare (defaults to 'latest'), or the selector string.
168
+ */
169
+ export function runBrowserSessions(opts) {
170
+ const groups = listBrowserSessions(opts.profile);
171
+ if (opts.open !== undefined && opts.open !== false) {
172
+ const selector = opts.open === true ? 'latest' : opts.open;
173
+ const target = resolveArtifact(groups, selector);
174
+ if (!target) {
175
+ console.error(`No capture matching "${selector}".`);
176
+ process.exit(1);
177
+ }
178
+ console.log(target);
179
+ if (!openArtifact(target)) {
180
+ console.error(`Could not open ${target}`);
181
+ process.exit(1);
182
+ }
183
+ return;
184
+ }
185
+ if (opts.json) {
186
+ console.log(JSON.stringify(groups, null, 2));
187
+ return;
188
+ }
189
+ console.log(renderBrowserSessions(groups));
190
+ }
@@ -0,0 +1,37 @@
1
+ import type { CommsMsg } from './mailbox.js';
2
+ export type { CommsMsg } from './mailbox.js';
3
+ export declare const GLYPH: {
4
+ readonly live: "●";
5
+ readonly idle: "○";
6
+ readonly ask: "▲";
7
+ readonly delivered: "✓";
8
+ readonly pending: "⏳";
9
+ readonly route: "→";
10
+ readonly stream: "─→";
11
+ readonly thread: "⇄";
12
+ };
13
+ export type Accent = 'cyan' | 'amber';
14
+ /** Render the shared one-line comms header, with `right` aligned to the terminal edge. */
15
+ export declare function masthead(o: {
16
+ title: string;
17
+ host: string;
18
+ accent: Accent;
19
+ right?: string;
20
+ stats?: string[];
21
+ }): string;
22
+ /** Normalize non-negative counts across the eight Unicode sparkline levels. */
23
+ export declare function sparkline(counts: number[]): string;
24
+ /** Flatten mailbox histories into one newest-first communication stream. */
25
+ export declare function aggregate(boxes: {
26
+ id: string;
27
+ label: string;
28
+ messages: import('./mailbox.js').StoredMessage[];
29
+ }[]): CommsMsg[];
30
+ /** Count messages in rolling one-hour buckets, ordered oldest to newest. */
31
+ export declare function hourlyCounts(msgs: CommsMsg[], hours: number, now?: Date): number[];
32
+ /** Aggregate the human-readable sender-to-recipient routes, busiest first. */
33
+ export declare function graphEdges(msgs: CommsMsg[]): {
34
+ from: string;
35
+ to: string;
36
+ count: number;
37
+ }[];
@@ -0,0 +1,89 @@
1
+ import chalk from 'chalk';
2
+ import { stringWidth, terminalWidth } from './session/width.js';
3
+ export const GLYPH = {
4
+ live: '●',
5
+ idle: '○',
6
+ ask: '▲',
7
+ delivered: '✓',
8
+ pending: '⏳',
9
+ route: '→',
10
+ stream: '─→',
11
+ thread: '⇄',
12
+ };
13
+ /** Render the shared one-line comms header, with `right` aligned to the terminal edge. */
14
+ export function masthead(o) {
15
+ const accent = o.accent === 'cyan' ? chalk.cyan : chalk.yellow;
16
+ const title = `${accent('⌁')} ${chalk.bold(accent(o.title))}`;
17
+ const host = chalk.dim(` · ${o.host}`);
18
+ const stats = o.stats?.length ? ` ${o.stats.join(chalk.dim(' · '))}` : '';
19
+ const left = `${title}${host}${stats}`;
20
+ if (!o.right)
21
+ return left;
22
+ const right = chalk.dim(o.right);
23
+ const gap = Math.max(1, terminalWidth() - stringWidth(left) - stringWidth(right));
24
+ return `${left}${' '.repeat(gap)}${right}`;
25
+ }
26
+ const SPARK_LEVELS = '▁▂▃▄▅▆▇█';
27
+ /** Normalize non-negative counts across the eight Unicode sparkline levels. */
28
+ export function sparkline(counts) {
29
+ if (counts.length === 0)
30
+ return ' ';
31
+ const normalized = counts.map((count) => Number.isFinite(count) ? Math.max(0, count) : 0);
32
+ const max = Math.max(...normalized);
33
+ if (max === 0)
34
+ return ' '.repeat(counts.length);
35
+ return normalized
36
+ .map((count) => SPARK_LEVELS[Math.round((count / max) * (SPARK_LEVELS.length - 1))])
37
+ .join('');
38
+ }
39
+ /** Flatten mailbox histories into one newest-first communication stream. */
40
+ export function aggregate(boxes) {
41
+ return boxes
42
+ .flatMap((box) => {
43
+ const toLabel = box.label || box.id.slice(0, 8);
44
+ return box.messages.map((message) => ({
45
+ from: message.from || 'operator',
46
+ to: message.to,
47
+ toLabel,
48
+ ts: message.ts,
49
+ text: message.text,
50
+ state: message.state,
51
+ box: box.id,
52
+ }));
53
+ })
54
+ .sort((a, b) => compareText(b.ts, a.ts));
55
+ }
56
+ const HOUR_MS = 60 * 60 * 1_000;
57
+ /** Count messages in rolling one-hour buckets, ordered oldest to newest. */
58
+ export function hourlyCounts(msgs, hours, now = new Date()) {
59
+ const bucketCount = Number.isFinite(hours) ? Math.max(0, Math.floor(hours)) : 0;
60
+ const counts = Array.from({ length: bucketCount }, () => 0);
61
+ const nowMs = now.getTime();
62
+ if (bucketCount === 0 || !Number.isFinite(nowMs))
63
+ return counts;
64
+ for (const message of msgs) {
65
+ const timestamp = Date.parse(message.ts);
66
+ const age = nowMs - timestamp;
67
+ if (!Number.isFinite(timestamp) || age < 0 || age >= bucketCount * HOUR_MS)
68
+ continue;
69
+ const bucket = bucketCount - 1 - Math.floor(age / HOUR_MS);
70
+ counts[bucket]++;
71
+ }
72
+ return counts;
73
+ }
74
+ /** Aggregate the human-readable sender-to-recipient routes, busiest first. */
75
+ export function graphEdges(msgs) {
76
+ const edges = new Map();
77
+ for (const message of msgs) {
78
+ const key = JSON.stringify([message.from, message.toLabel]);
79
+ const existing = edges.get(key);
80
+ if (existing)
81
+ existing.count++;
82
+ else
83
+ edges.set(key, { from: message.from, to: message.toLabel, count: 1 });
84
+ }
85
+ return [...edges.values()].sort((a, b) => b.count - a.count || compareText(a.from, b.from) || compareText(a.to, b.to));
86
+ }
87
+ function compareText(a, b) {
88
+ return a < b ? -1 : a > b ? 1 : 0;
89
+ }
@@ -11,6 +11,7 @@
11
11
  * needs a provider token (e.g. HCLOUD_TOKEN) in the environment. We inject it
12
12
  * from a secrets bundle when one is configured (see `crabboxEnv`).
13
13
  */
14
+ import { type SecretsBundle } from '../secrets/bundles.js';
14
15
  /** A crabbox machine as reported by `crabbox list --json`. */
15
16
  export interface CrabboxBox {
16
17
  /** Provider machine name, e.g. `crabbox-blue-hermit-1039689b`. */
@@ -29,6 +30,16 @@ export interface CrabboxBox {
29
30
  class?: string;
30
31
  /** True when running + bootstrap-complete. */
31
32
  ready: boolean;
33
+ /** crabbox `keep` label — a kept box survives `crabbox cleanup` past its TTL. */
34
+ keep: boolean;
35
+ /** Unix seconds the box was created, or null when the label is absent. */
36
+ createdAt: number | null;
37
+ /** Unix seconds the lease expires, or null. */
38
+ expiresAt: number | null;
39
+ /** Unix seconds the box was last touched (reused / run against), or null. */
40
+ lastTouchedAt: number | null;
41
+ /** Idle-timeout window in seconds, or null. */
42
+ idleTimeoutSecs: number | null;
32
43
  }
33
44
  export interface CrabboxOptions {
34
45
  /**
@@ -41,6 +52,38 @@ export interface CrabboxOptions {
41
52
  }
42
53
  /** Locate the crabbox binary, or throw an actionable error. */
43
54
  export declare function findCrabbox(): string;
55
+ /**
56
+ * Env keys that mark a secrets bundle as usable for `--lease` — the provider
57
+ * tokens crabbox reads to reach a cloud API. Matching a bundle needs only its
58
+ * declared key NAMES; only the matched key's VALUE is ever injected (see
59
+ * `crabboxEnv`), so an auto-detected bundle can't leak its other secrets.
60
+ */
61
+ export declare const LEASE_PROVIDER_TOKEN_KEYS: string[];
62
+ /** The first bundle that declares a provider token key, or undefined. Pure over `bundles`. */
63
+ export declare function pickLeaseBundleFromList(bundles: SecretsBundle[]): string | undefined;
64
+ /** A resolved lease bundle: its name, plus (auto-detect only) the exact keys to inject. */
65
+ export interface ResolvedLeaseBundle {
66
+ name: string;
67
+ /** When set (auto-detect), inject ONLY these keys — not the whole bundle. */
68
+ keys?: string[];
69
+ }
70
+ /**
71
+ * The secrets bundle to feed crabbox, resolved in priority order:
72
+ * 1. `AGENTS_LEASE_SECRETS_BUNDLE` env var — explicit, no keychain
73
+ * 2. `lease.secretsBundle` config (set by `lease setup`) — explicit, no keychain
74
+ * 3. auto-detect: the first keychain bundle DECLARING a provider token key
75
+ *
76
+ * Tiers 1–2 are the frictionless steady state (env + config are plain, no keychain
77
+ * read). Tier 3 is a fallback that DOES read bundle metadata via `listBundles()`
78
+ * (one batched keychain unlock, ~7-day broker cache) — so it only runs when
79
+ * neither env nor config is set, and `crabboxEnv` memoizes the result for the
80
+ * process (it is called several times per lease, and we don't want a scan each
81
+ * time). Once `lease setup` persists the choice (tier 2), tier 3 never runs.
82
+ * Returns undefined when nothing matches — crabbox then falls back to `crabbox login`.
83
+ */
84
+ export declare function resolveLeaseBundle(): ResolvedLeaseBundle | undefined;
85
+ /** Persist `lease.secretsBundle` in agents config so `--lease` needs no env var. */
86
+ export declare function setLeaseSecretsBundle(name: string): void;
44
87
  /** Build the child env for crabbox, injecting a secrets bundle when configured. */
45
88
  export declare function crabboxEnv(opts: CrabboxOptions): NodeJS.ProcessEnv;
46
89
  /** All crabbox machines the broker knows about. */
@@ -96,3 +139,32 @@ export declare function crabboxRun(slug: string, remoteCmd: string, opts?: Crabb
96
139
  export declare function crabboxRunScript(slug: string, script: string, opts?: CrabboxRunOptions): Promise<number | null>;
97
140
  /** Release the lease / delete the box. Best-effort; never throws. */
98
141
  export declare function crabboxStop(slug: string, opts?: CrabboxOptions): boolean;
142
+ /** Never reap a box touched within this many seconds, regardless of idle-timeout. */
143
+ export declare const REAP_MIN_IDLE_SECS = 3600;
144
+ /**
145
+ * Whether a box is a genuine orphan that is safe to reap.
146
+ *
147
+ * Reap-safe ONLY when BOTH hold: the lease has already expired (`expiresAt` in the
148
+ * past) AND the box has not been touched for a safety window of
149
+ * `max(2 × idleTimeout, 1h)`. The freshness guard is what makes this safe against
150
+ * a TOCTOU race: a box a concurrent run just reused (`cbx_acquire_box`) has a
151
+ * recent `lastTouchedAt` and is never eligible. Reaping by `profile`/`ready` alone
152
+ * — as `crabbox cleanup` cannot (it skips `keep=true`, which every real orphan is)
153
+ * — would kill in-use boxes. Boxes with unknown age (`expiresAt`/`lastTouchedAt`
154
+ * null) are never reaped. `nowSecs` is injected so tests don't wall-clock.
155
+ */
156
+ export declare function isReapSafe(box: CrabboxBox, nowSecs: number): boolean;
157
+ /** The reap-safe orphans among `boxes`, most-stale (oldest touch) first. */
158
+ export declare function reapSafeOrphans(boxes: CrabboxBox[], nowSecs: number): CrabboxBox[];
159
+ /**
160
+ * List reap-safe orphans and (unless `dryRun`) stop them. Returns the candidates
161
+ * considered and the slugs actually stopped. Best-effort per box — a stop failure
162
+ * is skipped, never thrown. Backs `agents lease gc` and the 403 auto-reap opt-in.
163
+ */
164
+ export declare function reapOrphans(opts?: CrabboxOptions & {
165
+ nowSecs?: number;
166
+ dryRun?: boolean;
167
+ }): {
168
+ candidates: CrabboxBox[];
169
+ reaped: string[];
170
+ };