@proagentstore/cli 0.4.87 → 0.4.89

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.
@@ -12,6 +12,19 @@ const MAX_PANE = 64 * 1024;
12
12
  export class CodingRuntime {
13
13
  reposBaseDir;
14
14
  sessions = new Map();
15
+ /**
16
+ * Coding sessions that are running here (#896).
17
+ *
18
+ * A Claude session survives a restart — it is re-spawned with `--resume <session_id>` and only
19
+ * the turn in flight is cut — but a Codex, Grok or raw-engine session restarts cold and its turn
20
+ * is gone. Both are named, because the owner is the one deciding whether to take the machine.
21
+ */
22
+ liveWork() {
23
+ return [...this.sessions.values()].map((s) => {
24
+ const engine = s.clientType ?? "";
25
+ return engine === "claude" ? "a Claude coding session (resumes after a restart)" : `a ${engine || "coding"} turn (lost on a restart)`;
26
+ });
27
+ }
15
28
  /** Background cold-start clones, one per folder (#858). */
16
29
  cloneJobs = new CloneJobs();
17
30
  /**
@@ -38,6 +38,12 @@ function configFromArgs() {
38
38
  token: arg("--token", process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID()}`,
39
39
  instanceId: arg("--instance-id", process.env.PAGS_INSTANCE_ID),
40
40
  headless: flag("--headless") || process.env.PAGS_RUNNER_HEADLESS === "1",
41
+ // #896: the single-instance lock's identity and shutdown credential, passed by the parent
42
+ // (`runner connect`) that took the lock. Absent when nothing holds a lock — a bare
43
+ // `pags runner start`, or a CLI older than the lock — and then `/control/shutdown` refuses.
44
+ rsid: process.env.PAGS_RUNNER_RSID || undefined,
45
+ launch: process.env.PAGS_RUNNER_LAUNCH || undefined,
46
+ controlNonce: process.env.PAGS_RUNNER_CONTROL_NONCE || undefined,
41
47
  };
42
48
  }
43
49
  if (flag("--help") || flag("-h")) {
@@ -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
+ }
@@ -82,31 +82,38 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
82
82
  * dispatch, naming the update, rather than silently producing the older behaviour (`apply.ts`
83
83
  * `runnerContractProblem`).
84
84
  *
85
- * ── Why it moved to 0.4.85 (#989)
85
+ * ── Why it moved to 0.4.89 (#994)
86
86
  *
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.
87
+ * #994 makes a post-click confirmation observable and recognises SEEK's "application sent"
88
+ * receipt wording. It also carries only closed-vocabulary evidence when a pressed submit remains
89
+ * unconfirmed. Those changes live in `packages/browser-runner`, which ships INSIDE the published
90
+ * CLI. A machine below this floor cannot provide that evidence, so it must not silently look
91
+ * equivalent to a current runner.
96
92
  *
97
93
  * 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.
94
+ * So this floor moves with it: a machine that cannot observe a post-submit receipt is refused
95
+ * with a sentence that says so, rather than reporting an unhelpful `submit_unconfirmed` state.
101
96
  *
102
97
  * Bump this when the runner's half of the contract changes again, together with the CLI version
103
98
  * that ships it — `policy.test.ts` pins the pair, so the floor cannot name a release that has not
104
99
  * been published. `cliAtLeast` treats an unreported version as capable, which is the convention
105
100
  * every MIN_CLI gate here follows.
106
101
  */
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"];
102
+ export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.89";
103
+ export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output", "submit_unconfirmed"];
104
+ export const LOCAL_APPLY_CONFIRMATION_MARKERS = ["page_text", "url_receipt", "already_applied_notice"];
105
+ export const LOCAL_APPLY_SIGNALS = [
106
+ "approval_policy_blocked",
107
+ "bridge_tools_missing",
108
+ "auth_prompt",
109
+ "no_output",
110
+ "engine_refused_task",
111
+ "confirmation_no_marker",
112
+ "confirmation_page_unreadable",
113
+ "confirmation_url_changed",
114
+ "confirmation_url_unchanged",
115
+ "confirmation_title_changed",
116
+ ];
110
117
  export const LOCAL_APPLY_UNAVAILABLE_REASONS = ["expired", "unavailable"];
111
118
  export const LOCAL_APPLY_EVENT_TYPES = [
112
119
  "engine.auth_checked",
@@ -140,7 +147,11 @@ const isHttpUrl = (v) => /^https?:\/\/[^\s/]+/i.test(v);
140
147
  // submit since #985, and this whitelist silently dropped it — so the one fact that tells "it
141
148
  // refused the control that opens the form" from "it refused the real submit" never reached the
142
149
  // 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"]);
150
+ // `marker`, `looks`, `urlChanged` and `titleChanged` are #994's post-click evidence: a marker ID
151
+ // from `LOCAL_APPLY_CONFIRMATION_MARKERS`, how many times the page was read, and whether it moved.
152
+ // Each is an id, a count or a boolean — the same whitelist rule as everything else here, so no page
153
+ // text and no typed value can ride along on a `submit.confirmed` / `submit.unconfirmed` event.
154
+ 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
155
  /** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
145
156
  export function parseLocalApplyEvent(raw) {
146
157
  if (!raw || typeof raw !== "object")
@@ -167,6 +178,15 @@ export function parseLocalApplyEvent(raw) {
167
178
  for (const [k, v] of Object.entries(o.detail)) {
168
179
  if (!DETAIL_KEYS.has(k))
169
180
  continue;
181
+ // #994: `marker` is a runner-defined confirmation ID, never the success sentence the
182
+ // page displayed. Keep the closed vocabulary closed even if a compromised/outdated
183
+ // runner tries to use this otherwise-string field as a prose channel.
184
+ if (k === "marker") {
185
+ const marker = oneOf(LOCAL_APPLY_CONFIRMATION_MARKERS, v);
186
+ if (marker)
187
+ detail[k] = marker;
188
+ continue;
189
+ }
170
190
  if (typeof v === "string")
171
191
  detail[k] = v.slice(0, 300);
172
192
  else if (typeof v === "number" || typeof v === "boolean")
@@ -33,6 +33,7 @@ import { redactText, secretEnvValues } from "../local-browser/contract.js";
33
33
  import { finalText, missingLogin, signInHelp } from "../local-browser/engine.js";
34
34
  import { resolveWorkspacePath } from "../local-browser/runtime.js";
35
35
  import { ApplyBridge } from "./bridge.js";
36
+ import { confirmationSignals } from "./confirmation.js";
36
37
  import { LOCAL_APPLY_AUTH_MODES, LOCAL_APPLY_CAPS, LOCAL_APPLY_ENGINES, LOCAL_APPLY_MODES, LOCAL_APPLY_TASK_TYPE, } from "./contract.js";
37
38
  import { applyPrompt, buildApplyEngineSpec, observedApplyAuth } from "./engine.js";
38
39
  const MAX_EVENTS = 1000;
@@ -167,6 +168,10 @@ function bridgeUnusedHelp(engine, d) {
167
168
  export class LocalApplyRuntime {
168
169
  deps;
169
170
  runs = new Map();
171
+ /** Application fills that a restart would lose (#896) — their state is in this process. */
172
+ liveWork() {
173
+ return [...this.runs.values()].filter((r) => r.state !== "ended").map((r) => `an application fill (${r.envelope.applicationId.slice(0, 8)})`);
174
+ }
170
175
  root;
171
176
  now;
172
177
  spawn;
@@ -368,8 +373,20 @@ export class LocalApplyRuntime {
368
373
  return { outcome: "submitted", summary, submitted: b.submitted };
369
374
  if (b?.unavailable)
370
375
  return { outcome: "blocked", summary, blockReason: "job_unavailable", unavailable: b.unavailable };
371
- if (b?.submitAttempted)
372
- return { outcome: "blocked", summary, blockReason: "submit_unconfirmed", questions: b.blocked?.questions ?? [] };
376
+ // #994: a pressed submit nobody confirmed now SAYS what was seen. The outcome is unchanged —
377
+ // terminal, no retry, the owner told to check the employer's site — because that safety is
378
+ // correct; what was missing was any way to tell "the click never moved the page" from "the
379
+ // page moved and said something this platform does not recognise yet".
380
+ if (b?.submitAttempted) {
381
+ const d = this.diagnosticOf(run, code, "submit_unconfirmed");
382
+ return {
383
+ outcome: "blocked",
384
+ summary,
385
+ blockReason: "submit_unconfirmed",
386
+ questions: b.blocked?.questions ?? [],
387
+ diagnostic: { ...d, signals: [...d.signals, ...(b.confirmation ? confirmationSignals(b.confirmation) : [])] },
388
+ };
389
+ }
373
390
  if (b?.reviewReady)
374
391
  return { outcome: "awaiting_review", summary };
375
392
  if (!b?.filled && missingLogin(e.engine, run.output.join("\n")))
@@ -111,6 +111,10 @@ class Pause extends Error {
111
111
  export class LocalArtifactRuntime {
112
112
  deps;
113
113
  runs = new Map();
114
+ /** Tailoring runs that a restart would lose (#896) — their state is in this process. */
115
+ liveWork() {
116
+ return [...this.runs.values()].filter((r) => r.state !== "ended").map((r) => `a tailoring run (${r.envelope.runId.slice(0, 8)})`);
117
+ }
114
118
  root;
115
119
  now;
116
120
  spawn;
@@ -138,6 +138,15 @@ export function resolveWorkspacePath(path, home) {
138
138
  export class LocalBrowserRuntime {
139
139
  deps;
140
140
  runs = new Map();
141
+ /**
142
+ * Research runs that would be LOST if this process stopped (#896).
143
+ *
144
+ * Their state is in this process's memory: a restart does not resume them, and the cloud only
145
+ * gives up on them minutes later. So `--replace` asks before taking the machine.
146
+ */
147
+ liveWork() {
148
+ return [...this.runs.values()].filter((r) => r.state !== "ended").map((r) => `a browser research run (${r.envelope.instanceId.slice(0, 8)})`);
149
+ }
141
150
  root;
142
151
  now;
143
152
  spawn;
@@ -112,6 +112,20 @@ export class LocalRunner {
112
112
  if (expired > 0)
113
113
  console.log(`[runner] expired ${expired} orphaned in-flight task(s) from a previous session`);
114
114
  }
115
+ /**
116
+ * What this process is doing that a restart would destroy (#896).
117
+ *
118
+ * `close()` stops every local runtime it owns, so "restart the runner" is not a free action: a
119
+ * Claude coding session resumes, but a Codex or Grok turn, a browser research run, a tailoring
120
+ * run and an application fill are each lost, and the cloud only reports them `runner_lost`
121
+ * minutes later. `pags up --replace` reads this and refuses while anything is live unless the
122
+ * owner passes `--now`, which is the whole reason it is reported rather than counted silently.
123
+ */
124
+ liveWork() {
125
+ const coding = this.coding.liveWork();
126
+ const local = [...this.localBrowser.liveWork(), ...this.localArtifact.liveWork(), ...this.localApply.liveWork()];
127
+ return { codingTurns: coding.length, localRuns: local.length, detail: [...coding, ...local].slice(0, 10) };
128
+ }
115
129
  capabilities() {
116
130
  return {
117
131
  runtime: "pags-browser-runtime",
@@ -255,6 +269,17 @@ export class LocalRunner {
255
269
  * holding a live browser until someone `kill -9`ed it. That kill is exactly
256
270
  * what orphans the browser, so an unswallowed error here MADE the leak.
257
271
  */
272
+ /**
273
+ * Stop because somebody with the lock asked (#896) — the same teardown a signal runs.
274
+ *
275
+ * Deliberately not a second shutdown path: `index.ts`'s handler is idempotent and time-boxed
276
+ * (#274), so raising SIGTERM on ourselves reuses it rather than re-implementing it, which is
277
+ * what keeps "asked to stop" and "signalled to stop" from drifting apart.
278
+ */
279
+ async requestShutdown(reason) {
280
+ console.log(`[runner] stopping on request (${reason.slice(0, 60)})`);
281
+ process.kill(process.pid, "SIGTERM");
282
+ }
258
283
  async close() {
259
284
  this.localBrowser.closeAll();
260
285
  this.localArtifact.closeAll();
@@ -57,6 +57,12 @@ async function route(runner, req, res) {
57
57
  const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
58
58
  const path = url.pathname.replace(/\/$/, "") || "/";
59
59
  if ((req.method === "GET" || req.method === "POST") && path === "/health") {
60
+ // #896: a health check that cannot say WHO answered is not enough to decide anything with.
61
+ // A contender for the single-instance lock has a pid and a port from the lock file; what it
62
+ // needs is proof that the process on that port is the one the lock names (`rsid`), and what
63
+ // `--replace` needs on top of that is what would be destroyed by stopping it (`work`).
64
+ // Without the id, a recycled port answering "ok" reads as a live holder; without the work
65
+ // counts, replacing silently ends a Codex turn, a research run or an application fill.
60
66
  return json(res, 200, {
61
67
  ok: true,
62
68
  service: "proagentstore-browser-runtime",
@@ -64,8 +70,36 @@ async function route(runner, req, res) {
64
70
  controlPlane: "pags",
65
71
  runtimePlane: "pags",
66
72
  instanceId: runner.config.instanceId,
73
+ rsid: runner.config.rsid ?? null,
74
+ pid: process.pid,
75
+ launch: runner.config.launch ?? null,
76
+ work: runner.liveWork(),
67
77
  });
68
78
  }
79
+ /**
80
+ * `POST /control/shutdown` — stop gracefully, when asked by somebody holding the lock (#896).
81
+ *
82
+ * This is what makes `pags up --replace` and `pags down` an ASK rather than a kill. The caller
83
+ * proves it is entitled by quoting the nonce from the mode-600 lock file, which only a process
84
+ * able to read the owner's own lock can do; `pkill`, which this replaced, proved nothing and
85
+ * stopped anything matching a name — including another account's runner and a service's.
86
+ *
87
+ * The drain itself is `close()`, the same teardown a signal runs, so there is one shutdown path
88
+ * and not a second one that forgets something.
89
+ */
90
+ if (req.method === "POST" && path === "/control/shutdown") {
91
+ const expected = runner.config.controlNonce ?? "";
92
+ const body = (await readJson(req).catch(() => null));
93
+ const given = typeof body?.nonce === "string" ? body.nonce : "";
94
+ if (!expected || !given || given !== expected)
95
+ return json(res, 403, { error: "This runner only stops for a request carrying its own lock's nonce." });
96
+ const work = runner.liveWork();
97
+ json(res, 202, { stopping: true, pid: process.pid, work });
98
+ // Answer first, then stop: the caller is waiting for the pid to go, and a reply written
99
+ // after teardown never arrives.
100
+ setTimeout(() => void runner.requestShutdown(typeof body?.reason === "string" ? body.reason : "requested"), 10);
101
+ return;
102
+ }
69
103
  if ((req.method === "GET" || req.method === "POST") && path === "/capabilities") {
70
104
  return json(res, 200, runner.capabilities());
71
105
  }
package/dist/index.js CHANGED
@@ -1098,7 +1098,7 @@ var publishCommand = new Command6("publish").description("Publish an agent to Pr
1098
1098
 
1099
1099
  // src/commands/runner/command.ts
1100
1100
  import { spawn as spawn4 } from "child_process";
1101
- import { randomUUID as randomUUID2 } from "crypto";
1101
+ import { randomUUID as randomUUID3 } from "crypto";
1102
1102
  import { Command as Command7 } from "commander";
1103
1103
 
1104
1104
  // src/commands/runner/process.ts
@@ -1597,7 +1597,16 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1597
1597
  const [relayRttMs, sessions] = await Promise.all([first ? first.probeRtt() : Promise.resolve(null), readSessionResources(localUrl, runnerToken)]);
1598
1598
  const resources = sampleResources(void 0, void 0, {
1599
1599
  disk: sampleDisk() ?? void 0,
1600
- runner: { startedAt: processStartedAt, uptimeSec: Math.round(process.uptime()), starts24h, relayReconnects },
1600
+ runner: {
1601
+ startedAt: processStartedAt,
1602
+ uptimeSec: Math.round(process.uptime()),
1603
+ starts24h,
1604
+ relayReconnects,
1605
+ // #896: who is heartbeating. Set by `runner connect` from the lock it took.
1606
+ ...process.env.PAGS_RUNNER_RSID ? { rsid: process.env.PAGS_RUNNER_RSID } : {},
1607
+ pid: process.pid,
1608
+ ...process.env.PAGS_RUNNER_LAUNCH ? { launch: process.env.PAGS_RUNNER_LAUNCH } : {}
1609
+ },
1601
1610
  relayRttMs,
1602
1611
  sessions: sessions ?? void 0
1603
1612
  });
@@ -1853,7 +1862,204 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
1853
1862
  };
1854
1863
  }
1855
1864
 
1865
+ // src/runner-lock.ts
1866
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
1867
+ import { execFileSync as execFileSync2 } from "child_process";
1868
+ import { closeSync, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync6, renameSync, unlinkSync as unlinkSync2, writeSync } from "fs";
1869
+ import { homedir as homedir3 } from "os";
1870
+ import { join as join7 } from "path";
1871
+ function configDir(env = process.env) {
1872
+ return env.PAGS_CONFIG_DIR?.trim() || join7(homedir3(), ".config", "proagentstore");
1873
+ }
1874
+ var accountKey = (account) => createHash("sha256").update(account.trim().toLowerCase()).digest("hex").slice(0, 12);
1875
+ var lockPath = (account, env = process.env) => join7(configDir(env), `up-${accountKey(account)}.lock`);
1876
+ function classifyHolder(facts) {
1877
+ const lock = facts.lock;
1878
+ if (!lock) return "unknown";
1879
+ if (lock.v !== 1) return "unknown";
1880
+ if (!lock.pid || !Number.isInteger(lock.pid)) return "unknown";
1881
+ if (!facts.pidAlive) return "dead";
1882
+ if (lock.pidStartedAt && facts.pidStartedAt && lock.pidStartedAt !== facts.pidStartedAt) return "dead";
1883
+ if (facts.healthRsid && facts.healthRsid === lock.rsid) return "live";
1884
+ return "unresponsive";
1885
+ }
1886
+ var isTakeable = (verdict) => verdict === "dead";
1887
+ function holderWhere(lock) {
1888
+ if (lock.launch === "tmux") return `tmux session ${lock.launchDetail || "?"}`;
1889
+ if (lock.launch === "service") return "a background service (launchd/systemd)";
1890
+ if (lock.launch === "headless") return "headless";
1891
+ return lock.launchDetail ? `terminal ${lock.launchDetail}` : "a terminal";
1892
+ }
1893
+ function holderMessage(lock, verdict) {
1894
+ const since = new Date(lock.startedAt).toLocaleString();
1895
+ const lines = [
1896
+ `A \`pags up\` is already running for this account on this machine.`,
1897
+ ` pid ${lock.pid}, started ${since}, CLI ${lock.version || "?"}, in ${holderWhere(lock)}.`
1898
+ ];
1899
+ if (verdict === "unresponsive") {
1900
+ lines.push(` It is NOT answering its health check \u2014 it may be asleep, stopped, or the machine may be overloaded.`);
1901
+ }
1902
+ if (verdict === "unknown") {
1903
+ lines.push(` Its lock file could not be read (a newer CLI may have written it), so it is assumed to be running.`);
1904
+ }
1905
+ lines.push("", " What you can do:");
1906
+ if (lock.launch === "tmux" && lock.launchDetail) lines.push(` tmux attach -t ${lock.launchDetail} \u2014 go to the one that is running`);
1907
+ lines.push(
1908
+ ` pags status \u2014 what it is doing, and anything else running here`,
1909
+ lock.launch === "service" ? ` (this one is a service \u2014 stop its launchd/systemd unit rather than replacing it)` : ` pags up --replace \u2014 stop that one gracefully, then take over`
1910
+ );
1911
+ return lines;
1912
+ }
1913
+ function detectLaunch(env = process.env, isTty = Boolean(process.stdout.isTTY)) {
1914
+ if (env.PAGS_SERVICE === "1") return { launch: "service", detail: env.PAGS_SERVICE_NAME?.trim() || "" };
1915
+ if (env.TMUX) return { launch: "tmux", detail: env.TMUX_PANE?.trim() || env.TMUX.split(",")[0]?.split("/").pop() || "" };
1916
+ if (!isTty) return { launch: "headless", detail: "" };
1917
+ return { launch: "tty", detail: env.TERM_SESSION_ID?.trim() || env.WINDOWID?.trim() || "" };
1918
+ }
1919
+ function pidAlive(pid) {
1920
+ if (!pid || !Number.isInteger(pid) || pid <= 0) return false;
1921
+ try {
1922
+ process.kill(pid, 0);
1923
+ return true;
1924
+ } catch (e) {
1925
+ return e.code === "EPERM";
1926
+ }
1927
+ }
1928
+ function pidStartedAt(pid) {
1929
+ if (!pidAlive(pid)) return "";
1930
+ try {
1931
+ if (process.platform === "win32") {
1932
+ const out = execFileSync2("wmic", ["process", "where", `ProcessId=${pid}`, "get", "CreationDate", "/value"], { encoding: "utf-8", timeout: 4e3, stdio: ["ignore", "pipe", "ignore"] });
1933
+ return (out.match(/CreationDate=(\S+)/)?.[1] ?? "").trim();
1934
+ }
1935
+ return execFileSync2("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf-8", timeout: 4e3, stdio: ["ignore", "pipe", "ignore"] }).trim();
1936
+ } catch {
1937
+ return "";
1938
+ }
1939
+ }
1940
+ async function healthRsid(port, token = "", timeoutMs = 5e3, fetchImpl = fetch) {
1941
+ if (!port) return null;
1942
+ try {
1943
+ const res = await fetchImpl(`http://127.0.0.1:${port}/health`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(timeoutMs) });
1944
+ if (!res.ok) return null;
1945
+ const body = await res.json();
1946
+ return typeof body.rsid === "string" && body.rsid ? body.rsid : null;
1947
+ } catch {
1948
+ return null;
1949
+ }
1950
+ }
1951
+ function readLock(path) {
1952
+ try {
1953
+ const parsed = JSON.parse(readFileSync6(path, "utf-8"));
1954
+ return parsed && typeof parsed === "object" && typeof parsed.rsid === "string" ? parsed : null;
1955
+ } catch {
1956
+ return null;
1957
+ }
1958
+ }
1959
+ async function inspectHolder(path, opts = {}) {
1960
+ const lock = readLock(path);
1961
+ if (!lock) return { lock: null, verdict: "unknown" };
1962
+ const alive = pidAlive(lock.pid);
1963
+ const facts = {
1964
+ lock,
1965
+ pidAlive: alive,
1966
+ pidStartedAt: alive ? pidStartedAt(lock.pid) : "",
1967
+ healthRsid: alive ? await healthRsid(lock.port, lock.runnerToken ?? "", opts.healthTimeoutMs ?? 5e3, opts.fetchImpl ?? fetch) : null
1968
+ };
1969
+ return { lock, verdict: classifyHolder(facts) };
1970
+ }
1971
+ async function acquireLock(input) {
1972
+ const env = input.env ?? process.env;
1973
+ const path = lockPath(input.account, env);
1974
+ const rsid = input.rsid ?? randomUUID2();
1975
+ const handoff = input.handoffFrom?.trim() || env.PAGS_LOCK_HANDOFF?.trim() || "";
1976
+ mkdirSync5(configDir(env), { recursive: true });
1977
+ for (let attempt = 0; attempt < 3; attempt++) {
1978
+ const written = tryWrite(path, buildLock(input, rsid, env));
1979
+ if (written) return { ok: true, lock: written, path, tookOver: attempt === 0 ? "none" : "stale" };
1980
+ const { lock: lock2, verdict: verdict2 } = await inspectHolder(path);
1981
+ if (handoff && lock2?.rsid === handoff) {
1982
+ renameAside(path, lock2.pid);
1983
+ const taken = tryWrite(path, buildLock(input, rsid, env));
1984
+ if (taken) return { ok: true, lock: taken, path, tookOver: "handoff" };
1985
+ continue;
1986
+ }
1987
+ if (!isTakeable(verdict2)) return { ok: false, lock: lock2, path, verdict: verdict2 };
1988
+ if (!takeAsideIfStill(path, lock2?.rsid ?? "", lock2?.pid ?? 0)) continue;
1989
+ }
1990
+ const { lock, verdict } = await inspectHolder(path);
1991
+ return { ok: false, lock, path, verdict };
1992
+ }
1993
+ function buildLock(input, rsid, env) {
1994
+ const { launch, detail } = detectLaunch(env);
1995
+ const now = Date.now();
1996
+ return {
1997
+ v: 1,
1998
+ rsid,
1999
+ pid: process.pid,
2000
+ pidStartedAt: pidStartedAt(process.pid),
2001
+ startedAt: now,
2002
+ port: input.port,
2003
+ node: input.node,
2004
+ machineId: input.machineId,
2005
+ account: input.account,
2006
+ version: input.version,
2007
+ launch,
2008
+ launchDetail: detail,
2009
+ childPids: [],
2010
+ nonce: randomUUID2(),
2011
+ runnerToken: input.runnerToken ?? "",
2012
+ heartbeatAt: now
2013
+ };
2014
+ }
2015
+ function tryWrite(path, lock) {
2016
+ let fd = null;
2017
+ try {
2018
+ fd = openSync(path, "wx", 384);
2019
+ writeSync(fd, JSON.stringify(lock, null, 2));
2020
+ return lock;
2021
+ } catch (e) {
2022
+ if (e.code === "EEXIST") return null;
2023
+ throw e;
2024
+ } finally {
2025
+ if (fd !== null) closeSync(fd);
2026
+ }
2027
+ }
2028
+ function renameAside(path, pid) {
2029
+ const aside = `${path}.stale-${pid || "unknown"}`;
2030
+ try {
2031
+ renameSync(path, aside);
2032
+ return aside;
2033
+ } catch {
2034
+ return null;
2035
+ }
2036
+ }
2037
+ function takeAsideIfStill(path, deadRsid, pid) {
2038
+ const aside = renameAside(path, pid);
2039
+ if (!aside) return false;
2040
+ const moved = readLock(aside);
2041
+ if (moved && deadRsid && moved.rsid !== deadRsid) {
2042
+ try {
2043
+ renameSync(aside, path);
2044
+ } catch {
2045
+ }
2046
+ return false;
2047
+ }
2048
+ return true;
2049
+ }
2050
+ function releaseLock(path, rsid) {
2051
+ const current = readLock(path);
2052
+ if (!current || current.rsid !== rsid) return false;
2053
+ try {
2054
+ unlinkSync2(path);
2055
+ return true;
2056
+ } catch {
2057
+ return false;
2058
+ }
2059
+ }
2060
+
1856
2061
  // src/commands/runner/command.ts
2062
+ import { hostname as hostname4 } from "os";
1857
2063
  function collectCapability(value, previous = []) {
1858
2064
  return [...previous, value];
1859
2065
  }
@@ -1865,9 +2071,23 @@ function createRunnerCommand() {
1865
2071
  await startRunnerForeground(opts);
1866
2072
  });
1867
2073
  command.command("connect <instanceIds...>").description("Start ONE local runtime, connect via WebSocket relay, and register it for every given PAGS instance").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind", "49171").option("--data-dir <path>", "Runner data directory").option("--token <token>", "Runner bearer token. Defaults to PAGS_RUNNER_TOKEN or a generated token").option("--headless", "Run Playwright headless").option("--api-base <url>", "PAGS API base URL").option("--pags-token <token>", "PAGS session token. Defaults to PAGS_TOKEN").option("--runner-version <version>", "Runner version").option("--force", "Take over from another connected machine").option("--watch-instances", "Attach newly eligible agents while running, without a restart").action(async (instanceIds, opts) => {
1868
- const runnerToken = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID2()}`;
2074
+ const runnerToken = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID3()}`;
1869
2075
  const host = clean(opts.host) || "127.0.0.1";
1870
2076
  const port = clean(opts.port) || String(await findFreePort2(49171));
2077
+ const lockAccount = clean(process.env.PAGS_LOCK_ACCOUNT) || "";
2078
+ let lockHeld = null;
2079
+ if (lockAccount) {
2080
+ const taken = await acquireLock({ account: lockAccount, node: hostname4(), machineId: loadMachineIdentity().id || null, version: CLI_VERSION, port: Number(port), runnerToken });
2081
+ if (!taken.ok) {
2082
+ for (const line of taken.lock ? holderMessage(taken.lock, taken.verdict) : ["Another `pags up` holds this machine for this account."]) writeError(line);
2083
+ process.exit(1);
2084
+ }
2085
+ lockHeld = { path: taken.path, rsid: taken.lock.rsid, nonce: taken.lock.nonce, launch: taken.lock.launch };
2086
+ writeLine(`STATUS runner-session=${taken.lock.rsid}`);
2087
+ }
2088
+ const releaseHeldLock = () => {
2089
+ if (lockHeld) releaseLock(lockHeld.path, lockHeld.rsid);
2090
+ };
1871
2091
  const localUrl = `http://${host}:${port}`;
1872
2092
  const primary = instanceIds[0];
1873
2093
  const runnerOpts = { ...opts, host, port, token: runnerToken };
@@ -1875,7 +2095,14 @@ function createRunnerCommand() {
1875
2095
  const runner = spawn4(spec.command, spec.args, {
1876
2096
  cwd: spec.cwd,
1877
2097
  stdio: ["ignore", "pipe", "pipe"],
1878
- shell: process.platform === "win32"
2098
+ shell: process.platform === "win32",
2099
+ // #896: the runtime answers `/health` with the lock's identity and stops for a
2100
+ // request quoting its nonce — so a contender can prove who is on that port, and a
2101
+ // replace is an authorisation rather than a signal aimed at a name pattern.
2102
+ env: {
2103
+ ...process.env,
2104
+ ...lockHeld ? { PAGS_RUNNER_RSID: lockHeld.rsid, PAGS_RUNNER_CONTROL_NONCE: lockHeld.nonce, PAGS_RUNNER_LAUNCH: lockHeld.launch } : {}
2105
+ }
1879
2106
  });
1880
2107
  runner.stdout?.on("data", (data) => process.stdout.write(data));
1881
2108
  runner.stderr?.on("data", (data) => process.stderr.write(data));
@@ -1887,6 +2114,7 @@ function createRunnerCommand() {
1887
2114
  });
1888
2115
  const shutdown = () => {
1889
2116
  shuttingDown = true;
2117
+ releaseHeldLock();
1890
2118
  if (!runner.killed) runner.kill("SIGTERM");
1891
2119
  };
1892
2120
  process.once("exit", shutdown);
@@ -2023,14 +2251,14 @@ function createRunnerCommand() {
2023
2251
  var runnerCommand = createRunnerCommand();
2024
2252
 
2025
2253
  // src/commands/up.ts
2026
- import { execFileSync as execFileSync2 } from "child_process";
2254
+ import { execFileSync as execFileSync3 } from "child_process";
2027
2255
  import { createRequire as createRequire2 } from "module";
2028
2256
  import { Command as Command8 } from "commander";
2029
- import { hostname as hostname5 } from "os";
2257
+ import { hostname as hostname6 } from "os";
2030
2258
 
2031
2259
  // src/tui.ts
2032
2260
  import chalk from "chalk";
2033
- import { hostname as hostname4 } from "os";
2261
+ import { hostname as hostname5 } from "os";
2034
2262
  import readline from "readline";
2035
2263
  var ACCENT = "#7c3aed";
2036
2264
  var c = chalk.hex(ACCENT);
@@ -2083,7 +2311,7 @@ function printStatus(state) {
2083
2311
  clearScreen();
2084
2312
  printLogo(state.version);
2085
2313
  const connected = state.runner === "online" && state.tunnel === "online" && state.registration === "registered";
2086
- console.log(pad + d("Signed in as ") + w(state.user) + d(" \xB7 agent: ") + w(state.activeInstance) + d(" \xB7 node: ") + w(hostname4()));
2314
+ console.log(pad + d("Signed in as ") + w(state.user) + d(" \xB7 agent: ") + w(state.activeInstance) + d(" \xB7 node: ") + w(hostname5()));
2087
2315
  console.log("");
2088
2316
  const row = (kind, s, override) => {
2089
2317
  const { icon, label, note } = describe(kind, s);
@@ -2145,28 +2373,94 @@ async function waitForKey(keys, onInterrupt) {
2145
2373
  });
2146
2374
  }
2147
2375
 
2376
+ // src/runner-replace.ts
2377
+ var REPLACE_EXIT_TIMEOUT_MS = 3e4;
2378
+ function describeWork(work) {
2379
+ if (work.detail.length) return work.detail.join(", ");
2380
+ const parts = [];
2381
+ if (work.codingTurns) parts.push(`${work.codingTurns} coding turn${work.codingTurns === 1 ? "" : "s"}`);
2382
+ if (work.localRuns) parts.push(`${work.localRuns} local run${work.localRuns === 1 ? "" : "s"}`);
2383
+ return parts.join(", ") || "work";
2384
+ }
2385
+ var hasWork = (work) => !!work && work.codingTurns + work.localRuns > 0;
2386
+ function replaceVerdict(lock, work, opts) {
2387
+ if (lock.launch === "service") {
2388
+ return {
2389
+ allowed: false,
2390
+ lines: [
2391
+ `That runner is managed by a background service${lock.launchDetail ? ` (${lock.launchDetail})` : ""}, so replacing it would only make the service start it again.`,
2392
+ "Stop its launchd/systemd unit instead, then run `pags up` here."
2393
+ ]
2394
+ };
2395
+ }
2396
+ if (hasWork(work) && !opts.now) {
2397
+ return {
2398
+ allowed: false,
2399
+ lines: [
2400
+ `That runner is busy: ${describeWork(work)}.`,
2401
+ "Stopping it now would lose that work \u2014 a Codex or Grok turn, a research run, a tailoring run or an application fill do not survive a restart.",
2402
+ "Wait for it to finish, or run `pags up --replace --now` to stop it anyway."
2403
+ ]
2404
+ };
2405
+ }
2406
+ return { allowed: true, lines: [] };
2407
+ }
2408
+ async function holderWork(lock, deps = {}) {
2409
+ if (!lock.port) return null;
2410
+ try {
2411
+ const res = await (deps.fetchImpl ?? fetch)(`http://127.0.0.1:${lock.port}/health`, { headers: lock.runnerToken ? { Authorization: `Bearer ${lock.runnerToken}` } : {}, signal: AbortSignal.timeout(5e3) });
2412
+ if (!res.ok) return null;
2413
+ const body = await res.json();
2414
+ const w2 = body.work;
2415
+ if (!w2) return null;
2416
+ const n = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0;
2417
+ return { codingTurns: n(w2.codingTurns), localRuns: n(w2.localRuns), detail: (Array.isArray(w2.detail) ? w2.detail : []).filter((x) => typeof x === "string").slice(0, 10) };
2418
+ } catch {
2419
+ return null;
2420
+ }
2421
+ }
2422
+ async function replaceHolder(lock, opts = {}, deps = {}) {
2423
+ const fetchImpl = deps.fetchImpl ?? fetch;
2424
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
2425
+ const alive = deps.alive ?? pidAlive;
2426
+ const now = deps.now ?? Date.now;
2427
+ const work = await holderWork(lock, deps);
2428
+ const verdict = replaceVerdict(lock, work, opts);
2429
+ if (!verdict.allowed) return { ok: false, lines: verdict.lines };
2430
+ if (!lock.port) {
2431
+ return { ok: false, lines: [`The runner holding this machine (pid ${lock.pid}) does not say which port it is on, so it cannot be asked to stop.`, "Stop it where it is running, then try again."] };
2432
+ }
2433
+ try {
2434
+ const res = await fetchImpl(`http://127.0.0.1:${lock.port}/control/shutdown`, {
2435
+ method: "POST",
2436
+ // The runner authorises every request, and the nonce authorises THIS one: the token says
2437
+ // "I may talk to you", the nonce says "I hold your lock" (#896).
2438
+ headers: { "Content-Type": "application/json", ...lock.runnerToken ? { Authorization: `Bearer ${lock.runnerToken}` } : {} },
2439
+ body: JSON.stringify({ nonce: lock.nonce, reason: "replaced" }),
2440
+ signal: AbortSignal.timeout(1e4)
2441
+ });
2442
+ if (!res.ok) return { ok: false, lines: [`The runner on pid ${lock.pid} refused the stop request (HTTP ${res.status}).`, "Stop it where it is running, then try again."] };
2443
+ } catch (e) {
2444
+ return { ok: false, lines: [`The runner on pid ${lock.pid} could not be asked to stop: ${e instanceof Error ? e.message : String(e)}.`, "Stop it where it is running, then try again."] };
2445
+ }
2446
+ const until = now() + REPLACE_EXIT_TIMEOUT_MS;
2447
+ while (now() < until) {
2448
+ if (!alive(lock.pid)) return { ok: true, lines: [`Replaced the \`pags up\` on pid ${lock.pid}`] };
2449
+ await sleep(500);
2450
+ }
2451
+ return {
2452
+ ok: false,
2453
+ lines: [
2454
+ `The runner on pid ${lock.pid} was asked to stop but is still running after ${Math.round(REPLACE_EXIT_TIMEOUT_MS / 1e3)}s.`,
2455
+ "It may be finishing something. Wait and try again, or stop it where it is running \u2014 this command will not kill it for you."
2456
+ ]
2457
+ };
2458
+ }
2459
+
2148
2460
  // src/commands/up.ts
2149
2461
  var API_BASE3 = "https://api.proagentstore.online";
2150
2462
  var CLI_VERSION2 = createRequire2(import.meta.url)("../package.json").version;
2151
- async function stopRunnerProcesses() {
2152
- if (process.platform === "win32") return false;
2153
- const { execFileSync: execFileSync3 } = await import("child_process");
2154
- const patterns = [
2155
- "dist/browser-runner/index.js",
2156
- "browser-runner/src/index",
2157
- "runner connect"
2158
- ];
2159
- let stopped = false;
2160
- for (const p of patterns) {
2161
- try {
2162
- execFileSync3("pkill", ["-f", p], { stdio: "ignore" });
2163
- stopped = true;
2164
- } catch {
2165
- }
2166
- }
2167
- return stopped;
2168
- }
2169
- var upCommand = new Command8("up").description("Start the browser runner for all your agent instances").option("--headless", "Run browser in headless mode").option("--instance <id>", "Connect to a specific instance only").option("--force", "Take over from another connected machine").action(async (opts) => {
2463
+ var upCommand = new Command8("up").description("Start the browser runner for all your agent instances").option("--headless", "Run browser in headless mode").option("--instance <id>", "Connect to a specific instance only").option("--force", "Take over from another connected machine").option("--replace", "Stop the `pags up` already running here for this account, then take over").option("--now", "With --replace: stop it even though work is running on it (that work is lost)").action(async (opts) => {
2170
2464
  const session = requireSession();
2171
2465
  const state = {
2172
2466
  user: session.user.login,
@@ -2221,7 +2515,7 @@ var upCommand = new Command8("up").description("Start the browser runner for all
2221
2515
  headless: opts.headless
2222
2516
  }).catch(() => void 0);
2223
2517
  if (!opts.instance) {
2224
- const split = partitionByPin(instances, hostname5(), loadMachineIdentity(hostname5()).names);
2518
+ const split = partitionByPin(instances, hostname6(), loadMachineIdentity(hostname6()).names);
2225
2519
  instances = split.here;
2226
2520
  elsewhere = split.elsewhere;
2227
2521
  }
@@ -2242,7 +2536,26 @@ var upCommand = new Command8("up").description("Start the browser runner for all
2242
2536
  }
2243
2537
  state.activeInstance = instances.length === 1 ? instances[0].name || instances[0].slug || instances[0].id.slice(0, 8) : `${instances.length} agents`;
2244
2538
  printStep(`Connecting ${state.activeInstance}\u2026`, "wait");
2245
- await stopRunnerProcesses();
2539
+ const lockFile = lockPath(session.user.login);
2540
+ const held = await inspectHolder(lockFile);
2541
+ if (held.lock && !isTakeable(held.verdict)) {
2542
+ if (!opts.replace) {
2543
+ clearScreen();
2544
+ printLogo(CLI_VERSION2);
2545
+ for (const line of holderMessage(held.lock, held.verdict)) writeLine(` ${line}`);
2546
+ writeLine("");
2547
+ process.exit(1);
2548
+ }
2549
+ const replaced = await replaceHolder(held.lock, { now: opts.now === true });
2550
+ if (!replaced.ok) {
2551
+ clearScreen();
2552
+ printLogo(CLI_VERSION2);
2553
+ for (const line of replaced.lines) writeLine(` ${line}`);
2554
+ writeLine("");
2555
+ process.exit(1);
2556
+ }
2557
+ for (const line of replaced.lines) printStep(line, "ok");
2558
+ }
2246
2559
  const { spawn: spawn5 } = await import("child_process");
2247
2560
  const cliPath = process.argv[1];
2248
2561
  const args = [cliPath, "runner", "connect", ...instances.map((i) => i.id)];
@@ -2251,9 +2564,12 @@ var upCommand = new Command8("up").description("Start the browser runner for all
2251
2564
  if (!opts.instance) args.push("--watch-instances");
2252
2565
  const child = spawn5(process.execPath, args, {
2253
2566
  stdio: ["ignore", "pipe", "pipe"],
2254
- env: { ...process.env, PAGS_TOKEN: session.token, [SUPERVISED_ENV]: SUPERVISOR_RESTARTS }
2567
+ // The child is the lock HOLDER (#896): it owns the relay sockets, so it is what a
2568
+ // contender must find and what `--replace` must be able to stop.
2569
+ env: { ...process.env, PAGS_TOKEN: session.token, [SUPERVISED_ENV]: SUPERVISOR_RESTARTS, PAGS_LOCK_ACCOUNT: session.user.login }
2255
2570
  });
2256
2571
  const logs = [];
2572
+ let childRsid = "";
2257
2573
  const handleOutput = (data2) => {
2258
2574
  const text = data2.toString("utf-8");
2259
2575
  for (const line of text.split("\n")) {
@@ -2261,6 +2577,8 @@ var upCommand = new Command8("up").description("Start the browser runner for all
2261
2577
  if (!trimmed) continue;
2262
2578
  logs.push(trimmed);
2263
2579
  if (logs.length > 200) logs.shift();
2580
+ const rsidLine = trimmed.match(/^STATUS runner-session=(\S+)/);
2581
+ if (rsidLine) childRsid = rsidLine[1];
2264
2582
  const status = parseStatusLine(trimmed);
2265
2583
  if (status) {
2266
2584
  if (status.registration) {
@@ -2319,7 +2637,13 @@ var upCommand = new Command8("up").description("Start the browser runner for all
2319
2637
  return;
2320
2638
  }
2321
2639
  childDead = true;
2322
- if (code && code !== 0) {
2640
+ if (code === null) {
2641
+ state.runner = "error";
2642
+ state.lastEvent = "Runner stopped by another process (signal) \u2014 it is not connected any more";
2643
+ printStatus(state);
2644
+ return;
2645
+ }
2646
+ if (code !== 0) {
2323
2647
  state.runner = "error";
2324
2648
  state.lastEvent = `Runner exited (code ${code})`;
2325
2649
  const recent = logs.slice(-5);
@@ -2332,7 +2656,7 @@ var upCommand = new Command8("up").description("Start the browser runner for all
2332
2656
  try {
2333
2657
  if (process.stdin.isTTY) process.stdin.setRawMode(false);
2334
2658
  process.stdin.pause();
2335
- execFileSync2(process.execPath, [process.argv[1], ...restartUpArgs(opts)], { stdio: "inherit", env: { ...process.env, ...env } });
2659
+ execFileSync3(process.execPath, [process.argv[1], ...restartUpArgs(opts)], { stdio: "inherit", env: { ...process.env, ...env } });
2336
2660
  } catch (e) {
2337
2661
  const status = e.status;
2338
2662
  writeLine(` Restart failed${typeof status === "number" ? ` (exit ${status})` : ""} \u2014 the runner is NOT running. Run 'pags up' again.`);
@@ -2367,26 +2691,37 @@ var upCommand = new Command8("up").description("Start the browser runner for all
2367
2691
  printStatus(state);
2368
2692
  }
2369
2693
  if (key === "r") {
2370
- if (childDead) restartUp("Restarting runner...");
2694
+ if (childDead) {
2695
+ const other = await inspectHolder(lockFile);
2696
+ if (other.lock && other.lock.rsid !== childRsid && !isTakeable(other.verdict)) {
2697
+ state.lastEvent = `Another \`pags up\` holds this machine (pid ${other.lock.pid}) \u2014 restart refused`;
2698
+ printStatus(state);
2699
+ continue;
2700
+ }
2701
+ restartUp("Restarting runner...");
2702
+ }
2371
2703
  printStatus(state);
2372
2704
  }
2373
2705
  }
2374
2706
  });
2375
- var downCommand = new Command8("down").description("Stop the browser runner and disconnect").action(async () => {
2707
+ var downCommand = new Command8("down").description("Stop the browser runner and disconnect").option("--now", "Stop it even though work is running on it (that work is lost)").action(async (opts) => {
2376
2708
  clearScreen();
2377
2709
  printLogo(CLI_VERSION2);
2378
- if (process.platform === "win32") {
2379
- writeLine(" On Windows: switch to the 'pags up' window and press Ctrl+C to disconnect.");
2710
+ const session = requireSession();
2711
+ const lock = readLock(lockPath(session.user.login));
2712
+ if (!lock) {
2713
+ writeLine(" No runner of yours is running on this machine \u2014 nothing to stop.");
2380
2714
  writeLine("");
2381
2715
  return;
2382
2716
  }
2383
- const stopped = await stopRunnerProcesses();
2384
- if (stopped) {
2717
+ const stopped = await replaceHolder(lock, { now: opts.now === true });
2718
+ if (stopped.ok) {
2719
+ releaseLock(lockPath(session.user.login), lock.rsid);
2385
2720
  writeLine(" \u2713 Runner stopped \u2014 you're disconnected.");
2386
2721
  writeLine("");
2387
2722
  writeLine(" Your agent won't act on the web until you run 'pags up' again.");
2388
2723
  } else {
2389
- writeLine(" No runner was running \u2014 nothing to stop.");
2724
+ for (const line of stopped.lines) writeLine(` ${line}`);
2390
2725
  }
2391
2726
  writeLine("");
2392
2727
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.87",
3
+ "version": "0.4.89",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",