@phnx-labs/agents-cli 1.14.5 → 1.14.7

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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.14.6
4
+
5
+ **Fix: OAuth token refresh now persists to Keychain**
6
+
7
+ - Fixed bug where refreshed Claude OAuth tokens were used but never saved back to macOS Keychain
8
+ - Previously, agents-cli would refresh expired tokens on each run but discard them, eventually exhausting the refresh token
9
+ - Now refreshed `accessToken`, `refreshToken`, and `expiresAt` are written back to Keychain after successful refresh
10
+ - Accounts will stay healthy across runs without requiring re-login
11
+
3
12
  ## 1.14.5
4
13
 
5
14
  **Browser: custom binary and Electron app support**
package/README.md CHANGED
@@ -47,6 +47,7 @@ Also available as `ag` -- all commands work with both `agents` and `ag`.
47
47
  - [Sessions across agents](#sessions-across-agents)
48
48
  - [Run open models through Claude Code](#run-open-models-through-claude-code)
49
49
  - [Teams](#teams)
50
+ - [Browser](#browser)
50
51
  - [Secrets](#secrets)
51
52
  - [Routines](#routines)
52
53
  - [PTY](#pty)
@@ -242,6 +243,85 @@ Team state is observable via `agents teams list --json` / `agents teams status -
242
243
 
243
244
  ---
244
245
 
246
+ ## Browser
247
+
248
+ Give agents access to a real browser — no relay extension, no cloud service, no Playwright getting blocked.
249
+
250
+ ```bash
251
+ # Create an isolated profile for automation
252
+ agents browser profiles create work --browser chrome
253
+
254
+ # Start a task, navigate, interact
255
+ agents browser start login-flow --profile work
256
+ agents browser navigate login-flow https://app.example.com
257
+ agents browser refs login-flow # Get interactive element refs
258
+ agents browser click login-flow <tab> 42 # Click element ref 42
259
+ agents browser type login-flow <tab> 15 "hello"
260
+ agents browser screenshot login-flow # Smart resizing, token-efficient
261
+ ```
262
+
263
+ ### Why this works where Playwright fails
264
+
265
+ Playwright and Puppeteer spin up fresh browser instances with automation flags. Sites like LinkedIn, Google, and most finance apps detect and block them immediately.
266
+
267
+ `agents browser` launches your existing residential Chrome (or Brave, Edge, Chromium) on your machine via CDP. Same browser fingerprint, same IP, same everything. Sites can't detect automation because you're using the same browser you'd use manually.
268
+
269
+ ### Token-efficient automation
270
+
271
+ The CLI handles the mechanical work so agents don't burn tokens on low-level browser commands. Screenshots are automatically resized without excessive compression — agents process smaller images while keeping the detail they need to make decisions.
272
+
273
+ ### Profile isolation
274
+
275
+ Multiple agents can run browser tasks simultaneously without stepping on each other. Each profile gets its own user data directory, cookies, and state. One agent logs into your work Slack, another into your personal email — no conflicts, no shared state.
276
+
277
+ ```bash
278
+ agents browser profiles create work-slack --browser chrome
279
+ agents browser profiles create personal-gmail --browser chrome
280
+ # Two agents, two profiles, no interference
281
+ ```
282
+
283
+ ### Safe credential access
284
+
285
+ Attach a [secrets bundle](#secrets) to a profile. The agent can log in without credentials in plaintext, and every secret access is recorded in the session log.
286
+
287
+ ```bash
288
+ agents browser profiles create bank --browser chrome --secrets bank-creds
289
+ ```
290
+
291
+ ### Electron apps
292
+
293
+ Control Electron apps (Slack, Discord, VS Code, your own app) with custom binaries:
294
+
295
+ ```bash
296
+ agents browser profiles create rush \
297
+ --browser custom \
298
+ --binary "/Applications/Rush.app/Contents/MacOS/Rush" \
299
+ --electron
300
+ ```
301
+
302
+ ### Remote browsers
303
+
304
+ Connect to browsers running anywhere — local, SSH tunnels, or cloud services:
305
+
306
+ ```bash
307
+ # Local CDP (discovers WebSocket URL automatically)
308
+ agents browser profiles create local-debug \
309
+ --browser chrome \
310
+ --endpoint "http://localhost:9222"
311
+
312
+ # SSH tunnel to a remote machine
313
+ agents browser profiles create staging \
314
+ --browser chrome \
315
+ --endpoint "ssh://deploy@staging.example.com?port=9222"
316
+
317
+ # Cloud browser services (BrowserBase, Steel, etc.)
318
+ agents browser profiles create cloud \
319
+ --browser chrome \
320
+ --endpoint "wss://connect.browserbase.com?apiKey=..."
321
+ ```
322
+
323
+ ---
324
+
245
325
  ## Secrets
246
326
 
247
327
  ```bash
@@ -285,10 +285,19 @@ Examples:
285
285
 
286
286
  # Delete the whole bundle and purge every keychain item it owned
287
287
  agents secrets delete prod
288
+
289
+ # Generate a random password (32 chars, all character classes)
290
+ agents secrets generate
291
+
292
+ # Generate a 16-char password, or a PIN, or hex
293
+ agents secrets generate 16
294
+ agents secrets generate 8 --pin
295
+ agents secrets generate 32 --hex
288
296
  `);
289
297
  registerCommandGroups(cmd, [
290
298
  { title: 'Bundle commands', names: ['list', 'view', 'create', 'delete'] },
291
299
  { title: 'Secret commands', names: ['add', 'rotate', 'remove', 'import', 'export'] },
300
+ { title: 'Utilities', names: ['generate'] },
292
301
  ]);
293
302
  cmd
294
303
  .command('list')
@@ -702,4 +711,78 @@ Examples:
702
711
  process.exit(1);
703
712
  }
704
713
  });
714
+ cmd
715
+ .command('generate [length]')
716
+ .description('Generate a random password')
717
+ .option('-U, --uppercase', 'Include A-Z (default: on)')
718
+ .option('-l, --lowercase', 'Include a-z (default: on)')
719
+ .option('-d, --digits', 'Include 0-9 (default: on)')
720
+ .option('-s, --symbols', 'Include symbols (default: on)')
721
+ .option('--no-uppercase', 'Exclude A-Z')
722
+ .option('--no-lowercase', 'Exclude a-z')
723
+ .option('--no-digits', 'Exclude 0-9')
724
+ .option('--no-symbols', 'Exclude symbols')
725
+ .option('--strong', 'Include all character classes')
726
+ .option('--pin', 'Digits only (shortcut for -d --no-uppercase --no-lowercase --no-symbols)')
727
+ .option('--hex', 'Hex characters only (0-9, a-f)')
728
+ .option('-c, --copy', 'Copy to clipboard (does not print)')
729
+ .action(async (lengthArg, opts) => {
730
+ const length = lengthArg ? parseInt(lengthArg, 10) : 32;
731
+ if (isNaN(length) || length < 1 || length > 1024) {
732
+ console.error(chalk.red('Length must be a number between 1 and 1024.'));
733
+ process.exit(1);
734
+ }
735
+ const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
736
+ const LOWER = 'abcdefghijklmnopqrstuvwxyz';
737
+ const DIGITS = '0123456789';
738
+ const SYMBOLS = '!@#$%^&*()-_=+[]{}|;:,.<>?';
739
+ const HEX_LOWER = '0123456789abcdef';
740
+ let charClasses = [];
741
+ if (opts.hex) {
742
+ charClasses = [HEX_LOWER];
743
+ }
744
+ else if (opts.pin) {
745
+ charClasses = [DIGITS];
746
+ }
747
+ else {
748
+ const useUpper = opts.strong || opts.uppercase !== false;
749
+ const useLower = opts.strong || opts.lowercase !== false;
750
+ const useDigits = opts.strong || opts.digits !== false;
751
+ const useSymbols = opts.strong || opts.symbols !== false;
752
+ if (useUpper)
753
+ charClasses.push(UPPER);
754
+ if (useLower)
755
+ charClasses.push(LOWER);
756
+ if (useDigits)
757
+ charClasses.push(DIGITS);
758
+ if (useSymbols)
759
+ charClasses.push(SYMBOLS);
760
+ }
761
+ if (charClasses.length === 0) {
762
+ console.error(chalk.red('At least one character class must be enabled.'));
763
+ process.exit(1);
764
+ }
765
+ const randomBytes = crypto.getRandomValues(new Uint32Array(length * 2));
766
+ let password = '';
767
+ for (let i = 0; i < length; i++) {
768
+ const classIndex = randomBytes[i * 2] % charClasses.length;
769
+ const charClass = charClasses[classIndex];
770
+ const charIndex = randomBytes[i * 2 + 1] % charClass.length;
771
+ password += charClass[charIndex];
772
+ }
773
+ if (opts.copy) {
774
+ const { spawn } = await import('child_process');
775
+ const proc = spawn('pbcopy', [], { stdio: ['pipe', 'inherit', 'inherit'] });
776
+ proc.stdin.write(password);
777
+ proc.stdin.end();
778
+ await new Promise((resolve, reject) => {
779
+ proc.on('close', (code) => code === 0 ? resolve() : reject(new Error('pbcopy failed')));
780
+ proc.on('error', reject);
781
+ });
782
+ console.log(chalk.green(`Password copied to clipboard (${length} chars)`));
783
+ }
784
+ else {
785
+ console.log(password);
786
+ }
787
+ });
705
788
  }
@@ -8,6 +8,7 @@ import { connectSSH } from './drivers/ssh.js';
8
8
  import { generateTaskId, isValidTaskId, } from './types.js';
9
9
  import { getRefs, resolveRefToCoords } from './refs.js';
10
10
  import { clickAtCoords, hoverAtCoords, typeText, pressKey, focusNode } from './input.js';
11
+ import { emit } from '../events.js';
11
12
  export class BrowserService {
12
13
  connections = new Map();
13
14
  async start(profileName, taskId) {
@@ -39,6 +40,7 @@ export class BrowserService {
39
40
  };
40
41
  conn.tasks.set(finalTaskId, task);
41
42
  await this.saveTaskState(profileName, conn.tasks);
43
+ emit('browser.launch', { profile: profileName, task: finalTaskId, pid: conn.pid });
42
44
  return { task: finalTaskId, windowTargetId };
43
45
  }
44
46
  async stop(taskId) {
@@ -62,6 +64,7 @@ export class BrowserService {
62
64
  }
63
65
  conn.tasks.delete(taskId);
64
66
  await this.saveTaskState(profileName, conn.tasks);
67
+ emit('browser.close', { profile: profileName, task: taskId });
65
68
  return { ok: true, profile: profileName };
66
69
  }
67
70
  }
@@ -311,6 +314,34 @@ export class BrowserService {
311
314
  sessionCache: new Map(),
312
315
  };
313
316
  }
317
+ if (url.protocol === 'wss:' || url.protocol === 'ws:') {
318
+ const cdp = new CDPClient();
319
+ await cdp.connect(endpoint);
320
+ await this.enableDomains(cdp);
321
+ return {
322
+ cdp,
323
+ port: 0,
324
+ pid: 0,
325
+ electron: profile.electron,
326
+ tasks: this.loadTaskState(profile.name),
327
+ sessionCache: new Map(),
328
+ };
329
+ }
330
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
331
+ const port = parseInt(url.port || (url.protocol === 'https:' ? '443' : '80'), 10);
332
+ const wsUrl = await discoverBrowserWsUrl(port, url.hostname);
333
+ const cdp = new CDPClient();
334
+ await cdp.connect(wsUrl);
335
+ await this.enableDomains(cdp);
336
+ return {
337
+ cdp,
338
+ port,
339
+ pid: 0,
340
+ electron: profile.electron,
341
+ tasks: this.loadTaskState(profile.name),
342
+ sessionCache: new Map(),
343
+ };
344
+ }
314
345
  return null;
315
346
  }
316
347
  async enableDomains(cdp) {
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Centralized event logging for agents-cli.
3
+ *
4
+ * Structured JSONL logs at ~/.agents/logs/events-YYYY-MM-DD.jsonl
5
+ * with automatic daily rotation and rich metadata for debugging/auditing.
6
+ */
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';
8
+ export interface EventMeta {
9
+ ts: string;
10
+ tz: string;
11
+ tzName: string;
12
+ hostname: string;
13
+ platform: NodeJS.Platform;
14
+ arch: string;
15
+ pid: number;
16
+ event: EventType;
17
+ }
18
+ export interface EventPayload {
19
+ agent?: string;
20
+ version?: string;
21
+ cwd?: string;
22
+ durationMs?: number;
23
+ error?: string;
24
+ [key: string]: unknown;
25
+ }
26
+ export type EventRecord = EventMeta & EventPayload;
27
+ /**
28
+ * Emit a structured event to the daily log file.
29
+ *
30
+ * @param event - The event type
31
+ * @param payload - Event-specific data (agent, version, cwd, etc.)
32
+ */
33
+ export declare function emit(event: EventType, payload?: EventPayload): void;
34
+ /**
35
+ * Convenience wrapper for timed operations.
36
+ * Returns a function to call when the operation completes.
37
+ *
38
+ * @example
39
+ * const done = emitStart('agent.run.start', { agent: 'claude' });
40
+ * // ... do work ...
41
+ * done({ exitCode: 0 }); // emits agent.run.end with durationMs
42
+ */
43
+ export declare function emitStart(startEvent: EventType, payload?: EventPayload): (endPayload?: EventPayload) => void;
44
+ /**
45
+ * Remove log files older than the retention period.
46
+ * Called lazily on emit or explicitly via CLI.
47
+ *
48
+ * @param retentionDays - Number of days to keep (default 30)
49
+ * @returns Number of files removed
50
+ */
51
+ export declare function rotate(retentionDays?: number): number;
52
+ export declare function maybeRotate(): void;
53
+ /**
54
+ * Read events from log files within a date range.
55
+ *
56
+ * @param options - Query options
57
+ * @returns Array of event records
58
+ */
59
+ export declare function query(options: {
60
+ startDate?: Date;
61
+ endDate?: Date;
62
+ eventTypes?: EventType[];
63
+ agent?: string;
64
+ limit?: number;
65
+ }): EventRecord[];
66
+ export declare const LOGS_PATH: string;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Centralized event logging for agents-cli.
3
+ *
4
+ * Structured JSONL logs at ~/.agents/logs/events-YYYY-MM-DD.jsonl
5
+ * with automatic daily rotation and rich metadata for debugging/auditing.
6
+ */
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import * as os from 'os';
10
+ // ─── Constants ────────────────────────────────────────────────────────────────
11
+ const USER_AGENTS_DIR = path.join(os.homedir(), '.agents');
12
+ const LOGS_DIR = path.join(USER_AGENTS_DIR, 'logs');
13
+ /** Default retention period in days. */
14
+ const DEFAULT_RETENTION_DAYS = 30;
15
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
16
+ function getTimezoneOffset() {
17
+ const offset = new Date().getTimezoneOffset();
18
+ const sign = offset <= 0 ? '+' : '-';
19
+ const hours = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0');
20
+ const mins = String(Math.abs(offset) % 60).padStart(2, '0');
21
+ return `${sign}${hours}:${mins}`;
22
+ }
23
+ function getTimezoneName() {
24
+ try {
25
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
26
+ }
27
+ catch {
28
+ return 'Unknown';
29
+ }
30
+ }
31
+ function getLogFilePath(date = new Date()) {
32
+ const yyyy = date.getFullYear();
33
+ const mm = String(date.getMonth() + 1).padStart(2, '0');
34
+ const dd = String(date.getDate()).padStart(2, '0');
35
+ return path.join(LOGS_DIR, `events-${yyyy}-${mm}-${dd}.jsonl`);
36
+ }
37
+ function ensureLogsDir() {
38
+ if (!fs.existsSync(LOGS_DIR)) {
39
+ fs.mkdirSync(LOGS_DIR, { recursive: true });
40
+ }
41
+ }
42
+ // ─── Core API ─────────────────────────────────────────────────────────────────
43
+ /**
44
+ * Emit a structured event to the daily log file.
45
+ *
46
+ * @param event - The event type
47
+ * @param payload - Event-specific data (agent, version, cwd, etc.)
48
+ */
49
+ export function emit(event, payload = {}) {
50
+ try {
51
+ ensureLogsDir();
52
+ const record = {
53
+ ts: new Date().toISOString(),
54
+ tz: getTimezoneOffset(),
55
+ tzName: getTimezoneName(),
56
+ hostname: os.hostname(),
57
+ platform: os.platform(),
58
+ arch: os.arch(),
59
+ pid: process.pid,
60
+ event,
61
+ ...payload,
62
+ };
63
+ const line = JSON.stringify(record) + '\n';
64
+ fs.appendFileSync(getLogFilePath(), line);
65
+ }
66
+ catch {
67
+ // Silent failure - logging should never break the CLI
68
+ }
69
+ }
70
+ /**
71
+ * Convenience wrapper for timed operations.
72
+ * Returns a function to call when the operation completes.
73
+ *
74
+ * @example
75
+ * const done = emitStart('agent.run.start', { agent: 'claude' });
76
+ * // ... do work ...
77
+ * done({ exitCode: 0 }); // emits agent.run.end with durationMs
78
+ */
79
+ export function emitStart(startEvent, payload = {}) {
80
+ const startTime = Date.now();
81
+ emit(startEvent, payload);
82
+ const endEvent = startEvent.replace('.start', '.end');
83
+ return (endPayload = {}) => {
84
+ emit(endEvent, {
85
+ ...payload,
86
+ ...endPayload,
87
+ durationMs: Date.now() - startTime,
88
+ });
89
+ };
90
+ }
91
+ // ─── Rotation ─────────────────────────────────────────────────────────────────
92
+ /**
93
+ * Remove log files older than the retention period.
94
+ * Called lazily on emit or explicitly via CLI.
95
+ *
96
+ * @param retentionDays - Number of days to keep (default 30)
97
+ * @returns Number of files removed
98
+ */
99
+ export function rotate(retentionDays = DEFAULT_RETENTION_DAYS) {
100
+ try {
101
+ if (!fs.existsSync(LOGS_DIR))
102
+ return 0;
103
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
104
+ const files = fs.readdirSync(LOGS_DIR).filter(f => f.startsWith('events-') && f.endsWith('.jsonl'));
105
+ let removed = 0;
106
+ for (const file of files) {
107
+ const match = file.match(/^events-(\d{4})-(\d{2})-(\d{2})\.jsonl$/);
108
+ if (!match)
109
+ continue;
110
+ const [, yyyy, mm, dd] = match;
111
+ const fileDate = new Date(parseInt(yyyy), parseInt(mm) - 1, parseInt(dd));
112
+ if (fileDate.getTime() < cutoff) {
113
+ fs.unlinkSync(path.join(LOGS_DIR, file));
114
+ removed++;
115
+ }
116
+ }
117
+ return removed;
118
+ }
119
+ catch {
120
+ return 0;
121
+ }
122
+ }
123
+ /**
124
+ * Lazy rotation - runs at most once per day per process.
125
+ */
126
+ let lastRotationCheck = 0;
127
+ export function maybeRotate() {
128
+ const now = Date.now();
129
+ const oneDayMs = 24 * 60 * 60 * 1000;
130
+ if (now - lastRotationCheck > oneDayMs) {
131
+ lastRotationCheck = now;
132
+ rotate();
133
+ }
134
+ }
135
+ // ─── Query ────────────────────────────────────────────────────────────────────
136
+ /**
137
+ * Read events from log files within a date range.
138
+ *
139
+ * @param options - Query options
140
+ * @returns Array of event records
141
+ */
142
+ export function query(options) {
143
+ const { startDate, endDate = new Date(), eventTypes, agent, limit } = options;
144
+ const results = [];
145
+ if (!fs.existsSync(LOGS_DIR))
146
+ return results;
147
+ const files = fs.readdirSync(LOGS_DIR)
148
+ .filter(f => f.startsWith('events-') && f.endsWith('.jsonl'))
149
+ .sort()
150
+ .reverse();
151
+ for (const file of files) {
152
+ const match = file.match(/^events-(\d{4})-(\d{2})-(\d{2})\.jsonl$/);
153
+ if (!match)
154
+ continue;
155
+ const [, yyyy, mm, dd] = match;
156
+ const fileDate = new Date(parseInt(yyyy), parseInt(mm) - 1, parseInt(dd));
157
+ if (startDate && fileDate < startDate)
158
+ continue;
159
+ if (endDate && fileDate > endDate)
160
+ continue;
161
+ const content = fs.readFileSync(path.join(LOGS_DIR, file), 'utf-8');
162
+ const lines = content.trim().split('\n').filter(Boolean);
163
+ for (const line of lines.reverse()) {
164
+ try {
165
+ const record = JSON.parse(line);
166
+ if (eventTypes && !eventTypes.includes(record.event))
167
+ continue;
168
+ if (agent && record.agent !== agent)
169
+ continue;
170
+ results.push(record);
171
+ if (limit && results.length >= limit) {
172
+ return results;
173
+ }
174
+ }
175
+ catch {
176
+ // Skip malformed lines
177
+ }
178
+ }
179
+ }
180
+ return results;
181
+ }
182
+ // ─── Exports ──────────────────────────────────────────────────────────────────
183
+ export const LOGS_PATH = LOGS_DIR;
package/dist/lib/exec.js CHANGED
@@ -10,6 +10,7 @@ import * as path from 'path';
10
10
  import { parseTimeout } from './routines.js';
11
11
  import { getVersionHomePath, isVersionInstalled, resolveVersion } from './versions.js';
12
12
  import { resolveModel, buildReasoningFlags } from './models.js';
13
+ import { emitStart, maybeRotate } from './events.js';
13
14
  /** Pattern for valid environment variable names (C identifier rules). */
14
15
  const EXEC_ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
15
16
  /** Parse a single KEY=VALUE string into a tuple, validating the key name. */
@@ -201,6 +202,11 @@ export function buildExecCommand(options) {
201
202
  const template = AGENT_COMMANDS[options.agent];
202
203
  const cmd = [...template.base];
203
204
  const interactive = options.interactive === true || options.prompt === undefined;
205
+ // For Codex, 'exec' is the headless subcommand -- drop it for interactive mode
206
+ // so we run 'codex' (TUI) instead of 'codex exec' (one-shot)
207
+ if (options.agent === 'codex' && interactive && cmd[1] === 'exec') {
208
+ cmd.splice(1, 1);
209
+ }
204
210
  // Use versioned alias if a specific version was requested (e.g., claude@2.1.98)
205
211
  if (options.version && cmd.length > 0) {
206
212
  cmd[0] = `${cmd[0]}@${options.version}`;
@@ -298,6 +304,15 @@ async function spawnAgent(options) {
298
304
  const timeoutMs = options.timeout ? parseTimeout(options.timeout) : undefined;
299
305
  const piped = !process.stdout.isTTY;
300
306
  const interactive = options.interactive === true || options.prompt === undefined;
307
+ maybeRotate();
308
+ const done = emitStart('agent.run.start', {
309
+ agent: options.agent,
310
+ version: options.version,
311
+ cwd: options.cwd || process.cwd(),
312
+ mode: options.mode,
313
+ model: options.model,
314
+ interactive,
315
+ });
301
316
  return new Promise((resolve, reject) => {
302
317
  // Interactive mode inherits all stdio so the CLI owns the TTY (TUI
303
318
  // rendering, raw-mode keystrokes, colored output). Headless mode pipes
@@ -338,11 +353,13 @@ async function spawnAgent(options) {
338
353
  child.on('error', (err) => {
339
354
  if (timer)
340
355
  clearTimeout(timer);
356
+ done({ error: err.message, exitCode: -1 });
341
357
  reject(err);
342
358
  });
343
359
  child.on('close', (code) => {
344
360
  if (timer)
345
361
  clearTimeout(timer);
362
+ done({ exitCode: code ?? 0 });
346
363
  resolve({ exitCode: code ?? 0, stderr: stderrBuffer });
347
364
  });
348
365
  });
@@ -14,6 +14,7 @@ import { resolveJobPrompt, parseTimeout, writeRunMeta, getRunDir, } from './rout
14
14
  import { getRunsDir } from './state.js';
15
15
  import { prepareJobHome, buildSpawnEnv } from './sandbox.js';
16
16
  import { resolveModel, buildReasoningFlags } from './models.js';
17
+ import { emitStart, maybeRotate } from './events.js';
17
18
  /** CLI command templates per agent, with {prompt} as a placeholder. */
18
19
  const AGENT_COMMANDS = {
19
20
  claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
@@ -107,6 +108,13 @@ function generateRunId() {
107
108
  }
108
109
  /** Execute a job synchronously (waits for completion or timeout before resolving). */
109
110
  export async function executeJob(config) {
111
+ maybeRotate();
112
+ const done = emitStart('agent.run.start', {
113
+ agent: config.agent,
114
+ version: config.version,
115
+ jobName: config.name,
116
+ mode: config.mode,
117
+ });
110
118
  const resolvedPrompt = resolveJobPrompt(config);
111
119
  const cmd = buildJobCommand(config, resolvedPrompt);
112
120
  const useSandbox = config.sandbox !== false;
@@ -160,6 +168,7 @@ export async function executeJob(config) {
160
168
  meta.status = 'timeout';
161
169
  meta.completedAt = new Date().toISOString();
162
170
  writeRunMeta(meta);
171
+ done({ status: 'timeout', runId });
163
172
  const reportPath = extractAndSaveReport(stdoutPath, config.agent, runDir);
164
173
  resolve({ meta, reportPath });
165
174
  }, timeoutMs);
@@ -176,6 +185,7 @@ export async function executeJob(config) {
176
185
  meta.status = code === 0 ? 'completed' : 'failed';
177
186
  meta.completedAt = new Date().toISOString();
178
187
  writeRunMeta(meta);
188
+ done({ status: meta.status, exitCode: code, runId });
179
189
  const reportPath = extractAndSaveReport(stdoutPath, config.agent, runDir);
180
190
  resolve({ meta, reportPath });
181
191
  });
@@ -191,6 +201,7 @@ export async function executeJob(config) {
191
201
  meta.status = 'failed';
192
202
  meta.completedAt = new Date().toISOString();
193
203
  writeRunMeta(meta);
204
+ done({ status: 'failed', error: err.message, runId });
194
205
  resolve({ meta, reportPath: null });
195
206
  });
196
207
  child.unref();
@@ -16,6 +16,7 @@ import * as os from 'os';
16
16
  import * as path from 'path';
17
17
  import * as yaml from 'yaml';
18
18
  import { deleteKeychainToken, getKeychainToken, hasKeychainToken, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
19
+ import { emit } from '../events.js';
19
20
  /** Allowed values for a secret's `type` metadata field. */
20
21
  export const SECRET_TYPES = [
21
22
  'api-key',
@@ -148,10 +149,15 @@ export function writeBundle(bundle) {
148
149
  };
149
150
  const json = JSON.stringify(payload);
150
151
  setKeychainToken(bundleMetaItem(bundle.name), json, Boolean(bundle.icloud_sync));
152
+ emit('secrets.set', { bundle: bundle.name });
151
153
  }
152
154
  export function deleteBundle(name) {
153
155
  validateBundleName(name);
154
- return deleteKeychainToken(bundleMetaItem(name));
156
+ const deleted = deleteKeychainToken(bundleMetaItem(name));
157
+ if (deleted) {
158
+ emit('secrets.delete', { bundle: name });
159
+ }
160
+ return deleted;
155
161
  }
156
162
  export function listBundles() {
157
163
  let services;
@@ -13,6 +13,7 @@ import * as yaml from 'yaml';
13
13
  import { SKILLS_CAPABLE_AGENTS, ensureSkillsDir } from './agents.js';
14
14
  import { getUserSkillsDir, getSkillsDir as getSystemSkillsDir, getProjectAgentsDir, getEnabledExtraRepos } from './state.js';
15
15
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.js';
16
+ import { emit } from './events.js';
16
17
  const HOME = os.homedir();
17
18
  /** User-scoped skills dir (~/.agents/skills/). Used for installs. */
18
19
  export function getSkillsDir() {
@@ -255,6 +256,7 @@ export function installSkill(sourcePath, skillName, agents, method = 'symlink')
255
256
  };
256
257
  }
257
258
  }
259
+ emit('skill.install', { skill: skillName, agents });
258
260
  return { success: true };
259
261
  }
260
262
  /**
@@ -555,6 +557,7 @@ export function installSkillToVersion(agent, version, skillName, method = 'copy'
555
557
  // Best-effort; failure here shouldn't unwind the install
556
558
  }
557
559
  }
560
+ emit('skill.install', { skill: skillName, agent, version });
558
561
  return { success: true };
559
562
  }
560
563
  /**
@@ -616,6 +619,7 @@ export function uninstallSkill(skillName) {
616
619
  catch {
617
620
  // Ignore removal errors
618
621
  }
622
+ emit('skill.remove', { skill: skillName });
619
623
  return { success: true };
620
624
  }
621
625
  export function listInstalledSkills() {
package/dist/lib/usage.js CHANGED
@@ -211,7 +211,7 @@ async function getClaudeUsageInfo(options) {
211
211
  if (!isClaudeUsageOrgMatch(requestedOrgId, liveOrgId)) {
212
212
  return { snapshot: null, error: null };
213
213
  }
214
- const accessToken = await getClaudeAccessToken(oauth);
214
+ const accessToken = await getClaudeAccessToken(oauth, options?.home);
215
215
  if (!accessToken) {
216
216
  return { snapshot: null, error: null };
217
217
  }
@@ -343,9 +343,14 @@ function normalizeClaudeWindow(window, key, label, shortLabel) {
343
343
  windowMinutes: inferWindowMinutes(key),
344
344
  };
345
345
  }
346
+ let warnedNonDarwin = false;
346
347
  /** Load Claude OAuth credentials from the macOS Keychain. */
347
348
  export async function loadClaudeOauth(home) {
348
349
  if (process.platform !== 'darwin') {
350
+ if (!warnedNonDarwin && process.stderr.isTTY) {
351
+ process.stderr.write('[agents] Usage tracking requires macOS Keychain. Skipped on this platform.\n');
352
+ warnedNonDarwin = true;
353
+ }
349
354
  return null;
350
355
  }
351
356
  try {
@@ -374,6 +379,74 @@ export async function loadClaudeOauth(home) {
374
379
  return null;
375
380
  }
376
381
  }
382
+ /**
383
+ * Save Claude OAuth credentials to the macOS Keychain.
384
+ * Reads the existing payload, merges the new OAuth fields, and writes back.
385
+ */
386
+ async function saveClaudeOauth(home, credentials) {
387
+ if (process.platform !== 'darwin') {
388
+ return false;
389
+ }
390
+ try {
391
+ const account = os.userInfo().username;
392
+ const service = getClaudeKeychainService(home);
393
+ // Read existing payload to preserve other fields
394
+ let existingPayload = {};
395
+ try {
396
+ const { stdout } = await execFileAsync('security', [
397
+ 'find-generic-password',
398
+ '-a',
399
+ account,
400
+ '-s',
401
+ service,
402
+ '-w',
403
+ ]);
404
+ existingPayload = JSON.parse(stdout.trim());
405
+ }
406
+ catch {
407
+ // No existing entry, start fresh
408
+ }
409
+ // Merge new credentials into existing payload
410
+ const newPayload = {
411
+ ...existingPayload,
412
+ claudeAiOauth: {
413
+ ...existingPayload.claudeAiOauth,
414
+ accessToken: credentials.accessToken,
415
+ refreshToken: credentials.refreshToken,
416
+ expiresAt: credentials.expiresAt,
417
+ scopes: credentials.scopes ?? existingPayload.claudeAiOauth?.scopes,
418
+ },
419
+ };
420
+ const payloadJson = JSON.stringify(newPayload);
421
+ // Delete existing entry first (security add-generic-password -U can fail)
422
+ try {
423
+ await execFileAsync('security', [
424
+ 'delete-generic-password',
425
+ '-a',
426
+ account,
427
+ '-s',
428
+ service,
429
+ ]);
430
+ }
431
+ catch {
432
+ // Entry might not exist, ignore
433
+ }
434
+ // Add updated entry
435
+ await execFileAsync('security', [
436
+ 'add-generic-password',
437
+ '-a',
438
+ account,
439
+ '-s',
440
+ service,
441
+ '-w',
442
+ payloadJson,
443
+ ]);
444
+ return true;
445
+ }
446
+ catch {
447
+ return false;
448
+ }
449
+ }
377
450
  /**
378
451
  * Derive the Keychain service name for a Claude home directory.
379
452
  * Managed (non-default) homes get a hash suffix for isolation.
@@ -493,8 +566,8 @@ function isCachedUsageWindowFresh(window, capturedAt, now) {
493
566
  }
494
567
  return true;
495
568
  }
496
- /** Obtain a valid access token, refreshing if expired. */
497
- async function getClaudeAccessToken(oauth) {
569
+ /** Obtain a valid access token, refreshing if expired. Saves refreshed tokens to Keychain. */
570
+ async function getClaudeAccessToken(oauth, home) {
498
571
  const accessToken = oauth.accessToken?.trim();
499
572
  if (!accessToken) {
500
573
  return null;
@@ -507,7 +580,12 @@ async function getClaudeAccessToken(oauth) {
507
580
  return null;
508
581
  }
509
582
  const refreshed = await refreshClaudeToken(oauth);
510
- return refreshed?.accessToken?.trim() || null;
583
+ if (!refreshed?.accessToken) {
584
+ return null;
585
+ }
586
+ // Persist refreshed credentials to Keychain so they survive across runs
587
+ await saveClaudeOauth(home, refreshed);
588
+ return refreshed.accessToken.trim();
511
589
  }
512
590
  /** Refresh an expired Claude OAuth access token using the refresh token. */
513
591
  async function refreshClaudeToken(oauth) {
@@ -547,7 +625,7 @@ export async function isClaudeAuthValid(home) {
547
625
  const oauth = await loadClaudeOauth(home);
548
626
  if (!oauth)
549
627
  return false;
550
- const token = await getClaudeAccessToken(oauth);
628
+ const token = await getClaudeAccessToken(oauth, home);
551
629
  return token !== null;
552
630
  }
553
631
  /** Build a User-Agent string for Claude API requests. */
@@ -37,6 +37,7 @@ import { discoverPlugins, syncPluginToVersion, isPluginSynced, pluginSupportsAge
37
37
  import { composeRulesFromState } from './rules/compose.js';
38
38
  import { loadSyncManifest, saveSyncManifest, buildManifest, isSyncStale } from './sync-manifest.js';
39
39
  import { PLUGINS_CAPABLE_AGENTS } from './agents.js';
40
+ import { emit } from './events.js';
40
41
  import { safeJoin } from './paths.js';
41
42
  import { installCommandSkillToVersion, listCommandSkillsInVersion, shouldInstallCommandAsSkill } from './command-skills.js';
42
43
  /** Promisified exec for running shell commands. */
@@ -909,6 +910,7 @@ export function setGlobalDefault(agent, version) {
909
910
  }
910
911
  else {
911
912
  meta.agents[agent] = version;
913
+ emit('version.switch', { agent, version });
912
914
  }
913
915
  writeMeta(meta);
914
916
  }
@@ -989,6 +991,7 @@ export async function installVersion(agent, version, onProgress) {
989
991
  /* non-fatal; the install itself succeeded */
990
992
  }
991
993
  }
994
+ emit('version.install', { agent, version: installedVersion });
992
995
  return { success: true, installedVersion };
993
996
  }
994
997
  catch (err) {
@@ -997,6 +1000,7 @@ export async function installVersion(agent, version, onProgress) {
997
1000
  if (fs.existsSync(versionDir)) {
998
1001
  removeInstallArtifacts(versionDir);
999
1002
  }
1003
+ emit('version.install', { agent, version, error: err.message });
1000
1004
  return { success: false, installedVersion: version, error: err.message };
1001
1005
  }
1002
1006
  }
@@ -1086,6 +1090,7 @@ export function removeVersion(agent, version) {
1086
1090
  // Ignore if already gone
1087
1091
  }
1088
1092
  }
1093
+ emit('version.remove', { agent, version });
1089
1094
  return true;
1090
1095
  }
1091
1096
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.14.5",
3
+ "version": "1.14.7",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",