@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.
- package/CHANGELOG.md +62 -0
- package/README.md +19 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +96 -28
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +27 -8
- package/dist/commands/funnel.d.ts +5 -0
- package/dist/commands/funnel.js +62 -0
- package/dist/commands/hosts.js +42 -0
- package/dist/commands/lease.d.ts +23 -0
- package/dist/commands/lease.js +201 -0
- package/dist/commands/mailboxes.d.ts +20 -0
- package/dist/commands/mailboxes.js +390 -0
- package/dist/commands/repo.d.ts +4 -4
- package/dist/commands/repo.js +30 -19
- package/dist/commands/routines.js +92 -29
- package/dist/commands/sessions-export.d.ts +2 -0
- package/dist/commands/sessions-export.js +279 -0
- package/dist/commands/sessions-import.d.ts +2 -0
- package/dist/commands/sessions-import.js +230 -0
- package/dist/commands/sessions-sync.d.ts +1 -0
- package/dist/commands/sessions-sync.js +16 -2
- package/dist/commands/sessions.js +12 -1
- package/dist/commands/setup.js +9 -0
- package/dist/commands/ssh.js +170 -5
- package/dist/commands/sync-provision.d.ts +23 -0
- package/dist/commands/sync-provision.js +107 -0
- package/dist/commands/usage.d.ts +2 -0
- package/dist/commands/usage.js +7 -2
- package/dist/commands/view.d.ts +1 -1
- package/dist/commands/webhook.d.ts +9 -0
- package/dist/commands/webhook.js +93 -0
- package/dist/index.js +7 -2
- package/dist/lib/agents.d.ts +44 -0
- package/dist/lib/agents.js +85 -35
- package/dist/lib/browser/drivers/ssh.js +19 -2
- package/dist/lib/browser/ipc.js +5 -4
- package/dist/lib/browser/profiles.d.ts +13 -0
- package/dist/lib/browser/profiles.js +17 -0
- package/dist/lib/browser/service.d.ts +12 -1
- package/dist/lib/browser/service.js +48 -13
- package/dist/lib/browser/sessions-list.d.ts +40 -0
- package/dist/lib/browser/sessions-list.js +190 -0
- package/dist/lib/comms-render.d.ts +37 -0
- package/dist/lib/comms-render.js +89 -0
- package/dist/lib/crabbox/cli.d.ts +72 -0
- package/dist/lib/crabbox/cli.js +158 -9
- package/dist/lib/crabbox/runtimes.d.ts +13 -0
- package/dist/lib/crabbox/runtimes.js +24 -0
- package/dist/lib/daemon.js +8 -1
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/devices/health.d.ts +77 -0
- package/dist/lib/devices/health.js +186 -0
- package/dist/lib/funnel.d.ts +5 -0
- package/dist/lib/funnel.js +23 -0
- package/dist/lib/git.d.ts +21 -5
- package/dist/lib/git.js +64 -14
- package/dist/lib/hosts/credentials.d.ts +28 -0
- package/dist/lib/hosts/credentials.js +48 -0
- package/dist/lib/hosts/dispatch.d.ts +25 -0
- package/dist/lib/hosts/dispatch.js +68 -2
- package/dist/lib/hosts/passthrough.d.ts +13 -10
- package/dist/lib/hosts/passthrough.js +119 -29
- package/dist/lib/mailbox-gc.js +4 -16
- package/dist/lib/mailbox.d.ts +39 -0
- package/dist/lib/mailbox.js +112 -0
- package/dist/lib/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/paths.d.ts +13 -0
- package/dist/lib/paths.js +26 -4
- package/dist/lib/routines.d.ts +50 -12
- package/dist/lib/routines.js +82 -27
- package/dist/lib/runner.js +255 -13
- package/dist/lib/sandbox.d.ts +9 -1
- package/dist/lib/sandbox.js +11 -2
- package/dist/lib/session/bundle.d.ts +150 -0
- package/dist/lib/session/bundle.js +189 -0
- package/dist/lib/session/remote-bundle.d.ts +12 -0
- package/dist/lib/session/remote-bundle.js +61 -0
- package/dist/lib/session/sync/agents.d.ts +56 -6
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +6 -1
- package/dist/lib/session/sync/manifest.d.ts +14 -3
- package/dist/lib/session/sync/manifest.js +4 -0
- package/dist/lib/session/sync/provision.d.ts +49 -0
- package/dist/lib/session/sync/provision.js +91 -0
- package/dist/lib/session/sync/sync.d.ts +26 -2
- package/dist/lib/session/sync/sync.js +192 -69
- package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
- package/dist/lib/session/sync/transcript-crypto.js +147 -0
- package/dist/lib/ssh-tunnel.js +13 -1
- package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
- package/dist/lib/staleness/detectors/subagents.js +5 -192
- package/dist/lib/staleness/writers/subagents.d.ts +10 -0
- package/dist/lib/staleness/writers/subagents.js +11 -102
- package/dist/lib/startup/command-registry.d.ts +4 -0
- package/dist/lib/startup/command-registry.js +16 -0
- package/dist/lib/state.d.ts +10 -2
- package/dist/lib/state.js +14 -2
- package/dist/lib/subagents-registry.d.ts +85 -0
- package/dist/lib/subagents-registry.js +393 -0
- package/dist/lib/subagents.d.ts +8 -8
- package/dist/lib/subagents.js +32 -663
- package/dist/lib/sync-umbrella.d.ts +1 -0
- package/dist/lib/sync-umbrella.js +14 -3
- package/dist/lib/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- package/dist/lib/types.d.ts +9 -0
- package/dist/lib/usage.d.ts +42 -3
- package/dist/lib/usage.js +162 -22
- 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 =
|
|
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 =
|
|
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(
|
|
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:
|
|
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
|
+
};
|