@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.
@@ -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<string>;
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;