@proagentstore/cli 0.4.74 → 0.4.76
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/dist/browser-runner/coding/engine-turn.js +27 -5
- package/dist/browser-runner/coding/headless.js +21 -5
- package/dist/browser-runner/coding/inspect.js +42 -11
- package/dist/browser-runner/coding/runtime.js +9 -2
- package/dist/browser-runner/coding/session-resources.js +67 -0
- package/dist/browser-runner/server.js +9 -3
- package/dist/index.js +86 -3
- package/package.json +1 -1
|
@@ -29,17 +29,39 @@ export const MAX_TURN_DETAIL = 240;
|
|
|
29
29
|
export const MAX_TURN_TAIL_LINES = 60;
|
|
30
30
|
export const MAX_TURN_TAIL_LINE = 400;
|
|
31
31
|
export const MAX_TURN_TAIL_CHARS = 8000;
|
|
32
|
-
/**
|
|
32
|
+
/** Leads a tail that has lost its oldest lines, with how many (#898). */
|
|
33
|
+
const DROPPED_LINE = /^\[(\d+) earlier lines? of this turn not kept\]$/;
|
|
34
|
+
/**
|
|
35
|
+
* Append one line of the turn's own output to its tail, keeping the newest within the bounds above.
|
|
36
|
+
*
|
|
37
|
+
* What falls off the front is COUNTED, in a first line that says so (#898): the cloud reports "the
|
|
38
|
+
* last N lines", and a tail that silently lost its head made N read as all the turn printed.
|
|
39
|
+
*/
|
|
33
40
|
export function appendTurnTail(tail, line) {
|
|
34
41
|
const text = line.trim();
|
|
35
42
|
if (!text)
|
|
36
43
|
return;
|
|
44
|
+
const marked = tail.length ? DROPPED_LINE.exec(tail[0]) : null;
|
|
45
|
+
let dropped = marked ? Number(marked[1]) : 0;
|
|
46
|
+
if (marked)
|
|
47
|
+
tail.shift();
|
|
37
48
|
tail.push(text.length > MAX_TURN_TAIL_LINE ? `${text.slice(0, MAX_TURN_TAIL_LINE)}…` : text);
|
|
38
|
-
while (tail.length > MAX_TURN_TAIL_LINES)
|
|
49
|
+
while (tail.length > MAX_TURN_TAIL_LINES - 1) {
|
|
39
50
|
tail.shift();
|
|
51
|
+
dropped++;
|
|
52
|
+
}
|
|
40
53
|
let total = tail.reduce((n, l) => n + l.length + 1, 0);
|
|
41
|
-
|
|
54
|
+
// Room is kept for the count line itself, so the tail stays inside its total bound.
|
|
55
|
+
while (total > MAX_TURN_TAIL_CHARS - 48 && tail.length > 1) {
|
|
42
56
|
total -= tail.shift().length + 1;
|
|
57
|
+
dropped++;
|
|
58
|
+
}
|
|
59
|
+
if (dropped)
|
|
60
|
+
tail.unshift(`[${dropped} earlier line${dropped === 1 ? "" : "s"} of this turn not kept]`);
|
|
61
|
+
}
|
|
62
|
+
/** The engine's last line within {@link MAX_TURN_DETAIL}, with an ellipsis when it was cut (#898). */
|
|
63
|
+
function detailOf(text) {
|
|
64
|
+
return text.length > MAX_TURN_DETAIL ? `${text.slice(0, MAX_TURN_DETAIL)}…` : text;
|
|
43
65
|
}
|
|
44
66
|
/**
|
|
45
67
|
* A one-shot turn's process has exited — say what that means, and nothing more.
|
|
@@ -49,7 +71,7 @@ export function appendTurnTail(tail, line) {
|
|
|
49
71
|
* failure would let three slow builds read as a broken CLI.
|
|
50
72
|
*/
|
|
51
73
|
export function turnReportFromExit(code, signal, lastLine = "", now = Date.now(), tail = []) {
|
|
52
|
-
const detail = (lastLine.trim() || tail.at(-1) || "")
|
|
74
|
+
const detail = detailOf(lastLine.trim() || tail.at(-1) || "");
|
|
53
75
|
const verdict = signal !== null ? "killed" : code === 0 ? "ok" : "failed";
|
|
54
76
|
return {
|
|
55
77
|
verdict,
|
|
@@ -68,7 +90,7 @@ export function turnReportFromExit(code, signal, lastLine = "", now = Date.now()
|
|
|
68
90
|
* case that runs most.
|
|
69
91
|
*/
|
|
70
92
|
export function turnReportFromResult(isError, detail = "", now = Date.now(), tail = []) {
|
|
71
|
-
const text = detail.trim()
|
|
93
|
+
const text = detailOf(detail.trim());
|
|
72
94
|
return {
|
|
73
95
|
verdict: isError ? "failed" : "ok",
|
|
74
96
|
exitCode: null,
|
|
@@ -59,6 +59,8 @@ export class HeadlessSession {
|
|
|
59
59
|
proc = null;
|
|
60
60
|
buf = "";
|
|
61
61
|
transcript = [];
|
|
62
|
+
/** Records trimmed off the front of {@link transcript} — said in the pane, never silent (#898). */
|
|
63
|
+
droppedRecords = 0;
|
|
62
64
|
run = "idle";
|
|
63
65
|
/** Claude Code's own session id (from the init event) — used to --resume. */
|
|
64
66
|
claudeSessionId = null;
|
|
@@ -293,6 +295,10 @@ export class HeadlessSession {
|
|
|
293
295
|
get lastTurn() {
|
|
294
296
|
return this.turnReport;
|
|
295
297
|
}
|
|
298
|
+
/** The engine process's pid while one is running — what per-session resource attribution reads (#924). */
|
|
299
|
+
get pid() {
|
|
300
|
+
return this.procAlive ? (this.proc?.pid ?? null) : null;
|
|
301
|
+
}
|
|
296
302
|
/** Is a process running THIS instant? The persistent engine's liveness, and the spawn guard. */
|
|
297
303
|
get procAlive() {
|
|
298
304
|
return this.proc !== null && this.proc.exitCode === null && this.proc.signalCode === null;
|
|
@@ -351,7 +357,8 @@ export class HeadlessSession {
|
|
|
351
357
|
}
|
|
352
358
|
/** The rendered conversation the brain/console reads (Claude's real output). */
|
|
353
359
|
snapshot() {
|
|
354
|
-
|
|
360
|
+
const text = this.transcript.join("\n");
|
|
361
|
+
return this.droppedRecords > 0 ? `[${this.droppedRecords} earlier lines of this session's output are no longer kept]\n${text}` : text;
|
|
355
362
|
}
|
|
356
363
|
/** Launch (or resume) the agent process. Idempotent if already alive. */
|
|
357
364
|
/**
|
|
@@ -691,6 +698,10 @@ export class HeadlessSession {
|
|
|
691
698
|
if (this.buf.length > cap) {
|
|
692
699
|
if (this.mode === "raw")
|
|
693
700
|
this.pushRaw(this.buf);
|
|
701
|
+
// Said in the pane (#898): an event this size was discarded whole, and a reader could not
|
|
702
|
+
// tell a dropped tool result from one that never came.
|
|
703
|
+
else
|
|
704
|
+
this.push(`[a ${this.buf.length.toLocaleString("en-US")}-character line of engine output was too large to keep and was dropped]`);
|
|
694
705
|
this.buf = "";
|
|
695
706
|
}
|
|
696
707
|
}
|
|
@@ -730,8 +741,14 @@ export class HeadlessSession {
|
|
|
730
741
|
// never scraped back out of the rendered pane.
|
|
731
742
|
this.turnLastLine = clean.trim();
|
|
732
743
|
}
|
|
733
|
-
|
|
734
|
-
|
|
744
|
+
this.trimTranscript();
|
|
745
|
+
}
|
|
746
|
+
/** Keep the newest 3,000 records once past 4,000, and COUNT what went (#898). */
|
|
747
|
+
trimTranscript() {
|
|
748
|
+
if (this.transcript.length <= 4000)
|
|
749
|
+
return;
|
|
750
|
+
this.droppedRecords += this.transcript.length - 3000;
|
|
751
|
+
this.transcript = this.transcript.slice(-3000);
|
|
735
752
|
}
|
|
736
753
|
handle(line) {
|
|
737
754
|
const events = this.adapter.parseLine(line);
|
|
@@ -808,8 +825,7 @@ export class HeadlessSession {
|
|
|
808
825
|
// Keep the in-memory transcript bounded. Counts ENTRIES, and an entry may now be a
|
|
809
826
|
// multi-line block (a result, or a long assistant reply) rather than one line — the
|
|
810
827
|
// character bound that matters is `MAX_PANE` in runtime.ts, applied on the way out.
|
|
811
|
-
|
|
812
|
-
this.transcript = this.transcript.slice(-3000);
|
|
828
|
+
this.trimTranscript();
|
|
813
829
|
return events.length > 0;
|
|
814
830
|
}
|
|
815
831
|
push(line) {
|
|
@@ -272,7 +272,9 @@ export function repoSearch(workDir, opts) {
|
|
|
272
272
|
const limit = Math.max(1, Math.min(SEARCH_MAX_RESULTS, Math.floor(opts.maxResults ?? SEARCH_MAX_RESULTS)));
|
|
273
273
|
const relPath = opts.path ? relative(workDir, resolveInside(workDir, opts.path)) : undefined;
|
|
274
274
|
const argv = mode === "content"
|
|
275
|
-
?
|
|
275
|
+
? // No `--max-count` (#898): git stopped at 5 per file and `total` counted only those, so a
|
|
276
|
+
// file with 200 matches read as one with 5. The per-file cap is applied below, COUNTED.
|
|
277
|
+
["grep", "-n", "-I", "-i", "-F", "--untracked", "-e", pattern]
|
|
276
278
|
: // Tracked PLUS untracked-not-ignored: a file created ten minutes ago is exactly the one
|
|
277
279
|
// somebody is trying to find, and `ls-files` alone would deny it exists.
|
|
278
280
|
["ls-files", "--cached", "--others", "--exclude-standard"];
|
|
@@ -297,12 +299,33 @@ export function repoSearch(workDir, opts) {
|
|
|
297
299
|
throw new InspectError(err.message || `git ${mode} search failed`);
|
|
298
300
|
}
|
|
299
301
|
const lines = out.split("\n").filter((l) => l !== "");
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
302
|
+
if (mode === "path") {
|
|
303
|
+
// The pattern never reaches git here — the filter is a plain case-insensitive substring
|
|
304
|
+
// over the path, which is what "find the file called X" actually means.
|
|
305
|
+
const all = lines.filter((p) => p.toLowerCase().includes(pattern.toLowerCase())).map((p) => ({ path: p }));
|
|
306
|
+
return { mode, pattern, matches: all.slice(0, limit), shown: Math.min(all.length, limit), total: all.length, truncated: all.length > limit };
|
|
307
|
+
}
|
|
308
|
+
const all = lines.map(parseGrepLine).filter((m) => m !== null);
|
|
309
|
+
const perFile = new Map();
|
|
310
|
+
const omittedPerFile = {};
|
|
311
|
+
const capped = all.filter((m) => {
|
|
312
|
+
const n = (perFile.get(m.path) ?? 0) + 1;
|
|
313
|
+
perFile.set(m.path, n);
|
|
314
|
+
if (n <= SEARCH_PER_FILE)
|
|
315
|
+
return true;
|
|
316
|
+
omittedPerFile[m.path] = (omittedPerFile[m.path] ?? 0) + 1;
|
|
317
|
+
return false;
|
|
318
|
+
});
|
|
319
|
+
const matches = capped.slice(0, limit);
|
|
320
|
+
return {
|
|
321
|
+
mode,
|
|
322
|
+
pattern,
|
|
323
|
+
matches,
|
|
324
|
+
shown: matches.length,
|
|
325
|
+
total: all.length,
|
|
326
|
+
truncated: matches.length < all.length,
|
|
327
|
+
...(Object.keys(omittedPerFile).length ? { omittedPerFile } : {}),
|
|
328
|
+
};
|
|
306
329
|
}
|
|
307
330
|
/** `path:line:text` — split on the FIRST two colons only, since code is full of them. */
|
|
308
331
|
function parseGrepLine(line) {
|
|
@@ -315,7 +338,9 @@ function parseGrepLine(line) {
|
|
|
315
338
|
const n = Number.parseInt(line.slice(first + 1, second), 10);
|
|
316
339
|
if (!Number.isFinite(n))
|
|
317
340
|
return null;
|
|
318
|
-
|
|
341
|
+
const text = line.slice(second + 1).trim();
|
|
342
|
+
// Marked (#898): a cut line read as the whole line. `repo_read_file` with `startLine` has the rest.
|
|
343
|
+
return { path: line.slice(0, first), line: n, text: text.length > SEARCH_MAX_LINE ? `${text.slice(0, SEARCH_MAX_LINE)}…` : text };
|
|
319
344
|
}
|
|
320
345
|
/** The deepest `maxDepth` this will honour. Named because the TOOL has to say it (#508). */
|
|
321
346
|
export const TREE_MAX_DEPTH = 4;
|
|
@@ -342,6 +367,10 @@ export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500)
|
|
|
342
367
|
const queue = [{ dir: start, depth: 0 }];
|
|
343
368
|
let truncated = false;
|
|
344
369
|
let truncatedByDepth = false;
|
|
370
|
+
// Dot-entries ARE listed now (#898): `.github/`, `.env.example`, `.claude/` were hidden with
|
|
371
|
+
// nothing saying so. What is still skipped is named in `skipped`, so absence is never a claim.
|
|
372
|
+
const skipped = new Set();
|
|
373
|
+
const result = () => ({ root: relPath, entries, truncated, truncatedByDepth, depthCap, ...(skipped.size ? { skipped: [...skipped].sort() } : {}) });
|
|
345
374
|
while (queue.length) {
|
|
346
375
|
const { dir, depth } = queue.shift();
|
|
347
376
|
let items;
|
|
@@ -352,11 +381,13 @@ export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500)
|
|
|
352
381
|
continue;
|
|
353
382
|
}
|
|
354
383
|
for (const it of items) {
|
|
355
|
-
if (
|
|
384
|
+
if (IGNORE_DIRS.has(it.name)) {
|
|
385
|
+
skipped.add(it.name);
|
|
356
386
|
continue;
|
|
387
|
+
}
|
|
357
388
|
if (entries.length >= entryCap) {
|
|
358
389
|
truncated = true;
|
|
359
|
-
return
|
|
390
|
+
return result();
|
|
360
391
|
}
|
|
361
392
|
const abs = resolve(dir, it.name);
|
|
362
393
|
const rel = relative(workDir, abs);
|
|
@@ -387,7 +418,7 @@ export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500)
|
|
|
387
418
|
}
|
|
388
419
|
}
|
|
389
420
|
}
|
|
390
|
-
return
|
|
421
|
+
return result();
|
|
391
422
|
}
|
|
392
423
|
export const SYNC_FETCH_TTL_MS = 60_000;
|
|
393
424
|
/** Per work-tree root: when the last fetch attempt was made and how it went. */
|
|
@@ -189,10 +189,12 @@ export class CodingRuntime {
|
|
|
189
189
|
// `[exited with code N]` / `[error]` lines recorded on exit. Blanking it when the
|
|
190
190
|
// process is dead lost exactly the output + failure reason the brain/console needs
|
|
191
191
|
// to diagnose a crash or read a one-shot CLI's final result.
|
|
192
|
-
const
|
|
192
|
+
const full = session.snapshot();
|
|
193
|
+
const pane = clip(full);
|
|
193
194
|
return {
|
|
194
195
|
sessionId,
|
|
195
196
|
pane,
|
|
197
|
+
paneChars: full.length,
|
|
196
198
|
alive,
|
|
197
199
|
ready: alive ? session.ready : false,
|
|
198
200
|
runState: alive ? session.runState() : "idle",
|
|
@@ -284,6 +286,10 @@ export class CodingRuntime {
|
|
|
284
286
|
runState: s.runState(),
|
|
285
287
|
}));
|
|
286
288
|
}
|
|
289
|
+
/** Every session with a live engine process, and its pid — the roots resource attribution walks (#924). */
|
|
290
|
+
enginePids() {
|
|
291
|
+
return [...this.sessions.entries()].flatMap(([sessionId, s]) => (s.pid ? [{ sessionId, engineLabel: s.engineLabel, pid: s.pid }] : []));
|
|
292
|
+
}
|
|
287
293
|
/** Rich diagnostics for every tracked session — the console's transparency view. */
|
|
288
294
|
diagnostics() {
|
|
289
295
|
return [...this.sessions.entries()].map(([sessionId, s]) => ({
|
|
@@ -391,6 +397,7 @@ export class CodingRuntime {
|
|
|
391
397
|
return session;
|
|
392
398
|
}
|
|
393
399
|
}
|
|
400
|
+
/** The tail, marked (#898): the head it drops is said, with both numbers, at the top. */
|
|
394
401
|
function clip(pane) {
|
|
395
|
-
return pane.length > MAX_PANE ? pane.slice(pane.length - MAX_PANE) : pane;
|
|
402
|
+
return pane.length > MAX_PANE ? `[cut: showing the last ${MAX_PANE} of ${pane.length} characters]\n${pane.slice(pane.length - MAX_PANE)}` : pane;
|
|
396
403
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which coding session is using the machine (#924).
|
|
3
|
+
*
|
|
4
|
+
* The heartbeat says a machine is loaded; this says WHICH agent is loading it. Each session's engine
|
|
5
|
+
* is a child process that spawns its own (an engine's tools, a test runner, a build), so a session's
|
|
6
|
+
* share is its engine process plus every descendant. One `ps` per heartbeat reads the whole process
|
|
7
|
+
* table — the attribution is computed here from it, never by a `ps` per session.
|
|
8
|
+
*
|
|
9
|
+
* Not on Windows (no `ps`): the reading is absent there, which the platform reports as "not
|
|
10
|
+
* reported", never as zero.
|
|
11
|
+
*/
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
/** `ps -A -o pid=,ppid=,rss=,pcpu=` output → rows. A line that does not parse is skipped. */
|
|
14
|
+
export function parsePs(text) {
|
|
15
|
+
const rows = [];
|
|
16
|
+
for (const line of text.split("\n")) {
|
|
17
|
+
const [pid, ppid, rss, cpu] = line.trim().split(/\s+/).map(Number);
|
|
18
|
+
if ([pid, ppid, rss, cpu].every((n) => Number.isFinite(n)))
|
|
19
|
+
rows.push({ pid, ppid, rssKb: rss, cpuPct: cpu });
|
|
20
|
+
}
|
|
21
|
+
return rows;
|
|
22
|
+
}
|
|
23
|
+
/** Each root's own row plus all its descendants', summed. Pure, so the tree walk is testable. */
|
|
24
|
+
export function attribute(rows, roots) {
|
|
25
|
+
const children = new Map();
|
|
26
|
+
const byPid = new Map();
|
|
27
|
+
for (const r of rows) {
|
|
28
|
+
byPid.set(r.pid, r);
|
|
29
|
+
children.set(r.ppid, [...(children.get(r.ppid) ?? []), r]);
|
|
30
|
+
}
|
|
31
|
+
return roots.flatMap((root) => {
|
|
32
|
+
const top = byPid.get(root.pid);
|
|
33
|
+
if (!top)
|
|
34
|
+
return [];
|
|
35
|
+
let processes = 0;
|
|
36
|
+
let rssKb = 0;
|
|
37
|
+
let cpuPct = 0;
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
const stack = [top];
|
|
40
|
+
while (stack.length) {
|
|
41
|
+
const r = stack.pop();
|
|
42
|
+
if (seen.has(r.pid))
|
|
43
|
+
continue;
|
|
44
|
+
seen.add(r.pid);
|
|
45
|
+
processes++;
|
|
46
|
+
rssKb += r.rssKb;
|
|
47
|
+
cpuPct += r.cpuPct;
|
|
48
|
+
stack.push(...(children.get(r.pid) ?? []));
|
|
49
|
+
}
|
|
50
|
+
return [{ sessionId: root.sessionId, engineLabel: root.engineLabel, pid: root.pid, processes, rssBytes: rssKb * 1024, cpuPct: Math.round(cpuPct * 10) / 10 }];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** Read the process table once and attribute it to the live engines. Null when it cannot be read. */
|
|
54
|
+
export function readSessionResources(roots) {
|
|
55
|
+
if (process.platform === "win32")
|
|
56
|
+
return null;
|
|
57
|
+
if (!roots.length)
|
|
58
|
+
return [];
|
|
59
|
+
try {
|
|
60
|
+
const out = execFileSync("ps", ["-A", "-o", "pid=,ppid=,rss=,pcpu="], { encoding: "utf-8", timeout: 2000, maxBuffer: 8 * 1024 * 1024 });
|
|
61
|
+
return attribute(parsePs(out), roots);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// A `ps` that failed or timed out is "not read this beat", which the null says — never zeros.
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -199,6 +199,11 @@ async function route(runner, req, res) {
|
|
|
199
199
|
if (req.method === "GET" && path === "/coding/sessions") {
|
|
200
200
|
return json(res, 200, { sessions: runner.coding.list() });
|
|
201
201
|
}
|
|
202
|
+
// Per-session CPU and memory, for the heartbeat (#924): which agent is loading this machine.
|
|
203
|
+
if (req.method === "GET" && path === "/coding/resources") {
|
|
204
|
+
const { readSessionResources } = await import("./coding/session-resources.js");
|
|
205
|
+
return json(res, 200, { sessions: readSessionResources(runner.coding.enginePids()) });
|
|
206
|
+
}
|
|
202
207
|
if ((req.method === "GET" || req.method === "POST") && path === "/coding/diagnostics") {
|
|
203
208
|
// No tmux figures here any more (#247). The coding engine spawns a child process
|
|
204
209
|
// directly, so `pagsTmuxTotal` was structurally always 0 and `tmuxTotal` counted the
|
|
@@ -304,15 +309,16 @@ async function route(runner, req, res) {
|
|
|
304
309
|
const raw = (b.dir || "~").replace(/^~(?=$|\/)/, homedir());
|
|
305
310
|
const dir = resolve(raw);
|
|
306
311
|
try {
|
|
307
|
-
const
|
|
308
|
-
|
|
312
|
+
const visible = readdirSync(dir, { withFileTypes: true }).filter((e) => !e.name.startsWith("."));
|
|
313
|
+
const entries = visible
|
|
309
314
|
.slice(0, 200)
|
|
310
315
|
.map((e) => ({
|
|
311
316
|
name: e.name,
|
|
312
317
|
type: e.isDirectory() ? "dir" : "file",
|
|
313
318
|
size: e.isFile() ? statSync(resolve(dir, e.name)).size : undefined,
|
|
314
319
|
}));
|
|
315
|
-
|
|
320
|
+
// Said, not silent (#898): a folder of 500 entries read as one of 200.
|
|
321
|
+
return json(res, 200, { dir, entries, ...(visible.length > entries.length ? { total: visible.length, truncated: true } : {}) });
|
|
316
322
|
}
|
|
317
323
|
catch (e) {
|
|
318
324
|
return json(res, 400, { error: e instanceof Error ? e.message : String(e), dir });
|
package/dist/index.js
CHANGED
|
@@ -1205,10 +1205,40 @@ async function waitForLocalRunner(opts, timeoutMs = 15e3) {
|
|
|
1205
1205
|
import { hostname as hostname3 } from "os";
|
|
1206
1206
|
|
|
1207
1207
|
// src/commands/runner/resources.ts
|
|
1208
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync5, statfsSync, writeFileSync as writeFileSync4 } from "fs";
|
|
1208
1209
|
import os from "os";
|
|
1209
|
-
|
|
1210
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
1211
|
+
var CHECKOUT_ROOT = join6(os.homedir(), ".config", "proagentstore", "repos");
|
|
1212
|
+
function sampleDisk(path = CHECKOUT_ROOT, statfs = statfsSync) {
|
|
1213
|
+
for (const p of [path, os.homedir()]) {
|
|
1214
|
+
try {
|
|
1215
|
+
const s = statfs(p);
|
|
1216
|
+
return { path: p, totalBytes: Number(s.blocks) * Number(s.bsize), freeBytes: Number(s.bavail) * Number(s.bsize), inodesTotal: Number(s.files), inodesFree: Number(s.ffree) };
|
|
1217
|
+
} catch {
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
return null;
|
|
1221
|
+
}
|
|
1222
|
+
var STARTS_FILE = join6(os.homedir(), ".config", "proagentstore", "runner-starts.json");
|
|
1223
|
+
function recordRunnerStart(now = Date.now(), file = STARTS_FILE) {
|
|
1224
|
+
let starts = [];
|
|
1225
|
+
try {
|
|
1226
|
+
const parsed = JSON.parse(readFileSync5(file, "utf-8"));
|
|
1227
|
+
if (Array.isArray(parsed)) starts = parsed.filter((t) => typeof t === "number" && Number.isFinite(t));
|
|
1228
|
+
} catch {
|
|
1229
|
+
}
|
|
1230
|
+
starts = [...starts.filter((t) => now - t < 24 * 60 * 60 * 1e3), now].slice(-200);
|
|
1231
|
+
try {
|
|
1232
|
+
mkdirSync4(dirname2(file), { recursive: true });
|
|
1233
|
+
writeFileSync4(file, JSON.stringify(starts));
|
|
1234
|
+
} catch {
|
|
1235
|
+
}
|
|
1236
|
+
return starts.length;
|
|
1237
|
+
}
|
|
1238
|
+
function sampleResources(read = os, now = Date.now(), extra = {}) {
|
|
1210
1239
|
const [l1 = 0, l5 = 0, l15 = 0] = read.loadavg();
|
|
1211
1240
|
return {
|
|
1241
|
+
...Object.fromEntries(Object.entries(extra).filter(([, v]) => v !== void 0)),
|
|
1212
1242
|
loadAvg: [l1, l5, l15],
|
|
1213
1243
|
// `availableParallelism` would be better, but `cpus()` is what every supported Node has.
|
|
1214
1244
|
cpus: Math.max(1, read.cpus().length),
|
|
@@ -1413,6 +1443,9 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1413
1443
|
}
|
|
1414
1444
|
};
|
|
1415
1445
|
const attached = /* @__PURE__ */ new Map();
|
|
1446
|
+
const processStartedAt = Date.now();
|
|
1447
|
+
const starts24h = recordRunnerStart(processStartedAt);
|
|
1448
|
+
let relayReconnects = 0;
|
|
1416
1449
|
const blocked = /* @__PURE__ */ new Set();
|
|
1417
1450
|
let syncing = Promise.resolve();
|
|
1418
1451
|
const forceNext = /* @__PURE__ */ new Set();
|
|
@@ -1528,6 +1561,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1528
1561
|
// and taken when it did not — which is how a register lost to a boot-time
|
|
1529
1562
|
// `fetch failed` finally gets a second chance.
|
|
1530
1563
|
async (openedId, reconnect) => {
|
|
1564
|
+
if (reconnect) relayReconnects++;
|
|
1531
1565
|
if (!shouldRegisterOnOpen(reconnect, registered.has(openedId))) return;
|
|
1532
1566
|
await registerRuntime(openedId, reconnect ? false : force);
|
|
1533
1567
|
reportRegistration();
|
|
@@ -1559,7 +1593,14 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1559
1593
|
const heartbeat = () => {
|
|
1560
1594
|
const timer = setTimeout(async () => {
|
|
1561
1595
|
let failure = null;
|
|
1562
|
-
const
|
|
1596
|
+
const first = attached.values().next().value;
|
|
1597
|
+
const [relayRttMs, sessions] = await Promise.all([first ? first.probeRtt() : Promise.resolve(null), readSessionResources(localUrl, runnerToken)]);
|
|
1598
|
+
const resources = sampleResources(void 0, void 0, {
|
|
1599
|
+
disk: sampleDisk() ?? void 0,
|
|
1600
|
+
runner: { startedAt: processStartedAt, uptimeSec: Math.round(process.uptime()), starts24h, relayReconnects },
|
|
1601
|
+
relayRttMs,
|
|
1602
|
+
sessions: sessions ?? void 0
|
|
1603
|
+
});
|
|
1563
1604
|
for (const id of [...attached.keys()]) {
|
|
1564
1605
|
try {
|
|
1565
1606
|
await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime/heartbeat`, opts, { runnerNode, resources });
|
|
@@ -1639,6 +1680,18 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1639
1680
|
tick();
|
|
1640
1681
|
}
|
|
1641
1682
|
}
|
|
1683
|
+
async function readSessionResources(localUrl, runnerToken) {
|
|
1684
|
+
try {
|
|
1685
|
+
const res = await fetch(`${localUrl}/coding/resources`, { headers: { Authorization: `Bearer ${runnerToken}` }, signal: AbortSignal.timeout(3e3) });
|
|
1686
|
+
if (!res.ok) return null;
|
|
1687
|
+
const body = await res.json();
|
|
1688
|
+
return Array.isArray(body.sessions) ? body.sessions : null;
|
|
1689
|
+
} catch {
|
|
1690
|
+
return null;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
var RTT_PROBE_PREFIX = "rtt:";
|
|
1694
|
+
var RTT_ECHO_PREFIX = "rtt-echo:";
|
|
1642
1695
|
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl, runnerNode = hostname3()) {
|
|
1643
1696
|
let backoffMs = 1e3;
|
|
1644
1697
|
let reconnecting = false;
|
|
@@ -1646,6 +1699,7 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
1646
1699
|
let opened = false;
|
|
1647
1700
|
let socket = null;
|
|
1648
1701
|
let retryTimer = null;
|
|
1702
|
+
const rttWaiters = /* @__PURE__ */ new Map();
|
|
1649
1703
|
const connect = async () => {
|
|
1650
1704
|
if (closed) return;
|
|
1651
1705
|
let relayToken;
|
|
@@ -1686,6 +1740,10 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
1686
1740
|
}
|
|
1687
1741
|
return;
|
|
1688
1742
|
}
|
|
1743
|
+
if (text.startsWith(RTT_ECHO_PREFIX)) {
|
|
1744
|
+
rttWaiters.get(text.slice(RTT_ECHO_PREFIX.length))?.(Date.now());
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1689
1747
|
let cmd;
|
|
1690
1748
|
try {
|
|
1691
1749
|
cmd = JSON.parse(text);
|
|
@@ -1718,7 +1776,7 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
1718
1776
|
try {
|
|
1719
1777
|
result = text2 ? JSON.parse(text2) : {};
|
|
1720
1778
|
} catch {
|
|
1721
|
-
result = { raw: text2.slice(0, 500) };
|
|
1779
|
+
result = text2.length > 500 ? { raw: text2.slice(0, 500), rawChars: text2.length, truncated: true } : { raw: text2 };
|
|
1722
1780
|
}
|
|
1723
1781
|
try {
|
|
1724
1782
|
ws.send(JSON.stringify({ id: cmd.id, status: res.status, result }));
|
|
@@ -1766,6 +1824,31 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
1766
1824
|
} catch {
|
|
1767
1825
|
}
|
|
1768
1826
|
socket = null;
|
|
1827
|
+
},
|
|
1828
|
+
probeRtt(timeoutMs = 5e3) {
|
|
1829
|
+
const ws = socket;
|
|
1830
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return Promise.resolve(null);
|
|
1831
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1832
|
+
const sentAt = Date.now();
|
|
1833
|
+
return new Promise((resolveRtt) => {
|
|
1834
|
+
const timer = setTimeout(() => {
|
|
1835
|
+
rttWaiters.delete(id);
|
|
1836
|
+
resolveRtt(null);
|
|
1837
|
+
}, timeoutMs);
|
|
1838
|
+
timer.unref?.();
|
|
1839
|
+
rttWaiters.set(id, (echoedAt) => {
|
|
1840
|
+
clearTimeout(timer);
|
|
1841
|
+
rttWaiters.delete(id);
|
|
1842
|
+
resolveRtt(echoedAt - sentAt);
|
|
1843
|
+
});
|
|
1844
|
+
try {
|
|
1845
|
+
ws.send(`${RTT_PROBE_PREFIX}${id}`);
|
|
1846
|
+
} catch {
|
|
1847
|
+
clearTimeout(timer);
|
|
1848
|
+
rttWaiters.delete(id);
|
|
1849
|
+
resolveRtt(null);
|
|
1850
|
+
}
|
|
1851
|
+
});
|
|
1769
1852
|
}
|
|
1770
1853
|
};
|
|
1771
1854
|
}
|