@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
@@ -21,6 +21,8 @@ export declare function writeComputerPeers(allowedExecPaths: string[]): void;
21
21
  export declare function resolveHelperExec(): string | null;
22
22
  export declare function resolveHelperApp(): string | null;
23
23
  export declare function openComputerClient(): ComputerClient;
24
+ export declare const RPC_TIMEOUT_MS = 30000;
25
+ export declare function resolveRpcTimeoutMs(env: string | undefined): number;
24
26
  export declare function describeTransport(): {
25
27
  kind: 'socket' | 'stdio' | 'none';
26
28
  path: string | null;
@@ -200,6 +200,15 @@ export function openComputerClient() {
200
200
  }
201
201
  return new StdioClient(helperExec);
202
202
  }
203
+ // Per-call RPC timeout. Without it a hung daemon (deadlocked connection
204
+ // queue, stopped process) hangs the CLI forever — the waiter map never
205
+ // settles. 30s clears every daemon-side ceiling (wait caps at 30s,
206
+ // launch_app at 10s, screenshot at 5s). Overridable for slower flows.
207
+ export const RPC_TIMEOUT_MS = 30_000;
208
+ export function resolveRpcTimeoutMs(env) {
209
+ const n = Number(env);
210
+ return Number.isFinite(n) && n > 0 ? n : RPC_TIMEOUT_MS;
211
+ }
203
212
  // Shared waiter map + line parser. Both transports plug their reader into
204
213
  // `handleChunk` and their writer into `send`.
205
214
  class BaseClient {
@@ -241,8 +250,19 @@ class BaseClient {
241
250
  }
242
251
  const id = this.nextId++;
243
252
  const payload = JSON.stringify({ id, method, params: params ?? {} }) + '\n';
253
+ const timeoutMs = resolveRpcTimeoutMs(process.env.COMPUTER_HELPER_RPC_TIMEOUT_MS);
244
254
  return new Promise((resolve) => {
245
- this.waiters.set(id, resolve);
255
+ const timer = setTimeout(() => {
256
+ if (this.waiters.delete(id)) {
257
+ resolve({ id, error: { code: 'rpc_timeout', message: `helper did not respond within ${timeoutMs}ms` } });
258
+ }
259
+ }, timeoutMs);
260
+ // Resolve as an error (never reject) so callers flow through unwrap()
261
+ // uniformly, matching failPending's contract.
262
+ this.waiters.set(id, (r) => {
263
+ clearTimeout(timer);
264
+ resolve(r);
265
+ });
246
266
  this.send(payload);
247
267
  });
248
268
  }
@@ -11,7 +11,7 @@ import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as os from 'os';
13
13
  import { getDaemonDir as getDaemonDirRoot } from './state.js';
14
- import { isAlive } from './platform/index.js';
14
+ import { isAlive, killTree } from './platform/index.js';
15
15
  import { listJobs as listAllJobs } from './routines.js';
16
16
  import { JobScheduler } from './scheduler.js';
17
17
  import { executeJobDetached, monitorRunningJobs } from './runner.js';
@@ -437,17 +437,23 @@ export function stopDaemon() {
437
437
  }
438
438
  const pid = readDaemonPid();
439
439
  if (pid) {
440
- try {
441
- process.kill(pid, 'SIGTERM');
440
+ if (process.platform === 'win32') {
441
+ // Windows has no graceful termination signal — terminate the daemon and
442
+ // its job/browser child tree in one shot (taskkill /T), so stop doesn't
443
+ // report success while children keep running.
444
+ killTree(pid);
442
445
  }
443
- catch { /* process already exited */ }
444
- setTimeout(() => {
446
+ else {
445
447
  try {
446
- process.kill(pid, 0);
447
- process.kill(pid, 'SIGKILL');
448
+ process.kill(pid, 'SIGTERM');
448
449
  }
449
450
  catch { /* process already exited */ }
450
- }, 5000);
451
+ // Escalate to a hard tree-kill if it ignored SIGTERM after the grace period.
452
+ setTimeout(() => {
453
+ if (isAlive(pid))
454
+ killTree(pid);
455
+ }, 5000);
456
+ }
451
457
  }
452
458
  removeDaemonPid();
453
459
  return true;
@@ -479,6 +485,12 @@ export function signalDaemonReload() {
479
485
  const pid = readDaemonPid();
480
486
  if (!pid)
481
487
  return false;
488
+ if (process.platform === 'win32') {
489
+ // Windows has no SIGHUP, so signal-based live reload isn't available. Sending
490
+ // it would throw; instead report "not reloaded" so callers tell the user to
491
+ // restart the daemon to pick up job changes.
492
+ return false;
493
+ }
482
494
  try {
483
495
  process.kill(pid, 'SIGHUP');
484
496
  return true;
@@ -1,2 +1,8 @@
1
- /** Walk a directory recursively for files with a given extension. */
1
+ /** Walk a directory recursively for files with a given extension, newest first. */
2
2
  export declare function walkForFiles(dir: string, ext: string, limit: number): string[];
3
+ /**
4
+ * Return the newest mtime (ms) among files with the given extension, or null
5
+ * when none match. Single pass tracking the max — no collection or sort.
6
+ * Hot-path helper for the `agents run` account-recency probe.
7
+ */
8
+ export declare function latestFileMtimeMs(dir: string, ext: string): number | null;
@@ -1,35 +1,69 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- /** Walk a directory recursively for files with a given extension. */
4
- export function walkForFiles(dir, ext, limit) {
5
- const results = [];
3
+ /**
4
+ * Recursively visit files with a given extension, calling onFile with each
5
+ * match's path and mtime. Uses dirent types from readdir so only matching
6
+ * files (and symlinks, to preserve follow semantics) pay a stat call —
7
+ * directories are classified for free. On large session trees this roughly
8
+ * halves the syscall count versus stat-per-entry.
9
+ */
10
+ function walkEntries(dir, ext, onFile) {
6
11
  function walk(d, depth) {
7
12
  if (depth > 5)
8
13
  return;
9
14
  let entries;
10
15
  try {
11
- entries = fs.readdirSync(d);
16
+ entries = fs.readdirSync(d, { withFileTypes: true });
12
17
  }
13
18
  catch {
14
19
  return;
15
20
  }
16
21
  for (const entry of entries) {
17
- const full = path.join(d, entry);
18
- const stat = safeStatSync(full);
19
- if (!stat)
20
- continue;
21
- if (stat.isDirectory()) {
22
+ const full = path.join(d, entry.name);
23
+ let isDirectory = entry.isDirectory();
24
+ // Symlinks: dirent reports the link itself, but the previous stat-based
25
+ // walk followed links into directories and matched linked files. Stat
26
+ // (which follows) only for symlinks to keep that behavior.
27
+ if (entry.isSymbolicLink()) {
28
+ const stat = safeStatSync(full);
29
+ if (!stat)
30
+ continue;
31
+ isDirectory = stat.isDirectory();
32
+ }
33
+ if (isDirectory) {
22
34
  walk(full, depth + 1);
23
35
  }
24
- else if (entry.endsWith(ext)) {
25
- results.push({ path: full, mtime: stat.mtimeMs });
36
+ else if (entry.name.endsWith(ext)) {
37
+ const stat = safeStatSync(full);
38
+ if (stat)
39
+ onFile(full, stat.mtimeMs);
26
40
  }
27
41
  }
28
42
  }
29
43
  walk(dir, 0);
44
+ }
45
+ /** Walk a directory recursively for files with a given extension, newest first. */
46
+ export function walkForFiles(dir, ext, limit) {
47
+ const results = [];
48
+ walkEntries(dir, ext, (filePath, mtimeMs) => {
49
+ results.push({ path: filePath, mtime: mtimeMs });
50
+ });
30
51
  results.sort((a, b) => b.mtime - a.mtime);
31
52
  return results.slice(0, limit).map(r => r.path);
32
53
  }
54
+ /**
55
+ * Return the newest mtime (ms) among files with the given extension, or null
56
+ * when none match. Single pass tracking the max — no collection or sort.
57
+ * Hot-path helper for the `agents run` account-recency probe.
58
+ */
59
+ export function latestFileMtimeMs(dir, ext) {
60
+ let latest = null;
61
+ walkEntries(dir, ext, (_filePath, mtimeMs) => {
62
+ if (latest === null || mtimeMs > latest)
63
+ latest = mtimeMs;
64
+ });
65
+ return latest;
66
+ }
33
67
  function safeStatSync(p) {
34
68
  try {
35
69
  return fs.statSync(p);
package/dist/lib/git.js CHANGED
@@ -8,6 +8,7 @@
8
8
  import simpleGit from 'simple-git';
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
+ import { IS_WINDOWS, isWindowsAbsolutePath } from './platform/index.js';
11
12
  import { getPackageLocalPath } from './state.js';
12
13
  import { DEFAULT_SYSTEM_REPO, systemRepoSlug } from './types.js';
13
14
  /**
@@ -127,8 +128,10 @@ export function parseSource(source) {
127
128
  ref,
128
129
  };
129
130
  }
130
- // Local path (absolute or relative)
131
- if (cleanSource.startsWith('/') || cleanSource.startsWith('./') || cleanSource.startsWith('../')) {
131
+ // Local path (absolute or relative). On Windows also recognize drive-letter
132
+ // (C:\…) and UNC (\\…) roots, which the POSIX prefixes miss.
133
+ if (cleanSource.startsWith('/') || cleanSource.startsWith('./') || cleanSource.startsWith('../')
134
+ || (IS_WINDOWS && isWindowsAbsolutePath(cleanSource))) {
132
135
  if (fs.existsSync(cleanSource)) {
133
136
  return {
134
137
  type: 'local',
@@ -0,0 +1,7 @@
1
+ export interface FollowOptions {
2
+ /** Poll interval in milliseconds (default 500). */
3
+ intervalMs?: number;
4
+ /** Start at the current end of file (skip existing content). Default false. */
5
+ fromEnd?: boolean;
6
+ }
7
+ export declare function followFile(filePath: string, onChunk: (text: string) => void, opts?: FollowOptions): () => void;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Cross-platform `tail -f`.
3
+ *
4
+ * Replaces spawning the POSIX `tail` binary (absent on Windows) with a poll-based
5
+ * follower that behaves identically on every platform and needs no external
6
+ * dependency. Reads bytes appended since the last position every `intervalMs`;
7
+ * resets to 0 if the file shrinks (truncation / log rotation). The active timer
8
+ * keeps the event loop alive, so callers just register a SIGINT handler that
9
+ * calls the returned stop().
10
+ */
11
+ import * as fs from 'fs';
12
+ export function followFile(filePath, onChunk, opts = {}) {
13
+ const intervalMs = opts.intervalMs ?? 500;
14
+ let pos = 0;
15
+ if (opts.fromEnd) {
16
+ try {
17
+ pos = fs.statSync(filePath).size;
18
+ }
19
+ catch {
20
+ pos = 0;
21
+ }
22
+ }
23
+ else {
24
+ try {
25
+ const initial = fs.readFileSync(filePath);
26
+ if (initial.length > 0)
27
+ onChunk(initial.toString('utf-8'));
28
+ pos = initial.length;
29
+ }
30
+ catch { /* file may not exist yet — start at 0 and wait for it to appear */ }
31
+ }
32
+ const poll = () => {
33
+ let size;
34
+ try {
35
+ size = fs.statSync(filePath).size;
36
+ }
37
+ catch {
38
+ return; /* gone / not yet created */
39
+ }
40
+ if (size < pos)
41
+ pos = 0; // truncated or rotated — re-read from the top
42
+ if (size <= pos)
43
+ return;
44
+ let fd;
45
+ try {
46
+ fd = fs.openSync(filePath, 'r');
47
+ const buf = Buffer.alloc(size - pos);
48
+ const bytes = fs.readSync(fd, buf, 0, buf.length, pos);
49
+ pos += bytes;
50
+ if (bytes > 0)
51
+ onChunk(buf.subarray(0, bytes).toString('utf-8'));
52
+ }
53
+ catch { /* transient read error — retry next tick */ }
54
+ finally {
55
+ if (fd !== undefined) {
56
+ try {
57
+ fs.closeSync(fd);
58
+ }
59
+ catch { /* noop */ }
60
+ }
61
+ }
62
+ };
63
+ const timer = setInterval(poll, intervalMs);
64
+ return () => clearInterval(timer);
65
+ }
@@ -18,3 +18,4 @@ export declare const IS_LINUX: boolean;
18
18
  export * from './paths.js';
19
19
  export * from './exec.js';
20
20
  export * from './process.js';
21
+ export * from './ipc.js';
@@ -18,3 +18,4 @@ export const IS_LINUX = process.platform === 'linux';
18
18
  export * from './paths.js';
19
19
  export * from './exec.js';
20
20
  export * from './process.js';
21
+ export * from './ipc.js';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Resolve the address a local daemon listens on / clients connect to.
3
+ *
4
+ * POSIX: the AF_UNIX socket file path itself.
5
+ * Windows: a named pipe (`\\.\pipe\agents-<hash>`). Filesystem socket files
6
+ * aren't supported there, and named pipes are NOT filesystem objects — derive a
7
+ * stable name from a hash of the socket path so client and server agree without
8
+ * touching disk, and never probe the result with fs.existsSync (it always reports
9
+ * false). Both forms are accepted by net.createServer / net.createConnection.
10
+ */
11
+ export declare function ipcEndpoint(socketPath: string, platform?: NodeJS.Platform): string;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Local-daemon IPC endpoint, platform-aware.
3
+ */
4
+ import * as crypto from 'crypto';
5
+ /**
6
+ * Resolve the address a local daemon listens on / clients connect to.
7
+ *
8
+ * POSIX: the AF_UNIX socket file path itself.
9
+ * Windows: a named pipe (`\\.\pipe\agents-<hash>`). Filesystem socket files
10
+ * aren't supported there, and named pipes are NOT filesystem objects — derive a
11
+ * stable name from a hash of the socket path so client and server agree without
12
+ * touching disk, and never probe the result with fs.existsSync (it always reports
13
+ * false). Both forms are accepted by net.createServer / net.createConnection.
14
+ */
15
+ export function ipcEndpoint(socketPath, platform = process.platform) {
16
+ if (platform === 'win32') {
17
+ const hash = crypto.createHash('sha1').update(socketPath).digest('hex').slice(0, 16);
18
+ return `\\\\.\\pipe\\agents-${hash}`;
19
+ }
20
+ return socketPath;
21
+ }
@@ -20,3 +20,10 @@ export declare function toComparablePath(p: string, platform?: NodeJS.Platform):
20
20
  * correctly on all three platforms.
21
21
  */
22
22
  export declare function homeDir(): string;
23
+ /**
24
+ * Is this a Windows absolute path — a drive-letter root (`C:\`, `C:/`) or a UNC
25
+ * share (`\\server\share`)? Used by local-source parsing to recognize a native
26
+ * Windows path that the POSIX `/`, `./`, `../` prefixes miss. Caller decides
27
+ * whether to apply it (typically gated on win32).
28
+ */
29
+ export declare function isWindowsAbsolutePath(p: string): boolean;
@@ -47,3 +47,12 @@ export function toComparablePath(p, platform = process.platform) {
47
47
  export function homeDir() {
48
48
  return os.homedir();
49
49
  }
50
+ /**
51
+ * Is this a Windows absolute path — a drive-letter root (`C:\`, `C:/`) or a UNC
52
+ * share (`\\server\share`)? Used by local-source parsing to recognize a native
53
+ * Windows path that the POSIX `/`, `./`, `../` prefixes miss. Caller decides
54
+ * whether to apply it (typically gated on win32).
55
+ */
56
+ export function isWindowsAbsolutePath(p) {
57
+ return WIN_DRIVE_RE.test(p) || p.startsWith('\\\\');
58
+ }
@@ -1,6 +1,14 @@
1
1
  /**
2
- * Process liveness / control, platform-aware.
2
+ * Forcefully terminate a process AND its descendant tree.
3
+ *
4
+ * Windows: `taskkill /F /T /PID` — the only reliable way to take down the whole
5
+ * tree (a bare TerminateProcess leaves children orphaned, which is exactly the
6
+ * "stop reported success but the tree is still alive" bug). POSIX: SIGKILL to the
7
+ * pid (matching the existing hard-kill behavior; callers that own a process group
8
+ * can pass the negative pid). Best-effort — never throws; an already-exited
9
+ * process counts as success.
3
10
  */
11
+ export declare function killTree(pid: number): void;
4
12
  /**
5
13
  * Is a process with this PID currently alive?
6
14
  *
@@ -1,6 +1,33 @@
1
1
  /**
2
2
  * Process liveness / control, platform-aware.
3
3
  */
4
+ import { execFileSync } from 'child_process';
5
+ /**
6
+ * Forcefully terminate a process AND its descendant tree.
7
+ *
8
+ * Windows: `taskkill /F /T /PID` — the only reliable way to take down the whole
9
+ * tree (a bare TerminateProcess leaves children orphaned, which is exactly the
10
+ * "stop reported success but the tree is still alive" bug). POSIX: SIGKILL to the
11
+ * pid (matching the existing hard-kill behavior; callers that own a process group
12
+ * can pass the negative pid). Best-effort — never throws; an already-exited
13
+ * process counts as success.
14
+ */
15
+ export function killTree(pid) {
16
+ if (!pid || pid <= 0)
17
+ return;
18
+ if (process.platform === 'win32') {
19
+ try {
20
+ execFileSync('taskkill', ['/F', '/T', '/PID', String(pid)], { stdio: 'ignore' });
21
+ }
22
+ catch { /* already gone, or no such pid */ }
23
+ }
24
+ else {
25
+ try {
26
+ process.kill(pid, 'SIGKILL');
27
+ }
28
+ catch { /* already gone */ }
29
+ }
30
+ }
4
31
  /**
5
32
  * Is a process with this PID currently alive?
6
33
  *
@@ -12,6 +12,7 @@ import * as fs from 'fs';
12
12
  import * as path from 'path';
13
13
  import { execFileSync } from 'child_process';
14
14
  import { getPluginsDir, getTrashPluginsDir, getExtraPluginsDir, getProjectPluginsDir } from './state.js';
15
+ import { IS_WINDOWS, isWindowsAbsolutePath, homeDir } from './platform/index.js';
15
16
  import { listInstalledVersions, getVersionHomePath } from './versions.js';
16
17
  import { AGENTS, agentConfigDirName } from './agents.js';
17
18
  import { capableAgents, isCapable } from './capabilities.js';
@@ -1007,9 +1008,10 @@ export function parseInstallSpec(spec) {
1007
1008
  export async function installPlugin(spec) {
1008
1009
  const { name: specName, source } = parseInstallSpec(spec);
1009
1010
  // Resolve local path (handle ~)
1010
- const isLocalPath = source.startsWith('/') || source.startsWith('./') || source.startsWith('../') || source.startsWith('~');
1011
+ const isLocalPath = source.startsWith('/') || source.startsWith('./') || source.startsWith('../') || source.startsWith('~')
1012
+ || (IS_WINDOWS && isWindowsAbsolutePath(source));
1011
1013
  const resolvedSource = isLocalPath
1012
- ? source.replace(/^~/, process.env.HOME || '~')
1014
+ ? source.replace(/^~/, homeDir())
1013
1015
  : source;
1014
1016
  const pluginsDir = getPluginsDir();
1015
1017
  fs.mkdirSync(pluginsDir, { recursive: true });
@@ -1085,7 +1087,7 @@ export async function updatePlugin(name) {
1085
1087
  execFileSync('git', ['-C', plugin.root, 'pull', '--ff-only'], { stdio: 'pipe' });
1086
1088
  }
1087
1089
  else {
1088
- const resolvedSource = sourceInfo.source.replace(/^~/, process.env.HOME || '~');
1090
+ const resolvedSource = sourceInfo.source.replace(/^~/, homeDir());
1089
1091
  if (!fs.existsSync(resolvedSource)) {
1090
1092
  return { success: false, error: `Source path no longer exists: ${resolvedSource}` };
1091
1093
  }
@@ -212,8 +212,8 @@ export async function refresh(options = {}) {
212
212
  if (!isShimsInPath()) {
213
213
  const pathResult = addShimsToPath();
214
214
  if (pathResult.success && !pathResult.alreadyPresent) {
215
- console.log(chalk.green(`\nAdded shims to ~/${pathResult.rcFile}`));
216
- console.log(chalk.gray('Restart your shell or run: source ~/' + pathResult.rcFile));
215
+ console.log(chalk.green(`\nAdded shims to ${pathResult.location}`));
216
+ console.log(chalk.gray(pathResult.reloadHint));
217
217
  }
218
218
  else if (!pathResult.success) {
219
219
  console.log(chalk.yellow('\nCould not auto-add shims to PATH:'));
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Self-update install plumbing.
3
+ *
4
+ * The hard requirement: an upgrade must replace the copy that is currently
5
+ * running. A bare `npm install -g` writes into the global prefix of whatever
6
+ * `npm` PATH happens to resolve — on machines with more than one node
7
+ * installation (nvm + Homebrew + vendored runtimes) that prefix can belong to
8
+ * a different node than the one this copy lives under. The install then
9
+ * "succeeds" while the running copy stays stale and re-prompts forever.
10
+ *
11
+ * So every step here is anchored to the running package root on disk, never
12
+ * to PATH resolution.
13
+ */
14
+ export declare const NPM_PACKAGE_NAME = "@phnx-labs/agents-cli";
15
+ export interface UpdateCheckCache {
16
+ lastCheck: number;
17
+ latestVersion: string;
18
+ dismissed?: string;
19
+ }
20
+ /** Read the cached update-check state from disk. Returns null if the file is missing or corrupt. */
21
+ export declare function readUpdateCache(file: string): UpdateCheckCache | null;
22
+ /**
23
+ * Persist the latest known version and current timestamp. Preserves an
24
+ * existing `dismissed` marker — the background refresh must not erase a
25
+ * user's "Skip this version" choice, or they get re-prompted for the exact
26
+ * version they dismissed.
27
+ */
28
+ export declare function saveUpdateCheck(file: string, latestVersion: string): void;
29
+ /** Record that the user chose to skip `version`; suppresses prompts until a newer version appears. */
30
+ export declare function dismissUpdateVersion(file: string, version: string): void;
31
+ /** Whether the cached state warrants an upgrade prompt for a copy running `currentVersion`. */
32
+ export declare function shouldPromptUpgrade(cache: UpdateCheckCache | null, currentVersion: string): boolean;
33
+ /**
34
+ * Derive the npm global prefix that owns the install at `packageRoot`.
35
+ *
36
+ * npm's global layout for a scoped package:
37
+ * POSIX: <prefix>/lib/node_modules/@phnx-labs/agents-cli
38
+ * Windows: <prefix>/node_modules/@phnx-labs/agents-cli
39
+ *
40
+ * Throws when `packageRoot` is not inside a node_modules tree (e.g. running
41
+ * from a source checkout) — there is no prefix to install into, and guessing
42
+ * one is exactly the bug this module exists to prevent.
43
+ */
44
+ export declare function deriveGlobalPrefix(packageRoot: string): string;
45
+ /**
46
+ * Install `spec` into an explicit global prefix. `--prefix` pins the
47
+ * destination no matter which npm binary PATH resolves. `--ignore-scripts`
48
+ * skips lifecycle scripts; the caller refreshes alias shims afterwards via
49
+ * refreshAliasShims().
50
+ */
51
+ export declare function installPackageIntoPrefix(spec: string, prefix: string): Promise<void>;
52
+ /** Read the version field of the package.json at `packageRoot`, fresh from disk. */
53
+ export declare function readInstalledVersion(packageRoot: string): string;
54
+ /**
55
+ * Assert that the install at `packageRoot` now carries `expectedVersion`.
56
+ * npm exiting 0 only proves it wrote *somewhere*; this proves it wrote *here*.
57
+ */
58
+ export declare function verifyInstalledVersion(packageRoot: string, expectedVersion: string): void;
59
+ /**
60
+ * Re-run the freshly installed copy's postinstall in shims-only mode so the
61
+ * bare-command aliases (secrets, sessions, ...) pick up the new entrypoint
62
+ * and any aliases added in the new version. Best-effort: a failure here
63
+ * leaves the previous shims in place, which still point at the (now
64
+ * upgraded) package root.
65
+ */
66
+ export declare function refreshAliasShims(packageRoot: string): void;
67
+ export interface AgentsCliInstall {
68
+ /** The PATH entry (`<dir>/agents`) that resolves to this install. */
69
+ binPath: string;
70
+ /** Package root containing package.json and dist/. */
71
+ packageRoot: string;
72
+ version: string;
73
+ }
74
+ /**
75
+ * Scan PATH for `agents` entrypoints and resolve each to the agents-cli
76
+ * package root it executes. More than one distinct root means upgrades,
77
+ * shims, and the command the user types can act on different copies — the
78
+ * divergence behind silently-failing self-updates.
79
+ *
80
+ * npm bin entries are symlinks that resolve to `<packageRoot>/dist/index.js`
81
+ * (the dev install's `~/.local/bin/agents` chains through the dev prefix to
82
+ * the same shape). Anything that doesn't resolve to a dist/index.js inside a
83
+ * package named @phnx-labs/agents-cli is some other tool and is skipped.
84
+ * POSIX-only: Windows npm bins are .cmd wrappers, not symlinks.
85
+ */
86
+ export declare function findAgentsCliInstalls(pathEnv: string): AgentsCliInstall[];