@proagentstore/cli 0.4.82 → 0.4.84

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 } : {}) });
@@ -102,14 +105,19 @@ function withQuote(schema, required) {
102
105
  export class ApplyBridge {
103
106
  browser;
104
107
  host;
108
+ /** Read by the runtime for the #975 diagnostic: 0 calls is the finding, not an internal detail. */
105
109
  actions = 0;
106
110
  pages = 0;
107
111
  lastSnapshot = "";
108
112
  admitted = new Set();
113
+ /** Fill and submit are disabled until the cloud supervisor releases the current page checkpoint. */
114
+ supervisorApproved = false;
109
115
  filled = 0;
110
116
  uploaded = new Set();
111
117
  reviewReady = false;
112
118
  summary = null;
119
+ /** Present only after the CLI explicitly reports a notice that the runner independently saw. */
120
+ unavailable = null;
113
121
  submitAttempted = false;
114
122
  submitted = null;
115
123
  /** The last thing that stopped the work and was not resolved — the block reason if the run ends here. */
@@ -119,7 +127,7 @@ export class ApplyBridge {
119
127
  this.host = host;
120
128
  }
121
129
  get done() {
122
- return this.reviewReady || this.submitAttempted;
130
+ return this.reviewReady || this.submitAttempted || this.unavailable !== null;
123
131
  }
124
132
  async listTools() {
125
133
  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 +157,16 @@ export class ApplyBridge {
149
157
  description: "The form is filled as far as the sources allow. Stop here; the owner reviews it. Call once, last.",
150
158
  inputSchema: { type: "object", properties: { summary: { type: "string" } }, required: ["summary"] },
151
159
  },
160
+ {
161
+ name: "supervisor_checkpoint",
162
+ 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.",
163
+ 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 },
164
+ },
165
+ {
166
+ name: "report_job_unavailable",
167
+ 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.",
168
+ inputSchema: { type: "object", properties: { reason: { type: "string", enum: ["expired", "unavailable"] } }, required: ["reason"] },
169
+ },
152
170
  ];
153
171
  }
154
172
  async callTool(name, args = {}) {
@@ -158,6 +176,10 @@ export class ApplyBridge {
158
176
  return this.review(typeof args.summary === "string" ? args.summary : "");
159
177
  if (name === "request_answer")
160
178
  return this.requestAnswer(args);
179
+ if (name === "supervisor_checkpoint")
180
+ return this.supervisorCheckpoint(args);
181
+ if (name === "report_job_unavailable")
182
+ return this.reportUnavailable(args);
161
183
  if (REFUSED.has(name))
162
184
  return this.refuse(name, "read", `${name} is not available to an application run.`);
163
185
  if (!READ_TOOLS.has(name) && !FILL_TOOLS.has(name) && name !== "browser_click" && name !== "browser_press_key" && name !== "upload_artifact")
@@ -200,7 +222,8 @@ export class ApplyBridge {
200
222
  const v = res && !res.isError ? evaluateResult(textOf(res)) : null;
201
223
  if (!v || typeof v.url !== "string")
202
224
  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 };
225
+ const unavailable = v.unavailable === "expired" || v.unavailable === "unavailable" ? v.unavailable : null;
226
+ 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
227
  }
205
228
  blockerOf(s) {
206
229
  return s.captcha ? "captcha" : s.antiBot ? "anti_bot" : s.duplicate ? "duplicate_application" : s.login ? "login_required" : null;
@@ -213,12 +236,16 @@ export class ApplyBridge {
213
236
  return false;
214
237
  if (recheck && !(await recheck()))
215
238
  return false;
239
+ // A person may have changed the page or its answers while handling the pause.
240
+ this.supervisorApproved = false;
216
241
  this.blocked = null;
217
242
  return true;
218
243
  }
219
244
  /** After a page change: count it, trace it, and stop for a person when the page needs one. */
220
245
  async landed(toolText, known) {
221
246
  const state = known ?? (await this.inspect());
247
+ // A navigation can put the CLI on a materially different form, even on the same origin.
248
+ this.supervisorApproved = false;
222
249
  if (!state)
223
250
  return text(toolText);
224
251
  this.pages++;
@@ -316,13 +343,21 @@ export class ApplyBridge {
316
343
  // A native file input opens the file chooser for upload_artifact — a DOM fact, whatever its label.
317
344
  const fileInput = facts.tag === "input" && facts.type === "file";
318
345
  if (fileInput || (t.role === "button" && STEP_NAME.test(t.name.trim()) && !facts.submits)) {
346
+ const checkpoint = this.requireSupervisor("browser_click", "fill");
347
+ if (checkpoint)
348
+ return checkpoint;
319
349
  this.allow("browser_click", "fill");
320
350
  const res = await this.browser.callTool("browser_click", forward);
351
+ if (!res.isError && !fileInput)
352
+ this.supervisorApproved = false;
321
353
  return { content: [{ type: "text", text: textOf(res) }], ...(res.isError ? { isError: true } : {}) };
322
354
  }
323
355
  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
356
  }
325
357
  async fill(tool, role, forward) {
358
+ const checkpoint = this.requireSupervisor(tool, "fill");
359
+ if (checkpoint)
360
+ return checkpoint;
326
361
  const res = await this.browser.callTool(tool, forward);
327
362
  if (res.isError)
328
363
  return { content: [{ type: "text", text: textOf(res) }], isError: true };
@@ -368,6 +403,9 @@ export class ApplyBridge {
368
403
  return { content: [{ type: "text", text: textOf(res) }], ...(res.isError ? { isError: true } : {}) };
369
404
  }
370
405
  async upload(args) {
406
+ const checkpoint = this.requireSupervisor("upload_artifact", "fill");
407
+ if (checkpoint)
408
+ return checkpoint;
371
409
  const kind = args.kind === "resume" || args.kind === "cover_letter" ? args.kind : null;
372
410
  if (!kind)
373
411
  return this.refuse("upload_artifact", "fill", "kind must be resume or cover_letter.");
@@ -401,6 +439,74 @@ export class ApplyBridge {
401
439
  return text("The owner resumed without answering: leave that field empty and continue with the others.");
402
440
  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
441
  }
442
+ /**
443
+ * The CLI may ask to stop at a named checkpoint, but it cannot smuggle a decision through this
444
+ * tool. The runner waits for a separately persisted, typed directive and terminal directives are
445
+ * resolved by the runtime before this call returns.
446
+ */
447
+ async supervisorCheckpoint(args) {
448
+ const checkpointId = typeof args.checkpointId === "string" ? args.checkpointId.trim() : "";
449
+ if (!/^[A-Za-z0-9_.:-]{1,300}$/.test(checkpointId))
450
+ return text("supervisor_checkpoint needs a safe checkpointId (letters, numbers, dot, underscore, colon or hyphen).", true);
451
+ const phase = args.phase === "initial" || args.phase === "post_navigation" || args.phase === "before_submit" || args.phase === "uncertain" ? args.phase : null;
452
+ if (!phase)
453
+ return text("supervisor_checkpoint needs phase initial, post_navigation, before_submit or uncertain.", true);
454
+ const state = await this.inspect();
455
+ const blockers = new Set();
456
+ if (state?.captcha)
457
+ blockers.add("captcha");
458
+ if (state?.login)
459
+ blockers.add("login_required");
460
+ if (state?.antiBot)
461
+ blockers.add("anti_bot");
462
+ if (state?.duplicate)
463
+ blockers.add("duplicate_application");
464
+ if (this.blocked && ["missing_answer", "screening_ambiguity", "external_redirect", "duplicate_application", "anti_bot", "login_required", "captcha"].includes(this.blocked.reason))
465
+ blockers.add(this.blocked.reason);
466
+ const url = state?.url;
467
+ const domain = url ? hostOf(url) ?? undefined : undefined;
468
+ const directive = await this.host.supervisorCheckpoint({
469
+ schemaVersion: 1,
470
+ checkpointId,
471
+ facts: {
472
+ phase,
473
+ actions: this.actions,
474
+ filled: this.filled,
475
+ uploaded: this.uploaded.size,
476
+ blockers: [...blockers],
477
+ ...(url ? { url } : {}),
478
+ ...(domain ? { domain } : {}),
479
+ ...(state?.title ? { title: state.title.slice(0, 300) } : {}),
480
+ },
481
+ });
482
+ if (directive === "continue") {
483
+ this.supervisorApproved = true;
484
+ return text("The persisted supervisor directive is continue. You may continue using only the application bridge tools.");
485
+ }
486
+ return text(`The persisted supervisor directive is ${directive}. The runner has ended this application run locally; do not take any further action.`, true);
487
+ }
488
+ /**
489
+ * A terminal result needs the runner's own fresh observation, not the CLI's reading of a page.
490
+ * The snapshot requirement makes the notice visible to the CLI; the static page probe verifies
491
+ * its category and current URL before any evidence is retained.
492
+ */
493
+ async reportUnavailable(args) {
494
+ const reason = args.reason === "expired" || args.reason === "unavailable" ? args.reason : null;
495
+ if (!reason)
496
+ return text("reason must be expired or unavailable.", true);
497
+ if (!this.lastSnapshot)
498
+ return text("Take a fresh browser_snapshot that shows the listing notice before reporting it unavailable.", true);
499
+ const state = await this.inspect();
500
+ if (!state?.unavailable)
501
+ 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);
502
+ if (state.unavailable !== reason)
503
+ return text(`The runner verified this listing as ${state.unavailable}, not ${reason}. Report that reason or take a fresh snapshot.`, true);
504
+ const url = state.url;
505
+ const domain = hostOf(url) ?? "";
506
+ this.unavailable = { reason, url, observedAt: new Date(this.host.now()).toISOString(), source: "page_notice" };
507
+ this.host.emit({ type: "job.unavailable", url, domain, detail: { reason, source: "page_notice" } });
508
+ return text(`Recorded the runner-verified ${reason} listing notice. Stop now.`);
509
+ }
404
510
  review(summary) {
405
511
  this.reviewReady = true;
406
512
  this.summary = summary; // bounded where the result is built (runtime.ts `end`)
@@ -409,6 +515,9 @@ export class ApplyBridge {
409
515
  }
410
516
  /** The final Submit. Never under fill_and_review; once, gated and traced, under auto_submit. */
411
517
  async submit(forward) {
518
+ const checkpoint = this.requireSupervisor("browser_click", "submit");
519
+ if (checkpoint)
520
+ return checkpoint;
412
521
  if (this.host.mode !== "auto_submit" || !this.host.gateId) {
413
522
  this.host.emit({ type: "policy.decision", detail: { tool: "browser_click", class: "submit", decision: "refused", reason: "fill_and_review" } });
414
523
  this.review("Stopped at the final submit: this run fills and waits for review.");
@@ -439,4 +548,11 @@ export class ApplyBridge {
439
548
  this.host.emit({ type: "submit.unconfirmed", ...(after ? { url: after.url } : {}), domain, detail: { gateId: this.host.gateId } });
440
549
  return text("The submit was pressed but no confirmation was seen. Do not press it again. Stop now.", true);
441
550
  }
551
+ /** A cloud-persisted checkpoint, not a prompt instruction, authorizes each page's writes. */
552
+ requireSupervisor(tool, actionClass) {
553
+ if (this.supervisorApproved)
554
+ return null;
555
+ this.host.emit({ type: "policy.decision", detail: { tool, class: actionClass, decision: "refused", reason: "supervisor_checkpoint" } });
556
+ 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);
557
+ }
442
558
  }
@@ -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,12 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
52
55
  "api_key_refused",
53
56
  "source_unavailable",
54
57
  "artifact_changed",
58
+ "job_unavailable",
59
+ "bridge_unused",
55
60
  ];
61
+ export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output"];
62
+ export const LOCAL_APPLY_SIGNALS = ["approval_policy_blocked", "bridge_tools_missing", "auth_prompt", "no_output", "engine_refused_task"];
63
+ export const LOCAL_APPLY_UNAVAILABLE_REASONS = ["expired", "unavailable"];
56
64
  export const LOCAL_APPLY_EVENT_TYPES = [
57
65
  "engine.auth_checked",
58
66
  "engine.started",
@@ -67,16 +75,21 @@ export const LOCAL_APPLY_EVENT_TYPES = [
67
75
  "submit.attempted",
68
76
  "submit.confirmed",
69
77
  "submit.unconfirmed",
78
+ "job.unavailable",
79
+ "supervisor.checkpoint",
80
+ "supervisor.directive",
70
81
  "run.paused",
71
82
  "run.resumed",
72
83
  "note",
73
84
  ];
74
85
  export const LOCAL_APPLY_ENGINE_AUTHS = ["machine-login", "subscription", "api-key", "missing_login", "unknown"];
86
+ export const LOCAL_APPLY_SUPERVISOR_DIRECTIVES = ["continue", "request_review", "stop"];
87
+ export const LOCAL_APPLY_SUPERVISOR_PHASES = ["initial", "before_submit", "post_navigation", "uncertain"];
75
88
  export const LOCAL_APPLY_CAPS = { questions: 20, questionChars: 300, answers: 20, answerChars: 2000, summary: 2000 };
76
89
  const str = (v, max) => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null);
77
90
  const oneOf = (list, v) => (list.includes(v) ? v : null);
78
91
  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"]);
92
+ 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
93
  /** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
81
94
  export function parseLocalApplyEvent(raw) {
82
95
  if (!raw || typeof raw !== "object")
@@ -152,13 +165,45 @@ export function parseLocalApplyResult(raw) {
152
165
  out.submitted = { url, at, gateId };
153
166
  out.submitAttempted = true;
154
167
  }
168
+ // #975 — accepted ONLY as the closed vocabulary it declares. A runner that sends prose here gets
169
+ // it dropped rather than stored: the whole point of a structured diagnostic is that no new
170
+ // free-text channel opens between the machine and the owner's records.
171
+ const d = o.diagnostic && typeof o.diagnostic === "object" && !Array.isArray(o.diagnostic) ? o.diagnostic : null;
172
+ const cause = d ? oneOf(LOCAL_APPLY_DIAGNOSTIC_CAUSES, d.cause) : null;
173
+ if (d && cause) {
174
+ const count = (v) => (typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 1_000_000 ? v : 0);
175
+ out.diagnostic = {
176
+ cause,
177
+ bridgeCalls: count(d.bridgeCalls),
178
+ engineExit: typeof d.engineExit === "number" && Number.isInteger(d.engineExit) && d.engineExit >= -1 && d.engineExit <= 255 ? d.engineExit : 0,
179
+ activeMs: count(d.activeMs),
180
+ pages: count(d.pages),
181
+ filled: count(d.filled),
182
+ // Deduped from a closed 5-value vocabulary, so it is bounded by the enum itself — no cap needed.
183
+ signals: [...new Set((Array.isArray(d.signals) ? d.signals : []).filter((x) => LOCAL_APPLY_SIGNALS.includes(x)))],
184
+ };
185
+ }
155
186
  if (outcome === "blocked") {
156
187
  out.blockReason = oneOf(LOCAL_APPLY_BLOCK_REASONS, o.blockReason) ?? "incomplete";
157
188
  out.questions = (Array.isArray(o.questions) ? o.questions : [])
158
189
  .filter((q) => typeof q === "string" && q.trim() !== "")
159
190
  .slice(0, LOCAL_APPLY_CAPS.questions)
160
191
  .map((q) => q.trim().slice(0, LOCAL_APPLY_CAPS.questionChars));
192
+ if (out.blockReason === "job_unavailable") {
193
+ const evidence = (o.unavailable && typeof o.unavailable === "object" && !Array.isArray(o.unavailable) ? o.unavailable : {});
194
+ const reason = oneOf(LOCAL_APPLY_UNAVAILABLE_REASONS, evidence.reason);
195
+ const url = str(evidence.url, 2000);
196
+ const observedAt = str(evidence.observedAt, 40);
197
+ if (!reason || !url || !isHttpUrl(url) || !observedAt || Number.isNaN(Date.parse(observedAt)) || evidence.source !== "page_notice") {
198
+ return { error: "a job_unavailable result needs runner-verified unavailable evidence (reason, url, observedAt, source)" };
199
+ }
200
+ out.unavailable = { reason, url, observedAt, source: "page_notice" };
201
+ }
202
+ else if (o.unavailable !== undefined)
203
+ return { error: "unavailable evidence is only valid for a job_unavailable result" };
161
204
  }
205
+ else if (o.unavailable !== undefined)
206
+ return { error: "unavailable evidence is only valid for a job_unavailable result" };
162
207
  if (outcome === "failed")
163
208
  out.error = str(o.error, 1000) ?? "The run failed without a reason.";
164
209
  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");
@@ -147,6 +147,23 @@ export function verifyArtifact(workspace, home, a) {
147
147
  return { error: `The approved ${label} at ${a.path} could not be read.` };
148
148
  }
149
149
  }
150
+ /**
151
+ * What to tell the owner when the CLI put nothing on the page (#975) — built from the diagnostic's
152
+ * own ids, so the advice follows the observation rather than guessing at it.
153
+ */
154
+ function bridgeUnusedHelp(engine, d) {
155
+ if (d.signals.includes("approval_policy_blocked"))
156
+ return `The ${engine} CLI would not use the browser tools because its own approval policy refused them. Allow the PAGS bridge toolset for this session, then retry.`;
157
+ if (d.signals.includes("bridge_tools_missing"))
158
+ return `The ${engine} CLI never saw the browser tools. Update the CLI (npm i -g @proagentstore/cli), run \`pags up\` again, then retry.`;
159
+ if (d.signals.includes("auth_prompt"))
160
+ return `The ${engine} CLI stopped on a sign-in prompt before opening the page. Sign it in on this machine, then retry.`;
161
+ if (d.signals.includes("no_output"))
162
+ return `The ${engine} CLI produced no output at all before exiting. Check that it runs on this machine, then retry.`;
163
+ if (d.signals.includes("engine_refused_task"))
164
+ return `The ${engine} CLI declined the task and never opened the page. Its own closing message is on this run's summary.`;
165
+ return `The ${engine} CLI ran for ${Math.round(d.activeMs / 1000)}s and exited (code ${d.engineExit}) without opening the application page — no navigation, no checkpoint, no fill. Nothing was submitted. Retry, or run the CLI by hand on this machine to see why it stops.`;
166
+ }
150
167
  export class LocalApplyRuntime {
151
168
  deps;
152
169
  runs = new Map();
@@ -194,6 +211,7 @@ export class LocalApplyRuntime {
194
211
  engineAuth: "unknown",
195
212
  cancelled: false,
196
213
  timedOut: false,
214
+ checkpoints: new Map(),
197
215
  };
198
216
  this.runs.set(envelope.runId, run);
199
217
  void this.launch(run, selfUrl).catch((err) => {
@@ -252,6 +270,7 @@ export class LocalApplyRuntime {
252
270
  run.bridge = new ApplyBridge(run.browser.tools, {
253
271
  emit: (ev) => this.emit(run, ev),
254
272
  pause: (p) => this.pause(run, p),
273
+ supervisorCheckpoint: (checkpointId) => this.supervisorCheckpoint(run, checkpointId),
255
274
  isAllowed: (host) => [...run.allow].some((d) => domainWithin(host, d)),
256
275
  grounding: () => run.grounding.join("\n"),
257
276
  artifactPath: (kind) => {
@@ -302,6 +321,42 @@ export class LocalApplyRuntime {
302
321
  }, 5_000);
303
322
  run.timer.unref?.();
304
323
  }
324
+ /**
325
+ * What the runner OBSERVED about its own CLI (#975), as ids this platform defines.
326
+ *
327
+ * Matched against the engine's structured output on the machine; only the matching signal's ID
328
+ * crosses the contract, never the line that matched it. That is what makes a diagnostic safe to
329
+ * persist: a CLI prints the owner's résumé prose and their typed answers while it works, and no
330
+ * redaction can reliably tell those from the rest of its chatter.
331
+ */
332
+ signalsOf(run) {
333
+ const text = run.output.join("\n");
334
+ const out = [];
335
+ if (!text.trim())
336
+ out.push("no_output");
337
+ // #952's live failure, invisible from the cloud and fixable by configuration.
338
+ if (/approval policy is\s*[`'"]?never|requires? approval|permission denied for tool|not allowed to use/i.test(text))
339
+ out.push("approval_policy_blocked");
340
+ if (/browser_navigate.{0,40}(not found|unknown tool|unavailable)|no such tool|tool .{0,40}is not available/i.test(text))
341
+ out.push("bridge_tools_missing");
342
+ if (missingLogin(run.envelope.engine, text))
343
+ out.push("auth_prompt");
344
+ if (/\b(i (cannot|can't|won't)|unable to) (help|assist|complete|do that|proceed)/i.test(text))
345
+ out.push("engine_refused_task");
346
+ return [...new Set(out)];
347
+ }
348
+ /** The bounded, closed-vocabulary diagnostic for a run that put nothing on the page (#975). */
349
+ diagnosticOf(run, code, cause) {
350
+ return {
351
+ cause,
352
+ bridgeCalls: run.bridge?.actions ?? 0,
353
+ engineExit: Number.isInteger(code) && code >= -1 && code <= 255 ? code : 0,
354
+ activeMs: this.activeMs(run),
355
+ pages: run.bridge?.pages ?? 0,
356
+ filled: run.bridge?.filled ?? 0,
357
+ signals: this.signalsOf(run),
358
+ };
359
+ }
305
360
  /** What the run amounts to when the CLI exits — decided by what the bridge did, not what the CLI says. */
306
361
  outcomeOf(run, code) {
307
362
  const e = run.envelope;
@@ -311,6 +366,8 @@ export class LocalApplyRuntime {
311
366
  return { outcome: "failed", summary, error: "Cancelled by the owner" };
312
367
  if (b?.submitted)
313
368
  return { outcome: "submitted", summary, submitted: b.submitted };
369
+ if (b?.unavailable)
370
+ return { outcome: "blocked", summary, blockReason: "job_unavailable", unavailable: b.unavailable };
314
371
  if (b?.submitAttempted)
315
372
  return { outcome: "blocked", summary, blockReason: "submit_unconfirmed", questions: b.blocked?.questions ?? [] };
316
373
  if (b?.reviewReady)
@@ -319,10 +376,26 @@ export class LocalApplyRuntime {
319
376
  return { outcome: "blocked", summary, blockReason: "engine_not_signed_in", questions: [signInHelp(e.engine)], engineAuth: "missing_login" };
320
377
  if (b?.blocked)
321
378
  return { outcome: "blocked", summary, blockReason: b.blocked.reason, questions: b.blocked.questions };
322
- if (run.timedOut)
323
- return { outcome: "blocked", summary, blockReason: "incomplete", questions: [`Stopped at the ${e.limits.maxMinutes}-minute limit before the form was ready for review.`] };
379
+ if (run.timedOut) {
380
+ return { outcome: "blocked", summary, blockReason: "incomplete", questions: [`Stopped at the ${e.limits.maxMinutes}-minute limit before the form was ready for review.`], diagnostic: this.diagnosticOf(run, code, "timed_out") };
381
+ }
382
+ // #975: a CLI that never called the bridge did NOTHING on the page, which is a different
383
+ // failure from stopping partway — different cause, different fix — and saying `incomplete`
384
+ // for both left the owner with a 51-second run they could not explain. The diagnostic says
385
+ // how long it ran, how many bridge calls it made (0) and what the runner saw it print.
386
+ const calls = b?.actions ?? 0;
387
+ if (calls === 0) {
388
+ const diagnostic = this.diagnosticOf(run, code, !run.output.join("").trim() ? "no_engine_output" : code !== 0 ? "engine_exited_nonzero" : "bridge_unused");
389
+ return { outcome: "blocked", summary, blockReason: "bridge_unused", questions: [bridgeUnusedHelp(e.engine, diagnostic)], diagnostic };
390
+ }
324
391
  const tail = code !== 0 ? ` (the ${e.engine} CLI exited with code ${code})` : "";
325
- return { outcome: "blocked", summary, blockReason: "incomplete", questions: [`The CLI stopped before marking the form ready for review${tail}. Review the form yourself, or retry.`] };
392
+ return {
393
+ outcome: "blocked",
394
+ summary,
395
+ blockReason: "incomplete",
396
+ questions: [`The CLI stopped before marking the form ready for review${tail}. Review the form yourself, or retry.`],
397
+ diagnostic: this.diagnosticOf(run, code, code !== 0 ? "engine_exited_nonzero" : "bridge_unused"),
398
+ };
326
399
  }
327
400
  activeMs(run) {
328
401
  return run.activeMs + (run.state === "running" ? this.now() - run.activeSince : 0);
@@ -343,6 +416,31 @@ export class LocalApplyRuntime {
343
416
  }
344
417
  return new Promise((done) => run.waiters.push(done));
345
418
  }
419
+ /**
420
+ * Wait for a cloud-brain decision without accepting a model's prose as permission. A directive
421
+ * can be stored before this call reaches its pause (the delivery race), but a normal `resume`
422
+ * can never release this pause.
423
+ */
424
+ supervisorCheckpoint(run, supervisor) {
425
+ const { checkpointId } = supervisor;
426
+ let checkpoint = run.checkpoints.get(checkpointId);
427
+ if (!checkpoint) {
428
+ checkpoint = {};
429
+ run.checkpoints.set(checkpointId, checkpoint);
430
+ }
431
+ if (!checkpoint.checkpoint) {
432
+ checkpoint.checkpoint = supervisor;
433
+ this.emit(run, {
434
+ type: "supervisor.checkpoint",
435
+ ...(supervisor.facts.url ? { url: supervisor.facts.url } : {}),
436
+ ...(supervisor.facts.domain ? { domain: supervisor.facts.domain } : {}),
437
+ detail: { checkpointId, phase: supervisor.facts.phase, actions: supervisor.facts.actions, filled: supervisor.facts.filled, uploaded: supervisor.facts.uploaded },
438
+ });
439
+ }
440
+ if (checkpoint.directive)
441
+ return Promise.resolve(checkpoint.directive);
442
+ return this.pause(run, { reason: "supervisor_checkpoint", checkpoint: supervisor }).then(() => run.checkpoints.get(checkpointId)?.directive ?? "stop");
443
+ }
346
444
  release(run, outcome) {
347
445
  if (run.state === "paused") {
348
446
  run.state = "running";
@@ -360,6 +458,8 @@ export class LocalApplyRuntime {
360
458
  const run = this.get(String(o.runId ?? ""));
361
459
  if (run.state === "ended")
362
460
  throw new RunnerInputError("The run has ended", 409);
461
+ if (run.pause?.reason === "supervisor_checkpoint")
462
+ throw new RunnerInputError("This run is waiting for a persisted supervisor directive, not an owner resume", 409);
363
463
  for (const a of (Array.isArray(o.answers) ? o.answers : []).slice(0, LOCAL_APPLY_CAPS.answers)) {
364
464
  const r = (a && typeof a === "object" ? a : {});
365
465
  if (typeof r.question === "string" && typeof r.answer === "string" && r.answer.trim())
@@ -370,6 +470,41 @@ export class LocalApplyRuntime {
370
470
  this.release(run, "resumed");
371
471
  return this.status({ runId: run.envelope.runId, afterSeq: run.seq });
372
472
  }
473
+ /**
474
+ * Store then apply an immutable cloud directive. The caller persists it before this runner
475
+ * endpoint is reached; this in-process record makes delivery idempotent and closes the race
476
+ * where a directive arrives just before the bridge begins waiting.
477
+ */
478
+ directive(raw) {
479
+ const o = (raw && typeof raw === "object" ? raw : {});
480
+ const run = this.get(typeof o.runId === "string" ? o.runId : "");
481
+ const checkpointId = typeof o.checkpointId === "string" && SAFE_ID.test(o.checkpointId) ? o.checkpointId : null;
482
+ const directive = o.directive === "continue" || o.directive === "request_review" || o.directive === "stop" ? o.directive : null;
483
+ if (o.schemaVersion !== 1 || !checkpointId || !directive)
484
+ throw new RunnerInputError("directive needs schemaVersion: 1, a safe checkpointId, and directive continue, request_review or stop");
485
+ if (run.state === "ended")
486
+ throw new RunnerInputError("The run has ended", 409);
487
+ let checkpoint = run.checkpoints.get(checkpointId);
488
+ if (checkpoint?.directive) {
489
+ if (checkpoint.directive !== directive)
490
+ throw new RunnerInputError("A different directive is already recorded for this checkpoint", 409);
491
+ return this.status({ runId: run.envelope.runId, afterSeq: run.seq });
492
+ }
493
+ checkpoint ??= {};
494
+ checkpoint.directive = directive;
495
+ run.checkpoints.set(checkpointId, checkpoint);
496
+ this.emit(run, { type: "supervisor.directive", detail: { checkpointId, directive } });
497
+ const waitingForThis = run.state === "paused" && run.pause?.reason === "supervisor_checkpoint" && run.pause.checkpoint?.checkpointId === checkpointId;
498
+ if (waitingForThis && directive === "continue")
499
+ this.release(run, "resumed");
500
+ else if (waitingForThis && directive === "request_review") {
501
+ this.end(run, { outcome: "awaiting_review", summary: "The supervisor requested an owner review." });
502
+ }
503
+ else if (waitingForThis && directive === "stop") {
504
+ this.end(run, { outcome: "blocked", blockReason: "incomplete", questions: ["The supervisor stopped this run before it could continue."] });
505
+ }
506
+ return this.status({ runId: run.envelope.runId, afterSeq: run.seq });
507
+ }
373
508
  cancel(raw) {
374
509
  const runId = String(raw?.runId ?? "");
375
510
  const run = this.get(runId);
@@ -456,8 +591,11 @@ export class LocalApplyRuntime {
456
591
  submitAttempted: b?.submitAttempted ?? false,
457
592
  summary: redactText((r.summary ?? "").slice(0, LOCAL_APPLY_CAPS.summary), run.secrets),
458
593
  ...(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)) } : {}),
594
+ ...(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
595
  ...(r.outcome === "failed" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
596
+ // #975 — carried through as the closed vocabulary it is. Nothing here is redacted because
597
+ // nothing here is text: counts, an exit code, and signal ids this platform defines.
598
+ ...(r.diagnostic ? { diagnostic: r.diagnostic } : {}),
461
599
  };
462
600
  if (run.child && run.child.exitCode === null)
463
601
  this.kill(run);
@@ -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.82",
3
+ "version": "0.4.84",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",