@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.
@@ -1,10 +1,14 @@
1
1
  import * as fs from 'fs';
2
+ import * as os from 'os';
2
3
  import * as path from 'path';
4
+ import { execFile } from 'child_process';
5
+ import { promisify } from 'util';
3
6
  import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from './cdp.js';
4
7
  import { getProfile, getProfileRuntimeDir, getBrowserRuntimeDir, listProfiles, extractConfiguredPort, resolveEndpoint, } from './profiles.js';
5
8
  import { killChrome, getRunningChromeInfo, launchBrowser, allocatePort } from './chrome.js';
6
9
  import { connectLocal } from './drivers/local.js';
7
10
  import { connectSSH } from './drivers/ssh.js';
11
+ import { clearProfileRuntime } from './runtime-state.js';
8
12
  import { generateTaskId, generateShortId, generateTaskName, } from './types.js';
9
13
  import { getRefs, resolveRefToCoords } from './refs.js';
10
14
  import { clickAtCoords, hoverAtCoords, scrollAtCoords, typeText, pressKey, focusNode } from './input.js';
@@ -124,7 +128,81 @@ export function pickWindowTarget(targets, filter) {
124
128
  return visible;
125
129
  return pages[0];
126
130
  }
131
+ const execFileP = promisify(execFile);
132
+ /**
133
+ * Parse a `--since`/`--until` value. Accepts ISO-8601 absolute timestamps
134
+ * or relative offsets like `30s`, `5m`, `2h`, `1d`.
135
+ */
136
+ export function parseSinceUntil(s) {
137
+ const ms = Date.parse(s);
138
+ if (!isNaN(ms))
139
+ return new Date(ms);
140
+ const m = s.match(/^(\d+)([smhd])$/);
141
+ if (!m)
142
+ throw new Error(`Invalid since/until: ${s}`);
143
+ const n = parseInt(m[1], 10);
144
+ const unitMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
145
+ return new Date(Date.now() - n * unitMs[m[2]]);
146
+ }
147
+ async function execSSH(host, cmd) {
148
+ const { stdout } = await execFileP('ssh', [host, cmd], {
149
+ timeout: 10_000,
150
+ maxBuffer: 10_000_000,
151
+ });
152
+ return stdout;
153
+ }
154
+ export function readNewestMatchingFile(dir, prefix, tailLines) {
155
+ let entries;
156
+ try {
157
+ entries = fs.readdirSync(dir);
158
+ }
159
+ catch {
160
+ return '';
161
+ }
162
+ const candidates = entries
163
+ .filter((f) => f.startsWith(prefix) && f.endsWith('.jsonl'))
164
+ .map((f) => ({ f, mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
165
+ .sort((a, b) => b.mtime - a.mtime);
166
+ if (candidates.length === 0)
167
+ return '';
168
+ const lines = fs
169
+ .readFileSync(path.join(dir, candidates[0].f), 'utf8')
170
+ .split('\n')
171
+ .filter(Boolean);
172
+ return lines.slice(-tailLines).join('\n');
173
+ }
174
+ function expandHome(p) {
175
+ if (p.startsWith('~/'))
176
+ return path.join(os.homedir(), p.slice(2));
177
+ if (p === '~')
178
+ return os.homedir();
179
+ return p;
180
+ }
181
+ /**
182
+ * Probe a cached connection before reuse. A WebSocket can quietly transition
183
+ * to CLOSED without anyone noticing — most commonly when the user kills the
184
+ * browser process by hand. `Browser.getVersion` is the lightest CDP call we
185
+ * can make; if it doesn't round-trip within 1s the connection is dead.
186
+ */
187
+ async function isConnHealthy(conn, timeoutMs = 1000) {
188
+ if (!conn.cdp.isOpen)
189
+ return false;
190
+ try {
191
+ await Promise.race([
192
+ conn.cdp.send('Browser.getVersion'),
193
+ new Promise((_, reject) => setTimeout(() => reject(new Error('healthcheck timeout')), timeoutMs)),
194
+ ]);
195
+ return true;
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ }
127
201
  export class BrowserService {
202
+ static SOURCE_PREFIX = {
203
+ 'rush-app': 'rush-app-',
204
+ 'rush-cli': 'rush-cli-',
205
+ };
128
206
  connections = new Map();
129
207
  forkingProfiles = new Set();
130
208
  // Per-task storage for console, errors, network, downloads
@@ -163,6 +241,20 @@ export class BrowserService {
163
241
  const taskId = generateTaskId();
164
242
  let conn = this.connections.get(composite);
165
243
  let effectiveProfileName = composite;
244
+ // If we have a cached connection, confirm it's still usable before any
245
+ // caller relies on it. A browser killed externally (Cmd-Q, crash, or a
246
+ // user clearing the profile by hand) leaves a closed WebSocket here.
247
+ // Without this check the next `cdp.send` throws "CDP connection not
248
+ // open" and the user has no way to recover short of killing the daemon.
249
+ if (conn && !(await isConnHealthy(conn))) {
250
+ try {
251
+ conn.cdp.close();
252
+ }
253
+ catch { /* already closed */ }
254
+ conn.cleanup?.();
255
+ this.connections.delete(composite);
256
+ conn = undefined;
257
+ }
166
258
  if (conn && conn.electron && conn.tasks.size > 0) {
167
259
  if (this.forkingProfiles.has(composite)) {
168
260
  while (this.forkingProfiles.has(composite)) {
@@ -274,6 +366,7 @@ export class BrowserService {
274
366
  if (conn.forkedFrom && conn.tasks.size === 0) {
275
367
  conn.cdp.close();
276
368
  killChrome(conn.pid);
369
+ conn.cleanup?.();
277
370
  this.connections.delete(profileName);
278
371
  }
279
372
  return { ok: true, profile: profileName };
@@ -289,6 +382,7 @@ export class BrowserService {
289
382
  if (conn) {
290
383
  conn.cdp.close();
291
384
  killChrome(conn.pid);
385
+ conn.cleanup?.();
292
386
  this.connections.delete(profileName);
293
387
  }
294
388
  const runtimeDir = getProfileRuntimeDir(profileName);
@@ -1082,6 +1176,46 @@ export class BrowserService {
1082
1176
  }
1083
1177
  throw new Error(`No response matching "${urlPattern}" within ${timeout}ms`);
1084
1178
  }
1179
+ // ─── App Logs (source-side JSONL) ───────────────────────────────────────────
1180
+ async getAppLogs(taskId, opts) {
1181
+ const { task } = await this.findTask(taskId);
1182
+ const baseProfileName = task.profile.split('@')[0];
1183
+ const profile = await getProfile(baseProfileName);
1184
+ if (!profile?.logDir) {
1185
+ throw new Error(`Profile '${task.profile}' has no logDir set`);
1186
+ }
1187
+ const logDir = expandHome(profile.logDir);
1188
+ const sources = opts.source ? [opts.source] : ['rush-app', 'rush-cli'];
1189
+ const since = opts.since ? parseSinceUntil(opts.since) : null;
1190
+ const until = opts.until ? parseSinceUntil(opts.until) : null;
1191
+ const tailN = since ? 100_000 : (opts.lines ?? 200);
1192
+ const raws = await Promise.all(sources.map(async (src) => {
1193
+ const prefix = BrowserService.SOURCE_PREFIX[src];
1194
+ if (!prefix)
1195
+ return '';
1196
+ if (profile.logHost) {
1197
+ return execSSH(profile.logHost, `ls -1t ${logDir}/${prefix}*.jsonl 2>/dev/null | head -1 | xargs -r tail -n ${tailN}`);
1198
+ }
1199
+ return readNewestMatchingFile(logDir, prefix, tailN);
1200
+ }));
1201
+ const entries = raws
1202
+ .flatMap((r) => r.split('\n').filter(Boolean))
1203
+ .map((line) => {
1204
+ try {
1205
+ return JSON.parse(line);
1206
+ }
1207
+ catch {
1208
+ return { raw: line };
1209
+ }
1210
+ })
1211
+ .filter((e) => !opts.level || e.level === opts.level)
1212
+ .filter((e) => !opts.message || e.message === opts.message)
1213
+ .filter((e) => !opts.filter || JSON.stringify(e).includes(opts.filter))
1214
+ .filter((e) => !since || (e.timestamp && new Date(e.timestamp) >= since))
1215
+ .filter((e) => !until || (e.timestamp && new Date(e.timestamp) <= until))
1216
+ .sort((a, b) => new Date(a.timestamp ?? 0).getTime() - new Date(b.timestamp ?? 0).getTime());
1217
+ return since ? entries : entries.slice(-(opts.lines ?? 200));
1218
+ }
1085
1219
  // ─── Wait Conditions ─────────────────────────────────────────────────────────
1086
1220
  async wait(taskId, type, value, options = {}) {
1087
1221
  const timeout = options.timeout ?? 30000;
@@ -1213,6 +1347,7 @@ export class BrowserService {
1213
1347
  }
1214
1348
  for (const [, conn] of this.connections) {
1215
1349
  conn.cdp.close();
1350
+ conn.cleanup?.();
1216
1351
  }
1217
1352
  this.connections.clear();
1218
1353
  }
@@ -1232,7 +1367,7 @@ export class BrowserService {
1232
1367
  const forkName = `${profile.name}.${forkNum}`;
1233
1368
  const port = allocatePort();
1234
1369
  const chromeOpts = { ...profile.chrome, viewport: profile.viewport };
1235
- const { pid, wsUrl } = await launchBrowser(forkName, profile.browser, port, chromeOpts, profile.secrets, profile.binary);
1370
+ const { pid, wsUrl } = await launchBrowser(forkName, profile.browser, port, chromeOpts, profile.secrets, profile.binary, profile.electron === true);
1236
1371
  const cdp = new CDPClient();
1237
1372
  await cdp.connect(wsUrl);
1238
1373
  await this.enableDomains(cdp);
@@ -1261,21 +1396,32 @@ export class BrowserService {
1261
1396
  async connectProfile(effectiveProfile, target) {
1262
1397
  const existingInfo = getRunningChromeInfo(effectiveProfile.name);
1263
1398
  if (existingInfo) {
1264
- const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port);
1265
- verifyBrowserIdentity(browser, effectiveProfile.browser, existingInfo.port);
1266
- const cdp = new CDPClient();
1267
- await cdp.connect(wsUrl);
1268
- await this.enableDomains(cdp);
1269
- const tasks = this.loadTaskState(effectiveProfile.name);
1270
- return {
1271
- cdp,
1272
- port: existingInfo.port,
1273
- pid: existingInfo.pid,
1274
- electron: effectiveProfile.electron,
1275
- targetFilter: effectiveProfile.targetFilter,
1276
- tasks,
1277
- sessionCache: new Map(),
1278
- };
1399
+ try {
1400
+ const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port);
1401
+ verifyBrowserIdentity(browser, effectiveProfile.browser, existingInfo.port);
1402
+ const cdp = new CDPClient();
1403
+ await cdp.connect(wsUrl);
1404
+ await this.enableDomains(cdp);
1405
+ const tasks = this.loadTaskState(effectiveProfile.name);
1406
+ return {
1407
+ cdp,
1408
+ port: existingInfo.port,
1409
+ pid: existingInfo.pid,
1410
+ electron: effectiveProfile.electron,
1411
+ targetFilter: effectiveProfile.targetFilter,
1412
+ tasks,
1413
+ sessionCache: new Map(),
1414
+ };
1415
+ }
1416
+ catch (err) {
1417
+ // pid file says a process is alive on that port, but nothing is
1418
+ // actually responding to CDP — most commonly because the user
1419
+ // changed the configured endpoint port after a previous launch,
1420
+ // or because the OS reused the pid for an unrelated process.
1421
+ // Wipe the stale runtime files and fall through to a fresh
1422
+ // connect against the profile's currently-configured endpoint.
1423
+ clearProfileRuntime(effectiveProfile.name);
1424
+ }
1279
1425
  }
1280
1426
  const conn = await this.connectEndpoint(effectiveProfile, target);
1281
1427
  if (!conn) {
@@ -1309,6 +1455,7 @@ export class BrowserService {
1309
1455
  targetFilter: profile.targetFilter,
1310
1456
  tasks: new Map(),
1311
1457
  sessionCache: new Map(),
1458
+ cleanup: conn.cleanup,
1312
1459
  };
1313
1460
  }
1314
1461
  if (url.protocol === 'wss:' || url.protocol === 'ws:') {
@@ -41,6 +41,10 @@ export interface BrowserProfile {
41
41
  x?: number;
42
42
  y?: number;
43
43
  };
44
+ /** Directory holding source-side JSONL logs (e.g. ~/.rush/logs). */
45
+ logDir?: string;
46
+ /** Optional SSH host where logDir lives, e.g. "muqsit@mac-mini". */
47
+ logHost?: string;
44
48
  }
45
49
  /** Parsed form of `BrowserProfile.targetFilter`. */
46
50
  export interface TargetFilter {
@@ -105,7 +109,7 @@ export interface HistoricalTask {
105
109
  domains: string[];
106
110
  tabCount: number;
107
111
  }
108
- export type IPCAction = 'start' | 'record-start' | 'record-stop' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download' | 'upload';
112
+ export type IPCAction = 'start' | 'record-start' | 'record-stop' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download' | 'upload' | 'getAppLogs' | 'version';
109
113
  export interface IPCRequest {
110
114
  action: IPCAction;
111
115
  task?: string;
@@ -146,6 +150,12 @@ export interface IPCRequest {
146
150
  fps?: number;
147
151
  duration?: number;
148
152
  maxMb?: number;
153
+ source?: string;
154
+ lines?: number;
155
+ message?: string;
156
+ since?: string;
157
+ until?: string;
158
+ appLevel?: string;
149
159
  }
150
160
  /** Subset of IPCResponse describing a recording start result. */
151
161
  export interface RecordStartFields {
@@ -188,6 +198,8 @@ export interface IPCResponse {
188
198
  downloadPath?: string;
189
199
  devices?: string[];
190
200
  uploadMode?: 'input' | 'drop' | 'chooser';
201
+ appLogs?: any[];
202
+ version?: string;
191
203
  }
192
204
  export interface ConsoleEntry {
193
205
  level: 'log' | 'info' | 'warn' | 'error';
@@ -178,6 +178,24 @@ export async function runDaemon() {
178
178
  for (const job of scheduled) {
179
179
  log('INFO', ` ${job.name} -> next: ${job.nextRun?.toISOString() || 'unknown'}`);
180
180
  }
181
+ // Before the BrowserService comes up, reap browser + tunnel processes
182
+ // spawned by previous daemons that are no longer alive. Without this,
183
+ // a daemon hard-crash (SIGKILL, OOM) would leak every browser and SSH
184
+ // tunnel it had open — and the next session would either hijack those
185
+ // (cdp:// profile silently driven via stale ssh tunnel) or fail to
186
+ // bind because the ports are still claimed.
187
+ try {
188
+ const { reapOrphanedProcesses } = await import('./browser/runtime-state.js');
189
+ const result = reapOrphanedProcesses();
190
+ if (result.reaped > 0) {
191
+ log('INFO', `Reaped ${result.reaped} orphan process(es) from prior daemon(s)`);
192
+ for (const d of result.details)
193
+ log('INFO', ` ${d}`);
194
+ }
195
+ }
196
+ catch (err) {
197
+ log('ERROR', `Orphan reaper failed: ${err.message}`);
198
+ }
181
199
  const browserService = new BrowserService();
182
200
  const browserIPC = new BrowserIPCServer(browserService);
183
201
  try {
@@ -259,6 +277,14 @@ Environment=PATH=/usr/local/bin:/usr/bin:/bin:${os.homedir()}/.nvm/versions/node
259
277
  WantedBy=default.target`;
260
278
  }
261
279
  function getAgentsBinPath() {
280
+ // Prefer the binary actively executing this code. `which agents` returns
281
+ // whatever happens to be first on PATH, which means a side-by-side dev
282
+ // build at ~/.local/bin would silently spawn the registry-installed
283
+ // daemon and run stale code. process.argv[1] is the absolute path of
284
+ // the JS entrypoint the user actually invoked.
285
+ const argv1 = process.argv[1];
286
+ if (argv1 && fs.existsSync(argv1))
287
+ return argv1;
262
288
  try {
263
289
  return execSync('which agents', { encoding: 'utf-8' }).trim();
264
290
  }
@@ -299,13 +325,20 @@ function startDaemonLocked() {
299
325
  execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
300
326
  }
301
327
  catch { /* not loaded, expected */ }
302
- execFileSync('launchctl', ['load', plistPath], { encoding: 'utf-8' });
328
+ // launchctl prints `Load failed:` and exits 0 when the label is in a
329
+ // stuck state from a prior session — so a zero exit code isn't proof
330
+ // of success. If no pid materializes within the window, give up on
331
+ // launchd and fall through to a plain detached spawn.
332
+ execFileSync('launchctl', ['load', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
303
333
  const pid = waitForPid(3000);
304
- return { pid, method: 'launchd' };
334
+ if (pid)
335
+ return { pid, method: 'launchd' };
336
+ // launchctl claimed success but nothing ran. Fall through.
305
337
  }
306
338
  catch {
307
- return startDetached();
339
+ // load threw — fall through to detached spawn
308
340
  }
341
+ return startDetached();
309
342
  }
310
343
  if (platform === 'linux') {
311
344
  try {
@@ -11,7 +11,7 @@
11
11
  * - Permissions: logs dir is 0700, files are 0600 (owner-only)
12
12
  * - Performance tracking: withTiming() wrapper for any async function
13
13
  */
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';
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' | 'secrets.rename' | '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';
15
15
  export interface EventMeta {
16
16
  ts: string;
17
17
  tz: string;
@@ -82,6 +82,26 @@ export interface RotateOptions {
82
82
  * unless `clearMeta` or a `meta` patch is supplied.
83
83
  */
84
84
  export declare function rotateBundleSecret(bundle: SecretsBundle, key: string, opts: RotateOptions): void;
85
+ /** Options for renameBundle. */
86
+ export interface RenameOptions {
87
+ /** When true, overwrite an existing destination bundle (purges its keychain items first). */
88
+ force?: boolean;
89
+ }
90
+ /**
91
+ * Rename a bundle: move metadata + every keychain-backed value to a new name.
92
+ *
93
+ * Sequence is ordered so the source stays intact if anything in the copy
94
+ * phase fails:
95
+ * 1) read source, validate dest
96
+ * 2) purge dest if --force, refuse otherwise
97
+ * 3) copy each keychain value source -> dest
98
+ * 4) write new bundle metadata
99
+ * 5) delete the old per-key keychain items + old metadata
100
+ *
101
+ * Steps 1-4 are reversible. If 5 partially fails (e.g. iCloud Keychain
102
+ * hiccup), running `rename` again is a safe no-op for the source items.
103
+ */
104
+ export declare function renameBundle(oldName: string, newName: string, opts?: RenameOptions): void;
85
105
  export declare function keychainItemsForBundle(bundle: SecretsBundle): Array<{
86
106
  key: string;
87
107
  item: string;
@@ -306,6 +306,62 @@ export function rotateBundleSecret(bundle, key, opts) {
306
306
  }
307
307
  writeBundle(bundle);
308
308
  }
309
+ /**
310
+ * Rename a bundle: move metadata + every keychain-backed value to a new name.
311
+ *
312
+ * Sequence is ordered so the source stays intact if anything in the copy
313
+ * phase fails:
314
+ * 1) read source, validate dest
315
+ * 2) purge dest if --force, refuse otherwise
316
+ * 3) copy each keychain value source -> dest
317
+ * 4) write new bundle metadata
318
+ * 5) delete the old per-key keychain items + old metadata
319
+ *
320
+ * Steps 1-4 are reversible. If 5 partially fails (e.g. iCloud Keychain
321
+ * hiccup), running `rename` again is a safe no-op for the source items.
322
+ */
323
+ export function renameBundle(oldName, newName, opts = {}) {
324
+ validateBundleName(oldName);
325
+ validateBundleName(newName);
326
+ if (oldName === newName) {
327
+ throw new Error(`Bundle name unchanged ('${oldName}').`);
328
+ }
329
+ if (!bundleExists(oldName)) {
330
+ throw new Error(`Bundle '${oldName}' not found.`);
331
+ }
332
+ const source = readBundle(oldName);
333
+ if (bundleExists(newName)) {
334
+ if (!opts.force) {
335
+ throw new Error(`Bundle '${newName}' already exists. Use --force to overwrite.`);
336
+ }
337
+ const dest = readBundle(newName);
338
+ for (const { item } of keychainItemsForBundle(dest)) {
339
+ deleteKeychainToken(item, dest.icloud_sync);
340
+ }
341
+ deleteBundle(newName);
342
+ }
343
+ // Copy phase: read old item, write new item. Old items stay in place
344
+ // until step 5 so a partial failure here leaves the source intact.
345
+ const sourceItems = keychainItemsForBundle(source);
346
+ for (const { key, item: oldItem } of sourceItems) {
347
+ const raw = source.vars[key];
348
+ if (typeof raw !== 'string' || !raw.startsWith('keychain:'))
349
+ continue;
350
+ const shortId = raw.slice('keychain:'.length);
351
+ const newItem = secretsKeychainItem(newName, shortId);
352
+ const value = getKeychainToken(oldItem, source.icloud_sync);
353
+ setKeychainToken(newItem, value, source.icloud_sync);
354
+ }
355
+ // writeBundle preserves source.created_at and refreshes updated_at.
356
+ const renamed = { ...source, name: newName };
357
+ writeBundle(renamed);
358
+ // Cleanup: delete the old per-key keychain items, then the old metadata.
359
+ for (const { item: oldItem } of sourceItems) {
360
+ deleteKeychainToken(oldItem, source.icloud_sync);
361
+ }
362
+ deleteBundle(oldName);
363
+ emit('secrets.rename', { from: oldName, to: newName });
364
+ }
309
365
  // Iterate all keychain-backed keys in a bundle for cleanup on rm/unset.
310
366
  export function keychainItemsForBundle(bundle) {
311
367
  const items = [];
@@ -182,14 +182,14 @@ export function deleteKeychainToken(item, sync = false) {
182
182
  assertSupportedPlatform();
183
183
  if (isLinux())
184
184
  return linuxBackend.delete(item, sync);
185
- // macOS path
186
- if (sync) {
187
- const bin = ensureKeychainHelper();
188
- return spawnSync(bin, ['delete', item, os.userInfo().username], {
189
- stdio: ['ignore', 'pipe', 'pipe'],
190
- }).status === 0;
191
- }
192
- return spawnSync('security', ['delete-generic-password', '-a', os.userInfo().username, '-s', item], {
185
+ // macOS: Try security first (no prompts for local items), fall back to binary for synced items.
186
+ if (!sync && spawnSync('security', ['delete-generic-password', '-a', os.userInfo().username, '-s', item], {
187
+ stdio: ['ignore', 'pipe', 'pipe'],
188
+ }).status === 0)
189
+ return true;
190
+ // Fallback: binary deletes synced items via kSecAttrSynchronizableAny
191
+ const bin = ensureKeychainHelper();
192
+ return spawnSync(bin, ['delete', item, os.userInfo().username], {
193
193
  stdio: ['ignore', 'pipe', 'pipe'],
194
194
  }).status === 0;
195
195
  }
@@ -463,6 +463,10 @@ export interface BrowserProfileConfig {
463
463
  width: number;
464
464
  height: number;
465
465
  };
466
+ /** Directory holding source-side JSONL logs (e.g. ~/.rush/logs). */
467
+ logDir?: string;
468
+ /** Optional SSH host where logDir lives, e.g. "muqsit@mac-mini". */
469
+ logHost?: string;
466
470
  }
467
471
  /** Options controlling which agents and resources are synced during `agents pull` / `agents use`. */
468
472
  export interface SyncOptions {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Resolve the CLI version from the shipping package.json. Used by the daemon
3
+ * to answer `IPCAction: 'version'` and by the client to detect daemon drift —
4
+ * a dev-build CLI talking to a launchd-managed registry daemon would silently
5
+ * get stale behavior without this check.
6
+ */
7
+ export declare function getCliVersion(): string;
@@ -0,0 +1,25 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ const __filename = fileURLToPath(import.meta.url);
5
+ const __dirname = path.dirname(__filename);
6
+ let cached = null;
7
+ /**
8
+ * Resolve the CLI version from the shipping package.json. Used by the daemon
9
+ * to answer `IPCAction: 'version'` and by the client to detect daemon drift —
10
+ * a dev-build CLI talking to a launchd-managed registry daemon would silently
11
+ * get stale behavior without this check.
12
+ */
13
+ export function getCliVersion() {
14
+ if (cached)
15
+ return cached;
16
+ try {
17
+ const pkgPath = path.join(__dirname, '..', '..', 'package.json');
18
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
19
+ cached = String(pkg.version || 'unknown');
20
+ }
21
+ catch {
22
+ cached = 'unknown';
23
+ }
24
+ return cached;
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.18.4",
3
+ "version": "1.18.5",
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",