@proagentstore/cli 0.4.88 → 0.4.90

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.
@@ -25,6 +25,7 @@
25
25
  * The trace carries classes, domains, decisions and artifact handles — never a typed value.
26
26
  */
27
27
  import { classifyApplyClick } from "./contract.js";
28
+ import { CONFIRMATION_INTERVAL_SECONDS, CONFIRMATION_LOOKS, CONFIRMATION_TEXT_RE, confirmationMarker, observe, } from "./confirmation.js";
28
29
  import { ELEMENT_PROBE_FN } from "../commit-guard.js";
29
30
  import { normalize } from "../local-artifact/engine.js";
30
31
  import { evaluateResult, hostOf, refRole } from "../local-browser/bridge.js";
@@ -58,8 +59,15 @@ const CONSENT_NAME = /\b(agree|consent|terms|privacy|acknowledge|certify|accept|
58
59
  const CREDENTIAL_NAME = /password|passcode|one[- ]?time|\botp\b|verification code|security code|2fa|two[- ]factor/i;
59
60
  /** Keys that move between fields without entering or activating anything. */
60
61
  const SAFE_KEYS = /^(Tab|Shift\+Tab|Arrow(Up|Down|Left|Right)|Escape|Home|End|PageUp|PageDown)$/;
61
- /** Runs inside the page, through the browser's own evaluate tool — never offered to the CLI. */
62
+ /**
63
+ * Runs inside the page, through the browser's own evaluate tool — never offered to the CLI.
64
+ *
65
+ * The success vocabulary is INTERPOLATED from `confirmation.ts` rather than written out here
66
+ * (#994): a second copy of that regex is how the "sent" family — the exact word SEEK uses — was
67
+ * missing from the detector while being present in nobody's tests, through three live runs.
68
+ */
62
69
  const INSPECT_PAGE = `() => {
70
+ const CONFIRMATION_TEXT_SOURCE = ${JSON.stringify(CONFIRMATION_TEXT_RE.source)};
63
71
  const text = (document.body ? document.body.innerText : "").slice(0, 20000).toLowerCase();
64
72
  const has = (s) => !!document.querySelector(s);
65
73
  return {
@@ -70,7 +78,7 @@ const INSPECT_PAGE = `() => {
70
78
  login: has('input[type=password]'),
71
79
  duplicate: /you('ve| have) already applied|already applied (for|to) this|application (already exists|has already been (submitted|received))|duplicate application/.test(text),
72
80
  antiBot: /unusual traffic|access (is )?denied|request (has been )?blocked|bot detected|checking (if the site connection is secure|your browser)/.test(text),
73
- confirmed: /thank you for (your )?appl|application (has been |was )?(received|submitted)|we('ve| have) received your application|successfully (applied|submitted)/.test(text),
81
+ confirmed: new RegExp(CONFIRMATION_TEXT_SOURCE).test(text),
74
82
  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)
75
83
  ? (/expired/.test(text) ? "expired" : "unavailable")
76
84
  : null,
@@ -120,6 +128,8 @@ export class ApplyBridge {
120
128
  /** Present only after the CLI explicitly reports a notice that the runner independently saw. */
121
129
  unavailable = null;
122
130
  submitAttempted = false;
131
+ /** What was seen after the submit click (#994) — read by the runtime to build the diagnostic. */
132
+ confirmation = null;
123
133
  submitted = null;
124
134
  /** The last thing that stopped the work and was not resolved — the block reason if the run ends here. */
125
135
  blocked = null;
@@ -609,17 +619,44 @@ export class ApplyBridge {
609
619
  this.submitAttempted = true;
610
620
  this.host.emit({ type: "submit.attempted", url: before.url, domain, detail: { class: "submit", gateId: this.host.gateId } });
611
621
  const res = await this.browser.callTool("browser_click", forward);
612
- await this.browser.callTool("browser_wait_for", { time: 2 }).catch(() => undefined);
613
- const after = res.isError ? null : await this.inspect();
614
- if (after?.confirmed) {
615
- this.submitted = { url: after.url, at: new Date(this.host.now()).toISOString(), gateId: this.host.gateId };
616
- this.host.emit({ type: "submit.confirmed", url: after.url, domain: hostOf(after.url) ?? domain, detail: { gateId: this.host.gateId } });
622
+ // Look REPEATEDLY, not once (#994). The old path waited two seconds and read the page a
623
+ // single time, so a receipt that arrives on a navigation or a client-side re-render a moment
624
+ // later was simply not there yet when the only look happened. `submitAttempted` is already
625
+ // true above, so waiting longer cannot make a pressed button look unpressed.
626
+ const { after, looks } = res.isError ? { after: null, looks: 0 } : await this.awaitConfirmation(before);
627
+ const seen = observe(before, after, looks);
628
+ this.confirmation = seen;
629
+ const evidence = { looks: seen.looks, urlChanged: seen.urlChanged, titleChanged: seen.titleChanged };
630
+ if (seen.marker) {
631
+ const at = after;
632
+ this.submitted = { url: at.url, at: new Date(this.host.now()).toISOString(), gateId: this.host.gateId };
633
+ this.host.emit({ type: "submit.confirmed", url: at.url, domain: hostOf(at.url) ?? domain, detail: { gateId: this.host.gateId, marker: seen.marker, ...evidence } });
617
634
  return text("Submitted, and the site confirmed it. Stop now.");
618
635
  }
619
636
  this.blocked = { reason: "submit_unconfirmed", questions: ["The application was submitted but the site did not confirm it. Check the employer's site or your email before anything is retried."] };
620
- this.host.emit({ type: "submit.unconfirmed", ...(after ? { url: after.url } : {}), domain, detail: { gateId: this.host.gateId } });
637
+ this.host.emit({ type: "submit.unconfirmed", ...(after ? { url: after.url } : {}), domain, detail: { gateId: this.host.gateId, ...evidence } });
621
638
  return text("The submit was pressed but no confirmation was seen. Do not press it again. Stop now.", true);
622
639
  }
640
+ /**
641
+ * Read the page until it confirms, or until the looks run out (#994).
642
+ *
643
+ * It returns the LAST page it read, not the first: when nothing confirms, the owner's
644
+ * diagnostic should describe where the browser actually ended up. Bounded by
645
+ * `CONFIRMATION_LOOKS` — an application run holds the owner's only browser, so "look again"
646
+ * must never become "sit here".
647
+ */
648
+ async awaitConfirmation(before) {
649
+ let after = null;
650
+ for (let i = 0; i < CONFIRMATION_LOOKS; i++) {
651
+ await this.browser.callTool("browser_wait_for", { time: CONFIRMATION_INTERVAL_SECONDS }).catch(() => undefined);
652
+ const page = await this.inspect();
653
+ if (page)
654
+ after = page;
655
+ if (confirmationMarker(before, page))
656
+ return { after: page, looks: i + 1 };
657
+ }
658
+ return { after, looks: CONFIRMATION_LOOKS };
659
+ }
623
660
  /** A cloud-persisted checkpoint, not a prompt instruction, authorizes each page's writes. */
624
661
  requireSupervisor(tool, actionClass) {
625
662
  if (this.supervisorApproved)
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Did the employer's site confirm the submission? (#994)
3
+ *
4
+ * ── The live failure this module exists for
5
+ *
6
+ * Three distinct approved auto-submit runs on `au.seek.com` reached `submit.attempted` and all
7
+ * three ended terminal `blocked / submit_unconfirmed` with `submittedAt=null`: applications
8
+ * `435d31c8…` (run `a4bf5c0f…`), `71765edd…` (run `76416747…`) and `b6244557…` (run `2f2e88a5…`).
9
+ * The honest safety state was right — nothing was retried, and the owner was told to check the
10
+ * employer's site — but a 3/3 rate says the DETECTOR was wrong, not the sites.
11
+ *
12
+ * Three facts about the old detector, each read off the code rather than guessed at:
13
+ *
14
+ * 1. **The vocabulary had no "sent".** It matched `thank you for (your) appl`,
15
+ * `application (has been|was) (received|submitted)`, `we('ve| have) received your application`
16
+ * and `successfully (applied|submitted)` — and nothing else. SEEK's own post-apply wording is
17
+ * the "sent" family ("Application sent", "Your application has been sent", "You've applied"),
18
+ * so the one word the page actually uses was the one word the regex could not see.
19
+ * 2. **It looked once, 2 seconds after the click** (`browser_wait_for {time: 2}` then one
20
+ * `inspect()`), so a success state that arrives on a navigation or a client-side re-render a
21
+ * moment later was simply not there yet when the only look happened.
22
+ * 3. **Text was the only evidence considered.** The URL the click navigates to, the title, and
23
+ * the site's own "you have already applied" notice were all collected by `INSPECT_PAGE` and
24
+ * none of them counted.
25
+ *
26
+ * What remains UNVERIFIED, and is stated here rather than implied: no live SEEK page was captured
27
+ * while writing this, so which of the three was decisive for those three runs is not established.
28
+ * The tests below use realistic fixtures of the wording SEEK and the big ATSs publish, which the
29
+ * issue explicitly allows; a live capture would still be worth taking.
30
+ *
31
+ * ── What counts as confirmation
32
+ *
33
+ * A named marker, never a feeling. Every marker is an id this platform defines, and only the ID
34
+ * crosses the contract — never the text that matched it. That is the same rule `signalsOf` follows
35
+ * in `runtime.ts` for engine output, and for the same reason: an application page holds the
36
+ * owner's typed answers, and no redaction reliably separates those from the rest of the prose.
37
+ *
38
+ * The URL and duplicate markers are deliberately comparative — they need the page as it was BEFORE
39
+ * the click, which the submit path already has. A `/success` URL that was already there is not
40
+ * evidence of this click; "you have already applied" that was already there is somebody's earlier
41
+ * application, not this one.
42
+ */
43
+ /**
44
+ * The success vocabulary, as ONE source used in two places: this module (tested in Node) and the
45
+ * in-page script (built from `.source`, executed in the browser). Two copies of a regex is how the
46
+ * "sent" family went missing from one of them for three live runs.
47
+ *
48
+ * Lower-cased text is matched, so the patterns carry no case class.
49
+ */
50
+ export const CONFIRMATION_TEXT_RE = /thank you for (your )?appl|application (has been |was )?(received|submitted|sent)|we('ve| have) received your application|successfully (applied|submitted|sent)|your application (is|has been) (on its way|complete)|you(r application)?('ve| have)? (now )?applied|applied (on|to this job)|application (sent|complete|submitted) *[.!]?$/;
51
+ /**
52
+ * A URL that is itself the receipt. Matched against the path and query of the page the click
53
+ * landed on, and only when the click CHANGED it.
54
+ *
55
+ * Kept narrow on purpose: `/apply` alone is the form, not the confirmation, and treating it as one
56
+ * would turn "the click did nothing" into "submitted".
57
+ */
58
+ export const CONFIRMATION_URL_RE = /(^|\/)(apply|application|applications|job)?[^/]*\/(success|succeeded|complete|completed|confirmation|confirmed|submitted|sent|thank-?you|receipt)(\/|$|\?)|[?&](applied|submitted|success)=(1|true|yes)/;
59
+ /** Ordered by how strong the evidence is: the first match is the one recorded. */
60
+ export function confirmationMarker(before, after) {
61
+ if (!after)
62
+ return null;
63
+ if (after.confirmed)
64
+ return "page_text";
65
+ const url = urlPathAndQuery(after.url).toLowerCase();
66
+ if (CONFIRMATION_URL_RE.test(url) && before && urlPathAndQuery(before.url).toLowerCase() !== url)
67
+ return "url_receipt";
68
+ // The site's own duplicate notice, but ONLY if it appeared after this click: "you have already
69
+ // applied" that was on the page beforehand is an earlier application of the owner's, and
70
+ // reading it as this run's success would record a submission that never happened.
71
+ if (after.duplicate && before && !before.duplicate)
72
+ return "already_applied_notice";
73
+ return null;
74
+ }
75
+ /** Path + query only: a host or a fragment is not evidence, and the host is recorded separately. */
76
+ function urlPathAndQuery(raw) {
77
+ try {
78
+ const u = new URL(raw);
79
+ return `${u.pathname}${u.search}`;
80
+ }
81
+ catch {
82
+ return raw;
83
+ }
84
+ }
85
+ /**
86
+ * How long to keep looking, and how often.
87
+ *
88
+ * The old path waited 2s once. These are bounded for the reason every wait in this runner is: an
89
+ * application run holds the owner's only browser, so "look again" must not become "sit here". Six
90
+ * looks over ~9s covers a navigation and a client-side re-render, and stops long before the
91
+ * engine's own minute budget notices.
92
+ */
93
+ export const CONFIRMATION_LOOKS = 6;
94
+ export const CONFIRMATION_INTERVAL_SECONDS = 1.5;
95
+ export function observe(before, after, looks) {
96
+ return {
97
+ marker: confirmationMarker(before, after),
98
+ looks,
99
+ urlChanged: !!after && !!before && after.url !== before.url,
100
+ titleChanged: !!after && !!before && after.title !== before.title,
101
+ unreadable: !after,
102
+ };
103
+ }
104
+ /**
105
+ * The closed-vocabulary signals that go on the run's diagnostic when a submit was pressed and not
106
+ * confirmed, so the owner's record says what was seen rather than only what was not.
107
+ *
108
+ * Each is an id, so nothing here can carry page text; together they distinguish the three cases a
109
+ * person would act on differently: the page never changed (the click may not have landed), the page
110
+ * moved but said nothing this detector knows (a wording to add), and the page could not be read.
111
+ */
112
+ export function confirmationSignals(o) {
113
+ const out = [];
114
+ if (o.unreadable)
115
+ out.push("confirmation_page_unreadable");
116
+ else if (!o.marker)
117
+ out.push("confirmation_no_marker");
118
+ if (o.urlChanged)
119
+ out.push("confirmation_url_changed");
120
+ else if (!o.unreadable)
121
+ out.push("confirmation_url_unchanged");
122
+ if (o.titleChanged)
123
+ out.push("confirmation_title_changed");
124
+ return out;
125
+ }
@@ -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
+ /** Read-only job-page check used before an email lead may enter material preparation. */
35
+ export const LOCAL_APPLY_PREFLIGHT_PATH = "/local-apply/preflight";
34
36
  /** Delivers a cloud-persisted supervisory decision to one paused checkpoint. */
35
37
  export const LOCAL_APPLY_DIRECTIVE_PATH = "/local-apply/directive";
36
38
  export const LOCAL_APPLY_ENGINES = ["claude", "codex"];
@@ -82,31 +84,37 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
82
84
  * dispatch, naming the update, rather than silently producing the older behaviour (`apply.ts`
83
85
  * `runnerContractProblem`).
84
86
  *
85
- * ── Why it moved to 0.4.85 (#989)
87
+ * ── Why it moved to 0.4.90 (#953)
86
88
  *
87
- * #985 fixed the runner's click classification — the press that OPENS a SEEK application stopped
88
- * being read as the final submit ({@link classifyApplyClick}) — and that fix lives in
89
- * `packages/browser-runner`, which ships INSIDE the published CLI. The commit did not bump
90
- * `packages/cli/package.json`, and `publish-npm.yml` publishes only a version that is new, so
91
- * nothing was published: the live machine kept running 0.4.84, whose bundled bridge still tests
92
- * `FALLBACK_COMMIT_RE`. The next live run therefore reproduced the defect the fix had already
93
- * removed from `main` — `browser_click → class: submit → refused: fill_and_review`, no `rule`
94
- * field, three seconds after the cloud's own `continue` — and the only durable evidence that the
95
- * code was old was the absence of a field nobody looks for.
89
+ * #953 adds the read-only `/local-apply/preflight` route used before an email lead can enter
90
+ * tailoring. That route lives in `packages/browser-runner`, which ships INSIDE the published CLI.
91
+ * A machine below this floor cannot make the live/expired/unverifiable observation, so it must
92
+ * not silently look equivalent to a current runner.
96
93
  *
97
94
  * A behaviour change in the runner is a CONTRACT change, because the cloud's decisions assume it.
98
- * So this floor moves with it: a machine that cannot tell the entry control from the final submit
99
- * is refused with a sentence that says so, instead of ending `awaiting_review` having typed
100
- * nothing.
95
+ * So this floor moves with it: a machine that cannot observe a post-submit receipt is refused
96
+ * with a sentence that says so, rather than reporting an unhelpful `submit_unconfirmed` state.
101
97
  *
102
98
  * Bump this when the runner's half of the contract changes again, together with the CLI version
103
99
  * that ships it — `policy.test.ts` pins the pair, so the floor cannot name a release that has not
104
100
  * been published. `cliAtLeast` treats an unreported version as capable, which is the convention
105
101
  * every MIN_CLI gate here follows.
106
102
  */
107
- export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.85";
108
- export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output"];
109
- export const LOCAL_APPLY_SIGNALS = ["approval_policy_blocked", "bridge_tools_missing", "auth_prompt", "no_output", "engine_refused_task"];
103
+ export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.90";
104
+ export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output", "submit_unconfirmed"];
105
+ export const LOCAL_APPLY_CONFIRMATION_MARKERS = ["page_text", "url_receipt", "already_applied_notice"];
106
+ export const LOCAL_APPLY_SIGNALS = [
107
+ "approval_policy_blocked",
108
+ "bridge_tools_missing",
109
+ "auth_prompt",
110
+ "no_output",
111
+ "engine_refused_task",
112
+ "confirmation_no_marker",
113
+ "confirmation_page_unreadable",
114
+ "confirmation_url_changed",
115
+ "confirmation_url_unchanged",
116
+ "confirmation_title_changed",
117
+ ];
110
118
  export const LOCAL_APPLY_UNAVAILABLE_REASONS = ["expired", "unavailable"];
111
119
  export const LOCAL_APPLY_EVENT_TYPES = [
112
120
  "engine.auth_checked",
@@ -140,7 +148,11 @@ const isHttpUrl = (v) => /^https?:\/\/[^\s/]+/i.test(v);
140
148
  // submit since #985, and this whitelist silently dropped it — so the one fact that tells "it
141
149
  // refused the control that opens the form" from "it refused the real submit" never reached the
142
150
  // cloud trace, which is where the live investigation needed it.
143
- const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "mode", "class", "tool", "decision", "reason", "rule", "role", "kind", "path", "sha256", "bytes", "gateId", "exitCode", "count", "status", "basis", "source", "checkpointId", "directive", "phase", "actions", "filled", "uploaded"]);
151
+ // `marker`, `looks`, `urlChanged` and `titleChanged` are #994's post-click evidence: a marker ID
152
+ // from `LOCAL_APPLY_CONFIRMATION_MARKERS`, how many times the page was read, and whether it moved.
153
+ // Each is an id, a count or a boolean — the same whitelist rule as everything else here, so no page
154
+ // text and no typed value can ride along on a `submit.confirmed` / `submit.unconfirmed` event.
155
+ const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "mode", "class", "tool", "decision", "reason", "rule", "role", "kind", "path", "sha256", "bytes", "gateId", "exitCode", "count", "status", "basis", "source", "checkpointId", "directive", "phase", "actions", "filled", "uploaded", "marker", "looks", "urlChanged", "titleChanged"]);
144
156
  /** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
145
157
  export function parseLocalApplyEvent(raw) {
146
158
  if (!raw || typeof raw !== "object")
@@ -167,6 +179,15 @@ export function parseLocalApplyEvent(raw) {
167
179
  for (const [k, v] of Object.entries(o.detail)) {
168
180
  if (!DETAIL_KEYS.has(k))
169
181
  continue;
182
+ // #994: `marker` is a runner-defined confirmation ID, never the success sentence the
183
+ // page displayed. Keep the closed vocabulary closed even if a compromised/outdated
184
+ // runner tries to use this otherwise-string field as a prose channel.
185
+ if (k === "marker") {
186
+ const marker = oneOf(LOCAL_APPLY_CONFIRMATION_MARKERS, v);
187
+ if (marker)
188
+ detail[k] = marker;
189
+ continue;
190
+ }
170
191
  if (typeof v === "string")
171
192
  detail[k] = v.slice(0, 300);
172
193
  else if (typeof v === "number" || typeof v === "boolean")
@@ -15,12 +15,13 @@
15
15
  * - the result: `awaiting_review`, `submitted` (only with the site's confirmation), `blocked`
16
16
  * with the reason, or `failed`.
17
17
  *
18
- * Runs live in memory: a runner restart loses them, and `status` answers 404, which PAGS reads as
19
- * "the runner lost this run".
18
+ * Active runs live in memory: a runner restart loses them, and `status` answers 404, which PAGS
19
+ * reads as "the runner lost this run". Terminal typed/redacted results are journaled locally so a
20
+ * restart after completion does not erase observed evidence before PAGS pulls it.
20
21
  */
21
22
  import { spawn as nodeSpawn } from "node:child_process";
22
23
  import { createHash, randomBytes } from "node:crypto";
23
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
24
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
24
25
  import { homedir } from "node:os";
25
26
  import { join, resolve, sep } from "node:path";
26
27
  import { fileURLToPath } from "node:url";
@@ -33,7 +34,8 @@ import { redactText, secretEnvValues } from "../local-browser/contract.js";
33
34
  import { finalText, missingLogin, signInHelp } from "../local-browser/engine.js";
34
35
  import { resolveWorkspacePath } from "../local-browser/runtime.js";
35
36
  import { ApplyBridge } from "./bridge.js";
36
- import { LOCAL_APPLY_AUTH_MODES, LOCAL_APPLY_CAPS, LOCAL_APPLY_ENGINES, LOCAL_APPLY_MODES, LOCAL_APPLY_TASK_TYPE, } from "./contract.js";
37
+ import { confirmationSignals } from "./confirmation.js";
38
+ import { LOCAL_APPLY_AUTH_MODES, LOCAL_APPLY_CAPS, LOCAL_APPLY_ENGINES, LOCAL_APPLY_MODES, LOCAL_APPLY_TASK_TYPE, parseLocalApplyResult, } from "./contract.js";
37
39
  import { applyPrompt, buildApplyEngineSpec, observedApplyAuth } from "./engine.js";
38
40
  const MAX_EVENTS = 1000;
39
41
  const OUTPUT_LINES = 400;
@@ -181,6 +183,104 @@ export class LocalApplyRuntime {
181
183
  this.now = deps.now ?? Date.now;
182
184
  this.spawn = deps.spawn ?? nodeSpawn;
183
185
  this.retentionMs = deps.retentionMs ?? 24 * 60 * 60 * 1000;
186
+ this.hydrateTerminalJournals();
187
+ }
188
+ /**
189
+ * Read a specific job page before an email alert can cause any materials to be generated.
190
+ * This deliberately creates no application run, launches no CLI, and never clicks/fills: the
191
+ * only evidence it returns is a closed, redacted vocabulary the API can durably record.
192
+ */
193
+ async preflight(raw) {
194
+ const input = raw && typeof raw === "object" ? raw : {};
195
+ const jobUrl = typeof input.jobUrl === "string" ? input.jobUrl.trim() : "";
196
+ let parsed;
197
+ try {
198
+ parsed = new URL(jobUrl);
199
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
200
+ throw new Error("scheme");
201
+ }
202
+ catch {
203
+ return { state: "unverifiable", jobUrl, reason: "navigation_failed" };
204
+ }
205
+ const profile = input.browserProfile === "default" ? "default" : "isolated";
206
+ const dir = join(this.root, "preflight", randomBytes(12).toString("hex"));
207
+ let browser = null;
208
+ try {
209
+ browser = await this.deps.browserFor(profile, dir);
210
+ const navigated = await browser.tools.callTool("browser_navigate", { url: parsed.toString() });
211
+ if (navigated.isError)
212
+ return { state: "unverifiable", jobUrl: parsed.toString(), reason: "navigation_failed" };
213
+ const snapshot = await browser.tools.callTool("browser_snapshot");
214
+ const page = (snapshot.content ?? []).map((part) => part.text ?? "").join("\n");
215
+ if (snapshot.isError || /captcha|verify (?:you(?:'| a)re|that you are) human|access denied|unusual traffic|checking (?:if )?your browser/i.test(page)) {
216
+ return { state: "unverifiable", jobUrl: parsed.toString(), reason: "access_blocked" };
217
+ }
218
+ if (/(?: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)/i.test(page)) {
219
+ return { state: "unavailable", jobUrl: parsed.toString(), reason: /expired/i.test(page) ? "expired" : "unavailable" };
220
+ }
221
+ // An accessible Apply control is an active path, unlike a generic careers/search URL.
222
+ if (/(?:button|link)\s+"[^"]{0,80}\bapply(?: now| for| here)?\b|\bapply (?:now|for (?:this )?(?:job|role|position))\b|\bstart application\b/i.test(page)) {
223
+ return { state: "live", jobUrl: parsed.toString(), applyUrl: parsed.toString(), evidence: "apply_control_present" };
224
+ }
225
+ return { state: "unverifiable", jobUrl: parsed.toString(), reason: "no_active_apply_path" };
226
+ }
227
+ catch {
228
+ return { state: "unverifiable", jobUrl: parsed.toString(), reason: "navigation_failed" };
229
+ }
230
+ finally {
231
+ await browser?.stop().catch(() => undefined);
232
+ }
233
+ }
234
+ journalPath(dir) {
235
+ return join(dir, "terminal-result.json");
236
+ }
237
+ /** Write-before-return: a terminal result is durable before `status` can expose it. */
238
+ persistTerminal(run) {
239
+ if (!run.result || !run.endedAt)
240
+ return;
241
+ const journal = { version: 1, envelope: run.envelope, events: run.events, seq: run.seq, result: run.result, endedAt: run.endedAt };
242
+ const path = this.journalPath(run.dir);
243
+ const tmp = `${path}.${randomBytes(8).toString("hex")}.tmp`;
244
+ writeFileSync(tmp, JSON.stringify(journal), { mode: 0o600 });
245
+ renameSync(tmp, path);
246
+ }
247
+ /** Hydrate terminal-only records. Interrupted runs remain deliberately unrecoverable. */
248
+ hydrateTerminalJournals() {
249
+ if (!existsSync(this.root))
250
+ return;
251
+ for (const instance of readdirSync(this.root)) {
252
+ const instanceDir = join(this.root, instance);
253
+ let runIds;
254
+ try {
255
+ runIds = readdirSync(instanceDir);
256
+ }
257
+ catch {
258
+ continue;
259
+ }
260
+ for (const runId of runIds) {
261
+ const dir = join(instanceDir, runId);
262
+ const path = this.journalPath(dir);
263
+ if (!existsSync(path))
264
+ continue;
265
+ try {
266
+ const raw = JSON.parse(readFileSync(path, "utf8"));
267
+ if (raw.version !== 1 || !raw.envelope || !raw.result || !Array.isArray(raw.events) || typeof raw.seq !== "number" || typeof raw.endedAt !== "number")
268
+ continue;
269
+ const envelope = parseApplyEnvelope(raw.envelope);
270
+ const parsed = parseLocalApplyResult(raw.result);
271
+ if ("error" in parsed || parsed.result.runId !== envelope.runId)
272
+ continue;
273
+ const checked = parsed.result;
274
+ this.runs.set(envelope.runId, {
275
+ envelope, dir, token: "", state: "ended", events: raw.events, seq: raw.seq,
276
+ result: checked, allow: new Set(envelope.policy.allowDomains), grounding: [], secrets: [], activeSince: raw.endedAt,
277
+ activeMs: 0, waiters: [], output: [], engineAuth: checked.engineAuth, cancelled: false, timedOut: false,
278
+ checkpoints: new Map(), endedAt: raw.endedAt,
279
+ });
280
+ }
281
+ catch { /* a corrupt local cache is never an authority */ }
282
+ }
283
+ }
184
284
  }
185
285
  start(raw) {
186
286
  const envelope = parseApplyEnvelope(raw);
@@ -372,8 +472,20 @@ export class LocalApplyRuntime {
372
472
  return { outcome: "submitted", summary, submitted: b.submitted };
373
473
  if (b?.unavailable)
374
474
  return { outcome: "blocked", summary, blockReason: "job_unavailable", unavailable: b.unavailable };
375
- if (b?.submitAttempted)
376
- return { outcome: "blocked", summary, blockReason: "submit_unconfirmed", questions: b.blocked?.questions ?? [] };
475
+ // #994: a pressed submit nobody confirmed now SAYS what was seen. The outcome is unchanged —
476
+ // terminal, no retry, the owner told to check the employer's site — because that safety is
477
+ // correct; what was missing was any way to tell "the click never moved the page" from "the
478
+ // page moved and said something this platform does not recognise yet".
479
+ if (b?.submitAttempted) {
480
+ const d = this.diagnosticOf(run, code, "submit_unconfirmed");
481
+ return {
482
+ outcome: "blocked",
483
+ summary,
484
+ blockReason: "submit_unconfirmed",
485
+ questions: b.blocked?.questions ?? [],
486
+ diagnostic: { ...d, signals: [...d.signals, ...(b.confirmation ? confirmationSignals(b.confirmation) : [])] },
487
+ };
488
+ }
377
489
  if (b?.reviewReady)
378
490
  return { outcome: "awaiting_review", summary };
379
491
  if (!b?.filled && missingLogin(e.engine, run.output.join("\n")))
@@ -601,6 +713,9 @@ export class LocalApplyRuntime {
601
713
  // nothing here is text: counts, an exit code, and signal ids this platform defines.
602
714
  ...(r.diagnostic ? { diagnostic: r.diagnostic } : {}),
603
715
  };
716
+ // This must happen before stopping the browser or exposing `ended`: a process restart in the
717
+ // narrow handoff window must not turn an observed result into `runner_lost` in the cloud.
718
+ this.persistTerminal(run);
604
719
  if (run.child && run.child.exitCode === null)
605
720
  this.kill(run);
606
721
  void run.browser?.stop().catch(() => undefined);
@@ -32,6 +32,7 @@ export const LOCAL_ARTIFACT_BLOCK_REASONS = [
32
32
  "workspace_unavailable",
33
33
  "missing_information",
34
34
  "uncertain_claim",
35
+ "invalid_cli_output",
35
36
  "engine_not_signed_in",
36
37
  "api_key_refused",
37
38
  ];
@@ -48,6 +49,7 @@ export const LOCAL_ARTIFACT_EVENT_TYPES = [
48
49
  "note",
49
50
  ];
50
51
  export const LOCAL_ARTIFACT_ENGINE_AUTHS = ["machine-login", "subscription", "api-key", "missing_login", "unknown"];
52
+ export const LOCAL_ARTIFACT_PARSE_ATTEMPTS = ["fenced_json", "balanced_object"];
51
53
  export const LOCAL_ARTIFACT_CAPS = { questions: 20, questionChars: 300, sources: 6, sourceBytes: 256 * 1024 };
52
54
  /** An id used as a folder name: no separators, no dots, nothing a path could be built out of. */
53
55
  export const PATH_SEGMENT = /^[A-Za-z0-9_-]{1,100}$/;
@@ -88,6 +90,7 @@ export function parseLocalArtifactLead(raw) {
88
90
  const leadUrl = str(o.leadUrl, 2000) ?? "";
89
91
  if (leadUrl && !/^https?:\/\/[^\s/]+/i.test(leadUrl))
90
92
  return { error: "the lead's URL is not an http(s) URL" };
93
+ const workKey = str(o.workKey, 600);
91
94
  const requestedAt = str(o.requestedAt, 40);
92
95
  if (!requestedAt || Number.isNaN(Date.parse(requestedAt)))
93
96
  return { error: "the lead has no requestedAt time" };
@@ -101,9 +104,9 @@ export function parseLocalArtifactLead(raw) {
101
104
  if (v)
102
105
  lead[f] = v;
103
106
  }
104
- return { lead: { eventId, sourceInstanceId, leadId, leadUrl, lifecycleVersion, requestedAt, lead } };
107
+ return { lead: { eventId, sourceInstanceId, leadId, leadUrl, ...(workKey ? { workKey } : {}), lifecycleVersion, requestedAt, lead } };
105
108
  }
106
- const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "role", "path", "sha256", "bytes", "kind", "total", "unmatched", "reason", "exitCode", "count", "removed", "status"]);
109
+ const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "role", "path", "sha256", "bytes", "kind", "total", "unmatched", "reason", "exitCode", "count", "removed", "status", "validationError", "parseAttempts", "rawOutputChars", "runId", "attemptNumber"]);
107
110
  /** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
108
111
  export function parseLocalArtifactEvent(raw) {
109
112
  if (!raw || typeof raw !== "object")
@@ -181,6 +184,25 @@ export function parseLocalArtifactResult(raw) {
181
184
  .filter((q) => typeof q === "string" && q.trim() !== "")
182
185
  .slice(0, LOCAL_ARTIFACT_CAPS.questions)
183
186
  .map((q) => q.trim().slice(0, LOCAL_ARTIFACT_CAPS.questionChars));
187
+ const diagnostic = o.diagnostic && typeof o.diagnostic === "object" ? o.diagnostic : null;
188
+ const validationError = diagnostic?.validationError;
189
+ if (diagnostic && (validationError === "no_json_object" || validationError === "invalid_json" || validationError === "not_draft_object" || validationError === "incomplete_draft" || validationError === "unverified_claim")) {
190
+ const parseAttempts = [];
191
+ if (Array.isArray(diagnostic.parseAttempts)) {
192
+ for (let index = 0; index < diagnostic.parseAttempts.length && index < 8; index += 1) {
193
+ const parseAttempt = oneOf(LOCAL_ARTIFACT_PARSE_ATTEMPTS, diagnostic.parseAttempts[index]);
194
+ if (parseAttempt && !parseAttempts.includes(parseAttempt))
195
+ parseAttempts.push(parseAttempt);
196
+ }
197
+ }
198
+ out.diagnostic = {
199
+ validationError,
200
+ parseAttempts,
201
+ rawOutputChars: typeof diagnostic.rawOutputChars === "number" ? Math.max(0, Math.min(diagnostic.rawOutputChars, 200_000)) : 0,
202
+ runId: str(diagnostic.runId, 100) ?? runId,
203
+ attemptNumber: typeof diagnostic.attemptNumber === "number" && Number.isInteger(diagnostic.attemptNumber) && diagnostic.attemptNumber > 0 ? diagnostic.attemptNumber : 1,
204
+ };
205
+ }
184
206
  }
185
207
  if (outcome === "failed")
186
208
  out.error = str(o.error, 1000) ?? "The run failed without a reason.";
@@ -19,8 +19,8 @@
19
19
  */
20
20
  import { mergeEnv } from "../coding/engine-env.js";
21
21
  import { resolveEngineAuth } from "../coding/engine-auth.js";
22
- import { finalText, missingLogin } from "../local-browser/engine.js";
23
- export { finalText, missingLogin };
22
+ import { finalText as finalTextFromEngine, missingLogin } from "../local-browser/engine.js";
23
+ export { missingLogin };
24
24
  const PROVIDER_KEYS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
25
25
  const SUBSCRIPTION_TOKEN = "CLAUDE_CODE_OAUTH_TOKEN";
26
26
  /** The engine's environment: the machine's, never with a provider API key. */
@@ -84,19 +84,64 @@ export function tailorPrompt(lead, sources) {
84
84
  '{"status":"ready","resume_markdown":"…","cover_letter_markdown":"…","claims":[{"text":"…","source":"resume","quote":"…"}]}',
85
85
  ].join("\n");
86
86
  }
87
- /** The CLI's answer: the first JSON object in its final text, fences and prose around it ignored. */
88
- export function parseDraft(text) {
89
- const start = text.indexOf("{");
90
- const end = text.lastIndexOf("}");
91
- if (start < 0 || end <= start)
92
- return null;
93
- try {
94
- const v = JSON.parse(text.slice(start, end + 1));
95
- return v && typeof v === "object" && !Array.isArray(v) ? v : null;
87
+ /** JSON objects from fenced blocks or prose, while respecting braces inside JSON strings. */
88
+ function jsonCandidates(text) {
89
+ const candidates = [];
90
+ for (const match of text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi))
91
+ candidates.push({ source: "fenced_json", text: match[1].trim() });
92
+ for (let start = 0; start < text.length; start++) {
93
+ if (text[start] !== "{")
94
+ continue;
95
+ let quoted = false, escaped = false, depth = 0;
96
+ for (let end = start; end < text.length; end++) {
97
+ const ch = text[end];
98
+ if (quoted) {
99
+ if (escaped)
100
+ escaped = false;
101
+ else if (ch === "\\")
102
+ escaped = true;
103
+ else if (ch === '"')
104
+ quoted = false;
105
+ continue;
106
+ }
107
+ if (ch === '"')
108
+ quoted = true;
109
+ else if (ch === "{")
110
+ depth++;
111
+ else if (ch === "}" && --depth === 0) {
112
+ candidates.push({ source: "balanced_object", text: text.slice(start, end + 1) });
113
+ break;
114
+ }
115
+ }
96
116
  }
97
- catch {
98
- return null;
117
+ return candidates;
118
+ }
119
+ /** Prefer the structured closing message, but tolerate a CLI that printed a JSON object directly.
120
+ * Non-JSON output must stay empty so login and process-failure handling remains fail-closed. */
121
+ export function finalText(engine, lines) {
122
+ const structured = finalTextFromEngine(engine, lines);
123
+ if (structured)
124
+ return structured;
125
+ const raw = lines.join("\n");
126
+ return jsonCandidates(raw).length ? raw : "";
127
+ }
128
+ /** The CLI's answer, with a closed diagnostic instead of a generic parsing collapse. */
129
+ export function parseDraft(text) {
130
+ const candidates = jsonCandidates(text);
131
+ if (!candidates.length)
132
+ return { draft: null, validationError: "no_json_object", parseAttempts: ["fenced_json", "balanced_object"] };
133
+ let invalid = false;
134
+ for (const candidate of candidates) {
135
+ try {
136
+ const parsed = JSON.parse(candidate.text);
137
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "status" in parsed)
138
+ return { draft: parsed, parseAttempts: [candidate.source] };
139
+ }
140
+ catch {
141
+ invalid = true;
142
+ }
99
143
  }
144
+ return { draft: null, validationError: invalid ? "invalid_json" : "not_draft_object", parseAttempts: [...new Set(candidates.map((c) => c.source))] };
100
145
  }
101
146
  /** Case, whitespace, quote-style and dash-style insensitive — so a faithful quote is never failed on typography. */
102
147
  export function normalize(s) {
@@ -117,18 +162,19 @@ const digits = (s) => s.replace(/\D/g, "");
117
162
  * Check the CLI's draft against the sources. Questions name WHAT to confirm, bounded, and are
118
163
  * returned only to the owner's own record — never to a trace.
119
164
  */
120
- export function checkDraft(draft, sources) {
165
+ export function checkDraft(parsed, sources) {
166
+ const draft = parsed.draft;
121
167
  if (!draft)
122
- return { ok: false, reason: "uncertain_claim", questions: ["The CLI did not return a readable draft. Run it again, or check the source files."], claims: 0, unmatched: 0 };
168
+ return { ok: false, reason: "invalid_cli_output", questions: [`The CLI did not return a readable application JSON object (${parsed.validationError ?? "unknown"}). Retry tailoring after checking the CLI output format.`], claims: 0, unmatched: 0, validationError: parsed.validationError ?? "no_json_object", parseAttempts: parsed.parseAttempts };
123
169
  if (draft.status === "needs_human") {
124
170
  const qs = (Array.isArray(draft.questions) ? draft.questions : []).filter((q) => typeof q === "string" && q.trim() !== "").map((q) => q.trim());
125
- return { ok: false, reason: "missing_information", questions: qs.length ? qs : ["The CLI needs more information but did not say what."], claims: 0, unmatched: 0 };
171
+ return { ok: false, reason: "missing_information", questions: qs.length ? qs : ["The CLI needs more information but did not say what."], claims: 0, unmatched: 0, validationError: "incomplete_draft", parseAttempts: parsed.parseAttempts };
126
172
  }
127
173
  const resume = typeof draft.resume_markdown === "string" ? draft.resume_markdown.trim() : "";
128
174
  const coverLetter = typeof draft.cover_letter_markdown === "string" ? draft.cover_letter_markdown.trim() : "";
129
175
  const claims = Array.isArray(draft.claims) ? draft.claims : [];
130
176
  if (draft.status !== "ready" || !resume || !coverLetter || !claims.length) {
131
- return { ok: false, reason: "uncertain_claim", questions: ["The draft was incomplete (missing a document or its source citations). Run it again."], claims: claims.length, unmatched: 0 };
177
+ return { ok: false, reason: "invalid_cli_output", questions: ["The CLI JSON was incomplete (missing a document or its source citations). Run it again."], claims: claims.length, unmatched: 0, validationError: "incomplete_draft", parseAttempts: parsed.parseAttempts };
132
178
  }
133
179
  const byRole = new Map(sources.map((s) => [s.role, normalize(s.text)]));
134
180
  const all = normalize(sources.map((s) => s.text).join("\n"));
@@ -161,6 +207,6 @@ export function checkDraft(draft, sources) {
161
207
  if (digits(p).length >= 9 && !sourceDigits.includes(digits(p)))
162
208
  flag("Confirm the phone number in the draft — it does not appear in your sources.");
163
209
  if (unmatched)
164
- return { ok: false, reason: "uncertain_claim", questions, claims: claims.length, unmatched };
210
+ return { ok: false, reason: "uncertain_claim", questions, claims: claims.length, unmatched, validationError: "unverified_claim", parseAttempts: parsed.parseAttempts };
165
211
  return { ok: true, resume, coverLetter, claims: claims.length };
166
212
  }
@@ -273,10 +273,19 @@ export class LocalArtifactRuntime {
273
273
  return;
274
274
  }
275
275
  const check = checkDraft(parseDraft(text), ctx.texts);
276
- this.emit(run, { type: "claims.checked", detail: { total: check.claims, unmatched: check.ok ? 0 : check.unmatched } });
276
+ const retry = /:retry:(\d+)$/.exec(e.requestId);
277
+ const attemptNumber = retry ? Number(retry[1]) : 1;
278
+ const diagnostic = check.ok ? undefined : {
279
+ validationError: check.validationError,
280
+ parseAttempts: check.parseAttempts,
281
+ rawOutputChars: text.length,
282
+ runId: e.runId,
283
+ attemptNumber,
284
+ };
285
+ this.emit(run, { type: "claims.checked", detail: { total: check.claims, unmatched: check.ok ? 0 : check.unmatched, ...(diagnostic ? { validationError: diagnostic.validationError, parseAttempts: diagnostic.parseAttempts.join(","), rawOutputChars: diagnostic.rawOutputChars, runId: diagnostic.runId, attemptNumber } : {}) } });
277
286
  if (!check.ok) {
278
287
  this.removeIfEmpty(ctx.outDir);
279
- this.pauseEnd(run, check.reason, check.questions);
288
+ this.pauseEnd(run, check.reason, check.questions, diagnostic);
280
289
  return;
281
290
  }
282
291
  const at = new Date(this.now()).toISOString();
@@ -340,9 +349,9 @@ export class LocalArtifactRuntime {
340
349
  return;
341
350
  run.events.push({ ...ev, at: new Date(this.now()).toISOString(), seq: ++run.seq });
342
351
  }
343
- pauseEnd(run, reason, questions) {
344
- this.emit(run, { type: "run.needs_human", detail: { reason, count: questions.length } });
345
- this.end(run, { outcome: "needs_human", blockReason: reason, questions });
352
+ pauseEnd(run, reason, questions, diagnostic) {
353
+ this.emit(run, { type: "run.needs_human", detail: { reason, count: questions.length, ...(diagnostic ? { validationError: diagnostic.validationError, parseAttempts: diagnostic.parseAttempts.join(","), rawOutputChars: diagnostic.rawOutputChars, runId: diagnostic.runId, attemptNumber: diagnostic.attemptNumber } : {}) } });
354
+ this.end(run, { outcome: "needs_human", blockReason: reason, questions, diagnostic });
346
355
  }
347
356
  end(run, r) {
348
357
  if (run.state === "ended")
@@ -360,7 +369,7 @@ export class LocalArtifactRuntime {
360
369
  engineAuth: run.engineAuth,
361
370
  traceId: run.envelope.runId,
362
371
  ...(r.generatedAt ? { generatedAt: r.generatedAt } : {}),
363
- ...(r.outcome === "needs_human" ? { blockReason: r.blockReason, questions: (r.questions ?? []).map((q) => redactText(q, run.secrets)) } : {}),
372
+ ...(r.outcome === "needs_human" ? { blockReason: r.blockReason, questions: (r.questions ?? []).map((q) => redactText(q, run.secrets)), ...(r.diagnostic ? { diagnostic: r.diagnostic } : {}) } : {}),
364
373
  // CLI output becomes this text, and a CLI prints whatever it was given: redacted before it is kept.
365
374
  ...(r.outcome === "failed" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
366
375
  };
@@ -226,6 +226,10 @@ async function route(runner, req, res) {
226
226
  return json(res, 200, runner.localArtifact.cancel(await readJson(req)));
227
227
  }
228
228
  // Local application execution (#957) — run, status, resume, cancel; PULL like research.
229
+ // The email-lead preflight is read-only and precedes Tailor material generation (#953).
230
+ if (req.method === "POST" && path === "/local-apply/preflight") {
231
+ return json(res, 200, await runner.localApply.preflight(await readJson(req)));
232
+ }
229
233
  if (req.method === "POST" && path === "/local-apply/run") {
230
234
  return json(res, 202, runner.localApply.start(await readJson(req)));
231
235
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.88",
3
+ "version": "0.4.90",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",