@phnx-labs/agents-cli 1.20.7 → 1.20.9

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/dist/commands/computer-actions.d.ts +19 -0
  4. package/dist/commands/computer-actions.js +159 -1
  5. package/dist/commands/computer.js +2 -2
  6. package/dist/commands/daemon.js +6 -6
  7. package/dist/commands/import.js +3 -6
  8. package/dist/commands/inspect.js +17 -8
  9. package/dist/commands/models.js +2 -1
  10. package/dist/commands/plugins.js +3 -2
  11. package/dist/commands/refresh-rules.js +4 -4
  12. package/dist/commands/routines.js +8 -7
  13. package/dist/commands/sessions.js +17 -2
  14. package/dist/commands/setup.js +2 -2
  15. package/dist/commands/subagents.js +2 -1
  16. package/dist/commands/usage.js +11 -3
  17. package/dist/commands/versions.js +2 -2
  18. package/dist/index.js +69 -47
  19. package/dist/lib/agents.d.ts +18 -1
  20. package/dist/lib/agents.js +89 -23
  21. package/dist/lib/browser/chrome.d.ts +4 -3
  22. package/dist/lib/browser/chrome.js +87 -12
  23. package/dist/lib/browser/ipc.js +59 -13
  24. package/dist/lib/computer-rpc.d.ts +2 -0
  25. package/dist/lib/computer-rpc.js +21 -1
  26. package/dist/lib/daemon.js +20 -8
  27. package/dist/lib/fs-walk.d.ts +7 -1
  28. package/dist/lib/fs-walk.js +45 -11
  29. package/dist/lib/git.js +5 -2
  30. package/dist/lib/log-follow.d.ts +7 -0
  31. package/dist/lib/log-follow.js +65 -0
  32. package/dist/lib/platform/index.d.ts +1 -0
  33. package/dist/lib/platform/index.js +1 -0
  34. package/dist/lib/platform/ipc.d.ts +11 -0
  35. package/dist/lib/platform/ipc.js +21 -0
  36. package/dist/lib/platform/paths.d.ts +7 -0
  37. package/dist/lib/platform/paths.js +9 -0
  38. package/dist/lib/platform/process.d.ts +9 -1
  39. package/dist/lib/platform/process.js +27 -0
  40. package/dist/lib/plugins.js +5 -3
  41. package/dist/lib/refresh.js +2 -2
  42. package/dist/lib/self-update.d.ts +86 -0
  43. package/dist/lib/self-update.js +178 -0
  44. package/dist/lib/shims.d.ts +13 -8
  45. package/dist/lib/shims.js +46 -11
  46. package/dist/lib/versions.js +3 -3
  47. package/package.json +1 -1
  48. package/scripts/postinstall.js +36 -26
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
23
  const packageJsonPath = path.join(__dirname, '..', 'package.json');
24
24
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
25
25
  const VERSION = packageJson.version;
26
- const NPM_PACKAGE_NAME = '@phnx-labs/agents-cli';
26
+ import { NPM_PACKAGE_NAME, deriveGlobalPrefix, installPackageIntoPrefix, verifyInstalledVersion, refreshAliasShims, } from './lib/self-update.js';
27
27
  // Detect dev/working-tree builds and default the noisy startup steps off.
28
28
  // Three cases trip this:
29
29
  // 1. Dev install (scripts/install.sh) — package.json version stamped 0.0.0-dev.<sha>
@@ -268,17 +268,52 @@ async function showWhatsNew(fromVersion, toVersion) {
268
268
  }
269
269
  }
270
270
  const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
271
- import { getUpdateCheckPath, getMigratedSentinelPath, getUserAgentsDir } from './lib/state.js';
271
+ import { getUpdateCheckPath, getMigratedSentinelPath, getUserAgentsDir, getRuntimeStateDir } from './lib/state.js';
272
+ import { readUpdateCache, saveUpdateCheck, dismissUpdateVersion, shouldPromptUpgrade, findAgentsCliInstalls, } from './lib/self-update.js';
272
273
  const UPDATE_CHECK_FILE = getUpdateCheckPath();
273
- /** Read the cached update-check state from disk. Returns null if the file is missing or corrupt. */
274
- function readUpdateCache() {
274
+ /**
275
+ * Warn once when PATH resolves `agents` to a different agents-cli install
276
+ * than the copy that is currently running (or to several). Divergent installs
277
+ * are how self-updates "succeed" without changing the command the user types.
278
+ * The warning re-fires only when the set of install roots changes; dev builds
279
+ * (0.0.0-dev) are ignored because side-by-side dev installs are a supported
280
+ * workflow.
281
+ */
282
+ function maybeWarnMultiInstall() {
283
+ const sentinel = path.join(getRuntimeStateDir(), 'multi-install-warned');
284
+ const runningRoot = path.resolve(__dirname, '..');
285
+ const byRoot = new Map();
286
+ byRoot.set(runningRoot, { version: VERSION, note: 'running' });
287
+ for (const install of findAgentsCliInstalls(process.env.PATH || '')) {
288
+ if (install.version.startsWith('0.0.0-dev'))
289
+ continue;
290
+ if (!byRoot.has(install.packageRoot)) {
291
+ byRoot.set(install.packageRoot, { version: install.version, note: `agents on PATH: ${install.binPath}` });
292
+ }
293
+ }
294
+ if (byRoot.size < 2) {
295
+ try {
296
+ fs.unlinkSync(sentinel);
297
+ }
298
+ catch { /* nothing recorded */ }
299
+ return;
300
+ }
301
+ const key = [...byRoot.keys()].sort().join('\n');
275
302
  try {
276
- return JSON.parse(fs.readFileSync(UPDATE_CHECK_FILE, 'utf-8'));
303
+ if (fs.readFileSync(sentinel, 'utf-8') === key)
304
+ return;
277
305
  }
278
- catch {
279
- /* cache file missing or corrupt */
280
- return null;
306
+ catch { /* not warned for this set yet */ }
307
+ console.error(chalk.yellow('Multiple agents-cli installs detected:'));
308
+ for (const [root, info] of byRoot) {
309
+ console.error(chalk.gray(` ${root} ${info.version} (${info.note})`));
310
+ }
311
+ console.error(chalk.gray('Upgrades apply to the running copy. Remove a stale copy with: npm uninstall -g --prefix <prefix> @phnx-labs/agents-cli'));
312
+ try {
313
+ fs.mkdirSync(path.dirname(sentinel), { recursive: true });
314
+ fs.writeFileSync(sentinel, key);
281
315
  }
316
+ catch { /* best-effort; worst case the warning repeats */ }
282
317
  }
283
318
  /** Determine whether enough time has elapsed since the last registry fetch. */
284
319
  function shouldFetchLatest(cache) {
@@ -286,18 +321,6 @@ function shouldFetchLatest(cache) {
286
321
  return true;
287
322
  return Date.now() - cache.lastCheck > UPDATE_CHECK_INTERVAL_MS;
288
323
  }
289
- /** Persist the latest known version and current timestamp to the update-check cache. */
290
- function saveUpdateCheck(latestVersion) {
291
- try {
292
- const dir = path.dirname(UPDATE_CHECK_FILE);
293
- if (!fs.existsSync(dir))
294
- fs.mkdirSync(dir, { recursive: true });
295
- fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify({ lastCheck: Date.now(), latestVersion }));
296
- }
297
- catch {
298
- /* best-effort cache update */
299
- }
300
- }
301
324
  /** Fetch the exact latest npm version plus its registry integrity hash. */
302
325
  async function fetchNpmPackageMetadata(versionOrTag = 'latest', timeoutMs = 5000) {
303
326
  const response = await fetch(`https://registry.npmjs.org/${NPM_PACKAGE_NAME}/${versionOrTag}`, {
@@ -320,12 +343,11 @@ function printResolvedPackage(metadata) {
320
343
  console.log(chalk.gray(`Integrity: ${metadata.integrity}`));
321
344
  }
322
345
  async function installResolvedPackage(metadata) {
323
- const { execFile } = await import('child_process');
324
- const { promisify } = await import('util');
325
- const execFileAsync = promisify(execFile);
326
- const installArgs = ['install', '-g', '@phnx-labs/agents-cli', '--ignore-scripts'];
327
- installArgs[2] = `${NPM_PACKAGE_NAME}@${metadata.version}`;
328
- await execFileAsync('npm', installArgs);
346
+ const packageRoot = path.resolve(__dirname, '..');
347
+ const prefix = deriveGlobalPrefix(packageRoot);
348
+ await installPackageIntoPrefix(`${NPM_PACKAGE_NAME}@${metadata.version}`, prefix);
349
+ verifyInstalledVersion(packageRoot, metadata.version);
350
+ refreshAliasShims(packageRoot);
329
351
  }
330
352
  /** Present an interactive upgrade prompt (TTY) or a one-line hint (non-TTY). */
331
353
  async function promptUpgrade(latestVersion) {
@@ -342,19 +364,7 @@ async function promptUpgrade(latestVersion) {
342
364
  ],
343
365
  });
344
366
  if (answer === 'dismiss') {
345
- try {
346
- const dir = path.dirname(UPDATE_CHECK_FILE);
347
- if (!fs.existsSync(dir))
348
- fs.mkdirSync(dir, { recursive: true });
349
- const existing = readUpdateCache();
350
- fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify({
351
- ...existing,
352
- lastCheck: existing?.lastCheck ?? Date.now(),
353
- latestVersion,
354
- dismissed: latestVersion,
355
- }));
356
- }
357
- catch { /* best-effort */ }
367
+ dismissUpdateVersion(UPDATE_CHECK_FILE, latestVersion);
358
368
  return;
359
369
  }
360
370
  if (answer === 'now') {
@@ -362,6 +372,10 @@ async function promptUpgrade(latestVersion) {
362
372
  let spinner = ora('Resolving package metadata...').start();
363
373
  try {
364
374
  const metadata = await fetchNpmPackageMetadata();
375
+ // The prompt showed the cached latest, which can lag the registry (the
376
+ // 24h window) — sync the cache to what was actually resolved so later
377
+ // prompts and the install agree on the same version.
378
+ saveUpdateCheck(UPDATE_CHECK_FILE, metadata.version);
365
379
  spinner.succeed(`Resolved ${NPM_PACKAGE_NAME}@${metadata.version}`);
366
380
  printResolvedPackage(metadata);
367
381
  const approved = await confirm({
@@ -377,15 +391,20 @@ async function promptUpgrade(latestVersion) {
377
391
  spinner.succeed(`Upgraded to ${metadata.version}`);
378
392
  await showWhatsNew(VERSION, metadata.version);
379
393
  console.log();
380
- // Re-exec with new version and exit
381
- const result = spawnSync('agents', process.argv.slice(2), {
394
+ // Re-exec the verified install's entrypoint and exit. PATH lookup of
395
+ // `agents` could resolve a different copy (dev build, another prefix)
396
+ // than the one that was just upgraded.
397
+ const entrypoint = path.resolve(__dirname, '..', 'dist', 'index.js');
398
+ const result = spawnSync(process.execPath, [entrypoint, ...process.argv.slice(2)], {
382
399
  stdio: 'inherit',
383
400
  shell: false,
384
401
  });
385
402
  process.exit(result.status ?? 0);
386
403
  }
387
- catch {
388
- spinner.fail('Upgrade failed');
404
+ catch (err) {
405
+ if (isPromptCancelled(err))
406
+ return;
407
+ spinner.fail(`Upgrade failed: ${err instanceof Error ? err.message : String(err)}`);
389
408
  console.log(chalk.gray('Run manually: agents upgrade --yes'));
390
409
  }
391
410
  console.log();
@@ -405,7 +424,7 @@ function refreshUpdateCacheInBackground() {
405
424
  .then((response) => (response.ok ? response.json() : null))
406
425
  .then((data) => {
407
426
  if (data && typeof data.version === 'string') {
408
- saveUpdateCheck(data.version);
427
+ saveUpdateCheck(UPDATE_CHECK_FILE, data.version);
409
428
  }
410
429
  })
411
430
  .catch(() => {
@@ -416,7 +435,8 @@ function refreshUpdateCacheInBackground() {
416
435
  async function checkForUpdates() {
417
436
  if (process.env.AGENTS_CLI_DISABLE_AUTO_UPDATE)
418
437
  return;
419
- const cache = readUpdateCache();
438
+ maybeWarnMultiInstall();
439
+ const cache = readUpdateCache(UPDATE_CHECK_FILE);
420
440
  // Kick off network refresh in background if stale. Does not block.
421
441
  if (shouldFetchLatest(cache)) {
422
442
  refreshUpdateCacheInBackground();
@@ -424,7 +444,7 @@ async function checkForUpdates() {
424
444
  // Prompt based on current cache (may be from a previous run's background refresh).
425
445
  // Skip if the user dismissed this exact version — they'll be prompted again when
426
446
  // a newer version appears.
427
- if (cache?.latestVersion && cache.latestVersion !== VERSION && compareVersions(cache.latestVersion, VERSION) > 0 && cache.latestVersion !== cache.dismissed) {
447
+ if (shouldPromptUpgrade(cache, VERSION)) {
428
448
  try {
429
449
  await promptUpgrade(cache.latestVersion);
430
450
  }
@@ -699,7 +719,9 @@ program
699
719
  }
700
720
  }
701
721
  catch (err) {
702
- spinner.fail('Upgrade failed');
722
+ if (isPromptCancelled(err))
723
+ return;
724
+ spinner.fail(`Upgrade failed: ${err instanceof Error ? err.message : String(err)}`);
703
725
  console.log(chalk.gray(`Run manually: agents upgrade ${version ? version + ' ' : ''}--yes`));
704
726
  }
705
727
  });
@@ -104,6 +104,17 @@ export declare function getAccountEmail(agentId: AgentId, home?: string): Promis
104
104
  * the agent's local auth/config files. Supports Claude, Codex, and Gemini.
105
105
  */
106
106
  export declare function getAccountInfo(agentId: AgentId, home?: string): Promise<AccountInfo>;
107
+ /**
108
+ * Determine when the agent was last used by checking session file mtimes,
109
+ * falling back to config mtime.
110
+ *
111
+ * The session walk stats every transcript under the home's session dir —
112
+ * thousands of files on long-lived installs — and `agents run` rotation calls
113
+ * this once per installed version on every launch. The walk result is cached
114
+ * on disk for a short window so back-to-back launches skip it entirely.
115
+ * Cache read/write is best-effort: any failure falls back to walking.
116
+ */
117
+ export declare function resolveLastActive(agentId: AgentId, base: string, configPath?: string, cachePath?: string, now?: Date): Date | null;
107
118
  /**
108
119
  * Quick count of session files for an agent (without full DB scan).
109
120
  * Used during init to show approximate session count to user.
@@ -190,7 +201,13 @@ export declare function listInstalledMcpsWithScope(agentId: AgentId, cwd?: strin
190
201
  }): InstalledMcp[];
191
202
  /** Map of agent name aliases and shorthand identifiers to canonical AgentId values. */
192
203
  export declare const AGENT_NAME_ALIASES: Record<string, AgentId>;
193
- /** Resolve a user-provided agent name (alias, shorthand, or canonical) to its AgentId. */
204
+ /**
205
+ * Resolve a user-provided agent name (alias, shorthand, or canonical) to its AgentId.
206
+ * Tolerates a single typo (insertion/deletion/substitution/transposition) against
207
+ * canonical ids and aliases — `cladue` -> claude, `kim` -> kimi, `codx` -> codex —
208
+ * but only when the correction is unambiguous (all distance-1 candidates agree on
209
+ * one agent). Two-letter shorthands are excluded as fuzzy candidates.
210
+ */
194
211
  export declare function resolveAgentName(input: string): AgentId | null;
195
212
  /** Check whether the input string matches any known agent name or alias. */
196
213
  export declare function isAgentName(input: string): boolean;
@@ -15,8 +15,9 @@ import * as path from 'path';
15
15
  import * as os from 'os';
16
16
  import * as TOML from 'smol-toml';
17
17
  import chalk from 'chalk';
18
- import { walkForFiles } from './fs-walk.js';
19
- import { getVersionsDir, getShimsDir, getCliVersionCachePath } from './state.js';
18
+ import { latestFileMtimeMs } from './fs-walk.js';
19
+ import { damerauLevenshtein } from './fuzzy.js';
20
+ import { getCacheDir, getVersionsDir, getShimsDir, getCliVersionCachePath } from './state.js';
20
21
  import { resolveVersion, getVersionHomePath, getBinaryPath } from './versions.js';
21
22
  const execFileAsync = promisify(execFile);
22
23
  const HOME = os.homedir();
@@ -869,14 +870,50 @@ export async function getAccountInfo(agentId, home) {
869
870
  return { ...empty, lastActive };
870
871
  }
871
872
  }
872
- /** Determine when the agent was last used by checking session file mtimes, falling back to config mtime. */
873
- function resolveLastActive(agentId, base, configPath) {
873
+ // Fresh window for the cached session walk. Matches USAGE_CACHE_FRESH_MS in
874
+ // usage.ts so a launch storm reuses both probes for the same period.
875
+ const LAST_ACTIVE_CACHE_FRESH_MS = 2 * 60 * 1000;
876
+ const getLastActiveCachePath = () => path.join(getCacheDir(), 'last-active.json');
877
+ /**
878
+ * Determine when the agent was last used by checking session file mtimes,
879
+ * falling back to config mtime.
880
+ *
881
+ * The session walk stats every transcript under the home's session dir —
882
+ * thousands of files on long-lived installs — and `agents run` rotation calls
883
+ * this once per installed version on every launch. The walk result is cached
884
+ * on disk for a short window so back-to-back launches skip it entirely.
885
+ * Cache read/write is best-effort: any failure falls back to walking.
886
+ */
887
+ export function resolveLastActive(agentId, base, configPath, cachePath = getLastActiveCachePath(), now = new Date()) {
874
888
  const sessionDir = getSessionDir(agentId, base);
875
889
  const sessionExt = getSessionExtension(agentId);
876
890
  if (sessionDir && sessionExt) {
877
- const latestSession = getLatestFileMtime(sessionDir, sessionExt);
878
- if (latestSession) {
879
- return latestSession;
891
+ const key = `${agentId}:${base}`;
892
+ const cache = readLastActiveCacheFile(cachePath);
893
+ const entry = cache[key];
894
+ const fresh = entry &&
895
+ typeof entry.computedAt === 'number' &&
896
+ now.getTime() - entry.computedAt >= 0 &&
897
+ now.getTime() - entry.computedAt < LAST_ACTIVE_CACHE_FRESH_MS;
898
+ if (fresh) {
899
+ if (entry.mtimeMs !== null)
900
+ return new Date(entry.mtimeMs);
901
+ // Fresh entry with no sessions: fall through to the config mtime below.
902
+ }
903
+ else {
904
+ const mtimeMs = latestFileMtimeMs(sessionDir, sessionExt);
905
+ cache[key] = { mtimeMs, computedAt: now.getTime() };
906
+ // Stale entries are never served, so drop them on write — keeps homes
907
+ // that no longer exist (removed versions, test temp dirs) from
908
+ // accumulating in the file.
909
+ for (const [k, v] of Object.entries(cache)) {
910
+ if (k !== key && !(typeof v?.computedAt === 'number' && now.getTime() - v.computedAt < LAST_ACTIVE_CACHE_FRESH_MS)) {
911
+ delete cache[k];
912
+ }
913
+ }
914
+ writeLastActiveCacheFile(cache, cachePath);
915
+ if (mtimeMs !== null)
916
+ return new Date(mtimeMs);
880
917
  }
881
918
  }
882
919
  if (!configPath)
@@ -888,6 +925,28 @@ function resolveLastActive(agentId, base, configPath) {
888
925
  return null;
889
926
  }
890
927
  }
928
+ /** Read the entire last-active cache file. Missing or corrupt file reads as empty. */
929
+ function readLastActiveCacheFile(cachePath) {
930
+ if (!fs.existsSync(cachePath))
931
+ return {};
932
+ try {
933
+ const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
934
+ return parsed && typeof parsed === 'object' ? parsed : {};
935
+ }
936
+ catch {
937
+ return {};
938
+ }
939
+ }
940
+ /** Write the entire last-active cache. Best-effort; a failed write just means the next call walks again. */
941
+ function writeLastActiveCacheFile(cache, cachePath) {
942
+ try {
943
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
944
+ fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf-8');
945
+ }
946
+ catch {
947
+ /* best-effort */
948
+ }
949
+ }
891
950
  /** Return the root directory where the agent stores session files, or null if unknown. */
892
951
  function getSessionDir(agentId, base) {
893
952
  switch (agentId) {
@@ -951,20 +1010,6 @@ export function countSessionFiles(agentId) {
951
1010
  walk(sessionDir);
952
1011
  return count;
953
1012
  }
954
- /** Walk a directory for files matching the extension and return the mtime of the most recent one. */
955
- function getLatestFileMtime(dir, ext) {
956
- if (!fs.existsSync(dir))
957
- return null;
958
- const [latest] = walkForFiles(dir, ext, 1);
959
- if (!latest)
960
- return null;
961
- try {
962
- return fs.statSync(latest).mtime;
963
- }
964
- catch {
965
- return null;
966
- }
967
- }
968
1013
  /** Decode the payload section of a JWT token without verifying its signature. */
969
1014
  function decodeJwtPayload(token) {
970
1015
  const payload = token.split('.')[1];
@@ -1515,10 +1560,31 @@ export const AGENT_NAME_ALIASES = {
1515
1560
  'grok-build': 'grok',
1516
1561
  'xai-grok': 'grok',
1517
1562
  gk: 'grok',
1563
+ kimi: 'kimi',
1564
+ 'kimi-code': 'kimi',
1518
1565
  };
1519
- /** Resolve a user-provided agent name (alias, shorthand, or canonical) to its AgentId. */
1566
+ /**
1567
+ * Resolve a user-provided agent name (alias, shorthand, or canonical) to its AgentId.
1568
+ * Tolerates a single typo (insertion/deletion/substitution/transposition) against
1569
+ * canonical ids and aliases — `cladue` -> claude, `kim` -> kimi, `codx` -> codex —
1570
+ * but only when the correction is unambiguous (all distance-1 candidates agree on
1571
+ * one agent). Two-letter shorthands are excluded as fuzzy candidates.
1572
+ */
1520
1573
  export function resolveAgentName(input) {
1521
- return AGENT_NAME_ALIASES[input.toLowerCase()] || null;
1574
+ const lower = input.toLowerCase();
1575
+ const exact = AGENT_NAME_ALIASES[lower] ?? (AGENTS[lower] ? lower : null);
1576
+ if (exact || lower.length < 3)
1577
+ return exact;
1578
+ const hits = new Set();
1579
+ for (const id of ALL_AGENT_IDS) {
1580
+ if (damerauLevenshtein(lower, id) === 1)
1581
+ hits.add(id);
1582
+ }
1583
+ for (const [key, id] of Object.entries(AGENT_NAME_ALIASES)) {
1584
+ if (key.length >= 3 && damerauLevenshtein(lower, key) === 1)
1585
+ hits.add(id);
1586
+ }
1587
+ return hits.size === 1 ? hits.values().next().value : null;
1522
1588
  }
1523
1589
  /** Check whether the input string matches any known agent name or alias. */
1524
1590
  export function isAgentName(input) {
@@ -42,8 +42,9 @@ export interface PortOccupant {
42
42
  command: string;
43
43
  }
44
44
  /**
45
- * Identify the process listening on a TCP port via lsof. Returns null when nothing is bound.
46
- * Used for clearer error messages when a profile's configured port is taken by a non-debug
47
- * process (e.g. Comet running without --remote-debugging-port).
45
+ * Identify the process listening on a TCP port. Returns null when nothing is bound.
46
+ * Used for clearer error messages when a profile's configured port is taken by a
47
+ * non-debug process (e.g. Comet running without --remote-debugging-port).
48
+ * `lsof` on POSIX; `netstat -ano` + `tasklist` on Windows.
48
49
  */
49
50
  export declare function getPortOccupant(port: number): PortOccupant | null;
@@ -6,6 +6,13 @@ import { getProfileRuntimeDir } from './profiles.js';
6
6
  import { discoverBrowserWsUrl, registerPipeTransport } from './cdp.js';
7
7
  import { readAndResolveBundleEnv, bundleExists } from '../secrets/bundles.js';
8
8
  import { writeProfileRuntime, readProfileRuntime } from './runtime-state.js';
9
+ // Windows install roots. Resolve from the environment (fall back to the usual
10
+ // defaults) so per-user installs under %LOCALAPPDATA% and 64-bit Program Files
11
+ // are found, not just the hardcoded x86 path. Only the `win32` entries below use
12
+ // these; on other platforms they compute unused placeholder strings.
13
+ const WIN_PROGRAMFILES = process.env.ProgramFiles || 'C:\\Program Files';
14
+ const WIN_PROGRAMFILES_X86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
15
+ const WIN_LOCALAPPDATA = process.env.LOCALAPPDATA || `${os.homedir()}\\AppData\\Local`;
9
16
  const BROWSER_PATHS = {
10
17
  darwin: {
11
18
  chrome: [
@@ -28,16 +35,22 @@ const BROWSER_PATHS = {
28
35
  },
29
36
  win32: {
30
37
  chrome: [
31
- 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
32
- 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
38
+ `${WIN_PROGRAMFILES}\\Google\\Chrome\\Application\\chrome.exe`,
39
+ `${WIN_PROGRAMFILES_X86}\\Google\\Chrome\\Application\\chrome.exe`,
40
+ `${WIN_LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
33
41
  ],
34
42
  comet: [],
35
- chromium: [],
43
+ chromium: [
44
+ `${WIN_LOCALAPPDATA}\\Chromium\\Application\\chrome.exe`,
45
+ ],
36
46
  brave: [
37
- 'C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe',
47
+ `${WIN_PROGRAMFILES}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe`,
48
+ `${WIN_PROGRAMFILES_X86}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe`,
49
+ `${WIN_LOCALAPPDATA}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe`,
38
50
  ],
39
51
  edge: [
40
- 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
52
+ `${WIN_PROGRAMFILES}\\Microsoft\\Edge\\Application\\msedge.exe`,
53
+ `${WIN_PROGRAMFILES_X86}\\Microsoft\\Edge\\Application\\msedge.exe`,
41
54
  ],
42
55
  custom: [],
43
56
  },
@@ -308,25 +321,87 @@ function seedDefaultProfileName(userDataDir, profileName) {
308
321
  function sleep(ms) {
309
322
  return new Promise((resolve) => setTimeout(resolve, ms));
310
323
  }
324
+ /**
325
+ * Is a TCP port currently bound? `lsof` on POSIX, `netstat -ano` on Windows
326
+ * (lsof doesn't exist there). Returns false on any tooling error so port
327
+ * allocation degrades to "assume free" rather than throwing.
328
+ */
329
+ function isPortInUse(port) {
330
+ if (process.platform === 'win32') {
331
+ try {
332
+ const out = execFileSync('netstat', ['-ano', '-p', 'TCP'], {
333
+ encoding: 'utf8',
334
+ stdio: ['ignore', 'pipe', 'ignore'],
335
+ });
336
+ // Lines look like: " TCP 0.0.0.0:9200 0.0.0.0:0 LISTENING 1234"
337
+ return out.split('\n').some((line) => {
338
+ const f = line.trim().split(/\s+/);
339
+ return f[0] === 'TCP' && f[3] === 'LISTENING' && !!f[1]?.endsWith(`:${port}`);
340
+ });
341
+ }
342
+ catch {
343
+ return false;
344
+ }
345
+ }
346
+ try {
347
+ execFileSync('lsof', ['-i', `:${port}`], { stdio: 'ignore' });
348
+ return true; // lsof found a binding
349
+ }
350
+ catch {
351
+ return false; // nothing on the port
352
+ }
353
+ }
311
354
  export function allocatePort() {
312
355
  const base = 9200;
313
356
  const max = 9300;
314
357
  for (let port = base; port < max; port++) {
315
- try {
316
- execFileSync('lsof', ['-i', `:${port}`], { stdio: 'ignore' });
317
- }
318
- catch {
358
+ if (!isPortInUse(port)) {
319
359
  return port;
320
360
  }
321
361
  }
322
362
  throw new Error('No available ports in range 9200-9300');
323
363
  }
324
364
  /**
325
- * Identify the process listening on a TCP port via lsof. Returns null when nothing is bound.
326
- * Used for clearer error messages when a profile's configured port is taken by a non-debug
327
- * process (e.g. Comet running without --remote-debugging-port).
365
+ * Identify the process listening on a TCP port. Returns null when nothing is bound.
366
+ * Used for clearer error messages when a profile's configured port is taken by a
367
+ * non-debug process (e.g. Comet running without --remote-debugging-port).
368
+ * `lsof` on POSIX; `netstat -ano` + `tasklist` on Windows.
328
369
  */
329
370
  export function getPortOccupant(port) {
371
+ if (process.platform === 'win32') {
372
+ try {
373
+ const out = execFileSync('netstat', ['-ano', '-p', 'TCP'], {
374
+ encoding: 'utf8',
375
+ stdio: ['ignore', 'pipe', 'ignore'],
376
+ });
377
+ let pid = 0;
378
+ for (const line of out.split('\n')) {
379
+ const f = line.trim().split(/\s+/);
380
+ if (f[0] === 'TCP' && f[3] === 'LISTENING' && f[1]?.endsWith(`:${port}`)) {
381
+ pid = parseInt(f[4], 10) || 0;
382
+ break;
383
+ }
384
+ }
385
+ if (!pid)
386
+ return null;
387
+ let command = 'unknown';
388
+ try {
389
+ // tasklist CSV row: "image.exe","1234","Console","1","12,345 K"
390
+ const tl = execFileSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
391
+ encoding: 'utf8',
392
+ stdio: ['ignore', 'pipe', 'ignore'],
393
+ });
394
+ const m = tl.match(/^"([^"]+)"/);
395
+ if (m)
396
+ command = m[1];
397
+ }
398
+ catch { /* keep 'unknown' */ }
399
+ return { pid, command };
400
+ }
401
+ catch {
402
+ return null;
403
+ }
404
+ }
330
405
  try {
331
406
  const out = execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpcn'], {
332
407
  encoding: 'utf8',
@@ -1,6 +1,7 @@
1
1
  import * as net from 'net';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
+ import { IS_WINDOWS, ipcEndpoint } from '../platform/index.js';
4
5
  import { getHelpersDir } from '../state.js';
5
6
  import { startDaemon } from '../daemon.js';
6
7
  import { getCliVersion } from '../version.js';
@@ -22,10 +23,38 @@ export function formatBrowserDaemonNotRunningError() {
22
23
  export function getSocketPath() {
23
24
  return path.join(getHelpersDir(), 'browser', SOCKET_NAME);
24
25
  }
26
+ /**
27
+ * The address the daemon actually listens on / clients connect to: the unix
28
+ * socket file on POSIX, a `\\.\pipe\` named pipe on Windows. `getSocketPath`
29
+ * stays the canonical key (and the POSIX socket path); on Windows it's only used
30
+ * to derive a stable pipe name, never touched on disk.
31
+ */
32
+ function getIpcEndpoint() {
33
+ return ipcEndpoint(getSocketPath());
34
+ }
35
+ /** Can we open a connection to the daemon right now? Used on Windows where a
36
+ * named pipe can't be probed with fs.existsSync. Resolves false on any error. */
37
+ function probeDaemon(endpoint, timeoutMs = 500) {
38
+ return new Promise((resolve) => {
39
+ const sock = net.createConnection(endpoint);
40
+ let done = false;
41
+ const finish = (ok) => { if (done)
42
+ return; done = true; sock.destroy(); resolve(ok); };
43
+ const timer = setTimeout(() => finish(false), timeoutMs);
44
+ sock.on('connect', () => { clearTimeout(timer); finish(true); });
45
+ sock.on('error', () => { clearTimeout(timer); finish(false); });
46
+ });
47
+ }
48
+ /** Is the daemon reachable? existsSync probe on POSIX, connect probe on Windows. */
49
+ async function isDaemonReachable() {
50
+ if (IS_WINDOWS)
51
+ return probeDaemon(getIpcEndpoint());
52
+ return fs.existsSync(getSocketPath());
53
+ }
25
54
  async function waitForSocket(socketPath, timeoutMs) {
26
55
  const deadline = Date.now() + timeoutMs;
27
56
  while (Date.now() < deadline) {
28
- if (fs.existsSync(socketPath))
57
+ if (IS_WINDOWS ? await probeDaemon(getIpcEndpoint()) : fs.existsSync(socketPath))
29
58
  return;
30
59
  await new Promise((resolve) => setTimeout(resolve, 100));
31
60
  }
@@ -39,11 +68,16 @@ export class BrowserIPCServer {
39
68
  }
40
69
  async start() {
41
70
  const socketPath = getSocketPath();
71
+ const endpoint = getIpcEndpoint();
42
72
  const socketDir = path.dirname(socketPath);
43
73
  fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });
44
- fs.chmodSync(socketDir, 0o700);
45
- if (fs.existsSync(socketPath)) {
46
- fs.unlinkSync(socketPath);
74
+ if (!IS_WINDOWS) {
75
+ fs.chmodSync(socketDir, 0o700);
76
+ // Remove a stale unix socket from a prior crash. (Named pipes are not
77
+ // filesystem objects and vanish with their owning process.)
78
+ if (fs.existsSync(socketPath)) {
79
+ fs.unlinkSync(socketPath);
80
+ }
47
81
  }
48
82
  this.server = net.createServer((socket) => {
49
83
  let buffer = '';
@@ -70,6 +104,13 @@ export class BrowserIPCServer {
70
104
  });
71
105
  });
72
106
  return new Promise((resolve, reject) => {
107
+ if (IS_WINDOWS) {
108
+ // Windows named pipe: no umask/chmod — filesystem perms don't apply and
109
+ // pipe ACLs default to the creating user.
110
+ this.server.listen(endpoint, () => resolve());
111
+ this.server.on('error', (err) => reject(err));
112
+ return;
113
+ }
73
114
  // Lock down the browser socket dir before opening the socket; on macOS
74
115
  // the parent dir is the real local-user boundary for AF_UNIX sockets.
75
116
  const prevUmask = process.umask(0o077);
@@ -103,9 +144,11 @@ export class BrowserIPCServer {
103
144
  this.server.close();
104
145
  this.server = null;
105
146
  }
106
- const socketPath = getSocketPath();
107
- if (fs.existsSync(socketPath)) {
108
- fs.unlinkSync(socketPath);
147
+ if (!IS_WINDOWS) {
148
+ const socketPath = getSocketPath();
149
+ if (fs.existsSync(socketPath)) {
150
+ fs.unlinkSync(socketPath);
151
+ }
109
152
  }
110
153
  await this.service.shutdown();
111
154
  }
@@ -448,24 +491,27 @@ export async function sendIPCRequest(request, opts = {}) {
448
491
  }
449
492
  async function sendRawIPCRequest(request, opts = {}) {
450
493
  const socketPath = getSocketPath();
494
+ const endpoint = getIpcEndpoint();
451
495
  const autoStartDaemon = opts.autoStartDaemon ?? true;
452
- if (!fs.existsSync(socketPath)) {
496
+ if (!(await isDaemonReachable())) {
453
497
  if (!autoStartDaemon) {
454
498
  throw new BrowserDaemonNotRunningError();
455
499
  }
456
- await fs.promises.mkdir(path.dirname(socketPath), { recursive: true, mode: 0o700 });
457
- await fs.promises.chmod(path.dirname(socketPath), 0o700);
500
+ if (!IS_WINDOWS) {
501
+ await fs.promises.mkdir(path.dirname(socketPath), { recursive: true, mode: 0o700 });
502
+ await fs.promises.chmod(path.dirname(socketPath), 0o700);
503
+ }
458
504
  startDaemon();
459
- if (!fs.existsSync(socketPath)) {
505
+ if (!(await isDaemonReachable())) {
460
506
  await waitForSocket(socketPath, 6000);
461
507
  }
462
- if (!fs.existsSync(socketPath)) {
508
+ if (!(await isDaemonReachable())) {
463
509
  throw new Error('Failed to start browser daemon');
464
510
  }
465
511
  await new Promise((r) => setTimeout(r, 300));
466
512
  }
467
513
  return new Promise((resolve, reject) => {
468
- const socket = net.createConnection(socketPath);
514
+ const socket = net.createConnection(endpoint);
469
515
  let buffer = '';
470
516
  socket.on('connect', () => {
471
517
  socket.write(JSON.stringify(request) + '\n');