@proagentstore/cli 0.4.39 → 0.4.41
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.
|
@@ -1,110 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-CLI
|
|
2
|
+
* Per-CLI facts the coding runtime needs before it can spawn an engine.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* This file used to carry pane heuristics too — `isReady` / `isProcessing` / `extractResponse` /
|
|
5
|
+
* `completion`, ported from AgentCoder's `bridge/src/agents/handlers/*` — which answered
|
|
6
|
+
* "is it ready for input?" by matching strings a vendor prints in its TUI. They died with the
|
|
7
|
+
* tmux backend (commit c94642f): a session is a child process now, so Claude's turn boundary is a
|
|
8
|
+
* `result` event and a raw engine's is process exit. Nothing had called them since, and keeping
|
|
9
|
+
* them left a live-looking `pane.includes("ctrl+c to interrupt")` one vendor release away from
|
|
10
|
+
* being silently wrong — for anyone who wired it back up believing it worked.
|
|
11
|
+
*
|
|
12
|
+
* What remains is data: which binary this engine is, and which env var carries its key.
|
|
9
13
|
*/
|
|
10
|
-
/** Shared "find the user's input line, return everything after it up to the next prompt". */
|
|
11
|
-
function sliceAfterInput(captured, userInput, isPromptLine) {
|
|
12
|
-
const lines = captured.split("\n");
|
|
13
|
-
const needle = userInput.slice(0, 25);
|
|
14
|
-
let start = -1;
|
|
15
|
-
if (needle) {
|
|
16
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
17
|
-
if (lines[i].includes(needle)) {
|
|
18
|
-
start = i;
|
|
19
|
-
break;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
if (start === -1)
|
|
24
|
-
return lines.slice(-200).join("\n").trim();
|
|
25
|
-
let end = lines.length;
|
|
26
|
-
for (let i = lines.length - 1; i > start; i--) {
|
|
27
|
-
if (isPromptLine(lines[i].trim())) {
|
|
28
|
-
end = i;
|
|
29
|
-
break;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return lines.slice(start + 1, end).join("\n").trim();
|
|
33
|
-
}
|
|
34
|
-
export class ClaudeHandler {
|
|
35
|
-
clientType = "claude";
|
|
36
|
-
cliCommand = "claude --dangerously-skip-permissions";
|
|
37
|
-
envVar = "ANTHROPIC_API_KEY";
|
|
38
|
-
isProcessing(pane) {
|
|
39
|
-
return pane.includes("ctrl+c to interrupt") || /Working|Thinking|Reading|Searching|Running|Editing|Writing/.test(pane);
|
|
40
|
-
}
|
|
41
|
-
isReady(pane) {
|
|
42
|
-
if (this.isProcessing(pane))
|
|
43
|
-
return false;
|
|
44
|
-
const tail = pane.split("\n").slice(-15).join("\n");
|
|
45
|
-
return (tail.includes("bypass permissions") ||
|
|
46
|
-
tail.includes("? for shortcuts") ||
|
|
47
|
-
/❯\s*$/.test(tail) ||
|
|
48
|
-
/❯ .*↵ send/.test(tail));
|
|
49
|
-
}
|
|
50
|
-
extractResponse(captured, userInput) {
|
|
51
|
-
return sliceAfterInput(captured, userInput, (l) => l === "❯" || l.startsWith("❯ "));
|
|
52
|
-
}
|
|
53
|
-
completion() {
|
|
54
|
-
return { minWait: 1000, stableThreshold: 0, forceCompleteAfter: 5 * 60 * 1000, pollInterval: 500 };
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
export class GeminiHandler {
|
|
58
|
-
clientType = "gemini";
|
|
59
|
-
cliCommand = "gemini";
|
|
60
|
-
envVar = "GEMINI_API_KEY";
|
|
61
|
-
isProcessing(pane) {
|
|
62
|
-
return /thinking|processing|generating|working|loading/i.test(pane);
|
|
63
|
-
}
|
|
64
|
-
isReady(pane) {
|
|
65
|
-
const last = pane.split("\n").slice(-1)[0]?.trim() ?? "";
|
|
66
|
-
const hasPrompt = /[>$]\s*$/.test(last) || /^>/.test(last);
|
|
67
|
-
return hasPrompt && !this.isProcessing(pane);
|
|
68
|
-
}
|
|
69
|
-
extractResponse(captured, userInput) {
|
|
70
|
-
return sliceAfterInput(captured, userInput, (l) => /^[>$]\s*$/.test(l) || /^[>$]\s+\S/.test(l));
|
|
71
|
-
}
|
|
72
|
-
completion() {
|
|
73
|
-
return { stableThreshold: 1500, forceCompleteAfter: 5 * 60 * 1000, pollInterval: 500 };
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
/** Codex / Grok render close enough to a generic prompt; reuse the shell heuristics. */
|
|
77
|
-
export class GenericHandler {
|
|
78
|
-
clientType;
|
|
79
|
-
cliCommand;
|
|
80
|
-
envVar;
|
|
81
|
-
constructor(clientType = "generic", cliCommand = "bash", envVar = "") {
|
|
82
|
-
this.clientType = clientType;
|
|
83
|
-
this.cliCommand = cliCommand;
|
|
84
|
-
this.envVar = envVar;
|
|
85
|
-
}
|
|
86
|
-
isProcessing(pane) {
|
|
87
|
-
const last = pane.split("\n").slice(-1)[0]?.trim() ?? "";
|
|
88
|
-
return /\.\.\.$/.test(last);
|
|
89
|
-
}
|
|
90
|
-
isReady(pane) {
|
|
91
|
-
const last = pane.split("\n").slice(-1)[0]?.trim() ?? "";
|
|
92
|
-
// A shell prompt ends in $, #, >, or % (optionally followed by a cursor space).
|
|
93
|
-
return /[$#>%]\s*$/.test(last) && !this.isProcessing(pane);
|
|
94
|
-
}
|
|
95
|
-
extractResponse(captured, userInput) {
|
|
96
|
-
return sliceAfterInput(captured, userInput, (l) => /[$#>%]\s*$/.test(l));
|
|
97
|
-
}
|
|
98
|
-
completion() {
|
|
99
|
-
return { stableThreshold: 800, forceCompleteAfter: 60 * 1000, pollInterval: 300 };
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
14
|
const HANDLERS = {
|
|
103
|
-
claude:
|
|
104
|
-
gemini:
|
|
105
|
-
codex:
|
|
106
|
-
grok:
|
|
107
|
-
generic:
|
|
15
|
+
claude: { clientType: "claude", cliCommand: "claude --dangerously-skip-permissions", envVar: "ANTHROPIC_API_KEY" },
|
|
16
|
+
gemini: { clientType: "gemini", cliCommand: "gemini --approval-mode yolo --skip-trust --prompt", envVar: "GEMINI_API_KEY" },
|
|
17
|
+
codex: { clientType: "codex", cliCommand: "codex exec --sandbox danger-full-access", envVar: "OPENAI_API_KEY" },
|
|
18
|
+
grok: { clientType: "grok", cliCommand: "grok --permission-mode bypassPermissions -p", envVar: "XAI_API_KEY" },
|
|
19
|
+
generic: { clientType: "generic", cliCommand: "bash", envVar: "" },
|
|
108
20
|
};
|
|
109
21
|
export function handlerFor(clientType) {
|
|
110
22
|
return HANDLERS[clientType] ?? HANDLERS.generic;
|
|
@@ -44,6 +44,19 @@ const MAX_PENDING_USAGE = 200;
|
|
|
44
44
|
* matter most, whereas the merge that started an incident is the one you cannot afford to lose.
|
|
45
45
|
*/
|
|
46
46
|
const MAX_PENDING_ACTS = 100;
|
|
47
|
+
/**
|
|
48
|
+
* Absolute ceiling on ONE one-shot turn (#391).
|
|
49
|
+
*
|
|
50
|
+
* The old idle heuristic carried a 15-minute backstop so a wedged engine could not sit "thinking"
|
|
51
|
+
* forever. Now that exit is the only thing that ends a one-shot turn, that ceiling has to be
|
|
52
|
+
* ENFORCED rather than inferred: a rule that merely relabels a still-running process as idle is
|
|
53
|
+
* the very defect #391 is about — it hands the next turn a repo another engine is still editing.
|
|
54
|
+
*
|
|
55
|
+
* So the timer ends the turn (SIGTERM) and says so in the transcript. Removing the ceiling
|
|
56
|
+
* outright would trade an early kill for a permanent hang, which is worse: nothing else on this
|
|
57
|
+
* path can unstick a process that never exits, and the console's Restart is a human noticing.
|
|
58
|
+
*/
|
|
59
|
+
const MAX_ONE_SHOT_TURN_MS = 15 * 60 * 1000;
|
|
47
60
|
export class HeadlessSession {
|
|
48
61
|
config;
|
|
49
62
|
/**
|
|
@@ -67,11 +80,11 @@ export class HeadlessSession {
|
|
|
67
80
|
cmdBin;
|
|
68
81
|
cmdArgs;
|
|
69
82
|
binName;
|
|
70
|
-
/** Wall-clock of the last stdout/stderr byte — drives the raw
|
|
83
|
+
/** Wall-clock of the last stdout/stderr byte — drives the persistent-raw idle heuristic. */
|
|
71
84
|
lastOutputAt = 0;
|
|
72
|
-
/**
|
|
85
|
+
/** Persistent-raw: has any output arrived since the current turn's input? */
|
|
73
86
|
sawOutputSinceInput = false;
|
|
74
|
-
/**
|
|
87
|
+
/** Persistent-raw: when the current turn started (absolute idle backstop). */
|
|
75
88
|
turnStartedAt = 0;
|
|
76
89
|
/** Set by stop() — the only thing that ends a one-shot session (see `alive`). */
|
|
77
90
|
stopped = false;
|
|
@@ -198,7 +211,27 @@ export class HeadlessSession {
|
|
|
198
211
|
runState() {
|
|
199
212
|
if (!this.alive)
|
|
200
213
|
return "idle";
|
|
201
|
-
//
|
|
214
|
+
// A ONE-SHOT engine's turn IS a process, so its exit is an exact end-of-turn signal and no
|
|
215
|
+
// timer may pre-empt it (#391). Idle is set by the `close` handler; here the only question
|
|
216
|
+
// is whether that process is still running.
|
|
217
|
+
//
|
|
218
|
+
// This used to fall through to the timer rules below, which were written for a PERSISTENT
|
|
219
|
+
// interactive CLI — one with no other signal to read. Against a one-shot engine the 1.5s
|
|
220
|
+
// quiet rule could only ever fire EARLY: any pause inside a turn (a test suite, an install,
|
|
221
|
+
// a slow network fetch) read as "finished", the Pilot sent turn 2, and `runOneShot` killed
|
|
222
|
+
// turn 1 to keep two engines off one repo. The heuristic that existed to prevent a
|
|
223
|
+
// premature finish was causing one, and destroying the work in flight to do it.
|
|
224
|
+
//
|
|
225
|
+
// The ceiling that stops a wedged process is armed in `runOneShot` — it ENDS the turn
|
|
226
|
+
// rather than relabelling a live one as idle, which is the same mistake in slower form.
|
|
227
|
+
if (this.oneShot)
|
|
228
|
+
return this.procAlive ? "thinking" : "idle";
|
|
229
|
+
// Below: a PERSISTENT non-Claude engine — alive between turns, so exit says nothing about
|
|
230
|
+
// a turn and idle must be inferred. None ships today; every raw engine is one-shot. The
|
|
231
|
+
// gate is `!oneShot` rather than `mode === "raw"` because the latter now means the
|
|
232
|
+
// OPPOSITE of the condition these rules were written for.
|
|
233
|
+
//
|
|
234
|
+
// Three rules, in order:
|
|
202
235
|
// 1. produced output, then went quiet for 1.5s → settled (the common case).
|
|
203
236
|
// 2. NEVER produced output but 8s elapsed → a silent turn (just a prompt) is done.
|
|
204
237
|
// 3. absolute 15-min backstop → never wedge "thinking" forever (e.g. a heartbeat
|
|
@@ -316,7 +349,7 @@ export class HeadlessSession {
|
|
|
316
349
|
const now = Date.now();
|
|
317
350
|
this.lastOutputAt = now;
|
|
318
351
|
this.turnStartedAt = now;
|
|
319
|
-
this.sawOutputSinceInput = false; // arm the raw idle heuristic for THIS turn
|
|
352
|
+
this.sawOutputSinceInput = false; // arm the persistent-raw idle heuristic for THIS turn
|
|
320
353
|
try {
|
|
321
354
|
if (this.mode === "stream-json") {
|
|
322
355
|
const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
|
|
@@ -353,11 +386,17 @@ export class HeadlessSession {
|
|
|
353
386
|
env: mergeEnv(process.env, this.config.env),
|
|
354
387
|
stdio: ["ignore", "pipe", "pipe"],
|
|
355
388
|
});
|
|
356
|
-
// A turn already running is aborted before its replacement starts
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
389
|
+
// A turn already running is aborted before its replacement starts: `input()` accepts a turn
|
|
390
|
+
// at any time — a human typing in the console, a takeover, a Loop that stopped waiting —
|
|
391
|
+
// so without this TWO engine processes edit the same repo at once. Still needed after #391
|
|
392
|
+
// made exit authoritative: that stops the Pilot from being TOLD a running turn is over, it
|
|
393
|
+
// does not stop anyone from sending anyway.
|
|
394
|
+
//
|
|
395
|
+
// It SAYS what it destroyed, for the same reason the non-zero exit code goes into the
|
|
396
|
+
// transcript below: a turn's work vanishing with no line in the record is how the Pilot
|
|
397
|
+
// ends up reasoning about a turn that never finished, with nothing to explain the gap.
|
|
360
398
|
if (this.procAlive) {
|
|
399
|
+
this.push(`[${this.config.clientType} turn aborted — a new instruction arrived while the previous one was still running]`);
|
|
361
400
|
try {
|
|
362
401
|
this.proc?.kill();
|
|
363
402
|
}
|
|
@@ -380,7 +419,23 @@ export class HeadlessSession {
|
|
|
380
419
|
});
|
|
381
420
|
proc.stdout?.on("data", (d) => this.onStdout(d.toString()));
|
|
382
421
|
proc.stderr?.on("data", (d) => this.onStdout(d.toString()));
|
|
422
|
+
// The enforced ceiling (#391). `unref` so a pending timer can never keep the runner's
|
|
423
|
+
// event loop alive past its own shutdown.
|
|
424
|
+
const maxTurnMs = this.config.maxTurnMs ?? MAX_ONE_SHOT_TURN_MS;
|
|
425
|
+
const ceiling = setTimeout(() => {
|
|
426
|
+
if (this.proc !== proc)
|
|
427
|
+
return; // a newer turn owns the session; this one is already gone
|
|
428
|
+
this.push(`[${this.config.clientType} turn ended after ${Math.round(maxTurnMs / 60000)}m — the engine never exited, so the session was unwedged]`);
|
|
429
|
+
try {
|
|
430
|
+
proc.kill();
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
/* already gone */
|
|
434
|
+
}
|
|
435
|
+
}, maxTurnMs);
|
|
436
|
+
ceiling.unref();
|
|
383
437
|
proc.on("close", (code) => {
|
|
438
|
+
clearTimeout(ceiling); // cleared before the staleness guard: the timer belongs to THIS process
|
|
384
439
|
// A non-zero exit is the engine's own failure (bad flags, not signed in) and the
|
|
385
440
|
// operator needs to see it — silently going idle is how "stdin is not a terminal"
|
|
386
441
|
// looked like an idle session for a whole afternoon.
|
|
@@ -11,8 +11,8 @@ export class CodingRuntime {
|
|
|
11
11
|
/**
|
|
12
12
|
* Active human handoffs keyed by session id. `resolved` flips when the human
|
|
13
13
|
* finishes (console "Resume" / submits a value); the brain workflow polls
|
|
14
|
-
* {@link takeoverStatus} and continues once it does — the
|
|
15
|
-
* browser runtime's handoff-status machinery.
|
|
14
|
+
* {@link takeoverStatus} and continues once it does — the coding-session analogue of
|
|
15
|
+
* the browser runtime's handoff-status machinery.
|
|
16
16
|
*/
|
|
17
17
|
takeovers = new Map();
|
|
18
18
|
/** Base directory under which repos are cloned (one subdir per repo). */
|
package/dist/index.js
CHANGED
|
@@ -617,11 +617,57 @@ var publishCommand = new Command5("publish").description("Publish an agent to Pr
|
|
|
617
617
|
|
|
618
618
|
// src/commands/runner/command.ts
|
|
619
619
|
import { spawn as spawn3 } from "child_process";
|
|
620
|
-
import { randomUUID } from "crypto";
|
|
620
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
621
621
|
import { Command as Command6 } from "commander";
|
|
622
622
|
|
|
623
623
|
// src/commands/runner/http.ts
|
|
624
|
-
import { hostname } from "os";
|
|
624
|
+
import { hostname as hostname2 } from "os";
|
|
625
|
+
|
|
626
|
+
// src/machine.ts
|
|
627
|
+
import { randomUUID } from "crypto";
|
|
628
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
629
|
+
import { homedir as homedir2, hostname } from "os";
|
|
630
|
+
import { join as join5 } from "path";
|
|
631
|
+
var CONFIG_DIR2 = join5(homedir2(), ".config", "proagentstore");
|
|
632
|
+
var MACHINE_FILE = join5(CONFIG_DIR2, "machine.json");
|
|
633
|
+
var MAX_NAMES = 10;
|
|
634
|
+
function isValidMachineId(value) {
|
|
635
|
+
return typeof value === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(value);
|
|
636
|
+
}
|
|
637
|
+
function parseMachineFile(text) {
|
|
638
|
+
try {
|
|
639
|
+
const data = JSON.parse(text);
|
|
640
|
+
if (!isValidMachineId(data.id)) return null;
|
|
641
|
+
const names = Array.isArray(data.names) ? data.names.filter((n) => typeof n === "string" && n.trim().length > 0).map((n) => n.trim()) : [];
|
|
642
|
+
return { id: data.id, names: names.slice(0, MAX_NAMES) };
|
|
643
|
+
} catch {
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function withName(identity, name) {
|
|
648
|
+
const current = name.trim();
|
|
649
|
+
const prev = identity?.names ?? [];
|
|
650
|
+
const names = current ? [current, ...prev.filter((n) => n !== current)] : [...prev];
|
|
651
|
+
return { id: identity?.id ?? "", names: names.slice(0, MAX_NAMES) };
|
|
652
|
+
}
|
|
653
|
+
function loadMachineIdentity(now = hostname()) {
|
|
654
|
+
let stored = null;
|
|
655
|
+
try {
|
|
656
|
+
if (existsSync5(MACHINE_FILE)) stored = parseMachineFile(readFileSync4(MACHINE_FILE, "utf-8"));
|
|
657
|
+
} catch {
|
|
658
|
+
}
|
|
659
|
+
const next = withName(stored ?? { id: randomUUID(), names: [] }, now);
|
|
660
|
+
if (stored && stored.id === next.id && stored.names.join("\0") === next.names.join("\0")) return next;
|
|
661
|
+
try {
|
|
662
|
+
mkdirSync3(CONFIG_DIR2, { recursive: true });
|
|
663
|
+
writeFileSync3(MACHINE_FILE, JSON.stringify(next, null, 2));
|
|
664
|
+
return next;
|
|
665
|
+
} catch {
|
|
666
|
+
return stored ?? { id: "", names: [] };
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// src/commands/runner/http.ts
|
|
625
671
|
function clean(value) {
|
|
626
672
|
const trimmed = value?.trim();
|
|
627
673
|
return trimmed || void 0;
|
|
@@ -647,13 +693,17 @@ function apiPathSegment(value) {
|
|
|
647
693
|
return encodeURIComponent(value);
|
|
648
694
|
}
|
|
649
695
|
function buildRuntimeRegistrationBody(opts, capabilities = []) {
|
|
696
|
+
const node = hostname2();
|
|
697
|
+
const machine = loadMachineIdentity(node);
|
|
650
698
|
return {
|
|
651
699
|
endpointUrl: clean(opts.endpointUrl) || opts.endpointUrl,
|
|
652
700
|
token: clean(opts.runnerToken) || clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN),
|
|
653
701
|
placement: opts.placement === "managed" ? "managed" : "local",
|
|
654
702
|
capabilities,
|
|
655
703
|
runnerVersion: clean(opts.runnerVersion) || "",
|
|
656
|
-
runnerNode:
|
|
704
|
+
runnerNode: node,
|
|
705
|
+
machineId: machine.id,
|
|
706
|
+
machineNames: machine.names
|
|
657
707
|
};
|
|
658
708
|
}
|
|
659
709
|
async function requestRunner(method, path, opts, body) {
|
|
@@ -708,7 +758,7 @@ function responseErrorMessage(data, text, statusText) {
|
|
|
708
758
|
|
|
709
759
|
// src/commands/runner/process.ts
|
|
710
760
|
import { spawn as spawn2 } from "child_process";
|
|
711
|
-
import { existsSync as
|
|
761
|
+
import { existsSync as existsSync6 } from "fs";
|
|
712
762
|
import { resolve as resolve4 } from "path";
|
|
713
763
|
import { createServer as createServer2 } from "net";
|
|
714
764
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
@@ -745,7 +795,7 @@ function buildRunnerArgs(opts) {
|
|
|
745
795
|
function findWorkspaceRoot() {
|
|
746
796
|
let dir = process.cwd();
|
|
747
797
|
for (let i = 0; i < 8; i++) {
|
|
748
|
-
if (
|
|
798
|
+
if (existsSync6(resolve4(dir, "pnpm-workspace.yaml"))) return dir;
|
|
749
799
|
const parent = resolve4(dir, "..");
|
|
750
800
|
if (parent === dir) break;
|
|
751
801
|
dir = parent;
|
|
@@ -763,10 +813,10 @@ function runnerSpawnSpec(opts) {
|
|
|
763
813
|
let cwd = root;
|
|
764
814
|
let command = "pags-browser-runner";
|
|
765
815
|
let args = runnerArgs;
|
|
766
|
-
if (
|
|
816
|
+
if (existsSync6(localPackage)) {
|
|
767
817
|
command = "pnpm";
|
|
768
818
|
args = ["--filter", "@proagentstore/browser-runner", "dev", "--", ...runnerArgs];
|
|
769
|
-
} else if (
|
|
819
|
+
} else if (existsSync6(bundledPackage)) {
|
|
770
820
|
cwd = process.cwd();
|
|
771
821
|
command = process.execPath;
|
|
772
822
|
args = [bundledPackage, ...runnerArgs];
|
|
@@ -804,19 +854,19 @@ async function waitForLocalRunner(opts, timeoutMs = 15e3) {
|
|
|
804
854
|
}
|
|
805
855
|
|
|
806
856
|
// src/commands/runner/relay.ts
|
|
807
|
-
import { hostname as
|
|
857
|
+
import { hostname as hostname3 } from "os";
|
|
808
858
|
|
|
809
859
|
// src/commands/runner/membership.ts
|
|
810
|
-
function isEligible(inst, thisNode) {
|
|
860
|
+
function isEligible(inst, thisNode, alsoKnownAs = []) {
|
|
811
861
|
if (inst.status !== "active") return false;
|
|
812
862
|
if (inst.capabilities?.runtime == null) return false;
|
|
813
863
|
const pin = inst.config?.runnerNode;
|
|
814
|
-
if (pin && pin !== thisNode) return false;
|
|
864
|
+
if (pin && pin !== thisNode && !alsoKnownAs.includes(pin)) return false;
|
|
815
865
|
return true;
|
|
816
866
|
}
|
|
817
|
-
function diffMembership(attached, eligible, thisNode, blocked = /* @__PURE__ */ new Set()) {
|
|
867
|
+
function diffMembership(attached, eligible, thisNode, blocked = /* @__PURE__ */ new Set(), alsoKnownAs = []) {
|
|
818
868
|
const have = new Set(attached);
|
|
819
|
-
const want = eligible.filter((i) => isEligible(i, thisNode));
|
|
869
|
+
const want = eligible.filter((i) => isEligible(i, thisNode, alsoKnownAs));
|
|
820
870
|
const wantIds = new Set(want.map((i) => i.id));
|
|
821
871
|
return {
|
|
822
872
|
attach: want.filter((i) => !have.has(i.id) && !blocked.has(i.id)),
|
|
@@ -836,7 +886,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
836
886
|
const apiBase = pagsApiBase(opts.apiBase).replace(/^http/, "ws");
|
|
837
887
|
const pagsToken = clean(opts.pagsToken) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
|
|
838
888
|
if (!pagsToken) throw new Error("PAGS token required for WebSocket relay");
|
|
839
|
-
const runnerNode =
|
|
889
|
+
const runnerNode = hostname3();
|
|
890
|
+
const machine = loadMachineIdentity(runnerNode);
|
|
840
891
|
const capabilities = await requestRunner("GET", "/capabilities", { url: localUrl, token: runnerToken, instanceId: instanceIds[0] });
|
|
841
892
|
const caps = Array.isArray(capabilities.capabilities) ? capabilities.capabilities.filter((item) => typeof item === "string") : [];
|
|
842
893
|
const registerRuntime = async (id) => {
|
|
@@ -848,6 +899,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
848
899
|
capabilities: caps,
|
|
849
900
|
runnerVersion: CLI_VERSION,
|
|
850
901
|
runnerNode,
|
|
902
|
+
machineId: machine.id,
|
|
903
|
+
machineNames: machine.names,
|
|
851
904
|
force
|
|
852
905
|
});
|
|
853
906
|
} catch (e) {
|
|
@@ -881,7 +934,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
881
934
|
writeLine("Runtime registered with PAGS \u2713");
|
|
882
935
|
writeLine("");
|
|
883
936
|
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");
|
|
884
|
-
writeLine(` \u2705 CONNECTED \u2014 WebSocket relay \xB7 ${
|
|
937
|
+
writeLine(` \u2705 CONNECTED \u2014 WebSocket relay \xB7 ${hostname3()}`);
|
|
885
938
|
writeLine(` Agents: ${instanceIds.length} instance${instanceIds.length === 1 ? "" : "s"}`);
|
|
886
939
|
writeLine(" No cloudflared needed. Ctrl+C to disconnect.");
|
|
887
940
|
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");
|
|
@@ -922,7 +975,11 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
922
975
|
attached.keys(),
|
|
923
976
|
res.instances ?? [],
|
|
924
977
|
runnerNode,
|
|
925
|
-
blocked
|
|
978
|
+
blocked,
|
|
979
|
+
// The names this machine has also worn. Without them a pin made under a
|
|
980
|
+
// previous hostname reads as "pinned to another machine", and this poll
|
|
981
|
+
// detaches the agent twenty seconds after startup attached it (#379).
|
|
982
|
+
machine.names
|
|
926
983
|
);
|
|
927
984
|
for (const inst of toAttach) {
|
|
928
985
|
await registerRuntime(inst.id);
|
|
@@ -963,7 +1020,7 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
963
1020
|
backoffMs = Math.min(backoffMs * 2, 3e4);
|
|
964
1021
|
return;
|
|
965
1022
|
}
|
|
966
|
-
const params = new URLSearchParams({ token: relayToken, node:
|
|
1023
|
+
const params = new URLSearchParams({ token: relayToken, node: hostname3() });
|
|
967
1024
|
if (force) params.set("force", "1");
|
|
968
1025
|
const url = `${wsBase}/v1/relay/${encodeURIComponent(instanceId)}/connect?${params.toString()}`;
|
|
969
1026
|
const ws = new WebSocket(url);
|
|
@@ -1069,7 +1126,7 @@ function createRunnerCommand() {
|
|
|
1069
1126
|
await startRunnerForeground(opts);
|
|
1070
1127
|
});
|
|
1071
1128
|
command.command("connect <instanceIds...>").description("Start ONE local runtime, connect via WebSocket relay, 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("--runner-version <version>", "Runner version").option("--force", "Take over from another connected machine").option("--watch-instances", "Attach newly eligible agents while running, without a restart").action(async (instanceIds, opts) => {
|
|
1072
|
-
const runnerToken = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${
|
|
1129
|
+
const runnerToken = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID2()}`;
|
|
1073
1130
|
const host = clean(opts.host) || "127.0.0.1";
|
|
1074
1131
|
const port = clean(opts.port) || String(await findFreePort2(49171));
|
|
1075
1132
|
const localUrl = `http://${host}:${port}`;
|
|
@@ -1231,7 +1288,7 @@ import { Command as Command7 } from "commander";
|
|
|
1231
1288
|
|
|
1232
1289
|
// src/tui.ts
|
|
1233
1290
|
import chalk from "chalk";
|
|
1234
|
-
import { hostname as
|
|
1291
|
+
import { hostname as hostname4 } from "os";
|
|
1235
1292
|
import readline from "readline";
|
|
1236
1293
|
var ACCENT = "#7c3aed";
|
|
1237
1294
|
var c = chalk.hex(ACCENT);
|
|
@@ -1273,7 +1330,7 @@ function printStatus(state) {
|
|
|
1273
1330
|
clearScreen();
|
|
1274
1331
|
printLogo(state.version);
|
|
1275
1332
|
const connected = state.runner === "online" && state.tunnel === "online" && state.registration === "registered";
|
|
1276
|
-
console.log(pad + d("Signed in as ") + w(state.user) + d(" \xB7 agent: ") + w(state.activeInstance) + d(" \xB7 node: ") + w(
|
|
1333
|
+
console.log(pad + d("Signed in as ") + w(state.user) + d(" \xB7 agent: ") + w(state.activeInstance) + d(" \xB7 node: ") + w(hostname4()));
|
|
1277
1334
|
console.log("");
|
|
1278
1335
|
const row = (kind, s) => {
|
|
1279
1336
|
const { icon, label, note } = describe(kind, s);
|