@proagentstore/cli 0.4.146 → 0.4.148

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.
@@ -31,6 +31,7 @@ import { ELEMENT_PROBE_FN } from "../commit-guard.js";
31
31
  import { normalize } from "../local-artifact/engine.js";
32
32
  import { evaluateResult, hostOf, refRole } from "../local-browser/bridge.js";
33
33
  import { looksLikeSignIn, siteKeyOf } from "./site-session.js";
34
+ import { followOpenedTab, tabCount } from "./follow-tab.js";
34
35
  const READ_TOOLS = new Set(["browser_navigate", "browser_navigate_back", "browser_snapshot", "browser_wait_for"]);
35
36
  const FILL_TOOLS = new Set(["browser_type", "browser_select_option"]);
36
37
  const BRIDGE_TOOLS = ["upload_artifact", "request_answer", "ready_for_review", "supervisor_checkpoint"];
@@ -424,10 +425,7 @@ export class ApplyBridge {
424
425
  if (NAV_ROLES.has(t.role) || (t.role === "button" && PAGINATION_NAME.test(t.name.trim()))) {
425
426
  if (this.pages >= this.host.limits.maxPages)
426
427
  return this.refuse("browser_click", "read", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call ready_for_review now.`);
427
- const res = await this.browser.callTool("browser_click", forward);
428
- if (res.isError)
429
- return { content: [{ type: "text", text: textOf(res) }], isError: true };
430
- return this.afterMove("browser_click", textOf(res));
428
+ return this.clickAndFollow(forward);
431
429
  }
432
430
  // A native file input opens the file chooser for upload_artifact — a DOM fact, whatever its label.
433
431
  const fileInput = facts.tag === "input" && facts.type === "file";
@@ -643,10 +641,18 @@ export class ApplyBridge {
643
641
  if (this.pages >= this.host.limits.maxPages)
644
642
  return this.refuse("browser_click", "read", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call ready_for_review now.`);
645
643
  this.host.emit({ type: "policy.decision", detail: { tool: "browser_click", class: "entry", decision: "allowed", reason, role: t.role } });
644
+ return this.clickAndFollow(forward);
645
+ }
646
+ /** A page-changing click — followed into a tab it opened (#1133, `follow-tab.ts`). */
647
+ async clickAndFollow(forward) {
648
+ const before = await tabCount(this.browser);
646
649
  const res = await this.browser.callTool("browser_click", forward);
647
650
  if (res.isError)
648
651
  return { content: [{ type: "text", text: textOf(res) }], isError: true };
649
- return this.afterMove("browser_click", textOf(res));
652
+ if (!(await followOpenedTab(this.browser, before)))
653
+ return this.afterMove("browser_click", textOf(res));
654
+ this.host.emit({ type: "policy.decision", detail: { tool: "browser_click", class: "read", decision: "followed", reason: "opened_new_tab" } });
655
+ return this.afterMove("browser_click", `That click opened a new tab; the run now works in it. Take a browser_snapshot to read it.\n${textOf(res)}`);
650
656
  }
651
657
  /** The final Submit. Never under fill_and_review; once, gated and traced, under auto_submit. */
652
658
  async submit(forward, why = "terminal_label") {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The tab a click OPENED (#1133).
3
+ *
4
+ * SEEK's "Apply" can open its sign-in in a NEW tab. Playwright MCP adds a popup to its tab list but keeps
5
+ * the current tab where it was (`_onPageCreated` sets `_currentTab` only when there is none), and the CLI
6
+ * may not switch tabs (`browser_tabs` is refused to it). So every page check after that click — landing,
7
+ * checkpoint, ready_for_review, all of #1100's sign-in detection — read the job ad the click started on,
8
+ * saw no sign-in, and the run ended `review.ready` count 0 (f63b2963, on runner 0.4.145, after #1100).
9
+ *
10
+ * The bridge therefore counts tabs around a page-changing click and selects one that appeared DURING it,
11
+ * so its checks read the page the click led to. Only a new tab: the profile is the owner's real browser,
12
+ * and a tab already open is theirs.
13
+ */
14
+ export async function tabCount(browser) {
15
+ const res = await browser.callTool("browser_tabs", { action: "list" }).catch(() => null);
16
+ if (!res || res.isError)
17
+ return null;
18
+ const body = (res.content ?? []).map((c) => c.text ?? "").join("\n");
19
+ // Only the `### Result` section: Playwright MCP may repeat the same list under `### Open tabs`.
20
+ const result = /### Result\n([\s\S]*?)(?=\n### |$)/.exec(body)?.[1] ?? body;
21
+ return (result.match(/^- \d+:/gm) ?? []).length;
22
+ }
23
+ /** Select the newest tab when the count grew across a click; true when the run now works in it. */
24
+ export async function followOpenedTab(browser, before) {
25
+ if (before === null)
26
+ return false;
27
+ const after = await tabCount(browser);
28
+ if (after === null || after <= before)
29
+ return false;
30
+ const selected = await browser.callTool("browser_tabs", { action: "select", index: after - 1 }).catch(() => null);
31
+ return !!selected && !selected.isError;
32
+ }
@@ -48,6 +48,12 @@ const SAFE_ID = /^[A-Za-z0-9_.:-]{1,300}$/;
48
48
  const HASH = /^[a-f0-9]{64}$/;
49
49
  /** One application at a time per agent: two runs on one employer's form is how duplicates happen. */
50
50
  const MAX_ACTIVE = 1;
51
+ /**
52
+ * Pauses that wait on a DECISION, not on the page (#1121): the owner's stored answer satisfies them on
53
+ * a fresh fill, so a run paused on one gives the machine back. Every other pause — sign-in, CAPTCHA,
54
+ * consent, a redirect, the supervisor's checkpoint — needs this exact live page and keeps it.
55
+ */
56
+ const PARKABLE_PAUSES = new Set(["missing_answer", "screening_ambiguity"]);
51
57
  class Blocked extends Error {
52
58
  reason;
53
59
  questions;
@@ -554,6 +560,21 @@ export class LocalApplyRuntime {
554
560
  pause(run, p) {
555
561
  if (run.state === "ended" || run.cancelled)
556
562
  return Promise.resolve("stopped");
563
+ // #1121: a question only the owner can answer does not need this page — park it. The run ENDS
564
+ // `blocked` with the question, the browser and the CLI stop, and the machine's one slot is free
565
+ // for the next application. The owner's answer is stored (#1079) and re-fills this application
566
+ // from a fresh page, with the answer as a source. Never once a submit was attempted: that run
567
+ // must not be filled again (#1013, #1073), so it keeps holding the slot and its page.
568
+ if (run.state === "running" && PARKABLE_PAUSES.has(p.reason) && !run.bridge?.submitAttempted) {
569
+ this.emit(run, { type: "run.paused", pauseReason: p.reason });
570
+ this.end(run, {
571
+ outcome: "blocked",
572
+ blockReason: p.reason,
573
+ questions: p.question ? [p.question] : [],
574
+ summary: "Parked for the owner's answer; this machine was freed. Nothing was submitted.",
575
+ });
576
+ return Promise.resolve("stopped");
577
+ }
557
578
  if (run.state === "running") {
558
579
  run.activeMs += this.now() - run.activeSince;
559
580
  run.state = "paused";
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Launch Chromium for a test that runs real DOM — after making sure Chromium is installed.
3
+ *
4
+ * In CI the browsers are installed AFTER the unit + integration step (ci.yml). The binary those tests
5
+ * use arrives mid-run, because `LocalRunner` installs it on first launch (`ensureChromiumInstalled`,
6
+ * `runner-browser.test.ts`). A file that called `chromium.launch()` on its own passed only if vitest
7
+ * happened to schedule it after that download finished. `masked-page-screenshot.test.ts` (#1067) lost
8
+ * that race on every CI run from 98b25b98: "Executable doesn't exist … chrome-headless-shell", 1.7s
9
+ * before the download completed. `inspect-page.test.ts` won it by scheduling luck.
10
+ *
11
+ * So this runs the same idempotent `playwright install chromium` the runner runs. It is a quick check
12
+ * when the binary is present, and Playwright's own install lock makes a concurrent install wait rather
13
+ * than collide. `PAGS_SKIP_PLAYWRIGHT_INSTALL=1` skips it, as it does for the runner. Test-only.
14
+ */
15
+ import { spawnSync } from "node:child_process";
16
+ import { createRequire } from "node:module";
17
+ import { dirname, join } from "node:path";
18
+ import { chromium } from "playwright";
19
+ let installed = false;
20
+ export async function launchTestChromium() {
21
+ if (!installed && process.env.PAGS_SKIP_PLAYWRIGHT_INSTALL !== "1") {
22
+ const cli = join(dirname(createRequire(import.meta.url).resolve("playwright")), "cli.js");
23
+ const r = spawnSync(process.execPath, [cli, "install", "chromium"], { stdio: "inherit", env: process.env });
24
+ if (r.status !== 0)
25
+ throw new Error("`playwright install chromium` failed — real-DOM tests cannot run without it.");
26
+ installed = true;
27
+ }
28
+ return chromium.launch();
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.146",
3
+ "version": "0.4.148",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",