@proagentstore/cli 0.4.96 → 0.4.98

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.
@@ -46,6 +46,18 @@ export const LOCAL_APPLY_HANDOFF_FRAME_PATH = "/local-apply/handoff/frame";
46
46
  export const LOCAL_APPLY_HANDOFF_INPUT_PATH = "/local-apply/handoff/input";
47
47
  export const LOCAL_APPLY_HANDOFF_RESUME_PATH = "/local-apply/handoff/resume";
48
48
  export const LOCAL_APPLY_HANDOFF_END_PATH = "/local-apply/handoff/end";
49
+ /**
50
+ * A separate, browser-only receipt/history check for an already-ended uncertain attempt. This is
51
+ * deliberately not an application run: it receives no engine, sources, artifacts or submit gate.
52
+ */
53
+ export const LOCAL_APPLY_RECONCILIATION_RUN_PATH = "/local-apply/reconciliation/run";
54
+ export const LOCAL_APPLY_RECONCILIATION_STATUS_PATH = "/local-apply/reconciliation/status";
55
+ export const LOCAL_APPLY_RECONCILIATION_HANDOFF_PATH = "/local-apply/reconciliation/handoff";
56
+ export const LOCAL_APPLY_RECONCILIATION_HANDOFF_STATUS_PATH = "/local-apply/reconciliation/handoff-status";
57
+ export const LOCAL_APPLY_RECONCILIATION_HANDOFF_FRAME_PATH = "/local-apply/reconciliation/handoff/frame";
58
+ export const LOCAL_APPLY_RECONCILIATION_HANDOFF_INPUT_PATH = "/local-apply/reconciliation/handoff/input";
59
+ export const LOCAL_APPLY_RECONCILIATION_HANDOFF_RESUME_PATH = "/local-apply/reconciliation/handoff/resume";
60
+ export const LOCAL_APPLY_RECONCILIATION_HANDOFF_END_PATH = "/local-apply/reconciliation/handoff/end";
49
61
  export const LOCAL_APPLY_ENGINES = ["claude", "codex"];
50
62
  export const LOCAL_APPLY_AUTH_MODES = ["machine", "subscription"];
51
63
  export const LOCAL_APPLY_MODES = ["fill_and_review", "auto_submit"];
@@ -95,12 +107,12 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
95
107
  * dispatch, naming the update, rather than silently producing the older behaviour (`apply.ts`
96
108
  * `runnerContractProblem`).
97
109
  *
98
- * ── Why it moved to 0.4.96 (#1013)
110
+ * ── Why it moved to 0.4.97 (#1013)
99
111
  *
100
- * #953 adds the read-only `/local-apply/preflight` route used before an email lead can enter
101
- * tailoring. That route lives in `packages/browser-runner`, which ships INSIDE the published CLI.
102
- * A machine below this floor cannot make the live/expired/unverifiable observation, so it must
103
- * not silently look equivalent to a current runner.
112
+ * #1013 adds the exact isolated-run browser handoff used when a sign-in or CAPTCHA blocks an
113
+ * application. That route lives in `packages/browser-runner`, which ships INSIDE the published
114
+ * CLI. A machine below this floor cannot retain the actual isolated page and must not silently
115
+ * substitute the shared/default browser page.
104
116
  *
105
117
  * A behaviour change in the runner is a CONTRACT change, because the cloud's decisions assume it.
106
118
  * So this floor moves with it: #1013's exact-page login/CAPTCHA handoff and post-submit receipt
@@ -111,7 +123,7 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
111
123
  * been published. `cliAtLeast` treats an unreported version as capable, which is the convention
112
124
  * every MIN_CLI gate here follows.
113
125
  */
114
- export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.96";
126
+ export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.97";
115
127
  export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output", "submit_unconfirmed"];
116
128
  export const LOCAL_APPLY_CONFIRMATION_MARKERS = ["page_text", "url_receipt", "already_applied_notice"];
117
129
  export const LOCAL_APPLY_SIGNALS = [
@@ -0,0 +1,101 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { McpRuntime } from "../mcp-runtime.js";
4
+ /** Bind application handoff to the run browser, not to the runner-wide active page. */
5
+ export function createLocalApplyBrowserFactory(options) {
6
+ return async (profile, runDir) => {
7
+ if (profile === "isolated")
8
+ return createIsolatedApplyBrowser(runDir, options.headless);
9
+ const browser = await options.defaultBrowser(profile, runDir);
10
+ return { ...browser, handoffPage: options.sharedPage };
11
+ };
12
+ }
13
+ /**
14
+ * Keep local-apply's browser construction out of `runner.ts`: research uses the generic factory,
15
+ * while application runs get a page-owning isolated context for the scoped handoff.
16
+ */
17
+ export function createRunnerBrowserFactories(options) {
18
+ const browserFor = async (profile, runDir) => {
19
+ if (profile === "default")
20
+ return { tools: await options.defaultTools(), stop: async () => undefined };
21
+ const mcp = new McpRuntime();
22
+ await mcp.start({ userDataDir: join(runDir, "profile"), headless: options.headless });
23
+ return { tools: mcp, stop: () => mcp.stop() };
24
+ };
25
+ return { browserFor, applyBrowserFor: createLocalApplyBrowserFactory({ headless: options.headless, defaultBrowser: browserFor, sharedPage: options.sharedPage }) };
26
+ }
27
+ /**
28
+ * Start the disposable browser context for one application run and retain only its current live
29
+ * page for the bounded takeover adapter. This deliberately does not seed, copy, or persist an
30
+ * owner profile: the profile remains the run's existing throwaway `runDir/profile` directory.
31
+ */
32
+ export async function createIsolatedApplyBrowser(runDir, headless) {
33
+ const playwright = await import("playwright");
34
+ const profileDir = join(runDir, "profile");
35
+ let context;
36
+ let mcp;
37
+ let activePage = null;
38
+ let stopped = false;
39
+ try {
40
+ context = await playwright.chromium.launchPersistentContext(profileDir, {
41
+ headless,
42
+ acceptDownloads: true,
43
+ viewport: null,
44
+ args: [
45
+ "--disable-blink-features=AutomationControlled",
46
+ "--start-maximized",
47
+ "--window-size=1512,982",
48
+ "--remote-debugging-port=0",
49
+ "--use-fake-ui-for-media-stream",
50
+ "--use-fake-device-for-media-stream",
51
+ ],
52
+ });
53
+ await context.addInitScript(() => {
54
+ Object.defineProperty(navigator, "webdriver", { get: () => undefined });
55
+ }).catch(() => undefined);
56
+ const track = (page) => {
57
+ activePage = page;
58
+ // Never fall back to another tab after the exact page closes: that would hand a
59
+ // person a different site/page. A lost page is terminal for this handoff.
60
+ page.once("close", () => {
61
+ if (activePage === page)
62
+ activePage = null;
63
+ });
64
+ };
65
+ context.on("page", track);
66
+ const initial = context.pages().at(-1) ?? await context.newPage();
67
+ track(initial);
68
+ mcp = new McpRuntime();
69
+ await mcp.start({ cdpEndpoint: await cdpEndpoint(profileDir) });
70
+ return {
71
+ tools: mcp,
72
+ handoffPage: () => activePage && !activePage.isClosed() ? activePage : null,
73
+ stop: async () => {
74
+ if (stopped)
75
+ return;
76
+ stopped = true;
77
+ activePage = null;
78
+ await mcp.stop().catch(() => undefined);
79
+ await context.close().catch(() => undefined);
80
+ },
81
+ };
82
+ }
83
+ catch (error) {
84
+ await mcp?.stop().catch(() => undefined);
85
+ await context?.close().catch(() => undefined);
86
+ throw error;
87
+ }
88
+ }
89
+ async function cdpEndpoint(profileDir) {
90
+ const portFile = join(profileDir, "DevToolsActivePort");
91
+ for (let i = 0; i < 50; i++) {
92
+ if (existsSync(portFile)) {
93
+ const text = await import("node:fs/promises").then(({ readFile }) => readFile(portFile, "utf8"));
94
+ const port = text.split("\n", 1)[0]?.trim();
95
+ if (port)
96
+ return `http://127.0.0.1:${port}`;
97
+ }
98
+ await new Promise((resolve) => setTimeout(resolve, 100));
99
+ }
100
+ throw new Error("could not read the local application browser CDP port");
101
+ }
@@ -0,0 +1,24 @@
1
+ /** Separate from apply and generic tasks: this surface has no bridge/fill/submit operation. */
2
+ export async function routeLocalApplyReconciliation(runtime, method, path, read) {
3
+ if (method !== "POST")
4
+ return undefined;
5
+ if (path === "/local-apply/reconciliation/run")
6
+ return runtime.start(await read());
7
+ if (path === "/local-apply/reconciliation/status")
8
+ return runtime.status(await read());
9
+ if (path === "/local-apply/reconciliation/handoff")
10
+ return runtime.handoff(await read());
11
+ if (path === "/local-apply/reconciliation/handoff-status")
12
+ return runtime.status(await read());
13
+ if (path === "/local-apply/reconciliation/handoff/frame")
14
+ return runtime.handoffFrame(await read());
15
+ if (path === "/local-apply/reconciliation/handoff/input") {
16
+ await runtime.handoffInput(await read());
17
+ return { ok: true };
18
+ }
19
+ if (path === "/local-apply/reconciliation/handoff/resume")
20
+ return runtime.resume(await read());
21
+ if (path === "/local-apply/reconciliation/handoff/end")
22
+ return runtime.end(await read());
23
+ return undefined;
24
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Read-only reconciliation for an ended `submit_unconfirmed` attempt.
3
+ *
4
+ * This is intentionally independent of ApplyBridge: it launches no CLI, receives no source or
5
+ * artifact paths and has no action surface capable of filling, uploading or submitting. The only
6
+ * page inspection remains in this process and returns a closed evidence vocabulary to PAGS.
7
+ */
8
+ import { join } from "node:path";
9
+ import { RunnerInputError } from "../errors.js";
10
+ import { domainWithin } from "../local-browser/bridge.js";
11
+ const SAFE_ID = /^[A-Za-z0-9_.:-]{1,300}$/;
12
+ const TTL_MS = 10 * 60_000;
13
+ /** Dedicated runtime; there is deliberately no bridge, engine, or generic browser-task fallback. */
14
+ export class LocalApplyReconciliationRuntime {
15
+ deps;
16
+ sessions = new Map();
17
+ now;
18
+ constructor(deps) {
19
+ this.deps = deps;
20
+ this.now = deps.now ?? (() => Date.now());
21
+ }
22
+ async start(raw) {
23
+ const envelope = parseEnvelope(raw);
24
+ const existing = this.sessions.get(envelope.reconciliationId);
25
+ if (existing) {
26
+ if (existing.envelope.runId !== envelope.runId || existing.envelope.applicationId !== envelope.applicationId || existing.envelope.instanceId !== envelope.instanceId || existing.envelope.browserProfile !== envelope.browserProfile)
27
+ throw new RunnerInputError("The reconciliation id is already bound to another attempt", 409);
28
+ return this.view(existing);
29
+ }
30
+ const browser = await this.deps.browserFor(envelope.browserProfile, join(this.deps.dataDir, "reconciliation", envelope.reconciliationId));
31
+ const session = { envelope, browser, state: "running", expiresAt: this.now() + TTL_MS };
32
+ this.sessions.set(envelope.reconciliationId, session);
33
+ try {
34
+ const page = this.page(session);
35
+ if (!page)
36
+ return await this.finish(session, { state: "unavailable", proofKind: "authorized_profile_unavailable" });
37
+ await page.goto(envelope.applicationUrl, { waitUntil: "domcontentloaded", timeout: 20_000 });
38
+ return await this.inspect(session);
39
+ }
40
+ catch {
41
+ return this.finish(session, { state: "unavailable", proofKind: "authorized_profile_unavailable" });
42
+ }
43
+ }
44
+ async status(raw) {
45
+ const session = this.require(raw);
46
+ if (session.state === "ended")
47
+ return this.view(session);
48
+ if (session.expiresAt <= this.now())
49
+ return this.finish(session, { state: "ambiguous", proofKind: "ambiguous_site_history" });
50
+ if (!this.page(session))
51
+ return this.finish(session, { state: "unavailable", proofKind: "authorized_profile_unavailable" });
52
+ if (session.handoff?.state === "ready" && (session.handoff.expiresAt <= this.now() || !this.deps.takeover || await this.deps.takeover.state(session.handoff.id) !== "ready"))
53
+ await this.closeHandoff(session, session.handoff.expiresAt <= this.now() ? "expired" : "page_lost");
54
+ return this.view(session);
55
+ }
56
+ async handoff(raw) {
57
+ const request = this.handoffRequest(raw);
58
+ const session = this.require(request);
59
+ this.assertHandoffBinding(session, request);
60
+ if (session.state !== "paused" || !session.pauseReason)
61
+ throw new RunnerInputError("Reconciliation handoff is available only for a live login or CAPTCHA pause", 409);
62
+ if (session.handoff)
63
+ throw new RunnerInputError("A reconciliation handoff already exists", 409);
64
+ const page = this.page(session);
65
+ if (!page || !this.deps.takeover)
66
+ return this.closedHandoff(session, request.handoffId, "profile_unavailable");
67
+ try {
68
+ await this.deps.takeover.open(request, page);
69
+ }
70
+ catch {
71
+ return this.closedHandoff(session, request.handoffId, "page_lost");
72
+ }
73
+ session.handoff = { id: request.handoffId, expiresAt: this.now() + TTL_MS, state: "ready" };
74
+ return this.view(session);
75
+ }
76
+ async handoffFrame(raw) {
77
+ const session = await this.liveHandoff(raw);
78
+ try {
79
+ return await this.deps.takeover.frame(session.handoff.id);
80
+ }
81
+ catch {
82
+ await this.closeHandoff(session, "page_lost");
83
+ throw new RunnerInputError("The reconciliation page is no longer available", 409);
84
+ }
85
+ }
86
+ async handoffInput(raw) {
87
+ const request = raw && typeof raw === "object" ? raw : {};
88
+ const session = await this.liveHandoff(request);
89
+ if (!request.input || typeof request.input !== "object")
90
+ throw new RunnerInputError("handoff input is required");
91
+ // A person may authenticate/solve a CAPTCHA, but once that wall is gone this session accepts
92
+ // no further controls. It can never become an application fill/submit surface.
93
+ const blocker = await this.blocker(this.page(session));
94
+ if (!blocker)
95
+ throw new RunnerInputError("The read-only reconciliation handoff no longer accepts browser input", 409);
96
+ try {
97
+ await this.deps.takeover.input(session.handoff.id, request.input);
98
+ }
99
+ catch {
100
+ await this.closeHandoff(session, "page_lost");
101
+ throw new RunnerInputError("The reconciliation page is no longer available", 409);
102
+ }
103
+ }
104
+ async resume(raw) {
105
+ const session = await this.liveHandoff(raw);
106
+ return this.inspect(session);
107
+ }
108
+ async end(raw) {
109
+ const session = this.require(raw);
110
+ if (session.handoff?.state === "ready")
111
+ await this.closeHandoff(session, "unavailable");
112
+ session.state = "running";
113
+ session.pauseReason = undefined;
114
+ return this.view(session);
115
+ }
116
+ liveWork() { return [...this.sessions.values()].filter((s) => s.state !== "ended").map((s) => `reconciliation:${s.envelope.reconciliationId}`); }
117
+ closeAll() { for (const session of this.sessions.values())
118
+ void this.finish(session, { state: "unavailable", proofKind: "authorized_profile_unavailable" }); }
119
+ async inspect(session) {
120
+ const page = this.page(session);
121
+ if (!page)
122
+ return this.finish(session, { state: "unavailable", proofKind: "authorized_profile_unavailable" });
123
+ const state = await this.pageState(page).catch(() => null);
124
+ if (!state || !this.permitted(session, state.url))
125
+ return this.finish(session, { state: "ambiguous", proofKind: "ambiguous_site_history" });
126
+ if (state.login || state.captcha) {
127
+ session.state = "paused";
128
+ session.pauseReason = state.captcha ? "captcha" : "login_required";
129
+ return this.view(session);
130
+ }
131
+ // A handoff which has just cleared authentication is the one case where we retain the
132
+ // bounded browser. It lets the owner inspect their authenticated account/job view without
133
+ // turning generic page prose into an outcome. Explicit end or the ten-minute deadline
134
+ // resolves it as ambiguous unless a future provider-specific verifier supplies exact proof.
135
+ if (session.handoff?.state === "ready") {
136
+ session.state = "running";
137
+ session.pauseReason = undefined;
138
+ return this.view(session);
139
+ }
140
+ // SEEK has no validated account/job receipt or history contract in this runtime. Generic
141
+ // prose, receipt-like URLs, unauthenticated pages and absent receipts prove nothing.
142
+ return this.finish(session, { state: "ambiguous", proofKind: "ambiguous_site_history" });
143
+ }
144
+ async finish(session, result) {
145
+ if (session.state === "ended")
146
+ return this.view(session);
147
+ if (session.handoff?.state === "ready")
148
+ await this.closeHandoff(session, "unavailable");
149
+ session.state = "ended";
150
+ session.pauseReason = undefined;
151
+ session.result = result;
152
+ await session.browser.stop().catch(() => undefined);
153
+ return this.view(session);
154
+ }
155
+ async closeHandoff(session, reason) {
156
+ if (!session.handoff || session.handoff.state === "closed")
157
+ return;
158
+ session.handoff.state = "closed";
159
+ session.handoff.reason = reason;
160
+ await this.deps.takeover?.end(session.handoff.id).catch(() => undefined);
161
+ }
162
+ closedHandoff(session, id, reason) { session.handoff = { id, expiresAt: this.now(), state: "closed", reason }; return this.view(session); }
163
+ page(session) { const page = session.browser.handoffPage?.(); return page && !page.isClosed() ? page : null; }
164
+ permitted(session, raw) { try {
165
+ const host = new URL(raw).hostname.toLowerCase();
166
+ return session.envelope.allowDomains.some((d) => domainWithin(host, d));
167
+ }
168
+ catch {
169
+ return false;
170
+ } }
171
+ async blocker(page) { const state = page ? await this.pageState(page).catch(() => null) : null; return !!state && (state.login || state.captcha); }
172
+ async pageState(page) {
173
+ return page.evaluate(() => {
174
+ const text = (document.body?.innerText ?? "").toLowerCase();
175
+ const captcha = !!document.querySelector('iframe[src*="hcaptcha.com"], .h-captcha, iframe[src*="challenges.cloudflare.com"], .cf-turnstile, iframe[src*="arkoselabs"], .geetest_holder') || /confirm (that )?you('?re| are) not a robot|i'?m not a robot|verify (that )?you('?re| are) (a )?human|checking (if the site connection is secure|your browser)/.test(text);
176
+ // Kept in the page: site prose never crosses the runner boundary. This is deliberately
177
+ // narrower than a guess; anything unrecognised becomes `ambiguous`.
178
+ const password = !!document.querySelector("input[type=password]");
179
+ const otp = !!document.querySelector("input[autocomplete='one-time-code'], input[name*='otp' i], input[name*='verification' i], input[id*='otp' i], input[id*='verification' i]");
180
+ const emailSignIn = !!document.querySelector("form[action*='login' i] input[type=email], form[action*='sign-in' i] input[type=email], [data-auth-screen] input[type=email]");
181
+ return { url: location.href, login: password || otp || emailSignIn, captcha };
182
+ });
183
+ }
184
+ require(raw) { const id = typeof raw?.reconciliationId === "string" ? raw.reconciliationId : ""; if (!SAFE_ID.test(id))
185
+ throw new RunnerInputError("reconciliationId is required"); const session = this.sessions.get(id); if (!session)
186
+ throw new RunnerInputError("Reconciliation session not found", 404); return session; }
187
+ handoffRequest(raw) { const o = raw && typeof raw === "object" ? raw : {}; const handoffId = typeof o.handoffId === "string" ? o.handoffId : ""; const runId = typeof o.runId === "string" ? o.runId : ""; const applicationId = typeof o.applicationId === "string" ? o.applicationId : ""; const reconciliationId = typeof o.reconciliationId === "string" ? o.reconciliationId : ""; const browserProfile = o.browserProfile === "isolated" || o.browserProfile === "default" ? o.browserProfile : null; if (![handoffId, runId, applicationId, reconciliationId].every((v) => SAFE_ID.test(v)) || !browserProfile)
188
+ throw new RunnerInputError("reconciliation handoff needs opaque ids and browserProfile"); return { handoffId, runId, applicationId, reconciliationId, browserProfile }; }
189
+ assertHandoffBinding(session, request) { const e = session.envelope; if (e.reconciliationId !== request.reconciliationId || e.runId !== request.runId || e.applicationId !== request.applicationId || e.browserProfile !== request.browserProfile)
190
+ throw new RunnerInputError("The reconciliation handoff does not match this exact attempt", 403); }
191
+ async liveHandoff(raw) { const request = this.handoffRequest(raw); const session = this.require(request); this.assertHandoffBinding(session, request); if ((session.state !== "paused" && session.state !== "running") || session.handoff?.id !== request.handoffId || session.handoff.state !== "ready")
192
+ throw new RunnerInputError("The reconciliation handoff is closed", 409); if (session.handoff.expiresAt <= this.now()) {
193
+ await this.closeHandoff(session, "expired");
194
+ throw new RunnerInputError("The reconciliation handoff is closed", 409);
195
+ } return session; }
196
+ view(session) { const e = session.envelope; return { reconciliationId: e.reconciliationId, runId: e.runId, applicationId: e.applicationId, browserProfile: e.browserProfile, context: "separate_read_only_context", state: session.state, ...(session.pauseReason ? { pauseReason: session.pauseReason } : {}), ...(session.result ? { result: session.result } : {}) }; }
197
+ }
198
+ function parseEnvelope(raw) {
199
+ const o = raw && typeof raw === "object" ? raw : {};
200
+ const ids = ["reconciliationId", "runId", "instanceId", "applicationId"];
201
+ for (const key of ids)
202
+ if (typeof o[key] !== "string" || !SAFE_ID.test(o[key]))
203
+ throw new RunnerInputError(`Invalid reconciliation: ${key} is required`);
204
+ if (o.type !== "local_browser.apply.reconciliation" || (o.browserProfile !== "isolated" && o.browserProfile !== "default") || typeof o.applicationUrl !== "string" || !/^https?:\/\/[^\s/]+/i.test(o.applicationUrl))
205
+ throw new RunnerInputError("Invalid read-only reconciliation envelope");
206
+ const allowDomains = Array.isArray(o.allowDomains) ? o.allowDomains.filter((d) => typeof d === "string" && /^[a-z0-9.-]+$/i.test(d)).map((d) => d.toLowerCase()) : [];
207
+ if (!allowDomains.length || typeof o.jobIdentity !== "string" || !o.materialFingerprint || typeof o.materialFingerprint !== "object")
208
+ throw new RunnerInputError("Invalid reconciliation binding");
209
+ return { type: o.type, reconciliationId: o.reconciliationId, runId: o.runId, instanceId: o.instanceId, applicationId: o.applicationId, browserProfile: o.browserProfile, applicationUrl: o.applicationUrl, allowDomains, jobIdentity: o.jobIdentity, materialFingerprint: o.materialFingerprint };
210
+ }
@@ -17,8 +17,11 @@ export class LocalApplyHandoffRuntime {
17
17
  throw new RunnerInputError("A handoff already exists for this application run", 409);
18
18
  if (!this.host.takeover)
19
19
  return this.closed(run, request.handoffId, "unavailable");
20
+ const page = run.browser?.handoffPage?.();
21
+ if (!page || page.isClosed())
22
+ return this.closed(run, request.handoffId, "page_lost");
20
23
  try {
21
- await this.host.takeover.open(request);
24
+ await this.host.takeover.open(request, page);
22
25
  }
23
26
  catch {
24
27
  return this.closed(run, request.handoffId, "profile_unavailable");
@@ -5,14 +5,12 @@ export function isLocalApplyTakeover(id) { return id.startsWith(prefix); }
5
5
  export function createLocalApplyTakeoverAdapter(deps) {
6
6
  const key = (handoffId) => `${prefix}${handoffId}`;
7
7
  return {
8
- async open(request) {
9
- if (request.browserProfile !== "default")
10
- throw new RunnerInputError("This browser profile has no live handoff page", 409);
8
+ async open(request, page) {
11
9
  const id = key(request.handoffId);
12
10
  if (deps.get(id))
13
11
  throw new RunnerInputError("A local application handoff already exists", 409);
14
- // Do not create a page here: a destroyed page must remain a lost handoff, never look ready.
15
- const page = deps.activePage();
12
+ // Do not create or substitute a page here. The runtime supplied the page retained by
13
+ // this exact run/profile; a destroyed page must remain a lost handoff, never look ready.
16
14
  if (!page || page.isClosed())
17
15
  throw new RunnerInputError("The local application page is no longer available", 409);
18
16
  deps.set(id, page);
@@ -17,7 +17,9 @@ import { LOCAL_BROWSER_TASK_TYPE } from "./local-browser/contract.js";
17
17
  import { LocalArtifactRuntime } from "./local-artifact/runtime.js";
18
18
  import { LOCAL_ARTIFACT_TASK_TYPE } from "./local-artifact/contract.js";
19
19
  import { LocalApplyRuntime } from "./local-apply/runtime.js";
20
+ import { LocalApplyReconciliationRuntime } from "./local-apply/reconciliation-runtime.js";
20
21
  import { LOCAL_APPLY_TASK_TYPE } from "./local-apply/contract.js";
22
+ import { createRunnerBrowserFactories } from "./local-apply/live-run-browser.js";
21
23
  import { createLocalApplyTakeoverAdapter, isLocalApplyTakeover } from "./local-apply/takeover-adapter.js";
22
24
  import { WORKFLOW_DRIVEN_TASKS } from "./task-types.js";
23
25
  /** True for a plain object. */
@@ -86,6 +88,8 @@ export class LocalRunner {
86
88
  localArtifact;
87
89
  /** Local application execution (#957): a signed-in CLI filling one application through the apply bridge. */
88
90
  localApply;
91
+ /** Browser-only, no-bridge reconciliation for an ended uncertain apply attempt. */
92
+ localApplyReconciliation;
89
93
  /** This runner's own local URL, once the server listens — the bridge forwarder calls back to it. */
90
94
  selfUrl = null;
91
95
  /** Live human-takeover sessions, keyed by task id (the page is kept alive). */
@@ -95,23 +99,17 @@ export class LocalRunner {
95
99
  mkdirSync(config.dataDir, { recursive: true });
96
100
  this.store = new RunnerStore(config.dataDir);
97
101
  this.coding = new CodingRuntime(join(config.dataDir, "repos"));
98
- // `default` is the runner's own signed-in browser, shared and never closed by a run;
99
- // `isolated` is a fresh profile inside the run's folder, removed with it.
100
- const browserFor = async (profile, runDir) => {
101
- if (profile === "default")
102
- return { tools: await this.getMcp(), stop: async () => undefined };
103
- const mcp = new McpRuntime();
104
- await mcp.start({ userDataDir: join(runDir, "profile"), headless: config.headless });
105
- return { tools: mcp, stop: () => mcp.stop() };
106
- };
102
+ const { browserFor, applyBrowserFor } = createRunnerBrowserFactories({ headless: config.headless, defaultTools: () => this.getMcp(), sharedPage: () => this.activePage });
107
103
  this.localBrowser = new LocalBrowserRuntime({ dataDir: config.dataDir, selfUrl: () => this.selfUrl, browserFor });
108
104
  this.localArtifact = new LocalArtifactRuntime({ dataDir: config.dataDir });
105
+ const localApplyTakeover = createLocalApplyTakeoverAdapter({ get: (id) => this.takeovers.get(id), set: (id, page) => this.takeovers.set(id, { page, reason: "local_apply", humanDone: false }), frame: (id) => this.takeoverFrame(id), input: (id, input) => this.takeoverInput(id, input), end: (id) => this.endTakeover(id) });
109
106
  this.localApply = new LocalApplyRuntime({
110
107
  dataDir: config.dataDir,
111
108
  selfUrl: () => this.selfUrl,
112
- browserFor,
113
- takeover: createLocalApplyTakeoverAdapter({ activePage: () => this.activePage, get: (id) => this.takeovers.get(id), set: (id, page) => this.takeovers.set(id, { page, reason: "local_apply", humanDone: false }), frame: (id) => this.takeoverFrame(id), input: (id, input) => this.takeoverInput(id, input), end: (id) => this.endTakeover(id) }),
109
+ browserFor: applyBrowserFor,
110
+ takeover: localApplyTakeover,
114
111
  });
112
+ this.localApplyReconciliation = new LocalApplyReconciliationRuntime({ dataDir: config.dataDir, browserFor: applyBrowserFor, takeover: localApplyTakeover });
115
113
  // Tasks paused/running on a previous process are orphaned now — their
116
114
  // pages and takeover sessions are gone. Fail them so the board is clean.
117
115
  const expired = this.store.expireInFlightTasks();
@@ -129,7 +127,7 @@ export class LocalRunner {
129
127
  */
130
128
  liveWork() {
131
129
  const coding = this.coding.liveWork();
132
- const local = [...this.localBrowser.liveWork(), ...this.localArtifact.liveWork(), ...this.localApply.liveWork()];
130
+ const local = [...this.localBrowser.liveWork(), ...this.localArtifact.liveWork(), ...this.localApply.liveWork(), ...this.localApplyReconciliation.liveWork()];
133
131
  return { codingTurns: coding.length, localRuns: local.length, detail: [...coding, ...local].slice(0, 10) };
134
132
  }
135
133
  capabilities() {
@@ -290,6 +288,7 @@ export class LocalRunner {
290
288
  this.localBrowser.closeAll();
291
289
  this.localArtifact.closeAll();
292
290
  this.localApply.closeAll();
291
+ this.localApplyReconciliation.closeAll();
293
292
  try {
294
293
  this.coding.closeAll();
295
294
  }
@@ -5,6 +5,7 @@ import { probeGitSshIdentity } from "./coding/repo.js";
5
5
  import { checkEngine } from "./coding/engine-check.js";
6
6
  import { listGithubOrgs, listGithubRepos, searchGithubRepos, getGithubRepoDetail, getGithubCredentialScope } from "./coding/github-browse.js";
7
7
  import { routeLocalApplyHandoff } from "./local-apply/handoff-routes.js";
8
+ import { routeLocalApplyReconciliation } from "./local-apply/reconciliation-routes.js";
8
9
  export function createRunnerServer(runner) {
9
10
  return createServer(async (req, res) => {
10
11
  try {
@@ -239,6 +240,9 @@ async function route(runner, req, res) {
239
240
  const handoff = await routeLocalApplyHandoff(runner.localApply, req.method, path, () => readJson(req));
240
241
  if (handoff)
241
242
  return json(res, 200, handoff);
243
+ const reconciliation = await routeLocalApplyReconciliation(runner.localApplyReconciliation, req.method, path, () => readJson(req));
244
+ if (reconciliation !== undefined)
245
+ return json(res, 200, reconciliation);
242
246
  if (req.method === "POST" && path === "/local-apply/resume") {
243
247
  return json(res, 200, runner.localApply.resume(await readJson(req)));
244
248
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.96",
3
+ "version": "0.4.98",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",