@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.
@@ -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
- * Find a port in 9222–9399 that is not already claimed by another profile
64
- * 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.
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 = extractConfiguredPort(p);
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
- // Check for port collision with existing profiles
94
- const newPort = extractConfiguredPort(profile);
95
- 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) {
96
137
  for (const [existingName, existingConfig] of Object.entries(meta.browser)) {
97
138
  const existingProfile = configToProfile(existingName, existingConfig);
98
- const existingPort = extractConfiguredPort(existingProfile);
99
- if (existingPort === newPort) {
100
- throw new Error(`Port ${newPort} is already used by profile "${existingName}". ` +
101
- `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.`);
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 extractConfiguredPort(profile) {
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
- const endpoint = presets[firstName].target;
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
- if (url.port)
203
- return parseInt(url.port, 10);
204
- if (url.protocol === 'cdp:')
205
- return 9222;
206
- if (url.protocol === 'ssh:')
207
- 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 };
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;