@proagentstore/cli 0.4.13 → 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,9 +29,15 @@ 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
- this.cmdArgs = args;
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
43
  /** True while the agent process is running. NOTE: do NOT use `proc.killed` — Node sets
@@ -81,31 +88,39 @@ export class HeadlessSession {
81
88
  // (e.g. --model) without letting them clobber or orphan-value our flags. raw:
82
89
  // run exactly what the user configured and capture stdout.
83
90
  const args = this.mode === "stream-json" ? buildClaudeArgs(this.cmdArgs, this.claudeSessionId) : [...this.cmdArgs];
84
- this.proc = spawn(this.cmdBin, args, {
91
+ const proc = spawn(this.cmdBin, args, {
85
92
  cwd: this.config.workDir,
86
93
  env: { ...process.env, ...this.config.env },
87
94
  stdio: ["pipe", "pipe", "pipe"],
88
95
  });
96
+ this.proc = proc;
89
97
  this.run = "idle";
90
98
  this.lastOutputAt = Date.now();
91
99
  // MUST handle 'error' — without a listener, a spawn failure (e.g. the binary
92
100
  // not on PATH) is thrown as an uncaught exception and crashes the runner.
93
- this.proc.on("error", (err) => {
101
+ proc.on("error", (err) => {
102
+ if (this.proc !== proc)
103
+ return; // a newer process replaced this one — ignore the stale event
94
104
  this.run = "idle";
95
105
  this.push(`[cannot run \`${this.cmdBin}\`: ${err.message} — is ${this.binName} installed and on your PATH?]`);
96
106
  this.proc = null;
97
107
  });
98
108
  // Swallow EPIPE when writing to a process that just exited (one-shot turn).
99
- this.proc.stdin?.on("error", () => { });
100
- this.proc.stdout?.on("data", (d) => this.onStdout(d.toString("utf8")));
101
- this.proc.stderr?.on("data", (d) => {
109
+ proc.stdin?.on("error", () => { });
110
+ proc.stdout?.on("data", (d) => this.onStdout(d.toString("utf8")));
111
+ proc.stderr?.on("data", (d) => {
102
112
  this.lastOutputAt = Date.now();
103
113
  this.sawOutputSinceInput = true;
104
114
  const text = d.toString("utf8").trim();
105
115
  if (text)
106
116
  this.push(`[${this.binName}] ${stripAnsi(text)}`);
107
117
  });
108
- this.proc.on("exit", (code) => {
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;
109
124
  this.run = "idle";
110
125
  if (code && code !== 0)
111
126
  this.push(`[${this.binName} exited with code ${code}]`);
@@ -184,9 +199,14 @@ export class HeadlessSession {
184
199
  else
185
200
  this.pushRaw(line); // raw engine — the line IS the terminal output
186
201
  }
187
- // A TUI/raw engine may render without newlines; don't let `buf` grow unbounded
188
- // (and surface the partial output so the pane isn't blank). 16KB is generous.
189
- if (this.buf.length > 16 * 1024) {
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) {
190
210
  if (this.mode === "raw")
191
211
  this.pushRaw(this.buf);
192
212
  this.buf = "";
@@ -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
- // A stale/empty/half-cloned dir (exists but no .git) would make `git clone`
112
- // fail on a non-empty target — clear it first so the clone is clean.
113
- if (existsSync(dir))
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
@@ -1365,9 +1365,13 @@ var upCommand = new Command7("up").description("Start the browser runner for all
1365
1365
  if (key === "r") {
1366
1366
  if (childDead) {
1367
1367
  writeLine(" Restarting runner...");
1368
- const { execSync } = await import("child_process");
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);
1369
1373
  try {
1370
- execSync(`${process.execPath} ${process.argv[1]} up${opts.headless ? " --headless" : ""}${opts.force ? " --force" : ""}`, {
1374
+ execFileSync2(process.execPath, restartArgs, {
1371
1375
  stdio: "inherit",
1372
1376
  env: process.env
1373
1377
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.13",
3
+ "version": "0.4.14",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",