@proagentstore/cli 0.4.27 → 0.4.28

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,53 @@ 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
- // One token = a run of unquoted chars and/or quoted spans, glued together (`"a b"c` → `a bc`).
437
- const re = /(?:[^\s"']+|"[^"]*"|'[^']*')+/g;
438
- for (const raw of (command ?? "").match(re) ?? []) {
439
- tokens.push(raw.replace(/"([^"]*)"|'([^']*)'/g, (_m, d, s) => d ?? s ?? ""));
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 start = i;
458
+ let out = "";
459
+ let quote = null;
460
+ while (i < src.length) {
461
+ const ch = src[i];
462
+ if (quote) {
463
+ if (ch === quote)
464
+ quote = null;
465
+ else
466
+ out += ch;
467
+ i++;
468
+ continue;
469
+ }
470
+ if (ch === '"' || ch === "'") {
471
+ quote = ch;
472
+ i++;
473
+ continue;
474
+ }
475
+ if (isSpace(ch))
476
+ break;
477
+ out += ch;
478
+ i++;
479
+ }
480
+ if (quote) {
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
+ }
440
495
  }
441
496
  return { bin: tokens[0] ?? "", args: tokens.slice(1) };
442
497
  }
@@ -30,6 +30,16 @@ const CAPABILITIES = [
30
30
  /** How many times a task may attempt an action before handing off to a human. */
31
31
  export const MAX_AUTONOMOUS_ATTEMPTS = 3;
32
32
  const APPROVAL_REQUIRED_TASKS = new Set(["browser.open"]);
33
+ /**
34
+ * Tasks steered by a remote durable Workflow rather than executed by the runner.
35
+ *
36
+ * ONE list, because the two places that need it drifted: task creation already treated
37
+ * `browser.task` like `job.apply_agent`, but `resumeTakeover` did not — so a human resolving a
38
+ * stuck browse handoff destroyed the takeover session instead of handing control back, and the
39
+ * workflow then polled a dead session for 15 minutes before failing a run it had already
40
+ * completed.
41
+ */
42
+ const WORKFLOW_DRIVEN_TASKS = new Set(["job.apply_agent", "browser.task"]);
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 (normalized.type === "job.apply_agent" || normalized.type === "browser.task") {
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 applications are steered by the remote workflow. "Resume"
378
- // here just signals the human finished the stuck step (or solved a captcha);
379
- // the workflow polls humanDone and continues driving. Do NOT complete/submit.
380
- if (task.type === "job.apply_agent") {
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 (job.apply_agent returns above) — it just
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
- const task = this.store.getTask(taskId);
996
- if (task) {
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.27",
3
+ "version": "0.4.28",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",