@proagentstore/cli 0.4.94 → 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.
@@ -21,9 +21,9 @@ export class CodingRuntime {
21
21
  * answer rather than restarting across work whose state it cannot prove is idle.
22
22
  */
23
23
  liveWork() {
24
- return [...this.sessions.values()].flatMap((s) => {
24
+ return [...this.sessions.entries()].flatMap(([sessionId, s]) => {
25
25
  const engine = s.clientType ?? "";
26
- const label = engine === "claude" ? "a Claude coding session (resumes after a restart)" : `a ${engine || "coding"} turn (lost on a restart)`;
26
+ const consequence = engine === "claude" ? "Claude conversation resumes after a restart" : `${engine || "coding"} turn is lost on a restart`;
27
27
  try {
28
28
  // `alive: false` is an observed inactive session. Any other answer (including a
29
29
  // malformed older implementation) is unknown and must fail closed below.
@@ -34,13 +34,13 @@ export class CodingRuntime {
34
34
  if (alive === true && state === "idle")
35
35
  return [];
36
36
  if (alive === true && (state === "thinking" || state === "responding"))
37
- return [label];
37
+ return [`coding session ${sessionId} (${state}): ${consequence}`];
38
38
  // An unrecognised `alive`/`runState` pair is not proof that an engine is idle.
39
- return [label];
39
+ return [`coding session ${sessionId} (${typeof state === "string" ? state : "unknown"}): ${consequence}`];
40
40
  }
41
41
  catch {
42
42
  // Do not turn a failed state probe into permission to restart the machine.
43
- return [label];
43
+ return [`coding session ${sessionId} (unknown): ${consequence}`];
44
44
  }
45
45
  });
46
46
  }
@@ -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
+ }
@@ -401,6 +401,19 @@ export class LocalArtifactRuntime {
401
401
  return;
402
402
  if (run.timer)
403
403
  clearTimeout(run.timer);
404
+ // An uploaded source is a bounded, per-run transfer, not runner state. The durable
405
+ // result deliberately contains only its handle and hash; remove the materialized text as
406
+ // soon as the engine no longer needs it instead of retaining it with generic scratch data.
407
+ // `end` is the one terminal path (completion, refusal, timeout, cancellation and launch
408
+ // failure), so a source cannot linger just because a particular failure path was missed.
409
+ if (run.envelope.uploadedSources?.length) {
410
+ try {
411
+ rmSync(join(run.scratch, "sources"), { recursive: true, force: true });
412
+ }
413
+ catch { /* best-effort cleanup */ }
414
+ for (const source of run.envelope.uploadedSources)
415
+ source.text = "";
416
+ }
404
417
  run.state = "ended";
405
418
  run.endedAt = this.now();
406
419
  run.result = {
@@ -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
@@ -1269,6 +1269,19 @@ function restarterFrom(env) {
1269
1269
  function supervisorNote(restarter) {
1270
1270
  return restarter === "pags-up-child-only" ? "The `pags up` on this machine predates supervisor restarts (#860): the runner comes back on the new release, but the `pags up` window keeps running its own older code until it is restarted there once." : null;
1271
1271
  }
1272
+ var MAX_DEFERRED_UPDATE_LEASE_MS = 5 * 6e4;
1273
+ function deferredUpdateLease(value, now = Date.now()) {
1274
+ if (!value || typeof value !== "object") return null;
1275
+ const raw = value;
1276
+ const operationId = typeof raw.operationId === "string" ? raw.operationId.trim() : "";
1277
+ const expiresAt = typeof raw.deferUntil === "number" ? raw.deferUntil : NaN;
1278
+ if (!operationId || operationId.length > 128 || !Number.isFinite(expiresAt)) return null;
1279
+ if (expiresAt <= now || expiresAt > now + MAX_DEFERRED_UPDATE_LEASE_MS) return null;
1280
+ return { operationId, expiresAt };
1281
+ }
1282
+ function deferredUpdateLeaseActive(lease, now = Date.now()) {
1283
+ return now < lease.expiresAt;
1284
+ }
1272
1285
  function leaveForRestart(restarter, deps = { spawn: spawn3, exit: (code) => process.exit(code) }) {
1273
1286
  if (restarter === "command") {
1274
1287
  deps.spawn(process.env[RESTART_COMMAND_ENV], { shell: true, detached: true, stdio: "ignore", env: process.env }).unref();
@@ -1862,6 +1875,31 @@ var RelayMutationAdmission = class {
1862
1875
  }
1863
1876
  };
1864
1877
 
1878
+ // src/commands/runner/runner-work-observation.ts
1879
+ function observedRunnerWork(observation) {
1880
+ const sessions = observation.sessions;
1881
+ const work = observation.work;
1882
+ if (!Array.isArray(sessions) || !work || typeof work !== "object") return ["runner-work-observation-unavailable"];
1883
+ const codingTurns = typeof work.codingTurns === "number" && Number.isInteger(work.codingTurns) && work.codingTurns >= 0 ? work.codingTurns : null;
1884
+ const localRuns = typeof work.localRuns === "number" && Number.isInteger(work.localRuns) && work.localRuns >= 0 ? work.localRuns : null;
1885
+ const detail = Array.isArray(work.detail) && work.detail.every((item) => typeof item === "string") ? work.detail : null;
1886
+ if (codingTurns === null || localRuns === null || detail === null) return ["runner-work-observation-unavailable"];
1887
+ const activeCoding = sessions.flatMap((session) => {
1888
+ if (session?.alive === false) return [];
1889
+ const id = typeof session?.sessionId === "string" && session.sessionId ? session.sessionId : "unidentified-session";
1890
+ const state = typeof session?.runState === "string" && session.runState ? session.runState : "unknown";
1891
+ if (session?.alive === true && state === "idle") return [];
1892
+ return [`coding session ${id} (${state})`];
1893
+ });
1894
+ const unmatchedCodingTurns = Math.max(0, codingTurns - activeCoding.length);
1895
+ const localDetail = detail.slice(codingTurns);
1896
+ return [
1897
+ ...activeCoding,
1898
+ ...Array.from({ length: unmatchedCodingTurns }, (_v, i) => `coding-turn-unmatched-${i + 1}`),
1899
+ ...Array.from({ length: localRuns }, (_v, i) => localDetail[i] || `local-run-${i + 1}`)
1900
+ ];
1901
+ }
1902
+
1865
1903
  // src/commands/runner/relay.ts
1866
1904
  var MEMBERSHIP_SYNC_PATH = "/pags/membership/sync";
1867
1905
  var CLI_CONTROL_PATHS = /* @__PURE__ */ new Set([MEMBERSHIP_SYNC_PATH, RUNNER_UPDATE_PATH]);
@@ -1967,14 +2005,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1967
2005
  }),
1968
2006
  requestRunner("GET", "/health", { url: localUrl, token: runnerToken, instanceId: instanceIds[0] })
1969
2007
  ]);
1970
- const observationsUnavailable = sessionsResult.status !== "fulfilled" || healthResult.status !== "fulfilled" || !healthResult.value.work;
1971
- const sessions = sessionsResult.status === "fulfilled" ? sessionsResult.value : { sessions: [] };
1972
- const health = healthResult.status === "fulfilled" ? healthResult.value : {};
1973
- const activeCoding = (sessions.sessions ?? []).filter((s) => s.alive !== false && (s.alive !== true || !["idle", "thinking", "responding"].includes(s.runState ?? "") || s.runState !== "idle")).map((s) => s.sessionId || "coding-session-state-unavailable");
1974
- const localRuns = health.work?.localRuns ?? 0;
1975
- const codingTurns = health.work?.codingTurns ?? 0;
1976
- const unmatchedCodingTurns = Math.max(0, codingTurns - activeCoding.length);
1977
- const localDetail = health.work?.detail?.slice(codingTurns) ?? [];
2008
+ const busy = sessionsResult.status === "fulfilled" && healthResult.status === "fulfilled" ? observedRunnerWork({ sessions: sessionsResult.value.sessions, work: healthResult.value.work }) : ["runner-work-observation-unavailable"];
1978
2009
  return {
1979
2010
  current: CLI_VERSION,
1980
2011
  latest: await latestPublishedVersion(),
@@ -1982,14 +2013,14 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1982
2013
  restarter: restarterFrom(process.env),
1983
2014
  // A restart ends browser/application work too. Give these durable names to the same
1984
2015
  // planner so neither manual nor automatic update can mistake them for an idle machine.
1985
- busy: observationsUnavailable ? ["runner-work-observation-unavailable"] : [...activeCoding, ...Array.from({ length: unmatchedCodingTurns }, (_v, i) => `coding-turn-unmatched-${i + 1}`), ...Array.from({ length: localRuns }, (_v, i) => localDetail[i] || `local-run-${i + 1}`)]
2016
+ busy
1986
2017
  };
1987
2018
  };
1988
2019
  let updateInstallInFlight = false;
1989
2020
  let installedPendingRestart = null;
1990
2021
  const relayMutationAdmission = new RelayMutationAdmission();
1991
- const installAndRestart = async (plan, automatic = false) => {
1992
- if (updateInstallInFlight) return false;
2022
+ const installAndRestart = async (plan, automatic = false, lease) => {
2023
+ if (updateInstallInFlight || lease && !deferredUpdateLeaseActive(lease)) return false;
1993
2024
  updateInstallInFlight = true;
1994
2025
  if (automatic && (!await refreshAutomaticPolicy() || !autoUpdatePolicy.autoUpdate)) {
1995
2026
  updateInstallInFlight = false;
@@ -2005,9 +2036,14 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
2005
2036
  throw error;
2006
2037
  }
2007
2038
  }
2039
+ await relayMutationAdmission.drain();
2040
+ await new Promise((resolve5) => setTimeout(resolve5, 500));
2041
+ if (lease && !deferredUpdateLeaseActive(lease)) {
2042
+ relayMutationAdmission.resume();
2043
+ updateInstallInFlight = false;
2044
+ return false;
2045
+ }
2008
2046
  if (automatic) {
2009
- await relayMutationAdmission.drain();
2010
- await new Promise((resolve5) => setTimeout(resolve5, 500));
2011
2047
  if (!await refreshAutomaticPolicy() || !autoUpdatePolicy.autoUpdate) {
2012
2048
  relayMutationAdmission.resume();
2013
2049
  updateInstallInFlight = false;
@@ -2022,15 +2058,22 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
2022
2058
  }
2023
2059
  writeLine(`Installed ${plan.latest} \u2014 restarting; every agent re-attaches on the way back up.`);
2024
2060
  for (const id of [...attached.keys()]) detach(id);
2025
- leaveForRestart(plan.restarter);
2026
2061
  return true;
2027
2062
  };
2028
2063
  let updateWaiting = false;
2029
- const updateWhenIdle = (automatic = false) => {
2030
- if (updateWaiting) return;
2064
+ let deferredManualLease = null;
2065
+ const updateWhenIdle = (automatic = false, lease) => {
2066
+ if (updateWaiting || !automatic && !lease) return false;
2031
2067
  updateWaiting = true;
2032
- const until = Date.now() + 60 * 6e4;
2068
+ if (!automatic) deferredManualLease = lease;
2069
+ const until = automatic ? Date.now() + 60 * 6e4 : lease.expiresAt;
2033
2070
  const tick = () => setTimeout(async () => {
2071
+ if (!automatic && (!deferredManualLease || !deferredUpdateLeaseActive(deferredManualLease))) {
2072
+ writeLine("runner_update: the deferred operation lease expired; no delayed restart was performed.");
2073
+ deferredManualLease = null;
2074
+ updateWaiting = false;
2075
+ return;
2076
+ }
2034
2077
  if (automatic && (!await refreshAutomaticPolicy() || !autoUpdatePolicy.autoUpdate)) {
2035
2078
  saveAutoUpdateStatus("offline", { reason: "The owner policy could not be refreshed; automatic update is deferred." });
2036
2079
  updateWaiting = false;
@@ -2043,24 +2086,30 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
2043
2086
  return;
2044
2087
  }
2045
2088
  if (automatic) saveAutoUpdateStatus("running", { latestVersion: plan.latest, reason: void 0 });
2046
- const installed = await installAndRestart(plan, automatic).catch((e) => {
2089
+ const installed = await installAndRestart(plan, automatic, automatic ? void 0 : deferredManualLease ?? void 0).catch((e) => {
2047
2090
  if (automatic) saveAutoUpdateStatus("failure", { reason: e instanceof Error ? e.message : String(e) });
2048
2091
  writeError(`runner_update: install failed \u2014 ${e instanceof Error ? e.message : String(e)}`);
2049
2092
  return false;
2050
2093
  });
2051
- if (automatic && !installed && Date.now() < until) {
2094
+ if (!installed && Date.now() < until) {
2052
2095
  saveAutoUpdateStatus("waiting-for-idle", { latestVersion: plan.latest });
2053
2096
  tick();
2054
- } else updateWaiting = false;
2097
+ } else {
2098
+ deferredManualLease = null;
2099
+ updateWaiting = false;
2100
+ if (installed) leaveForRestart(plan.restarter);
2101
+ }
2055
2102
  } else if (plan.action === "wait" && Date.now() < until) {
2056
2103
  if (automatic) saveAutoUpdateStatus("waiting-for-idle", { latestVersion: plan.latest, reason: void 0 });
2057
2104
  tick();
2058
2105
  } else {
2059
2106
  if (automatic && plan.action === "refused") saveAutoUpdateStatus("unsupported", { reason: plan.reason });
2107
+ deferredManualLease = null;
2060
2108
  updateWaiting = false;
2061
2109
  }
2062
2110
  }, 15e3).unref();
2063
2111
  tick();
2112
+ return true;
2064
2113
  };
2065
2114
  let automaticTimer = null;
2066
2115
  let automaticChecking = false;
@@ -2140,7 +2189,11 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
2140
2189
  plan: planRunnerUpdate,
2141
2190
  // This bridge is deliberately the same function used by explicit runner_update. It owns npm,
2142
2191
  // a fresh authoritative policy, the mutation drain, final local-work observation and restart.
2143
- installAndRestart,
2192
+ installAndRestart: async (plan, automatic) => {
2193
+ const installed = await installAndRestart(plan, automatic);
2194
+ if (installed) setTimeout(() => leaveForRestart(plan.restarter), 0).unref();
2195
+ return installed;
2196
+ },
2144
2197
  status: saveAutoUpdateStatus
2145
2198
  });
2146
2199
  scheduleAutomaticUpdate = (initial = false, cancel = false) => automaticUpdateController.onPolicy(cancel ? "cancel" : initial ? "start" : "keep");
@@ -2150,15 +2203,18 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
2150
2203
  const plan = planRunnerUpdate(await updateFacts());
2151
2204
  if (dryRun || plan.action === "up-to-date" || plan.action === "refused") return { status: 200, result: { ...plan, dryRun } };
2152
2205
  if (plan.action === "wait") {
2153
- updateWhenIdle();
2154
- return { status: 200, result: { ...plan, detail: "Restarts itself as soon as these engines finish their turns \u2014 no run is cut off." } };
2206
+ const lease = deferredUpdateLease(body);
2207
+ if (!lease) return { status: 400, result: { error: "A busy runner_update needs a current operation lease; no delayed restart was scheduled." } };
2208
+ if (!updateWhenIdle(false, lease)) return { status: 409, result: { error: "A runner update is already waiting for active work to finish." } };
2209
+ return { status: 200, result: { ...plan, operationId: lease.operationId, deferredUntil: lease.expiresAt, detail: "This operation may restart only before its recorded lease expires; no delayed restart survives an expired or terminal operation." } };
2155
2210
  }
2156
2211
  try {
2157
- if (!await automaticUpdateController.installManually(plan)) return { status: 409, result: { error: "A runner update is already installing or restarting." } };
2212
+ if (!await installAndRestart(plan)) return { status: 409, result: { error: "A runner update is already installing or restarting." } };
2158
2213
  } catch (e) {
2159
2214
  return { status: 500, result: { error: `npm could not install ${plan.latest}: ${e instanceof Error ? e.message : String(e)}` } };
2160
2215
  }
2161
2216
  const supervisor = supervisorNote(plan.restarter);
2217
+ setTimeout(() => leaveForRestart(plan.restarter), 25).unref();
2162
2218
  return { status: 200, result: { action: "restarting", current: plan.current, latest: plan.latest, restartedBy: plan.restarter, ...supervisor ? { supervisor } : {} } };
2163
2219
  };
2164
2220
  const answerControl = async (path, body) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.94",
3
+ "version": "0.4.96",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",