@phnx-labs/agents-cli 1.18.4 → 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 +18 -7
- package/README.md +1 -1
- package/dist/commands/browser.js +275 -141
- 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/drivers/local.js +29 -2
- package/dist/lib/browser/drivers/ssh.js +82 -7
- package/dist/lib/browser/ipc.js +55 -0
- package/dist/lib/browser/profiles.d.ts +46 -2
- package/dist/lib/browser/profiles.js +123 -19
- 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 +16 -0
- package/dist/lib/browser/service.js +163 -16
- package/dist/lib/browser/types.d.ts +13 -1
- package/dist/lib/daemon.js +36 -3
- package/dist/lib/events.d.ts +1 -1
- 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 +4 -0
- package/dist/lib/version.d.ts +7 -0
- package/dist/lib/version.js +25 -0
- package/package.json +1 -1
|
@@ -21,6 +21,8 @@ function configToProfile(name, config) {
|
|
|
21
21
|
chrome: config.chrome,
|
|
22
22
|
secrets: config.secrets,
|
|
23
23
|
viewport: config.viewport,
|
|
24
|
+
logDir: config.logDir,
|
|
25
|
+
logHost: config.logHost,
|
|
24
26
|
};
|
|
25
27
|
}
|
|
26
28
|
function profileToConfig(profile) {
|
|
@@ -44,6 +46,10 @@ function profileToConfig(profile) {
|
|
|
44
46
|
config.secrets = profile.secrets;
|
|
45
47
|
if (profile.viewport)
|
|
46
48
|
config.viewport = profile.viewport;
|
|
49
|
+
if (profile.logDir)
|
|
50
|
+
config.logDir = profile.logDir;
|
|
51
|
+
if (profile.logHost)
|
|
52
|
+
config.logHost = profile.logHost;
|
|
47
53
|
return config;
|
|
48
54
|
}
|
|
49
55
|
export async function listProfiles() {
|
|
@@ -60,14 +66,45 @@ export async function getProfile(name) {
|
|
|
60
66
|
return configToProfile(name, config);
|
|
61
67
|
}
|
|
62
68
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
69
|
+
* Compute the LOCAL port a profile will occupy at runtime:
|
|
70
|
+
* - `cdp://127.0.0.1:N` → N (we listen on N directly)
|
|
71
|
+
* - `ssh://host?port=N` → N (the SSH tunnel binds local N → remote N now)
|
|
72
|
+
* - `ws[s]://`, `http[s]://` → undefined (we don't claim a local port)
|
|
73
|
+
*
|
|
74
|
+
* This is what callers should compare to detect collisions; the (host,
|
|
75
|
+
* port) tuple is no longer enough because SSH profiles do compete with
|
|
76
|
+
* cdp:// profiles for local ports under the new tunnel scheme.
|
|
77
|
+
*/
|
|
78
|
+
export function effectiveLocalPort(profile) {
|
|
79
|
+
const presets = getEndpointPresets(profile);
|
|
80
|
+
const firstName = profile.defaultEndpoint && presets[profile.defaultEndpoint]
|
|
81
|
+
? profile.defaultEndpoint
|
|
82
|
+
: Object.keys(presets)[0];
|
|
83
|
+
if (!firstName)
|
|
84
|
+
return undefined;
|
|
85
|
+
const target = presets[firstName].target;
|
|
86
|
+
let url;
|
|
87
|
+
try {
|
|
88
|
+
url = new URL(target);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
if (url.protocol !== 'cdp:' && url.protocol !== 'ssh:')
|
|
94
|
+
return undefined;
|
|
95
|
+
return parseEndpointUrl(target)?.port;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Find a port in 9222–9399 that is not already claimed by ANY existing
|
|
99
|
+
* profile (cdp:// or ssh://) and is not in use by any OS process. The
|
|
100
|
+
* SSH change to bind locally on `?port=N` means we no longer get to
|
|
101
|
+
* skip remote profiles in this scan.
|
|
65
102
|
*/
|
|
66
103
|
export async function findFreeProfilePort() {
|
|
67
104
|
const profiles = await listProfiles();
|
|
68
105
|
const usedByProfile = new Set();
|
|
69
106
|
for (const p of profiles) {
|
|
70
|
-
const port =
|
|
107
|
+
const port = effectiveLocalPort(p);
|
|
71
108
|
if (port !== undefined)
|
|
72
109
|
usedByProfile.add(port);
|
|
73
110
|
}
|
|
@@ -90,15 +127,20 @@ export async function createProfile(profile) {
|
|
|
90
127
|
if (meta.browser?.[profile.name]) {
|
|
91
128
|
throw new Error(`Profile "${profile.name}" already exists`);
|
|
92
129
|
}
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
130
|
+
// Collision check. Every CDP/SSH profile ends up listening on (or
|
|
131
|
+
// tunneling to) the same LOCAL port number as the one configured in the
|
|
132
|
+
// endpoint URL — SSH profiles now reuse `?port=N` locally so we no
|
|
133
|
+
// longer need to scope by host. Two profiles that would need the same
|
|
134
|
+
// local port can't both run at the same time.
|
|
135
|
+
const newLocal = effectiveLocalPort(profile);
|
|
136
|
+
if (newLocal !== undefined && meta.browser) {
|
|
96
137
|
for (const [existingName, existingConfig] of Object.entries(meta.browser)) {
|
|
97
138
|
const existingProfile = configToProfile(existingName, existingConfig);
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
throw new Error(`
|
|
101
|
-
`Each profile must own a unique port
|
|
139
|
+
const existingLocal = effectiveLocalPort(existingProfile);
|
|
140
|
+
if (existingLocal === newLocal) {
|
|
141
|
+
throw new Error(`Local port ${newLocal} is already used by profile "${existingName}". ` +
|
|
142
|
+
`Each profile must own a unique local port (SSH tunnels now bind ` +
|
|
143
|
+
`to their configured port locally too). Pick a different port.`);
|
|
102
144
|
}
|
|
103
145
|
}
|
|
104
146
|
}
|
|
@@ -181,17 +223,34 @@ export function resolveEndpoint(profile, endpointName) {
|
|
|
181
223
|
};
|
|
182
224
|
}
|
|
183
225
|
/**
|
|
184
|
-
* Extract the port intended by the profile's default endpoint.
|
|
226
|
+
* Extract the (host, port) pair intended by the profile's default endpoint.
|
|
185
227
|
* Returns undefined for endpoint shapes that don't carry a port (e.g. ws:// without one).
|
|
228
|
+
*
|
|
229
|
+
* Ports are scoped by host: a `cdp://127.0.0.1:9222` profile (local Chrome on
|
|
230
|
+
* this machine) and an `ssh://mac-mini:9222` profile (Comet on mac-mini)
|
|
231
|
+
* point at different physical ports — the host disambiguates them.
|
|
232
|
+
*
|
|
233
|
+
* Accepts both `scheme://host:port` and `scheme://host?port=N` shapes (the
|
|
234
|
+
* latter is the documented form in `types.ts` for `ssh://`). Without this,
|
|
235
|
+
* `ssh://mac-mini?port=18805` would silently fall back to 9222 and every
|
|
236
|
+
* `?port=`-style SSH profile would collide on creation.
|
|
186
237
|
*/
|
|
187
|
-
export function
|
|
238
|
+
export function extractConfiguredEndpoint(profile) {
|
|
188
239
|
const presets = getEndpointPresets(profile);
|
|
189
240
|
const firstName = profile.defaultEndpoint && presets[profile.defaultEndpoint]
|
|
190
241
|
? profile.defaultEndpoint
|
|
191
242
|
: Object.keys(presets)[0];
|
|
192
243
|
if (!firstName)
|
|
193
244
|
return undefined;
|
|
194
|
-
|
|
245
|
+
return parseEndpointUrl(presets[firstName].target);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Shared endpoint parser used by both the collision-detection code path and
|
|
249
|
+
* the connection drivers. Returning a single normalized `(host, port)` here
|
|
250
|
+
* keeps `extractConfiguredEndpoint` and the SSH driver from drifting on URL
|
|
251
|
+
* conventions (which is how `?port=N` ended up being silently ignored).
|
|
252
|
+
*/
|
|
253
|
+
export function parseEndpointUrl(endpoint) {
|
|
195
254
|
let url;
|
|
196
255
|
try {
|
|
197
256
|
url = new URL(endpoint);
|
|
@@ -199,11 +258,56 @@ export function extractConfiguredPort(profile) {
|
|
|
199
258
|
catch {
|
|
200
259
|
return undefined;
|
|
201
260
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (
|
|
207
|
-
return
|
|
261
|
+
const host = normalizeHost(url.hostname, url.protocol);
|
|
262
|
+
if (!host)
|
|
263
|
+
return undefined;
|
|
264
|
+
const port = extractPortFromUrl(url);
|
|
265
|
+
if (port !== undefined)
|
|
266
|
+
return { host, port };
|
|
267
|
+
// SSH endpoints tunnel to a remote port AND bind that same port locally,
|
|
268
|
+
// so they do "own" a local port — the host-scoped collision check used
|
|
269
|
+
// to disagree, but we want the local-port-scoped semantics now.
|
|
270
|
+
if (url.protocol === 'cdp:' || url.protocol === 'ssh:')
|
|
271
|
+
return { host, port: 9222 };
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
function extractPortFromUrl(url) {
|
|
275
|
+
if (url.port) {
|
|
276
|
+
const n = parseInt(url.port, 10);
|
|
277
|
+
if (Number.isFinite(n))
|
|
278
|
+
return n;
|
|
279
|
+
}
|
|
280
|
+
// `scheme://host?port=N` — the form documented for SSH endpoints in
|
|
281
|
+
// `types.ts`. WHATWG URL parsing surfaces it via searchParams only.
|
|
282
|
+
const qp = url.searchParams.get('port');
|
|
283
|
+
if (qp) {
|
|
284
|
+
const n = parseInt(qp, 10);
|
|
285
|
+
if (Number.isFinite(n))
|
|
286
|
+
return n;
|
|
287
|
+
}
|
|
208
288
|
return undefined;
|
|
209
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Extract the port intended by the profile's default endpoint.
|
|
292
|
+
* Returns undefined for endpoint shapes that don't carry a port (e.g. ws:// without one).
|
|
293
|
+
*
|
|
294
|
+
* Note: this loses the host dimension — for collision detection use
|
|
295
|
+
* `extractConfiguredEndpoint` instead, which returns the (host, port) pair.
|
|
296
|
+
*/
|
|
297
|
+
export function extractConfiguredPort(profile) {
|
|
298
|
+
return extractConfiguredEndpoint(profile)?.port;
|
|
299
|
+
}
|
|
300
|
+
function normalizeHost(hostname, protocol) {
|
|
301
|
+
if (!hostname) {
|
|
302
|
+
// cdp:// and ssh:// without an explicit host imply localhost.
|
|
303
|
+
if (protocol === 'cdp:' || protocol === 'ssh:')
|
|
304
|
+
return '127.0.0.1';
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
if (hostname === 'localhost')
|
|
308
|
+
return '127.0.0.1';
|
|
309
|
+
return hostname;
|
|
310
|
+
}
|
|
311
|
+
export function isLocalHost(host) {
|
|
312
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
313
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-profile runtime files we persist under
|
|
3
|
+
* `~/.agents/.cache/browser/<composite>/`:
|
|
4
|
+
*
|
|
5
|
+
* - `pid` — child process ID we spawned (or 0 if attached to an
|
|
6
|
+
* already-running browser)
|
|
7
|
+
* - `port` — CDP port we ended up speaking on
|
|
8
|
+
* - `command` — basename of the executable so we can defend against pid
|
|
9
|
+
* reuse (`process.kill(pid, 0)` only proves *some* process
|
|
10
|
+
* with that id exists; if the OS recycled it for an
|
|
11
|
+
* unrelated daemon, we'd happily attach to garbage)
|
|
12
|
+
* - `meta.json` — richer record: which daemon spawned us, when, the
|
|
13
|
+
* user-data-dir we wrote into, optional tunnel PID. This
|
|
14
|
+
* is the file the orphan reaper reads on daemon startup.
|
|
15
|
+
* - `tasks.json` — open task state (managed elsewhere by service.ts)
|
|
16
|
+
*
|
|
17
|
+
* The one-value-per-file fields are kept for backward compat with older
|
|
18
|
+
* builds; `meta.json` is additive and consulted preferentially.
|
|
19
|
+
*/
|
|
20
|
+
export interface ProfileRuntime {
|
|
21
|
+
pid: number;
|
|
22
|
+
port: number;
|
|
23
|
+
command?: string;
|
|
24
|
+
/** Full path of the user-data-dir we passed to --user-data-dir, used by the reaper to confirm. */
|
|
25
|
+
userDataDir?: string;
|
|
26
|
+
/** PID of the daemon that spawned this. When the daemon dies, the next one reaps. */
|
|
27
|
+
daemonPid?: number;
|
|
28
|
+
/** Wall-clock time of spawn — useful for diagnostics and TTL-based cleanup. */
|
|
29
|
+
spawnedAt?: number;
|
|
30
|
+
/** What kind of process: 'browser' (Chrome-family), 'electron' (Notion etc.), or 'tunnel' (ssh -L). */
|
|
31
|
+
kind?: 'browser' | 'electron' | 'tunnel';
|
|
32
|
+
/** Local ssh -L PID, if this profile is SSH-backed. Distinct from `pid` (which is the remote browser, normally 0). */
|
|
33
|
+
tunnelPid?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Save the runtime record atomically. We write the legacy one-value-per-
|
|
37
|
+
* file fields plus a JSON meta blob so future code can read either.
|
|
38
|
+
* The cache directory may not exist yet (first launch); we create it.
|
|
39
|
+
*/
|
|
40
|
+
export declare function writeProfileRuntime(profileName: string, runtime: ProfileRuntime): void;
|
|
41
|
+
/** Read just the JSON meta record. Returns null when absent or malformed. */
|
|
42
|
+
export declare function readProfileRuntimeMeta(profileName: string): ProfileRuntime | null;
|
|
43
|
+
/**
|
|
44
|
+
* Read the runtime triple. Returns null when the files are missing OR when
|
|
45
|
+
* the recorded pid no longer points at the same process we launched —
|
|
46
|
+
* stale data is auto-cleaned to keep the next caller from acting on it.
|
|
47
|
+
*/
|
|
48
|
+
export declare function readProfileRuntime(profileName: string): ProfileRuntime | null;
|
|
49
|
+
/** Remove the pid/port/command/meta files. Leaves chrome-data + tasks.json intact. */
|
|
50
|
+
export declare function clearProfileRuntime(profileName: string): void;
|
|
51
|
+
/**
|
|
52
|
+
* Recursively remove the whole profile cache (chrome-data, tasks.json,
|
|
53
|
+
* everything). Used by `profiles delete` so an old profile name doesn't
|
|
54
|
+
* leak its history into a freshly-recreated one.
|
|
55
|
+
*/
|
|
56
|
+
export declare function removeProfileCache(profileName: string): void;
|
|
57
|
+
/**
|
|
58
|
+
* Find every cache directory belonging to a given profile. The composite
|
|
59
|
+
* naming (`<name>@<endpoint>`) means a single agents-cli profile can have
|
|
60
|
+
* multiple runtime dirs side by side; this finds them all plus the legacy
|
|
61
|
+
* non-composite dir from older builds.
|
|
62
|
+
*/
|
|
63
|
+
export declare function listProfileCacheDirs(profileName: string): string[];
|
|
64
|
+
/**
|
|
65
|
+
* `process.kill(pid, 0)` answers "is a process with this id alive?" — but
|
|
66
|
+
* pid reuse is real on long-uptime machines, and a stale cache pointing
|
|
67
|
+
* at a since-reassigned pid would happily call the imposter ours.
|
|
68
|
+
*
|
|
69
|
+
* Strategy: if we recorded the executable basename when we launched, ask
|
|
70
|
+
* `ps` what command the live pid is running and compare. No command on
|
|
71
|
+
* record means we fall back to the existence check (older cache entries
|
|
72
|
+
* or `pid:0` for "attached to an externally-launched browser").
|
|
73
|
+
*/
|
|
74
|
+
export declare function isProcessAlive(pid: number, expectedCommand?: string): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Snapshot of one tracked profile, suitable for `agents browser ps` output.
|
|
77
|
+
* Combines the on-disk meta record with live-process probes so callers can
|
|
78
|
+
* tell at a glance which entries are alive, stale, or have outright leaked.
|
|
79
|
+
*/
|
|
80
|
+
export interface ProfileSnapshot {
|
|
81
|
+
/** Composite name as the cache dir is keyed: `<profile>` or `<profile>@<endpoint>`. */
|
|
82
|
+
name: string;
|
|
83
|
+
/** Absolute path of the cache dir. */
|
|
84
|
+
dir: string;
|
|
85
|
+
meta: ProfileRuntime | null;
|
|
86
|
+
/** Live-process probe: does the recorded pid still exist + match command? */
|
|
87
|
+
pidAlive: boolean;
|
|
88
|
+
/** Live-process probe for the tunnel pid (SSH profiles). */
|
|
89
|
+
tunnelAlive: boolean;
|
|
90
|
+
/** True iff the daemon that started this is still alive. False == orphaned. */
|
|
91
|
+
daemonAlive: boolean;
|
|
92
|
+
/** Number of tasks recorded in tasks.json (open browser tabs). */
|
|
93
|
+
taskCount: number;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Read every profile cache directory and produce a structured snapshot.
|
|
97
|
+
* Works without the daemon — `agents browser ps` uses this to render a
|
|
98
|
+
* complete state view even when the IPC server is down. The caller can
|
|
99
|
+
* post-process to detect conflicts (e.g. two profiles with the same port,
|
|
100
|
+
* or a port someone else is listening on).
|
|
101
|
+
*/
|
|
102
|
+
export declare function listAllProfileSnapshots(): ProfileSnapshot[];
|
|
103
|
+
/**
|
|
104
|
+
* Reap browser + tunnel processes spawned by daemons that no longer exist.
|
|
105
|
+
* Call once on daemon startup. The idea: every process we spawn records
|
|
106
|
+
* its daemonPid in meta.json. If that daemon is dead (crashed, SIGKILL),
|
|
107
|
+
* its children were left rootless — kill them now so they don't hijack
|
|
108
|
+
* the next session's local ports.
|
|
109
|
+
*
|
|
110
|
+
* We're conservative: a record with no daemonPid (older builds) is left
|
|
111
|
+
* alone — we'd rather leak than wrongly kill a user-owned process that
|
|
112
|
+
* happens to share metadata.
|
|
113
|
+
*/
|
|
114
|
+
export declare function reapOrphanedProcesses(): {
|
|
115
|
+
reaped: number;
|
|
116
|
+
details: string[];
|
|
117
|
+
};
|
|
@@ -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;
|
|
@@ -162,6 +169,15 @@ export declare class BrowserService {
|
|
|
162
169
|
maxChars?: number;
|
|
163
170
|
tabHint?: string;
|
|
164
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[]>;
|
|
165
181
|
wait(taskId: string, type: 'time' | 'selector' | 'url' | 'function' | 'load', value: string | number, options?: {
|
|
166
182
|
timeout?: number;
|
|
167
183
|
tabHint?: string;
|