@proagentstore/cli 0.4.27 → 0.4.29
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.
|
@@ -44,6 +44,15 @@ export class HeadlessSession {
|
|
|
44
44
|
turnStartedAt = 0;
|
|
45
45
|
/** Set by stop() — the only thing that ends a one-shot session (see `alive`). */
|
|
46
46
|
stopped = false;
|
|
47
|
+
/**
|
|
48
|
+
* The engine binary could not be spawned (ENOENT, not executable).
|
|
49
|
+
*
|
|
50
|
+
* Without this, `alive = !stopped` made a MISCONFIGURED engine indistinguishable from a
|
|
51
|
+
* healthy idle one — so `runCodingLoop`'s `if (!snap.alive)` guard, the thing that catches a
|
|
52
|
+
* bad command, became structurally unreachable for every one-shot engine and the Pilot burned
|
|
53
|
+
* all 40 BYOK decisions re-spawning a binary that isn't there.
|
|
54
|
+
*/
|
|
55
|
+
spawnFailed = false;
|
|
47
56
|
constructor(config) {
|
|
48
57
|
this.config = config;
|
|
49
58
|
this.sessionName = `pags-${config.clientType}-${config.id}`;
|
|
@@ -87,7 +96,7 @@ export class HeadlessSession {
|
|
|
87
96
|
*/
|
|
88
97
|
get alive() {
|
|
89
98
|
if (this.oneShot)
|
|
90
|
-
return !this.stopped;
|
|
99
|
+
return !this.stopped && !this.spawnFailed;
|
|
91
100
|
return this.procAlive;
|
|
92
101
|
}
|
|
93
102
|
/** Is a process running THIS instant? The persistent engine's liveness, and the spawn guard. */
|
|
@@ -153,6 +162,7 @@ export class HeadlessSession {
|
|
|
153
162
|
// Starting always un-stops: `stop()` is what ends a one-shot session, so a (re)start
|
|
154
163
|
// after one has to bring it back or the session is permanently unusable.
|
|
155
164
|
this.stopped = false;
|
|
165
|
+
this.spawnFailed = false; // a restart is also a retry of a command that could not run
|
|
156
166
|
// A one-shot engine has nothing to start until there is a turn to run; starting it here
|
|
157
167
|
// is what produced the instant "exited with code 1".
|
|
158
168
|
if (this.oneShot) {
|
|
@@ -276,6 +286,9 @@ export class HeadlessSession {
|
|
|
276
286
|
this.push(`[${this.config.clientType}] failed to start: ${err.message}`);
|
|
277
287
|
this.run = "idle";
|
|
278
288
|
this.proc = null;
|
|
289
|
+
// The command itself is unrunnable — report the session dead so the loop stops with a
|
|
290
|
+
// real reason instead of retrying a binary that does not exist.
|
|
291
|
+
this.spawnFailed = true;
|
|
279
292
|
});
|
|
280
293
|
proc.stdout?.on("data", (d) => this.onStdout(d.toString()));
|
|
281
294
|
proc.stderr?.on("data", (d) => this.onStdout(d.toString()));
|
|
@@ -432,11 +445,46 @@ function stamp() {
|
|
|
432
445
|
* tokenize like the command line it looks like.
|
|
433
446
|
*/
|
|
434
447
|
export function parseCommand(command) {
|
|
448
|
+
const src = command ?? "";
|
|
435
449
|
const tokens = [];
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
450
|
+
const isSpace = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
|
451
|
+
let i = 0;
|
|
452
|
+
while (i < src.length) {
|
|
453
|
+
while (i < src.length && isSpace(src[i]))
|
|
454
|
+
i++;
|
|
455
|
+
if (i >= src.length)
|
|
456
|
+
break;
|
|
457
|
+
const tokenStart = i;
|
|
458
|
+
let out = "";
|
|
459
|
+
while (i < src.length) {
|
|
460
|
+
const ch = src[i];
|
|
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;
|
|
479
|
+
i++;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (isSpace(ch))
|
|
483
|
+
break;
|
|
484
|
+
out += ch;
|
|
485
|
+
i++;
|
|
486
|
+
}
|
|
487
|
+
tokens.push(out);
|
|
440
488
|
}
|
|
441
489
|
return { bin: tokens[0] ?? "", args: tokens.slice(1) };
|
|
442
490
|
}
|
|
@@ -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);
|
|
@@ -30,6 +31,15 @@ const CAPABILITIES = [
|
|
|
30
31
|
/** How many times a task may attempt an action before handing off to a human. */
|
|
31
32
|
export const MAX_AUTONOMOUS_ATTEMPTS = 3;
|
|
32
33
|
const APPROVAL_REQUIRED_TASKS = new Set(["browser.open"]);
|
|
34
|
+
/**
|
|
35
|
+
* Tasks steered by a remote durable Workflow rather than executed by the runner.
|
|
36
|
+
*
|
|
37
|
+
* ONE list, because the two places that need it drifted: task creation already treated
|
|
38
|
+
* `browser.task` like `job.apply_agent`, but `resumeTakeover` did not — so a human resolving a
|
|
39
|
+
* stuck browse handoff destroyed the takeover session instead of handing control back, and the
|
|
40
|
+
* workflow then polled a dead session for 15 minutes before failing a run it had already
|
|
41
|
+
* completed.
|
|
42
|
+
*/
|
|
33
43
|
const require = createRequire(import.meta.url);
|
|
34
44
|
export class LocalRunner {
|
|
35
45
|
config;
|
|
@@ -101,7 +111,7 @@ export class LocalRunner {
|
|
|
101
111
|
// steered by the remote Workflow brain via the /browser/* endpoints — the runner
|
|
102
112
|
// never auto-executes them. The task exists for the console board, the activity
|
|
103
113
|
// trace, and takeover keying.
|
|
104
|
-
if (
|
|
114
|
+
if (WORKFLOW_DRIVEN_TASKS.has(normalized.type)) {
|
|
105
115
|
const task = {
|
|
106
116
|
id: `task_${crypto.randomUUID()}`,
|
|
107
117
|
type: normalized.type,
|
|
@@ -374,10 +384,18 @@ export class LocalRunner {
|
|
|
374
384
|
if (!task)
|
|
375
385
|
throw new RunnerInputError("Task not found");
|
|
376
386
|
const page = session.page;
|
|
377
|
-
// Agent-driven
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
|
|
387
|
+
// Agent-driven runs are steered by the remote workflow. "Resume" here just signals the
|
|
388
|
+
// human finished the stuck step (or solved a captcha); the workflow polls humanDone and
|
|
389
|
+
// continues driving. Do NOT complete/submit.
|
|
390
|
+
//
|
|
391
|
+
// `browser.task` belongs here too, and its omission made a stuck handoff UNRESOLVABLE:
|
|
392
|
+
// `humanDone` is set ONLY in this branch, so a browse task fell through to the path below,
|
|
393
|
+
// which sets `status = "completed"` and calls `endTakeover()` — destroying the session.
|
|
394
|
+
// `browserHandoffStatus` then returns `{solved:false}` forever (no session), so the
|
|
395
|
+
// workflow polled for the full 15 minutes and closed the run "failed — stuck not resolved
|
|
396
|
+
// in time", on a task it had already marked completed. The console's Resume button is
|
|
397
|
+
// agent-agnostic, so the user pressing it was what broke the run.
|
|
398
|
+
if (WORKFLOW_DRIVEN_TASKS.has(task.type)) {
|
|
381
399
|
session.humanDone = true;
|
|
382
400
|
this.addTaskEvent(task, "job.resumed", "Human finished the step — handing back to the agent");
|
|
383
401
|
return { submitted: false, resumed: true, reason: "handed back to the agent — the agent is continuing" };
|
|
@@ -387,7 +405,7 @@ export class LocalRunner {
|
|
|
387
405
|
this.addTaskEvent(task, "job.human_challenge_present", `Challenge not yet solved: ${challenge}`);
|
|
388
406
|
return { submitted: false, reason: `The ${challenge} challenge isn't solved yet — complete it in the live view, then submit again.` };
|
|
389
407
|
}
|
|
390
|
-
// Only browser.open reaches here now (
|
|
408
|
+
// Only browser.open reaches here now (workflow-driven tasks return above) — it just
|
|
391
409
|
// needs the challenge cleared, no form submit.
|
|
392
410
|
this.addTaskEvent(task, "job.resumed", "Human cleared the challenge; resuming");
|
|
393
411
|
const output = {
|
|
@@ -992,8 +1010,21 @@ export class LocalRunner {
|
|
|
992
1010
|
const page = await this.getActivePage();
|
|
993
1011
|
this.takeovers.set(taskId, { page, reason, humanDone: false });
|
|
994
1012
|
const screenshotBase64 = await captureScreenshotDataUrl(page);
|
|
995
|
-
|
|
996
|
-
|
|
1013
|
+
// The takeover is registered ABOVE, unconditionally — that is what makes the live view work.
|
|
1014
|
+
// Everything below is the task's own bookkeeping, and it used to be skipped whenever no
|
|
1015
|
+
// LOCAL task existed. The engine sign-in relay mints its own id (`signin-<sessionId>`) for
|
|
1016
|
+
// a coding session that has no runner task at all, so the handoff registered silently and
|
|
1017
|
+
// none of the status flip, the message or the screenshot ever happened: the console
|
|
1018
|
+
// reported success and offered "take over the browser", while the takeover it pointed at
|
|
1019
|
+
// carried no context. A task row is created for it here rather than requiring the caller
|
|
1020
|
+
// to have made one, so the handoff is complete for every caller.
|
|
1021
|
+
let task = this.store.getTask(taskId);
|
|
1022
|
+
if (!task) {
|
|
1023
|
+
const now = new Date().toISOString();
|
|
1024
|
+
task = { id: taskId, type: "browser.handoff", status: "needs_human", input: {}, requiresApproval: false, createdAt: now, updatedAt: now };
|
|
1025
|
+
this.store.putTask(task);
|
|
1026
|
+
}
|
|
1027
|
+
{
|
|
997
1028
|
task.status = "needs_human";
|
|
998
1029
|
task.updatedAt = new Date().toISOString();
|
|
999
1030
|
this.store.putTask(task);
|
|
@@ -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(() => {
|
|
@@ -921,7 +917,9 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
921
917
|
ws.onclose = (ev) => {
|
|
922
918
|
if (reconnecting) return;
|
|
923
919
|
reconnecting = true;
|
|
924
|
-
const
|
|
920
|
+
const said = (ev.reason || "").trim();
|
|
921
|
+
const hint = ev.code === 4401 ? " \u2014 run `pags login`, then `pags up`" : ev.code === 4409 ? " \u2014 run `pags up --force` to take over" : "";
|
|
922
|
+
const reason = said ? ` (${said}${hint})` : ev.code === 1008 ? " (token expired \u2014 run `pags login` then `pags up`)" : "";
|
|
925
923
|
writeLine(`Relay disconnected: ${instanceId.slice(0, 8)}\u2026${reason} \u2014 reconnecting in ${Math.round(backoffMs / 1e3)}s`);
|
|
926
924
|
setTimeout(() => {
|
|
927
925
|
reconnecting = false;
|
|
@@ -943,7 +941,7 @@ function createRunnerCommand() {
|
|
|
943
941
|
const command = new Command6("runner").description(
|
|
944
942
|
"Manage the local ProAgentStore browser runtime for ProAgentStore agents"
|
|
945
943
|
);
|
|
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
|
|
944
|
+
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
945
|
await startRunnerForeground(opts);
|
|
948
946
|
});
|
|
949
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) => {
|
|
@@ -1184,7 +1182,7 @@ function printStep(label, status) {
|
|
|
1184
1182
|
const icon = status === "ok" ? chalk.green("\u2713") : status === "fail" ? chalk.red("\u2717") : chalk.yellow("\u2026");
|
|
1185
1183
|
console.log(pad + icon + " " + label);
|
|
1186
1184
|
}
|
|
1187
|
-
async function waitForKey(keys) {
|
|
1185
|
+
async function waitForKey(keys, onInterrupt) {
|
|
1188
1186
|
return new Promise((resolve5) => {
|
|
1189
1187
|
readline.emitKeypressEvents(process.stdin);
|
|
1190
1188
|
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
@@ -1192,6 +1190,10 @@ async function waitForKey(keys) {
|
|
|
1192
1190
|
const onKeypress = (str, key) => {
|
|
1193
1191
|
if (key?.ctrl && key.name === "c") {
|
|
1194
1192
|
cleanup();
|
|
1193
|
+
if (onInterrupt) {
|
|
1194
|
+
onInterrupt();
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1195
1197
|
process.exit(0);
|
|
1196
1198
|
}
|
|
1197
1199
|
const val = (str || "").trim().toLowerCase();
|
|
@@ -1366,7 +1368,7 @@ var upCommand = new Command7("up").description("Start the browser runner for all
|
|
|
1366
1368
|
process.on("SIGINT", shutdown);
|
|
1367
1369
|
process.on("SIGTERM", shutdown);
|
|
1368
1370
|
while (true) {
|
|
1369
|
-
const key = await waitForKey(["r", "l", "q"]);
|
|
1371
|
+
const key = await waitForKey(["r", "l", "q"], shutdown);
|
|
1370
1372
|
if (key === "q") {
|
|
1371
1373
|
shutdown();
|
|
1372
1374
|
break;
|