@proagentstore/cli 0.4.81 → 0.4.83

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.
@@ -29,7 +29,7 @@ import { normalize } from "../local-artifact/engine.js";
29
29
  import { evaluateResult, hostOf, refRole } from "../local-browser/bridge.js";
30
30
  const READ_TOOLS = new Set(["browser_navigate", "browser_navigate_back", "browser_snapshot", "browser_wait_for"]);
31
31
  const FILL_TOOLS = new Set(["browser_type", "browser_select_option"]);
32
- const BRIDGE_TOOLS = ["upload_artifact", "request_answer", "ready_for_review"];
32
+ const BRIDGE_TOOLS = ["upload_artifact", "request_answer", "ready_for_review", "supervisor_checkpoint"];
33
33
  const REFUSED = new Set([
34
34
  "browser_fill_form",
35
35
  "browser_file_upload",
@@ -70,6 +70,9 @@ const INSPECT_PAGE = `() => {
70
70
  duplicate: /you('ve| have) already applied|already applied (for|to) this|application (already exists|has already been (submitted|received))|duplicate application/.test(text),
71
71
  antiBot: /unusual traffic|access (is )?denied|request (has been )?blocked|bot detected|checking (if the site connection is secure|your browser)/.test(text),
72
72
  confirmed: /thank you for (your )?appl|application (has been |was )?(received|submitted)|we('ve| have) received your application|successfully (applied|submitted)/.test(text),
73
+ unavailable: /(?:this |the )?(?:job|position|posting|role|opportunity).{0,120}(?:has )?(?:expired|closed|been filled|is no longer available|is no longer accepting applications)|(?:expired|closed|no longer available|no longer accepting applications).{0,120}(?:job|position|posting|role|opportunity)/.test(text)
74
+ ? (/expired/.test(text) ? "expired" : "unavailable")
75
+ : null,
73
76
  };
74
77
  }`;
75
78
  const text = (t, isError = false) => ({ content: [{ type: "text", text: t }], ...(isError ? { isError: true } : {}) });
@@ -106,10 +109,14 @@ export class ApplyBridge {
106
109
  pages = 0;
107
110
  lastSnapshot = "";
108
111
  admitted = new Set();
112
+ /** Fill and submit are disabled until the cloud supervisor releases the current page checkpoint. */
113
+ supervisorApproved = false;
109
114
  filled = 0;
110
115
  uploaded = new Set();
111
116
  reviewReady = false;
112
117
  summary = null;
118
+ /** Present only after the CLI explicitly reports a notice that the runner independently saw. */
119
+ unavailable = null;
113
120
  submitAttempted = false;
114
121
  submitted = null;
115
122
  /** The last thing that stopped the work and was not resolved — the block reason if the run ends here. */
@@ -119,7 +126,7 @@ export class ApplyBridge {
119
126
  this.host = host;
120
127
  }
121
128
  get done() {
122
- return this.reviewReady || this.submitAttempted;
129
+ return this.reviewReady || this.submitAttempted || this.unavailable !== null;
123
130
  }
124
131
  async listTools() {
125
132
  const own = (await this.browser.listTools()).filter((t) => READ_TOOLS.has(t.name) || FILL_TOOLS.has(t.name) || t.name === "browser_click" || t.name === "browser_press_key");
@@ -149,6 +156,16 @@ export class ApplyBridge {
149
156
  description: "The form is filled as far as the sources allow. Stop here; the owner reviews it. Call once, last.",
150
157
  inputSchema: { type: "object", properties: { summary: { type: "string" } }, required: ["summary"] },
151
158
  },
159
+ {
160
+ name: "supervisor_checkpoint",
161
+ description: "Pause safely for the cloud supervisor. Use the exact checkpointId supplied in your task; do not continue until this returns continue. request_review and stop are terminally handled by the runner.",
162
+ inputSchema: { type: "object", properties: { checkpointId: { type: "string", minLength: 1, maxLength: 300 }, phase: { type: "string", enum: ["initial", "post_navigation", "before_submit", "uncertain"] } }, required: ["checkpointId", "phase"], additionalProperties: false },
163
+ },
164
+ {
165
+ name: "report_job_unavailable",
166
+ description: "End the run when a fresh browser snapshot shows that this job is expired, closed or unavailable. The runner independently verifies the page notice; it refuses unsupported reports.",
167
+ inputSchema: { type: "object", properties: { reason: { type: "string", enum: ["expired", "unavailable"] } }, required: ["reason"] },
168
+ },
152
169
  ];
153
170
  }
154
171
  async callTool(name, args = {}) {
@@ -158,6 +175,10 @@ export class ApplyBridge {
158
175
  return this.review(typeof args.summary === "string" ? args.summary : "");
159
176
  if (name === "request_answer")
160
177
  return this.requestAnswer(args);
178
+ if (name === "supervisor_checkpoint")
179
+ return this.supervisorCheckpoint(args);
180
+ if (name === "report_job_unavailable")
181
+ return this.reportUnavailable(args);
161
182
  if (REFUSED.has(name))
162
183
  return this.refuse(name, "read", `${name} is not available to an application run.`);
163
184
  if (!READ_TOOLS.has(name) && !FILL_TOOLS.has(name) && name !== "browser_click" && name !== "browser_press_key" && name !== "upload_artifact")
@@ -200,7 +221,8 @@ export class ApplyBridge {
200
221
  const v = res && !res.isError ? evaluateResult(textOf(res)) : null;
201
222
  if (!v || typeof v.url !== "string")
202
223
  return null;
203
- return { url: v.url, title: String(v.title ?? ""), captcha: !!v.captcha, login: !!v.login, duplicate: !!v.duplicate, antiBot: !!v.antiBot, confirmed: !!v.confirmed };
224
+ const unavailable = v.unavailable === "expired" || v.unavailable === "unavailable" ? v.unavailable : null;
225
+ return { url: v.url, title: String(v.title ?? ""), captcha: !!v.captcha, login: !!v.login, duplicate: !!v.duplicate, antiBot: !!v.antiBot, confirmed: !!v.confirmed, unavailable };
204
226
  }
205
227
  blockerOf(s) {
206
228
  return s.captcha ? "captcha" : s.antiBot ? "anti_bot" : s.duplicate ? "duplicate_application" : s.login ? "login_required" : null;
@@ -213,12 +235,16 @@ export class ApplyBridge {
213
235
  return false;
214
236
  if (recheck && !(await recheck()))
215
237
  return false;
238
+ // A person may have changed the page or its answers while handling the pause.
239
+ this.supervisorApproved = false;
216
240
  this.blocked = null;
217
241
  return true;
218
242
  }
219
243
  /** After a page change: count it, trace it, and stop for a person when the page needs one. */
220
244
  async landed(toolText, known) {
221
245
  const state = known ?? (await this.inspect());
246
+ // A navigation can put the CLI on a materially different form, even on the same origin.
247
+ this.supervisorApproved = false;
222
248
  if (!state)
223
249
  return text(toolText);
224
250
  this.pages++;
@@ -316,13 +342,21 @@ export class ApplyBridge {
316
342
  // A native file input opens the file chooser for upload_artifact — a DOM fact, whatever its label.
317
343
  const fileInput = facts.tag === "input" && facts.type === "file";
318
344
  if (fileInput || (t.role === "button" && STEP_NAME.test(t.name.trim()) && !facts.submits)) {
345
+ const checkpoint = this.requireSupervisor("browser_click", "fill");
346
+ if (checkpoint)
347
+ return checkpoint;
319
348
  this.allow("browser_click", "fill");
320
349
  const res = await this.browser.callTool("browser_click", forward);
350
+ if (!res.isError && !fileInput)
351
+ this.supervisorApproved = false;
321
352
  return { content: [{ type: "text", text: textOf(res) }], ...(res.isError ? { isError: true } : {}) };
322
353
  }
323
354
  return this.refuse("browser_click", "read", `"${t.name || t.role}" is not a control an application run may press. Links, form-step buttons (Next, Upload, Add) and answers are allowed.`);
324
355
  }
325
356
  async fill(tool, role, forward) {
357
+ const checkpoint = this.requireSupervisor(tool, "fill");
358
+ if (checkpoint)
359
+ return checkpoint;
326
360
  const res = await this.browser.callTool(tool, forward);
327
361
  if (res.isError)
328
362
  return { content: [{ type: "text", text: textOf(res) }], isError: true };
@@ -368,6 +402,9 @@ export class ApplyBridge {
368
402
  return { content: [{ type: "text", text: textOf(res) }], ...(res.isError ? { isError: true } : {}) };
369
403
  }
370
404
  async upload(args) {
405
+ const checkpoint = this.requireSupervisor("upload_artifact", "fill");
406
+ if (checkpoint)
407
+ return checkpoint;
371
408
  const kind = args.kind === "resume" || args.kind === "cover_letter" ? args.kind : null;
372
409
  if (!kind)
373
410
  return this.refuse("upload_artifact", "fill", "kind must be resume or cover_letter.");
@@ -401,6 +438,74 @@ export class ApplyBridge {
401
438
  return text("The owner resumed without answering: leave that field empty and continue with the others.");
402
439
  return text("The owner answered. Their answer is now part of your sources — quote it as source_quote. Take a browser_snapshot and continue.");
403
440
  }
441
+ /**
442
+ * The CLI may ask to stop at a named checkpoint, but it cannot smuggle a decision through this
443
+ * tool. The runner waits for a separately persisted, typed directive and terminal directives are
444
+ * resolved by the runtime before this call returns.
445
+ */
446
+ async supervisorCheckpoint(args) {
447
+ const checkpointId = typeof args.checkpointId === "string" ? args.checkpointId.trim() : "";
448
+ if (!/^[A-Za-z0-9_.:-]{1,300}$/.test(checkpointId))
449
+ return text("supervisor_checkpoint needs a safe checkpointId (letters, numbers, dot, underscore, colon or hyphen).", true);
450
+ const phase = args.phase === "initial" || args.phase === "post_navigation" || args.phase === "before_submit" || args.phase === "uncertain" ? args.phase : null;
451
+ if (!phase)
452
+ return text("supervisor_checkpoint needs phase initial, post_navigation, before_submit or uncertain.", true);
453
+ const state = await this.inspect();
454
+ const blockers = new Set();
455
+ if (state?.captcha)
456
+ blockers.add("captcha");
457
+ if (state?.login)
458
+ blockers.add("login_required");
459
+ if (state?.antiBot)
460
+ blockers.add("anti_bot");
461
+ if (state?.duplicate)
462
+ blockers.add("duplicate_application");
463
+ if (this.blocked && ["missing_answer", "screening_ambiguity", "external_redirect", "duplicate_application", "anti_bot", "login_required", "captcha"].includes(this.blocked.reason))
464
+ blockers.add(this.blocked.reason);
465
+ const url = state?.url;
466
+ const domain = url ? hostOf(url) ?? undefined : undefined;
467
+ const directive = await this.host.supervisorCheckpoint({
468
+ schemaVersion: 1,
469
+ checkpointId,
470
+ facts: {
471
+ phase,
472
+ actions: this.actions,
473
+ filled: this.filled,
474
+ uploaded: this.uploaded.size,
475
+ blockers: [...blockers],
476
+ ...(url ? { url } : {}),
477
+ ...(domain ? { domain } : {}),
478
+ ...(state?.title ? { title: state.title.slice(0, 300) } : {}),
479
+ },
480
+ });
481
+ if (directive === "continue") {
482
+ this.supervisorApproved = true;
483
+ return text("The persisted supervisor directive is continue. You may continue using only the application bridge tools.");
484
+ }
485
+ return text(`The persisted supervisor directive is ${directive}. The runner has ended this application run locally; do not take any further action.`, true);
486
+ }
487
+ /**
488
+ * A terminal result needs the runner's own fresh observation, not the CLI's reading of a page.
489
+ * The snapshot requirement makes the notice visible to the CLI; the static page probe verifies
490
+ * its category and current URL before any evidence is retained.
491
+ */
492
+ async reportUnavailable(args) {
493
+ const reason = args.reason === "expired" || args.reason === "unavailable" ? args.reason : null;
494
+ if (!reason)
495
+ return text("reason must be expired or unavailable.", true);
496
+ if (!this.lastSnapshot)
497
+ return text("Take a fresh browser_snapshot that shows the listing notice before reporting it unavailable.", true);
498
+ const state = await this.inspect();
499
+ if (!state?.unavailable)
500
+ return text("The runner could not verify an expired or unavailable listing notice on the current page. Continue only if the page is still actionable, or take a fresh snapshot.", true);
501
+ if (state.unavailable !== reason)
502
+ return text(`The runner verified this listing as ${state.unavailable}, not ${reason}. Report that reason or take a fresh snapshot.`, true);
503
+ const url = state.url;
504
+ const domain = hostOf(url) ?? "";
505
+ this.unavailable = { reason, url, observedAt: new Date(this.host.now()).toISOString(), source: "page_notice" };
506
+ this.host.emit({ type: "job.unavailable", url, domain, detail: { reason, source: "page_notice" } });
507
+ return text(`Recorded the runner-verified ${reason} listing notice. Stop now.`);
508
+ }
404
509
  review(summary) {
405
510
  this.reviewReady = true;
406
511
  this.summary = summary; // bounded where the result is built (runtime.ts `end`)
@@ -409,6 +514,9 @@ export class ApplyBridge {
409
514
  }
410
515
  /** The final Submit. Never under fill_and_review; once, gated and traced, under auto_submit. */
411
516
  async submit(forward) {
517
+ const checkpoint = this.requireSupervisor("browser_click", "submit");
518
+ if (checkpoint)
519
+ return checkpoint;
412
520
  if (this.host.mode !== "auto_submit" || !this.host.gateId) {
413
521
  this.host.emit({ type: "policy.decision", detail: { tool: "browser_click", class: "submit", decision: "refused", reason: "fill_and_review" } });
414
522
  this.review("Stopped at the final submit: this run fills and waits for review.");
@@ -439,4 +547,11 @@ export class ApplyBridge {
439
547
  this.host.emit({ type: "submit.unconfirmed", ...(after ? { url: after.url } : {}), domain, detail: { gateId: this.host.gateId } });
440
548
  return text("The submit was pressed but no confirmation was seen. Do not press it again. Stop now.", true);
441
549
  }
550
+ /** A cloud-persisted checkpoint, not a prompt instruction, authorizes each page's writes. */
551
+ requireSupervisor(tool, actionClass) {
552
+ if (this.supervisorApproved)
553
+ return null;
554
+ this.host.emit({ type: "policy.decision", detail: { tool, class: actionClass, decision: "refused", reason: "supervisor_checkpoint" } });
555
+ return text(`A persisted supervisor_checkpoint must return continue before ${actionClass === "submit" ? "the final submit" : "any fill or upload"}. Take a browser_snapshot if needed, then call supervisor_checkpoint.`, true);
556
+ }
442
557
  }
@@ -31,6 +31,8 @@ export const LOCAL_APPLY_RUN_PATH = "/local-apply/run";
31
31
  export const LOCAL_APPLY_STATUS_PATH = "/local-apply/status";
32
32
  export const LOCAL_APPLY_RESUME_PATH = "/local-apply/resume";
33
33
  export const LOCAL_APPLY_CANCEL_PATH = "/local-apply/cancel";
34
+ /** Delivers a cloud-persisted supervisory decision to one paused checkpoint. */
35
+ export const LOCAL_APPLY_DIRECTIVE_PATH = "/local-apply/directive";
34
36
  export const LOCAL_APPLY_ENGINES = ["claude", "codex"];
35
37
  export const LOCAL_APPLY_AUTH_MODES = ["machine", "subscription"];
36
38
  export const LOCAL_APPLY_MODES = ["fill_and_review", "auto_submit"];
@@ -43,6 +45,7 @@ export const LOCAL_APPLY_PAUSE_REASONS = [
43
45
  "external_redirect",
44
46
  "duplicate_application",
45
47
  "anti_bot",
48
+ "supervisor_checkpoint",
46
49
  ];
47
50
  export const LOCAL_APPLY_BLOCK_REASONS = [
48
51
  ...LOCAL_APPLY_PAUSE_REASONS,
@@ -52,7 +55,9 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
52
55
  "api_key_refused",
53
56
  "source_unavailable",
54
57
  "artifact_changed",
58
+ "job_unavailable",
55
59
  ];
60
+ export const LOCAL_APPLY_UNAVAILABLE_REASONS = ["expired", "unavailable"];
56
61
  export const LOCAL_APPLY_EVENT_TYPES = [
57
62
  "engine.auth_checked",
58
63
  "engine.started",
@@ -67,16 +72,21 @@ export const LOCAL_APPLY_EVENT_TYPES = [
67
72
  "submit.attempted",
68
73
  "submit.confirmed",
69
74
  "submit.unconfirmed",
75
+ "job.unavailable",
76
+ "supervisor.checkpoint",
77
+ "supervisor.directive",
70
78
  "run.paused",
71
79
  "run.resumed",
72
80
  "note",
73
81
  ];
74
82
  export const LOCAL_APPLY_ENGINE_AUTHS = ["machine-login", "subscription", "api-key", "missing_login", "unknown"];
83
+ export const LOCAL_APPLY_SUPERVISOR_DIRECTIVES = ["continue", "request_review", "stop"];
84
+ export const LOCAL_APPLY_SUPERVISOR_PHASES = ["initial", "before_submit", "post_navigation", "uncertain"];
75
85
  export const LOCAL_APPLY_CAPS = { questions: 20, questionChars: 300, answers: 20, answerChars: 2000, summary: 2000 };
76
86
  const str = (v, max) => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null);
77
87
  const oneOf = (list, v) => (list.includes(v) ? v : null);
78
88
  const isHttpUrl = (v) => /^https?:\/\/[^\s/]+/i.test(v);
79
- const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "mode", "class", "tool", "decision", "reason", "role", "kind", "path", "sha256", "bytes", "gateId", "exitCode", "count", "status", "basis"]);
89
+ const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "mode", "class", "tool", "decision", "reason", "role", "kind", "path", "sha256", "bytes", "gateId", "exitCode", "count", "status", "basis", "source", "checkpointId", "directive", "phase", "actions", "filled", "uploaded"]);
80
90
  /** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
81
91
  export function parseLocalApplyEvent(raw) {
82
92
  if (!raw || typeof raw !== "object")
@@ -158,7 +168,21 @@ export function parseLocalApplyResult(raw) {
158
168
  .filter((q) => typeof q === "string" && q.trim() !== "")
159
169
  .slice(0, LOCAL_APPLY_CAPS.questions)
160
170
  .map((q) => q.trim().slice(0, LOCAL_APPLY_CAPS.questionChars));
171
+ if (out.blockReason === "job_unavailable") {
172
+ const evidence = (o.unavailable && typeof o.unavailable === "object" && !Array.isArray(o.unavailable) ? o.unavailable : {});
173
+ const reason = oneOf(LOCAL_APPLY_UNAVAILABLE_REASONS, evidence.reason);
174
+ const url = str(evidence.url, 2000);
175
+ const observedAt = str(evidence.observedAt, 40);
176
+ if (!reason || !url || !isHttpUrl(url) || !observedAt || Number.isNaN(Date.parse(observedAt)) || evidence.source !== "page_notice") {
177
+ return { error: "a job_unavailable result needs runner-verified unavailable evidence (reason, url, observedAt, source)" };
178
+ }
179
+ out.unavailable = { reason, url, observedAt, source: "page_notice" };
180
+ }
181
+ else if (o.unavailable !== undefined)
182
+ return { error: "unavailable evidence is only valid for a job_unavailable result" };
161
183
  }
184
+ else if (o.unavailable !== undefined)
185
+ return { error: "unavailable evidence is only valid for a job_unavailable result" };
162
186
  if (outcome === "failed")
163
187
  out.error = str(o.error, 1000) ?? "The run failed without a reason.";
164
188
  return { result: out };
@@ -40,10 +40,12 @@ export function applyPrompt(e, sources) {
40
40
  ...sources.map((s) => `<source name="${s.label}">\n${s.text}\n</source>\n`),
41
41
  "How to work:",
42
42
  `- Your only tools are from the ${BRIDGE_SERVER_NAME} server. Open the application page with ${t("browser_navigate")}, read it with ${t("browser_snapshot")}, and act on elements by their ref.`,
43
+ `- Before any fill, upload, form-step click, or final submit, call ${t("supervisor_checkpoint")} and wait for its persisted continue directive. Call it after the initial snapshot (phase initial), after every page-changing move (phase post_navigation), and immediately before a final submit (phase before_submit). Use a stable safe checkpointId for each point. A page change clears the previous approval; request_review and stop end the run locally.`,
43
44
  `- Every answer — ${t("browser_type")}, ${t("browser_select_option")}, or a click on a checkbox, radio or option — must carry source_quote: the exact text from a source above that the value comes from. Type values exactly as the source writes them.`,
44
45
  `- If a question has no answer in the sources, or is ambiguous, call ${t("request_answer")}. Never guess, never invent, never pick a "safe" default.`,
45
46
  `- To attach a document, click the field's upload control, then call ${t("upload_artifact")} with kind resume or cover_letter.`,
46
47
  "- Never sign in, solve a captcha, accept terms or work around any block; the run pauses for the owner by itself.",
48
+ `- If a fresh snapshot shows the job itself is expired, closed or unavailable, call ${t("report_job_unavailable")} with reason expired or unavailable. The runner will independently verify the page notice; do not report it from inference alone.`,
47
49
  `- Stay on the application's site. Limits: ${e.limits.maxPages} pages, ${e.limits.maxActions} browser actions, ${e.limits.maxMinutes} minutes.`,
48
50
  finish,
49
51
  ].join("\n");
@@ -194,6 +194,7 @@ export class LocalApplyRuntime {
194
194
  engineAuth: "unknown",
195
195
  cancelled: false,
196
196
  timedOut: false,
197
+ checkpoints: new Map(),
197
198
  };
198
199
  this.runs.set(envelope.runId, run);
199
200
  void this.launch(run, selfUrl).catch((err) => {
@@ -252,6 +253,7 @@ export class LocalApplyRuntime {
252
253
  run.bridge = new ApplyBridge(run.browser.tools, {
253
254
  emit: (ev) => this.emit(run, ev),
254
255
  pause: (p) => this.pause(run, p),
256
+ supervisorCheckpoint: (checkpointId) => this.supervisorCheckpoint(run, checkpointId),
255
257
  isAllowed: (host) => [...run.allow].some((d) => domainWithin(host, d)),
256
258
  grounding: () => run.grounding.join("\n"),
257
259
  artifactPath: (kind) => {
@@ -311,6 +313,8 @@ export class LocalApplyRuntime {
311
313
  return { outcome: "failed", summary, error: "Cancelled by the owner" };
312
314
  if (b?.submitted)
313
315
  return { outcome: "submitted", summary, submitted: b.submitted };
316
+ if (b?.unavailable)
317
+ return { outcome: "blocked", summary, blockReason: "job_unavailable", unavailable: b.unavailable };
314
318
  if (b?.submitAttempted)
315
319
  return { outcome: "blocked", summary, blockReason: "submit_unconfirmed", questions: b.blocked?.questions ?? [] };
316
320
  if (b?.reviewReady)
@@ -343,6 +347,31 @@ export class LocalApplyRuntime {
343
347
  }
344
348
  return new Promise((done) => run.waiters.push(done));
345
349
  }
350
+ /**
351
+ * Wait for a cloud-brain decision without accepting a model's prose as permission. A directive
352
+ * can be stored before this call reaches its pause (the delivery race), but a normal `resume`
353
+ * can never release this pause.
354
+ */
355
+ supervisorCheckpoint(run, supervisor) {
356
+ const { checkpointId } = supervisor;
357
+ let checkpoint = run.checkpoints.get(checkpointId);
358
+ if (!checkpoint) {
359
+ checkpoint = {};
360
+ run.checkpoints.set(checkpointId, checkpoint);
361
+ }
362
+ if (!checkpoint.checkpoint) {
363
+ checkpoint.checkpoint = supervisor;
364
+ this.emit(run, {
365
+ type: "supervisor.checkpoint",
366
+ ...(supervisor.facts.url ? { url: supervisor.facts.url } : {}),
367
+ ...(supervisor.facts.domain ? { domain: supervisor.facts.domain } : {}),
368
+ detail: { checkpointId, phase: supervisor.facts.phase, actions: supervisor.facts.actions, filled: supervisor.facts.filled, uploaded: supervisor.facts.uploaded },
369
+ });
370
+ }
371
+ if (checkpoint.directive)
372
+ return Promise.resolve(checkpoint.directive);
373
+ return this.pause(run, { reason: "supervisor_checkpoint", checkpoint: supervisor }).then(() => run.checkpoints.get(checkpointId)?.directive ?? "stop");
374
+ }
346
375
  release(run, outcome) {
347
376
  if (run.state === "paused") {
348
377
  run.state = "running";
@@ -360,6 +389,8 @@ export class LocalApplyRuntime {
360
389
  const run = this.get(String(o.runId ?? ""));
361
390
  if (run.state === "ended")
362
391
  throw new RunnerInputError("The run has ended", 409);
392
+ if (run.pause?.reason === "supervisor_checkpoint")
393
+ throw new RunnerInputError("This run is waiting for a persisted supervisor directive, not an owner resume", 409);
363
394
  for (const a of (Array.isArray(o.answers) ? o.answers : []).slice(0, LOCAL_APPLY_CAPS.answers)) {
364
395
  const r = (a && typeof a === "object" ? a : {});
365
396
  if (typeof r.question === "string" && typeof r.answer === "string" && r.answer.trim())
@@ -370,6 +401,41 @@ export class LocalApplyRuntime {
370
401
  this.release(run, "resumed");
371
402
  return this.status({ runId: run.envelope.runId, afterSeq: run.seq });
372
403
  }
404
+ /**
405
+ * Store then apply an immutable cloud directive. The caller persists it before this runner
406
+ * endpoint is reached; this in-process record makes delivery idempotent and closes the race
407
+ * where a directive arrives just before the bridge begins waiting.
408
+ */
409
+ directive(raw) {
410
+ const o = (raw && typeof raw === "object" ? raw : {});
411
+ const run = this.get(typeof o.runId === "string" ? o.runId : "");
412
+ const checkpointId = typeof o.checkpointId === "string" && SAFE_ID.test(o.checkpointId) ? o.checkpointId : null;
413
+ const directive = o.directive === "continue" || o.directive === "request_review" || o.directive === "stop" ? o.directive : null;
414
+ if (o.schemaVersion !== 1 || !checkpointId || !directive)
415
+ throw new RunnerInputError("directive needs schemaVersion: 1, a safe checkpointId, and directive continue, request_review or stop");
416
+ if (run.state === "ended")
417
+ throw new RunnerInputError("The run has ended", 409);
418
+ let checkpoint = run.checkpoints.get(checkpointId);
419
+ if (checkpoint?.directive) {
420
+ if (checkpoint.directive !== directive)
421
+ throw new RunnerInputError("A different directive is already recorded for this checkpoint", 409);
422
+ return this.status({ runId: run.envelope.runId, afterSeq: run.seq });
423
+ }
424
+ checkpoint ??= {};
425
+ checkpoint.directive = directive;
426
+ run.checkpoints.set(checkpointId, checkpoint);
427
+ this.emit(run, { type: "supervisor.directive", detail: { checkpointId, directive } });
428
+ const waitingForThis = run.state === "paused" && run.pause?.reason === "supervisor_checkpoint" && run.pause.checkpoint?.checkpointId === checkpointId;
429
+ if (waitingForThis && directive === "continue")
430
+ this.release(run, "resumed");
431
+ else if (waitingForThis && directive === "request_review") {
432
+ this.end(run, { outcome: "awaiting_review", summary: "The supervisor requested an owner review." });
433
+ }
434
+ else if (waitingForThis && directive === "stop") {
435
+ this.end(run, { outcome: "blocked", blockReason: "incomplete", questions: ["The supervisor stopped this run before it could continue."] });
436
+ }
437
+ return this.status({ runId: run.envelope.runId, afterSeq: run.seq });
438
+ }
373
439
  cancel(raw) {
374
440
  const runId = String(raw?.runId ?? "");
375
441
  const run = this.get(runId);
@@ -456,7 +522,7 @@ export class LocalApplyRuntime {
456
522
  submitAttempted: b?.submitAttempted ?? false,
457
523
  summary: redactText((r.summary ?? "").slice(0, LOCAL_APPLY_CAPS.summary), run.secrets),
458
524
  ...(r.outcome === "submitted" && r.submitted ? { submitted: r.submitted } : {}),
459
- ...(r.outcome === "blocked" ? { blockReason: r.blockReason ?? "incomplete", questions: (r.questions ?? []).map((q) => redactText(q, run.secrets)) } : {}),
525
+ ...(r.outcome === "blocked" ? { blockReason: r.blockReason ?? "incomplete", questions: (r.questions ?? []).map((q) => redactText(q, run.secrets)), ...(r.blockReason === "job_unavailable" && r.unavailable ? { unavailable: r.unavailable } : {}) } : {}),
460
526
  ...(r.outcome === "failed" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
461
527
  };
462
528
  if (run.child && run.child.exitCode === null)
@@ -113,6 +113,9 @@ const BLOCKER_WORDS = {
113
113
  captcha: { noun: "a captcha", failure: "captcha" },
114
114
  login_required: { noun: "a sign-in", failure: "login_required" },
115
115
  access_blocked: { noun: "a bot check or access block", failure: "access_denied" },
116
+ // A pause like the rest (#947): an owner who subscribes can sign in to it in that browser. The run
117
+ // itself never gets around one — not resumed, or still paywalled after, it is reported.
118
+ paywall: { noun: "a paywall", failure: "paywall" },
116
119
  };
117
120
  /** The role and accessible name of a snapshot line holding this ref, e.g. `- link "Next" [ref=e12]`. */
118
121
  export function refRole(snapshot, ref) {
@@ -271,9 +274,9 @@ export class BrowserBridge {
271
274
  this.pages++;
272
275
  this.noteNavigation(state);
273
276
  const host = hostOf(state.url) ?? "";
274
- // Pauses, never obstacles (#947): a captcha, a sign-in, a bot check or an access-control page is
277
+ // Pauses, never obstacles (#947): a captcha, a sign-in, a bot check, an access-control page or a paywall is
275
278
  // for a PERSON in the browser on this machine — the run waits, and re-checks once resumed.
276
- const blocker = state.captcha ? "captcha" : state.login ? "login_required" : state.accessBlocked ? "access_blocked" : null;
279
+ const blocker = state.captcha ? "captcha" : state.login ? "login_required" : state.accessBlocked ? "access_blocked" : state.paywall ? "paywall" : null;
277
280
  if (blocker) {
278
281
  const what = BLOCKER_WORDS[blocker];
279
282
  this.host.emit({ type: "browser.blocked", url: state.url, domain: host, detail: { reason: blocker } });
@@ -281,7 +284,7 @@ export class BrowserBridge {
281
284
  if (outcome !== "resumed")
282
285
  return text(`${host} needs a person (${what.noun}) and nobody resolved it. Record it with report_source_failure (${what.failure}) and move on.`, true);
283
286
  const after = await this.inspect();
284
- if (after && (after.captcha || after.login || after.accessBlocked))
287
+ if (after && (after.captcha || after.login || after.accessBlocked || after.paywall))
285
288
  return text(`${host} still shows ${what.noun}. Record it with report_source_failure (${what.failure}) and move on.`, true);
286
289
  return text(`Resumed after the owner handled ${host}. Take a browser_snapshot to read the page.`);
287
290
  }
@@ -297,10 +300,6 @@ export class BrowserBridge {
297
300
  }
298
301
  return text("The owner let the run read this page. It is still research only: nothing here can be filled, submitted or uploaded. Take a browser_snapshot to read it.");
299
302
  }
300
- if (state.paywall) {
301
- this.host.emit({ type: "browser.blocked", url: state.url, domain: host, detail: { reason: "paywall" } });
302
- return text(`${host} is behind a paywall. Do not try to get around it: record it with report_source_failure (paywall) and move on.`, true);
303
- }
304
303
  return text(toolText);
305
304
  }
306
305
  noteNavigation(state) {
@@ -201,6 +201,9 @@ async function route(runner, req, res) {
201
201
  if (req.method === "POST" && path === "/local-apply/resume") {
202
202
  return json(res, 200, runner.localApply.resume(await readJson(req)));
203
203
  }
204
+ if (req.method === "POST" && path === "/local-apply/directive") {
205
+ return json(res, 200, runner.localApply.directive(await readJson(req)));
206
+ }
204
207
  if (req.method === "POST" && path === "/local-apply/cancel") {
205
208
  return json(res, 200, runner.localApply.cancel(await readJson(req)));
206
209
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.81",
3
+ "version": "0.4.83",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",