@proagentstore/cli 0.4.86 → 0.4.88

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")) {
@@ -167,6 +167,10 @@ function bridgeUnusedHelp(engine, d) {
167
167
  export class LocalApplyRuntime {
168
168
  deps;
169
169
  runs = new Map();
170
+ /** Application fills that a restart would lose (#896) — their state is in this process. */
171
+ liveWork() {
172
+ return [...this.runs.values()].filter((r) => r.state !== "ended").map((r) => `an application fill (${r.envelope.applicationId.slice(0, 8)})`);
173
+ }
170
174
  root;
171
175
  now;
172
176
  spawn;
@@ -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;
@@ -148,6 +148,36 @@ export function parseLocalBrowserEvent(raw) {
148
148
  event.detail = detail;
149
149
  return event;
150
150
  }
151
+ /**
152
+ * A finding, with every free-text part redacted (#947).
153
+ *
154
+ * The gap this closes: the result envelope's `summary` and `error` went through {@link redactText}
155
+ * and its FINDINGS did not. `title`, `evidence` and `detail` were length-capped only, and `fields`
156
+ * was filtered by KEY ({@link SENSITIVE_KEY}) — so a credential-shaped value under an innocuous key
157
+ * (`{"note": "sk-ant-api03-…"}`) survived, and a secret environment value quoted into `evidence`
158
+ * survived verbatim. Both are written by the same CLI, on the same machine, in the same envelope as
159
+ * the summary that WAS cleaned, and findings are the part that persists furthest: the run's result
160
+ * row, the console, MCP, and then the owner's own collection record. #947's observability rule —
161
+ * "do not retain … secret environment values" — was therefore breached on the longest-lived path.
162
+ *
163
+ * One definition, used by the runner (which knows the machine's secret env values and passes them)
164
+ * and again by the API when it parses what arrived (which does not, and catches shapes). Defence in
165
+ * depth on purpose: the runner is where secrets are knowable, and the cloud is where a runner that
166
+ * skipped the step is still caught.
167
+ */
168
+ export function redactFinding(f, secrets = []) {
169
+ const fields = {};
170
+ for (const [k, v] of Object.entries(f.fields ?? {})) {
171
+ if (SENSITIVE_KEY.test(k))
172
+ continue;
173
+ fields[k] = typeof v === "string" ? redactText(v, secrets) : v;
174
+ }
175
+ return { title: redactText(f.title, secrets), url: f.url, evidence: redactText(f.evidence, secrets), fields };
176
+ }
177
+ /** The same for a source failure's free text; its `reason` is a closed vocabulary and its url is a url. */
178
+ export function redactSourceFailure(f, secrets = []) {
179
+ return f.detail === undefined ? f : { ...f, detail: redactText(f.detail, secrets) };
180
+ }
151
181
  /**
152
182
  * A validated result envelope, or the reason it is not one.
153
183
  *
@@ -194,7 +224,9 @@ export function parseLocalBrowserResult(raw) {
194
224
  else if (typeof v === "number" || typeof v === "boolean" || v === null)
195
225
  fields[k.slice(0, 100)] = v;
196
226
  }
197
- findings.push({ title, url, evidence, fields });
227
+ // #947: capped and key-filtered is not redacted. A finding is the longest-lived thing a run
228
+ // produces, and its free text was the one part of this envelope nothing cleaned.
229
+ findings.push(redactFinding({ title, url, evidence, fields }));
198
230
  }
199
231
  const sourceFailures = [];
200
232
  for (const [i, f] of o.sourceFailures.entries()) {
@@ -204,7 +236,7 @@ export function parseLocalBrowserResult(raw) {
204
236
  if (!url || !reason)
205
237
  return { error: `sourceFailures[${i}] needs url and a known reason` };
206
238
  const detail = str(r.detail, 1000);
207
- sourceFailures.push(detail ? { url, reason, detail } : { url, reason });
239
+ sourceFailures.push(redactSourceFailure(detail ? { url, reason, detail } : { url, reason }));
208
240
  }
209
241
  const out = { runId, outcome, findings, sourceFailures, summary: redactText(str(o.summary, LOCAL_BROWSER_CAPS.text) ?? ""), traceId, engineAuth };
210
242
  // Redacted again here, whatever the runner did: an error is CLI output, and CLIs print keys.
@@ -26,7 +26,7 @@ import { fileURLToPath } from "node:url";
26
26
  import { RunnerInputError } from "../errors.js";
27
27
  import { BrowserBridge, domainWithin } from "./bridge.js";
28
28
  import { BRIDGE_ENV } from "./bridge-stdio.js";
29
- import { LOCAL_BROWSER_AUTH_MODES, LOCAL_BROWSER_ENGINES, LOCAL_BROWSER_TASK_TYPE, redactDetail, redactText, secretEnvValues, } from "./contract.js";
29
+ import { LOCAL_BROWSER_AUTH_MODES, LOCAL_BROWSER_ENGINES, LOCAL_BROWSER_TASK_TYPE, redactDetail, redactFinding, redactSourceFailure, redactText, secretEnvValues, } from "./contract.js";
30
30
  import { bridgeUnused, buildEngineSpec, finalText, missingLogin, observedEngineAuth, researchPrompt, signInHelp } from "./engine.js";
31
31
  /**
32
32
  * Why the run could not be launched, in words the owner can act on (#944).
@@ -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;
@@ -434,8 +443,11 @@ export class LocalBrowserRuntime {
434
443
  run.result = {
435
444
  runId: run.envelope.runId,
436
445
  outcome: r.outcome,
437
- findings: run.bridge?.findings ?? [],
438
- sourceFailures: run.bridge?.sourceFailures ?? [],
446
+ // #947: the machine's own secret env values are knowable HERE and nowhere later, so the
447
+ // findings are cleaned with them — the same treatment `summary` and `error` below already
448
+ // had, and the part of this envelope that previously kept whatever the CLI wrote.
449
+ findings: (run.bridge?.findings ?? []).map((f) => redactFinding(f, run.secrets)),
450
+ sourceFailures: (run.bridge?.sourceFailures ?? []).map((f) => redactSourceFailure(f, run.secrets)),
439
451
  summary: redactText((r.summary ?? "").slice(0, 4000), run.secrets),
440
452
  traceId: run.envelope.runId,
441
453
  engineAuth: r.engineAuth ?? run.engineAuth,
@@ -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.86",
3
+ "version": "0.4.88",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",