@phnx-labs/agents-cli 1.14.7 → 1.15.0

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.
@@ -9,7 +9,7 @@ export async function connectSSH(endpoint, profile) {
9
9
  }
10
10
  const user = url.username || process.env.USER || 'root';
11
11
  const host = url.hostname;
12
- const remotePort = parseInt(url.searchParams.get('port') || '9222', 10);
12
+ const remotePort = url.port ? parseInt(url.port, 10) : 9222;
13
13
  const localPort = allocatePort();
14
14
  try {
15
15
  await ensureRemoteBrowser(user, host, profile.browser, remotePort, profile.binary);
@@ -17,7 +17,7 @@ export async function connectSSH(endpoint, profile) {
17
17
  catch {
18
18
  // Browser may already be running, continue
19
19
  }
20
- const tunnel = await startSSHTunnel(user, host, localPort, remotePort);
20
+ let tunnel = await startSSHTunnel(user, host, localPort, remotePort);
21
21
  try {
22
22
  await waitForPort(localPort, 8000);
23
23
  }
@@ -133,6 +133,27 @@ async function ensureRemoteBrowser(user, host, browserType, port, customBinary)
133
133
  }, 2000);
134
134
  });
135
135
  }
136
+ export async function restartRemoteBrowser(user, host, browserType, port, customBinary) {
137
+ // Kill any process using the remote debugging port
138
+ const killCmd = `lsof -ti :${port} | xargs kill -9 2>/dev/null || true`;
139
+ await runSSHCommand(user, host, killCmd);
140
+ await sleep(500);
141
+ await ensureRemoteBrowser(user, host, browserType, port, customBinary);
142
+ await sleep(1500);
143
+ }
144
+ function runSSHCommand(user, host, cmd) {
145
+ return new Promise((resolve) => {
146
+ const child = spawn('ssh', [`${user}@${host}`, '-o', 'BatchMode=yes', cmd], {
147
+ stdio: 'ignore',
148
+ });
149
+ child.on('close', () => resolve());
150
+ child.on('error', () => resolve());
151
+ setTimeout(() => {
152
+ child.kill();
153
+ resolve();
154
+ }, 3000);
155
+ });
156
+ }
136
157
  function sleep(ms) {
137
158
  return new Promise((resolve) => setTimeout(resolve, ms));
138
159
  }
@@ -195,6 +195,7 @@ export async function sendIPCRequest(request) {
195
195
  if (!fs.existsSync(socketPath)) {
196
196
  throw new Error('Failed to start browser daemon');
197
197
  }
198
+ await new Promise((r) => setTimeout(r, 300));
198
199
  }
199
200
  return new Promise((resolve, reject) => {
200
201
  const socket = net.createConnection(socketPath);
@@ -2,6 +2,7 @@ import { type TabInfo, type ProfileStatus } from './types.js';
2
2
  import { type RefOpts, type RefNode } from './refs.js';
3
3
  export declare class BrowserService {
4
4
  private connections;
5
+ private forkingProfiles;
5
6
  start(profileName: string, taskId?: string): Promise<{
6
7
  task: string;
7
8
  windowTargetId?: string;
@@ -30,6 +31,8 @@ export declare class BrowserService {
30
31
  hover(taskId: string, tabId: string, ref: number): Promise<void>;
31
32
  status(profileName?: string): Promise<ProfileStatus[]>;
32
33
  shutdown(): Promise<void>;
34
+ private findAvailableFork;
35
+ private forkElectronProfile;
33
36
  private connectProfile;
34
37
  private connectEndpoint;
35
38
  private enableDomains;
@@ -2,7 +2,7 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { CDPClient, discoverBrowserWsUrl } from './cdp.js';
4
4
  import { getProfile, getProfileRuntimeDir, getBrowserRuntimeDir } from './profiles.js';
5
- import { killChrome, getRunningChromeInfo } from './chrome.js';
5
+ import { killChrome, getRunningChromeInfo, launchBrowser, allocatePort } from './chrome.js';
6
6
  import { connectLocal } from './drivers/local.js';
7
7
  import { connectSSH } from './drivers/ssh.js';
8
8
  import { generateTaskId, isValidTaskId, } from './types.js';
@@ -11,6 +11,7 @@ import { clickAtCoords, hoverAtCoords, typeText, pressKey, focusNode } from './i
11
11
  import { emit } from '../events.js';
12
12
  export class BrowserService {
13
13
  connections = new Map();
14
+ forkingProfiles = new Set();
14
15
  async start(profileName, taskId) {
15
16
  const profile = await getProfile(profileName);
16
17
  if (!profile) {
@@ -21,7 +22,34 @@ export class BrowserService {
21
22
  throw new Error(`Invalid task ID "${finalTaskId}". Must be lowercase alphanumeric with hyphens.`);
22
23
  }
23
24
  let conn = this.connections.get(profileName);
24
- if (!conn) {
25
+ let effectiveProfileName = profileName;
26
+ if (conn && conn.electron && conn.tasks.size > 0) {
27
+ if (this.forkingProfiles.has(profileName)) {
28
+ while (this.forkingProfiles.has(profileName)) {
29
+ await new Promise((r) => setTimeout(r, 50));
30
+ }
31
+ const existingFork = this.findAvailableFork(profileName);
32
+ if (existingFork) {
33
+ conn = existingFork.conn;
34
+ effectiveProfileName = existingFork.name;
35
+ }
36
+ else {
37
+ throw new Error(`Fork in progress but no available fork found for "${profileName}"`);
38
+ }
39
+ }
40
+ else {
41
+ this.forkingProfiles.add(profileName);
42
+ try {
43
+ const { forkName, connection } = await this.forkElectronProfile(profile);
44
+ conn = connection;
45
+ effectiveProfileName = forkName;
46
+ }
47
+ finally {
48
+ this.forkingProfiles.delete(profileName);
49
+ }
50
+ }
51
+ }
52
+ else if (!conn) {
25
53
  conn = await this.connectProfile(profile);
26
54
  this.connections.set(profileName, conn);
27
55
  }
@@ -32,15 +60,15 @@ export class BrowserService {
32
60
  const { windowTargetId } = await this.createTaskWindow(conn, finalTaskId);
33
61
  const task = {
34
62
  id: finalTaskId,
35
- profile: profileName,
63
+ profile: effectiveProfileName,
36
64
  windowTargetId,
37
- tabIds: [],
65
+ tabIds: conn.electron && windowTargetId ? [windowTargetId] : [],
38
66
  createdAt: Date.now(),
39
67
  pid: conn.pid,
40
68
  };
41
69
  conn.tasks.set(finalTaskId, task);
42
- await this.saveTaskState(profileName, conn.tasks);
43
- emit('browser.launch', { profile: profileName, task: finalTaskId, pid: conn.pid });
70
+ await this.saveTaskState(effectiveProfileName, conn.tasks);
71
+ emit('browser.launch', { profile: effectiveProfileName, task: finalTaskId, pid: conn.pid });
44
72
  return { task: finalTaskId, windowTargetId };
45
73
  }
46
74
  async stop(taskId) {
@@ -65,6 +93,11 @@ export class BrowserService {
65
93
  conn.tasks.delete(taskId);
66
94
  await this.saveTaskState(profileName, conn.tasks);
67
95
  emit('browser.close', { profile: profileName, task: taskId });
96
+ if (conn.forkedFrom && conn.tasks.size === 0) {
97
+ conn.cdp.close();
98
+ killChrome(conn.pid);
99
+ this.connections.delete(profileName);
100
+ }
68
101
  return { ok: true, profile: profileName };
69
102
  }
70
103
  }
@@ -259,6 +292,37 @@ export class BrowserService {
259
292
  }
260
293
  this.connections.clear();
261
294
  }
295
+ findAvailableFork(profileName) {
296
+ for (const [name, conn] of this.connections) {
297
+ if (conn.forkedFrom === profileName && conn.tasks.size === 0) {
298
+ return { name, conn };
299
+ }
300
+ }
301
+ return null;
302
+ }
303
+ async forkElectronProfile(profile) {
304
+ let forkNum = 2;
305
+ while (this.connections.has(`${profile.name}.${forkNum}`)) {
306
+ forkNum++;
307
+ }
308
+ const forkName = `${profile.name}.${forkNum}`;
309
+ const port = allocatePort();
310
+ const { pid, wsUrl } = await launchBrowser(forkName, profile.browser, port, profile.chrome, profile.secrets, profile.binary);
311
+ const cdp = new CDPClient();
312
+ await cdp.connect(wsUrl);
313
+ await this.enableDomains(cdp);
314
+ const connection = {
315
+ cdp,
316
+ port,
317
+ pid,
318
+ electron: true,
319
+ forkedFrom: profile.name,
320
+ tasks: new Map(),
321
+ sessionCache: new Map(),
322
+ };
323
+ this.connections.set(forkName, connection);
324
+ return { forkName, connection };
325
+ }
262
326
  async connectProfile(profile) {
263
327
  const existingInfo = getRunningChromeInfo(profile.name);
264
328
  if (existingInfo) {
@@ -351,7 +415,20 @@ export class BrowserService {
351
415
  if (conn.electron) {
352
416
  const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
353
417
  const pageTarget = targetInfos.find((t) => t.type === 'page');
354
- return { windowTargetId: pageTarget?.targetId };
418
+ if (pageTarget) {
419
+ return { windowTargetId: pageTarget.targetId };
420
+ }
421
+ // No existing page - try to create one (works on some Electron apps)
422
+ try {
423
+ const result = (await conn.cdp.send('Target.createTarget', {
424
+ url: 'about:blank',
425
+ newWindow: true,
426
+ }));
427
+ return { windowTargetId: result.targetId };
428
+ }
429
+ catch {
430
+ throw new Error('No page targets found and unable to create new window');
431
+ }
355
432
  }
356
433
  const result = (await conn.cdp.send('Target.createTarget', {
357
434
  url: 'about:blank',
@@ -6,7 +6,7 @@
6
6
  * (macOS), systemd (Linux), or as a plain detached process. PID tracking,
7
7
  * log output, reload (SIGHUP), and graceful shutdown are handled here.
8
8
  */
9
- import { spawn, execSync } from 'child_process';
9
+ import { spawn, execSync, execFileSync } from 'child_process';
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as os from 'os';
@@ -297,10 +297,10 @@ function startDaemonLocked() {
297
297
  }
298
298
  fs.writeFileSync(plistPath, generateLaunchdPlist(), 'utf-8');
299
299
  try {
300
- execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { encoding: 'utf-8' });
300
+ execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
301
301
  }
302
302
  catch { /* not loaded, expected */ }
303
- execSync(`launchctl load "${plistPath}"`, { encoding: 'utf-8' });
303
+ execFileSync('launchctl', ['load', plistPath], { encoding: 'utf-8' });
304
304
  const pid = waitForPid(3000);
305
305
  return { pid, method: 'launchd' };
306
306
  }
@@ -358,7 +358,7 @@ export function stopDaemon() {
358
358
  const plistPath = getLaunchdPlistPath();
359
359
  if (fs.existsSync(plistPath)) {
360
360
  try {
361
- execSync(`launchctl unload "${plistPath}"`, { encoding: 'utf-8' });
361
+ execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8' });
362
362
  fs.unlinkSync(plistPath);
363
363
  }
364
364
  catch (err) {
@@ -3,8 +3,15 @@
3
3
  *
4
4
  * Structured JSONL logs at ~/.agents/logs/events-YYYY-MM-DD.jsonl
5
5
  * with automatic daily rotation and rich metadata for debugging/auditing.
6
+ *
7
+ * Features:
8
+ * - Rich metadata: hostname, platform, arch, pid, timezone
9
+ * - Timing helpers: measure operation duration automatically
10
+ * - Truncation: long inputs/outputs are trimmed with ellipsis
11
+ * - Permissions: logs dir is 0700, files are 0600 (owner-only)
12
+ * - Performance tracking: withTiming() wrapper for any async function
6
13
  */
7
- export type EventType = 'agent.run.start' | 'agent.run.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'secrets.get' | 'secrets.set' | 'secrets.delete' | 'cloud.dispatch' | 'cloud.complete' | 'teams.create' | 'teams.start' | 'teams.complete' | 'hook.fire' | 'hook.error' | 'resource.sync' | 'error' | 'warn' | 'info';
14
+ export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.set' | 'secrets.delete' | 'cloud.dispatch' | 'cloud.complete' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'resource.sync' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'error' | 'warn' | 'info' | 'debug';
8
15
  export interface EventMeta {
9
16
  ts: string;
10
17
  tz: string;
@@ -13,17 +20,33 @@ export interface EventMeta {
13
20
  platform: NodeJS.Platform;
14
21
  arch: string;
15
22
  pid: number;
23
+ ppid: number;
16
24
  event: EventType;
17
25
  }
18
26
  export interface EventPayload {
19
27
  agent?: string;
20
28
  version?: string;
29
+ sessionId?: string;
21
30
  cwd?: string;
31
+ command?: string;
32
+ args?: string[];
33
+ input?: string;
34
+ output?: string;
35
+ prompt?: string;
22
36
  durationMs?: number;
37
+ startupMs?: number;
38
+ exitCode?: number;
39
+ status?: string;
23
40
  error?: string;
41
+ errorStack?: string;
24
42
  [key: string]: unknown;
25
43
  }
26
44
  export type EventRecord = EventMeta & EventPayload;
45
+ /**
46
+ * Truncate a string to maxLength, adding ellipsis if truncated.
47
+ * Returns undefined for null/undefined input.
48
+ */
49
+ export declare function truncate(str: string | null | undefined, maxLength?: number): string | undefined;
27
50
  /**
28
51
  * Emit a structured event to the daily log file.
29
52
  *
@@ -41,6 +64,62 @@ export declare function emit(event: EventType, payload?: EventPayload): void;
41
64
  * done({ exitCode: 0 }); // emits agent.run.end with durationMs
42
65
  */
43
66
  export declare function emitStart(startEvent: EventType, payload?: EventPayload): (endPayload?: EventPayload) => void;
67
+ /**
68
+ * Measure execution time of a synchronous function.
69
+ * Emits a perf.timing event with the duration.
70
+ *
71
+ * @example
72
+ * const result = time('parse-config', () => parseConfig(path));
73
+ */
74
+ export declare function time<T>(label: string, fn: () => T, payload?: EventPayload): T;
75
+ /**
76
+ * Measure execution time of an async function.
77
+ * Emits a perf.timing event with the duration.
78
+ *
79
+ * @example
80
+ * const result = await timeAsync('fetch-data', () => fetchData(url));
81
+ */
82
+ export declare function timeAsync<T>(label: string, fn: () => Promise<T>, payload?: EventPayload): Promise<T>;
83
+ /**
84
+ * Create a timing context for measuring multiple phases of an operation.
85
+ * Useful for tracking startup time vs execution time.
86
+ *
87
+ * @example
88
+ * const timer = createTimer('agent.run', { agent: 'claude' });
89
+ * // ... setup work ...
90
+ * timer.mark('startup'); // records startup time
91
+ * // ... main work ...
92
+ * timer.end({ exitCode: 0 }); // records total time and emits event
93
+ */
94
+ export declare function createTimer(label: string, payload?: EventPayload): {
95
+ mark: (phase: string) => number;
96
+ end: (endPayload?: EventPayload) => void;
97
+ elapsed: () => number;
98
+ };
99
+ /**
100
+ * Higher-order function that wraps an async function with timing.
101
+ * The wrapper emits start/end events automatically.
102
+ *
103
+ * @example
104
+ * const timedFetch = withTiming('fetch', fetchData, { service: 'api' });
105
+ * const result = await timedFetch(url);
106
+ */
107
+ export declare function withTiming<Args extends unknown[], R>(label: string, fn: (...args: Args) => Promise<R>, basePayload?: EventPayload): (...args: Args) => Promise<R>;
108
+ /**
109
+ * Emit a command.start event with CLI args.
110
+ * Returns a done() function to emit command.end with duration.
111
+ *
112
+ * @example
113
+ * // At CLI entry point:
114
+ * const done = emitCommand('run', process.argv.slice(2));
115
+ * // ... execute command ...
116
+ * done({ exitCode: 0 });
117
+ */
118
+ export declare function emitCommand(command: string, args?: string[], payload?: EventPayload): (endPayload?: EventPayload) => void;
119
+ /**
120
+ * Emit an error event with full details.
121
+ */
122
+ export declare function emitError(err: Error | string, payload?: EventPayload): void;
44
123
  /**
45
124
  * Remove log files older than the retention period.
46
125
  * Called lazily on emit or explicitly via CLI.
@@ -61,6 +140,20 @@ export declare function query(options: {
61
140
  endDate?: Date;
62
141
  eventTypes?: EventType[];
63
142
  agent?: string;
143
+ command?: string;
64
144
  limit?: number;
65
145
  }): EventRecord[];
146
+ /**
147
+ * Get performance stats for a specific label.
148
+ */
149
+ export declare function getTimingStats(label: string, options?: {
150
+ days?: number;
151
+ }): {
152
+ count: number;
153
+ avgMs: number;
154
+ minMs: number;
155
+ maxMs: number;
156
+ p50Ms: number;
157
+ p95Ms: number;
158
+ } | null;
66
159
  export declare const LOGS_PATH: string;