@phnx-labs/agents-cli 1.18.3 → 1.18.5
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 +89 -0
- package/README.md +14 -7
- package/dist/commands/browser.js +503 -132
- package/dist/commands/factory.js +13 -1
- package/dist/commands/rules.js +14 -0
- package/dist/commands/secrets.js +66 -11
- package/dist/lib/browser/cdp.js +7 -1
- package/dist/lib/browser/chrome.d.ts +1 -1
- package/dist/lib/browser/chrome.js +52 -26
- package/dist/lib/browser/devices.d.ts +11 -0
- package/dist/lib/browser/devices.js +14 -3
- package/dist/lib/browser/drivers/local.js +29 -2
- package/dist/lib/browser/drivers/ssh.js +82 -7
- package/dist/lib/browser/ipc.js +84 -9
- package/dist/lib/browser/profiles.d.ts +69 -3
- package/dist/lib/browser/profiles.js +184 -20
- package/dist/lib/browser/runtime-state.d.ts +117 -0
- package/dist/lib/browser/runtime-state.js +259 -0
- package/dist/lib/browser/service.d.ts +57 -10
- package/dist/lib/browser/service.js +477 -73
- package/dist/lib/browser/types.d.ts +67 -2
- package/dist/lib/browser/types.js +20 -0
- package/dist/lib/daemon.js +36 -3
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/help.js +30 -3
- package/dist/lib/secrets/bundles.d.ts +20 -0
- package/dist/lib/secrets/bundles.js +56 -0
- package/dist/lib/secrets/index.js +8 -8
- package/dist/lib/types.d.ts +16 -1
- package/dist/lib/version.d.ts +7 -0
- package/dist/lib/version.js +25 -0
- package/package.json +1 -1
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import { getProfileRuntimeDir, getBrowserRuntimeDir } from './profiles.js';
|
|
5
|
+
const PID_FILE = 'pid';
|
|
6
|
+
const PORT_FILE = 'port';
|
|
7
|
+
const COMMAND_FILE = 'command';
|
|
8
|
+
const META_FILE = 'meta.json';
|
|
9
|
+
function readNumberFile(p) {
|
|
10
|
+
try {
|
|
11
|
+
const n = parseInt(fs.readFileSync(p, 'utf-8').trim(), 10);
|
|
12
|
+
return Number.isFinite(n) ? n : null;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function readStringFile(p) {
|
|
19
|
+
try {
|
|
20
|
+
return fs.readFileSync(p, 'utf-8').trim() || null;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Save the runtime record atomically. We write the legacy one-value-per-
|
|
28
|
+
* file fields plus a JSON meta blob so future code can read either.
|
|
29
|
+
* The cache directory may not exist yet (first launch); we create it.
|
|
30
|
+
*/
|
|
31
|
+
export function writeProfileRuntime(profileName, runtime) {
|
|
32
|
+
const dir = getProfileRuntimeDir(profileName);
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
fs.writeFileSync(path.join(dir, PID_FILE), String(runtime.pid));
|
|
35
|
+
fs.writeFileSync(path.join(dir, PORT_FILE), String(runtime.port));
|
|
36
|
+
if (runtime.command) {
|
|
37
|
+
fs.writeFileSync(path.join(dir, COMMAND_FILE), runtime.command);
|
|
38
|
+
}
|
|
39
|
+
const meta = {
|
|
40
|
+
...runtime,
|
|
41
|
+
daemonPid: runtime.daemonPid ?? process.pid,
|
|
42
|
+
spawnedAt: runtime.spawnedAt ?? Date.now(),
|
|
43
|
+
};
|
|
44
|
+
fs.writeFileSync(path.join(dir, META_FILE), JSON.stringify(meta));
|
|
45
|
+
}
|
|
46
|
+
/** Read just the JSON meta record. Returns null when absent or malformed. */
|
|
47
|
+
export function readProfileRuntimeMeta(profileName) {
|
|
48
|
+
const dir = getProfileRuntimeDir(profileName);
|
|
49
|
+
try {
|
|
50
|
+
const raw = fs.readFileSync(path.join(dir, META_FILE), 'utf-8');
|
|
51
|
+
const obj = JSON.parse(raw);
|
|
52
|
+
if (typeof obj !== 'object' || obj === null)
|
|
53
|
+
return null;
|
|
54
|
+
return obj;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Read the runtime triple. Returns null when the files are missing OR when
|
|
62
|
+
* the recorded pid no longer points at the same process we launched —
|
|
63
|
+
* stale data is auto-cleaned to keep the next caller from acting on it.
|
|
64
|
+
*/
|
|
65
|
+
export function readProfileRuntime(profileName) {
|
|
66
|
+
const dir = getProfileRuntimeDir(profileName);
|
|
67
|
+
const pid = readNumberFile(path.join(dir, PID_FILE));
|
|
68
|
+
const port = readNumberFile(path.join(dir, PORT_FILE));
|
|
69
|
+
const command = readStringFile(path.join(dir, COMMAND_FILE)) ?? undefined;
|
|
70
|
+
if (pid === null || port === null)
|
|
71
|
+
return null;
|
|
72
|
+
if (!isProcessAlive(pid, command)) {
|
|
73
|
+
clearProfileRuntime(profileName);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return { pid, port, command };
|
|
77
|
+
}
|
|
78
|
+
/** Remove the pid/port/command/meta files. Leaves chrome-data + tasks.json intact. */
|
|
79
|
+
export function clearProfileRuntime(profileName) {
|
|
80
|
+
const dir = getProfileRuntimeDir(profileName);
|
|
81
|
+
for (const f of [PID_FILE, PORT_FILE, COMMAND_FILE, META_FILE]) {
|
|
82
|
+
try {
|
|
83
|
+
fs.unlinkSync(path.join(dir, f));
|
|
84
|
+
}
|
|
85
|
+
catch { /* not present */ }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Recursively remove the whole profile cache (chrome-data, tasks.json,
|
|
90
|
+
* everything). Used by `profiles delete` so an old profile name doesn't
|
|
91
|
+
* leak its history into a freshly-recreated one.
|
|
92
|
+
*/
|
|
93
|
+
export function removeProfileCache(profileName) {
|
|
94
|
+
const dir = getProfileRuntimeDir(profileName);
|
|
95
|
+
try {
|
|
96
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
catch { /* gone */ }
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Find every cache directory belonging to a given profile. The composite
|
|
102
|
+
* naming (`<name>@<endpoint>`) means a single agents-cli profile can have
|
|
103
|
+
* multiple runtime dirs side by side; this finds them all plus the legacy
|
|
104
|
+
* non-composite dir from older builds.
|
|
105
|
+
*/
|
|
106
|
+
export function listProfileCacheDirs(profileName) {
|
|
107
|
+
const root = getBrowserRuntimeDir();
|
|
108
|
+
if (!fs.existsSync(root))
|
|
109
|
+
return [];
|
|
110
|
+
const matches = [];
|
|
111
|
+
for (const entry of fs.readdirSync(root)) {
|
|
112
|
+
if (entry === profileName)
|
|
113
|
+
matches.push(path.join(root, entry));
|
|
114
|
+
else if (entry.startsWith(`${profileName}@`))
|
|
115
|
+
matches.push(path.join(root, entry));
|
|
116
|
+
}
|
|
117
|
+
return matches;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* `process.kill(pid, 0)` answers "is a process with this id alive?" — but
|
|
121
|
+
* pid reuse is real on long-uptime machines, and a stale cache pointing
|
|
122
|
+
* at a since-reassigned pid would happily call the imposter ours.
|
|
123
|
+
*
|
|
124
|
+
* Strategy: if we recorded the executable basename when we launched, ask
|
|
125
|
+
* `ps` what command the live pid is running and compare. No command on
|
|
126
|
+
* record means we fall back to the existence check (older cache entries
|
|
127
|
+
* or `pid:0` for "attached to an externally-launched browser").
|
|
128
|
+
*/
|
|
129
|
+
export function isProcessAlive(pid, expectedCommand) {
|
|
130
|
+
if (pid === 0)
|
|
131
|
+
return true;
|
|
132
|
+
try {
|
|
133
|
+
process.kill(pid, 0);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
if (err && err.code === 'EPERM') {
|
|
137
|
+
// exists but we can't signal it — count it as alive
|
|
138
|
+
return !expectedCommand || matchesCommand(pid, expectedCommand);
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
if (!expectedCommand)
|
|
143
|
+
return true;
|
|
144
|
+
return matchesCommand(pid, expectedCommand);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Read every profile cache directory and produce a structured snapshot.
|
|
148
|
+
* Works without the daemon — `agents browser ps` uses this to render a
|
|
149
|
+
* complete state view even when the IPC server is down. The caller can
|
|
150
|
+
* post-process to detect conflicts (e.g. two profiles with the same port,
|
|
151
|
+
* or a port someone else is listening on).
|
|
152
|
+
*/
|
|
153
|
+
export function listAllProfileSnapshots() {
|
|
154
|
+
const root = getBrowserRuntimeDir();
|
|
155
|
+
if (!fs.existsSync(root))
|
|
156
|
+
return [];
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const name of fs.readdirSync(root).sort()) {
|
|
159
|
+
const dir = path.join(root, name);
|
|
160
|
+
let stat;
|
|
161
|
+
try {
|
|
162
|
+
stat = fs.statSync(dir);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (!stat.isDirectory())
|
|
168
|
+
continue;
|
|
169
|
+
const meta = readProfileRuntimeMeta(name);
|
|
170
|
+
const taskCount = readTaskCount(dir);
|
|
171
|
+
const pidAlive = meta ? isProcessAlive(meta.pid, meta.command) : false;
|
|
172
|
+
const tunnelAlive = meta?.tunnelPid ? isProcessAlive(meta.tunnelPid, 'ssh') : false;
|
|
173
|
+
const daemonAlive = meta?.daemonPid ? isProcessAlive(meta.daemonPid) : false;
|
|
174
|
+
out.push({ name, dir, meta, pidAlive, tunnelAlive, daemonAlive, taskCount });
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
function readTaskCount(dir) {
|
|
179
|
+
try {
|
|
180
|
+
const raw = fs.readFileSync(path.join(dir, 'tasks.json'), 'utf-8');
|
|
181
|
+
const obj = JSON.parse(raw);
|
|
182
|
+
if (Array.isArray(obj))
|
|
183
|
+
return obj.length;
|
|
184
|
+
if (obj && typeof obj === 'object')
|
|
185
|
+
return Object.keys(obj).length;
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Reap browser + tunnel processes spawned by daemons that no longer exist.
|
|
194
|
+
* Call once on daemon startup. The idea: every process we spawn records
|
|
195
|
+
* its daemonPid in meta.json. If that daemon is dead (crashed, SIGKILL),
|
|
196
|
+
* its children were left rootless — kill them now so they don't hijack
|
|
197
|
+
* the next session's local ports.
|
|
198
|
+
*
|
|
199
|
+
* We're conservative: a record with no daemonPid (older builds) is left
|
|
200
|
+
* alone — we'd rather leak than wrongly kill a user-owned process that
|
|
201
|
+
* happens to share metadata.
|
|
202
|
+
*/
|
|
203
|
+
export function reapOrphanedProcesses() {
|
|
204
|
+
const root = getBrowserRuntimeDir();
|
|
205
|
+
if (!fs.existsSync(root))
|
|
206
|
+
return { reaped: 0, details: [] };
|
|
207
|
+
let reaped = 0;
|
|
208
|
+
const details = [];
|
|
209
|
+
for (const profileName of fs.readdirSync(root)) {
|
|
210
|
+
const meta = readProfileRuntimeMeta(profileName);
|
|
211
|
+
if (!meta)
|
|
212
|
+
continue;
|
|
213
|
+
if (!meta.daemonPid)
|
|
214
|
+
continue;
|
|
215
|
+
if (meta.daemonPid === process.pid)
|
|
216
|
+
continue;
|
|
217
|
+
// Owning daemon still alive — leave its kids alone.
|
|
218
|
+
if (isProcessAlive(meta.daemonPid))
|
|
219
|
+
continue;
|
|
220
|
+
// Kill what the dead daemon left behind. Best-effort.
|
|
221
|
+
const kill = (pid, label) => {
|
|
222
|
+
if (!pid || pid === 0)
|
|
223
|
+
return;
|
|
224
|
+
// Only kill if it matches the recorded command — guards against
|
|
225
|
+
// pid reuse handing us an unrelated process to murder.
|
|
226
|
+
if (meta.command && !matchesCommand(pid, meta.command) &&
|
|
227
|
+
!matchesCommand(pid, 'ssh'))
|
|
228
|
+
return;
|
|
229
|
+
try {
|
|
230
|
+
process.kill(pid, 'SIGTERM');
|
|
231
|
+
reaped++;
|
|
232
|
+
details.push(`reaped ${label ?? 'pid'} ${pid} (profile ${profileName})`);
|
|
233
|
+
}
|
|
234
|
+
catch { /* already gone */ }
|
|
235
|
+
};
|
|
236
|
+
kill(meta.pid, 'browser');
|
|
237
|
+
kill(meta.tunnelPid, 'tunnel');
|
|
238
|
+
clearProfileRuntime(profileName);
|
|
239
|
+
}
|
|
240
|
+
return { reaped, details };
|
|
241
|
+
}
|
|
242
|
+
function matchesCommand(pid, expectedCommand) {
|
|
243
|
+
try {
|
|
244
|
+
const out = execSync(`ps -p ${pid} -o comm=`, {
|
|
245
|
+
encoding: 'utf-8',
|
|
246
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
247
|
+
}).trim();
|
|
248
|
+
if (!out)
|
|
249
|
+
return false;
|
|
250
|
+
// Match on the basename only — `/Applications/Comet.app/Contents/MacOS/Comet`
|
|
251
|
+
// vs the recorded `Comet`, vs `Google\ Chrome`. Case-insensitive.
|
|
252
|
+
const live = path.basename(out).toLowerCase();
|
|
253
|
+
const want = path.basename(expectedCommand).toLowerCase();
|
|
254
|
+
return live === want || live.startsWith(want) || want.startsWith(live);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -34,7 +34,14 @@ export declare function pickWindowTarget<T extends {
|
|
|
34
34
|
url?: string;
|
|
35
35
|
title?: string;
|
|
36
36
|
}>(targets: T[], filter: string | undefined): T | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Parse a `--since`/`--until` value. Accepts ISO-8601 absolute timestamps
|
|
39
|
+
* or relative offsets like `30s`, `5m`, `2h`, `1d`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseSinceUntil(s: string): Date;
|
|
42
|
+
export declare function readNewestMatchingFile(dir: string, prefix: string, tailLines: number): string;
|
|
37
43
|
export declare class BrowserService {
|
|
44
|
+
private static readonly SOURCE_PREFIX;
|
|
38
45
|
private connections;
|
|
39
46
|
private forkingProfiles;
|
|
40
47
|
private consoleLogs;
|
|
@@ -45,20 +52,13 @@ export declare class BrowserService {
|
|
|
45
52
|
start(profileName: string, opts?: {
|
|
46
53
|
taskName?: string;
|
|
47
54
|
url?: string;
|
|
55
|
+
endpointName?: string;
|
|
48
56
|
}): Promise<{
|
|
49
57
|
task: string;
|
|
50
58
|
name: string;
|
|
51
59
|
tabId?: string;
|
|
52
60
|
windowId?: string;
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Launch (or attach to) the profile's browser without creating a task. Used by
|
|
56
|
-
* `agents browser profiles launch <name>` so users can warm up the browser —
|
|
57
|
-
* including the first-run onboarding flow — before any automation starts.
|
|
58
|
-
*/
|
|
59
|
-
launchProfile(profileName: string): Promise<{
|
|
60
|
-
port: number;
|
|
61
|
-
pid: number;
|
|
61
|
+
profile: string;
|
|
62
62
|
}>;
|
|
63
63
|
stop(taskName: string): Promise<{
|
|
64
64
|
ok: boolean;
|
|
@@ -93,7 +93,34 @@ export declare class BrowserService {
|
|
|
93
93
|
tabs(taskId?: string, profileName?: string): Promise<TabInfo[]>;
|
|
94
94
|
tabClose(taskId: string, tabHint?: string): Promise<void>;
|
|
95
95
|
evaluate(taskId: string, tabHint: string | undefined, expression: string): Promise<unknown>;
|
|
96
|
-
screenshot(taskId: string, tabHint?: string, outputPath?: string): Promise<
|
|
96
|
+
screenshot(taskId: string, tabHint?: string, outputPath?: string, quality?: 'compressed' | 'raw'): Promise<{
|
|
97
|
+
path: string;
|
|
98
|
+
bytes: number;
|
|
99
|
+
width: number;
|
|
100
|
+
height: number;
|
|
101
|
+
}>;
|
|
102
|
+
private recordings;
|
|
103
|
+
recordStart(taskId: string, tabHint?: string, opts?: {
|
|
104
|
+
fps?: number;
|
|
105
|
+
duration?: number;
|
|
106
|
+
maxMb?: number;
|
|
107
|
+
}): Promise<{
|
|
108
|
+
path: string;
|
|
109
|
+
fps: number;
|
|
110
|
+
durationCapSec: number;
|
|
111
|
+
maxMb: number;
|
|
112
|
+
}>;
|
|
113
|
+
recordStop(taskId: string, reason?: 'manual' | 'duration-cap' | 'size-cap'): Promise<{
|
|
114
|
+
path: string;
|
|
115
|
+
bytes: number;
|
|
116
|
+
durationMs: number;
|
|
117
|
+
reason: string;
|
|
118
|
+
}>;
|
|
119
|
+
recordStatus(taskId: string): Promise<{
|
|
120
|
+
recording: boolean;
|
|
121
|
+
path?: string;
|
|
122
|
+
elapsedMs?: number;
|
|
123
|
+
}>;
|
|
97
124
|
private refsCache;
|
|
98
125
|
refs(taskId: string, tabHint?: string, opts?: RefOpts): Promise<{
|
|
99
126
|
refs: string;
|
|
@@ -142,6 +169,15 @@ export declare class BrowserService {
|
|
|
142
169
|
maxChars?: number;
|
|
143
170
|
tabHint?: string;
|
|
144
171
|
}): Promise<string>;
|
|
172
|
+
getAppLogs(taskId: string, opts: {
|
|
173
|
+
lines?: number;
|
|
174
|
+
level?: string;
|
|
175
|
+
filter?: string;
|
|
176
|
+
message?: string;
|
|
177
|
+
source?: string;
|
|
178
|
+
since?: string;
|
|
179
|
+
until?: string;
|
|
180
|
+
}): Promise<any[]>;
|
|
145
181
|
wait(taskId: string, type: 'time' | 'selector' | 'url' | 'function' | 'load', value: string | number, options?: {
|
|
146
182
|
timeout?: number;
|
|
147
183
|
tabHint?: string;
|
|
@@ -152,10 +188,21 @@ export declare class BrowserService {
|
|
|
152
188
|
shutdown(): Promise<void>;
|
|
153
189
|
private findAvailableFork;
|
|
154
190
|
private forkElectronProfile;
|
|
191
|
+
/**
|
|
192
|
+
* Connect to a profile at a specific endpoint preset. The caller has
|
|
193
|
+
* already resolved the endpoint and built the `effectiveProfile` with
|
|
194
|
+
* the per-endpoint binary/targetFilter overrides applied; we just use it.
|
|
195
|
+
*
|
|
196
|
+
* `effectiveProfile.name` is the composite identifier (`<profile>@<endpoint>`)
|
|
197
|
+
* so per-endpoint pid/port files don't collide when the same app runs
|
|
198
|
+
* locally and remotely at the same time.
|
|
199
|
+
*/
|
|
155
200
|
private connectProfile;
|
|
156
201
|
private connectEndpoint;
|
|
157
202
|
private enableDomains;
|
|
158
203
|
private getOrCreateWindow;
|
|
204
|
+
private hasTaskNamed;
|
|
205
|
+
private generateUniqueTaskName;
|
|
159
206
|
private findTask;
|
|
160
207
|
private getTabsForTask;
|
|
161
208
|
private getProfileStatus;
|