@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.
@@ -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
- /** Append one line of the turn's own output to its tail, keeping the newest within the bounds above. */
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
- while (total > MAX_TURN_TAIL_CHARS && tail.length > 1)
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) || "").slice(0, MAX_TURN_DETAIL);
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().slice(0, MAX_TURN_DETAIL);
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
- return this.transcript.join("\n");
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
- if (this.transcript.length > 4000)
734
- this.transcript = this.transcript.slice(-3000);
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
- if (this.transcript.length > 4000)
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
- ? ["grep", "-n", "-I", "-i", "-F", "--untracked", "--max-count", String(SEARCH_PER_FILE), "-e", pattern]
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
- const all = mode === "content"
301
- ? lines.map(parseGrepLine).filter((m) => m !== null)
302
- : // The pattern never reaches git here — the filter is a plain case-insensitive substring
303
- // over the path, which is what "find the file called X" actually means.
304
- lines.filter((p) => p.toLowerCase().includes(pattern.toLowerCase())).map((p) => ({ path: p }));
305
- return { mode, pattern, matches: all.slice(0, limit), shown: Math.min(all.length, limit), total: all.length, truncated: all.length > limit };
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
- return { path: line.slice(0, first), line: n, text: line.slice(second + 1).trim().slice(0, SEARCH_MAX_LINE) };
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 (it.name.startsWith(".") || IGNORE_DIRS.has(it.name))
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 { root: relPath, entries, truncated, truncatedByDepth, depthCap };
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 { root: relPath, entries, truncated, truncatedByDepth, depthCap };
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 pane = clip(session.snapshot());
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 entries = readdirSync(dir, { withFileTypes: true })
308
- .filter((e) => !e.name.startsWith("."))
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
- return json(res, 200, { dir, entries });
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
- 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);
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.74",
3
+ "version": "0.4.76",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",