@proagentstore/cli 0.4.12 → 0.4.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { handlerFor } from "./handlers.js";
|
|
4
5
|
export class HeadlessSession {
|
|
5
6
|
config;
|
|
6
7
|
sessionName;
|
|
@@ -28,14 +29,25 @@ export class HeadlessSession {
|
|
|
28
29
|
// Claude is the structured engine; everything else is a raw CLI.
|
|
29
30
|
this.mode = config.clientType === "claude" ? "stream-json" : "raw";
|
|
30
31
|
const { bin, args } = parseCommand(config.command);
|
|
32
|
+
// When no explicit command is configured, fall back to THIS engine's default
|
|
33
|
+
// command (codex/gemini/grok/…) — not a hard-coded "claude", which would drive a
|
|
34
|
+
// non-Claude session with the wrong CLI (and mode is already "raw" for it).
|
|
35
|
+
const fallback = parseCommand(handlerFor(config.clientType).cliCommand);
|
|
31
36
|
// A test/override bin wins for the binary only; the command's args are kept.
|
|
32
|
-
this.cmdBin = config.bin ?? (bin || "claude");
|
|
33
|
-
|
|
37
|
+
this.cmdBin = config.bin ?? (bin || fallback.bin || "claude");
|
|
38
|
+
// Use the configured command's args when a command was given (bin set), else the
|
|
39
|
+
// engine default's args.
|
|
40
|
+
this.cmdArgs = bin ? args : fallback.args;
|
|
34
41
|
this.binName = (this.cmdBin.split("/").pop() || this.cmdBin) || "cli";
|
|
35
42
|
}
|
|
36
|
-
/** True while the agent process is running.
|
|
43
|
+
/** True while the agent process is running. NOTE: do NOT use `proc.killed` — Node sets
|
|
44
|
+
* it true the instant a signal is DELIVERED, not when the process exits. interrupt()
|
|
45
|
+
* SIGINTs to abort a turn while the process keeps running, so `killed` gave a false
|
|
46
|
+
* "dead" → input() then spawned a SECOND process (orphaning the first). exitCode is
|
|
47
|
+
* null while running and set on normal exit; signalCode is set when a signal actually
|
|
48
|
+
* terminated it — together they're the real liveness signal. */
|
|
37
49
|
get alive() {
|
|
38
|
-
return this.proc !== null && this.proc.exitCode === null &&
|
|
50
|
+
return this.proc !== null && this.proc.exitCode === null && this.proc.signalCode === null;
|
|
39
51
|
}
|
|
40
52
|
/** Idle = ready for the next instruction. */
|
|
41
53
|
get ready() {
|
|
@@ -76,31 +88,39 @@ export class HeadlessSession {
|
|
|
76
88
|
// (e.g. --model) without letting them clobber or orphan-value our flags. raw:
|
|
77
89
|
// run exactly what the user configured and capture stdout.
|
|
78
90
|
const args = this.mode === "stream-json" ? buildClaudeArgs(this.cmdArgs, this.claudeSessionId) : [...this.cmdArgs];
|
|
79
|
-
|
|
91
|
+
const proc = spawn(this.cmdBin, args, {
|
|
80
92
|
cwd: this.config.workDir,
|
|
81
93
|
env: { ...process.env, ...this.config.env },
|
|
82
94
|
stdio: ["pipe", "pipe", "pipe"],
|
|
83
95
|
});
|
|
96
|
+
this.proc = proc;
|
|
84
97
|
this.run = "idle";
|
|
85
98
|
this.lastOutputAt = Date.now();
|
|
86
99
|
// MUST handle 'error' — without a listener, a spawn failure (e.g. the binary
|
|
87
100
|
// not on PATH) is thrown as an uncaught exception and crashes the runner.
|
|
88
|
-
|
|
101
|
+
proc.on("error", (err) => {
|
|
102
|
+
if (this.proc !== proc)
|
|
103
|
+
return; // a newer process replaced this one — ignore the stale event
|
|
89
104
|
this.run = "idle";
|
|
90
105
|
this.push(`[cannot run \`${this.cmdBin}\`: ${err.message} — is ${this.binName} installed and on your PATH?]`);
|
|
91
106
|
this.proc = null;
|
|
92
107
|
});
|
|
93
108
|
// Swallow EPIPE when writing to a process that just exited (one-shot turn).
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
109
|
+
proc.stdin?.on("error", () => { });
|
|
110
|
+
proc.stdout?.on("data", (d) => this.onStdout(d.toString("utf8")));
|
|
111
|
+
proc.stderr?.on("data", (d) => {
|
|
97
112
|
this.lastOutputAt = Date.now();
|
|
98
113
|
this.sawOutputSinceInput = true;
|
|
99
114
|
const text = d.toString("utf8").trim();
|
|
100
115
|
if (text)
|
|
101
116
|
this.push(`[${this.binName}] ${stripAnsi(text)}`);
|
|
102
117
|
});
|
|
103
|
-
|
|
118
|
+
proc.on("exit", (code) => {
|
|
119
|
+
// A stop() + re-start() on the SAME session object can leave this old process's
|
|
120
|
+
// async exit to fire AFTER the fresh start — capturing `proc` and bailing when it
|
|
121
|
+
// no longer matches stops the stale handler from resetting the new turn to idle.
|
|
122
|
+
if (this.proc !== proc)
|
|
123
|
+
return;
|
|
104
124
|
this.run = "idle";
|
|
105
125
|
if (code && code !== 0)
|
|
106
126
|
this.push(`[${this.binName} exited with code ${code}]`);
|
|
@@ -179,9 +199,14 @@ export class HeadlessSession {
|
|
|
179
199
|
else
|
|
180
200
|
this.pushRaw(line); // raw engine — the line IS the terminal output
|
|
181
201
|
}
|
|
182
|
-
// A TUI/raw engine may render without newlines;
|
|
183
|
-
//
|
|
184
|
-
|
|
202
|
+
// A TUI/raw engine may render without newlines; surface the partial output and cap
|
|
203
|
+
// growth at 16KB. Claude's stream-json, though, emits ONE JSON object per line and a
|
|
204
|
+
// single event (a large tool_result / the final `result`) can legitimately exceed
|
|
205
|
+
// 16KB — truncating mid-line there corrupts the event, and losing a `result` line
|
|
206
|
+
// wedges the turn "thinking" forever (stream-json has no idle backstop). So give the
|
|
207
|
+
// structured path a far larger ceiling; only a pathologically huge line resets it.
|
|
208
|
+
const cap = this.mode === "raw" ? 16 * 1024 : 4 * 1024 * 1024;
|
|
209
|
+
if (this.buf.length > cap) {
|
|
185
210
|
if (this.mode === "raw")
|
|
186
211
|
this.pushRaw(this.buf);
|
|
187
212
|
this.buf = "";
|
|
@@ -275,8 +300,12 @@ export function buildClaudeArgs(userArgs, resumeId) {
|
|
|
275
300
|
i++;
|
|
276
301
|
continue;
|
|
277
302
|
}
|
|
278
|
-
|
|
279
|
-
|
|
303
|
+
// Push every user token as-is. A previous `!args.includes(a)` dedup silently
|
|
304
|
+
// dropped a REPEATED flag token (e.g. the 2nd `--add-dir` in `--add-dir /a
|
|
305
|
+
// --add-dir /b`), which orphaned its value (`/b` became a stray positional).
|
|
306
|
+
// Our own structural flags are already protected via RESERVED_CLAUDE_FLAGS, so
|
|
307
|
+
// no dedup is needed here.
|
|
308
|
+
args.push(a);
|
|
280
309
|
}
|
|
281
310
|
if (!args.includes("--dangerously-skip-permissions"))
|
|
282
311
|
args.push("--dangerously-skip-permissions");
|
|
@@ -54,7 +54,11 @@ export class CodingRuntime {
|
|
|
54
54
|
snapshot(sessionId) {
|
|
55
55
|
const session = this.require(sessionId);
|
|
56
56
|
const alive = session.alive;
|
|
57
|
-
|
|
57
|
+
// ALWAYS return the transcript — it holds the produced output AND the
|
|
58
|
+
// `[exited with code N]` / `[error]` lines recorded on exit. Blanking it when the
|
|
59
|
+
// process is dead lost exactly the output + failure reason the brain/console needs
|
|
60
|
+
// to diagnose a crash or read a one-shot CLI's final result.
|
|
61
|
+
const pane = clip(session.snapshot());
|
|
58
62
|
return {
|
|
59
63
|
sessionId,
|
|
60
64
|
pane,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
/**
|
|
5
5
|
* Low-level tmux primitives for the coding runtime.
|
|
@@ -108,10 +108,18 @@ export function ensureRepo(dir, opts = {}) {
|
|
|
108
108
|
mkdirSync(dir, { recursive: true });
|
|
109
109
|
return dir;
|
|
110
110
|
}
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
111
|
+
// The dir exists but has no `.git`. It could be a half-cloned/empty managed dir
|
|
112
|
+
// (safe to clear) OR a real user directory the caller passed as an explicit workDir
|
|
113
|
+
// (deleting it = data loss). NEVER recursively delete a non-empty non-git dir — refuse
|
|
114
|
+
// instead, so a mis-wired workDir+cloneUrl can't nuke a user's files. An empty dir is
|
|
115
|
+
// fine to remove (git clone needs an empty/absent target).
|
|
116
|
+
if (existsSync(dir)) {
|
|
117
|
+
const entries = readdirSync(dir);
|
|
118
|
+
if (entries.length > 0) {
|
|
119
|
+
throw new Error(`Refusing to clone into non-empty directory "${dir}" (no .git found) — move it aside or point at an empty path.`);
|
|
120
|
+
}
|
|
114
121
|
rmSync(dir, { recursive: true, force: true });
|
|
122
|
+
}
|
|
115
123
|
let url = opts.cloneUrl;
|
|
116
124
|
if (opts.token && /^https:\/\//.test(url)) {
|
|
117
125
|
url = url.replace(/^https:\/\//, `https://x-access-token:${opts.token}@`);
|
|
@@ -758,6 +758,28 @@ export class LocalRunner {
|
|
|
758
758
|
return `⚠ "${name}" REJECTED: now reads "${shown}"${parsed.msg ? ` — ${parsed.msg.slice(0, 80)}` : ""}`;
|
|
759
759
|
return `"${name}" now reads "${shown}"`;
|
|
760
760
|
}
|
|
761
|
+
/** Read a checkbox/radio's checked state via the standard evaluate tool so `check`
|
|
762
|
+
* can be made idempotent (a raw click would toggle). Returns true/false, or null when
|
|
763
|
+
* the state can't be determined (then we fall back to clicking). Never throws. */
|
|
764
|
+
async isChecked(mcp, ref, label) {
|
|
765
|
+
const fn = "el => { if (el.checked !== undefined && el.checked !== null) return !!el.checked; " +
|
|
766
|
+
"const a = el.getAttribute('aria-checked'); return a === 'true' ? true : a === 'false' ? false : null; }";
|
|
767
|
+
const res = await mcp.callTool("browser_evaluate", { element: label, target: ref, function: fn }).catch(() => null);
|
|
768
|
+
if (!res || res.isError)
|
|
769
|
+
return null;
|
|
770
|
+
const txt = mcp.textOf(res);
|
|
771
|
+
const i = txt.indexOf("### Result");
|
|
772
|
+
if (i < 0)
|
|
773
|
+
return null;
|
|
774
|
+
const after = txt.slice(i + "### Result".length).trim();
|
|
775
|
+
const end = after.indexOf("\n###");
|
|
776
|
+
const block = (end >= 0 ? after.slice(0, end) : after).trim().replace(/^["']|["']$/g, "");
|
|
777
|
+
if (/^true$/i.test(block))
|
|
778
|
+
return true;
|
|
779
|
+
if (/^false$/i.test(block))
|
|
780
|
+
return false;
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
761
783
|
/** The snapshot ref the brain must target the element by (standard-tool `target`). */
|
|
762
784
|
refOf(action) {
|
|
763
785
|
const ref = (action.ref || "").trim();
|
|
@@ -773,10 +795,19 @@ export class LocalRunner {
|
|
|
773
795
|
return mcp.callTool("browser_navigate", { url: a.url });
|
|
774
796
|
case "type":
|
|
775
797
|
return mcp.callTool("browser_type", { element: label, target: this.refOf(a), text: a.text ?? "" });
|
|
776
|
-
// A checkbox/radio is just a click at the standard-tool level.
|
|
777
798
|
case "click":
|
|
778
|
-
case "check":
|
|
779
799
|
return mcp.callTool("browser_click", { element: label, target: this.refOf(a) });
|
|
800
|
+
case "check": {
|
|
801
|
+
const ref = this.refOf(a);
|
|
802
|
+
// A checkbox/radio click is a TOGGLE at the standard-tool level — clicking an
|
|
803
|
+
// ALREADY-checked control unchecks it, silently reversing a pre-ticked consent
|
|
804
|
+
// or a default-selected radio before submit. Make `check` idempotent
|
|
805
|
+
// (ensure-checked): read the current state and only click when it isn't set.
|
|
806
|
+
if ((await this.isChecked(mcp, ref, label)) === true) {
|
|
807
|
+
return { isError: false, content: [{ type: "text", text: `"${label}" already checked` }] };
|
|
808
|
+
}
|
|
809
|
+
return mcp.callTool("browser_click", { element: label, target: ref });
|
|
810
|
+
}
|
|
780
811
|
case "upload": {
|
|
781
812
|
if (!this.applyResumePath)
|
|
782
813
|
throw new RunnerInputError("no résumé file available to upload");
|
package/dist/index.js
CHANGED
|
@@ -952,10 +952,14 @@ function createRunnerCommand() {
|
|
|
952
952
|
});
|
|
953
953
|
runner.stdout?.on("data", (data) => process.stdout.write(data));
|
|
954
954
|
runner.stderr?.on("data", (data) => process.stderr.write(data));
|
|
955
|
+
let shuttingDown = false;
|
|
955
956
|
runner.on("exit", (code) => {
|
|
956
|
-
if (
|
|
957
|
+
if (shuttingDown) return;
|
|
958
|
+
writeError(`Local browser runtime exited unexpectedly${code ? ` (code ${code})` : ""}. Run \`pags up\` again to reconnect.`);
|
|
959
|
+
process.exit(code ?? 1);
|
|
957
960
|
});
|
|
958
961
|
const shutdown = () => {
|
|
962
|
+
shuttingDown = true;
|
|
959
963
|
if (!runner.killed) runner.kill("SIGTERM");
|
|
960
964
|
};
|
|
961
965
|
process.once("SIGINT", () => {
|
|
@@ -1361,9 +1365,13 @@ var upCommand = new Command7("up").description("Start the browser runner for all
|
|
|
1361
1365
|
if (key === "r") {
|
|
1362
1366
|
if (childDead) {
|
|
1363
1367
|
writeLine(" Restarting runner...");
|
|
1364
|
-
const {
|
|
1368
|
+
const { execFileSync: execFileSync2 } = await import("child_process");
|
|
1369
|
+
const restartArgs = [process.argv[1], "up"];
|
|
1370
|
+
if (opts.headless) restartArgs.push("--headless");
|
|
1371
|
+
if (opts.force) restartArgs.push("--force");
|
|
1372
|
+
if (opts.instance) restartArgs.push("--instance", opts.instance);
|
|
1365
1373
|
try {
|
|
1366
|
-
|
|
1374
|
+
execFileSync2(process.execPath, restartArgs, {
|
|
1367
1375
|
stdio: "inherit",
|
|
1368
1376
|
env: process.env
|
|
1369
1377
|
});
|