@proagentstore/cli 0.4.90 → 0.4.91

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.
Files changed (2) hide show
  1. package/dist/index.js +537 -219
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1406,6 +1406,316 @@ function parseStatusLine(line) {
1406
1406
  return status;
1407
1407
  }
1408
1408
 
1409
+ // src/commands/runner/auto-update.ts
1410
+ import { existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync7, renameSync as renameSync2, writeFileSync as writeFileSync5 } from "fs";
1411
+ import { dirname as dirname3, join as join8 } from "path";
1412
+
1413
+ // src/runner-lock.ts
1414
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
1415
+ import { execFileSync as execFileSync2 } from "child_process";
1416
+ import { closeSync, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync6, renameSync, unlinkSync as unlinkSync2, writeSync } from "fs";
1417
+ import { homedir as homedir3 } from "os";
1418
+ import { join as join7 } from "path";
1419
+ function configDir(env = process.env) {
1420
+ return env.PAGS_CONFIG_DIR?.trim() || join7(homedir3(), ".config", "proagentstore");
1421
+ }
1422
+ var accountKey = (account) => createHash("sha256").update(account.trim().toLowerCase()).digest("hex").slice(0, 12);
1423
+ var lockPath = (account, env = process.env) => join7(configDir(env), `up-${accountKey(account)}.lock`);
1424
+ function classifyHolder(facts) {
1425
+ const lock = facts.lock;
1426
+ if (!lock) return "unknown";
1427
+ if (lock.v !== 1) return "unknown";
1428
+ if (!lock.pid || !Number.isInteger(lock.pid)) return "unknown";
1429
+ if (!facts.pidAlive) return "dead";
1430
+ if (lock.pidStartedAt && facts.pidStartedAt && lock.pidStartedAt !== facts.pidStartedAt) return "dead";
1431
+ if (facts.healthRsid && facts.healthRsid === lock.rsid) return "live";
1432
+ return "unresponsive";
1433
+ }
1434
+ var isTakeable = (verdict) => verdict === "dead";
1435
+ function holderWhere(lock) {
1436
+ if (lock.launch === "tmux") return `tmux session ${lock.launchDetail || "?"}`;
1437
+ if (lock.launch === "service") return "a background service (launchd/systemd)";
1438
+ if (lock.launch === "headless") return "headless";
1439
+ return lock.launchDetail ? `terminal ${lock.launchDetail}` : "a terminal";
1440
+ }
1441
+ function holderMessage(lock, verdict) {
1442
+ const since = new Date(lock.startedAt).toLocaleString();
1443
+ const lines = [
1444
+ `A \`pags up\` is already running for this account on this machine.`,
1445
+ ` pid ${lock.pid}, started ${since}, CLI ${lock.version || "?"}, in ${holderWhere(lock)}.`
1446
+ ];
1447
+ if (verdict === "unresponsive") {
1448
+ lines.push(` It is NOT answering its health check \u2014 it may be asleep, stopped, or the machine may be overloaded.`);
1449
+ }
1450
+ if (verdict === "unknown") {
1451
+ lines.push(` Its lock file could not be read (a newer CLI may have written it), so it is assumed to be running.`);
1452
+ }
1453
+ lines.push("", " What you can do:");
1454
+ if (lock.launch === "tmux" && lock.launchDetail) lines.push(` tmux attach -t ${lock.launchDetail} \u2014 go to the one that is running`);
1455
+ lines.push(
1456
+ ` pags status \u2014 what it is doing, and anything else running here`,
1457
+ 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`
1458
+ );
1459
+ return lines;
1460
+ }
1461
+ function detectLaunch(env = process.env, isTty = Boolean(process.stdout.isTTY)) {
1462
+ if (env.PAGS_SERVICE === "1") return { launch: "service", detail: env.PAGS_SERVICE_NAME?.trim() || "" };
1463
+ if (env.TMUX) return { launch: "tmux", detail: env.TMUX_PANE?.trim() || env.TMUX.split(",")[0]?.split("/").pop() || "" };
1464
+ if (!isTty) return { launch: "headless", detail: "" };
1465
+ return { launch: "tty", detail: env.TERM_SESSION_ID?.trim() || env.WINDOWID?.trim() || "" };
1466
+ }
1467
+ function pidAlive(pid) {
1468
+ if (!pid || !Number.isInteger(pid) || pid <= 0) return false;
1469
+ try {
1470
+ process.kill(pid, 0);
1471
+ return true;
1472
+ } catch (e) {
1473
+ return e.code === "EPERM";
1474
+ }
1475
+ }
1476
+ function pidStartedAt(pid) {
1477
+ if (!pidAlive(pid)) return "";
1478
+ try {
1479
+ if (process.platform === "win32") {
1480
+ const out = execFileSync2("wmic", ["process", "where", `ProcessId=${pid}`, "get", "CreationDate", "/value"], { encoding: "utf-8", timeout: 4e3, stdio: ["ignore", "pipe", "ignore"] });
1481
+ return (out.match(/CreationDate=(\S+)/)?.[1] ?? "").trim();
1482
+ }
1483
+ return execFileSync2("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf-8", timeout: 4e3, stdio: ["ignore", "pipe", "ignore"] }).trim();
1484
+ } catch {
1485
+ return "";
1486
+ }
1487
+ }
1488
+ async function healthRsid(port, token = "", timeoutMs = 5e3, fetchImpl = fetch) {
1489
+ if (!port) return null;
1490
+ try {
1491
+ const res = await fetchImpl(`http://127.0.0.1:${port}/health`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(timeoutMs) });
1492
+ if (!res.ok) return null;
1493
+ const body = await res.json();
1494
+ return typeof body.rsid === "string" && body.rsid ? body.rsid : null;
1495
+ } catch {
1496
+ return null;
1497
+ }
1498
+ }
1499
+ function readLock(path) {
1500
+ try {
1501
+ const parsed = JSON.parse(readFileSync6(path, "utf-8"));
1502
+ return parsed && typeof parsed === "object" && typeof parsed.rsid === "string" ? parsed : null;
1503
+ } catch {
1504
+ return null;
1505
+ }
1506
+ }
1507
+ async function inspectHolder(path, opts = {}) {
1508
+ const lock = readLock(path);
1509
+ if (!lock) return { lock: null, verdict: "unknown" };
1510
+ const alive = pidAlive(lock.pid);
1511
+ const facts = {
1512
+ lock,
1513
+ pidAlive: alive,
1514
+ pidStartedAt: alive ? pidStartedAt(lock.pid) : "",
1515
+ healthRsid: alive ? await healthRsid(lock.port, lock.runnerToken ?? "", opts.healthTimeoutMs ?? 5e3, opts.fetchImpl ?? fetch) : null
1516
+ };
1517
+ return { lock, verdict: classifyHolder(facts) };
1518
+ }
1519
+ async function acquireLock(input) {
1520
+ const env = input.env ?? process.env;
1521
+ const path = lockPath(input.account, env);
1522
+ const rsid = input.rsid ?? randomUUID2();
1523
+ const handoff = input.handoffFrom?.trim() || env.PAGS_LOCK_HANDOFF?.trim() || "";
1524
+ mkdirSync5(configDir(env), { recursive: true });
1525
+ for (let attempt = 0; attempt < 3; attempt++) {
1526
+ const written = tryWrite(path, buildLock(input, rsid, env));
1527
+ if (written) return { ok: true, lock: written, path, tookOver: attempt === 0 ? "none" : "stale" };
1528
+ const { lock: lock2, verdict: verdict2 } = await inspectHolder(path);
1529
+ if (handoff && lock2?.rsid === handoff) {
1530
+ renameAside(path, lock2.pid);
1531
+ const taken = tryWrite(path, buildLock(input, rsid, env));
1532
+ if (taken) return { ok: true, lock: taken, path, tookOver: "handoff" };
1533
+ continue;
1534
+ }
1535
+ if (!isTakeable(verdict2)) return { ok: false, lock: lock2, path, verdict: verdict2 };
1536
+ if (!takeAsideIfStill(path, lock2?.rsid ?? "", lock2?.pid ?? 0)) continue;
1537
+ }
1538
+ const { lock, verdict } = await inspectHolder(path);
1539
+ return { ok: false, lock, path, verdict };
1540
+ }
1541
+ function buildLock(input, rsid, env) {
1542
+ const { launch, detail } = detectLaunch(env);
1543
+ const now = Date.now();
1544
+ return {
1545
+ v: 1,
1546
+ rsid,
1547
+ pid: process.pid,
1548
+ pidStartedAt: pidStartedAt(process.pid),
1549
+ startedAt: now,
1550
+ port: input.port,
1551
+ node: input.node,
1552
+ machineId: input.machineId,
1553
+ account: input.account,
1554
+ version: input.version,
1555
+ launch,
1556
+ launchDetail: detail,
1557
+ childPids: [],
1558
+ nonce: randomUUID2(),
1559
+ runnerToken: input.runnerToken ?? "",
1560
+ heartbeatAt: now
1561
+ };
1562
+ }
1563
+ function tryWrite(path, lock) {
1564
+ let fd = null;
1565
+ try {
1566
+ fd = openSync(path, "wx", 384);
1567
+ writeSync(fd, JSON.stringify(lock, null, 2));
1568
+ return lock;
1569
+ } catch (e) {
1570
+ if (e.code === "EEXIST") return null;
1571
+ throw e;
1572
+ } finally {
1573
+ if (fd !== null) closeSync(fd);
1574
+ }
1575
+ }
1576
+ function renameAside(path, pid) {
1577
+ const aside = `${path}.stale-${pid || "unknown"}`;
1578
+ try {
1579
+ renameSync(path, aside);
1580
+ return aside;
1581
+ } catch {
1582
+ return null;
1583
+ }
1584
+ }
1585
+ function takeAsideIfStill(path, deadRsid, pid) {
1586
+ const aside = renameAside(path, pid);
1587
+ if (!aside) return false;
1588
+ const moved = readLock(aside);
1589
+ if (moved && deadRsid && moved.rsid !== deadRsid) {
1590
+ try {
1591
+ renameSync(aside, path);
1592
+ } catch {
1593
+ }
1594
+ return false;
1595
+ }
1596
+ return true;
1597
+ }
1598
+ function releaseLock(path, rsid) {
1599
+ const current = readLock(path);
1600
+ if (!current || current.rsid !== rsid) return false;
1601
+ try {
1602
+ unlinkSync2(path);
1603
+ return true;
1604
+ } catch {
1605
+ return false;
1606
+ }
1607
+ }
1608
+
1609
+ // src/commands/runner/auto-update.ts
1610
+ function autoUpdateStatusWire(status) {
1611
+ if (!status) return void 0;
1612
+ return {
1613
+ "waiting-for-idle": "waiting_for_idle",
1614
+ running: "installing",
1615
+ "verified-success": "verified_success",
1616
+ failure: "failed"
1617
+ }[status] ?? status;
1618
+ }
1619
+ var statuses = /* @__PURE__ */ new Set([
1620
+ "enabled",
1621
+ "checking",
1622
+ "waiting-for-idle",
1623
+ "running",
1624
+ "restarting",
1625
+ "verified-success",
1626
+ "offline",
1627
+ "unsupported",
1628
+ "failure"
1629
+ ]);
1630
+ function parseStatus(value) {
1631
+ if (typeof value !== "string") return void 0;
1632
+ const readable = {
1633
+ waiting_for_idle: "waiting-for-idle",
1634
+ installing: "running",
1635
+ verified_success: "verified-success",
1636
+ failed: "failure"
1637
+ }[value] ?? value;
1638
+ return statuses.has(readable) ? readable : void 0;
1639
+ }
1640
+ function autoUpdatePolicyKey(machineId, env = process.env) {
1641
+ return `${env.PAGS_LOCK_ACCOUNT?.trim() || "default"}:${machineId.trim() || "unidentified"}`;
1642
+ }
1643
+ function autoUpdatePolicyPath(env = process.env) {
1644
+ return env.PAGS_AUTO_UPDATE_POLICY_FILE?.trim() || join8(configDir(env), "auto-update.json");
1645
+ }
1646
+ function parseAutoUpdatePolicy(value) {
1647
+ if (!value || typeof value !== "object") return null;
1648
+ const raw = value;
1649
+ const autoUpdate = raw.autoUpdate ?? raw.auto_update;
1650
+ if (typeof autoUpdate !== "boolean") return null;
1651
+ const status = parseStatus(raw.status ?? raw.autoUpdateStatus ?? raw.auto_update_status);
1652
+ const latestVersion = raw.latestVersion ?? raw.latest_version;
1653
+ const lastAttemptAt = raw.lastAttemptAt ?? raw.last_attempt_at;
1654
+ const reason = raw.reason;
1655
+ return {
1656
+ autoUpdate,
1657
+ ...status ? { status } : {},
1658
+ ...typeof latestVersion === "string" ? { latestVersion } : {},
1659
+ ...typeof lastAttemptAt === "string" ? { lastAttemptAt } : {},
1660
+ ...typeof reason === "string" ? { reason } : {}
1661
+ };
1662
+ }
1663
+ function policyFromResponse(value) {
1664
+ if (!value || typeof value !== "object") return null;
1665
+ const raw = value;
1666
+ const direct = raw.autoUpdatePolicy ?? raw.auto_update_policy ?? raw.policy;
1667
+ if (typeof direct === "boolean") {
1668
+ return parseAutoUpdatePolicy({
1669
+ autoUpdate: direct,
1670
+ status: raw.autoUpdateStatus ?? raw.auto_update_status,
1671
+ latestVersion: raw.latestVersion ?? raw.latest_version,
1672
+ lastAttemptAt: raw.lastAttemptAt ?? raw.last_attempt_at,
1673
+ reason: raw.reason
1674
+ });
1675
+ }
1676
+ return parseAutoUpdatePolicy(direct) ?? parseAutoUpdatePolicy(raw.machine && typeof raw.machine === "object" ? raw.machine.autoUpdatePolicy ?? raw.machine.auto_update_policy : void 0);
1677
+ }
1678
+ function loadAutoUpdatePolicy(key, env = process.env) {
1679
+ try {
1680
+ const data = JSON.parse(readFileSync7(autoUpdatePolicyPath(env), "utf8"));
1681
+ if (data.v !== 1 || !data.policies || typeof data.policies !== "object") return null;
1682
+ return parseAutoUpdatePolicy(data.policies[key]);
1683
+ } catch {
1684
+ return null;
1685
+ }
1686
+ }
1687
+ function saveAutoUpdatePolicy(key, policy, env = process.env) {
1688
+ const path = autoUpdatePolicyPath(env);
1689
+ try {
1690
+ let old = { v: 1, policies: {} };
1691
+ if (existsSync7(path)) {
1692
+ const parsed = JSON.parse(readFileSync7(path, "utf8"));
1693
+ if (parsed.v === 1 && parsed.policies && typeof parsed.policies === "object") old = parsed;
1694
+ }
1695
+ mkdirSync6(dirname3(path), { recursive: true });
1696
+ const next = { v: 1, policies: { ...old.policies, [key]: policy } };
1697
+ const temp = `${path}.${process.pid}.tmp`;
1698
+ writeFileSync5(temp, JSON.stringify(next, null, 2), { mode: 384 });
1699
+ renameSync2(temp, path);
1700
+ return true;
1701
+ } catch {
1702
+ return false;
1703
+ }
1704
+ }
1705
+ function nextAutoUpdateDelayMs(failures, random = Math.random) {
1706
+ const base = failures > 0 ? Math.min(60 * 6e4, 6e4 * 2 ** Math.min(failures - 1, 6)) : 6 * 60 * 6e4;
1707
+ const jitter = 0.85 + Math.max(0, Math.min(1, random())) * 0.3;
1708
+ return Math.round(base * jitter);
1709
+ }
1710
+ function policyScheduleAction(previous, next, wasAuthoritative) {
1711
+ if (!next.autoUpdate) return "cancel";
1712
+ if (!wasAuthoritative || !previous.autoUpdate) return "start";
1713
+ return "keep";
1714
+ }
1715
+ function mayAutomaticallyRestart(input) {
1716
+ return input.authoritative && input.enabled && input.workObserved && input.busy.length === 0;
1717
+ }
1718
+
1409
1719
  // src/commands/runner/relay.ts
1410
1720
  var MEMBERSHIP_SYNC_PATH = "/pags/membership/sync";
1411
1721
  var CLI_CONTROL_PATHS = /* @__PURE__ */ new Set([MEMBERSHIP_SYNC_PATH, RUNNER_UPDATE_PATH]);
@@ -1415,13 +1725,44 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1415
1725
  if (!pagsToken) throw new Error("PAGS token required for WebSocket relay");
1416
1726
  const runnerNode = hostname3();
1417
1727
  const machine = loadMachineIdentity(runnerNode);
1728
+ const autoUpdateKey = machine.id ? autoUpdatePolicyKey(machine.id) : "";
1729
+ let autoUpdatePolicy = autoUpdateKey ? loadAutoUpdatePolicy(autoUpdateKey) ?? { autoUpdate: false } : { autoUpdate: false };
1730
+ let autoUpdatePolicyAuthoritative = false;
1731
+ let refreshingAutomaticPolicy = false;
1732
+ let scheduleAutomaticUpdate = () => void 0;
1733
+ const saveAutoUpdateStatus = (status, extra = {}) => {
1734
+ autoUpdatePolicy = {
1735
+ ...autoUpdatePolicy,
1736
+ ...extra,
1737
+ status,
1738
+ lastAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
1739
+ };
1740
+ if (autoUpdateKey) saveAutoUpdatePolicy(autoUpdateKey, autoUpdatePolicy);
1741
+ };
1742
+ const acceptAutoUpdatePolicy = (response) => {
1743
+ if (!autoUpdateKey) return;
1744
+ const policy = policyFromResponse(response);
1745
+ if (!policy) return;
1746
+ const action = policyScheduleAction(autoUpdatePolicy, policy, autoUpdatePolicyAuthoritative);
1747
+ autoUpdatePolicy = { ...autoUpdatePolicy, ...policy };
1748
+ autoUpdatePolicyAuthoritative = true;
1749
+ if (autoUpdateKey) saveAutoUpdatePolicy(autoUpdateKey, autoUpdatePolicy);
1750
+ if (action === "cancel") {
1751
+ scheduleAutomaticUpdate(false, true);
1752
+ return;
1753
+ }
1754
+ if (refreshingAutomaticPolicy) return;
1755
+ if (action === "start") {
1756
+ scheduleAutomaticUpdate(true);
1757
+ }
1758
+ };
1418
1759
  const capabilities = await requestRunner("GET", "/capabilities", { url: localUrl, token: runnerToken, instanceId: instanceIds[0] });
1419
1760
  const caps = Array.isArray(capabilities.capabilities) ? capabilities.capabilities.filter((item) => typeof item === "string") : [];
1420
1761
  const registered = /* @__PURE__ */ new Set();
1421
1762
  let lastRegisterError = "";
1422
1763
  const registerRuntime = async (id, forceClaim = force) => {
1423
1764
  try {
1424
- await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime`, opts, {
1765
+ const result = await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime`, opts, {
1425
1766
  endpointUrl: localUrl,
1426
1767
  token: runnerToken,
1427
1768
  placement: "local",
@@ -1430,8 +1771,14 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1430
1771
  runnerNode,
1431
1772
  machineId: machine.id,
1432
1773
  machineNames: machine.names,
1433
- force: forceClaim
1774
+ force: forceClaim,
1775
+ autoUpdateStatus: {
1776
+ status: autoUpdateStatusWire(autoUpdatePolicy.status),
1777
+ latestVersion: autoUpdatePolicy.latestVersion,
1778
+ error: autoUpdatePolicy.reason
1779
+ }
1434
1780
  });
1781
+ acceptAutoUpdatePolicy(result);
1435
1782
  registered.add(id);
1436
1783
  return true;
1437
1784
  } catch (e) {
@@ -1442,6 +1789,17 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1442
1789
  return false;
1443
1790
  }
1444
1791
  };
1792
+ const refreshAutomaticPolicy = async () => {
1793
+ const id = registered.values().next().value;
1794
+ if (!id) return false;
1795
+ autoUpdatePolicyAuthoritative = false;
1796
+ refreshingAutomaticPolicy = true;
1797
+ try {
1798
+ return await registerRuntime(id) && autoUpdatePolicyAuthoritative;
1799
+ } finally {
1800
+ refreshingAutomaticPolicy = false;
1801
+ }
1802
+ };
1445
1803
  const attached = /* @__PURE__ */ new Map();
1446
1804
  const processStartedAt = Date.now();
1447
1805
  const starts24h = recordRunnerStart(processStartedAt);
@@ -1455,43 +1813,179 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1455
1813
  };
1456
1814
  for (const id of instanceIds) await registerRuntime(id);
1457
1815
  const updateFacts = async () => {
1458
- const sessions = await requestRunner("GET", "/coding/sessions", {
1459
- url: localUrl,
1460
- token: runnerToken,
1461
- instanceId: instanceIds[0]
1462
- }).catch(() => ({ sessions: [] }));
1816
+ const [sessionsResult, healthResult] = await Promise.allSettled([
1817
+ requestRunner("GET", "/coding/sessions", {
1818
+ url: localUrl,
1819
+ token: runnerToken,
1820
+ instanceId: instanceIds[0]
1821
+ }),
1822
+ requestRunner("GET", "/health", { url: localUrl, token: runnerToken, instanceId: instanceIds[0] })
1823
+ ]);
1824
+ const observationsUnavailable = sessionsResult.status !== "fulfilled" || healthResult.status !== "fulfilled" || !healthResult.value.work;
1825
+ const sessions = sessionsResult.status === "fulfilled" ? sessionsResult.value : { sessions: [] };
1826
+ const health = healthResult.status === "fulfilled" ? healthResult.value : {};
1827
+ const activeCoding = (sessions.sessions ?? []).filter((s) => s.alive && s.runState && s.runState !== "idle").map((s) => s.sessionId);
1828
+ const localRuns = health.work?.localRuns ?? 0;
1829
+ const codingTurns = health.work?.codingTurns ?? 0;
1463
1830
  return {
1464
1831
  current: CLI_VERSION,
1465
1832
  latest: await latestPublishedVersion(),
1466
1833
  fromSource: runsFromSource(),
1467
1834
  restarter: restarterFrom(process.env),
1468
- busy: (sessions.sessions ?? []).filter((s) => s.alive && s.runState && s.runState !== "idle").map((s) => s.sessionId)
1835
+ // A restart ends browser/application work too. Give these durable names to the same
1836
+ // planner so neither manual nor automatic update can mistake them for an idle machine.
1837
+ busy: observationsUnavailable ? ["runner-work-observation-unavailable"] : [...activeCoding, ...Array.from({ length: codingTurns }, (_v, i) => `coding-turn-${i + 1}`), ...Array.from({ length: localRuns }, (_v, i) => health.work?.detail?.[i] || `local-run-${i + 1}`)]
1469
1838
  };
1470
1839
  };
1471
- const installAndRestart = async (plan) => {
1472
- writeLine(`Updating ${plan.current} \u2192 ${plan.latest} (runner_update)\u2026`);
1473
- await installVersion(plan.latest);
1840
+ let updateInstallInFlight = false;
1841
+ let installedPendingRestart = null;
1842
+ let automaticRestartDraining = false;
1843
+ const installAndRestart = async (plan, automatic = false) => {
1844
+ if (updateInstallInFlight) return false;
1845
+ updateInstallInFlight = true;
1846
+ if (automatic && (!await refreshAutomaticPolicy() || !autoUpdatePolicy.autoUpdate)) {
1847
+ updateInstallInFlight = false;
1848
+ return false;
1849
+ }
1850
+ if (installedPendingRestart !== plan.latest) {
1851
+ writeLine(`Updating ${plan.current} \u2192 ${plan.latest} (runner_update)\u2026`);
1852
+ try {
1853
+ await installVersion(plan.latest);
1854
+ installedPendingRestart = plan.latest;
1855
+ } catch (error) {
1856
+ updateInstallInFlight = false;
1857
+ throw error;
1858
+ }
1859
+ }
1860
+ if (automatic) {
1861
+ automaticRestartDraining = true;
1862
+ await new Promise((resolve5) => setTimeout(resolve5, 500));
1863
+ if (!await refreshAutomaticPolicy() || !autoUpdatePolicy.autoUpdate) {
1864
+ automaticRestartDraining = false;
1865
+ updateInstallInFlight = false;
1866
+ return false;
1867
+ }
1868
+ }
1869
+ const finalFacts = await updateFacts();
1870
+ if (finalFacts.busy.length || automatic && !mayAutomaticallyRestart({ authoritative: autoUpdatePolicyAuthoritative, enabled: autoUpdatePolicy.autoUpdate, workObserved: !finalFacts.busy.includes("runner-work-observation-unavailable"), busy: finalFacts.busy })) {
1871
+ automaticRestartDraining = false;
1872
+ updateInstallInFlight = false;
1873
+ return false;
1874
+ }
1474
1875
  writeLine(`Installed ${plan.latest} \u2014 restarting; every agent re-attaches on the way back up.`);
1475
- setTimeout(() => {
1476
- for (const id of [...attached.keys()]) detach(id);
1477
- leaveForRestart(plan.restarter);
1478
- }, 500).unref();
1876
+ for (const id of [...attached.keys()]) detach(id);
1877
+ leaveForRestart(plan.restarter);
1878
+ return true;
1479
1879
  };
1480
1880
  let updateWaiting = false;
1481
- const updateWhenIdle = () => {
1881
+ const updateWhenIdle = (automatic = false) => {
1482
1882
  if (updateWaiting) return;
1483
1883
  updateWaiting = true;
1484
1884
  const until = Date.now() + 60 * 6e4;
1485
1885
  const tick = () => setTimeout(async () => {
1886
+ if (automatic && (!await refreshAutomaticPolicy() || !autoUpdatePolicy.autoUpdate)) {
1887
+ saveAutoUpdateStatus("offline", { reason: "The owner policy could not be refreshed; automatic update is deferred." });
1888
+ updateWaiting = false;
1889
+ return;
1890
+ }
1486
1891
  const plan = planRunnerUpdate(await updateFacts());
1487
1892
  if (plan.action === "update") {
1488
- await installAndRestart(plan).catch((e) => writeError(`runner_update: install failed \u2014 ${e instanceof Error ? e.message : String(e)}`));
1893
+ if (automatic && !autoUpdatePolicy.autoUpdate) {
1894
+ updateWaiting = false;
1895
+ return;
1896
+ }
1897
+ if (automatic) saveAutoUpdateStatus("running", { latestVersion: plan.latest, reason: void 0 });
1898
+ const installed = await installAndRestart(plan, automatic).catch((e) => {
1899
+ if (automatic) saveAutoUpdateStatus("failure", { reason: e instanceof Error ? e.message : String(e) });
1900
+ writeError(`runner_update: install failed \u2014 ${e instanceof Error ? e.message : String(e)}`);
1901
+ return false;
1902
+ });
1903
+ if (automatic && !installed && Date.now() < until) {
1904
+ saveAutoUpdateStatus("waiting-for-idle", { latestVersion: plan.latest });
1905
+ tick();
1906
+ } else updateWaiting = false;
1907
+ } else if (plan.action === "wait" && Date.now() < until) {
1908
+ if (automatic) saveAutoUpdateStatus("waiting-for-idle", { latestVersion: plan.latest, reason: void 0 });
1909
+ tick();
1910
+ } else {
1911
+ if (automatic && plan.action === "refused") saveAutoUpdateStatus("unsupported", { reason: plan.reason });
1489
1912
  updateWaiting = false;
1490
- } else if (plan.action === "wait" && Date.now() < until) tick();
1491
- else updateWaiting = false;
1913
+ }
1492
1914
  }, 15e3).unref();
1493
1915
  tick();
1494
1916
  };
1917
+ let automaticTimer = null;
1918
+ let automaticChecking = false;
1919
+ let automaticFailures = 0;
1920
+ const runAutomaticUpdateCheck = async () => {
1921
+ if (!autoUpdatePolicy.autoUpdate || !autoUpdatePolicyAuthoritative || automaticChecking || updateWaiting) return;
1922
+ automaticChecking = true;
1923
+ try {
1924
+ if (!await refreshAutomaticPolicy() || !autoUpdatePolicy.autoUpdate) {
1925
+ saveAutoUpdateStatus("offline", { reason: "The owner policy could not be refreshed; automatic update is deferred." });
1926
+ scheduleAutomaticUpdate();
1927
+ return;
1928
+ }
1929
+ saveAutoUpdateStatus("checking", { reason: void 0 });
1930
+ const facts = await updateFacts();
1931
+ if (!autoUpdatePolicy.autoUpdate || !autoUpdatePolicyAuthoritative) return;
1932
+ if (!facts.latest) {
1933
+ automaticFailures++;
1934
+ saveAutoUpdateStatus("failure", { reason: "Could not check the trusted npm registry for a newer CLI release." });
1935
+ scheduleAutomaticUpdate();
1936
+ return;
1937
+ }
1938
+ automaticFailures = 0;
1939
+ const plan = planRunnerUpdate(facts);
1940
+ if (plan.action === "up-to-date") {
1941
+ saveAutoUpdateStatus("verified-success", { latestVersion: facts.latest, reason: void 0 });
1942
+ scheduleAutomaticUpdate();
1943
+ return;
1944
+ }
1945
+ if (plan.action === "refused") {
1946
+ saveAutoUpdateStatus("unsupported", { latestVersion: facts.latest, reason: plan.reason });
1947
+ scheduleAutomaticUpdate();
1948
+ return;
1949
+ }
1950
+ if (plan.action === "wait") {
1951
+ saveAutoUpdateStatus("waiting-for-idle", { latestVersion: plan.latest, reason: void 0 });
1952
+ updateWhenIdle(true);
1953
+ return;
1954
+ }
1955
+ if (!autoUpdatePolicy.autoUpdate || !autoUpdatePolicyAuthoritative) return;
1956
+ saveAutoUpdateStatus("running", { latestVersion: plan.latest, reason: void 0 });
1957
+ try {
1958
+ const installed = await installAndRestart(plan, true);
1959
+ if (!installed) {
1960
+ saveAutoUpdateStatus("waiting-for-idle", { latestVersion: plan.latest });
1961
+ updateWhenIdle(true);
1962
+ return;
1963
+ }
1964
+ saveAutoUpdateStatus("restarting", { latestVersion: plan.latest, reason: void 0 });
1965
+ } catch (e) {
1966
+ automaticFailures++;
1967
+ saveAutoUpdateStatus("failure", { latestVersion: plan.latest, reason: e instanceof Error ? e.message : String(e) });
1968
+ scheduleAutomaticUpdate();
1969
+ }
1970
+ } finally {
1971
+ automaticChecking = false;
1972
+ }
1973
+ };
1974
+ scheduleAutomaticUpdate = (initial = false, cancel = false) => {
1975
+ if (cancel || !autoUpdatePolicy.autoUpdate || !autoUpdatePolicyAuthoritative) {
1976
+ if (automaticTimer) clearTimeout(automaticTimer);
1977
+ automaticTimer = null;
1978
+ return;
1979
+ }
1980
+ if (automaticTimer || updateWaiting) return;
1981
+ const delay = initial ? Math.round(5e3 + Math.random() * 25e3) : nextAutoUpdateDelayMs(automaticFailures);
1982
+ automaticTimer = setTimeout(() => {
1983
+ automaticTimer = null;
1984
+ void runAutomaticUpdateCheck();
1985
+ }, delay);
1986
+ automaticTimer.unref();
1987
+ };
1988
+ if (autoUpdatePolicyAuthoritative && autoUpdatePolicy.autoUpdate) scheduleAutomaticUpdate(true);
1495
1989
  const answerUpdate = async (body) => {
1496
1990
  const dryRun = body?.dryRun === true;
1497
1991
  const plan = planRunnerUpdate(await updateFacts());
@@ -1501,7 +1995,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1501
1995
  return { status: 200, result: { ...plan, detail: "Restarts itself as soon as these engines finish their turns \u2014 no run is cut off." } };
1502
1996
  }
1503
1997
  try {
1504
- await installAndRestart(plan);
1998
+ if (!await installAndRestart(plan)) return { status: 409, result: { error: "A runner update is already installing or restarting." } };
1505
1999
  } catch (e) {
1506
2000
  return { status: 500, result: { error: `npm could not install ${plan.latest}: ${e instanceof Error ? e.message : String(e)}` } };
1507
2001
  }
@@ -1567,7 +2061,10 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1567
2061
  reportRegistration();
1568
2062
  },
1569
2063
  answerControl,
1570
- runnerNode
2064
+ runnerNode,
2065
+ // The final automatic-update drain is an admission gate, not merely another poll:
2066
+ // command work starts only when the old runner is still permitted to serve it.
2067
+ () => !automaticRestartDraining
1571
2068
  )
1572
2069
  );
1573
2070
  if (label) writeLine(`Attached agent: ${label}`);
@@ -1612,7 +2109,17 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1612
2109
  });
1613
2110
  for (const id of [...attached.keys()]) {
1614
2111
  try {
1615
- await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime/heartbeat`, opts, { runnerNode, resources });
2112
+ const response = await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime/heartbeat`, opts, {
2113
+ runnerNode,
2114
+ machineId: machine.id,
2115
+ resources,
2116
+ autoUpdateStatus: {
2117
+ status: autoUpdateStatusWire(autoUpdatePolicy.status),
2118
+ latestVersion: autoUpdatePolicy.latestVersion,
2119
+ error: autoUpdatePolicy.reason
2120
+ }
2121
+ });
2122
+ acceptAutoUpdatePolicy(response);
1616
2123
  } catch (e) {
1617
2124
  failure = e instanceof Error ? e.message : String(e);
1618
2125
  }
@@ -1701,7 +2208,7 @@ async function readSessionResources(localUrl, runnerToken) {
1701
2208
  }
1702
2209
  var RTT_PROBE_PREFIX = "rtt:";
1703
2210
  var RTT_ECHO_PREFIX = "rtt-echo:";
1704
- function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl, runnerNode = hostname3()) {
2211
+ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl, runnerNode = hostname3(), admitMutation = () => true) {
1705
2212
  let backoffMs = 1e3;
1706
2213
  let reconnecting = false;
1707
2214
  let closed = false;
@@ -1769,6 +2276,13 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
1769
2276
  return;
1770
2277
  }
1771
2278
  const method = (cmd.method || "POST").toUpperCase();
2279
+ if (method !== "GET" && method !== "HEAD" && !admitMutation()) {
2280
+ try {
2281
+ ws.send(JSON.stringify({ id: cmd.id, status: 503, result: { error: "Runner is draining for a safe automatic update; retry shortly." } }));
2282
+ } catch {
2283
+ }
2284
+ return;
2285
+ }
1772
2286
  const hasBody = method !== "GET" && method !== "HEAD" && cmd.body !== void 0;
1773
2287
  try {
1774
2288
  const headers = {};
@@ -1862,202 +2376,6 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
1862
2376
  };
1863
2377
  }
1864
2378
 
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
-
2061
2379
  // src/commands/runner/command.ts
2062
2380
  import { hostname as hostname4 } from "os";
2063
2381
  function collectCapability(value, previous = []) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.90",
3
+ "version": "0.4.91",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",