@proagentstore/cli 0.4.45 → 0.4.47
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/terminal.js +104 -1
- package/dist/browser-runner/coding/tmux.js +41 -0
- package/dist/browser-runner/server.js +54 -11
- package/dist/index.js +282 -124
- package/package.json +1 -1
|
@@ -76,7 +76,14 @@ export function activeTerminalCommand(target, backend) {
|
|
|
76
76
|
switch (t.backend) {
|
|
77
77
|
case "tmux": {
|
|
78
78
|
const out = tmuxExec(["display-message", "-p", "-t", t.id, "#{pane_current_command}"]).trim();
|
|
79
|
-
|
|
79
|
+
// `#{pane_current_command}` reads the pane process's argv, and Claude Code rewrites
|
|
80
|
+
// its own — measured on this platform's flagship case, tmux answered `2.1.226` (the
|
|
81
|
+
// version) for a pane whose process `comm` was `claude`. So `aiCliDrives` was 0 for a
|
|
82
|
+
// week of driving Claude Code through a tmux Operator. `comm` is the executable name
|
|
83
|
+
// and no title rewrite touches it, so ask the process tree when the argv is not a
|
|
84
|
+
// name (#498). Only then: a pane reporting `zsh` genuinely has nothing in the
|
|
85
|
+
// foreground, and promoting a background child would be an invention.
|
|
86
|
+
return resolvePaneCommand(out, () => paneDescendantComms(t.id));
|
|
80
87
|
}
|
|
81
88
|
case "kitty":
|
|
82
89
|
return kittyForegroundCommand(t.id);
|
|
@@ -90,6 +97,102 @@ export function activeTerminalCommand(target, backend) {
|
|
|
90
97
|
return null;
|
|
91
98
|
}
|
|
92
99
|
}
|
|
100
|
+
/** Shell names that mean "the pane is at a prompt", not "this program is running". */
|
|
101
|
+
const SHELL_NAMES = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "tcsh", "csh", "login", "screen", "tmux"]);
|
|
102
|
+
/** A program name is a bare token. A version (`2.1.226`) and a sentence are both NOT one. */
|
|
103
|
+
export function looksLikeCommandName(raw) {
|
|
104
|
+
const value = String(raw ?? "").trim();
|
|
105
|
+
if (!value || value.length > 60)
|
|
106
|
+
return false;
|
|
107
|
+
if (/^v?\d+(\.\d+)+$/.test(value))
|
|
108
|
+
return false; // a version string is not a program name
|
|
109
|
+
return /^[A-Za-z_][\w.@+-]*$/.test(value);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* What the pane is running, given what tmux SAID and a way to ask the process tree (#498).
|
|
113
|
+
*
|
|
114
|
+
* Pure so the measured fixture — tmux says `2.1.226`, the pane's child `comm` is `claude` — is a
|
|
115
|
+
* unit test rather than a story. Keeps tmux's answer whenever it is a name: that is the fast path,
|
|
116
|
+
* it costs no extra exec, and the descendant walk can only ever be a second-best guess.
|
|
117
|
+
*/
|
|
118
|
+
export function resolvePaneCommand(reported, listDescendantComms) {
|
|
119
|
+
const value = String(reported ?? "").trim();
|
|
120
|
+
if (looksLikeCommandName(value))
|
|
121
|
+
return value;
|
|
122
|
+
// The probe is best-effort by construction: `pgrep`/`ps` availability differs by platform, and
|
|
123
|
+
// a missing one must degrade to what tmux said, not lose it.
|
|
124
|
+
let descendants = [];
|
|
125
|
+
try {
|
|
126
|
+
descendants = listDescendantComms();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
descendants = [];
|
|
130
|
+
}
|
|
131
|
+
for (const raw of descendants) {
|
|
132
|
+
const name = (String(raw ?? "").trim().split("/").pop() ?? "").trim();
|
|
133
|
+
if (!looksLikeCommandName(name))
|
|
134
|
+
continue;
|
|
135
|
+
if (SHELL_NAMES.has(name.toLowerCase()))
|
|
136
|
+
continue;
|
|
137
|
+
return name;
|
|
138
|
+
}
|
|
139
|
+
// Nothing better found: hand back exactly what tmux said. The cloud decides what an
|
|
140
|
+
// unreadable value means; this module does not invent a name it did not read.
|
|
141
|
+
return value || null;
|
|
142
|
+
}
|
|
143
|
+
/** Bounded, so a probe on a path a Loop drives continuously can never become the slow part. */
|
|
144
|
+
const PROBE_TIMEOUT_MS = 1_000;
|
|
145
|
+
/** `comm` for the pane's descendant processes, nearest first. `[]` on any failure. */
|
|
146
|
+
function paneDescendantComms(paneId) {
|
|
147
|
+
let pid = "";
|
|
148
|
+
try {
|
|
149
|
+
pid = tmuxExec(["display-message", "-p", "-t", paneId, "#{pane_pid}"], PROBE_TIMEOUT_MS).trim();
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
if (!/^\d+$/.test(pid))
|
|
155
|
+
return [];
|
|
156
|
+
const found = [];
|
|
157
|
+
let frontier = [pid];
|
|
158
|
+
// Two levels is enough for `zsh → claude` and for one wrapper in between, and it bounds the
|
|
159
|
+
// work: `pgrep`/`ps` availability differs by platform, so every step degrades to today's
|
|
160
|
+
// answer rather than throwing (the runner is macOS/Linux; kitty and iTerm2 are untouched).
|
|
161
|
+
for (let depth = 0; depth < 2 && frontier.length > 0; depth++) {
|
|
162
|
+
const children = childPids(frontier);
|
|
163
|
+
if (children.length === 0)
|
|
164
|
+
break;
|
|
165
|
+
found.push(...commsFor(children));
|
|
166
|
+
frontier = children;
|
|
167
|
+
}
|
|
168
|
+
return found;
|
|
169
|
+
}
|
|
170
|
+
function childPids(parents) {
|
|
171
|
+
try {
|
|
172
|
+
const out = execFileSync("pgrep", ["-P", parents.join(",")], {
|
|
173
|
+
encoding: "utf8",
|
|
174
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
175
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
176
|
+
});
|
|
177
|
+
return out.split("\n").map((s) => s.trim()).filter((s) => /^\d+$/.test(s)).slice(0, 16);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return []; // pgrep exits non-zero when nothing matches — that is an answer, not an error
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function commsFor(pids) {
|
|
184
|
+
try {
|
|
185
|
+
const out = execFileSync("ps", ["-o", "comm=", "-p", pids.join(",")], {
|
|
186
|
+
encoding: "utf8",
|
|
187
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
188
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
189
|
+
});
|
|
190
|
+
return out.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
93
196
|
/** The foreground process of a kitty window, from `kitty @ ls`. Null if it can't be determined. */
|
|
94
197
|
function kittyForegroundCommand(id) {
|
|
95
198
|
try {
|
|
@@ -111,6 +111,47 @@ export function runCommand(target, command) {
|
|
|
111
111
|
sendText(target, command);
|
|
112
112
|
sendKey(target, "Enter");
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Settle heuristic constants — mirror the Coder headless.ts values (1.5s quiet = idle;
|
|
116
|
+
* 8s absolute backstop for a slow-booting CLI). The short backstop covers send/run where
|
|
117
|
+
* the pane is already live; the long one is for new-session launches where the CLI may
|
|
118
|
+
* take several seconds to paint its first prompt.
|
|
119
|
+
*/
|
|
120
|
+
export const SETTLE_QUIET_MS = 750;
|
|
121
|
+
export const SETTLE_POLL_MS = 120;
|
|
122
|
+
export const SETTLE_TIMEOUT_MS = 8_000;
|
|
123
|
+
/**
|
|
124
|
+
* Poll-capture a pane until its content is unchanged for `quietMs` ms, or until
|
|
125
|
+
* `timeoutMs` elapses (backstop so a continuously-animated pane can't hang the tool).
|
|
126
|
+
*
|
|
127
|
+
* Returns the final pane content. This is the write-side analogue of the read-side labels
|
|
128
|
+
* in `terminal-label.ts`: before returning "Sent", we verify the pane has reacted.
|
|
129
|
+
*
|
|
130
|
+
* Pure behaviour — no side effects beyond calling `capturePane`; tested in unit tests
|
|
131
|
+
* without a real tmux by passing a custom `captureFn`.
|
|
132
|
+
*/
|
|
133
|
+
export async function waitForPaneSettle(target, opts = {}) {
|
|
134
|
+
const quietMs = opts.quietMs ?? SETTLE_QUIET_MS;
|
|
135
|
+
const timeoutMs = opts.timeoutMs ?? SETTLE_TIMEOUT_MS;
|
|
136
|
+
const pollMs = opts.pollMs ?? SETTLE_POLL_MS;
|
|
137
|
+
const capture = opts.captureFn ?? ((t) => capturePane(t));
|
|
138
|
+
const deadline = Date.now() + timeoutMs;
|
|
139
|
+
let last = capture(target);
|
|
140
|
+
let lastChangedAt = Date.now();
|
|
141
|
+
while (true) {
|
|
142
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
const current = capture(target);
|
|
145
|
+
if (current !== last) {
|
|
146
|
+
last = current;
|
|
147
|
+
lastChangedAt = now;
|
|
148
|
+
}
|
|
149
|
+
const quietFor = now - lastChangedAt;
|
|
150
|
+
if (quietFor >= quietMs || now >= deadline) {
|
|
151
|
+
return last;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
114
155
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes from tmux output.
|
|
115
156
|
const ANSI = /\x1B\[[0-?]*[ -/]*[@-~]/g;
|
|
116
157
|
/** Strip ANSI escape codes. */
|
|
@@ -305,24 +305,29 @@ async function route(runner, req, res) {
|
|
|
305
305
|
return json(res, 200, { session, pane: capturePane(session, lines) });
|
|
306
306
|
}
|
|
307
307
|
if (req.method === "POST" && path === "/tmux/send") {
|
|
308
|
-
const { sendText, sendKey, capturePane, sessionExists } = await import("./coding/tmux.js");
|
|
308
|
+
const { sendText, sendKey, capturePane, sessionExists, waitForPaneSettle } = await import("./coding/tmux.js");
|
|
309
309
|
const b = await readJson(req);
|
|
310
310
|
const session = String(b.session || "").trim();
|
|
311
311
|
if (!session)
|
|
312
312
|
return json(res, 400, { error: "A `session` name is required." });
|
|
313
313
|
if (!sessionExists(session))
|
|
314
314
|
return json(res, 404, { error: `No tmux session "${session}".` });
|
|
315
|
+
// Capture BEFORE the send so the caller can verify what changed (#481).
|
|
316
|
+
const paneBefore = capturePane(session, 200);
|
|
315
317
|
if (b.text != null)
|
|
316
318
|
sendText(session, String(b.text));
|
|
317
319
|
for (const k of b.keys ?? [])
|
|
318
320
|
sendKey(session, String(k));
|
|
321
|
+
// Wait for the pane to quiesce (750ms quiet / 3s backstop) instead of returning
|
|
322
|
+
// the pre-reaction snapshot. Mirrors the settle heuristic in headless.ts.
|
|
323
|
+
const pane = await waitForPaneSettle(session, { quietMs: 750, timeoutMs: 3_000 });
|
|
319
324
|
// `activeCommand` rides along on every WRITE so the cloud can record what it just drove
|
|
320
325
|
// (#348). It is a process name, never a cost — see activeTerminalCommand's comment.
|
|
321
326
|
const { activeTerminalCommand } = await import("./coding/terminal.js");
|
|
322
|
-
return json(res, 200, { session, pane
|
|
327
|
+
return json(res, 200, { session, pane, paneBefore, changed: pane !== paneBefore, activeCommand: activeTerminalCommand(session, "tmux") });
|
|
323
328
|
}
|
|
324
329
|
if (req.method === "POST" && path === "/tmux/run") {
|
|
325
|
-
const { runCommand, capturePane, sessionExists } = await import("./coding/tmux.js");
|
|
330
|
+
const { runCommand, capturePane, sessionExists, waitForPaneSettle } = await import("./coding/tmux.js");
|
|
326
331
|
const b = await readJson(req);
|
|
327
332
|
const session = String(b.session || "").trim();
|
|
328
333
|
const command = String(b.command ?? "");
|
|
@@ -332,12 +337,14 @@ async function route(runner, req, res) {
|
|
|
332
337
|
return json(res, 400, { error: "A `command` is required." });
|
|
333
338
|
if (!sessionExists(session))
|
|
334
339
|
return json(res, 404, { error: `No tmux session "${session}".` });
|
|
340
|
+
const paneBefore = capturePane(session, 200);
|
|
335
341
|
runCommand(session, command);
|
|
342
|
+
const pane = await waitForPaneSettle(session, { quietMs: 750, timeoutMs: 3_000 });
|
|
336
343
|
const { activeTerminalCommand } = await import("./coding/terminal.js");
|
|
337
|
-
return json(res, 200, { session, command, pane
|
|
344
|
+
return json(res, 200, { session, command, pane, paneBefore, changed: pane !== paneBefore, activeCommand: activeTerminalCommand(session, "tmux") });
|
|
338
345
|
}
|
|
339
346
|
if (req.method === "POST" && path === "/tmux/session") {
|
|
340
|
-
const { createSession, killSession, sessionExists } = await import("./coding/tmux.js");
|
|
347
|
+
const { createSession, killSession, sessionExists, waitForPaneSettle } = await import("./coding/tmux.js");
|
|
341
348
|
const { homedir } = await import("node:os");
|
|
342
349
|
const b = await readJson(req);
|
|
343
350
|
const session = String(b.session || "").trim();
|
|
@@ -352,6 +359,12 @@ async function route(runner, req, res) {
|
|
|
352
359
|
const { resolve } = await import("node:path");
|
|
353
360
|
const workDir = resolve(String(b.workDir || "~").replace(/^~(?=$|\/)/, homedir()));
|
|
354
361
|
createSession(session, workDir, b.command ? String(b.command) : undefined);
|
|
362
|
+
// When the session starts a command (e.g. "claude"), wait until the pane quiesces
|
|
363
|
+
// (the CLI has painted its initial prompt) before returning "ready" (#481). Without a
|
|
364
|
+
// startup command the pane is already at a shell prompt — no settle needed.
|
|
365
|
+
if (b.command) {
|
|
366
|
+
await waitForPaneSettle(session, { quietMs: 750, timeoutMs: 8_000 });
|
|
367
|
+
}
|
|
355
368
|
return json(res, 200, { session, created: true, workDir });
|
|
356
369
|
}
|
|
357
370
|
// ── generic terminal connector ──────────────────────────────────────────
|
|
@@ -373,7 +386,8 @@ async function route(runner, req, res) {
|
|
|
373
386
|
return json(res, 200, { target, pane: captureTerminalTarget(target, { backend, lines: b.lines }) });
|
|
374
387
|
}
|
|
375
388
|
if (req.method === "POST" && path === "/terminal/run") {
|
|
376
|
-
const { runTerminalCommand, activeTerminalCommand } = await import("./coding/terminal.js");
|
|
389
|
+
const { runTerminalCommand, captureTerminalTarget, activeTerminalCommand } = await import("./coding/terminal.js");
|
|
390
|
+
const { waitForPaneSettle, SETTLE_QUIET_MS } = await import("./coding/tmux.js");
|
|
377
391
|
const b = await readJson(req);
|
|
378
392
|
const target = String(b.target || "").trim();
|
|
379
393
|
const command = String(b.command ?? "");
|
|
@@ -382,23 +396,48 @@ async function route(runner, req, res) {
|
|
|
382
396
|
if (!command.trim())
|
|
383
397
|
return json(res, 400, { error: "A `command` is required." });
|
|
384
398
|
const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
|
|
385
|
-
|
|
399
|
+
// Capture BEFORE to detect whether the input landed (#481).
|
|
400
|
+
const paneBefore = captureTerminalTarget(target, { backend });
|
|
401
|
+
runTerminalCommand(target, command, backend);
|
|
402
|
+
// Settle: for tmux targets, use the tmux poll. For other backends, fall back to the
|
|
403
|
+
// just-run snapshot (they don't support a reliable settle poll).
|
|
404
|
+
let pane;
|
|
405
|
+
if (!backend && target.startsWith("tmux:") || backend === "tmux") {
|
|
406
|
+
const { splitTerminalTarget } = await import("./coding/terminal.js");
|
|
407
|
+
const t = splitTerminalTarget(target, backend);
|
|
408
|
+
pane = await waitForPaneSettle(t.id, { quietMs: SETTLE_QUIET_MS, timeoutMs: 3_000 });
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
pane = captureTerminalTarget(target, { backend });
|
|
412
|
+
}
|
|
386
413
|
// Read AFTER the command lands, so `claude "fix x"` reports `claude` rather than the shell
|
|
387
414
|
// that was sitting there a moment earlier (#348).
|
|
388
|
-
return json(res, 200, { target, command, pane, activeCommand: activeTerminalCommand(target, backend) });
|
|
415
|
+
return json(res, 200, { target, command, pane, paneBefore, changed: pane !== paneBefore, activeCommand: activeTerminalCommand(target, backend) });
|
|
389
416
|
}
|
|
390
417
|
if (req.method === "POST" && path === "/terminal/send") {
|
|
391
|
-
const { sendTerminalKeys, activeTerminalCommand } = await import("./coding/terminal.js");
|
|
418
|
+
const { sendTerminalKeys, captureTerminalTarget, activeTerminalCommand } = await import("./coding/terminal.js");
|
|
419
|
+
const { waitForPaneSettle, SETTLE_QUIET_MS } = await import("./coding/tmux.js");
|
|
392
420
|
const b = await readJson(req);
|
|
393
421
|
const target = String(b.target || "").trim();
|
|
394
422
|
if (!target)
|
|
395
423
|
return json(res, 400, { error: "A `target` is required." });
|
|
396
424
|
const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
|
|
397
|
-
const
|
|
398
|
-
|
|
425
|
+
const paneBefore = captureTerminalTarget(target, { backend });
|
|
426
|
+
sendTerminalKeys(target, { backend, text: b.text == null ? undefined : String(b.text), keys: b.keys ?? [] });
|
|
427
|
+
let pane;
|
|
428
|
+
if (!backend && target.startsWith("tmux:") || backend === "tmux") {
|
|
429
|
+
const { splitTerminalTarget } = await import("./coding/terminal.js");
|
|
430
|
+
const t = splitTerminalTarget(target, backend);
|
|
431
|
+
pane = await waitForPaneSettle(t.id, { quietMs: SETTLE_QUIET_MS, timeoutMs: 3_000 });
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
pane = captureTerminalTarget(target, { backend });
|
|
435
|
+
}
|
|
436
|
+
return json(res, 200, { target, pane, paneBefore, changed: pane !== paneBefore, activeCommand: activeTerminalCommand(target, backend) });
|
|
399
437
|
}
|
|
400
438
|
if (req.method === "POST" && path === "/terminal/session") {
|
|
401
439
|
const { createTerminalTarget, killTerminalTarget } = await import("./coding/terminal.js");
|
|
440
|
+
const { waitForPaneSettle } = await import("./coding/tmux.js");
|
|
402
441
|
const b = await readJson(req);
|
|
403
442
|
const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
|
|
404
443
|
if (b.action === "kill") {
|
|
@@ -410,6 +449,10 @@ async function route(runner, req, res) {
|
|
|
410
449
|
if (!backend)
|
|
411
450
|
return json(res, 400, { error: "`backend` must be tmux, kitty, or iterm2." });
|
|
412
451
|
const target = createTerminalTarget({ backend, name: b.name, workDir: b.workDir, command: b.command });
|
|
452
|
+
// When the new target starts a command, wait until the pane quiesces (#481).
|
|
453
|
+
if (b.command && backend === "tmux" && !target.existed) {
|
|
454
|
+
await waitForPaneSettle(target.id, { quietMs: 750, timeoutMs: 8_000 });
|
|
455
|
+
}
|
|
413
456
|
return json(res, 200, { target });
|
|
414
457
|
}
|
|
415
458
|
return json(res, 404, { error: "Not found" });
|
package/dist/index.js
CHANGED
|
@@ -549,6 +549,14 @@ function withClaimedNames(identity, claimed) {
|
|
|
549
549
|
declined: (identity.declined ?? []).filter((n) => !add.includes(n))
|
|
550
550
|
};
|
|
551
551
|
}
|
|
552
|
+
function withUnclaimedName(identity, name) {
|
|
553
|
+
const unclaim = name.trim();
|
|
554
|
+
if (!unclaim) return identity;
|
|
555
|
+
const names = identity.names.filter((n) => n !== unclaim);
|
|
556
|
+
const declined = [...identity.declined ?? []];
|
|
557
|
+
if (!declined.includes(unclaim)) declined.push(unclaim);
|
|
558
|
+
return { id: identity.id, names, declined: declined.slice(-MAX_DECLINED) };
|
|
559
|
+
}
|
|
552
560
|
function withDeclinedNames(identity, declined) {
|
|
553
561
|
const out = [...identity.declined ?? []];
|
|
554
562
|
for (const raw of declined) {
|
|
@@ -699,7 +707,7 @@ function renderCandidates(candidates, nowMs) {
|
|
|
699
707
|
});
|
|
700
708
|
lines.push("");
|
|
701
709
|
lines.push("Selecting a name merges its agents, pins and sessions onto this machine.");
|
|
702
|
-
lines.push("Pick only names THIS machine has used \u2014
|
|
710
|
+
lines.push("Pick only names THIS machine has used \u2014 use `pags machines unclaim <name>` to undo a wrong claim.");
|
|
703
711
|
return lines;
|
|
704
712
|
}
|
|
705
713
|
async function fetchNodeSummaries(opts) {
|
|
@@ -777,6 +785,96 @@ async function defaultAsk(question) {
|
|
|
777
785
|
}
|
|
778
786
|
}
|
|
779
787
|
|
|
788
|
+
// src/commands/runner/http.ts
|
|
789
|
+
import { hostname as hostname2 } from "os";
|
|
790
|
+
function clean(value) {
|
|
791
|
+
const trimmed = value?.trim();
|
|
792
|
+
return trimmed || void 0;
|
|
793
|
+
}
|
|
794
|
+
function runnerBaseUrl(url) {
|
|
795
|
+
return (clean(url) || clean(process.env.PAGS_RUNNER_URL) || "http://127.0.0.1:49171").replace(/\/$/, "");
|
|
796
|
+
}
|
|
797
|
+
function pagsApiBase(url) {
|
|
798
|
+
return (clean(url) || clean(process.env.PAGS_API_BASE) || "https://api.proagentstore.online").replace(/\/$/, "");
|
|
799
|
+
}
|
|
800
|
+
function pagsHeaders(token) {
|
|
801
|
+
const resolved = clean(token) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
|
|
802
|
+
return resolved ? { Authorization: `Bearer ${resolved}` } : {};
|
|
803
|
+
}
|
|
804
|
+
function runnerRequestHeaders(opts) {
|
|
805
|
+
const resolved = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN);
|
|
806
|
+
const headers = resolved ? { Authorization: `Bearer ${resolved}` } : {};
|
|
807
|
+
const instanceId = clean(opts.instanceId) || clean(process.env.PAGS_INSTANCE_ID);
|
|
808
|
+
if (instanceId) headers["X-PAGS-Instance-Id"] = instanceId;
|
|
809
|
+
return headers;
|
|
810
|
+
}
|
|
811
|
+
function apiPathSegment(value) {
|
|
812
|
+
return encodeURIComponent(value);
|
|
813
|
+
}
|
|
814
|
+
function buildRuntimeRegistrationBody(opts, capabilities = []) {
|
|
815
|
+
const node = hostname2();
|
|
816
|
+
const machine = loadMachineIdentity(node);
|
|
817
|
+
return {
|
|
818
|
+
endpointUrl: clean(opts.endpointUrl) || opts.endpointUrl,
|
|
819
|
+
token: clean(opts.runnerToken) || clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN),
|
|
820
|
+
placement: opts.placement === "managed" ? "managed" : "local",
|
|
821
|
+
capabilities,
|
|
822
|
+
runnerVersion: clean(opts.runnerVersion) || "",
|
|
823
|
+
runnerNode: node,
|
|
824
|
+
machineId: machine.id,
|
|
825
|
+
machineNames: machine.names
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
async function requestRunner(method, path, opts, body) {
|
|
829
|
+
const headers = {
|
|
830
|
+
...runnerRequestHeaders(opts)
|
|
831
|
+
};
|
|
832
|
+
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
833
|
+
const res = await fetch(`${runnerBaseUrl(opts.url)}${path}`, {
|
|
834
|
+
method,
|
|
835
|
+
headers,
|
|
836
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
837
|
+
});
|
|
838
|
+
const { text, data } = await readResponse(res);
|
|
839
|
+
if (!res.ok) {
|
|
840
|
+
const message = responseErrorMessage(data, text, res.statusText);
|
|
841
|
+
throw new Error(`${res.status} ${message}`);
|
|
842
|
+
}
|
|
843
|
+
return data;
|
|
844
|
+
}
|
|
845
|
+
async function requestPags(method, path, opts, body) {
|
|
846
|
+
const headers = {
|
|
847
|
+
...pagsHeaders(opts.pagsToken)
|
|
848
|
+
};
|
|
849
|
+
if (!headers.Authorization) {
|
|
850
|
+
throw new Error("PAGS token required. Set PAGS_TOKEN or pass --pags-token.");
|
|
851
|
+
}
|
|
852
|
+
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
853
|
+
const res = await fetch(`${pagsApiBase(opts.apiBase)}${path}`, {
|
|
854
|
+
method,
|
|
855
|
+
headers,
|
|
856
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
857
|
+
});
|
|
858
|
+
const { text, data } = await readResponse(res);
|
|
859
|
+
if (!res.ok) {
|
|
860
|
+
const message = responseErrorMessage(data, text, res.statusText);
|
|
861
|
+
throw new Error(`${res.status} ${message}`);
|
|
862
|
+
}
|
|
863
|
+
return data;
|
|
864
|
+
}
|
|
865
|
+
async function readResponse(res) {
|
|
866
|
+
const text = await res.text();
|
|
867
|
+
if (!text) return { text, data: {} };
|
|
868
|
+
try {
|
|
869
|
+
return { text, data: JSON.parse(text) };
|
|
870
|
+
} catch {
|
|
871
|
+
return { text, data: {} };
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
function responseErrorMessage(data, text, statusText) {
|
|
875
|
+
return typeof data.error === "string" ? data.error : text || statusText;
|
|
876
|
+
}
|
|
877
|
+
|
|
780
878
|
// src/commands/machines.ts
|
|
781
879
|
var API_BASE2 = "https://api.proagentstore.online";
|
|
782
880
|
async function loadNodes(token) {
|
|
@@ -804,6 +902,7 @@ var listCommand = new Command4("list").description("List the machines ProAgentSt
|
|
|
804
902
|
}
|
|
805
903
|
writeLine("");
|
|
806
904
|
writeLine(" Claim a name this machine has used before: pags machines claim <name>");
|
|
905
|
+
writeLine(" Remove a wrong claim: pags machines unclaim <name>");
|
|
807
906
|
writeLine("");
|
|
808
907
|
});
|
|
809
908
|
var claimCommand = new Command4("claim").description("Record that a machine name on this account is THIS machine").argument("<name...>", "Node name(s) to claim, as shown by `pags machines list`").action(async (names) => {
|
|
@@ -826,7 +925,51 @@ var claimCommand = new Command4("claim").description("Record that a machine name
|
|
|
826
925
|
}
|
|
827
926
|
writeLine(` \u2713 Claimed ${claim.join(", ")}. Restart \`pags up\` to merge them onto this machine.`);
|
|
828
927
|
});
|
|
829
|
-
var
|
|
928
|
+
var unclaimCommand = new Command4("unclaim").description("Remove a mistaken machine name claim from this machine (#467)").argument("<name...>", "Node name(s) to un-claim, as shown by `pags machines list`").action(async (names) => {
|
|
929
|
+
const session = requireSession();
|
|
930
|
+
const identity = loadMachineIdentity();
|
|
931
|
+
if (!identity.id) {
|
|
932
|
+
writeError("This machine has no id \u2014 `~/.config/proagentstore/` is not writable, so the claim record cannot be found.");
|
|
933
|
+
process.exit(1);
|
|
934
|
+
}
|
|
935
|
+
let anyFailed = false;
|
|
936
|
+
for (const raw of names) {
|
|
937
|
+
const name = raw.trim();
|
|
938
|
+
if (!name) continue;
|
|
939
|
+
if (name === identity.names[0]) {
|
|
940
|
+
writeError(` \u2717 ${name}: this is the machine's CURRENT hostname. You cannot un-claim the name it is actively registering under \u2014 stop \`pags up\` and rename the machine first.`);
|
|
941
|
+
anyFailed = true;
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
if (!identity.names.includes(name)) {
|
|
945
|
+
writeError(` \u2717 ${name}: this machine does not claim that name.`);
|
|
946
|
+
anyFailed = true;
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
try {
|
|
950
|
+
await requestPags(
|
|
951
|
+
"DELETE",
|
|
952
|
+
`/v1/terminals/nodes/${apiPathSegment(name)}/claim`,
|
|
953
|
+
{ pagsToken: session.token, apiBase: pagsApiBase() },
|
|
954
|
+
{ machineId: identity.id }
|
|
955
|
+
);
|
|
956
|
+
} catch (e) {
|
|
957
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
958
|
+
writeError(` \u2717 ${name}: ${msg}`);
|
|
959
|
+
anyFailed = true;
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
const updated = withUnclaimedName(identity, name);
|
|
963
|
+
if (!saveMachineIdentity(updated)) {
|
|
964
|
+
writeError(` \u2717 Server un-claimed ${name} but could not update ${machineFilePath()} \u2014 the next \`pags up\` may re-stamp it. Edit that file by hand and remove "${name}" from the names array.`);
|
|
965
|
+
anyFailed = true;
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
writeLine(` \u2713 ${name} \u2014 un-claimed on server and removed from ${machineFilePath()}.`);
|
|
969
|
+
}
|
|
970
|
+
if (anyFailed) process.exit(1);
|
|
971
|
+
});
|
|
972
|
+
var machinesCommand = new Command4("machines").description("Show and claim the machine names ProAgentStore has for this account").addCommand(listCommand, { isDefault: true }).addCommand(claimCommand).addCommand(unclaimCommand);
|
|
830
973
|
|
|
831
974
|
// src/commands/mcp.ts
|
|
832
975
|
import { spawn } from "child_process";
|
|
@@ -951,96 +1094,6 @@ import { spawn as spawn3 } from "child_process";
|
|
|
951
1094
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
952
1095
|
import { Command as Command7 } from "commander";
|
|
953
1096
|
|
|
954
|
-
// src/commands/runner/http.ts
|
|
955
|
-
import { hostname as hostname2 } from "os";
|
|
956
|
-
function clean(value) {
|
|
957
|
-
const trimmed = value?.trim();
|
|
958
|
-
return trimmed || void 0;
|
|
959
|
-
}
|
|
960
|
-
function runnerBaseUrl(url) {
|
|
961
|
-
return (clean(url) || clean(process.env.PAGS_RUNNER_URL) || "http://127.0.0.1:49171").replace(/\/$/, "");
|
|
962
|
-
}
|
|
963
|
-
function pagsApiBase(url) {
|
|
964
|
-
return (clean(url) || clean(process.env.PAGS_API_BASE) || "https://api.proagentstore.online").replace(/\/$/, "");
|
|
965
|
-
}
|
|
966
|
-
function pagsHeaders(token) {
|
|
967
|
-
const resolved = clean(token) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
|
|
968
|
-
return resolved ? { Authorization: `Bearer ${resolved}` } : {};
|
|
969
|
-
}
|
|
970
|
-
function runnerRequestHeaders(opts) {
|
|
971
|
-
const resolved = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN);
|
|
972
|
-
const headers = resolved ? { Authorization: `Bearer ${resolved}` } : {};
|
|
973
|
-
const instanceId = clean(opts.instanceId) || clean(process.env.PAGS_INSTANCE_ID);
|
|
974
|
-
if (instanceId) headers["X-PAGS-Instance-Id"] = instanceId;
|
|
975
|
-
return headers;
|
|
976
|
-
}
|
|
977
|
-
function apiPathSegment(value) {
|
|
978
|
-
return encodeURIComponent(value);
|
|
979
|
-
}
|
|
980
|
-
function buildRuntimeRegistrationBody(opts, capabilities = []) {
|
|
981
|
-
const node = hostname2();
|
|
982
|
-
const machine = loadMachineIdentity(node);
|
|
983
|
-
return {
|
|
984
|
-
endpointUrl: clean(opts.endpointUrl) || opts.endpointUrl,
|
|
985
|
-
token: clean(opts.runnerToken) || clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN),
|
|
986
|
-
placement: opts.placement === "managed" ? "managed" : "local",
|
|
987
|
-
capabilities,
|
|
988
|
-
runnerVersion: clean(opts.runnerVersion) || "",
|
|
989
|
-
runnerNode: node,
|
|
990
|
-
machineId: machine.id,
|
|
991
|
-
machineNames: machine.names
|
|
992
|
-
};
|
|
993
|
-
}
|
|
994
|
-
async function requestRunner(method, path, opts, body) {
|
|
995
|
-
const headers = {
|
|
996
|
-
...runnerRequestHeaders(opts)
|
|
997
|
-
};
|
|
998
|
-
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
999
|
-
const res = await fetch(`${runnerBaseUrl(opts.url)}${path}`, {
|
|
1000
|
-
method,
|
|
1001
|
-
headers,
|
|
1002
|
-
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
1003
|
-
});
|
|
1004
|
-
const { text, data } = await readResponse(res);
|
|
1005
|
-
if (!res.ok) {
|
|
1006
|
-
const message = responseErrorMessage(data, text, res.statusText);
|
|
1007
|
-
throw new Error(`${res.status} ${message}`);
|
|
1008
|
-
}
|
|
1009
|
-
return data;
|
|
1010
|
-
}
|
|
1011
|
-
async function requestPags(method, path, opts, body) {
|
|
1012
|
-
const headers = {
|
|
1013
|
-
...pagsHeaders(opts.pagsToken)
|
|
1014
|
-
};
|
|
1015
|
-
if (!headers.Authorization) {
|
|
1016
|
-
throw new Error("PAGS token required. Set PAGS_TOKEN or pass --pags-token.");
|
|
1017
|
-
}
|
|
1018
|
-
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
1019
|
-
const res = await fetch(`${pagsApiBase(opts.apiBase)}${path}`, {
|
|
1020
|
-
method,
|
|
1021
|
-
headers,
|
|
1022
|
-
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
1023
|
-
});
|
|
1024
|
-
const { text, data } = await readResponse(res);
|
|
1025
|
-
if (!res.ok) {
|
|
1026
|
-
const message = responseErrorMessage(data, text, res.statusText);
|
|
1027
|
-
throw new Error(`${res.status} ${message}`);
|
|
1028
|
-
}
|
|
1029
|
-
return data;
|
|
1030
|
-
}
|
|
1031
|
-
async function readResponse(res) {
|
|
1032
|
-
const text = await res.text();
|
|
1033
|
-
if (!text) return { text, data: {} };
|
|
1034
|
-
try {
|
|
1035
|
-
return { text, data: JSON.parse(text) };
|
|
1036
|
-
} catch {
|
|
1037
|
-
return { text, data: {} };
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
function responseErrorMessage(data, text, statusText) {
|
|
1041
|
-
return typeof data.error === "string" ? data.error : text || statusText;
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
1097
|
// src/commands/runner/process.ts
|
|
1045
1098
|
import { spawn as spawn2 } from "child_process";
|
|
1046
1099
|
import { existsSync as existsSync6 } from "fs";
|
|
@@ -1161,11 +1214,44 @@ function diffMembership(attached, eligible, thisNode, blocked = /* @__PURE__ */
|
|
|
1161
1214
|
detach: [...have].filter((id) => !wantIds.has(id))
|
|
1162
1215
|
};
|
|
1163
1216
|
}
|
|
1217
|
+
function shouldRegisterOnOpen(reconnect, alreadyRegistered) {
|
|
1218
|
+
return reconnect || !alreadyRegistered;
|
|
1219
|
+
}
|
|
1164
1220
|
function instanceLabel(inst) {
|
|
1165
1221
|
const short = `${inst.id.slice(0, 8)}\u2026`;
|
|
1166
1222
|
return inst.name ? `${inst.name} (${short})` : short;
|
|
1167
1223
|
}
|
|
1168
1224
|
|
|
1225
|
+
// src/commands/runner/status-line.ts
|
|
1226
|
+
var STATUS_PREFIX = "PAGS-STATUS";
|
|
1227
|
+
function formatStatusLine(status) {
|
|
1228
|
+
const parts = [];
|
|
1229
|
+
if (status.registration) parts.push(`registration=${status.registration}`);
|
|
1230
|
+
if (status.heartbeat) parts.push(`heartbeat=${status.heartbeat}`);
|
|
1231
|
+
if (status.agents) parts.push(`agents=${status.agents}`);
|
|
1232
|
+
if (status.reason) parts.push(`reason=${status.reason.replace(/\s+/g, " ").trim().slice(0, 200)}`);
|
|
1233
|
+
return `${STATUS_PREFIX} ${parts.join(" ")}`;
|
|
1234
|
+
}
|
|
1235
|
+
function parseStatusLine(line) {
|
|
1236
|
+
const trimmed = line.trim();
|
|
1237
|
+
if (!trimmed.startsWith(`${STATUS_PREFIX} `)) return null;
|
|
1238
|
+
const body = trimmed.slice(STATUS_PREFIX.length + 1);
|
|
1239
|
+
const status = {};
|
|
1240
|
+
const reasonAt = body.indexOf("reason=");
|
|
1241
|
+
const head = reasonAt >= 0 ? body.slice(0, reasonAt) : body;
|
|
1242
|
+
if (reasonAt >= 0) {
|
|
1243
|
+
const reason = body.slice(reasonAt + "reason=".length).trim();
|
|
1244
|
+
if (reason) status.reason = reason;
|
|
1245
|
+
}
|
|
1246
|
+
for (const token of head.split(/\s+/).filter(Boolean)) {
|
|
1247
|
+
const [key, value] = token.split("=", 2);
|
|
1248
|
+
if (key === "registration" && (value === "ok" || value === "partial" || value === "fail")) status.registration = value;
|
|
1249
|
+
else if (key === "heartbeat" && (value === "ok" || value === "fail")) status.heartbeat = value;
|
|
1250
|
+
else if (key === "agents" && value) status.agents = value;
|
|
1251
|
+
}
|
|
1252
|
+
return status;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1169
1255
|
// src/commands/runner/relay.ts
|
|
1170
1256
|
async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force = false, watchInstances = false) {
|
|
1171
1257
|
const apiBase = pagsApiBase(opts.apiBase).replace(/^http/, "ws");
|
|
@@ -1175,7 +1261,9 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1175
1261
|
const machine = loadMachineIdentity(runnerNode);
|
|
1176
1262
|
const capabilities = await requestRunner("GET", "/capabilities", { url: localUrl, token: runnerToken, instanceId: instanceIds[0] });
|
|
1177
1263
|
const caps = Array.isArray(capabilities.capabilities) ? capabilities.capabilities.filter((item) => typeof item === "string") : [];
|
|
1178
|
-
const
|
|
1264
|
+
const registered = /* @__PURE__ */ new Set();
|
|
1265
|
+
let lastRegisterError = "";
|
|
1266
|
+
const registerRuntime = async (id, forceClaim = force) => {
|
|
1179
1267
|
try {
|
|
1180
1268
|
await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime`, opts, {
|
|
1181
1269
|
endpointUrl: localUrl,
|
|
@@ -1186,13 +1274,23 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1186
1274
|
runnerNode,
|
|
1187
1275
|
machineId: machine.id,
|
|
1188
1276
|
machineNames: machine.names,
|
|
1189
|
-
force
|
|
1277
|
+
force: forceClaim
|
|
1190
1278
|
});
|
|
1279
|
+
registered.add(id);
|
|
1280
|
+
return true;
|
|
1191
1281
|
} catch (e) {
|
|
1192
1282
|
const msg = e instanceof Error ? e.message : String(e);
|
|
1283
|
+
registered.delete(id);
|
|
1284
|
+
lastRegisterError = msg;
|
|
1193
1285
|
writeError(`register ${id.slice(0, 8)}\u2026 failed: ${msg}`);
|
|
1286
|
+
return false;
|
|
1194
1287
|
}
|
|
1195
1288
|
};
|
|
1289
|
+
const reportRegistration = () => {
|
|
1290
|
+
const agents = `${registered.size}/${instanceIds.length}`;
|
|
1291
|
+
const state = registered.size === instanceIds.length ? "ok" : registered.size === 0 ? "fail" : "partial";
|
|
1292
|
+
writeLine(formatStatusLine({ registration: state, agents, reason: state === "ok" ? void 0 : lastRegisterError }));
|
|
1293
|
+
};
|
|
1196
1294
|
for (const id of instanceIds) await registerRuntime(id);
|
|
1197
1295
|
const attached = /* @__PURE__ */ new Map();
|
|
1198
1296
|
const blocked = /* @__PURE__ */ new Set();
|
|
@@ -1201,10 +1299,31 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1201
1299
|
const mintToken = () => requestPags("POST", `/v1/relay/${apiPathSegment(id)}/token`, { ...opts, pagsToken }, {}).then((r) => r.token);
|
|
1202
1300
|
attached.set(
|
|
1203
1301
|
id,
|
|
1204
|
-
openRelaySocket(
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1302
|
+
openRelaySocket(
|
|
1303
|
+
id,
|
|
1304
|
+
apiBase,
|
|
1305
|
+
mintToken,
|
|
1306
|
+
localUrl,
|
|
1307
|
+
runnerToken,
|
|
1308
|
+
force,
|
|
1309
|
+
(conflicted) => {
|
|
1310
|
+
blocked.add(conflicted);
|
|
1311
|
+
attached.delete(conflicted);
|
|
1312
|
+
},
|
|
1313
|
+
// Registration rides the RECONNECT, which is the whole wake case (#497). The socket
|
|
1314
|
+
// retries with backoff; `POST …/runtime` did not, so after a sleep the machine had a
|
|
1315
|
+
// live relay and no runtime row — and `resumeSessionsForNode`, which lives inside that
|
|
1316
|
+
// route, never ran either, so its own suspended coding sessions stayed suspended. The
|
|
1317
|
+
// upsert is idempotent, so re-registering on every reconnect is safe. The first open
|
|
1318
|
+
// is skipped when the register already succeeded above (or in the discovery pass),
|
|
1319
|
+
// and taken when it did not — which is how a register lost to a boot-time
|
|
1320
|
+
// `fetch failed` finally gets a second chance.
|
|
1321
|
+
async (openedId, reconnect) => {
|
|
1322
|
+
if (!shouldRegisterOnOpen(reconnect, registered.has(openedId))) return;
|
|
1323
|
+
await registerRuntime(openedId, reconnect ? false : force);
|
|
1324
|
+
reportRegistration();
|
|
1325
|
+
}
|
|
1326
|
+
)
|
|
1208
1327
|
);
|
|
1209
1328
|
if (label) writeLine(`Attached agent: ${label}`);
|
|
1210
1329
|
};
|
|
@@ -1216,7 +1335,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1216
1335
|
writeLine(`Detached agent: ${label}`);
|
|
1217
1336
|
};
|
|
1218
1337
|
for (const id of instanceIds) attach(id, "");
|
|
1219
|
-
|
|
1338
|
+
reportRegistration();
|
|
1339
|
+
writeLine(registered.size === instanceIds.length ? `Runtime registered with PAGS \u2713 (${registered.size}/${instanceIds.length} agents)` : `Runtime registration incomplete: ${registered.size}/${instanceIds.length} agents \u2014 retried on each relay (re)connect.`);
|
|
1220
1340
|
writeLine("");
|
|
1221
1341
|
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");
|
|
1222
1342
|
writeLine(` \u2705 CONNECTED \u2014 WebSocket relay \xB7 ${hostname3()}`);
|
|
@@ -1236,9 +1356,11 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1236
1356
|
}
|
|
1237
1357
|
if (failure && !heartbeatFailing) {
|
|
1238
1358
|
heartbeatFailing = true;
|
|
1359
|
+
writeLine(formatStatusLine({ heartbeat: "fail", reason: failure }));
|
|
1239
1360
|
writeError(`Heartbeat failed: ${failure} \u2014 the console will show this machine as OFFLINE until it recovers. The relay itself is still connected; don't run \`pags up --force\` elsewhere.`);
|
|
1240
1361
|
} else if (!failure && heartbeatFailing) {
|
|
1241
1362
|
heartbeatFailing = false;
|
|
1363
|
+
writeLine(formatStatusLine({ heartbeat: "ok" }));
|
|
1242
1364
|
writeLine("Heartbeat recovered \u2014 this machine reads as online again.");
|
|
1243
1365
|
}
|
|
1244
1366
|
heartbeat();
|
|
@@ -1247,10 +1369,23 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1247
1369
|
};
|
|
1248
1370
|
heartbeat();
|
|
1249
1371
|
if (watchInstances) startDiscovery();
|
|
1372
|
+
async function clearFinishedConflicts() {
|
|
1373
|
+
for (const id of [...blocked]) {
|
|
1374
|
+
const free = await requestPags(
|
|
1375
|
+
"GET",
|
|
1376
|
+
`/v1/relay/${apiPathSegment(id)}/status`,
|
|
1377
|
+
{ ...opts, pagsToken }
|
|
1378
|
+
).then((r) => r.connected === false).catch(() => false);
|
|
1379
|
+
if (!free) continue;
|
|
1380
|
+
blocked.delete(id);
|
|
1381
|
+
writeLine(`Relay conflict cleared: ${id.slice(0, 8)}\u2026 \u2014 the other runner is gone; reattaching.`);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1250
1384
|
function startDiscovery() {
|
|
1251
1385
|
const tick = () => {
|
|
1252
1386
|
const timer = setTimeout(async () => {
|
|
1253
1387
|
try {
|
|
1388
|
+
await clearFinishedConflicts();
|
|
1254
1389
|
const res = await requestPags(
|
|
1255
1390
|
"GET",
|
|
1256
1391
|
"/v1/instances/my/instances",
|
|
@@ -1280,10 +1415,11 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1280
1415
|
tick();
|
|
1281
1416
|
}
|
|
1282
1417
|
}
|
|
1283
|
-
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict) {
|
|
1418
|
+
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen) {
|
|
1284
1419
|
let backoffMs = 1e3;
|
|
1285
1420
|
let reconnecting = false;
|
|
1286
1421
|
let closed = false;
|
|
1422
|
+
let opened = false;
|
|
1287
1423
|
let socket = null;
|
|
1288
1424
|
let retryTimer = null;
|
|
1289
1425
|
const connect = async () => {
|
|
@@ -1312,7 +1448,10 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
1312
1448
|
socket = ws;
|
|
1313
1449
|
ws.onopen = () => {
|
|
1314
1450
|
backoffMs = 1e3;
|
|
1451
|
+
const reconnect = opened;
|
|
1452
|
+
opened = true;
|
|
1315
1453
|
writeLine(`Relay connected: ${instanceId.slice(0, 8)}\u2026`);
|
|
1454
|
+
void Promise.resolve(onOpen?.(instanceId, reconnect)).catch(() => void 0);
|
|
1316
1455
|
};
|
|
1317
1456
|
ws.onmessage = async (event) => {
|
|
1318
1457
|
const text = typeof event.data === "string" ? event.data : String(event.data);
|
|
@@ -1599,31 +1738,42 @@ function printLogo(version2) {
|
|
|
1599
1738
|
console.log(pad + d(" Browser runner") + (version2 ? d(` \xB7 v${version2}`) : ""));
|
|
1600
1739
|
console.log("");
|
|
1601
1740
|
}
|
|
1741
|
+
var STATUS_ROWS = {
|
|
1742
|
+
runner: { label: "Browser", ok: "running on your computer", busy: "starting up\u2026", bad: "stopped \u2014 press r to retry" },
|
|
1743
|
+
tunnel: { label: "Secure link", ok: "connected to ProAgentStore", busy: "opening\u2026", bad: "offline" },
|
|
1744
|
+
pags: { label: "ProAgentStore", ok: "connected \u2014 ready for jobs", busy: "registering\u2026", bad: "not registered (retries automatically)" }
|
|
1745
|
+
};
|
|
1746
|
+
var LABEL_WIDTH = Math.max(...Object.values(STATUS_ROWS).map((r) => r.label.length)) + 2;
|
|
1602
1747
|
function describe(kind, s) {
|
|
1603
1748
|
const ok = s === "online" || s === "registered";
|
|
1604
1749
|
const busy = s === "starting" || s === "pending";
|
|
1605
1750
|
const icon = ok ? chalk.green("\u2713") : busy ? chalk.yellow("\u2026") : chalk.red("\u2717");
|
|
1606
|
-
const
|
|
1607
|
-
runner: { label: "Browser", ok: "running on your computer", busy: "starting up\u2026", bad: "stopped \u2014 press r to retry" },
|
|
1608
|
-
tunnel: { label: "Secure link", ok: "connected to ProAgentStore", busy: "opening\u2026", bad: "offline" },
|
|
1609
|
-
pags: { label: "ProAgentStore", ok: "connected \u2014 ready for jobs", busy: "registering\u2026", bad: "not registered (retries automatically)" }
|
|
1610
|
-
};
|
|
1611
|
-
const m = map[kind];
|
|
1751
|
+
const m = STATUS_ROWS[kind];
|
|
1612
1752
|
return { icon, label: m.label, note: ok ? m.ok : busy ? m.busy : m.bad };
|
|
1613
1753
|
}
|
|
1754
|
+
function connectingNote(elapsedMs) {
|
|
1755
|
+
if (elapsedMs < 3e4) return "Setting things up\u2026 this takes a few seconds. Keep this window open.";
|
|
1756
|
+
const mins = Math.floor(elapsedMs / 6e4);
|
|
1757
|
+
const elapsed = mins >= 1 ? `${mins}m` : `${Math.floor(elapsedMs / 1e3)}s`;
|
|
1758
|
+
return `Still connecting \u2014 ${elapsed} elapsed. Press l for logs.`;
|
|
1759
|
+
}
|
|
1760
|
+
function pagsNote(registration, heartbeat) {
|
|
1761
|
+
if (registration !== "registered" || heartbeat !== "failing") return void 0;
|
|
1762
|
+
return "registered \u2014 but the heartbeat is failing, so the website reads this machine as offline";
|
|
1763
|
+
}
|
|
1614
1764
|
function printStatus(state) {
|
|
1615
1765
|
clearScreen();
|
|
1616
1766
|
printLogo(state.version);
|
|
1617
1767
|
const connected = state.runner === "online" && state.tunnel === "online" && state.registration === "registered";
|
|
1618
1768
|
console.log(pad + d("Signed in as ") + w(state.user) + d(" \xB7 agent: ") + w(state.activeInstance) + d(" \xB7 node: ") + w(hostname4()));
|
|
1619
1769
|
console.log("");
|
|
1620
|
-
const row = (kind, s) => {
|
|
1770
|
+
const row = (kind, s, override) => {
|
|
1621
1771
|
const { icon, label, note } = describe(kind, s);
|
|
1622
|
-
console.log(pad + icon + " " + w(label.padEnd(
|
|
1772
|
+
console.log(pad + icon + " " + w(label.padEnd(LABEL_WIDTH)) + d(override ?? note));
|
|
1623
1773
|
};
|
|
1624
1774
|
row("runner", state.runner);
|
|
1625
1775
|
row("tunnel", state.tunnel);
|
|
1626
|
-
row("pags", state.registration);
|
|
1776
|
+
row("pags", state.registration, pagsNote(state.registration, state.heartbeat));
|
|
1627
1777
|
console.log("");
|
|
1628
1778
|
if (connected) {
|
|
1629
1779
|
console.log(pad + chalk.green("\u2713 You're all set!") + d(" Your agent can now act on the web."));
|
|
@@ -1635,7 +1785,7 @@ function printStatus(state) {
|
|
|
1635
1785
|
console.log("");
|
|
1636
1786
|
console.log(pad + d("The website can take a few seconds to show \u201Conline\u201D \u2014 that's normal."));
|
|
1637
1787
|
} else {
|
|
1638
|
-
console.log(pad + d(
|
|
1788
|
+
console.log(pad + d(connectingNote(state.startedAt ? Date.now() - state.startedAt : 0)));
|
|
1639
1789
|
if (state.lastEvent) console.log(pad + d("Status: ") + d(state.lastEvent));
|
|
1640
1790
|
}
|
|
1641
1791
|
console.log("");
|
|
@@ -1708,9 +1858,13 @@ var upCommand = new Command8("up").description("Start the browser runner for all
|
|
|
1708
1858
|
tunnel: "offline",
|
|
1709
1859
|
tunnelUrl: "",
|
|
1710
1860
|
registration: "pending",
|
|
1861
|
+
heartbeat: "ok",
|
|
1711
1862
|
lastEvent: "Fetching instances...",
|
|
1712
1863
|
taskCount: 0,
|
|
1713
|
-
version: CLI_VERSION2
|
|
1864
|
+
version: CLI_VERSION2,
|
|
1865
|
+
// What "a few seconds" is measured against: a state that never resolves was described
|
|
1866
|
+
// as taking a few seconds, indefinitely, because nothing counted (#497).
|
|
1867
|
+
startedAt: Date.now()
|
|
1714
1868
|
};
|
|
1715
1869
|
clearScreen();
|
|
1716
1870
|
printLogo(CLI_VERSION2);
|
|
@@ -1773,6 +1927,19 @@ var upCommand = new Command8("up").description("Start the browser runner for all
|
|
|
1773
1927
|
if (!trimmed) continue;
|
|
1774
1928
|
logs.push(trimmed);
|
|
1775
1929
|
if (logs.length > 200) logs.shift();
|
|
1930
|
+
const status = parseStatusLine(trimmed);
|
|
1931
|
+
if (status) {
|
|
1932
|
+
if (status.registration) {
|
|
1933
|
+
state.registration = status.registration === "ok" ? "registered" : "failed";
|
|
1934
|
+
state.lastEvent = status.registration === "ok" ? `Registered with PAGS \u2014 ${status.agents ?? "all"} agents ready` : `PAGS registration ${status.registration}${status.agents ? ` (${status.agents} agents)` : ""}${status.reason ? `: ${status.reason}` : ""}`;
|
|
1935
|
+
}
|
|
1936
|
+
if (status.heartbeat) {
|
|
1937
|
+
state.heartbeat = status.heartbeat === "ok" ? "ok" : "failing";
|
|
1938
|
+
state.lastEvent = status.heartbeat === "ok" ? "Heartbeat recovered \u2014 this machine reads as online again" : `Heartbeat failing${status.reason ? `: ${status.reason}` : ""} \u2014 the console will show this machine offline`;
|
|
1939
|
+
}
|
|
1940
|
+
printStatus(state);
|
|
1941
|
+
continue;
|
|
1942
|
+
}
|
|
1776
1943
|
if (trimmed.includes("Relay connected:")) {
|
|
1777
1944
|
state.tunnel = "online";
|
|
1778
1945
|
state.tunnelUrl = "WebSocket relay";
|
|
@@ -1783,7 +1950,6 @@ var upCommand = new Command8("up").description("Start the browser runner for all
|
|
|
1783
1950
|
if (trimmed.includes("WebSocket relay")) {
|
|
1784
1951
|
state.tunnel = "online";
|
|
1785
1952
|
state.tunnelUrl = "WebSocket relay";
|
|
1786
|
-
state.registration = "registered";
|
|
1787
1953
|
state.lastEvent = "Connected via WebSocket relay";
|
|
1788
1954
|
printStatus(state);
|
|
1789
1955
|
continue;
|
|
@@ -1794,21 +1960,13 @@ var upCommand = new Command8("up").description("Start the browser runner for all
|
|
|
1794
1960
|
printStatus(state);
|
|
1795
1961
|
continue;
|
|
1796
1962
|
}
|
|
1797
|
-
if (trimmed.includes("
|
|
1798
|
-
state.
|
|
1799
|
-
state.lastEvent = "Registered with PAGS \u2014 ready for tasks";
|
|
1800
|
-
printStatus(state);
|
|
1801
|
-
continue;
|
|
1802
|
-
}
|
|
1803
|
-
if (trimmed.includes("Another machine")) {
|
|
1804
|
-
state.registration = "failed";
|
|
1805
|
-
state.lastEvent = trimmed.slice(0, 80);
|
|
1963
|
+
if (trimmed.includes("Relay conflict:")) {
|
|
1964
|
+
state.lastEvent = "Another runner holds this agent \u2014 run `pags up --force` here to take it over";
|
|
1806
1965
|
printStatus(state);
|
|
1807
1966
|
continue;
|
|
1808
1967
|
}
|
|
1809
|
-
if (trimmed.includes("
|
|
1810
|
-
state.
|
|
1811
|
-
state.lastEvent = "PAGS registration failed";
|
|
1968
|
+
if (trimmed.includes("Relay conflict cleared:")) {
|
|
1969
|
+
state.lastEvent = "Relay conflict cleared \u2014 reattaching";
|
|
1812
1970
|
printStatus(state);
|
|
1813
1971
|
continue;
|
|
1814
1972
|
}
|