@proagentstore/cli 0.4.29 → 0.4.31
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/headless.js +11 -2
- package/dist/browser-runner/coding/repo.js +63 -0
- package/dist/browser-runner/coding/runtime.js +3 -3
- package/dist/browser-runner/coding/terminal.js +256 -0
- package/dist/browser-runner/coding/tmux.js +0 -48
- package/dist/browser-runner/server.js +68 -36
- package/dist/index.js +114 -13
- package/package.json +1 -1
|
@@ -24,7 +24,16 @@ import { dirname, join } from "node:path";
|
|
|
24
24
|
import { handlerFor } from "./handlers.js";
|
|
25
25
|
export class HeadlessSession {
|
|
26
26
|
config;
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* A human-readable label for this engine process (#247).
|
|
29
|
+
*
|
|
30
|
+
* Was `sessionName`, formatted `pags-<client>-<id>` — which looked exactly like a tmux
|
|
31
|
+
* target and was reported to the console as `tmuxSession`. The engine has not used tmux
|
|
32
|
+
* since it moved to the stream-json interface, so a user who did the obvious thing with a
|
|
33
|
+
* name like that (`tmux attach -t pags-claude-…`) got "session not found" and reasonably
|
|
34
|
+
* concluded their engine was broken. It addresses nothing — it is only ever displayed.
|
|
35
|
+
*/
|
|
36
|
+
engineLabel;
|
|
28
37
|
proc = null;
|
|
29
38
|
buf = "";
|
|
30
39
|
transcript = [];
|
|
@@ -55,7 +64,7 @@ export class HeadlessSession {
|
|
|
55
64
|
spawnFailed = false;
|
|
56
65
|
constructor(config) {
|
|
57
66
|
this.config = config;
|
|
58
|
-
this.
|
|
67
|
+
this.engineLabel = `${config.clientType}:${config.id}`;
|
|
59
68
|
this.claudeSessionId = readState(config.statePath, config.id);
|
|
60
69
|
// Claude is the structured engine; everything else is a raw CLI.
|
|
61
70
|
this.mode = config.clientType === "claude" ? "stream-json" : "raw";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Repo/workdir helpers for the coding engine.
|
|
6
|
+
*
|
|
7
|
+
* These lived in `coding/tmux.ts` and have nothing to do with tmux — `ensureRepo` runs `git
|
|
8
|
+
* clone`. The coding engine stopped using tmux when it moved to the structured stream-json
|
|
9
|
+
* interface, so anyone cleaning up that module found its two most load-bearing functions
|
|
10
|
+
* inside it (#247). Split out so the tmux module is only tmux, and only the terminal-operator
|
|
11
|
+
* agents depend on it.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* A safe, collision-resistant label derived from an arbitrary string.
|
|
15
|
+
*
|
|
16
|
+
* Named for tmux because that is where it started, but the coding engine uses it purely as a
|
|
17
|
+
* display/identity label — no tmux target is derived from it (#247).
|
|
18
|
+
*/
|
|
19
|
+
export function sanitizeSessionName(label) {
|
|
20
|
+
return label.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "session";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Ensure a repo is present at `dir`, cloning it from `cloneUrl` if not. Idempotent
|
|
24
|
+
* — an existing checkout is left alone (no clobber). For private repos a GitHub
|
|
25
|
+
* App installation token is injected as `x-access-token` into an https URL. The
|
|
26
|
+
* coding CLI then runs in this directory.
|
|
27
|
+
*
|
|
28
|
+
* Returns the absolute working directory. Throws on clone failure so the caller
|
|
29
|
+
* can surface it (a session can't start without its repo).
|
|
30
|
+
*/
|
|
31
|
+
export function ensureRepo(dir, opts = {}) {
|
|
32
|
+
// A real checkout (has .git) is reused as-is.
|
|
33
|
+
if (existsSync(join(dir, ".git")))
|
|
34
|
+
return dir;
|
|
35
|
+
if (!opts.cloneUrl) {
|
|
36
|
+
// No source to clone from — just make the directory the engine will run in.
|
|
37
|
+
if (!existsSync(dir))
|
|
38
|
+
mkdirSync(dir, { recursive: true });
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
// The dir exists but has no `.git`. It could be a half-cloned/empty managed dir
|
|
42
|
+
// (safe to clear) OR a real user directory the caller passed as an explicit workDir
|
|
43
|
+
// (deleting it = data loss). NEVER recursively delete a non-empty non-git dir — refuse
|
|
44
|
+
// instead, so a mis-wired workDir+cloneUrl can't nuke a user's files. An empty dir is
|
|
45
|
+
// fine to remove (git clone needs an empty/absent target).
|
|
46
|
+
if (existsSync(dir)) {
|
|
47
|
+
const entries = readdirSync(dir);
|
|
48
|
+
if (entries.length > 0) {
|
|
49
|
+
throw new Error(`Refusing to clone into non-empty directory "${dir}" (no .git found) — move it aside or point at an empty path.`);
|
|
50
|
+
}
|
|
51
|
+
rmSync(dir, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
let url = opts.cloneUrl;
|
|
54
|
+
if (opts.token && /^https:\/\//.test(url)) {
|
|
55
|
+
url = url.replace(/^https:\/\//, `https://x-access-token:${opts.token}@`);
|
|
56
|
+
}
|
|
57
|
+
const args = ["clone", "--depth", "1"];
|
|
58
|
+
if (opts.branch)
|
|
59
|
+
args.push("--branch", opts.branch);
|
|
60
|
+
args.push(url, dir);
|
|
61
|
+
execFileSync("git", args, { stdio: "pipe", timeout: 180_000 });
|
|
62
|
+
return dir;
|
|
63
|
+
}
|
|
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { defaultStatePath, HeadlessSession } from "./headless.js";
|
|
4
4
|
import { InspectError, readGitRemoteOrigin, readRepoFile, repoTree, runRepoGit } from "./inspect.js";
|
|
5
|
-
import { ensureRepo, sanitizeSessionName } from "./
|
|
5
|
+
import { ensureRepo, sanitizeSessionName } from "./repo.js";
|
|
6
6
|
/** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
|
|
7
7
|
const MAX_PANE = 64 * 1024;
|
|
8
8
|
export class CodingRuntime {
|
|
@@ -135,14 +135,14 @@ export class CodingRuntime {
|
|
|
135
135
|
return [...this.sessions.entries()].map(([sessionId, s]) => ({
|
|
136
136
|
sessionId,
|
|
137
137
|
alive: s.alive,
|
|
138
|
-
|
|
138
|
+
engineLabel: s.engineLabel,
|
|
139
139
|
}));
|
|
140
140
|
}
|
|
141
141
|
/** Rich diagnostics for every tracked session — the console's transparency view. */
|
|
142
142
|
diagnostics() {
|
|
143
143
|
return [...this.sessions.entries()].map(([sessionId, s]) => ({
|
|
144
144
|
sessionId,
|
|
145
|
-
|
|
145
|
+
engineLabel: s.engineLabel,
|
|
146
146
|
alive: s.alive,
|
|
147
147
|
runState: s.alive ? s.runState() : "idle",
|
|
148
148
|
ready: s.alive ? s.ready : false,
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { capturePane, createSession, killSession, listSessionsDetailed, runCommand as tmuxRunCommand, sendKey as tmuxSendKey, sendText as tmuxSendText, sessionExists } from "./tmux.js";
|
|
5
|
+
function shellPath() {
|
|
6
|
+
return process.env.SHELL || "/bin/zsh";
|
|
7
|
+
}
|
|
8
|
+
function expandWorkDir(workDir) {
|
|
9
|
+
return resolve(String(workDir || "~").replace(/^~(?=$|\/)/, homedir()));
|
|
10
|
+
}
|
|
11
|
+
export function splitTerminalTarget(raw, fallback) {
|
|
12
|
+
const value = String(raw || "").trim();
|
|
13
|
+
const m = value.match(/^(tmux|kitty|iterm2):(.+)$/i);
|
|
14
|
+
if (m)
|
|
15
|
+
return { backend: m[1].toLowerCase(), id: m[2].trim() };
|
|
16
|
+
if (!fallback)
|
|
17
|
+
throw new Error("A target must include a backend prefix like `tmux:main`, or pass `backend` separately.");
|
|
18
|
+
return { backend: fallback, id: value };
|
|
19
|
+
}
|
|
20
|
+
export function listTerminalTargets(backend = "all") {
|
|
21
|
+
const out = [];
|
|
22
|
+
if (backend === "all" || backend === "tmux") {
|
|
23
|
+
for (const s of listSessionsDetailed()) {
|
|
24
|
+
out.push({
|
|
25
|
+
backend: "tmux",
|
|
26
|
+
id: s.name,
|
|
27
|
+
name: s.name,
|
|
28
|
+
attached: s.attached,
|
|
29
|
+
activeCommand: s.activeCommand,
|
|
30
|
+
activeWindow: s.activeWindow,
|
|
31
|
+
created: s.created,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (backend === "all" || backend === "kitty")
|
|
36
|
+
out.push(...listKittyTargets());
|
|
37
|
+
if (backend === "all" || backend === "iterm2")
|
|
38
|
+
out.push(...listItermTargets());
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
export function captureTerminalTarget(target, opts = {}) {
|
|
42
|
+
const t = splitTerminalTarget(target, opts.backend);
|
|
43
|
+
switch (t.backend) {
|
|
44
|
+
case "tmux":
|
|
45
|
+
if (!sessionExists(t.id))
|
|
46
|
+
throw new Error(`No tmux session "${t.id}".`);
|
|
47
|
+
return capturePane(t.id, Math.min(Math.max(Number(opts.lines) || 200, 1), 2000));
|
|
48
|
+
case "kitty":
|
|
49
|
+
return kittyExec(["@", "get-text", "--match", `id:${t.id}`, "--extent", "all"], 5000);
|
|
50
|
+
case "iterm2":
|
|
51
|
+
return itermSessionScript(t.id, "return contents of theSession");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function runTerminalCommand(target, command, backend) {
|
|
55
|
+
const t = splitTerminalTarget(target, backend);
|
|
56
|
+
if (!command.trim())
|
|
57
|
+
throw new Error("A `command` is required.");
|
|
58
|
+
switch (t.backend) {
|
|
59
|
+
case "tmux":
|
|
60
|
+
if (!sessionExists(t.id))
|
|
61
|
+
throw new Error(`No tmux session "${t.id}".`);
|
|
62
|
+
tmuxRunCommand(t.id, command);
|
|
63
|
+
return capturePane(t.id, 200);
|
|
64
|
+
case "kitty":
|
|
65
|
+
kittyExec(["@", "send-text", "--match", `id:${t.id}`, command]);
|
|
66
|
+
kittyExec(["@", "send-key", "--match", `id:${t.id}`, "enter"]);
|
|
67
|
+
return captureTerminalTarget(t.id, { backend: "kitty" });
|
|
68
|
+
case "iterm2":
|
|
69
|
+
itermSessionScript(t.id, `tell theSession to write text ${appleString(command)}`);
|
|
70
|
+
return captureTerminalTarget(t.id, { backend: "iterm2" });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function sendTerminalKeys(target, opts) {
|
|
74
|
+
const t = splitTerminalTarget(target, opts.backend);
|
|
75
|
+
if (opts.text == null && (!opts.keys || opts.keys.length === 0))
|
|
76
|
+
throw new Error("Provide `text` and/or `keys` to send.");
|
|
77
|
+
switch (t.backend) {
|
|
78
|
+
case "tmux":
|
|
79
|
+
if (!sessionExists(t.id))
|
|
80
|
+
throw new Error(`No tmux session "${t.id}".`);
|
|
81
|
+
if (opts.text != null)
|
|
82
|
+
tmuxSendText(t.id, opts.text);
|
|
83
|
+
for (const key of opts.keys ?? [])
|
|
84
|
+
tmuxSendKey(t.id, key);
|
|
85
|
+
return capturePane(t.id, 200);
|
|
86
|
+
case "kitty":
|
|
87
|
+
if (opts.text != null)
|
|
88
|
+
kittyExec(["@", "send-text", "--match", `id:${t.id}`, opts.text]);
|
|
89
|
+
for (const key of opts.keys ?? [])
|
|
90
|
+
kittyExec(["@", "send-key", "--match", `id:${t.id}`, kittyKeyName(key)]);
|
|
91
|
+
return captureTerminalTarget(t.id, { backend: "kitty" });
|
|
92
|
+
case "iterm2":
|
|
93
|
+
if ((opts.keys ?? []).length > 0)
|
|
94
|
+
throw new Error("iTerm2 generic key events are not supported yet; send text or run a command.");
|
|
95
|
+
itermSessionScript(t.id, `tell theSession to write text ${appleString(opts.text ?? "")}`);
|
|
96
|
+
return captureTerminalTarget(t.id, { backend: "iterm2" });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function createTerminalTarget(opts) {
|
|
100
|
+
const workDir = expandWorkDir(opts.workDir);
|
|
101
|
+
switch (opts.backend) {
|
|
102
|
+
case "tmux": {
|
|
103
|
+
const name = String(opts.name || "").trim();
|
|
104
|
+
if (!name)
|
|
105
|
+
throw new Error("A `name` is required for tmux.");
|
|
106
|
+
if (sessionExists(name))
|
|
107
|
+
return { backend: "tmux", id: name, name, existed: true, workDir };
|
|
108
|
+
createSession(name, workDir, opts.command);
|
|
109
|
+
return { backend: "tmux", id: name, name, workDir };
|
|
110
|
+
}
|
|
111
|
+
case "kitty": {
|
|
112
|
+
const args = ["@", "launch", "--type", "os-window", "--cwd", workDir];
|
|
113
|
+
if (opts.command)
|
|
114
|
+
args.push(shellPath(), "-lc", opts.command);
|
|
115
|
+
const id = kittyExec(args).trim();
|
|
116
|
+
return { backend: "kitty", id, name: opts.name || id, workDir };
|
|
117
|
+
}
|
|
118
|
+
case "iterm2": {
|
|
119
|
+
const script = [
|
|
120
|
+
'tell application "iTerm2"',
|
|
121
|
+
"create window with default profile",
|
|
122
|
+
"set theSession to current session of current window",
|
|
123
|
+
`tell theSession to write text ${appleString(`cd ${shellQuote(workDir)}${opts.command ? ` && ${opts.command}` : ""}`)}`,
|
|
124
|
+
"return ((index of current window) as text) & \":1:1\"",
|
|
125
|
+
"end tell",
|
|
126
|
+
].join("\n");
|
|
127
|
+
const id = osascript(script).trim() || "1:1:1";
|
|
128
|
+
return { backend: "iterm2", id, name: opts.name || id, workDir };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export function killTerminalTarget(target, backend) {
|
|
133
|
+
const t = splitTerminalTarget(target, backend);
|
|
134
|
+
switch (t.backend) {
|
|
135
|
+
case "tmux":
|
|
136
|
+
return killSession(t.id);
|
|
137
|
+
case "kitty":
|
|
138
|
+
kittyExec(["@", "close-window", "--match", `id:${t.id}`]);
|
|
139
|
+
return true;
|
|
140
|
+
case "iterm2":
|
|
141
|
+
itermCloseTarget(t.id);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function listKittyTargets() {
|
|
146
|
+
try {
|
|
147
|
+
const raw = kittyExec(["@", "ls"], 5000);
|
|
148
|
+
const parsed = JSON.parse(raw);
|
|
149
|
+
if (!Array.isArray(parsed))
|
|
150
|
+
return [];
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const osWindow of parsed) {
|
|
153
|
+
const tabs = Array.isArray(osWindow.tabs) ? osWindow.tabs : [];
|
|
154
|
+
for (const tab of tabs) {
|
|
155
|
+
const windows = Array.isArray(tab.windows) ? tab.windows : [];
|
|
156
|
+
for (const win of windows) {
|
|
157
|
+
const row = win;
|
|
158
|
+
const id = String(row.id ?? "").trim();
|
|
159
|
+
if (!id)
|
|
160
|
+
continue;
|
|
161
|
+
const title = String(row.title ?? row.user_vars ?? id);
|
|
162
|
+
out.push({ backend: "kitty", id, name: title || id, activeWindow: String(tab.title ?? "") });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function listItermTargets() {
|
|
173
|
+
try {
|
|
174
|
+
const script = [
|
|
175
|
+
'set out to ""',
|
|
176
|
+
'tell application "iTerm2"',
|
|
177
|
+
"repeat with wi from 1 to count of windows",
|
|
178
|
+
"repeat with ti from 1 to count of tabs of window wi",
|
|
179
|
+
"repeat with si from 1 to count of sessions of tab ti of window wi",
|
|
180
|
+
"set sid to (wi as text) & \":\" & (ti as text) & \":\" & (si as text)",
|
|
181
|
+
"set sname to name of session si of tab ti of window wi",
|
|
182
|
+
"set out to out & sid & (ASCII character 9) & sname & linefeed",
|
|
183
|
+
"end repeat",
|
|
184
|
+
"end repeat",
|
|
185
|
+
"end repeat",
|
|
186
|
+
"end tell",
|
|
187
|
+
"return out",
|
|
188
|
+
].join("\n");
|
|
189
|
+
return osascript(script)
|
|
190
|
+
.split("\n")
|
|
191
|
+
.map((line) => line.trim())
|
|
192
|
+
.filter(Boolean)
|
|
193
|
+
.flatMap((line) => {
|
|
194
|
+
const [id, name] = line.split("\t");
|
|
195
|
+
return id ? [{ backend: "iterm2", id, name: name || id }] : [];
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function kittyExec(args, timeoutMs = 5000) {
|
|
203
|
+
try {
|
|
204
|
+
return execFileSync("kitty", args, { encoding: "utf8", timeout: timeoutMs, stdio: "pipe" });
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
throw new Error(`kitty remote control is unavailable. Start kitty with remote control enabled (for example, allow_remote_control yes). ${e instanceof Error ? e.message : String(e)}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function osascript(script) {
|
|
211
|
+
try {
|
|
212
|
+
return execFileSync("osascript", ["-e", script], { encoding: "utf8", timeout: 5000, stdio: "pipe" });
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
throw new Error(`iTerm2 automation is unavailable. Open iTerm2 and grant macOS Automation/Accessibility permission if prompted. ${e instanceof Error ? e.message : String(e)}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function itermSessionScript(target, command) {
|
|
219
|
+
const [w, t, s] = target.split(":").map((v) => Math.max(1, Number.parseInt(v, 10) || 1));
|
|
220
|
+
const script = [
|
|
221
|
+
'tell application "iTerm2"',
|
|
222
|
+
`set theSession to session ${s} of tab ${t} of window ${w}`,
|
|
223
|
+
command,
|
|
224
|
+
"end tell",
|
|
225
|
+
].join("\n");
|
|
226
|
+
return osascript(script);
|
|
227
|
+
}
|
|
228
|
+
function itermCloseTarget(target) {
|
|
229
|
+
const [w, t] = target.split(":").map((v) => Math.max(1, Number.parseInt(v, 10) || 1));
|
|
230
|
+
const script = [
|
|
231
|
+
'tell application "iTerm2"',
|
|
232
|
+
`close tab ${t} of window ${w}`,
|
|
233
|
+
"end tell",
|
|
234
|
+
].join("\n");
|
|
235
|
+
return osascript(script);
|
|
236
|
+
}
|
|
237
|
+
function appleString(s) {
|
|
238
|
+
return JSON.stringify(s);
|
|
239
|
+
}
|
|
240
|
+
function shellQuote(s) {
|
|
241
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
242
|
+
}
|
|
243
|
+
function kittyKeyName(key) {
|
|
244
|
+
const k = key.trim();
|
|
245
|
+
if (/^enter$/i.test(k))
|
|
246
|
+
return "enter";
|
|
247
|
+
if (/^escape$/i.test(k))
|
|
248
|
+
return "escape";
|
|
249
|
+
if (/^tab$/i.test(k))
|
|
250
|
+
return "tab";
|
|
251
|
+
if (/^backspace$/i.test(k))
|
|
252
|
+
return "backspace";
|
|
253
|
+
if (/^c-/i.test(k))
|
|
254
|
+
return `ctrl+${k.slice(2).toLowerCase()}`;
|
|
255
|
+
return k.toLowerCase();
|
|
256
|
+
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
2
|
/**
|
|
5
3
|
* Low-level tmux primitives for the coding runtime.
|
|
6
4
|
*
|
|
@@ -127,49 +125,3 @@ export function capturePane(target, lines = 200) {
|
|
|
127
125
|
const captured = tmuxExec(["capture-pane", "-p", "-t", target, "-S", `-${lines}`, "-J"]);
|
|
128
126
|
return stripAnsi(captured).trim();
|
|
129
127
|
}
|
|
130
|
-
/** A safe, collision-resistant tmux session name derived from an arbitrary label. */
|
|
131
|
-
export function sanitizeSessionName(label) {
|
|
132
|
-
return label.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "session";
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* Ensure a repo is present at `dir`, cloning it from `cloneUrl` if not. Idempotent
|
|
136
|
-
* — an existing checkout is left alone (no clobber). For private repos a GitHub
|
|
137
|
-
* App installation token is injected as `x-access-token` into an https URL. The
|
|
138
|
-
* coding CLI then runs in this directory.
|
|
139
|
-
*
|
|
140
|
-
* Returns the absolute working directory. Throws on clone failure so the caller
|
|
141
|
-
* can surface it (a session can't start without its repo).
|
|
142
|
-
*/
|
|
143
|
-
export function ensureRepo(dir, opts = {}) {
|
|
144
|
-
// A real checkout (has .git) is reused as-is.
|
|
145
|
-
if (existsSync(join(dir, ".git")))
|
|
146
|
-
return dir;
|
|
147
|
-
if (!opts.cloneUrl) {
|
|
148
|
-
// No source to clone from — make the directory so tmux can cd into it.
|
|
149
|
-
if (!existsSync(dir))
|
|
150
|
-
mkdirSync(dir, { recursive: true });
|
|
151
|
-
return dir;
|
|
152
|
-
}
|
|
153
|
-
// The dir exists but has no `.git`. It could be a half-cloned/empty managed dir
|
|
154
|
-
// (safe to clear) OR a real user directory the caller passed as an explicit workDir
|
|
155
|
-
// (deleting it = data loss). NEVER recursively delete a non-empty non-git dir — refuse
|
|
156
|
-
// instead, so a mis-wired workDir+cloneUrl can't nuke a user's files. An empty dir is
|
|
157
|
-
// fine to remove (git clone needs an empty/absent target).
|
|
158
|
-
if (existsSync(dir)) {
|
|
159
|
-
const entries = readdirSync(dir);
|
|
160
|
-
if (entries.length > 0) {
|
|
161
|
-
throw new Error(`Refusing to clone into non-empty directory "${dir}" (no .git found) — move it aside or point at an empty path.`);
|
|
162
|
-
}
|
|
163
|
-
rmSync(dir, { recursive: true, force: true });
|
|
164
|
-
}
|
|
165
|
-
let url = opts.cloneUrl;
|
|
166
|
-
if (opts.token && /^https:\/\//.test(url)) {
|
|
167
|
-
url = url.replace(/^https:\/\//, `https://x-access-token:${opts.token}@`);
|
|
168
|
-
}
|
|
169
|
-
const args = ["clone", "--depth", "1"];
|
|
170
|
-
if (opts.branch)
|
|
171
|
-
args.push("--branch", opts.branch);
|
|
172
|
-
args.push(url, dir);
|
|
173
|
-
execFileSync("git", args, { stdio: "pipe", timeout: 180_000 });
|
|
174
|
-
return dir;
|
|
175
|
-
}
|
|
@@ -162,18 +162,12 @@ async function route(runner, req, res) {
|
|
|
162
162
|
return json(res, 200, { sessions: runner.coding.list() });
|
|
163
163
|
}
|
|
164
164
|
if ((req.method === "GET" || req.method === "POST") && path === "/coding/diagnostics") {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return json(res, 200, {
|
|
172
|
-
tracked,
|
|
173
|
-
orphanedTmux,
|
|
174
|
-
tmuxTotal: allTmux.length,
|
|
175
|
-
pagsTmuxTotal: pagsTmux.length,
|
|
176
|
-
});
|
|
165
|
+
// No tmux figures here any more (#247). The coding engine spawns a child process
|
|
166
|
+
// directly, so `pagsTmuxTotal` was structurally always 0 and `tmuxTotal` counted the
|
|
167
|
+
// user's own unrelated sessions — this is the panel someone opens BECAUSE something is
|
|
168
|
+
// wrong, and it pointed them at a false cause. The terminal-operator agents, which do
|
|
169
|
+
// use tmux, have their own /tmux/* endpoints and are unaffected.
|
|
170
|
+
return json(res, 200, { tracked: runner.coding.diagnostics() });
|
|
177
171
|
}
|
|
178
172
|
if (req.method === "POST" && path === "/coding/browse") {
|
|
179
173
|
const { readdirSync, statSync } = await import("node:fs");
|
|
@@ -197,30 +191,14 @@ async function route(runner, req, res) {
|
|
|
197
191
|
return json(res, 400, { error: e instanceof Error ? e.message : String(e), dir });
|
|
198
192
|
}
|
|
199
193
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
targets = pagsTmux.filter((n) => !tracked.has(n));
|
|
209
|
-
}
|
|
210
|
-
else if (b.sessions?.length) {
|
|
211
|
-
targets = b.sessions.filter((n) => typeof n === "string" && n.startsWith("pags-"));
|
|
212
|
-
}
|
|
213
|
-
else {
|
|
214
|
-
// Kill all pags-* tmux sessions
|
|
215
|
-
targets = tmuxList().filter((n) => n.startsWith("pags-"));
|
|
216
|
-
runner.coding.closeAll();
|
|
217
|
-
}
|
|
218
|
-
let killed = 0;
|
|
219
|
-
for (const name of targets) {
|
|
220
|
-
if (tmuxKill(name))
|
|
221
|
-
killed++;
|
|
222
|
-
}
|
|
223
|
-
return json(res, 200, { killed, sessions: targets });
|
|
194
|
+
// Close every tracked coding session. The path still says "kill-tmux" ON PURPOSE: an older
|
|
195
|
+
// runner must keep answering a newer API, and renaming it would 404 across that skew (#247).
|
|
196
|
+
// The tmux half is gone — it only ever targeted `pags-*` sessions, which the coding engine
|
|
197
|
+
// has never created. `closeAll()` is the part that always worked, and is now the whole job.
|
|
198
|
+
if (req.method === "POST" && (path === "/coding/kill-tmux" || path === "/coding/close-sessions")) {
|
|
199
|
+
const closed = runner.coding.diagnostics().map((s) => s.sessionId);
|
|
200
|
+
runner.coding.closeAll();
|
|
201
|
+
return json(res, 200, { closed: closed.length, sessions: closed });
|
|
224
202
|
}
|
|
225
203
|
// ── Read-only code inspection (the Co-pilot/Chat's "eyes" — no CLI driving) ──
|
|
226
204
|
// Confined to the session's workDir by inspect.ts; errors surface as 400.
|
|
@@ -357,6 +335,60 @@ async function route(runner, req, res) {
|
|
|
357
335
|
createSession(session, workDir, b.command ? String(b.command) : undefined);
|
|
358
336
|
return json(res, 200, { session, created: true, workDir });
|
|
359
337
|
}
|
|
338
|
+
// ── generic terminal connector ──────────────────────────────────────────
|
|
339
|
+
// One local-terminal vocabulary over backend-specific adapters. tmux is fully
|
|
340
|
+
// controllable; kitty needs remote control enabled; iTerm2 needs macOS Automation access.
|
|
341
|
+
if ((req.method === "GET" || req.method === "POST") && path === "/terminal/list") {
|
|
342
|
+
const { listTerminalTargets } = await import("./coding/terminal.js");
|
|
343
|
+
const b = req.method === "POST" ? await readJson(req) : { backend: "all" };
|
|
344
|
+
const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : "all";
|
|
345
|
+
return json(res, 200, { targets: listTerminalTargets(backend) });
|
|
346
|
+
}
|
|
347
|
+
if (req.method === "POST" && path === "/terminal/capture") {
|
|
348
|
+
const { captureTerminalTarget } = await import("./coding/terminal.js");
|
|
349
|
+
const b = await readJson(req);
|
|
350
|
+
const target = String(b.target || "").trim();
|
|
351
|
+
if (!target)
|
|
352
|
+
return json(res, 400, { error: "A `target` is required." });
|
|
353
|
+
const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
|
|
354
|
+
return json(res, 200, { target, pane: captureTerminalTarget(target, { backend, lines: b.lines }) });
|
|
355
|
+
}
|
|
356
|
+
if (req.method === "POST" && path === "/terminal/run") {
|
|
357
|
+
const { runTerminalCommand } = await import("./coding/terminal.js");
|
|
358
|
+
const b = await readJson(req);
|
|
359
|
+
const target = String(b.target || "").trim();
|
|
360
|
+
const command = String(b.command ?? "");
|
|
361
|
+
if (!target)
|
|
362
|
+
return json(res, 400, { error: "A `target` is required." });
|
|
363
|
+
if (!command.trim())
|
|
364
|
+
return json(res, 400, { error: "A `command` is required." });
|
|
365
|
+
const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
|
|
366
|
+
return json(res, 200, { target, command, pane: runTerminalCommand(target, command, backend) });
|
|
367
|
+
}
|
|
368
|
+
if (req.method === "POST" && path === "/terminal/send") {
|
|
369
|
+
const { sendTerminalKeys } = await import("./coding/terminal.js");
|
|
370
|
+
const b = await readJson(req);
|
|
371
|
+
const target = String(b.target || "").trim();
|
|
372
|
+
if (!target)
|
|
373
|
+
return json(res, 400, { error: "A `target` is required." });
|
|
374
|
+
const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
|
|
375
|
+
return json(res, 200, { target, pane: sendTerminalKeys(target, { backend, text: b.text == null ? undefined : String(b.text), keys: b.keys ?? [] }) });
|
|
376
|
+
}
|
|
377
|
+
if (req.method === "POST" && path === "/terminal/session") {
|
|
378
|
+
const { createTerminalTarget, killTerminalTarget } = await import("./coding/terminal.js");
|
|
379
|
+
const b = await readJson(req);
|
|
380
|
+
const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
|
|
381
|
+
if (b.action === "kill") {
|
|
382
|
+
const target = String(b.target || "").trim();
|
|
383
|
+
if (!target)
|
|
384
|
+
return json(res, 400, { error: "A `target` is required." });
|
|
385
|
+
return json(res, 200, { target, killed: killTerminalTarget(target, backend) });
|
|
386
|
+
}
|
|
387
|
+
if (!backend)
|
|
388
|
+
return json(res, 400, { error: "`backend` must be tmux, kitty, or iterm2." });
|
|
389
|
+
const target = createTerminalTarget({ backend, name: b.name, workDir: b.workDir, command: b.command });
|
|
390
|
+
return json(res, 200, { target });
|
|
391
|
+
}
|
|
360
392
|
return json(res, 404, { error: "Not found" });
|
|
361
393
|
}
|
|
362
394
|
function authorize(req, config) {
|
package/dist/index.js
CHANGED
|
@@ -794,14 +794,41 @@ async function waitForLocalRunner(opts, timeoutMs = 15e3) {
|
|
|
794
794
|
|
|
795
795
|
// src/commands/runner/relay.ts
|
|
796
796
|
import { hostname as hostname2 } from "os";
|
|
797
|
-
|
|
797
|
+
|
|
798
|
+
// src/commands/runner/membership.ts
|
|
799
|
+
function isEligible(inst, thisNode) {
|
|
800
|
+
if (inst.status !== "active") return false;
|
|
801
|
+
if (inst.capabilities?.runtime == null) return false;
|
|
802
|
+
const pin = inst.config?.runnerNode;
|
|
803
|
+
if (pin && pin !== thisNode) return false;
|
|
804
|
+
return true;
|
|
805
|
+
}
|
|
806
|
+
function diffMembership(attached, eligible, thisNode, blocked = /* @__PURE__ */ new Set()) {
|
|
807
|
+
const have = new Set(attached);
|
|
808
|
+
const want = eligible.filter((i) => isEligible(i, thisNode));
|
|
809
|
+
const wantIds = new Set(want.map((i) => i.id));
|
|
810
|
+
return {
|
|
811
|
+
attach: want.filter((i) => !have.has(i.id) && !blocked.has(i.id)),
|
|
812
|
+
// Detach what is no longer eligible — unsubscribed, deactivated, or re-pinned to another
|
|
813
|
+
// machine. Leaving the socket open would keep the agent looking connected here while the
|
|
814
|
+
// platform routes its work elsewhere.
|
|
815
|
+
detach: [...have].filter((id) => !wantIds.has(id))
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
function instanceLabel(inst) {
|
|
819
|
+
const short = `${inst.id.slice(0, 8)}\u2026`;
|
|
820
|
+
return inst.name ? `${inst.name} (${short})` : short;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// src/commands/runner/relay.ts
|
|
824
|
+
async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force = false, watchInstances = false) {
|
|
798
825
|
const apiBase = pagsApiBase(opts.apiBase).replace(/^http/, "ws");
|
|
799
826
|
const pagsToken = clean(opts.pagsToken) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
|
|
800
827
|
if (!pagsToken) throw new Error("PAGS token required for WebSocket relay");
|
|
801
828
|
const runnerNode = hostname2();
|
|
802
829
|
const capabilities = await requestRunner("GET", "/capabilities", { url: localUrl, token: runnerToken, instanceId: instanceIds[0] });
|
|
803
830
|
const caps = Array.isArray(capabilities.capabilities) ? capabilities.capabilities.filter((item) => typeof item === "string") : [];
|
|
804
|
-
|
|
831
|
+
const registerRuntime = async (id) => {
|
|
805
832
|
try {
|
|
806
833
|
await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime`, opts, {
|
|
807
834
|
endpointUrl: localUrl,
|
|
@@ -816,11 +843,30 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
816
843
|
const msg = e instanceof Error ? e.message : String(e);
|
|
817
844
|
writeError(`register ${id.slice(0, 8)}\u2026 failed: ${msg}`);
|
|
818
845
|
}
|
|
819
|
-
}
|
|
820
|
-
for (const id of instanceIds)
|
|
846
|
+
};
|
|
847
|
+
for (const id of instanceIds) await registerRuntime(id);
|
|
848
|
+
const attached = /* @__PURE__ */ new Map();
|
|
849
|
+
const blocked = /* @__PURE__ */ new Set();
|
|
850
|
+
const attach = (id, label = `${id.slice(0, 8)}\u2026`) => {
|
|
851
|
+
if (attached.has(id)) return;
|
|
821
852
|
const mintToken = () => requestPags("POST", `/v1/relay/${apiPathSegment(id)}/token`, { ...opts, pagsToken }, {}).then((r) => r.token);
|
|
822
|
-
|
|
823
|
-
|
|
853
|
+
attached.set(
|
|
854
|
+
id,
|
|
855
|
+
openRelaySocket(id, apiBase, mintToken, localUrl, runnerToken, force, (conflicted) => {
|
|
856
|
+
blocked.add(conflicted);
|
|
857
|
+
attached.delete(conflicted);
|
|
858
|
+
})
|
|
859
|
+
);
|
|
860
|
+
if (label) writeLine(`Attached agent: ${label}`);
|
|
861
|
+
};
|
|
862
|
+
const detach = (id, label = `${id.slice(0, 8)}\u2026`) => {
|
|
863
|
+
const handle = attached.get(id);
|
|
864
|
+
if (!handle) return;
|
|
865
|
+
handle.close();
|
|
866
|
+
attached.delete(id);
|
|
867
|
+
writeLine(`Detached agent: ${label}`);
|
|
868
|
+
};
|
|
869
|
+
for (const id of instanceIds) attach(id, "");
|
|
824
870
|
writeLine("Runtime registered with PAGS \u2713");
|
|
825
871
|
writeLine("");
|
|
826
872
|
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");
|
|
@@ -830,7 +876,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
830
876
|
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");
|
|
831
877
|
const heartbeat = () => {
|
|
832
878
|
const timer = setTimeout(async () => {
|
|
833
|
-
for (const id of
|
|
879
|
+
for (const id of [...attached.keys()]) {
|
|
834
880
|
await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime/heartbeat`, opts, { runnerNode }).catch(() => void 0);
|
|
835
881
|
}
|
|
836
882
|
heartbeat();
|
|
@@ -838,11 +884,44 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
838
884
|
timer.unref();
|
|
839
885
|
};
|
|
840
886
|
heartbeat();
|
|
887
|
+
if (watchInstances) startDiscovery();
|
|
888
|
+
function startDiscovery() {
|
|
889
|
+
const tick = () => {
|
|
890
|
+
const timer = setTimeout(async () => {
|
|
891
|
+
try {
|
|
892
|
+
const res = await requestPags(
|
|
893
|
+
"GET",
|
|
894
|
+
"/v1/instances/my/instances",
|
|
895
|
+
{ ...opts, pagsToken }
|
|
896
|
+
);
|
|
897
|
+
const { attach: toAttach, detach: toDetach } = diffMembership(
|
|
898
|
+
attached.keys(),
|
|
899
|
+
res.instances ?? [],
|
|
900
|
+
runnerNode,
|
|
901
|
+
blocked
|
|
902
|
+
);
|
|
903
|
+
for (const inst of toAttach) {
|
|
904
|
+
await registerRuntime(inst.id);
|
|
905
|
+
attach(inst.id, instanceLabel(inst));
|
|
906
|
+
}
|
|
907
|
+
for (const id of toDetach) detach(id);
|
|
908
|
+
} catch {
|
|
909
|
+
}
|
|
910
|
+
tick();
|
|
911
|
+
}, 2e4);
|
|
912
|
+
timer.unref();
|
|
913
|
+
};
|
|
914
|
+
tick();
|
|
915
|
+
}
|
|
841
916
|
}
|
|
842
|
-
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false) {
|
|
917
|
+
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict) {
|
|
843
918
|
let backoffMs = 1e3;
|
|
844
919
|
let reconnecting = false;
|
|
920
|
+
let closed = false;
|
|
921
|
+
let socket = null;
|
|
922
|
+
let retryTimer = null;
|
|
845
923
|
const connect = async () => {
|
|
924
|
+
if (closed) return;
|
|
846
925
|
let relayToken;
|
|
847
926
|
try {
|
|
848
927
|
relayToken = await mintToken();
|
|
@@ -854,7 +933,7 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
854
933
|
}
|
|
855
934
|
const hint = /401|token|sign/i.test(msg) ? " (run `pags login`)" : "";
|
|
856
935
|
writeLine(`Relay token mint failed: ${instanceId.slice(0, 8)}\u2026${hint} \u2014 retrying in ${Math.round(backoffMs / 1e3)}s`);
|
|
857
|
-
setTimeout(() => {
|
|
936
|
+
retryTimer = setTimeout(() => {
|
|
858
937
|
connect();
|
|
859
938
|
}, backoffMs);
|
|
860
939
|
backoffMs = Math.min(backoffMs * 2, 3e4);
|
|
@@ -864,6 +943,7 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
864
943
|
if (force) params.set("force", "1");
|
|
865
944
|
const url = `${wsBase}/v1/relay/${encodeURIComponent(instanceId)}/connect?${params.toString()}`;
|
|
866
945
|
const ws = new WebSocket(url);
|
|
946
|
+
socket = ws;
|
|
867
947
|
ws.onopen = () => {
|
|
868
948
|
backoffMs = 1e3;
|
|
869
949
|
writeLine(`Relay connected: ${instanceId.slice(0, 8)}\u2026`);
|
|
@@ -915,13 +995,19 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
915
995
|
}
|
|
916
996
|
};
|
|
917
997
|
ws.onclose = (ev) => {
|
|
918
|
-
if (reconnecting) return;
|
|
998
|
+
if (closed || reconnecting) return;
|
|
999
|
+
if (ev.code === 4409 && !force) {
|
|
1000
|
+
writeLine(`Relay conflict: ${instanceId.slice(0, 8)}\u2026 is connected on another machine \u2014 run \`pags up --force\` here to take it over.`);
|
|
1001
|
+
closed = true;
|
|
1002
|
+
onConflict?.(instanceId);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
919
1005
|
reconnecting = true;
|
|
920
1006
|
const said = (ev.reason || "").trim();
|
|
921
1007
|
const hint = ev.code === 4401 ? " \u2014 run `pags login`, then `pags up`" : ev.code === 4409 ? " \u2014 run `pags up --force` to take over" : "";
|
|
922
1008
|
const reason = said ? ` (${said}${hint})` : ev.code === 1008 ? " (token expired \u2014 run `pags login` then `pags up`)" : "";
|
|
923
1009
|
writeLine(`Relay disconnected: ${instanceId.slice(0, 8)}\u2026${reason} \u2014 reconnecting in ${Math.round(backoffMs / 1e3)}s`);
|
|
924
|
-
setTimeout(() => {
|
|
1010
|
+
retryTimer = setTimeout(() => {
|
|
925
1011
|
reconnecting = false;
|
|
926
1012
|
connect();
|
|
927
1013
|
}, backoffMs);
|
|
@@ -931,6 +1017,20 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
931
1017
|
};
|
|
932
1018
|
};
|
|
933
1019
|
connect();
|
|
1020
|
+
return {
|
|
1021
|
+
close() {
|
|
1022
|
+
closed = true;
|
|
1023
|
+
if (retryTimer) {
|
|
1024
|
+
clearTimeout(retryTimer);
|
|
1025
|
+
retryTimer = null;
|
|
1026
|
+
}
|
|
1027
|
+
try {
|
|
1028
|
+
socket?.close();
|
|
1029
|
+
} catch {
|
|
1030
|
+
}
|
|
1031
|
+
socket = null;
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
934
1034
|
}
|
|
935
1035
|
|
|
936
1036
|
// src/commands/runner/command.ts
|
|
@@ -944,7 +1044,7 @@ function createRunnerCommand() {
|
|
|
944
1044
|
command.command("start").description("Start the local ProAgentStore browser runtime in the foreground").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind (default: first free port from 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) => {
|
|
945
1045
|
await startRunnerForeground(opts);
|
|
946
1046
|
});
|
|
947
|
-
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").action(async (instanceIds, opts) => {
|
|
1047
|
+
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) => {
|
|
948
1048
|
const runnerToken = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID()}`;
|
|
949
1049
|
const host = clean(opts.host) || "127.0.0.1";
|
|
950
1050
|
const port = clean(opts.port) || String(await findFreePort2(49171));
|
|
@@ -980,7 +1080,7 @@ function createRunnerCommand() {
|
|
|
980
1080
|
try {
|
|
981
1081
|
await waitForLocalRunner({ url: localUrl, token: runnerToken, instanceId: primary });
|
|
982
1082
|
writeLine(`Local browser runtime healthy at ${localUrl}`);
|
|
983
|
-
await connectViaRelay(instanceIds, localUrl, runnerToken, opts, Boolean(opts.force));
|
|
1083
|
+
await connectViaRelay(instanceIds, localUrl, runnerToken, opts, Boolean(opts.force), Boolean(opts.watchInstances));
|
|
984
1084
|
await new Promise((resolvePromise) => {
|
|
985
1085
|
runner.on("exit", () => resolvePromise());
|
|
986
1086
|
});
|
|
@@ -1289,6 +1389,7 @@ var upCommand = new Command7("up").description("Start the browser runner for all
|
|
|
1289
1389
|
const args = [cliPath, "runner", "connect", ...instances.map((i) => i.id)];
|
|
1290
1390
|
if (opts.headless) args.push("--headless");
|
|
1291
1391
|
if (opts.force) args.push("--force");
|
|
1392
|
+
if (!opts.instance) args.push("--watch-instances");
|
|
1292
1393
|
const child = spawn4(process.execPath, args, {
|
|
1293
1394
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1294
1395
|
env: { ...process.env, PAGS_TOKEN: session.token }
|