@proagentstore/cli 0.1.18 → 0.1.20

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.
@@ -84,6 +84,62 @@ async function route(runner, req, res) {
84
84
  const limit = clampLimit(url.searchParams.get("limit"), 100, 500);
85
85
  return json(res, 200, { events: runner.store.listEvents(limit) });
86
86
  }
87
+ // ── Brain-driven browser control (remote LLM acts on the live page) ─────
88
+ if (req.method === "POST" && path === "/browser/snapshot") {
89
+ const b = await readJson(req).catch(() => ({}));
90
+ return json(res, 200, await runner.browserSnapshot(b.taskId));
91
+ }
92
+ if (req.method === "POST" && path === "/browser/act") {
93
+ const body = await readJson(req);
94
+ return json(res, 200, await runner.browserAct(body, body.resumePath));
95
+ }
96
+ if (req.method === "POST" && path === "/browser/event") {
97
+ const b = await readJson(req);
98
+ return json(res, 200, runner.browserEvent(b.taskId, b.type, b.message, b.data));
99
+ }
100
+ if (req.method === "POST" && path === "/browser/handoff") {
101
+ const b = await readJson(req);
102
+ return json(res, 200, await runner.browserHandoff(b.taskId, b.label ?? b.challenge ?? "this step", b.reason ?? "challenge"));
103
+ }
104
+ if (req.method === "POST" && path === "/browser/handoff-status") {
105
+ const b = await readJson(req);
106
+ return json(res, 200, await runner.browserHandoffStatus(b.taskId));
107
+ }
108
+ if (req.method === "POST" && path === "/browser/resume") {
109
+ const b = await readJson(req);
110
+ return json(res, 200, await runner.browserResume(b.taskId));
111
+ }
112
+ if (req.method === "POST" && path === "/browser/input") {
113
+ const b = await readJson(req);
114
+ return json(res, 200, runner.browserSubmitInput(b.taskId, String(b.value ?? "")));
115
+ }
116
+ if (req.method === "POST" && path === "/browser/complete") {
117
+ const b = await readJson(req);
118
+ return json(res, 200, await runner.browserComplete(b.taskId, b.outcome, b.detail));
119
+ }
120
+ // ── Human takeover (remote view + control) ──────────────────────────────
121
+ if (req.method === "GET" && path === "/takeover") {
122
+ return json(res, 200, { takeovers: runner.listTakeovers() });
123
+ }
124
+ const frameMatch = path.match(/^\/takeover\/([^/]+)\/frame$/);
125
+ if (req.method === "GET" && frameMatch) {
126
+ return json(res, 200, await runner.takeoverFrame(frameMatch[1]));
127
+ }
128
+ const inputMatch = path.match(/^\/takeover\/([^/]+)\/input$/);
129
+ if (req.method === "POST" && inputMatch) {
130
+ const body = await readJson(req);
131
+ await runner.takeoverInput(inputMatch[1], body);
132
+ return json(res, 200, { ok: true });
133
+ }
134
+ const resumeMatch = path.match(/^\/takeover\/([^/]+)\/resume$/);
135
+ if (req.method === "POST" && resumeMatch) {
136
+ return json(res, 200, await runner.resumeTakeover(resumeMatch[1]));
137
+ }
138
+ const endMatch = path.match(/^\/takeover\/([^/]+)\/end$/);
139
+ if (req.method === "POST" && endMatch) {
140
+ await runner.endTakeover(endMatch[1]);
141
+ return json(res, 200, { ok: true });
142
+ }
87
143
  return json(res, 404, { error: "Not found" });
88
144
  }
89
145
  function authorize(req, config) {
@@ -33,6 +33,30 @@ export class RunnerStore {
33
33
  getTask(id) {
34
34
  return this.read().tasks.find((task) => task.id === id) || null;
35
35
  }
36
+ /**
37
+ * On process startup, any persisted task still in an in-flight state
38
+ * (needs_human / running) is orphaned — its live browser page and takeover
39
+ * session died with the previous process and can't be resumed. Fail them so
40
+ * they don't linger as stale "Needs you" cards. Returns the count expired.
41
+ */
42
+ expireInFlightTasks() {
43
+ const data = this.read();
44
+ const now = new Date().toISOString();
45
+ let expired = 0;
46
+ for (const task of data.tasks) {
47
+ if (task.status === "needs_human" || task.status === "running") {
48
+ task.status = "failed";
49
+ task.error =
50
+ "Runner restarted — this paused task was orphaned (its browser session is gone). Re-run it to try again.";
51
+ task.updatedAt = now;
52
+ task.completedAt = now;
53
+ expired += 1;
54
+ }
55
+ }
56
+ if (expired > 0)
57
+ this.write(data);
58
+ return expired;
59
+ }
36
60
  putTask(task) {
37
61
  const data = this.read();
38
62
  const index = data.tasks.findIndex((row) => row.id === task.id);
@@ -32,7 +32,12 @@ async function route(req, res, submissions) {
32
32
  return;
33
33
  }
34
34
  if (req.method === "GET" && url.pathname === "/jobs/software-engineer") {
35
- html(res, 200, jobPage());
35
+ html(res, 200, jobPage({
36
+ challenge: url.searchParams.get("challenge") === "1",
37
+ hcaptcha: url.searchParams.get("hcaptcha") === "1",
38
+ invisibleRecaptcha: url.searchParams.get("invisible_recaptcha") === "1",
39
+ recaptcha: url.searchParams.get("recaptcha") === "1",
40
+ }));
36
41
  return;
37
42
  }
38
43
  if (req.method === "POST" && url.pathname === "/apply") {
@@ -57,7 +62,28 @@ async function route(req, res, submissions) {
57
62
  }
58
63
  html(res, 404, "<h1>Not found</h1>");
59
64
  }
60
- function jobPage() {
65
+ function jobPage(opts = {}) {
66
+ let challenge = "";
67
+ if (opts.hcaptcha) {
68
+ // Real hCaptcha widget using hCaptcha's official always-passes TEST sitekey
69
+ // — lets a human actually solve a challenge end-to-end with zero real-world impact.
70
+ challenge =
71
+ '<div class="h-captcha" data-sitekey="10000000-ffff-ffff-ffff-000000000001"></div>\n <script src="https://js.hcaptcha.com/1/api.js" async defer></script>';
72
+ }
73
+ else if (opts.invisibleRecaptcha) {
74
+ // The invisible v3 badge — present on countless pages, needs NO human. Must
75
+ // NOT be treated as a challenge (the false positive that bailed to the user).
76
+ challenge =
77
+ '<div class="grecaptcha-badge"><iframe title="reCAPTCHA" src="https://example.test/recaptcha/api2/anchor?size=invisible&k=x" style="width:256px;height:60px;display:block"></iframe></div>';
78
+ }
79
+ else if (opts.recaptcha) {
80
+ // A VISIBLE "I'm not a robot" checkbox — a real human challenge.
81
+ challenge =
82
+ '<div class="g-recaptcha"><iframe title="reCAPTCHA" src="https://example.test/recaptcha/api2/anchor?k=x" style="width:304px;height:78px;display:block"></iframe></div>';
83
+ }
84
+ else if (opts.challenge) {
85
+ challenge = '<div class="cf-turnstile" data-sitekey="test">Verify you are human</div>';
86
+ }
61
87
  return page("Senior Software Engineer", `
62
88
  <main>
63
89
  <section class="job">
@@ -82,6 +108,7 @@ function jobPage() {
82
108
  </label>
83
109
  <label>Resume <input name="resume" type="file" accept=".pdf,.txt,.doc,.docx" required /></label>
84
110
  <label>Cover note <textarea name="coverNote" rows="5" required></textarea></label>
111
+ ${challenge}
85
112
  <button type="submit">Submit application</button>
86
113
  </form>
87
114
  </main>
package/dist/index.js CHANGED
@@ -618,8 +618,21 @@ import { chmod, mkdir, writeFile } from "fs/promises";
618
618
  import { gunzipSync } from "zlib";
619
619
  import { arch, homedir as homedir2, platform } from "os";
620
620
  import { resolve as resolve4 } from "path";
621
+ import { createServer as createServer2 } from "net";
621
622
  import { fileURLToPath as fileURLToPath2 } from "url";
622
623
  import { Command as Command6 } from "commander";
624
+ async function findFreePort2(start) {
625
+ for (let p = start; p < start + 25; p++) {
626
+ const free = await new Promise((res) => {
627
+ const s = createServer2();
628
+ s.once("error", () => res(false));
629
+ s.once("listening", () => s.close(() => res(true)));
630
+ s.listen(p, "127.0.0.1");
631
+ });
632
+ if (free) return p;
633
+ }
634
+ return start;
635
+ }
623
636
  var runnerCommand = createRunnerCommand();
624
637
  function runnerBaseUrl(url) {
625
638
  return (clean(url) || clean(process.env.PAGS_RUNNER_URL) || "http://127.0.0.1:49171").replace(/\/$/, "");
@@ -628,7 +641,7 @@ function pagsApiBase(url) {
628
641
  return (clean(url) || clean(process.env.PAGS_API_BASE) || "https://api.proagentstore.online").replace(/\/$/, "");
629
642
  }
630
643
  function pagsHeaders(token) {
631
- const resolved = clean(token) || clean(process.env.PAGS_TOKEN);
644
+ const resolved = clean(token) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
632
645
  return resolved ? { Authorization: `Bearer ${resolved}` } : {};
633
646
  }
634
647
  function runnerRequestHeaders(opts) {
@@ -898,7 +911,7 @@ function createRunnerCommand() {
898
911
  command.command("connect <instanceId>").description("Start the local FAGS runtime, open a Cloudflare quick tunnel, and register it with PAGS").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("--cloudflared <path>", "cloudflared executable").option("--runner-version <version>", "Runner version").option("--skip-probe", "Skip PAGS FAGS-runtime probe after registration").action(async (instanceId, opts) => {
899
912
  const runnerToken = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID()}`;
900
913
  const host = clean(opts.host) || "127.0.0.1";
901
- const port = clean(opts.port) || "49171";
914
+ const port = clean(opts.port) || String(await findFreePort2(49171));
902
915
  const localUrl = `http://${host}:${port}`;
903
916
  const runnerOpts = {
904
917
  ...opts,
@@ -934,55 +947,86 @@ function createRunnerCommand() {
934
947
  try {
935
948
  await waitForLocalRunner({ url: localUrl, token: runnerToken, instanceId });
936
949
  writeLine(`Local FAGS runtime healthy at ${localUrl}`);
937
- const tunnelUrl = await new Promise((resolveTunnel, reject) => {
950
+ const cloudflaredPath = await resolveCloudflaredCommand(opts.cloudflared);
951
+ const openTunnel = () => new Promise((resolveTunnel, reject) => {
938
952
  let output = "";
939
- resolveCloudflaredCommand(opts.cloudflared).then((cloudflared) => {
940
- tunnel = spawn2(cloudflared, buildCloudflaredArgs(localUrl), {
941
- stdio: ["ignore", "pipe", "pipe"],
942
- shell: process.platform === "win32"
943
- });
944
- const onData = (data) => {
945
- const text = data.toString("utf-8");
946
- output += text;
947
- process.stderr.write(text);
948
- const parsed = parseCloudflaredTunnelUrl(output);
949
- if (parsed) resolveTunnel(parsed);
950
- };
951
- tunnel.stdout?.on("data", onData);
952
- tunnel.stderr?.on("data", onData);
953
- tunnel.on("error", reject);
954
- tunnel.on("exit", (code) => {
955
- if (code && code !== 0) reject(new Error(`cloudflared exited with code ${code}`));
956
- });
957
- }).catch(reject);
953
+ const proc = spawn2(cloudflaredPath, buildCloudflaredArgs(localUrl), {
954
+ stdio: ["ignore", "pipe", "pipe"],
955
+ shell: process.platform === "win32"
956
+ });
957
+ const onData = (data) => {
958
+ output += data.toString("utf-8");
959
+ const parsed = parseCloudflaredTunnelUrl(output);
960
+ if (parsed) resolveTunnel({ url: parsed, proc });
961
+ };
962
+ proc.stdout?.on("data", onData);
963
+ proc.stderr?.on("data", onData);
964
+ proc.on("error", reject);
958
965
  setTimeout(() => reject(new Error("timed out waiting for cloudflared tunnel URL")), 45e3).unref();
959
966
  });
960
- writeLine(`Tunnel ready: ${tunnelUrl}`);
961
- const capabilities = await requestRunner("GET", "/capabilities", {
962
- url: localUrl,
963
- token: runnerToken,
964
- instanceId
965
- });
966
- await requestPags("POST", `/v1/instances/${apiPathSegment(instanceId)}/runtime`, opts, {
967
- endpointUrl: tunnelUrl,
968
- token: runnerToken,
969
- placement: "local",
970
- capabilities: Array.isArray(capabilities.capabilities) ? capabilities.capabilities.filter((item) => typeof item === "string") : [],
971
- runnerVersion: clean(opts.runnerVersion) || ""
972
- });
967
+ const registerRuntime = async (url) => {
968
+ const capabilities = await requestRunner("GET", "/capabilities", { url: localUrl, token: runnerToken, instanceId });
969
+ await requestPags("POST", `/v1/instances/${apiPathSegment(instanceId)}/runtime`, opts, {
970
+ endpointUrl: url,
971
+ token: runnerToken,
972
+ placement: "local",
973
+ capabilities: Array.isArray(capabilities.capabilities) ? capabilities.capabilities.filter((item) => typeof item === "string") : [],
974
+ runnerVersion: clean(opts.runnerVersion) || ""
975
+ });
976
+ };
977
+ const first = await openTunnel();
978
+ tunnel = first.proc;
979
+ let tunnelUrl = first.url;
980
+ await registerRuntime(tunnelUrl);
973
981
  writeLine("Runtime registered with PAGS \u2713");
974
982
  writeLine("");
975
983
  writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
976
984
  writeLine(" \u2705 CONNECTED \u2014 Browser runner is live");
977
985
  writeLine(` Instance: ${instanceId.slice(0, 8)}...`);
978
986
  writeLine(` Tunnel: ${tunnelUrl}`);
979
- writeLine(" The agent can now submit applications via browser.");
980
- writeLine(" Keep this terminal open. Press Ctrl+C to disconnect.");
987
+ writeLine(" Auto-reconnect is on \u2014 keep this terminal open. Ctrl+C to disconnect.");
981
988
  writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
989
+ let stopped = false;
990
+ const heartbeatPath = `/v1/instances/${apiPathSegment(instanceId)}/runtime/heartbeat`;
991
+ const probeTunnel = async (url) => {
992
+ try {
993
+ const res = await fetch(`${url.replace(/\/$/, "")}/health`, {
994
+ headers: { Authorization: `Bearer ${runnerToken}`, "X-PAGS-Instance-Id": instanceId },
995
+ signal: AbortSignal.timeout(8e3)
996
+ });
997
+ return res.ok;
998
+ } catch {
999
+ return false;
1000
+ }
1001
+ };
1002
+ void (async () => {
1003
+ while (!stopped) {
1004
+ await new Promise((r) => setTimeout(r, 3e4));
1005
+ if (stopped) break;
1006
+ if (await probeTunnel(tunnelUrl)) {
1007
+ await requestPags("POST", heartbeatPath, opts, {}).catch(() => void 0);
1008
+ continue;
1009
+ }
1010
+ writeLine("\u26A0 Tunnel unreachable \u2014 reconnecting\u2026");
1011
+ try {
1012
+ if (tunnel && !tunnel.killed) tunnel.kill("SIGTERM");
1013
+ } catch {
1014
+ }
1015
+ try {
1016
+ const next = await openTunnel();
1017
+ tunnel = next.proc;
1018
+ tunnelUrl = next.url;
1019
+ await registerRuntime(tunnelUrl);
1020
+ writeLine(`\u2705 Reconnected: ${tunnelUrl}`);
1021
+ } catch (error) {
1022
+ writeError(`reconnect failed (${error instanceof Error ? error.message : String(error)}); retrying in 30s`);
1023
+ }
1024
+ }
1025
+ })();
982
1026
  await new Promise((resolvePromise) => {
983
1027
  runner.on("exit", () => resolvePromise());
984
- tunnel?.on("exit", () => resolvePromise());
985
1028
  });
1029
+ stopped = true;
986
1030
  } catch (error) {
987
1031
  shutdown();
988
1032
  throw error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",