@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.
@@ -3,6 +3,7 @@ import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import { getHelpersDir } from '../state.js';
5
5
  import { startDaemon } from '../daemon.js';
6
+ import { getCliVersion } from '../version.js';
6
7
  const SOCKET_NAME = 'browser.sock';
7
8
  export function getSocketPath() {
8
9
  return path.join(getHelpersDir(), SOCKET_NAME);
@@ -63,6 +64,9 @@ export class BrowserIPCServer {
63
64
  }
64
65
  async handleRequest(request) {
65
66
  switch (request.action) {
67
+ case 'version': {
68
+ return { ok: true, version: getCliVersion() };
69
+ }
66
70
  case 'start': {
67
71
  if (!request.profile) {
68
72
  return { ok: false, error: 'Profile required' };
@@ -70,6 +74,7 @@ export class BrowserIPCServer {
70
74
  const result = await this.service.start(request.profile, {
71
75
  taskName: request.taskName,
72
76
  url: request.url,
77
+ endpointName: request.endpoint,
73
78
  });
74
79
  return {
75
80
  ok: true,
@@ -78,13 +83,6 @@ export class BrowserIPCServer {
78
83
  windowTargetId: result.windowId,
79
84
  };
80
85
  }
81
- case 'launch-profile': {
82
- if (!request.profile) {
83
- return { ok: false, error: 'Profile required' };
84
- }
85
- const result = await this.service.launchProfile(request.profile);
86
- return { ok: true, port: result.port, pid: result.pid };
87
- }
88
86
  case 'done': {
89
87
  if (!request.task) {
90
88
  return { ok: false, error: 'Task required' };
@@ -153,12 +151,38 @@ export class BrowserIPCServer {
153
151
  const result = await this.service.evaluate(request.task, request.tabId, request.expr);
154
152
  return { ok: true, result };
155
153
  }
154
+ case 'record-start': {
155
+ if (!request.task)
156
+ return { ok: false, error: 'Task required' };
157
+ try {
158
+ const r = await this.service.recordStart(request.task, request.tabId, {
159
+ fps: request.fps,
160
+ duration: request.duration,
161
+ maxMb: request.maxMb,
162
+ });
163
+ return { ok: true, path: r.path, fps: r.fps, durationCapSec: r.durationCapSec, maxMb: r.maxMb };
164
+ }
165
+ catch (err) {
166
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
167
+ }
168
+ }
169
+ case 'record-stop': {
170
+ if (!request.task)
171
+ return { ok: false, error: 'Task required' };
172
+ try {
173
+ const r = await this.service.recordStop(request.task);
174
+ return { ok: true, path: r.path, bytes: r.bytes, durationMs: r.durationMs, stopReason: r.reason };
175
+ }
176
+ catch (err) {
177
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
178
+ }
179
+ }
156
180
  case 'screenshot': {
157
181
  if (!request.task) {
158
182
  return { ok: false, error: 'Task required' };
159
183
  }
160
- const resultPath = await this.service.screenshot(request.task, request.tabId, request.path);
161
- return { ok: true, path: resultPath };
184
+ const shot = await this.service.screenshot(request.task, request.tabId, request.path, request.quality);
185
+ return { ok: true, path: shot.path, bytes: shot.bytes, width: shot.width, height: shot.height };
162
186
  }
163
187
  case 'refs': {
164
188
  if (!request.task) {
@@ -296,6 +320,21 @@ export class BrowserIPCServer {
296
320
  const downloadPath = await this.service.waitForDownload(request.task, request.timeout);
297
321
  return { ok: true, downloadPath };
298
322
  }
323
+ case 'getAppLogs': {
324
+ if (!request.task) {
325
+ return { ok: false, error: 'Task required' };
326
+ }
327
+ const appLogs = await this.service.getAppLogs(request.task, {
328
+ lines: request.lines,
329
+ level: request.appLevel,
330
+ filter: request.filter,
331
+ message: request.message,
332
+ source: request.source,
333
+ since: request.since,
334
+ until: request.until,
335
+ });
336
+ return { ok: true, appLogs };
337
+ }
299
338
  case 'upload': {
300
339
  if (!request.task || !request.files || request.files.length === 0) {
301
340
  return { ok: false, error: 'Task and at least one file required' };
@@ -314,7 +353,43 @@ export class BrowserIPCServer {
314
353
  }
315
354
  }
316
355
  }
356
+ let versionCheckedThisProcess = false;
357
+ /**
358
+ * Check the daemon's version against ours and warn loudly when they
359
+ * differ. Fires at most once per CLI process — successive calls in the
360
+ * same `agents browser ...` invocation are cheap. The whole reason this
361
+ * code exists: a launchd-managed registry daemon kept serving stale code
362
+ * to a dev-build CLI for an entire session and nothing surfaced it.
363
+ */
364
+ async function maybeWarnVersionMismatch() {
365
+ if (versionCheckedThisProcess)
366
+ return;
367
+ versionCheckedThisProcess = true;
368
+ try {
369
+ const resp = await sendRawIPCRequest({ action: 'version' });
370
+ const daemon = resp.version;
371
+ const client = getCliVersion();
372
+ if (!daemon || daemon === 'unknown' || daemon === client)
373
+ return;
374
+ process.stderr.write(`\nwarning: browser daemon is on ${daemon} but this CLI is on ${client}.\n` +
375
+ ` Run \`agents daemon restart\` to load the current code.\n\n`);
376
+ }
377
+ catch {
378
+ // daemon might be an older build that doesn't speak 'version' — that's
379
+ // itself a hint, but a noisy one. Stay silent on this path.
380
+ }
381
+ }
317
382
  export async function sendIPCRequest(request) {
383
+ const result = await sendRawIPCRequest(request);
384
+ // Run the version check after the user's request returns — keeps the
385
+ // critical path zero-overhead and ensures `start` doesn't get blocked
386
+ // on a daemon-restart warning that the user hasn't read yet.
387
+ if (request.action !== 'version') {
388
+ maybeWarnVersionMismatch().catch(() => { });
389
+ }
390
+ return result;
391
+ }
392
+ async function sendRawIPCRequest(request) {
318
393
  const socketPath = getSocketPath();
319
394
  if (!fs.existsSync(socketPath)) {
320
395
  await fs.promises.mkdir(path.dirname(socketPath), { recursive: true });
@@ -5,15 +5,81 @@ export declare function getProfileRuntimeDir(name: string): string;
5
5
  export declare function listProfiles(): Promise<BrowserProfile[]>;
6
6
  export declare function getProfile(name: string): Promise<BrowserProfile | null>;
7
7
  /**
8
- * Find a port in 9222–9399 that is not already claimed by another profile
9
- * and is not currently in use by any OS process.
8
+ * Compute the LOCAL port a profile will occupy at runtime:
9
+ * - `cdp://127.0.0.1:N` N (we listen on N directly)
10
+ * - `ssh://host?port=N` → N (the SSH tunnel binds local N → remote N now)
11
+ * - `ws[s]://`, `http[s]://` → undefined (we don't claim a local port)
12
+ *
13
+ * This is what callers should compare to detect collisions; the (host,
14
+ * port) tuple is no longer enough because SSH profiles do compete with
15
+ * cdp:// profiles for local ports under the new tunnel scheme.
16
+ */
17
+ export declare function effectiveLocalPort(profile: BrowserProfile): number | undefined;
18
+ /**
19
+ * Find a port in 9222–9399 that is not already claimed by ANY existing
20
+ * profile (cdp:// or ssh://) and is not in use by any OS process. The
21
+ * SSH change to bind locally on `?port=N` means we no longer get to
22
+ * skip remote profiles in this scan.
10
23
  */
11
24
  export declare function findFreeProfilePort(): Promise<number>;
12
25
  export declare function createProfile(profile: BrowserProfile): Promise<void>;
13
26
  export declare function updateProfile(profile: BrowserProfile): Promise<void>;
14
27
  export declare function deleteProfile(name: string): Promise<void>;
15
28
  /**
16
- * Extract the port intended by the profile's first endpoint.
29
+ * Resolve a profile's endpoint presets into a normalized map regardless of
30
+ * whether the YAML uses the legacy `string[]` shape or the new map shape.
31
+ * The legacy entries get auto-named `endpoint-0`, `endpoint-1`, ... .
32
+ */
33
+ export declare function getEndpointPresets(profile: BrowserProfile): Record<string, import('./types.js').EndpointPreset>;
34
+ /**
35
+ * Pick the endpoint preset to use. Order:
36
+ * 1. Explicit name passed in (errors if unknown)
37
+ * 2. `profile.defaultEndpoint` if set
38
+ * 3. First entry (preserves legacy string[] behavior)
39
+ *
40
+ * Returns the resolved name + the preset (with per-endpoint overrides
41
+ * already applied to binary / targetFilter), so callers don't have to
42
+ * remember the precedence rules.
43
+ */
44
+ export declare function resolveEndpoint(profile: BrowserProfile, endpointName?: string): {
45
+ name: string;
46
+ target: string;
47
+ binary?: string;
48
+ targetFilter?: string;
49
+ };
50
+ /**
51
+ * Extract the (host, port) pair intended by the profile's default endpoint.
52
+ * Returns undefined for endpoint shapes that don't carry a port (e.g. ws:// without one).
53
+ *
54
+ * Ports are scoped by host: a `cdp://127.0.0.1:9222` profile (local Chrome on
55
+ * this machine) and an `ssh://mac-mini:9222` profile (Comet on mac-mini)
56
+ * point at different physical ports — the host disambiguates them.
57
+ *
58
+ * Accepts both `scheme://host:port` and `scheme://host?port=N` shapes (the
59
+ * latter is the documented form in `types.ts` for `ssh://`). Without this,
60
+ * `ssh://mac-mini?port=18805` would silently fall back to 9222 and every
61
+ * `?port=`-style SSH profile would collide on creation.
62
+ */
63
+ export declare function extractConfiguredEndpoint(profile: BrowserProfile): {
64
+ host: string;
65
+ port: number;
66
+ } | undefined;
67
+ /**
68
+ * Shared endpoint parser used by both the collision-detection code path and
69
+ * the connection drivers. Returning a single normalized `(host, port)` here
70
+ * keeps `extractConfiguredEndpoint` and the SSH driver from drifting on URL
71
+ * conventions (which is how `?port=N` ended up being silently ignored).
72
+ */
73
+ export declare function parseEndpointUrl(endpoint: string): {
74
+ host: string;
75
+ port: number;
76
+ } | undefined;
77
+ /**
78
+ * Extract the port intended by the profile's default endpoint.
17
79
  * Returns undefined for endpoint shapes that don't carry a port (e.g. ws:// without one).
80
+ *
81
+ * Note: this loses the host dimension — for collision detection use
82
+ * `extractConfiguredEndpoint` instead, which returns the (host, port) pair.
18
83
  */
19
84
  export declare function extractConfiguredPort(profile: BrowserProfile): number | undefined;
85
+ export declare function isLocalHost(host: string): boolean;
@@ -17,9 +17,12 @@ function configToProfile(name, config) {
17
17
  electron: config.electron,
18
18
  targetFilter: config.targetFilter,
19
19
  endpoints: config.endpoints,
20
+ defaultEndpoint: config.defaultEndpoint,
20
21
  chrome: config.chrome,
21
22
  secrets: config.secrets,
22
23
  viewport: config.viewport,
24
+ logDir: config.logDir,
25
+ logHost: config.logHost,
23
26
  };
24
27
  }
25
28
  function profileToConfig(profile) {
@@ -35,12 +38,18 @@ function profileToConfig(profile) {
35
38
  config.electron = profile.electron;
36
39
  if (profile.targetFilter)
37
40
  config.targetFilter = profile.targetFilter;
41
+ if (profile.defaultEndpoint)
42
+ config.defaultEndpoint = profile.defaultEndpoint;
38
43
  if (profile.chrome)
39
44
  config.chrome = profile.chrome;
40
45
  if (profile.secrets)
41
46
  config.secrets = profile.secrets;
42
47
  if (profile.viewport)
43
48
  config.viewport = profile.viewport;
49
+ if (profile.logDir)
50
+ config.logDir = profile.logDir;
51
+ if (profile.logHost)
52
+ config.logHost = profile.logHost;
44
53
  return config;
45
54
  }
46
55
  export async function listProfiles() {
@@ -57,14 +66,45 @@ export async function getProfile(name) {
57
66
  return configToProfile(name, config);
58
67
  }
59
68
  /**
60
- * Find a port in 9222–9399 that is not already claimed by another profile
61
- * and is not currently in use by any OS process.
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.
62
102
  */
63
103
  export async function findFreeProfilePort() {
64
104
  const profiles = await listProfiles();
65
105
  const usedByProfile = new Set();
66
106
  for (const p of profiles) {
67
- const port = extractConfiguredPort(p);
107
+ const port = effectiveLocalPort(p);
68
108
  if (port !== undefined)
69
109
  usedByProfile.add(port);
70
110
  }
@@ -87,15 +127,20 @@ export async function createProfile(profile) {
87
127
  if (meta.browser?.[profile.name]) {
88
128
  throw new Error(`Profile "${profile.name}" already exists`);
89
129
  }
90
- // Check for port collision with existing profiles
91
- const newPort = extractConfiguredPort(profile);
92
- if (newPort !== undefined && meta.browser) {
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) {
93
137
  for (const [existingName, existingConfig] of Object.entries(meta.browser)) {
94
138
  const existingProfile = configToProfile(existingName, existingConfig);
95
- const existingPort = extractConfiguredPort(existingProfile);
96
- if (existingPort === newPort) {
97
- throw new Error(`Port ${newPort} is already used by profile "${existingName}". ` +
98
- `Each profile must own a unique port. Use a different port or omit --endpoint to auto-assign.`);
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.`);
99
144
  }
100
145
  }
101
146
  }
@@ -125,13 +170,87 @@ export async function deleteProfile(name) {
125
170
  writeMeta(meta);
126
171
  }
127
172
  /**
128
- * Extract the port intended by the profile's first endpoint.
173
+ * Resolve a profile's endpoint presets into a normalized map regardless of
174
+ * whether the YAML uses the legacy `string[]` shape or the new map shape.
175
+ * The legacy entries get auto-named `endpoint-0`, `endpoint-1`, ... .
176
+ */
177
+ export function getEndpointPresets(profile) {
178
+ if (Array.isArray(profile.endpoints)) {
179
+ const out = {};
180
+ profile.endpoints.forEach((target, i) => {
181
+ out[`endpoint-${i}`] = { target };
182
+ });
183
+ return out;
184
+ }
185
+ return profile.endpoints;
186
+ }
187
+ /**
188
+ * Pick the endpoint preset to use. Order:
189
+ * 1. Explicit name passed in (errors if unknown)
190
+ * 2. `profile.defaultEndpoint` if set
191
+ * 3. First entry (preserves legacy string[] behavior)
192
+ *
193
+ * Returns the resolved name + the preset (with per-endpoint overrides
194
+ * already applied to binary / targetFilter), so callers don't have to
195
+ * remember the precedence rules.
196
+ */
197
+ export function resolveEndpoint(profile, endpointName) {
198
+ const presets = getEndpointPresets(profile);
199
+ const names = Object.keys(presets);
200
+ if (names.length === 0) {
201
+ throw new Error(`Profile "${profile.name}" has no endpoints configured`);
202
+ }
203
+ let chosenName;
204
+ if (endpointName) {
205
+ if (!presets[endpointName]) {
206
+ throw new Error(`Endpoint "${endpointName}" not found on profile "${profile.name}". ` +
207
+ `Available: ${names.join(', ')}`);
208
+ }
209
+ chosenName = endpointName;
210
+ }
211
+ else if (profile.defaultEndpoint && presets[profile.defaultEndpoint]) {
212
+ chosenName = profile.defaultEndpoint;
213
+ }
214
+ else {
215
+ chosenName = names[0];
216
+ }
217
+ const preset = presets[chosenName];
218
+ return {
219
+ name: chosenName,
220
+ target: preset.target,
221
+ binary: preset.binary ?? profile.binary,
222
+ targetFilter: preset.targetFilter ?? profile.targetFilter,
223
+ };
224
+ }
225
+ /**
226
+ * Extract the (host, port) pair intended by the profile's default endpoint.
129
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.
130
237
  */
131
- export function extractConfiguredPort(profile) {
132
- const endpoint = profile.endpoints[0];
133
- if (!endpoint)
238
+ export function extractConfiguredEndpoint(profile) {
239
+ const presets = getEndpointPresets(profile);
240
+ const firstName = profile.defaultEndpoint && presets[profile.defaultEndpoint]
241
+ ? profile.defaultEndpoint
242
+ : Object.keys(presets)[0];
243
+ if (!firstName)
134
244
  return undefined;
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) {
135
254
  let url;
136
255
  try {
137
256
  url = new URL(endpoint);
@@ -139,11 +258,56 @@ export function extractConfiguredPort(profile) {
139
258
  catch {
140
259
  return undefined;
141
260
  }
142
- if (url.port)
143
- return parseInt(url.port, 10);
144
- if (url.protocol === 'cdp:')
145
- return 9222;
146
- if (url.protocol === 'ssh:')
147
- return 9222;
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 };
148
272
  return undefined;
149
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
+ }
288
+ return undefined;
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
+ };