@phnx-labs/agents-cli 1.18.3 → 1.18.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,19 @@
1
1
  export type BrowserType = 'chrome' | 'comet' | 'chromium' | 'brave' | 'edge' | 'custom';
2
+ /**
3
+ * A single named endpoint preset within a profile. Lets one profile cover
4
+ * the local + remote variants of the same app (e.g. Rush on this Mac vs.
5
+ * Rush on mac-mini) instead of forcing two parallel profiles.
6
+ *
7
+ * Per-endpoint overrides take precedence over profile-level fields.
8
+ */
9
+ export interface EndpointPreset {
10
+ /** CDP URL — `cdp://host:port` or `ssh://host?port=N` */
11
+ target: string;
12
+ /** Override the profile-level binary (e.g. mac-mini has no local binary). */
13
+ binary?: string;
14
+ /** Override the profile-level targetFilter (Electron app builds may diverge). */
15
+ targetFilter?: string;
16
+ }
2
17
  export interface BrowserProfile {
3
18
  name: string;
4
19
  description?: string;
@@ -10,7 +25,14 @@ export interface BrowserProfile {
10
25
  * represents the visible UI for Electron apps with multiple WebContents.
11
26
  */
12
27
  targetFilter?: string;
13
- endpoints: string[];
28
+ /**
29
+ * Endpoint presets. Accepts two shapes for backward compatibility:
30
+ * - Legacy: `string[]` of CDP URLs; first entry is the default.
31
+ * - New: `{ [presetName]: EndpointPreset }`, with optional `defaultEndpoint`.
32
+ * Normalize via `resolveEndpoint(profile, name?)` instead of reading directly.
33
+ */
34
+ endpoints: string[] | Record<string, EndpointPreset>;
35
+ defaultEndpoint?: string;
14
36
  chrome?: ChromeOptions;
15
37
  secrets?: string;
16
38
  viewport?: {
@@ -19,6 +41,10 @@ export interface BrowserProfile {
19
41
  x?: number;
20
42
  y?: number;
21
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;
22
48
  }
23
49
  /** Parsed form of `BrowserProfile.targetFilter`. */
24
50
  export interface TargetFilter {
@@ -83,7 +109,7 @@ export interface HistoricalTask {
83
109
  domains: string[];
84
110
  tabCount: number;
85
111
  }
86
- export type IPCAction = 'start' | 'launch-profile' | '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';
87
113
  export interface IPCRequest {
88
114
  action: IPCAction;
89
115
  task?: string;
@@ -119,6 +145,28 @@ export interface IPCRequest {
119
145
  files?: string[];
120
146
  trigger?: number;
121
147
  uploadMode?: 'auto' | 'input' | 'drop' | 'chooser';
148
+ quality?: 'compressed' | 'raw';
149
+ endpoint?: string;
150
+ fps?: number;
151
+ duration?: number;
152
+ maxMb?: number;
153
+ source?: string;
154
+ lines?: number;
155
+ message?: string;
156
+ since?: string;
157
+ until?: string;
158
+ appLevel?: string;
159
+ }
160
+ /** Subset of IPCResponse describing a recording start result. */
161
+ export interface RecordStartFields {
162
+ fps?: number;
163
+ durationCapSec?: number;
164
+ maxMb?: number;
165
+ }
166
+ /** Subset of IPCResponse describing a recording stop result. */
167
+ export interface RecordStopFields {
168
+ durationMs?: number;
169
+ stopReason?: 'manual' | 'duration-cap' | 'size-cap';
122
170
  }
123
171
  export interface IPCResponse {
124
172
  ok: boolean;
@@ -131,10 +179,18 @@ export interface IPCResponse {
131
179
  history?: HistoricalTask[];
132
180
  result?: unknown;
133
181
  path?: string;
182
+ bytes?: number;
183
+ width?: number;
184
+ height?: number;
134
185
  refs?: string;
135
186
  nodes?: RefNodeJson[];
136
187
  port?: number;
137
188
  pid?: number;
189
+ fps?: number;
190
+ durationCapSec?: number;
191
+ maxMb?: number;
192
+ durationMs?: number;
193
+ stopReason?: 'manual' | 'duration-cap' | 'size-cap';
138
194
  logs?: ConsoleEntry[];
139
195
  errors?: ErrorEntry[];
140
196
  requests?: NetworkRequest[];
@@ -142,6 +198,8 @@ export interface IPCResponse {
142
198
  downloadPath?: string;
143
199
  devices?: string[];
144
200
  uploadMode?: 'input' | 'drop' | 'chooser';
201
+ appLogs?: any[];
202
+ version?: string;
145
203
  }
146
204
  export interface ConsoleEntry {
147
205
  level: 'log' | 'info' | 'warn' | 'error';
@@ -183,3 +241,10 @@ export declare function isValidTaskId(id: string): boolean;
183
241
  export declare function generateTaskId(): string;
184
242
  export declare function generateShortId(): string;
185
243
  export declare function generateFunName(): string;
244
+ /**
245
+ * Auto-generated task name: `<adjective>-<noun>-<noun>-<hex8>`, e.g.
246
+ * `swift-crab-falcon-a3f92b1c`. Three English words make it memorable and
247
+ * easy to read; 32 bits of hex give every spawned task enough entropy that
248
+ * parallel agents never collide on the daemon side.
249
+ */
250
+ export declare function generateTaskName(): string;
@@ -11,13 +11,33 @@ export function generateShortId() {
11
11
  const ADJECTIVES = [
12
12
  'swift', 'cosmic', 'jolly', 'quiet', 'bold', 'bright', 'calm', 'eager',
13
13
  'golden', 'happy', 'keen', 'lucky', 'noble', 'proud', 'quick', 'royal',
14
+ 'silver', 'amber', 'crimson', 'misty', 'sunny', 'gentle', 'wild', 'brave',
15
+ 'merry', 'sleek', 'wise', 'fierce', 'curious', 'humble', 'spry', 'witty',
14
16
  ];
15
17
  const NOUNS = [
16
18
  'falcon', 'comet', 'tiger', 'nebula', 'phoenix', 'river', 'summit', 'wave',
17
19
  'aurora', 'breeze', 'crystal', 'dragon', 'ember', 'forest', 'glacier', 'harbor',
20
+ 'crab', 'otter', 'hawk', 'fox', 'wolf', 'panda', 'lynx', 'raven',
21
+ 'meadow', 'canyon', 'valley', 'orchid', 'cedar', 'thistle', 'lotus', 'briar',
18
22
  ];
19
23
  export function generateFunName() {
20
24
  const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
21
25
  const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
22
26
  return `${adj}-${noun}`;
23
27
  }
28
+ /**
29
+ * Auto-generated task name: `<adjective>-<noun>-<noun>-<hex8>`, e.g.
30
+ * `swift-crab-falcon-a3f92b1c`. Three English words make it memorable and
31
+ * easy to read; 32 bits of hex give every spawned task enough entropy that
32
+ * parallel agents never collide on the daemon side.
33
+ */
34
+ export function generateTaskName() {
35
+ const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
36
+ const noun1 = NOUNS[Math.floor(Math.random() * NOUNS.length)];
37
+ let noun2 = NOUNS[Math.floor(Math.random() * NOUNS.length)];
38
+ while (noun2 === noun1) {
39
+ noun2 = NOUNS[Math.floor(Math.random() * NOUNS.length)];
40
+ }
41
+ const hex8 = crypto.randomUUID().replace(/-/g, '').slice(0, 8);
42
+ return `${adj}-${noun1}-${noun2}-${hex8}`;
43
+ }
@@ -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;
package/dist/lib/help.js CHANGED
@@ -23,12 +23,36 @@ function formatHelpCommandsFirst(cmd, helper) {
23
23
  function formatList(textArray) {
24
24
  return textArray.join('\n').replace(/^/gm, ' '.repeat(itemIndentWidth));
25
25
  }
26
- let output = [`Usage: ${helper.commandUsage(cmd)}`, ''];
26
+ // Drop arguments flagged as hidden (deprecation / compat slots) from both
27
+ // the Usage line and the Arguments section. Commander v12's Argument lacks
28
+ // hideHelp(), so we read a custom `hidden` field that callers set directly.
29
+ const isHidden = (a) => a.hidden === true;
30
+ const registeredArgs = cmd.registeredArguments ?? [];
31
+ const parentNames = [];
32
+ for (let p = cmd.parent; p; p = p.parent)
33
+ parentNames.unshift(p.name());
34
+ const parentPrefix = parentNames.length > 0 ? parentNames.join(' ') + ' ' : '';
35
+ const visibleArgTokens = registeredArgs
36
+ .filter((a) => !isHidden(a))
37
+ .map((a) => {
38
+ const n = a.name() + (a.variadic ? '...' : '');
39
+ return a.required ? `<${n}>` : `[${n}]`;
40
+ })
41
+ .join(' ');
42
+ // commander always exposes -h/--help, so every command effectively has options.
43
+ // Order matches commander's default: name [options] <args> [command].
44
+ const argsToken = visibleArgTokens ? ` ${visibleArgTokens}` : '';
45
+ const commandToken = cmd.commands.length > 0 ? ' [command]' : '';
46
+ const usageLine = `${parentPrefix}${cmd.name()} [options]${argsToken}${commandToken}`;
47
+ let output = [`Usage: ${usageLine}`, ''];
27
48
  const commandDescription = helper.commandDescription(cmd);
28
49
  if (commandDescription.length > 0) {
29
50
  output = output.concat([helper.wrap(commandDescription, helpWidth, 0), '']);
30
51
  }
31
- const argumentList = helper.visibleArguments(cmd).map((argument) => {
52
+ const argumentList = helper
53
+ .visibleArguments(cmd)
54
+ .filter((a) => !isHidden(a))
55
+ .map((argument) => {
32
56
  return formatItem(helper.argumentTerm(argument), helper.argumentDescription(argument));
33
57
  });
34
58
  if (argumentList.length > 0) {
@@ -36,9 +60,12 @@ function formatHelpCommandsFirst(cmd, helper) {
36
60
  }
37
61
  const visibleCommands = helper.visibleCommands(cmd);
38
62
  const subcommandTermNoAlias = (sub) => {
39
- // Mirror commander's default subcommandTerm but drop the |alias suffix.
63
+ // Mirror commander's default subcommandTerm but drop the |alias suffix and
64
+ // skip arguments marked as hidden (Argument#hideHelp()), so deprecation /
65
+ // compatibility slots don't pollute the usage line.
40
66
  const argList = sub.registeredArguments ?? [];
41
67
  const args = argList
68
+ .filter((a) => !a.hidden)
42
69
  .map((a) => {
43
70
  const n = a.name() + (a.variadic ? '...' : '');
44
71
  return a.required ? `<${n}>` : `[${n}]`;
@@ -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
  }
@@ -442,7 +442,18 @@ export interface BrowserProfileConfig {
442
442
  * Only consulted when `electron` is true.
443
443
  */
444
444
  targetFilter?: string;
445
- endpoints: string[];
445
+ /**
446
+ * Endpoint presets. Accepts two shapes for backward compatibility:
447
+ * - Legacy: `string[]` of CDP URLs; first entry is the default.
448
+ * - New: `{ [presetName]: { target, binary?, targetFilter? } }`.
449
+ */
450
+ endpoints: string[] | Record<string, {
451
+ target: string;
452
+ binary?: string;
453
+ targetFilter?: string;
454
+ }>;
455
+ /** Preset name to use when `--endpoint` is not passed to `start`. */
456
+ defaultEndpoint?: string;
446
457
  chrome?: {
447
458
  headless?: boolean;
448
459
  args?: string[];
@@ -452,6 +463,10 @@ export interface BrowserProfileConfig {
452
463
  width: number;
453
464
  height: number;
454
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;
455
470
  }
456
471
  /** Options controlling which agents and resources are synced during `agents pull` / `agents use`. */
457
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.3",
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",