@proagentstore/cli 0.4.28 → 0.4.30
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 +20 -27
- package/dist/browser-runner/coding/terminal.js +256 -0
- package/dist/browser-runner/runner.js +1 -1
- package/dist/browser-runner/server.js +54 -0
- package/dist/browser-runner/store.js +2 -1
- package/dist/browser-runner/task-types.js +20 -0
- package/dist/index.js +133 -30
- package/package.json +1 -1
|
@@ -454,21 +454,28 @@ export function parseCommand(command) {
|
|
|
454
454
|
i++;
|
|
455
455
|
if (i >= src.length)
|
|
456
456
|
break;
|
|
457
|
-
const
|
|
457
|
+
const tokenStart = i;
|
|
458
458
|
let out = "";
|
|
459
|
-
let quote = null;
|
|
460
459
|
while (i < src.length) {
|
|
461
460
|
const ch = src[i];
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
461
|
+
// A DOUBLE quote always opens a span — that is what `--flag "two words"` means and
|
|
462
|
+
// what anyone typing this expects.
|
|
463
|
+
//
|
|
464
|
+
// A SINGLE quote opens one only at a token boundary (token start, or right after `=`),
|
|
465
|
+
// because in ordinary English it is an apostrophe. This field is a preset text box, not
|
|
466
|
+
// a shell: a real shell would pair the two apostrophes in `don't guess and don't stop`
|
|
467
|
+
// and hand the engine `dont guess and dont` plus a stray `stop`, which is exactly the
|
|
468
|
+
// mangling seen here. `--agent='my agent'` and `'my agent'` still work.
|
|
469
|
+
const opens = ch === '"' || (ch === "'" && (i === tokenStart || src[i - 1] === "="));
|
|
470
|
+
if (opens) {
|
|
471
|
+
const close = src.indexOf(ch, i + 1);
|
|
472
|
+
if (close !== -1) {
|
|
473
|
+
out += src.slice(i + 1, close);
|
|
474
|
+
i = close + 1;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
// Unterminated — the character is literal, not the start of a span.
|
|
478
|
+
out += ch;
|
|
472
479
|
i++;
|
|
473
480
|
continue;
|
|
474
481
|
}
|
|
@@ -477,21 +484,7 @@ export function parseCommand(command) {
|
|
|
477
484
|
out += ch;
|
|
478
485
|
i++;
|
|
479
486
|
}
|
|
480
|
-
|
|
481
|
-
// UNTERMINATED quote → it was a literal character, not a quote. `don't` is ordinary
|
|
482
|
-
// English in a user-edited preset; treating the apostrophe as an opening quote made
|
|
483
|
-
// `--append-system-prompt don't guess` reach the engine as three broken arguments.
|
|
484
|
-
i = start;
|
|
485
|
-
let raw = "";
|
|
486
|
-
while (i < src.length && !isSpace(src[i])) {
|
|
487
|
-
raw += src[i];
|
|
488
|
-
i++;
|
|
489
|
-
}
|
|
490
|
-
tokens.push(raw);
|
|
491
|
-
}
|
|
492
|
-
else {
|
|
493
|
-
tokens.push(out);
|
|
494
|
-
}
|
|
487
|
+
tokens.push(out);
|
|
495
488
|
}
|
|
496
489
|
return { bin: tokens[0] ?? "", args: tokens.slice(1) };
|
|
497
490
|
}
|
|
@@ -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
|
+
}
|
|
@@ -9,6 +9,7 @@ import { McpRuntime } from "./mcp-runtime.js";
|
|
|
9
9
|
import { HumanHandoffError, RunnerInputError } from "./errors.js";
|
|
10
10
|
import { RunnerStore } from "./store.js";
|
|
11
11
|
import { CodingRuntime } from "./coding/runtime.js";
|
|
12
|
+
import { WORKFLOW_DRIVEN_TASKS } from "./task-types.js";
|
|
12
13
|
/** True for a plain object. */
|
|
13
14
|
function isRecord(value) {
|
|
14
15
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -39,7 +40,6 @@ const APPROVAL_REQUIRED_TASKS = new Set(["browser.open"]);
|
|
|
39
40
|
* workflow then polled a dead session for 15 minutes before failing a run it had already
|
|
40
41
|
* completed.
|
|
41
42
|
*/
|
|
42
|
-
const WORKFLOW_DRIVEN_TASKS = new Set(["job.apply_agent", "browser.task"]);
|
|
43
43
|
const require = createRequire(import.meta.url);
|
|
44
44
|
export class LocalRunner {
|
|
45
45
|
config;
|
|
@@ -357,6 +357,60 @@ async function route(runner, req, res) {
|
|
|
357
357
|
createSession(session, workDir, b.command ? String(b.command) : undefined);
|
|
358
358
|
return json(res, 200, { session, created: true, workDir });
|
|
359
359
|
}
|
|
360
|
+
// ── generic terminal connector ──────────────────────────────────────────
|
|
361
|
+
// One local-terminal vocabulary over backend-specific adapters. tmux is fully
|
|
362
|
+
// controllable; kitty needs remote control enabled; iTerm2 needs macOS Automation access.
|
|
363
|
+
if ((req.method === "GET" || req.method === "POST") && path === "/terminal/list") {
|
|
364
|
+
const { listTerminalTargets } = await import("./coding/terminal.js");
|
|
365
|
+
const b = req.method === "POST" ? await readJson(req) : { backend: "all" };
|
|
366
|
+
const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : "all";
|
|
367
|
+
return json(res, 200, { targets: listTerminalTargets(backend) });
|
|
368
|
+
}
|
|
369
|
+
if (req.method === "POST" && path === "/terminal/capture") {
|
|
370
|
+
const { captureTerminalTarget } = await import("./coding/terminal.js");
|
|
371
|
+
const b = await readJson(req);
|
|
372
|
+
const target = String(b.target || "").trim();
|
|
373
|
+
if (!target)
|
|
374
|
+
return json(res, 400, { error: "A `target` is required." });
|
|
375
|
+
const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
|
|
376
|
+
return json(res, 200, { target, pane: captureTerminalTarget(target, { backend, lines: b.lines }) });
|
|
377
|
+
}
|
|
378
|
+
if (req.method === "POST" && path === "/terminal/run") {
|
|
379
|
+
const { runTerminalCommand } = await import("./coding/terminal.js");
|
|
380
|
+
const b = await readJson(req);
|
|
381
|
+
const target = String(b.target || "").trim();
|
|
382
|
+
const command = String(b.command ?? "");
|
|
383
|
+
if (!target)
|
|
384
|
+
return json(res, 400, { error: "A `target` is required." });
|
|
385
|
+
if (!command.trim())
|
|
386
|
+
return json(res, 400, { error: "A `command` is required." });
|
|
387
|
+
const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
|
|
388
|
+
return json(res, 200, { target, command, pane: runTerminalCommand(target, command, backend) });
|
|
389
|
+
}
|
|
390
|
+
if (req.method === "POST" && path === "/terminal/send") {
|
|
391
|
+
const { sendTerminalKeys } = await import("./coding/terminal.js");
|
|
392
|
+
const b = await readJson(req);
|
|
393
|
+
const target = String(b.target || "").trim();
|
|
394
|
+
if (!target)
|
|
395
|
+
return json(res, 400, { error: "A `target` is required." });
|
|
396
|
+
const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
|
|
397
|
+
return json(res, 200, { target, pane: sendTerminalKeys(target, { backend, text: b.text == null ? undefined : String(b.text), keys: b.keys ?? [] }) });
|
|
398
|
+
}
|
|
399
|
+
if (req.method === "POST" && path === "/terminal/session") {
|
|
400
|
+
const { createTerminalTarget, killTerminalTarget } = await import("./coding/terminal.js");
|
|
401
|
+
const b = await readJson(req);
|
|
402
|
+
const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
|
|
403
|
+
if (b.action === "kill") {
|
|
404
|
+
const target = String(b.target || "").trim();
|
|
405
|
+
if (!target)
|
|
406
|
+
return json(res, 400, { error: "A `target` is required." });
|
|
407
|
+
return json(res, 200, { target, killed: killTerminalTarget(target, backend) });
|
|
408
|
+
}
|
|
409
|
+
if (!backend)
|
|
410
|
+
return json(res, 400, { error: "`backend` must be tmux, kitty, or iterm2." });
|
|
411
|
+
const target = createTerminalTarget({ backend, name: b.name, workDir: b.workDir, command: b.command });
|
|
412
|
+
return json(res, 200, { target });
|
|
413
|
+
}
|
|
360
414
|
return json(res, 404, { error: "Not found" });
|
|
361
415
|
}
|
|
362
416
|
function authorize(req, config) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
+
import { WORKFLOW_DRIVEN_TASKS } from "./task-types.js";
|
|
3
4
|
function emptyStore() {
|
|
4
5
|
return {
|
|
5
6
|
sessions: [],
|
|
@@ -50,7 +51,7 @@ export class RunnerStore {
|
|
|
50
51
|
// to "failed", which re-mirrors to the board, resurrects the Retry button, and
|
|
51
52
|
// slips past the API single-flight guard → a second concurrent apply on the one
|
|
52
53
|
// browser page. Mirrors the API carve-out in expireOrphanedRuntimeTasks.
|
|
53
|
-
if (task.type
|
|
54
|
+
if (WORKFLOW_DRIVEN_TASKS.has(task.type))
|
|
54
55
|
continue;
|
|
55
56
|
if (task.status === "needs_human" || task.status === "running") {
|
|
56
57
|
task.status = "failed";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task types steered by a remote durable Workflow rather than executed by the runner.
|
|
3
|
+
*
|
|
4
|
+
* ONE list, in its own module because THREE places need it and they drifted: task creation had
|
|
5
|
+
* `job.apply_agent || browser.task`, `resumeTakeover` had only `job.apply_agent` (so resolving a
|
|
6
|
+
* stuck browse handoff destroyed the session), and `RunnerStore.expireInFlightTasks` still
|
|
7
|
+
* hardcodes its own copy — which meant restarting `pags up` failed a live browse run locally while
|
|
8
|
+
* the cloud side preserved it, leaving the two views of one durable run disagreeing.
|
|
9
|
+
*
|
|
10
|
+
* `browser.handoff` is the synthetic task `browserHandoff` creates for a caller that has none (the
|
|
11
|
+
* engine sign-in relay mints its own id for a coding session with no runner task). It belongs here
|
|
12
|
+
* for the same reason: the console's Resume button is agent-agnostic, so without it pressing
|
|
13
|
+
* Resume would complete and END the takeover mid-sign-in — reintroducing the exact bug the browse
|
|
14
|
+
* case was fixed for.
|
|
15
|
+
*/
|
|
16
|
+
export const WORKFLOW_DRIVEN_TASKS = new Set([
|
|
17
|
+
"job.apply_agent",
|
|
18
|
+
"browser.task",
|
|
19
|
+
"browser.handoff",
|
|
20
|
+
]);
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
|
-
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
|
-
}) : x)(function(x) {
|
|
5
|
-
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
|
-
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
-
});
|
|
8
2
|
|
|
9
3
|
// src/index.ts
|
|
10
4
|
import { createRequire as createRequire3 } from "module";
|
|
@@ -371,7 +365,7 @@ jobs:
|
|
|
371
365
|
});
|
|
372
366
|
|
|
373
367
|
// src/commands/login.ts
|
|
374
|
-
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
368
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
|
|
375
369
|
import { createServer } from "http";
|
|
376
370
|
import { homedir } from "os";
|
|
377
371
|
import { join as join3 } from "path";
|
|
@@ -472,14 +466,16 @@ var loginCommand = new Command3("login").description("Sign in with Google or Git
|
|
|
472
466
|
}
|
|
473
467
|
});
|
|
474
468
|
var logoutCommand = new Command3("logout").description("Sign out and remove stored session").action(() => {
|
|
469
|
+
if (!existsSync3(TOKEN_FILE)) {
|
|
470
|
+
writeLine("Already signed out.");
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
475
473
|
try {
|
|
476
|
-
|
|
477
|
-
const { unlinkSync } = __require("fs");
|
|
478
|
-
unlinkSync(TOKEN_FILE);
|
|
479
|
-
}
|
|
474
|
+
unlinkSync(TOKEN_FILE);
|
|
480
475
|
writeLine("Signed out.");
|
|
481
|
-
} catch {
|
|
482
|
-
|
|
476
|
+
} catch (err) {
|
|
477
|
+
writeError(`Could not remove ${TOKEN_FILE}: ${err instanceof Error ? err.message : String(err)}`);
|
|
478
|
+
process.exit(1);
|
|
483
479
|
}
|
|
484
480
|
});
|
|
485
481
|
var whoamiCommand = new Command3("whoami").description("Show current signed-in user").action(() => {
|
|
@@ -798,14 +794,41 @@ async function waitForLocalRunner(opts, timeoutMs = 15e3) {
|
|
|
798
794
|
|
|
799
795
|
// src/commands/runner/relay.ts
|
|
800
796
|
import { hostname as hostname2 } from "os";
|
|
801
|
-
|
|
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) {
|
|
802
825
|
const apiBase = pagsApiBase(opts.apiBase).replace(/^http/, "ws");
|
|
803
826
|
const pagsToken = clean(opts.pagsToken) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
|
|
804
827
|
if (!pagsToken) throw new Error("PAGS token required for WebSocket relay");
|
|
805
828
|
const runnerNode = hostname2();
|
|
806
829
|
const capabilities = await requestRunner("GET", "/capabilities", { url: localUrl, token: runnerToken, instanceId: instanceIds[0] });
|
|
807
830
|
const caps = Array.isArray(capabilities.capabilities) ? capabilities.capabilities.filter((item) => typeof item === "string") : [];
|
|
808
|
-
|
|
831
|
+
const registerRuntime = async (id) => {
|
|
809
832
|
try {
|
|
810
833
|
await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime`, opts, {
|
|
811
834
|
endpointUrl: localUrl,
|
|
@@ -820,11 +843,30 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
820
843
|
const msg = e instanceof Error ? e.message : String(e);
|
|
821
844
|
writeError(`register ${id.slice(0, 8)}\u2026 failed: ${msg}`);
|
|
822
845
|
}
|
|
823
|
-
}
|
|
824
|
-
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;
|
|
825
852
|
const mintToken = () => requestPags("POST", `/v1/relay/${apiPathSegment(id)}/token`, { ...opts, pagsToken }, {}).then((r) => r.token);
|
|
826
|
-
|
|
827
|
-
|
|
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, "");
|
|
828
870
|
writeLine("Runtime registered with PAGS \u2713");
|
|
829
871
|
writeLine("");
|
|
830
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");
|
|
@@ -834,7 +876,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
834
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");
|
|
835
877
|
const heartbeat = () => {
|
|
836
878
|
const timer = setTimeout(async () => {
|
|
837
|
-
for (const id of
|
|
879
|
+
for (const id of [...attached.keys()]) {
|
|
838
880
|
await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime/heartbeat`, opts, { runnerNode }).catch(() => void 0);
|
|
839
881
|
}
|
|
840
882
|
heartbeat();
|
|
@@ -842,11 +884,44 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
842
884
|
timer.unref();
|
|
843
885
|
};
|
|
844
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
|
+
}
|
|
845
916
|
}
|
|
846
|
-
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false) {
|
|
917
|
+
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict) {
|
|
847
918
|
let backoffMs = 1e3;
|
|
848
919
|
let reconnecting = false;
|
|
920
|
+
let closed = false;
|
|
921
|
+
let socket = null;
|
|
922
|
+
let retryTimer = null;
|
|
849
923
|
const connect = async () => {
|
|
924
|
+
if (closed) return;
|
|
850
925
|
let relayToken;
|
|
851
926
|
try {
|
|
852
927
|
relayToken = await mintToken();
|
|
@@ -858,7 +933,7 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
858
933
|
}
|
|
859
934
|
const hint = /401|token|sign/i.test(msg) ? " (run `pags login`)" : "";
|
|
860
935
|
writeLine(`Relay token mint failed: ${instanceId.slice(0, 8)}\u2026${hint} \u2014 retrying in ${Math.round(backoffMs / 1e3)}s`);
|
|
861
|
-
setTimeout(() => {
|
|
936
|
+
retryTimer = setTimeout(() => {
|
|
862
937
|
connect();
|
|
863
938
|
}, backoffMs);
|
|
864
939
|
backoffMs = Math.min(backoffMs * 2, 3e4);
|
|
@@ -868,6 +943,7 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
868
943
|
if (force) params.set("force", "1");
|
|
869
944
|
const url = `${wsBase}/v1/relay/${encodeURIComponent(instanceId)}/connect?${params.toString()}`;
|
|
870
945
|
const ws = new WebSocket(url);
|
|
946
|
+
socket = ws;
|
|
871
947
|
ws.onopen = () => {
|
|
872
948
|
backoffMs = 1e3;
|
|
873
949
|
writeLine(`Relay connected: ${instanceId.slice(0, 8)}\u2026`);
|
|
@@ -919,11 +995,19 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
919
995
|
}
|
|
920
996
|
};
|
|
921
997
|
ws.onclose = (ev) => {
|
|
922
|
-
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
|
+
}
|
|
923
1005
|
reconnecting = true;
|
|
924
|
-
const
|
|
1006
|
+
const said = (ev.reason || "").trim();
|
|
1007
|
+
const hint = ev.code === 4401 ? " \u2014 run `pags login`, then `pags up`" : ev.code === 4409 ? " \u2014 run `pags up --force` to take over" : "";
|
|
1008
|
+
const reason = said ? ` (${said}${hint})` : ev.code === 1008 ? " (token expired \u2014 run `pags login` then `pags up`)" : "";
|
|
925
1009
|
writeLine(`Relay disconnected: ${instanceId.slice(0, 8)}\u2026${reason} \u2014 reconnecting in ${Math.round(backoffMs / 1e3)}s`);
|
|
926
|
-
setTimeout(() => {
|
|
1010
|
+
retryTimer = setTimeout(() => {
|
|
927
1011
|
reconnecting = false;
|
|
928
1012
|
connect();
|
|
929
1013
|
}, backoffMs);
|
|
@@ -933,6 +1017,20 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
933
1017
|
};
|
|
934
1018
|
};
|
|
935
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
|
+
};
|
|
936
1034
|
}
|
|
937
1035
|
|
|
938
1036
|
// src/commands/runner/command.ts
|
|
@@ -943,10 +1041,10 @@ function createRunnerCommand() {
|
|
|
943
1041
|
const command = new Command6("runner").description(
|
|
944
1042
|
"Manage the local ProAgentStore browser runtime for ProAgentStore agents"
|
|
945
1043
|
);
|
|
946
|
-
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
|
|
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) => {
|
|
947
1045
|
await startRunnerForeground(opts);
|
|
948
1046
|
});
|
|
949
|
-
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) => {
|
|
950
1048
|
const runnerToken = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID()}`;
|
|
951
1049
|
const host = clean(opts.host) || "127.0.0.1";
|
|
952
1050
|
const port = clean(opts.port) || String(await findFreePort2(49171));
|
|
@@ -982,7 +1080,7 @@ function createRunnerCommand() {
|
|
|
982
1080
|
try {
|
|
983
1081
|
await waitForLocalRunner({ url: localUrl, token: runnerToken, instanceId: primary });
|
|
984
1082
|
writeLine(`Local browser runtime healthy at ${localUrl}`);
|
|
985
|
-
await connectViaRelay(instanceIds, localUrl, runnerToken, opts, Boolean(opts.force));
|
|
1083
|
+
await connectViaRelay(instanceIds, localUrl, runnerToken, opts, Boolean(opts.force), Boolean(opts.watchInstances));
|
|
986
1084
|
await new Promise((resolvePromise) => {
|
|
987
1085
|
runner.on("exit", () => resolvePromise());
|
|
988
1086
|
});
|
|
@@ -1184,7 +1282,7 @@ function printStep(label, status) {
|
|
|
1184
1282
|
const icon = status === "ok" ? chalk.green("\u2713") : status === "fail" ? chalk.red("\u2717") : chalk.yellow("\u2026");
|
|
1185
1283
|
console.log(pad + icon + " " + label);
|
|
1186
1284
|
}
|
|
1187
|
-
async function waitForKey(keys) {
|
|
1285
|
+
async function waitForKey(keys, onInterrupt) {
|
|
1188
1286
|
return new Promise((resolve5) => {
|
|
1189
1287
|
readline.emitKeypressEvents(process.stdin);
|
|
1190
1288
|
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
@@ -1192,6 +1290,10 @@ async function waitForKey(keys) {
|
|
|
1192
1290
|
const onKeypress = (str, key) => {
|
|
1193
1291
|
if (key?.ctrl && key.name === "c") {
|
|
1194
1292
|
cleanup();
|
|
1293
|
+
if (onInterrupt) {
|
|
1294
|
+
onInterrupt();
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1195
1297
|
process.exit(0);
|
|
1196
1298
|
}
|
|
1197
1299
|
const val = (str || "").trim().toLowerCase();
|
|
@@ -1287,6 +1389,7 @@ var upCommand = new Command7("up").description("Start the browser runner for all
|
|
|
1287
1389
|
const args = [cliPath, "runner", "connect", ...instances.map((i) => i.id)];
|
|
1288
1390
|
if (opts.headless) args.push("--headless");
|
|
1289
1391
|
if (opts.force) args.push("--force");
|
|
1392
|
+
if (!opts.instance) args.push("--watch-instances");
|
|
1290
1393
|
const child = spawn4(process.execPath, args, {
|
|
1291
1394
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1292
1395
|
env: { ...process.env, PAGS_TOKEN: session.token }
|
|
@@ -1366,7 +1469,7 @@ var upCommand = new Command7("up").description("Start the browser runner for all
|
|
|
1366
1469
|
process.on("SIGINT", shutdown);
|
|
1367
1470
|
process.on("SIGTERM", shutdown);
|
|
1368
1471
|
while (true) {
|
|
1369
|
-
const key = await waitForKey(["r", "l", "q"]);
|
|
1472
|
+
const key = await waitForKey(["r", "l", "q"], shutdown);
|
|
1370
1473
|
if (key === "q") {
|
|
1371
1474
|
shutdown();
|
|
1372
1475
|
break;
|