@proagentstore/cli 0.2.4 → 0.2.5

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.
@@ -149,6 +149,7 @@ export class HeadlessSession {
149
149
  /* ignore */
150
150
  }
151
151
  this.run = "idle";
152
+ this.lastOutputAt = Date.now();
152
153
  }
153
154
  /** Tear the process down. */
154
155
  stop() {
@@ -98,6 +98,20 @@ export class CodingRuntime {
98
98
  tmuxSession: s.sessionName,
99
99
  }));
100
100
  }
101
+ /** Rich diagnostics for every tracked session — the console's transparency view. */
102
+ diagnostics() {
103
+ return [...this.sessions.entries()].map(([sessionId, s]) => ({
104
+ sessionId,
105
+ tmuxSession: s.sessionName,
106
+ alive: s.alive,
107
+ runState: s.alive ? s.runState() : "idle",
108
+ ready: s.alive ? s.ready : false,
109
+ paneLines: s.alive ? (s.snapshot().split("\n").length) : 0,
110
+ clientType: s.config.clientType,
111
+ workDir: s.config.workDir,
112
+ takeover: this.takeovers.has(sessionId),
113
+ }));
114
+ }
101
115
  // ── Human takeover (the "stuck" handoff) ────────────────────────────────
102
116
  // A text-frame equivalent of the browser takeover: the console shows the live
103
117
  // pane and forwards the human's keystrokes until they Resume.
@@ -161,6 +161,20 @@ async function route(runner, req, res) {
161
161
  if (req.method === "GET" && path === "/coding/sessions") {
162
162
  return json(res, 200, { sessions: runner.coding.list() });
163
163
  }
164
+ if (req.method === "GET" && path === "/coding/diagnostics") {
165
+ const { listSessions: tmuxList } = await import("./coding/tmux.js");
166
+ const allTmux = tmuxList();
167
+ const pagsTmux = allTmux.filter((n) => n.startsWith("pags-"));
168
+ const tracked = runner.coding.diagnostics();
169
+ const trackedNames = new Set(tracked.map((s) => s.tmuxSession));
170
+ const orphanedTmux = pagsTmux.filter((n) => !trackedNames.has(n));
171
+ return json(res, 200, {
172
+ tracked,
173
+ orphanedTmux,
174
+ tmuxTotal: allTmux.length,
175
+ pagsTmuxTotal: pagsTmux.length,
176
+ });
177
+ }
164
178
  if (req.method === "POST" && path === "/coding/event") {
165
179
  // Brain progress events — recorded by PAGS, ignored locally. Accept + ack.
166
180
  return json(res, 200, { ok: true });
package/dist/index.js CHANGED
@@ -905,6 +905,98 @@ function responseErrorMessage(data, text, statusText) {
905
905
  function collectCapability(value, previous = []) {
906
906
  return [...previous, value];
907
907
  }
908
+ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts) {
909
+ const apiBase = pagsApiBase(opts.apiBase).replace(/^http/, "ws");
910
+ const pagsToken = clean(opts.pagsToken) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
911
+ if (!pagsToken) throw new Error("PAGS token required for WebSocket relay");
912
+ const capabilities = await requestRunner("GET", "/capabilities", { url: localUrl, token: runnerToken, instanceId: instanceIds[0] });
913
+ const caps = Array.isArray(capabilities.capabilities) ? capabilities.capabilities.filter((item) => typeof item === "string") : [];
914
+ for (const id of instanceIds) {
915
+ await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime`, opts, {
916
+ endpointUrl: localUrl,
917
+ token: runnerToken,
918
+ placement: "local",
919
+ capabilities: caps,
920
+ runnerVersion: ""
921
+ }).catch((e) => writeError(`register ${id.slice(0, 8)}\u2026 failed: ${e instanceof Error ? e.message : String(e)}`));
922
+ }
923
+ for (const id of instanceIds) {
924
+ openRelaySocket(id, apiBase, pagsToken, localUrl, runnerToken);
925
+ }
926
+ writeLine("Runtime registered with PAGS \u2713");
927
+ writeLine("");
928
+ writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
929
+ writeLine(" \u2705 CONNECTED \u2014 WebSocket relay (no tunnel)");
930
+ writeLine(` Agents: ${instanceIds.length} instance${instanceIds.length === 1 ? "" : "s"}`);
931
+ writeLine(" No cloudflared needed. Ctrl+C to disconnect.");
932
+ writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
933
+ let stopped = false;
934
+ const heartbeatLoop = async () => {
935
+ while (!stopped) {
936
+ await new Promise((r) => setTimeout(r, 3e4));
937
+ if (stopped) break;
938
+ for (const id of instanceIds) {
939
+ await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime/heartbeat`, opts, {}).catch(() => void 0);
940
+ }
941
+ }
942
+ };
943
+ void heartbeatLoop();
944
+ process.once("beforeExit", () => {
945
+ stopped = true;
946
+ });
947
+ }
948
+ function openRelaySocket(instanceId, wsBase, pagsToken, localUrl, runnerToken) {
949
+ let backoffMs = 1e3;
950
+ let reconnecting = false;
951
+ const connect = () => {
952
+ const url = `${wsBase}/v1/relay/${encodeURIComponent(instanceId)}/connect?token=${encodeURIComponent(pagsToken)}`;
953
+ const ws = new WebSocket(url);
954
+ ws.onopen = () => {
955
+ backoffMs = 1e3;
956
+ writeLine(`Relay connected: ${instanceId.slice(0, 8)}\u2026`);
957
+ };
958
+ ws.onmessage = async (event) => {
959
+ const text = typeof event.data === "string" ? event.data : String(event.data);
960
+ let cmd;
961
+ try {
962
+ cmd = JSON.parse(text);
963
+ } catch {
964
+ return;
965
+ }
966
+ if (!cmd.id || !cmd.path) return;
967
+ const method = (cmd.method || "POST").toUpperCase();
968
+ const hasBody = method !== "GET" && method !== "HEAD" && cmd.body !== void 0;
969
+ try {
970
+ const headers = {};
971
+ if (hasBody) headers["Content-Type"] = "application/json";
972
+ if (runnerToken) headers.Authorization = `Bearer ${runnerToken}`;
973
+ headers["X-PAGS-Instance-Id"] = instanceId;
974
+ const res = await fetch(`${localUrl}${cmd.path}`, {
975
+ method,
976
+ headers,
977
+ body: hasBody ? JSON.stringify(cmd.body) : void 0
978
+ });
979
+ const result = await res.json().catch(() => ({}));
980
+ ws.send(JSON.stringify({ id: cmd.id, status: res.status, result }));
981
+ } catch (err) {
982
+ ws.send(JSON.stringify({ id: cmd.id, status: 500, error: err instanceof Error ? err.message : String(err) }));
983
+ }
984
+ };
985
+ ws.onclose = () => {
986
+ if (reconnecting) return;
987
+ reconnecting = true;
988
+ writeLine(`Relay disconnected: ${instanceId.slice(0, 8)}\u2026 \u2014 reconnecting in ${Math.round(backoffMs / 1e3)}s`);
989
+ setTimeout(() => {
990
+ reconnecting = false;
991
+ connect();
992
+ }, backoffMs);
993
+ backoffMs = Math.min(backoffMs * 2, 3e4);
994
+ };
995
+ ws.onerror = () => {
996
+ };
997
+ };
998
+ connect();
999
+ }
908
1000
  function createRunnerCommand() {
909
1001
  const command = new Command6("runner").description(
910
1002
  "Manage the local FAGS browser runtime for ProAgentStore agents"
@@ -912,7 +1004,7 @@ function createRunnerCommand() {
912
1004
  command.command("start").description("Start the local FAGS browser runtime in the foreground").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind", "49171").option("--data-dir <path>", "Runner data directory").option("--token <token>", "Require this bearer token").option("--instance-id <id>", "Bind runner requests to a PAGS instance id").option("--headless", "Run Playwright headless").action(async (opts) => {
913
1005
  await startRunnerForeground(opts);
914
1006
  });
915
- command.command("connect <instanceIds...>").description("Start ONE local runtime, open a Cloudflare quick tunnel, and register it for every given PAGS instance").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind", "49171").option("--data-dir <path>", "Runner data directory").option("--token <token>", "Runner bearer token. Defaults to PAGS_RUNNER_TOKEN or a generated token").option("--headless", "Run Playwright headless").option("--api-base <url>", "PAGS API base URL").option("--pags-token <token>", "PAGS session token. Defaults to PAGS_TOKEN").option("--cloudflared <path>", "cloudflared executable").option("--runner-version <version>", "Runner version").option("--skip-probe", "Skip PAGS FAGS-runtime probe after registration").action(async (instanceIds, opts) => {
1007
+ command.command("connect <instanceIds...>").description("Start ONE local runtime, open a Cloudflare quick tunnel, and register it for every given PAGS instance").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind", "49171").option("--data-dir <path>", "Runner data directory").option("--token <token>", "Runner bearer token. Defaults to PAGS_RUNNER_TOKEN or a generated token").option("--headless", "Run Playwright headless").option("--api-base <url>", "PAGS API base URL").option("--pags-token <token>", "PAGS session token. Defaults to PAGS_TOKEN").option("--cloudflared <path>", "cloudflared executable").option("--runner-version <version>", "Runner version").option("--skip-probe", "Skip PAGS FAGS-runtime probe after registration").option("--tunnel <mode>", "Tunnel mode: 'ws' (WebSocket relay), 'named' (production), or 'quick' (default)", "quick").action(async (instanceIds, opts) => {
916
1008
  const runnerToken = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID()}`;
917
1009
  const host = clean(opts.host) || "127.0.0.1";
918
1010
  const port = clean(opts.port) || String(await findFreePort2(49171));
@@ -951,6 +1043,77 @@ function createRunnerCommand() {
951
1043
  try {
952
1044
  await waitForLocalRunner({ url: localUrl, token: runnerToken, instanceId: primary });
953
1045
  writeLine(`Local FAGS runtime healthy at ${localUrl}`);
1046
+ if (opts.tunnel === "ws") {
1047
+ await connectViaRelay(instanceIds, localUrl, runnerToken, opts);
1048
+ await new Promise((resolvePromise) => {
1049
+ runner.on("exit", () => resolvePromise());
1050
+ });
1051
+ return;
1052
+ }
1053
+ if (opts.tunnel === "named") {
1054
+ writeLine("Provisioning named tunnel\u2026");
1055
+ const provRes = await requestPags("POST", "/v1/tunnel/provision", opts, {});
1056
+ const prov = provRes;
1057
+ if (!prov.connectorToken) {
1058
+ writeError(`Named tunnel provisioning failed: ${prov.error || "no token returned"}. Falling back to quick tunnel.`);
1059
+ } else {
1060
+ const tunnelUrl2 = prov.endpointUrl || `https://${prov.hostname}`;
1061
+ writeLine(`Named tunnel: ${tunnelUrl2}`);
1062
+ const cloudflaredPath2 = await resolveCloudflaredCommand(opts.cloudflared);
1063
+ const tunnelProc = spawn2(cloudflaredPath2, ["tunnel", "run", "--token", prov.connectorToken], {
1064
+ stdio: ["ignore", "pipe", "pipe"],
1065
+ shell: process.platform === "win32"
1066
+ });
1067
+ tunnel = tunnelProc;
1068
+ tunnelProc.stdout?.on("data", (d2) => {
1069
+ const t = d2.toString("utf-8");
1070
+ if (/registered/.test(t) || /CONNECTED/.test(t)) writeLine("Named tunnel connected \u2713");
1071
+ });
1072
+ tunnelProc.stderr?.on("data", (d2) => {
1073
+ const t = d2.toString("utf-8");
1074
+ if (/registered/.test(t) || /CONNECTED/.test(t)) writeLine("Named tunnel connected \u2713");
1075
+ });
1076
+ await new Promise((r) => setTimeout(r, 4e3));
1077
+ const capabilities = await requestRunner("GET", "/capabilities", { url: localUrl, token: runnerToken, instanceId: primary });
1078
+ const caps = Array.isArray(capabilities.capabilities) ? capabilities.capabilities.filter((item) => typeof item === "string") : [];
1079
+ for (const id of instanceIds) {
1080
+ try {
1081
+ await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime`, opts, {
1082
+ endpointUrl: tunnelUrl2,
1083
+ token: runnerToken,
1084
+ placement: "local",
1085
+ capabilities: caps,
1086
+ runnerVersion: clean(opts.runnerVersion) || ""
1087
+ });
1088
+ } catch (error) {
1089
+ writeError(`register ${id.slice(0, 8)}\u2026 failed (${error instanceof Error ? error.message : String(error)})`);
1090
+ }
1091
+ }
1092
+ writeLine("Runtime registered with PAGS \u2713");
1093
+ writeLine("");
1094
+ writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
1095
+ writeLine(" \u2705 CONNECTED \u2014 named tunnel (production)");
1096
+ writeLine(` Agents: ${instanceIds.length} instance${instanceIds.length === 1 ? "" : "s"}`);
1097
+ writeLine(` Endpoint: ${tunnelUrl2}`);
1098
+ writeLine(" Stable URL \u2014 no reconnect churn. Ctrl+C to disconnect.");
1099
+ writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
1100
+ let stopped2 = false;
1101
+ void (async () => {
1102
+ while (!stopped2) {
1103
+ await new Promise((r) => setTimeout(r, 3e4));
1104
+ if (stopped2) break;
1105
+ for (const id of instanceIds) {
1106
+ await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime/heartbeat`, opts, {}).catch(() => void 0);
1107
+ }
1108
+ }
1109
+ })();
1110
+ await new Promise((resolvePromise) => {
1111
+ runner.on("exit", () => resolvePromise());
1112
+ });
1113
+ stopped2 = true;
1114
+ return;
1115
+ }
1116
+ }
954
1117
  const cloudflaredPath = await resolveCloudflaredCommand(opts.cloudflared);
955
1118
  const openTunnel = () => new Promise((resolveTunnel, reject) => {
956
1119
  let output = "";
@@ -1000,11 +1163,15 @@ function createRunnerCommand() {
1000
1163
  writeLine(" Auto-reconnect is on \u2014 keep this terminal open. Ctrl+C to disconnect.");
1001
1164
  writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
1002
1165
  let stopped = false;
1166
+ let consecutiveFailures = 0;
1167
+ let respawnBackoffMs = 3e4;
1168
+ const PROBE_FAIL_THRESHOLD = 3;
1169
+ const MAX_BACKOFF_MS = 5 * 6e4;
1003
1170
  const probeTunnel = async (url) => {
1004
1171
  try {
1005
1172
  const res = await fetch(`${url.replace(/\/$/, "")}/health`, {
1006
1173
  headers: { Authorization: `Bearer ${runnerToken}`, "X-PAGS-Instance-Id": primary },
1007
- signal: AbortSignal.timeout(8e3)
1174
+ signal: AbortSignal.timeout(1e4)
1008
1175
  });
1009
1176
  return res.ok;
1010
1177
  } catch {
@@ -1024,13 +1191,21 @@ function createRunnerCommand() {
1024
1191
  await new Promise((r) => setTimeout(r, 3e4));
1025
1192
  if (stopped) break;
1026
1193
  if (await probeTunnel(tunnelUrl)) {
1194
+ consecutiveFailures = 0;
1195
+ respawnBackoffMs = 3e4;
1027
1196
  const beat = await heartbeatAll();
1028
1197
  if (!beat) {
1029
1198
  await registerRuntime(tunnelUrl).then(() => writeLine("\u2705 Re-registered with PAGS")).catch(() => void 0);
1030
1199
  }
1031
1200
  continue;
1032
1201
  }
1202
+ consecutiveFailures++;
1203
+ if (consecutiveFailures < PROBE_FAIL_THRESHOLD) {
1204
+ writeLine(`\u26A0 Tunnel probe failed (${consecutiveFailures}/${PROBE_FAIL_THRESHOLD}) \u2014 will retry`);
1205
+ continue;
1206
+ }
1033
1207
  writeLine("\u26A0 Tunnel unreachable \u2014 reconnecting\u2026");
1208
+ consecutiveFailures = 0;
1034
1209
  try {
1035
1210
  if (tunnel && !tunnel.killed) tunnel.kill("SIGTERM");
1036
1211
  } catch {
@@ -1040,9 +1215,12 @@ function createRunnerCommand() {
1040
1215
  tunnel = next.proc;
1041
1216
  tunnelUrl = next.url;
1042
1217
  await registerRuntime(tunnelUrl);
1218
+ respawnBackoffMs = 3e4;
1043
1219
  writeLine(`\u2705 Reconnected: ${tunnelUrl}`);
1044
1220
  } catch (error) {
1045
- writeError(`reconnect failed (${error instanceof Error ? error.message : String(error)}); retrying in 30s`);
1221
+ writeError(`reconnect failed (${error instanceof Error ? error.message : String(error)}); retrying in ${Math.round(respawnBackoffMs / 1e3)}s`);
1222
+ await new Promise((r) => setTimeout(r, respawnBackoffMs));
1223
+ respawnBackoffMs = Math.min(respawnBackoffMs * 2, MAX_BACKOFF_MS);
1046
1224
  }
1047
1225
  }
1048
1226
  })();
@@ -1291,7 +1469,7 @@ async function stopRunnerProcesses() {
1291
1469
  }
1292
1470
  return stopped;
1293
1471
  }
1294
- var upCommand = new Command7("up").description("Start the browser runner for all your agent instances").option("--headless", "Run browser in headless mode").option("--instance <id>", "Connect to a specific instance only").action(async (opts) => {
1472
+ var upCommand = new Command7("up").description("Start the browser runner for all your agent instances").option("--headless", "Run browser in headless mode").option("--instance <id>", "Connect to a specific instance only").option("--tunnel <mode>", "Tunnel mode: 'ws' (WebSocket relay, recommended), 'named' (production, stable), or 'quick' (default, trycloudflare.com)", "quick").action(async (opts) => {
1295
1473
  const session = requireSession();
1296
1474
  const state = {
1297
1475
  user: session.user.login,
@@ -1338,6 +1516,8 @@ var upCommand = new Command7("up").description("Start the browser runner for all
1338
1516
  const cliPath = process.argv[1];
1339
1517
  const args = [cliPath, "runner", "connect", ...instances.map((i) => i.id)];
1340
1518
  if (opts.headless) args.push("--headless");
1519
+ if (opts.tunnel === "named") args.push("--tunnel", "named");
1520
+ if (opts.tunnel === "ws") args.push("--tunnel", "ws");
1341
1521
  const child = spawn3(process.execPath, args, {
1342
1522
  stdio: ["ignore", "pipe", "pipe"],
1343
1523
  env: { ...process.env, PAGS_TOKEN: session.token }
@@ -1358,6 +1538,21 @@ var upCommand = new Command7("up").description("Start the browser runner for all
1358
1538
  printStatus(state);
1359
1539
  continue;
1360
1540
  }
1541
+ if (trimmed.includes("Relay connected:")) {
1542
+ state.tunnel = "online";
1543
+ state.tunnelUrl = "WebSocket relay";
1544
+ state.lastEvent = "Relay connected";
1545
+ printStatus(state);
1546
+ continue;
1547
+ }
1548
+ if (trimmed.includes("WebSocket relay (no tunnel)")) {
1549
+ state.tunnel = "online";
1550
+ state.tunnelUrl = "WebSocket relay";
1551
+ state.registration = "registered";
1552
+ state.lastEvent = "Connected via WebSocket relay";
1553
+ printStatus(state);
1554
+ continue;
1555
+ }
1361
1556
  if (trimmed.includes("FAGS browser runtime listening")) {
1362
1557
  state.runner = "online";
1363
1558
  state.lastEvent = "Runner started";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",