@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.
- package/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/dist/commands/computer-actions.d.ts +19 -0
- package/dist/commands/computer-actions.js +159 -1
- package/dist/commands/computer.js +2 -2
- package/dist/commands/daemon.js +6 -6
- package/dist/commands/import.js +3 -6
- package/dist/commands/inspect.js +17 -8
- package/dist/commands/models.js +2 -1
- package/dist/commands/plugins.js +3 -2
- package/dist/commands/refresh-rules.js +4 -4
- package/dist/commands/routines.js +8 -7
- package/dist/commands/sessions.js +17 -2
- package/dist/commands/setup.js +2 -2
- package/dist/commands/subagents.js +2 -1
- package/dist/commands/usage.js +11 -3
- package/dist/commands/versions.js +2 -2
- package/dist/index.js +69 -47
- package/dist/lib/agents.d.ts +18 -1
- package/dist/lib/agents.js +89 -23
- package/dist/lib/browser/chrome.d.ts +4 -3
- package/dist/lib/browser/chrome.js +87 -12
- package/dist/lib/browser/ipc.js +59 -13
- package/dist/lib/computer-rpc.d.ts +2 -0
- package/dist/lib/computer-rpc.js +21 -1
- package/dist/lib/daemon.js +20 -8
- package/dist/lib/fs-walk.d.ts +7 -1
- package/dist/lib/fs-walk.js +45 -11
- package/dist/lib/git.js +5 -2
- package/dist/lib/log-follow.d.ts +7 -0
- package/dist/lib/log-follow.js +65 -0
- package/dist/lib/platform/index.d.ts +1 -0
- package/dist/lib/platform/index.js +1 -0
- package/dist/lib/platform/ipc.d.ts +11 -0
- package/dist/lib/platform/ipc.js +21 -0
- package/dist/lib/platform/paths.d.ts +7 -0
- package/dist/lib/platform/paths.js +9 -0
- package/dist/lib/platform/process.d.ts +9 -1
- package/dist/lib/platform/process.js +27 -0
- package/dist/lib/plugins.js +5 -3
- package/dist/lib/refresh.js +2 -2
- package/dist/lib/self-update.d.ts +86 -0
- package/dist/lib/self-update.js +178 -0
- package/dist/lib/shims.d.ts +13 -8
- package/dist/lib/shims.js +46 -11
- package/dist/lib/versions.js +3 -3
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
/**
|
|
274
|
-
|
|
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
|
-
|
|
303
|
+
if (fs.readFileSync(sentinel, 'utf-8') === key)
|
|
304
|
+
return;
|
|
277
305
|
}
|
|
278
|
-
catch {
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
});
|
package/dist/lib/agents.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/lib/agents.js
CHANGED
|
@@ -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 {
|
|
19
|
-
import {
|
|
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
|
-
|
|
873
|
-
|
|
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
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
46
|
-
* Used for clearer error messages when a profile's configured port is taken by a
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
326
|
-
* Used for clearer error messages when a profile's configured port is taken by a
|
|
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',
|
package/dist/lib/browser/ipc.js
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
fs.
|
|
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 (!
|
|
496
|
+
if (!(await isDaemonReachable())) {
|
|
453
497
|
if (!autoStartDaemon) {
|
|
454
498
|
throw new BrowserDaemonNotRunningError();
|
|
455
499
|
}
|
|
456
|
-
|
|
457
|
-
|
|
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 (!
|
|
505
|
+
if (!(await isDaemonReachable())) {
|
|
460
506
|
await waitForSocket(socketPath, 6000);
|
|
461
507
|
}
|
|
462
|
-
if (!
|
|
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(
|
|
514
|
+
const socket = net.createConnection(endpoint);
|
|
469
515
|
let buffer = '';
|
|
470
516
|
socket.on('connect', () => {
|
|
471
517
|
socket.write(JSON.stringify(request) + '\n');
|