@proagentstore/cli 0.4.95 → 0.4.96

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.
@@ -35,6 +35,17 @@ export const LOCAL_APPLY_CANCEL_PATH = "/local-apply/cancel";
35
35
  export const LOCAL_APPLY_PREFLIGHT_PATH = "/local-apply/preflight";
36
36
  /** Delivers a cloud-persisted supervisory decision to one paused checkpoint. */
37
37
  export const LOCAL_APPLY_DIRECTIVE_PATH = "/local-apply/directive";
38
+ /**
39
+ * A short-lived, owner-authorized view of the *existing* browser page for one local-apply run.
40
+ * The id is opaque and carries no browser/profile material. It deliberately is not a task id:
41
+ * local-apply runs are not generic runner tasks.
42
+ */
43
+ export const LOCAL_APPLY_HANDOFF_PATH = "/local-apply/handoff";
44
+ export const LOCAL_APPLY_HANDOFF_STATUS_PATH = "/local-apply/handoff-status";
45
+ export const LOCAL_APPLY_HANDOFF_FRAME_PATH = "/local-apply/handoff/frame";
46
+ export const LOCAL_APPLY_HANDOFF_INPUT_PATH = "/local-apply/handoff/input";
47
+ export const LOCAL_APPLY_HANDOFF_RESUME_PATH = "/local-apply/handoff/resume";
48
+ export const LOCAL_APPLY_HANDOFF_END_PATH = "/local-apply/handoff/end";
38
49
  export const LOCAL_APPLY_ENGINES = ["claude", "codex"];
39
50
  export const LOCAL_APPLY_AUTH_MODES = ["machine", "subscription"];
40
51
  export const LOCAL_APPLY_MODES = ["fill_and_review", "auto_submit"];
@@ -84,7 +95,7 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
84
95
  * dispatch, naming the update, rather than silently producing the older behaviour (`apply.ts`
85
96
  * `runnerContractProblem`).
86
97
  *
87
- * ── Why it moved to 0.4.90 (#953)
98
+ * ── Why it moved to 0.4.96 (#1013)
88
99
  *
89
100
  * #953 adds the read-only `/local-apply/preflight` route used before an email lead can enter
90
101
  * tailoring. That route lives in `packages/browser-runner`, which ships INSIDE the published CLI.
@@ -92,15 +103,15 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
92
103
  * not silently look equivalent to a current runner.
93
104
  *
94
105
  * A behaviour change in the runner is a CONTRACT change, because the cloud's decisions assume it.
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.
106
+ * So this floor moves with it: #1013's exact-page login/CAPTCHA handoff and post-submit receipt
107
+ * evidence are refused together rather than silently degrading to an unhelpful `submit_unconfirmed` state.
97
108
  *
98
109
  * Bump this when the runner's half of the contract changes again, together with the CLI version
99
110
  * that ships it — `policy.test.ts` pins the pair, so the floor cannot name a release that has not
100
111
  * been published. `cliAtLeast` treats an unreported version as capable, which is the convention
101
112
  * every MIN_CLI gate here follows.
102
113
  */
103
- export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.90";
114
+ export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.96";
104
115
  export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output", "submit_unconfirmed"];
105
116
  export const LOCAL_APPLY_CONFIRMATION_MARKERS = ["page_text", "url_receipt", "already_applied_notice"];
106
117
  export const LOCAL_APPLY_SIGNALS = [
@@ -140,6 +151,7 @@ export const LOCAL_APPLY_EVENT_TYPES = [
140
151
  export const LOCAL_APPLY_ENGINE_AUTHS = ["machine-login", "subscription", "api-key", "missing_login", "unknown"];
141
152
  export const LOCAL_APPLY_SUPERVISOR_DIRECTIVES = ["continue", "request_review", "stop"];
142
153
  export const LOCAL_APPLY_SUPERVISOR_PHASES = ["initial", "before_submit", "post_navigation", "uncertain"];
154
+ export const LOCAL_APPLY_HANDOFF_TERMINAL_REASONS = ["unavailable", "profile_unavailable", "profile_mismatch", "page_lost", "expired", "run_ended", "runner_restarted"];
143
155
  export const LOCAL_APPLY_CAPS = { questions: 20, questionChars: 300, answers: 20, answerChars: 2000, summary: 2000 };
144
156
  const str = (v, max) => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null);
145
157
  const oneOf = (list, v) => (list.includes(v) ? v : null);
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The local-apply takeover surface is separate from generic task takeover: every request carries
3
+ * the exact run/application/profile binding that the runtime verifies before it reaches a page.
4
+ */
5
+ export async function routeLocalApplyHandoff(runtime, method, path, read) {
6
+ if (method !== "POST")
7
+ return undefined;
8
+ if (path === "/local-apply/handoff")
9
+ return runtime.handoff(await read());
10
+ if (path === "/local-apply/handoff-status")
11
+ return runtime.handoffStatus(await read());
12
+ if (path === "/local-apply/handoff/frame")
13
+ return runtime.handoffFrame(await read());
14
+ if (path === "/local-apply/handoff/input") {
15
+ await runtime.handoffInput(await read());
16
+ return { ok: true };
17
+ }
18
+ if (path === "/local-apply/handoff/end" || path === "/local-apply/handoff/resume")
19
+ return runtime.endHandoff(await read());
20
+ return undefined;
21
+ }
@@ -0,0 +1,117 @@
1
+ import { RunnerInputError } from "../errors.js";
2
+ const SAFE_ID = /^[A-Za-z0-9_.:-]{1,300}$/;
3
+ const HANDOFF_TTL_MS = 10 * 60 * 1000;
4
+ /** Exact-page lifecycle only. It deliberately has no access to a bridge, browser credentials, or run output. */
5
+ export class LocalApplyHandoffRuntime {
6
+ host;
7
+ constructor(host) {
8
+ this.host = host;
9
+ }
10
+ async handoff(raw) {
11
+ const request = this.parse(raw);
12
+ const run = this.host.get(request.runId);
13
+ this.assertBinding(run, request);
14
+ if (run.state === "ended")
15
+ return this.closed(run, request.handoffId, "run_ended");
16
+ if (run.handoff)
17
+ throw new RunnerInputError("A handoff already exists for this application run", 409);
18
+ if (!this.host.takeover)
19
+ return this.closed(run, request.handoffId, "unavailable");
20
+ try {
21
+ await this.host.takeover.open(request);
22
+ }
23
+ catch {
24
+ return this.closed(run, request.handoffId, "profile_unavailable");
25
+ }
26
+ run.handoff = { handoffId: request.handoffId, expiresAt: this.host.now() + (this.host.handoffTtlMs ?? HANDOFF_TTL_MS), state: "ready" };
27
+ return this.status(request);
28
+ }
29
+ async status(raw) {
30
+ const request = this.parse(raw);
31
+ const run = this.host.get(request.runId);
32
+ this.assertBinding(run, request);
33
+ if (run.state === "ended")
34
+ return this.closed(run, request.handoffId, "run_ended");
35
+ const handoff = run.handoff;
36
+ if (!handoff || handoff.handoffId !== request.handoffId)
37
+ return this.closed(run, request.handoffId, "unavailable");
38
+ if (handoff.state === "closed")
39
+ return this.response(run, handoff);
40
+ if (handoff.expiresAt <= this.host.now())
41
+ await this.close(run, "expired");
42
+ else if (!this.host.takeover || (await this.host.takeover.state(handoff.handoffId)) !== "ready")
43
+ await this.close(run, "page_lost");
44
+ return this.response(run, run.handoff);
45
+ }
46
+ async frame(raw) {
47
+ const { run, handoff } = await this.live(raw);
48
+ try {
49
+ return await this.host.takeover.frame(handoff.handoffId);
50
+ }
51
+ catch {
52
+ await this.close(run, "page_lost");
53
+ throw new RunnerInputError("The handoff page is no longer available", 409);
54
+ }
55
+ }
56
+ async input(raw) {
57
+ const o = raw && typeof raw === "object" ? raw : {};
58
+ const { run, handoff } = await this.live(o);
59
+ if (!o.input || typeof o.input !== "object")
60
+ throw new RunnerInputError("handoff input is required");
61
+ try {
62
+ await this.host.takeover.input(handoff.handoffId, o.input);
63
+ }
64
+ catch {
65
+ await this.close(run, "page_lost");
66
+ throw new RunnerInputError("The handoff page is no longer available", 409);
67
+ }
68
+ }
69
+ async end(raw) {
70
+ const request = this.parse(raw);
71
+ const run = this.host.get(request.runId);
72
+ this.assertBinding(run, request);
73
+ if (!run.handoff || run.handoff.handoffId !== request.handoffId)
74
+ return this.closed(run, request.handoffId, "unavailable");
75
+ await this.close(run, "unavailable");
76
+ return this.response(run, run.handoff);
77
+ }
78
+ parse(raw) {
79
+ const o = raw && typeof raw === "object" ? raw : {};
80
+ const handoffId = typeof o.handoffId === "string" ? o.handoffId : "";
81
+ const runId = typeof o.runId === "string" ? o.runId : "";
82
+ const applicationId = typeof o.applicationId === "string" ? o.applicationId : "";
83
+ const browserProfile = o.browserProfile === "default" || o.browserProfile === "isolated" ? o.browserProfile : null;
84
+ if (!SAFE_ID.test(handoffId) || !SAFE_ID.test(runId) || !SAFE_ID.test(applicationId) || !browserProfile)
85
+ throw new RunnerInputError("handoff needs opaque handoffId, runId, applicationId, and browserProfile");
86
+ return { handoffId, runId, applicationId, browserProfile };
87
+ }
88
+ assertBinding(run, request) {
89
+ if (run.envelope.applicationId !== request.applicationId || run.envelope.browserProfile !== request.browserProfile)
90
+ throw new RunnerInputError("The handoff does not match this application run", 403);
91
+ }
92
+ response(run, handoff) {
93
+ return { handoffId: handoff.handoffId, runId: run.envelope.runId, applicationId: run.envelope.applicationId, browserProfile: run.envelope.browserProfile, state: handoff.state, expiresAt: new Date(handoff.expiresAt).toISOString(), ...(handoff.terminalReason ? { terminalReason: handoff.terminalReason } : {}) };
94
+ }
95
+ closed(run, handoffId, terminalReason) {
96
+ const handoff = { handoffId, expiresAt: this.host.now(), state: "closed", terminalReason };
97
+ if (run.handoff?.handoffId === handoffId)
98
+ run.handoff = handoff;
99
+ return this.response(run, handoff);
100
+ }
101
+ async close(run, reason) {
102
+ const handoff = run.handoff;
103
+ if (!handoff || handoff.state === "closed")
104
+ return;
105
+ handoff.state = "closed";
106
+ handoff.terminalReason = reason;
107
+ if (this.host.takeover)
108
+ await this.host.takeover.end(handoff.handoffId).catch(() => undefined);
109
+ }
110
+ async live(raw) {
111
+ const status = await this.status(raw);
112
+ if (status.state !== "ready")
113
+ throw new RunnerInputError(`The handoff is closed (${status.terminalReason ?? "unavailable"})`, 409);
114
+ const run = this.host.get(status.runId);
115
+ return { run, handoff: run.handoff };
116
+ }
117
+ }
@@ -37,6 +37,7 @@ import { ApplyBridge } from "./bridge.js";
37
37
  import { confirmationSignals } from "./confirmation.js";
38
38
  import { LOCAL_APPLY_AUTH_MODES, LOCAL_APPLY_CAPS, LOCAL_APPLY_ENGINES, LOCAL_APPLY_MODES, LOCAL_APPLY_TASK_TYPE, parseLocalApplyResult, } from "./contract.js";
39
39
  import { applyPrompt, buildApplyEngineSpec, observedApplyAuth } from "./engine.js";
40
+ import { LocalApplyHandoffRuntime } from "./runtime-handoff.js";
40
41
  const MAX_EVENTS = 1000;
41
42
  const OUTPUT_LINES = 400;
42
43
  const SOURCE_BYTES = 256 * 1024;
@@ -169,6 +170,7 @@ function bridgeUnusedHelp(engine, d) {
169
170
  export class LocalApplyRuntime {
170
171
  deps;
171
172
  runs = new Map();
173
+ handoffs;
172
174
  /** Application fills that a restart would lose (#896) — their state is in this process. */
173
175
  liveWork() {
174
176
  return [...this.runs.values()].filter((r) => r.state !== "ended").map((r) => `an application fill (${r.envelope.applicationId.slice(0, 8)})`);
@@ -183,6 +185,7 @@ export class LocalApplyRuntime {
183
185
  this.now = deps.now ?? Date.now;
184
186
  this.spawn = deps.spawn ?? nodeSpawn;
185
187
  this.retentionMs = deps.retentionMs ?? 24 * 60 * 60 * 1000;
188
+ this.handoffs = new LocalApplyHandoffRuntime({ get: (runId) => this.get(runId), now: this.now, takeover: deps.takeover, handoffTtlMs: deps.handoffTtlMs });
186
189
  this.hydrateTerminalJournals();
187
190
  }
188
191
  /**
@@ -650,6 +653,12 @@ export class LocalApplyRuntime {
650
653
  ...(run.result ? { result: run.result } : {}),
651
654
  };
652
655
  }
656
+ /** A takeover only views the exact live page; it never authorizes the paused bridge. */
657
+ async handoff(raw) { return this.handoffs.handoff(raw); }
658
+ async handoffStatus(raw) { return this.handoffs.status(raw); }
659
+ async handoffFrame(raw) { return this.handoffs.frame(raw); }
660
+ async handoffInput(raw) { return this.handoffs.input(raw); }
661
+ async endHandoff(raw) { return this.handoffs.end(raw); }
653
662
  /** Does this token belong to this run? The bridge forwarder's only credential. */
654
663
  authorizeBridge(runId, token) {
655
664
  const run = this.runs.get(runId);
@@ -689,6 +698,10 @@ export class LocalApplyRuntime {
689
698
  end(run, r) {
690
699
  if (run.state === "ended")
691
700
  return;
701
+ // A terminal application outcome never means that a handoff succeeded. Tear down only the
702
+ // live view and retain the explicit closed state for a caller that polls before restart.
703
+ if (run.handoff?.state === "ready")
704
+ void this.handoffs.close(run, "run_ended");
692
705
  if (run.state === "paused")
693
706
  this.release(run, "stopped");
694
707
  if (run.timer)
@@ -0,0 +1,28 @@
1
+ import { RunnerInputError } from "../errors.js";
2
+ const prefix = "local-apply:";
3
+ export function isLocalApplyTakeover(id) { return id.startsWith(prefix); }
4
+ /** Attach the bounded local-apply view to the runner's existing live-page takeover machinery. */
5
+ export function createLocalApplyTakeoverAdapter(deps) {
6
+ const key = (handoffId) => `${prefix}${handoffId}`;
7
+ return {
8
+ async open(request) {
9
+ if (request.browserProfile !== "default")
10
+ throw new RunnerInputError("This browser profile has no live handoff page", 409);
11
+ const id = key(request.handoffId);
12
+ if (deps.get(id))
13
+ 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();
16
+ if (!page || page.isClosed())
17
+ throw new RunnerInputError("The local application page is no longer available", 409);
18
+ deps.set(id, page);
19
+ },
20
+ async state(handoffId) {
21
+ const session = deps.get(key(handoffId));
22
+ return session && !session.page.isClosed() ? "ready" : "page_lost";
23
+ },
24
+ frame: (handoffId) => deps.frame(key(handoffId)),
25
+ input: (handoffId, input) => deps.input(key(handoffId), input),
26
+ end: (handoffId) => deps.end(key(handoffId)),
27
+ };
28
+ }
@@ -18,6 +18,7 @@ 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
20
  import { LOCAL_APPLY_TASK_TYPE } from "./local-apply/contract.js";
21
+ import { createLocalApplyTakeoverAdapter, isLocalApplyTakeover } from "./local-apply/takeover-adapter.js";
21
22
  import { WORKFLOW_DRIVEN_TASKS } from "./task-types.js";
22
23
  /** True for a plain object. */
23
24
  function isRecord(value) {
@@ -105,7 +106,12 @@ export class LocalRunner {
105
106
  };
106
107
  this.localBrowser = new LocalBrowserRuntime({ dataDir: config.dataDir, selfUrl: () => this.selfUrl, browserFor });
107
108
  this.localArtifact = new LocalArtifactRuntime({ dataDir: config.dataDir });
108
- this.localApply = new LocalApplyRuntime({ dataDir: config.dataDir, selfUrl: () => this.selfUrl, browserFor });
109
+ this.localApply = new LocalApplyRuntime({
110
+ dataDir: config.dataDir,
111
+ 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) }),
114
+ });
109
115
  // Tasks paused/running on a previous process are orphaned now — their
110
116
  // pages and takeover sessions are gone. Fail them so the board is clean.
111
117
  const expired = this.store.expireInFlightTasks();
@@ -365,7 +371,7 @@ export class LocalRunner {
365
371
  }
366
372
  /** Active human-takeover task ids (for status/discovery). */
367
373
  listTakeovers() {
368
- return [...this.takeovers.keys()];
374
+ return [...this.takeovers.keys()].filter((id) => !isLocalApplyTakeover(id));
369
375
  }
370
376
  requireTakeover(taskId) {
371
377
  const session = this.takeovers.get(taskId);
@@ -4,6 +4,7 @@ import { LocalRunner, RunnerInputError } from "./runner.js";
4
4
  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
+ import { routeLocalApplyHandoff } from "./local-apply/handoff-routes.js";
7
8
  export function createRunnerServer(runner) {
8
9
  return createServer(async (req, res) => {
9
10
  try {
@@ -226,7 +227,6 @@ async function route(runner, req, res) {
226
227
  return json(res, 200, runner.localArtifact.cancel(await readJson(req)));
227
228
  }
228
229
  // 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
230
  if (req.method === "POST" && path === "/local-apply/preflight") {
231
231
  return json(res, 200, await runner.localApply.preflight(await readJson(req)));
232
232
  }
@@ -236,6 +236,9 @@ async function route(runner, req, res) {
236
236
  if (req.method === "POST" && path === "/local-apply/status") {
237
237
  return json(res, 200, runner.localApply.status(await readJson(req)));
238
238
  }
239
+ const handoff = await routeLocalApplyHandoff(runner.localApply, req.method, path, () => readJson(req));
240
+ if (handoff)
241
+ return json(res, 200, handoff);
239
242
  if (req.method === "POST" && path === "/local-apply/resume") {
240
243
  return json(res, 200, runner.localApply.resume(await readJson(req)));
241
244
  }
package/dist/index.js CHANGED
@@ -1875,8 +1875,7 @@ var RelayMutationAdmission = class {
1875
1875
  }
1876
1876
  };
1877
1877
 
1878
- // src/commands/runner/relay.ts
1879
- var MEMBERSHIP_SYNC_PATH = "/pags/membership/sync";
1878
+ // src/commands/runner/runner-work-observation.ts
1880
1879
  function observedRunnerWork(observation) {
1881
1880
  const sessions = observation.sessions;
1882
1881
  const work = observation.work;
@@ -1900,6 +1899,9 @@ function observedRunnerWork(observation) {
1900
1899
  ...Array.from({ length: localRuns }, (_v, i) => localDetail[i] || `local-run-${i + 1}`)
1901
1900
  ];
1902
1901
  }
1902
+
1903
+ // src/commands/runner/relay.ts
1904
+ var MEMBERSHIP_SYNC_PATH = "/pags/membership/sync";
1903
1905
  var CLI_CONTROL_PATHS = /* @__PURE__ */ new Set([MEMBERSHIP_SYNC_PATH, RUNNER_UPDATE_PATH]);
1904
1906
  async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force = false, watchInstances = false) {
1905
1907
  const apiBase = pagsApiBase(opts.apiBase).replace(/^http/, "ws");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.95",
3
+ "version": "0.4.96",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",