@proagentstore/cli 0.4.75 → 0.4.77

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.
@@ -295,6 +295,10 @@ export class HeadlessSession {
295
295
  get lastTurn() {
296
296
  return this.turnReport;
297
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
+ }
298
302
  /** Is a process running THIS instant? The persistent engine's liveness, and the spawn guard. */
299
303
  get procAlive() {
300
304
  return this.proc !== null && this.proc.exitCode === null && this.proc.signalCode === null;
@@ -159,8 +159,15 @@ export class InspectError extends Error {
159
159
  }
160
160
  const DEFAULT_MAX_FILE_BYTES = 64 * 1024;
161
161
  const HARD_MAX_FILE_BYTES = 128 * 1024;
162
- /** Read a text file inside the repo. Rejects traversal, oversize, and binary files. */
163
- export function readRepoFile(workDir, relPath, maxBytes) {
162
+ /**
163
+ * Read a text file inside the repo. Rejects traversal and binary files; returns at most `maxBytes`.
164
+ *
165
+ * Ranged from #954: `startLine`/`startColumn` say where the returned bytes begin, so no part of a file
166
+ * is out of reach — before it, a read always began at byte 0 and nothing past the cap could be read.
167
+ * `firstLine`/`firstColumn`/`totalLines` are always present, which is how the cloud tells a runner that
168
+ * honours the range from an older one that silently started at the top.
169
+ */
170
+ export function readRepoFile(workDir, relPath, maxBytes, from = {}) {
164
171
  const abs = resolveInside(workDir, relPath, { checkSymlink: true });
165
172
  const st = statSync(abs);
166
173
  if (!st.isFile())
@@ -171,8 +178,31 @@ export function readRepoFile(workDir, relPath, maxBytes) {
171
178
  const head = buf.subarray(0, 8192);
172
179
  if (head.includes(0))
173
180
  return { path: relPath, size: st.size, truncated: false, binary: true };
174
- const truncated = buf.length > cap;
175
- return { path: relPath, size: st.size, truncated, content: buf.subarray(0, cap).toString("utf-8") };
181
+ let newlines = 0;
182
+ for (let i = buf.indexOf(10); i !== -1; i = buf.indexOf(10, i + 1))
183
+ newlines++;
184
+ const totalLines = newlines + (buf.length > 0 && buf[buf.length - 1] !== 10 ? 1 : 0);
185
+ const firstLine = Math.max(1, Math.trunc(Number(from.startLine) || 1));
186
+ const firstColumn = Math.max(1, Math.trunc(Number(from.startColumn) || 1));
187
+ const ranged = { firstLine, firstColumn, totalLines };
188
+ if (firstLine > totalLines)
189
+ return { path: relPath, size: st.size, truncated: false, content: "", ...ranged };
190
+ // The byte where line `firstLine` begins…
191
+ let start = 0;
192
+ for (let line = 1; line < firstLine; line++)
193
+ start = buf.indexOf(10, start) + 1;
194
+ // …and, for a column past the first, where that CHARACTER begins — counted on the decoded line, so
195
+ // a multi-byte character is never split.
196
+ if (firstColumn > 1) {
197
+ const eol = buf.indexOf(10, start);
198
+ const lineText = buf.subarray(start, eol === -1 ? buf.length : eol).toString("utf-8");
199
+ start += Buffer.byteLength(lineText.slice(0, firstColumn - 1), "utf-8");
200
+ }
201
+ let end = Math.min(buf.length, start + cap);
202
+ // Never end inside a multi-byte character: it would decode as U+FFFD at the cut.
203
+ while (end < buf.length && end > start && (buf[end] & 0xc0) === 0x80)
204
+ end--;
205
+ return { path: relPath, size: st.size, truncated: end < buf.length, content: buf.subarray(start, end).toString("utf-8"), ...ranged };
176
206
  }
177
207
  /** Run a whitelisted read-only git command in the repo. Never uses a shell. */
178
208
  export function runRepoGit(workDir, cmd, opts = {}) {
@@ -53,7 +53,7 @@ export class CodingRuntime {
53
53
  }
54
54
  /** Read one file inside the session's repo (traversal-guarded, size-capped). */
55
55
  readFile(input) {
56
- return readRepoFile(this.resolveWorkDir(input), input.path, input.maxBytes);
56
+ return readRepoFile(this.resolveWorkDir(input), input.path, input.maxBytes, { startLine: input.startLine, startColumn: input.startColumn });
57
57
  }
58
58
  /** Run a whitelisted read-only git command in the session's repo. */
59
59
  git(input) {
@@ -286,6 +286,10 @@ export class CodingRuntime {
286
286
  runState: s.runState(),
287
287
  }));
288
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
+ }
289
293
  /** Rich diagnostics for every tracked session — the console's transparency view. */
290
294
  diagnostics() {
291
295
  return [...this.sessions.entries()].map(([sessionId, s]) => ({
@@ -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
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
- function sampleResources(read = os, now = Date.now()) {
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 resources = sampleResources();
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);
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.75",
3
+ "version": "0.4.77",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",