@proagentstore/cli 0.2.3 → 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.
|
@@ -15,18 +15,22 @@ export class HeadlessSession {
|
|
|
15
15
|
cmdBin;
|
|
16
16
|
cmdArgs;
|
|
17
17
|
binName;
|
|
18
|
-
/** Wall-clock of the last stdout byte — drives the raw-mode idle heuristic. */
|
|
18
|
+
/** Wall-clock of the last stdout/stderr byte — drives the raw-mode idle heuristic. */
|
|
19
19
|
lastOutputAt = 0;
|
|
20
|
+
/** Raw-mode: has any output arrived since the current turn's input? */
|
|
21
|
+
sawOutputSinceInput = false;
|
|
22
|
+
/** Raw-mode: when the current turn started (absolute idle backstop). */
|
|
23
|
+
turnStartedAt = 0;
|
|
20
24
|
constructor(config) {
|
|
21
25
|
this.config = config;
|
|
22
26
|
this.sessionName = `pags-${config.clientType}-${config.id}`;
|
|
23
27
|
this.claudeSessionId = readState(config.statePath, config.id);
|
|
24
28
|
// Claude is the structured engine; everything else is a raw CLI.
|
|
25
29
|
this.mode = config.clientType === "claude" ? "stream-json" : "raw";
|
|
26
|
-
const
|
|
27
|
-
// A test/override bin wins
|
|
28
|
-
this.cmdBin = config.bin ??
|
|
29
|
-
this.cmdArgs =
|
|
30
|
+
const { bin, args } = parseCommand(config.command);
|
|
31
|
+
// A test/override bin wins for the binary only; the command's args are kept.
|
|
32
|
+
this.cmdBin = config.bin ?? (bin || "claude");
|
|
33
|
+
this.cmdArgs = args;
|
|
30
34
|
this.binName = (this.cmdBin.split("/").pop() || this.cmdBin) || "cli";
|
|
31
35
|
}
|
|
32
36
|
/** True while the agent process is running. */
|
|
@@ -40,10 +44,23 @@ export class HeadlessSession {
|
|
|
40
44
|
runState() {
|
|
41
45
|
if (!this.alive)
|
|
42
46
|
return "idle";
|
|
43
|
-
// Raw engines have no "turn over" event, so
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
// Raw engines have no "turn over" event, so we INFER idle. Three rules, in order:
|
|
48
|
+
// 1. produced output, then went quiet for 1.5s → settled (the common case).
|
|
49
|
+
// 2. NEVER produced output but 8s elapsed → a silent turn (just a prompt) is done.
|
|
50
|
+
// 3. absolute 15-min backstop → never wedge "thinking" forever (e.g. a heartbeat
|
|
51
|
+
// that keeps refreshing lastOutputAt). The user can also Restart from diagnostics.
|
|
52
|
+
// Rule 1 deliberately waits for first output so a slow first token can't flip us
|
|
53
|
+
// to idle mid-turn (which would make the brain proceed against a half-done turn).
|
|
54
|
+
if (this.mode === "raw" && this.run === "thinking") {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
const quiet = now - this.lastOutputAt;
|
|
57
|
+
const elapsed = now - this.turnStartedAt;
|
|
58
|
+
if (this.sawOutputSinceInput && quiet > 1500)
|
|
59
|
+
this.run = "idle";
|
|
60
|
+
else if (!this.sawOutputSinceInput && elapsed > 8000)
|
|
61
|
+
this.run = "idle";
|
|
62
|
+
else if (elapsed > 15 * 60 * 1000)
|
|
63
|
+
this.run = "idle";
|
|
47
64
|
}
|
|
48
65
|
return this.run === "thinking" ? "thinking" : "idle";
|
|
49
66
|
}
|
|
@@ -55,24 +72,10 @@ export class HeadlessSession {
|
|
|
55
72
|
start() {
|
|
56
73
|
if (this.alive)
|
|
57
74
|
return;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"];
|
|
63
|
-
for (const a of this.cmdArgs)
|
|
64
|
-
if (!args.includes(a))
|
|
65
|
-
args.push(a);
|
|
66
|
-
if (!args.includes("--dangerously-skip-permissions"))
|
|
67
|
-
args.push("--dangerously-skip-permissions");
|
|
68
|
-
// Continue the prior conversation after a runner restart (state in ~/.claude).
|
|
69
|
-
if (this.claudeSessionId)
|
|
70
|
-
args.push("--resume", this.claudeSessionId);
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
// Any other engine: run exactly what the user configured; capture stdout.
|
|
74
|
-
args = [...this.cmdArgs];
|
|
75
|
-
}
|
|
75
|
+
// stream-json: we own the structural flags + --resume; merge the user's extras
|
|
76
|
+
// (e.g. --model) without letting them clobber or orphan-value our flags. raw:
|
|
77
|
+
// run exactly what the user configured and capture stdout.
|
|
78
|
+
const args = this.mode === "stream-json" ? buildClaudeArgs(this.cmdArgs, this.claudeSessionId) : [...this.cmdArgs];
|
|
76
79
|
this.proc = spawn(this.cmdBin, args, {
|
|
77
80
|
cwd: this.config.workDir,
|
|
78
81
|
env: { ...process.env, ...this.config.env },
|
|
@@ -92,6 +95,7 @@ export class HeadlessSession {
|
|
|
92
95
|
this.proc.stdout?.on("data", (d) => this.onStdout(d.toString("utf8")));
|
|
93
96
|
this.proc.stderr?.on("data", (d) => {
|
|
94
97
|
this.lastOutputAt = Date.now();
|
|
98
|
+
this.sawOutputSinceInput = true;
|
|
95
99
|
const text = d.toString("utf8").trim();
|
|
96
100
|
if (text)
|
|
97
101
|
this.push(`[${this.binName}] ${stripAnsi(text)}`);
|
|
@@ -114,7 +118,10 @@ export class HeadlessSession {
|
|
|
114
118
|
this.start();
|
|
115
119
|
this.push(`\n❯ [${stamp()}] ${text}`); // ❯ — your turn, timestamped
|
|
116
120
|
this.run = "thinking";
|
|
117
|
-
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
this.lastOutputAt = now;
|
|
123
|
+
this.turnStartedAt = now;
|
|
124
|
+
this.sawOutputSinceInput = false; // arm the raw idle heuristic for THIS turn
|
|
118
125
|
try {
|
|
119
126
|
if (this.mode === "stream-json") {
|
|
120
127
|
const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
|
|
@@ -142,6 +149,7 @@ export class HeadlessSession {
|
|
|
142
149
|
/* ignore */
|
|
143
150
|
}
|
|
144
151
|
this.run = "idle";
|
|
152
|
+
this.lastOutputAt = Date.now();
|
|
145
153
|
}
|
|
146
154
|
/** Tear the process down. */
|
|
147
155
|
stop() {
|
|
@@ -157,6 +165,7 @@ export class HeadlessSession {
|
|
|
157
165
|
// ── internals ────────────────────────────────────────────────────────────
|
|
158
166
|
onStdout(chunk) {
|
|
159
167
|
this.lastOutputAt = Date.now();
|
|
168
|
+
this.sawOutputSinceInput = true; // a slow first token must NOT count as idle
|
|
160
169
|
this.buf += chunk;
|
|
161
170
|
let nl;
|
|
162
171
|
// biome-ignore lint/suspicious/noAssignInExpressions: standard line-buffer drain
|
|
@@ -170,6 +179,13 @@ export class HeadlessSession {
|
|
|
170
179
|
else
|
|
171
180
|
this.pushRaw(line); // raw engine — the line IS the terminal output
|
|
172
181
|
}
|
|
182
|
+
// A TUI/raw engine may render without newlines; don't let `buf` grow unbounded
|
|
183
|
+
// (and surface the partial output so the pane isn't blank). 16KB is generous.
|
|
184
|
+
if (this.buf.length > 16 * 1024) {
|
|
185
|
+
if (this.mode === "raw")
|
|
186
|
+
this.pushRaw(this.buf);
|
|
187
|
+
this.buf = "";
|
|
188
|
+
}
|
|
173
189
|
}
|
|
174
190
|
/** Raw-engine stdout: strip ANSI control codes and append to the transcript. */
|
|
175
191
|
pushRaw(line) {
|
|
@@ -230,6 +246,44 @@ export class HeadlessSession {
|
|
|
230
246
|
function stamp() {
|
|
231
247
|
return new Date().toTimeString().slice(0, 8);
|
|
232
248
|
}
|
|
249
|
+
/** Split a launch command into bin + args, respecting single/double quotes. */
|
|
250
|
+
export function parseCommand(command) {
|
|
251
|
+
const tokens = [];
|
|
252
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
253
|
+
let m;
|
|
254
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: tokenizer drain
|
|
255
|
+
while ((m = re.exec(command ?? "")) !== null)
|
|
256
|
+
tokens.push(m[1] ?? m[2] ?? m[3] ?? "");
|
|
257
|
+
return { bin: tokens[0] ?? "", args: tokens.slice(1) };
|
|
258
|
+
}
|
|
259
|
+
/** Structural flags PAGS owns for the Claude stream-json engine — a user command
|
|
260
|
+
* must not override or duplicate these (and must not orphan their values). */
|
|
261
|
+
const RESERVED_CLAUDE_FLAGS = new Set(["-p", "--print", "--input-format", "--output-format", "--verbose", "--resume"]);
|
|
262
|
+
/**
|
|
263
|
+
* Build Claude's argv: our structural stream-json flags + the user's extra args
|
|
264
|
+
* (e.g. `--model`), with reserved flags (and their values) stripped so the user
|
|
265
|
+
* can't clobber the protocol or leave an orphaned positional. `--resume` is added
|
|
266
|
+
* last from our persisted session id, never from the user's command.
|
|
267
|
+
*/
|
|
268
|
+
export function buildClaudeArgs(userArgs, resumeId) {
|
|
269
|
+
const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"];
|
|
270
|
+
for (let i = 0; i < userArgs.length; i++) {
|
|
271
|
+
const a = userArgs[i];
|
|
272
|
+
if (RESERVED_CLAUDE_FLAGS.has(a)) {
|
|
273
|
+
// drop the flag AND its value (when the next token isn't itself a flag)
|
|
274
|
+
if (i + 1 < userArgs.length && !userArgs[i + 1].startsWith("-"))
|
|
275
|
+
i++;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (!args.includes(a))
|
|
279
|
+
args.push(a);
|
|
280
|
+
}
|
|
281
|
+
if (!args.includes("--dangerously-skip-permissions"))
|
|
282
|
+
args.push("--dangerously-skip-permissions");
|
|
283
|
+
if (resumeId)
|
|
284
|
+
args.push("--resume", resumeId);
|
|
285
|
+
return args;
|
|
286
|
+
}
|
|
233
287
|
/** Strip ANSI/VT escape sequences so a raw CLI's coloured output reads as plain text. */
|
|
234
288
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping terminal escape codes
|
|
235
289
|
function stripAnsi(s) {
|
|
@@ -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(
|
|
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
|
|
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";
|