@phnx-labs/agents-cli 1.20.8 → 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/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/subagents.js +2 -1
- package/dist/commands/usage.js +11 -3
- 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/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/self-update.d.ts +86 -0
- package/dist/lib/self-update.js +178 -0
- package/dist/lib/versions.js +3 -3
- package/package.json +1 -1
- package/scripts/postinstall.js +29 -19
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');
|
package/dist/lib/daemon.js
CHANGED
|
@@ -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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
444
|
-
setTimeout(() => {
|
|
446
|
+
else {
|
|
445
447
|
try {
|
|
446
|
-
process.kill(pid,
|
|
447
|
-
process.kill(pid, 'SIGKILL');
|
|
448
|
+
process.kill(pid, 'SIGTERM');
|
|
448
449
|
}
|
|
449
450
|
catch { /* process already exited */ }
|
|
450
|
-
|
|
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;
|
package/dist/lib/fs-walk.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/fs-walk.js
CHANGED
|
@@ -1,35 +1,69 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|