@phnx-labs/agents-cli 1.20.8 → 1.20.10

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 (39) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +1 -1
  3. package/dist/commands/daemon.js +6 -6
  4. package/dist/commands/import.js +3 -6
  5. package/dist/commands/inspect.d.ts +2 -0
  6. package/dist/commands/inspect.js +75 -28
  7. package/dist/commands/models.js +2 -1
  8. package/dist/commands/plugins.js +3 -2
  9. package/dist/commands/refresh-rules.js +4 -4
  10. package/dist/commands/routines.js +8 -7
  11. package/dist/commands/sessions.js +17 -2
  12. package/dist/commands/subagents.js +2 -1
  13. package/dist/commands/usage.js +11 -3
  14. package/dist/index.js +69 -47
  15. package/dist/lib/agents.d.ts +18 -1
  16. package/dist/lib/agents.js +89 -23
  17. package/dist/lib/browser/chrome.d.ts +4 -3
  18. package/dist/lib/browser/chrome.js +87 -12
  19. package/dist/lib/browser/ipc.js +59 -13
  20. package/dist/lib/daemon.js +20 -8
  21. package/dist/lib/fs-walk.d.ts +7 -1
  22. package/dist/lib/fs-walk.js +45 -11
  23. package/dist/lib/git.js +5 -2
  24. package/dist/lib/log-follow.d.ts +7 -0
  25. package/dist/lib/log-follow.js +65 -0
  26. package/dist/lib/platform/index.d.ts +1 -0
  27. package/dist/lib/platform/index.js +1 -0
  28. package/dist/lib/platform/ipc.d.ts +11 -0
  29. package/dist/lib/platform/ipc.js +21 -0
  30. package/dist/lib/platform/paths.d.ts +7 -0
  31. package/dist/lib/platform/paths.js +9 -0
  32. package/dist/lib/platform/process.d.ts +9 -1
  33. package/dist/lib/platform/process.js +27 -0
  34. package/dist/lib/plugins.js +5 -3
  35. package/dist/lib/self-update.d.ts +86 -0
  36. package/dist/lib/self-update.js +178 -0
  37. package/dist/lib/versions.js +3 -3
  38. package/package.json +1 -1
  39. package/scripts/postinstall.js +29 -19
@@ -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');
@@ -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;