svamp-cli 0.2.304 → 0.2.306

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 (28) hide show
  1. package/dist/{adminCommands-DlD78CYk.mjs → adminCommands-Cx_Rp-TR.mjs} +1 -1
  2. package/dist/{agentCommands-B3FTfsg-.mjs → agentCommands-CUcyA_KE.mjs} +5 -5
  3. package/dist/{auth-2-M7wMHL.mjs → auth-BBjbREk1.mjs} +1 -1
  4. package/dist/{cli-CU9Xku8r.mjs → cli-Jj5S5UOL.mjs} +76 -70
  5. package/dist/cli.mjs +2 -2
  6. package/dist/{commands-CaeLSCaX.mjs → commands-3UnC4Lnb.mjs} +1 -1
  7. package/dist/{commands-CfSAnB1T.mjs → commands-BRQBNTsJ.mjs} +2 -2
  8. package/dist/{commands-DG0V3dvG.mjs → commands-BeD9b-Eh.mjs} +2 -2
  9. package/dist/{commands-CG6AF59l.mjs → commands-ByP8QKuO.mjs} +7 -7
  10. package/dist/{commands--WPYI2sm.mjs → commands-CvwLQ3MM.mjs} +1 -1
  11. package/dist/{commands-BO-oHKFw.mjs → commands-DJt9EFd7.mjs} +2 -2
  12. package/dist/{commands-CjZKWU9a.mjs → commands-DkKdjnPE.mjs} +2 -2
  13. package/dist/{commands-mm9jZg5L.mjs → commands-DlyOpWwF.mjs} +1 -1
  14. package/dist/{fleet-GklB5DQn.mjs → fleet-cBng1VMn.mjs} +1 -1
  15. package/dist/{frpc-Dsada5ox.mjs → frpc-C1jS23Sn.mjs} +30 -6
  16. package/dist/{headlessCli-B2IWICNI.mjs → headlessCli-CsnDECCN.mjs} +2 -2
  17. package/dist/{httpServer-CSMZTpoh.mjs → httpServer-1XjB2h3K.mjs} +15 -2
  18. package/dist/index.mjs +1 -1
  19. package/dist/{notifyCommands-BzCPoBy8.mjs → notifyCommands-D7Ja6442.mjs} +1 -1
  20. package/dist/{package-TCz4K-zx.mjs → package-DUF-safU.mjs} +2 -2
  21. package/dist/{rpc-DOVHyr_6.mjs → rpc-BxmbyDga.mjs} +2 -1
  22. package/dist/{rpc--eK356Xz.mjs → rpc-iNafaHoh.mjs} +1 -1
  23. package/dist/{run-BtVKqoF4.mjs → run-Bd57zohL.mjs} +784 -345
  24. package/dist/{run-IqHyd8bA.mjs → run-D351s_uv.mjs} +1 -1
  25. package/dist/{scheduler-DAkzJVgr.mjs → scheduler-CZYOanXt.mjs} +1 -1
  26. package/dist/{serveCommands-AEtGwfIU.mjs → serveCommands-DotLju8m.mjs} +6 -6
  27. package/dist/{sideband-CSwHDjzp.mjs → sideband-DM1-IAV7.mjs} +1 -1
  28. package/package.json +2 -2
@@ -8,8 +8,8 @@ import { fileURLToPath } from 'url';
8
8
  import { execFile, spawn, execSync, exec as exec$1 } from 'child_process';
9
9
  import * as crypto from 'crypto';
10
10
  import { randomUUID as randomUUID$1 } from 'crypto';
11
- import { randomUUID, randomBytes, createHash } from 'node:crypto';
12
- import { existsSync, readFileSync, mkdirSync as mkdirSync$1, writeFileSync as writeFileSync$1, chmodSync, rmSync as rmSync$1, cpSync, realpathSync, readdirSync, statSync, renameSync, appendFileSync, unlinkSync } from 'node:fs';
11
+ import { randomUUID, randomBytes, createHash, timingSafeEqual } from 'node:crypto';
12
+ import { existsSync, readFileSync, mkdirSync as mkdirSync$1, writeFileSync as writeFileSync$1, chmodSync, rmSync as rmSync$1, cpSync, statSync, realpathSync, readdirSync, renameSync, appendFileSync, unlinkSync } from 'node:fs';
13
13
  import { exec, spawn as spawn$1, execSync as execSync$1, execFile as execFile$1, execFileSync } from 'node:child_process';
14
14
  import { promisify } from 'util';
15
15
  import * as http from 'http';
@@ -19,7 +19,7 @@ import { join as join$1, extname, resolve, sep, basename, dirname } from 'node:p
19
19
  import { EventEmitter } from 'node:events';
20
20
  import { ndJsonStream, ClientSideConnection } from '@agentclientprotocol/sdk';
21
21
  import { createInterface } from 'node:readline';
22
- import { mkdir, rm, chmod, access, mkdtemp, copyFile, writeFile, readdir, stat, readFile as readFile$1 } from 'node:fs/promises';
22
+ import { mkdir, rm, chmod, access, mkdtemp, copyFile, writeFile, readdir, stat, readFile as readFile$1, rename as rename$1 } from 'node:fs/promises';
23
23
  import { promisify as promisify$1 } from 'node:util';
24
24
  import { parse, stringify } from 'yaml';
25
25
 
@@ -1280,6 +1280,14 @@ const MIME = {
1280
1280
  function contentTypeFor(filePath) {
1281
1281
  return MIME[path.extname(filePath).toLowerCase()] || "application/octet-stream";
1282
1282
  }
1283
+ function hasBlockedDotSegment(relPath) {
1284
+ const decoded = safeDecode(relPath.replace(/^\/+/, ""));
1285
+ for (const seg of decoded.split("/")) {
1286
+ if (!seg || seg === "." || seg === "..") continue;
1287
+ if (seg.startsWith(".") && seg !== ".well-known") return true;
1288
+ }
1289
+ return false;
1290
+ }
1283
1291
  function containedPath(rootDir, relPath) {
1284
1292
  const base = path.resolve(rootDir);
1285
1293
  const decoded = safeDecode(relPath);
@@ -1368,6 +1376,11 @@ function serveStaticMount(req, res, opts) {
1368
1376
  sendFile(req, res, rootDir, rootStat);
1369
1377
  return;
1370
1378
  }
1379
+ if (hasBlockedDotSegment(relPath)) {
1380
+ res.writeHead(404, CORS);
1381
+ res.end("Not Found");
1382
+ return;
1383
+ }
1371
1384
  const lexTarget = containedPath(rootDir, relPath.replace(/^\/+/, ""));
1372
1385
  if (!lexTarget) {
1373
1386
  res.writeHead(403, { ...CORS, "Content-Type": "text/plain" });
@@ -1496,6 +1509,7 @@ var staticFileServer = /*#__PURE__*/Object.freeze({
1496
1509
  containedPath: containedPath,
1497
1510
  contentTypeFor: contentTypeFor,
1498
1511
  directoryListingHtml: directoryListingHtml,
1512
+ hasBlockedDotSegment: hasBlockedDotSegment,
1499
1513
  parseRange: parseRange,
1500
1514
  realContainedPath: realContainedPath,
1501
1515
  serveStaticMount: serveStaticMount
@@ -1504,6 +1518,28 @@ var staticFileServer = /*#__PURE__*/Object.freeze({
1504
1518
  const AUTH0_NAMESPACES = ["https://api.imjoy.io/", "https://amun.ai/"];
1505
1519
  const COOKIE_NAME = "svamp_serve_token";
1506
1520
  const SVAMP_SERVE_TOKEN_PARAM = "__svamp_serve_token";
1521
+ function stripServeAuthCookie(cookieHeader) {
1522
+ if (!cookieHeader) return cookieHeader;
1523
+ const kept = cookieHeader.split(";").map((s) => s.trim()).filter((s) => s && !s.toLowerCase().startsWith(COOKIE_NAME + "="));
1524
+ return kept.length ? kept.join("; ") : void 0;
1525
+ }
1526
+ function stripServeTokenParam(pathWithQuery) {
1527
+ const qIdx = pathWithQuery.indexOf("?");
1528
+ if (qIdx < 0) return pathWithQuery;
1529
+ const base = pathWithQuery.slice(0, qIdx);
1530
+ const params = new URLSearchParams(pathWithQuery.slice(qIdx + 1));
1531
+ params.delete(SVAMP_SERVE_TOKEN_PARAM);
1532
+ const qs = params.toString();
1533
+ return qs ? `${base}?${qs}` : base;
1534
+ }
1535
+ function sanitizeForwardHeaders(headers) {
1536
+ const out = { ...headers };
1537
+ const cleaned = stripServeAuthCookie(typeof out.cookie === "string" ? out.cookie : void 0);
1538
+ if (cleaned === void 0) delete out.cookie;
1539
+ else out.cookie = cleaned;
1540
+ delete out.authorization;
1541
+ return out;
1542
+ }
1507
1543
  const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1e3;
1508
1544
  const DEFAULT_CACHE_MAX_SIZE = 1e3;
1509
1545
  function jsStringLiteral(value) {
@@ -2081,9 +2117,10 @@ function assertNonAdminMountDirSafe(directory, homeDir) {
2081
2117
  }
2082
2118
  function sanitizeMountForRole(mount, isAdmin) {
2083
2119
  if (isAdmin) return mount;
2084
- const { linkToken: _lt, directory: _dir, process: proc, ...rest } = mount;
2120
+ const { linkToken: _lt, directory: _dir, process: proc, url, ...rest } = mount;
2085
2121
  const safeProcess = proc ? { ...proc, env: proc.env ? Object.fromEntries(Object.keys(proc.env).map((k) => [k, "***"])) : proc.env } : proc;
2086
- return { ...rest, process: safeProcess };
2122
+ const safeUrl = mount.access === "link" ? void 0 : url;
2123
+ return { ...rest, process: safeProcess, ...safeUrl !== void 0 ? { url: safeUrl } : {} };
2087
2124
  }
2088
2125
  const DNS_LABEL_MAX = 63;
2089
2126
  function buildLinkSubdomain(subdomainSafe, linkToken) {
@@ -2286,6 +2323,10 @@ class ServeManager {
2286
2323
  }
2287
2324
  return all;
2288
2325
  }
2326
+ /** #0649: look up a single mount by name (for ownership checks before remove/replace). */
2327
+ getMount(name) {
2328
+ return this.mounts.get(name);
2329
+ }
2289
2330
  /**
2290
2331
  * Get server info — each mount has its own URL (per-mount subdomain).
2291
2332
  */
@@ -2437,9 +2478,9 @@ class ServeManager {
2437
2478
  });
2438
2479
  const warmupPath = cfg.warmupPath ?? "/";
2439
2480
  const warmupTimeoutMs = cfg.warmupTimeoutMs ?? 3e4;
2440
- const warmupPromise = this.warmupProbe(`http://127.0.0.1:${cfg.port}${warmupPath}`, warmupTimeoutMs).then(() => {
2481
+ const warmupPromise = this.warmupProbe(`http://127.0.0.1:${cfg.port}${warmupPath}`, warmupTimeoutMs, child).then(() => {
2441
2482
  const h = this.managedProcs.get(name);
2442
- if (h) {
2483
+ if (h && h.child === child) {
2443
2484
  h.warmupPromise = null;
2444
2485
  h.lastRequestAt = Date.now();
2445
2486
  }
@@ -2447,7 +2488,8 @@ class ServeManager {
2447
2488
  }).catch((err) => {
2448
2489
  this.log(`Managed process '${name}' warmup failed: ${err.message}`);
2449
2490
  this.killManagedTree(child, "SIGTERM");
2450
- this.managedProcs.delete(name);
2491
+ const h = this.managedProcs.get(name);
2492
+ if (h && h.child === child) this.managedProcs.delete(name);
2451
2493
  throw err;
2452
2494
  });
2453
2495
  const newHandle = {
@@ -2482,10 +2524,13 @@ class ServeManager {
2482
2524
  }
2483
2525
  }
2484
2526
  /** Poll a URL until it returns <500 or the deadline passes. */
2485
- async warmupProbe(url, timeoutMs) {
2527
+ async warmupProbe(url, timeoutMs, child) {
2486
2528
  const deadline = Date.now() + timeoutMs;
2487
2529
  let lastErr;
2488
2530
  while (Date.now() < deadline) {
2531
+ if (child && child.exitCode !== null) {
2532
+ throw new Error(`managed process exited (code=${child.exitCode}) before warmup completed`);
2533
+ }
2489
2534
  try {
2490
2535
  const ctrl = new AbortController();
2491
2536
  const t = setTimeout(() => ctrl.abort(), 2e3);
@@ -2572,8 +2617,12 @@ class ServeManager {
2572
2617
  };
2573
2618
  try {
2574
2619
  const tmp = `${this.persistFile}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
2575
- fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
2620
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 384 });
2576
2621
  fs.renameSync(tmp, this.persistFile);
2622
+ try {
2623
+ fs.chmodSync(this.persistFile, 384);
2624
+ } catch {
2625
+ }
2577
2626
  } catch (err) {
2578
2627
  this.log(`Error persisting serve state: ${err.message}`);
2579
2628
  }
@@ -2603,218 +2652,234 @@ a{color:#0969da;text-decoration:none;font-weight:500}a:hover{text-decoration:und
2603
2652
  startAuthProxy() {
2604
2653
  return new Promise((resolve, reject) => {
2605
2654
  const server = http.createServer(async (req, res) => {
2606
- const url = new URL(req.url || "/", `http://127.0.0.1:${this.port}`);
2607
- const incomingHost = (req.headers.host || "").split(":")[0].toLowerCase();
2608
- const hostMount = this.hostToMount.get(incomingHost);
2609
- let mountName;
2610
- let mountResolvedByHost = false;
2611
- let basePath;
2612
- if (hostMount && this.mounts.has(hostMount)) {
2613
- mountName = hostMount;
2614
- mountResolvedByHost = true;
2615
- basePath = url.pathname;
2616
- } else {
2617
- mountName = url.pathname.split("/").filter(Boolean)[0];
2618
- basePath = mountName ? url.pathname.slice(`/${mountName}`.length) || "/" : url.pathname;
2619
- }
2620
- const mount = mountName ? this.mounts.get(mountName) : void 0;
2621
- if (basePath === "/__svamp_health" || url.pathname === "/__svamp_health") {
2622
- res.writeHead(200, {
2623
- "Content-Type": "application/json",
2624
- "Cache-Control": "no-store"
2625
- });
2626
- res.end(JSON.stringify({
2627
- ok: true,
2628
- mount: mountName || null,
2629
- ts: Date.now()
2630
- }));
2631
- return;
2632
- }
2633
- if (basePath === "/__login__" || url.pathname === "/__login__") {
2634
- const returnUrl = url.searchParams.get("return") || "/";
2635
- const isSameOriginPath = returnUrl.startsWith("/") && !returnUrl.startsWith("//") && !returnUrl.startsWith("/__login__");
2636
- const safeReturn = isSameOriginPath ? returnUrl : "/";
2637
- const html = this.auth ? this.auth.getLoginPageHtml(safeReturn) : "<h1>Auth not configured</h1>";
2638
- res.writeHead(200, {
2639
- "Content-Type": "text/html; charset=utf-8",
2640
- "Cache-Control": "no-store"
2641
- });
2642
- res.end(html);
2643
- return;
2644
- }
2645
- if (mount && mount.access !== "public" && mount.access !== "link") {
2646
- const userEmail = this.auth ? await this.auth.authenticate(req).catch(() => null) : null;
2647
- if (mount.access === "owner" && !mount.ownerEmail) {
2648
- this.log(`Auth DENY '${mountName}': owner-only mount but ownerEmail is empty (misconfigured \u2014 the daemon's token has no email claim). Set access to an explicit email allowlist. requester=${userEmail || "anonymous"}`);
2649
- const html = this.auth ? this.auth.getAccessDeniedHtml({ email: userEmail, access: mount.access, ownerEmail: mount.ownerEmail, misconfigured: true, returnUrl: req.url || "/" }) : "Access denied (misconfigured owner mount).";
2650
- res.writeHead(403, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
2655
+ try {
2656
+ const url = new URL(req.url || "/", `http://127.0.0.1:${this.port}`);
2657
+ const incomingHost = (req.headers.host || "").split(":")[0].toLowerCase();
2658
+ const hostMount = this.hostToMount.get(incomingHost);
2659
+ let mountName;
2660
+ let mountResolvedByHost = false;
2661
+ let basePath;
2662
+ if (hostMount && this.mounts.has(hostMount)) {
2663
+ mountName = hostMount;
2664
+ mountResolvedByHost = true;
2665
+ basePath = url.pathname;
2666
+ } else {
2667
+ mountName = url.pathname.split("/").filter(Boolean)[0];
2668
+ basePath = mountName ? url.pathname.slice(`/${mountName}`.length) || "/" : url.pathname;
2669
+ }
2670
+ const mount = mountName ? this.mounts.get(mountName) : void 0;
2671
+ if (basePath === "/__svamp_health" || url.pathname === "/__svamp_health") {
2672
+ res.writeHead(200, {
2673
+ "Content-Type": "application/json",
2674
+ "Cache-Control": "no-store"
2675
+ });
2676
+ res.end(JSON.stringify({
2677
+ ok: true,
2678
+ mount: mountName || null,
2679
+ ts: Date.now()
2680
+ }));
2681
+ return;
2682
+ }
2683
+ if (basePath === "/__login__" || url.pathname === "/__login__") {
2684
+ const returnUrl = url.searchParams.get("return") || "/";
2685
+ const isSameOriginPath = returnUrl.startsWith("/") && !returnUrl.startsWith("//") && !returnUrl.startsWith("/__login__");
2686
+ const safeReturn = isSameOriginPath ? returnUrl : "/";
2687
+ const html = this.auth ? this.auth.getLoginPageHtml(safeReturn) : "<h1>Auth not configured</h1>";
2688
+ res.writeHead(200, {
2689
+ "Content-Type": "text/html; charset=utf-8",
2690
+ "Cache-Control": "no-store"
2691
+ });
2651
2692
  res.end(html);
2652
2693
  return;
2653
2694
  }
2654
- const allowed = this.auth ? this.auth.isAuthorized(userEmail, mount.access, mount.ownerEmail) : false;
2655
- if (!allowed) {
2656
- if (!userEmail) {
2657
- this.log(`Auth: '${mountName}' requires sign-in (no valid token) \u2014 redirecting to /__login__`);
2658
- const loginUrl = `/__login__?return=${encodeURIComponent(req.url || "/")}`;
2659
- const headers = { Location: loginUrl };
2660
- if (hasCookieToken(req)) {
2661
- headers["Set-Cookie"] = "svamp_serve_token=; Path=/; Max-Age=0; SameSite=Lax";
2695
+ if (mount && mount.access !== "public" && mount.access !== "link") {
2696
+ const userEmail = this.auth ? await this.auth.authenticate(req).catch(() => null) : null;
2697
+ if (mount.access === "owner" && !mount.ownerEmail) {
2698
+ this.log(`Auth DENY '${mountName}': owner-only mount but ownerEmail is empty (misconfigured \u2014 the daemon's token has no email claim). Set access to an explicit email allowlist. requester=${userEmail || "anonymous"}`);
2699
+ const html = this.auth ? this.auth.getAccessDeniedHtml({ email: userEmail, access: mount.access, ownerEmail: mount.ownerEmail, misconfigured: true, returnUrl: req.url || "/" }) : "Access denied (misconfigured owner mount).";
2700
+ res.writeHead(403, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
2701
+ res.end(html);
2702
+ return;
2703
+ }
2704
+ const allowed = this.auth ? this.auth.isAuthorized(userEmail, mount.access, mount.ownerEmail) : false;
2705
+ if (!allowed) {
2706
+ if (!userEmail) {
2707
+ this.log(`Auth: '${mountName}' requires sign-in (no valid token) \u2014 redirecting to /__login__`);
2708
+ const loginUrl = `/__login__?return=${encodeURIComponent(req.url || "/")}`;
2709
+ const headers = { Location: loginUrl };
2710
+ if (hasCookieToken(req)) {
2711
+ headers["Set-Cookie"] = "svamp_serve_token=; Path=/; Max-Age=0; SameSite=Lax";
2712
+ }
2713
+ res.writeHead(302, headers);
2714
+ res.end();
2715
+ return;
2662
2716
  }
2663
- res.writeHead(302, headers);
2664
- res.end();
2717
+ const need = mount.access === "owner" ? `owner (${mount.ownerEmail || "unset"})` : `one of [${mount.access.join(", ")}]`;
2718
+ this.log(`Auth DENY '${mountName}': '${userEmail}' is not authorized (requires ${need})`);
2719
+ const html = this.auth ? this.auth.getAccessDeniedHtml({ email: userEmail, access: mount.access, ownerEmail: mount.ownerEmail, returnUrl: req.url || "/" }) : `Access denied for ${userEmail}.`;
2720
+ res.writeHead(403, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
2721
+ res.end(html);
2665
2722
  return;
2666
2723
  }
2667
- const need = mount.access === "owner" ? `owner (${mount.ownerEmail || "unset"})` : `one of [${mount.access.join(", ")}]`;
2668
- this.log(`Auth DENY '${mountName}': '${userEmail}' is not authorized (requires ${need})`);
2669
- const html = this.auth ? this.auth.getAccessDeniedHtml({ email: userEmail, access: mount.access, ownerEmail: mount.ownerEmail, returnUrl: req.url || "/" }) : `Access denied for ${userEmail}.`;
2670
- res.writeHead(403, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
2671
- res.end(html);
2672
- return;
2724
+ this.log(`Auth OK '${mountName}': ${userEmail}`);
2673
2725
  }
2674
- this.log(`Auth OK '${mountName}': ${userEmail}`);
2675
- }
2676
- const resolveContained = () => {
2677
- const base = path.resolve(mount.directory);
2678
- const fp = path.resolve(path.join(base, basePath));
2679
- if (fp !== base && !fp.startsWith(base + path.sep)) return null;
2680
- try {
2681
- const realRoot = fs.realpathSync(base);
2682
- let ancestor = fp;
2683
- while (!fs.existsSync(ancestor) && path.dirname(ancestor) !== ancestor) {
2684
- ancestor = path.dirname(ancestor);
2726
+ const resolveContained = () => {
2727
+ const base = path.resolve(mount.directory);
2728
+ const fp = path.resolve(path.join(base, basePath));
2729
+ if (fp !== base && !fp.startsWith(base + path.sep)) return null;
2730
+ try {
2731
+ const realRoot = fs.realpathSync(base);
2732
+ let ancestor = fp;
2733
+ while (!fs.existsSync(ancestor) && path.dirname(ancestor) !== ancestor) {
2734
+ ancestor = path.dirname(ancestor);
2735
+ }
2736
+ const realAncestor = fs.realpathSync(ancestor);
2737
+ if (realAncestor !== realRoot && !realAncestor.startsWith(realRoot + path.sep)) return null;
2738
+ if (fs.existsSync(fp) && fs.lstatSync(fp).isSymbolicLink()) {
2739
+ const realTarget = fs.realpathSync(fp);
2740
+ if (realTarget !== realRoot && !realTarget.startsWith(realRoot + path.sep)) return null;
2741
+ }
2742
+ } catch {
2743
+ return null;
2685
2744
  }
2686
- const realAncestor = fs.realpathSync(ancestor);
2687
- if (realAncestor !== realRoot && !realAncestor.startsWith(realRoot + path.sep)) return null;
2688
- if (fs.existsSync(fp) && fs.lstatSync(fp).isSymbolicLink()) {
2689
- const realTarget = fs.realpathSync(fp);
2690
- if (realTarget !== realRoot && !realTarget.startsWith(realRoot + path.sep)) return null;
2745
+ return fp;
2746
+ };
2747
+ if ((req.method === "PUT" || req.method === "DELETE") && mount && mount.directory) {
2748
+ if (mount.access === "public") {
2749
+ this.log(`Write DENY '${mountName}': ${req.method} not allowed on a public (unauthenticated) mount`);
2750
+ res.writeHead(403, { "Content-Type": "text/plain" });
2751
+ res.end("Write access denied: public mounts are read-only.");
2752
+ return;
2753
+ }
2754
+ const filePath = resolveContained();
2755
+ if (!filePath) {
2756
+ this.log(`Write DENY '${mountName}': path escapes mount root (${basePath})`);
2757
+ res.writeHead(403, { "Content-Type": "text/plain" });
2758
+ res.end("Forbidden: path escapes mount root.");
2759
+ return;
2760
+ }
2761
+ if (req.method === "PUT") {
2762
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
2763
+ const ws = fs.createWriteStream(filePath);
2764
+ let done = false;
2765
+ const cleanupPartial = () => {
2766
+ if (done) return;
2767
+ done = true;
2768
+ ws.destroy();
2769
+ fs.unlink(filePath, () => {
2770
+ });
2771
+ };
2772
+ req.on("aborted", cleanupPartial);
2773
+ req.on("close", () => {
2774
+ if (!ws.writableFinished && !req.complete) cleanupPartial();
2775
+ });
2776
+ req.pipe(ws);
2777
+ ws.on("finish", () => {
2778
+ done = true;
2779
+ res.writeHead(201);
2780
+ res.end();
2781
+ });
2782
+ ws.on("error", (err) => {
2783
+ if (done) return;
2784
+ done = true;
2785
+ res.writeHead(500);
2786
+ res.end(err.message);
2787
+ });
2788
+ return;
2789
+ }
2790
+ try {
2791
+ fs.unlinkSync(filePath);
2792
+ res.writeHead(204);
2793
+ res.end();
2794
+ } catch (err) {
2795
+ res.writeHead(err.code === "ENOENT" ? 404 : 500);
2796
+ res.end(err.message);
2691
2797
  }
2692
- } catch {
2693
- return null;
2694
- }
2695
- return fp;
2696
- };
2697
- if ((req.method === "PUT" || req.method === "DELETE") && mount && mount.directory) {
2698
- if (mount.access === "public") {
2699
- this.log(`Write DENY '${mountName}': ${req.method} not allowed on a public (unauthenticated) mount`);
2700
- res.writeHead(403, { "Content-Type": "text/plain" });
2701
- res.end("Write access denied: public mounts are read-only.");
2702
- return;
2703
- }
2704
- const filePath = resolveContained();
2705
- if (!filePath) {
2706
- this.log(`Write DENY '${mountName}': path escapes mount root (${basePath})`);
2707
- res.writeHead(403, { "Content-Type": "text/plain" });
2708
- res.end("Forbidden: path escapes mount root.");
2709
2798
  return;
2710
2799
  }
2711
- if (req.method === "PUT") {
2712
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
2713
- const ws = fs.createWriteStream(filePath);
2714
- let done = false;
2715
- const cleanupPartial = () => {
2716
- if (done) return;
2717
- done = true;
2718
- ws.destroy();
2719
- fs.unlink(filePath, () => {
2720
- });
2721
- };
2722
- req.on("aborted", cleanupPartial);
2723
- req.on("close", () => {
2724
- if (!ws.writableFinished) cleanupPartial();
2800
+ if (mount && mount.process) {
2801
+ const cfg = mount.process;
2802
+ if (cfg.wakeOnRequest || !this.managedProcs.has(mount.name)) {
2803
+ try {
2804
+ await this.ensureManagedRunning(mount.name);
2805
+ } catch (err) {
2806
+ res.writeHead(503, { "Content-Type": "text/plain" });
2807
+ res.end(`Backend not ready: ${err?.message || err}`);
2808
+ return;
2809
+ }
2810
+ }
2811
+ const handle = this.managedProcs.get(mount.name);
2812
+ if (handle) {
2813
+ handle.lastRequestAt = Date.now();
2814
+ handle.activeConns++;
2815
+ let released = false;
2816
+ const release = () => {
2817
+ if (released) return;
2818
+ released = true;
2819
+ handle.activeConns = Math.max(0, handle.activeConns - 1);
2820
+ handle.lastRequestAt = Date.now();
2821
+ };
2822
+ res.on("close", release);
2823
+ }
2824
+ const targetPath = stripServeTokenParam(mountResolvedByHost ? req.url || "/" : (basePath || "/") + (url.search || ""));
2825
+ const proxyReq = http.request({
2826
+ hostname: "127.0.0.1",
2827
+ port: cfg.port,
2828
+ path: targetPath,
2829
+ method: req.method,
2830
+ headers: sanitizeForwardHeaders(req.headers)
2831
+ }, (proxyRes) => {
2832
+ res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
2833
+ proxyRes.pipe(res);
2725
2834
  });
2726
- req.pipe(ws);
2727
- ws.on("finish", () => {
2728
- done = true;
2729
- res.writeHead(201);
2730
- res.end();
2835
+ proxyReq.on("error", (err) => {
2836
+ if (!res.headersSent) {
2837
+ res.writeHead(502);
2838
+ res.end(`Backend error: ${err.message}`);
2839
+ }
2731
2840
  });
2732
- ws.on("error", (err) => {
2733
- if (done) return;
2734
- done = true;
2735
- res.writeHead(500);
2736
- res.end(err.message);
2841
+ const abortUpstream = () => {
2842
+ if (!proxyReq.destroyed) proxyReq.destroy();
2843
+ };
2844
+ req.on("aborted", abortUpstream);
2845
+ res.on("close", () => {
2846
+ if (!res.writableFinished) abortUpstream();
2737
2847
  });
2848
+ proxyReq.setTimeout(12e4, abortUpstream);
2849
+ req.pipe(proxyReq);
2738
2850
  return;
2739
2851
  }
2740
- try {
2741
- fs.unlinkSync(filePath);
2742
- res.writeHead(204);
2743
- res.end();
2744
- } catch (err) {
2745
- res.writeHead(err.code === "ENOENT" ? 404 : 500);
2746
- res.end(err.message);
2852
+ if (mount && mount.directory) {
2853
+ serveStaticMount(req, res, {
2854
+ rootDir: mount.directory,
2855
+ relPath: basePath || "/",
2856
+ // Path-based access is prefixed with /<mountName>; host-based has no prefix.
2857
+ mountUrlPrefix: mountResolvedByHost ? "" : `/${mountName}`,
2858
+ browse: true,
2859
+ log: (m) => this.log(m)
2860
+ });
2861
+ return;
2747
2862
  }
2748
- return;
2749
- }
2750
- if (mount && mount.process) {
2751
- const cfg = mount.process;
2752
- if (cfg.wakeOnRequest || !this.managedProcs.has(mount.name)) {
2863
+ const cors = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", "Access-Control-Allow-Headers": "*" };
2864
+ if (url.pathname === "/" || basePath === "/") {
2865
+ res.writeHead(200, { ...cors, "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
2866
+ res.end(this.mountListHtml());
2867
+ } else {
2868
+ res.writeHead(404, { ...cors, "Content-Type": "text/plain" });
2869
+ res.end("Not found");
2870
+ }
2871
+ } catch (err) {
2872
+ this.log(`Auth-proxy request error: ${err?.message || err}`);
2873
+ if (!res.headersSent) {
2753
2874
  try {
2754
- await this.ensureManagedRunning(mount.name);
2755
- } catch (err) {
2756
- res.writeHead(503, { "Content-Type": "text/plain" });
2757
- res.end(`Backend not ready: ${err?.message || err}`);
2758
- return;
2875
+ res.writeHead(500, { "Content-Type": "text/plain" });
2876
+ } catch {
2759
2877
  }
2760
2878
  }
2761
- const handle = this.managedProcs.get(mount.name);
2762
- if (handle) {
2763
- handle.lastRequestAt = Date.now();
2764
- handle.activeConns++;
2765
- let released = false;
2766
- const release = () => {
2767
- if (released) return;
2768
- released = true;
2769
- handle.activeConns = Math.max(0, handle.activeConns - 1);
2770
- handle.lastRequestAt = Date.now();
2771
- };
2772
- res.on("close", release);
2879
+ try {
2880
+ res.end("Internal Server Error");
2881
+ } catch {
2773
2882
  }
2774
- const targetPath = mountResolvedByHost ? req.url || "/" : (basePath || "/") + (url.search || "");
2775
- const proxyReq = http.request({
2776
- hostname: "127.0.0.1",
2777
- port: cfg.port,
2778
- path: targetPath,
2779
- method: req.method,
2780
- headers: req.headers
2781
- }, (proxyRes) => {
2782
- res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
2783
- proxyRes.pipe(res);
2784
- });
2785
- proxyReq.on("error", (err) => {
2786
- if (!res.headersSent) {
2787
- res.writeHead(502);
2788
- res.end(`Backend error: ${err.message}`);
2789
- }
2790
- });
2791
- const abortUpstream = () => {
2792
- if (!proxyReq.destroyed) proxyReq.destroy();
2793
- };
2794
- req.on("aborted", abortUpstream);
2795
- res.on("close", () => {
2796
- if (!res.writableFinished) abortUpstream();
2797
- });
2798
- proxyReq.setTimeout(12e4, abortUpstream);
2799
- req.pipe(proxyReq);
2800
- return;
2801
- }
2802
- if (mount && mount.directory) {
2803
- serveStaticMount(req, res, {
2804
- rootDir: mount.directory,
2805
- relPath: basePath || "/",
2806
- // Path-based access is prefixed with /<mountName>; host-based has no prefix.
2807
- mountUrlPrefix: mountResolvedByHost ? "" : `/${mountName}`,
2808
- browse: true});
2809
- return;
2810
- }
2811
- const cors = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", "Access-Control-Allow-Headers": "*" };
2812
- if (url.pathname === "/" || basePath === "/") {
2813
- res.writeHead(200, { ...cors, "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
2814
- res.end(this.mountListHtml());
2815
- } else {
2816
- res.writeHead(404, { ...cors, "Content-Type": "text/plain" });
2817
- res.end("Not found");
2818
2883
  }
2819
2884
  });
2820
2885
  server.on("upgrade", (req, clientSocket, head) => {
@@ -2882,11 +2947,20 @@ Connection: close\r
2882
2947
  handle.lastRequestAt = Date.now();
2883
2948
  });
2884
2949
  }
2885
- const targetPath = mountResolvedByHost ? req.url || "/" : (url.pathname.slice(`/${mountName}`.length) || "/") + (url.search || "");
2950
+ const targetPath = stripServeTokenParam(mountResolvedByHost ? req.url || "/" : (url.pathname.slice(`/${mountName}`.length) || "/") + (url.search || ""));
2886
2951
  const upstream = net.connect(cfg.port, "127.0.0.1", () => {
2887
2952
  const lines = [`${req.method} ${targetPath} HTTP/1.1`];
2888
2953
  for (let i = 0; i < req.rawHeaders.length; i += 2) {
2889
- lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
2954
+ const name = req.rawHeaders[i];
2955
+ const lname = name.toLowerCase();
2956
+ let value = req.rawHeaders[i + 1];
2957
+ if (lname === "authorization") continue;
2958
+ if (lname === "cookie") {
2959
+ const cleaned = stripServeAuthCookie(value);
2960
+ if (cleaned === void 0) continue;
2961
+ value = cleaned;
2962
+ }
2963
+ lines.push(`${name}: ${value}`);
2890
2964
  }
2891
2965
  upstream.write(lines.join("\r\n") + "\r\n\r\n");
2892
2966
  if (head && head.length) upstream.write(head);
@@ -2973,7 +3047,7 @@ Connection: close\r
2973
3047
  const mount = this.mounts.get(mountName);
2974
3048
  const subdomainOverride = mount?.access === "link" && mount.linkToken ? /* @__PURE__ */ new Map([[this.port, buildLinkSubdomain(subdomainSafe, mount.linkToken)]]) : void 0;
2975
3049
  try {
2976
- const { FrpcTunnel } = await import('./frpc-Dsada5ox.mjs');
3050
+ const { FrpcTunnel } = await import('./frpc-C1jS23Sn.mjs');
2977
3051
  let tunnel;
2978
3052
  tunnel = new FrpcTunnel({
2979
3053
  name: tunnelName,
@@ -3910,6 +3984,46 @@ function renderTemplate(template, ctx) {
3910
3984
  });
3911
3985
  }
3912
3986
 
3987
+ const _sleepSab$1 = new Int32Array(new SharedArrayBuffer(4));
3988
+ function sleepSync$1(ms) {
3989
+ try {
3990
+ Atomics.wait(_sleepSab$1, 0, 0, ms);
3991
+ } catch {
3992
+ }
3993
+ }
3994
+ function withFileLock(lockPath, fn, opts) {
3995
+ const deadlineMs = 50;
3996
+ const staleMs = 5e3;
3997
+ const deadline = Date.now() + deadlineMs;
3998
+ let held = false;
3999
+ while (Date.now() < deadline) {
4000
+ try {
4001
+ mkdirSync$1(lockPath);
4002
+ held = true;
4003
+ break;
4004
+ } catch {
4005
+ try {
4006
+ if (Date.now() - statSync(lockPath).mtimeMs > staleMs) {
4007
+ rmSync$1(lockPath, { recursive: true, force: true });
4008
+ continue;
4009
+ }
4010
+ } catch {
4011
+ }
4012
+ sleepSync$1(5);
4013
+ }
4014
+ }
4015
+ try {
4016
+ return fn();
4017
+ } finally {
4018
+ if (held) {
4019
+ try {
4020
+ rmSync$1(lockPath, { recursive: true, force: true });
4021
+ } catch {
4022
+ }
4023
+ }
4024
+ }
4025
+ }
4026
+
3913
4027
  const HARD_MAX_BYTES = 25 * 1024 * 1024;
3914
4028
  const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
3915
4029
  const DEFAULT_MAX_COUNT = 100;
@@ -4125,6 +4239,22 @@ class ChannelStore {
4125
4239
  _path(id) {
4126
4240
  return join$1(this.dir, `${id}.json`);
4127
4241
  }
4242
+ _lock(id) {
4243
+ return join$1(this.dir, `${id}.json.lock`);
4244
+ }
4245
+ // #0679: the actual validate + atomic write, WITHOUT the lock, so a locked RMW mutator can
4246
+ // reuse it inside its own lock section (no re-entrant re-lock / deadlock).
4247
+ _writeChannel(channel) {
4248
+ const c = { enabled: true, bind: "dynamic", template: DEFAULT_TEMPLATE, last_calls: [], ...channel };
4249
+ if (!c.id) c.id = genId();
4250
+ const errs = validateChannel(c);
4251
+ if (errs.length) throw new Error("invalid channel: " + errs.join("; "));
4252
+ mkdirSync$1(this.dir, { recursive: true });
4253
+ const tmp = `${this._path(c.id)}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
4254
+ writeFileSync$1(tmp, JSON.stringify(c, null, 2));
4255
+ renameSync(tmp, this._path(c.id));
4256
+ return c;
4257
+ }
4128
4258
  list() {
4129
4259
  if (!existsSync(this.dir)) return [];
4130
4260
  return readdirSync(this.dir).filter((f) => f.endsWith(".json")).map((f) => {
@@ -4143,15 +4273,8 @@ class ChannelStore {
4143
4273
  }
4144
4274
  }
4145
4275
  save(channel) {
4146
- const c = { enabled: true, bind: "dynamic", template: DEFAULT_TEMPLATE, last_calls: [], ...channel };
4147
- if (!c.id) c.id = genId();
4148
- const errs = validateChannel(c);
4149
- if (errs.length) throw new Error("invalid channel: " + errs.join("; "));
4150
- mkdirSync$1(this.dir, { recursive: true });
4151
- const tmp = `${this._path(c.id)}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
4152
- writeFileSync$1(tmp, JSON.stringify(c, null, 2));
4153
- renameSync(tmp, this._path(c.id));
4154
- return c;
4276
+ if (channel.id) return withFileLock(this._lock(channel.id), () => this._writeChannel(channel));
4277
+ return this._writeChannel(channel);
4155
4278
  }
4156
4279
  remove(id) {
4157
4280
  const p = this._path(id);
@@ -4161,28 +4284,40 @@ class ChannelStore {
4161
4284
  }
4162
4285
  return false;
4163
4286
  }
4287
+ // #0679: setEnabled/recordCall/addCaller are read-modify-write mutators — multiple ChannelStore
4288
+ // instances (one per session) point at the same .svamp/channels/<id>.json, so without
4289
+ // serialization two concurrent RMWs both read the same base and the second save() clobbers the
4290
+ // first (addCaller silently DROPS a freshly-generated caller key). Hold the bounded fail-open
4291
+ // per-channel lock across the whole read→mutate→write so the read sees the other's committed
4292
+ // change (mirrors inboxGuard's #0625 withAwaitLock). _writeChannel is the un-locked write.
4164
4293
  setEnabled(id, enabled) {
4165
- const c = this.get(id);
4166
- if (!c) return null;
4167
- c.enabled = enabled;
4168
- return this.save(c);
4294
+ return withFileLock(this._lock(id), () => {
4295
+ const c = this.get(id);
4296
+ if (!c) return null;
4297
+ c.enabled = enabled;
4298
+ return this._writeChannel(c);
4299
+ });
4169
4300
  }
4170
4301
  recordCall(id, entry) {
4171
- const c = this.get(id);
4172
- if (!c) return;
4173
- c.last_calls = c.last_calls || [];
4174
- c.last_calls.unshift({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
4175
- c.last_calls = c.last_calls.slice(0, 20);
4176
- this.save(c);
4302
+ withFileLock(this._lock(id), () => {
4303
+ const c = this.get(id);
4304
+ if (!c) return;
4305
+ c.last_calls = c.last_calls || [];
4306
+ c.last_calls.unshift({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
4307
+ c.last_calls = c.last_calls.slice(0, 20);
4308
+ this._writeChannel(c);
4309
+ });
4177
4310
  }
4178
4311
  addCaller(id, name, kind = "agent") {
4179
- const c = this.get(id);
4180
- if (!c) return null;
4181
- c.identity.callers = c.identity.callers || [];
4182
- const caller = { name, kind, key: genKey() };
4183
- c.identity.callers.push(caller);
4184
- this.save(c);
4185
- return caller;
4312
+ return withFileLock(this._lock(id), () => {
4313
+ const c = this.get(id);
4314
+ if (!c) return null;
4315
+ c.identity.callers = c.identity.callers || [];
4316
+ const caller = { name, kind, key: genKey() };
4317
+ c.identity.callers.push(caller);
4318
+ this._writeChannel(c);
4319
+ return caller;
4320
+ });
4186
4321
  }
4187
4322
  }
4188
4323
  function routingSession(channel, ctx) {
@@ -4326,6 +4461,13 @@ curl -X POST "${sendUrl}" \\
4326
4461
  Always-current copy of this skill (all ids filled in): ${skillUrl}${uploadSection}${queueSection}`;
4327
4462
  }
4328
4463
 
4464
+ function keysMatch(a, b) {
4465
+ if (typeof a !== "string" || typeof b !== "string") return false;
4466
+ const ba = Buffer.from(a);
4467
+ const bb = Buffer.from(b);
4468
+ if (ba.length !== bb.length) return false;
4469
+ return timingSafeEqual(ba, bb);
4470
+ }
4329
4471
  function resolveSender(channel, input = {}) {
4330
4472
  const { key, from, hyphaUser, hyphaWorkspace, hyphaAnonymous } = input;
4331
4473
  const id = channel.identity || {};
@@ -4342,7 +4484,7 @@ function resolveSender(channel, input = {}) {
4342
4484
  return { sender: { name: id.fixed.name, kind: id.fixed.kind, verified: true } };
4343
4485
  }
4344
4486
  if (id.mode === "per-key") {
4345
- const caller = (id.callers || []).find((c) => c.key && c.key === key);
4487
+ const caller = (id.callers || []).find((c) => c.key && keysMatch(c.key, key));
4346
4488
  if (!caller) {
4347
4489
  const acceptsHypha = Array.isArray(id.hypha_allow) && id.hypha_allow.length > 0;
4348
4490
  return { error: acceptsHypha ? "invalid or missing key \u2014 supply a caller key issued by the channel owner, or call with an authenticated Hypha identity (this channel accepts hypha_allow callers; anonymous/keyless requests are rejected)" : "invalid or missing key \u2014 this channel requires a caller key issued by the channel owner" };
@@ -4350,7 +4492,7 @@ function resolveSender(channel, input = {}) {
4350
4492
  return { sender: { name: caller.name, kind: caller.kind, verified: true } };
4351
4493
  }
4352
4494
  if (id.mode === "caller-supplied") {
4353
- if (id.shared_key && key !== id.shared_key) return { error: "invalid key" };
4495
+ if (id.shared_key && !keysMatch(key, id.shared_key)) return { error: "invalid key" };
4354
4496
  return { sender: { name: from || "anonymous", kind: "user", verified: false } };
4355
4497
  }
4356
4498
  return { error: "unsupported identity mode" };
@@ -4592,6 +4734,11 @@ class ChannelOutbox {
4592
4734
  this.reload();
4593
4735
  this._compact();
4594
4736
  }
4737
+ {
4738
+ const sig = this._stat();
4739
+ this._lastMtimeMs = sig.mtimeMs;
4740
+ this._lastSize = sig.size;
4741
+ }
4595
4742
  this.emitter.emit(channelId, reply);
4596
4743
  return reply;
4597
4744
  }
@@ -4618,6 +4765,7 @@ class ChannelOutbox {
4618
4765
  * the next append (filtered to this channel + `to`) or `timeoutMs`, then return.
4619
4766
  */
4620
4767
  wait(channelId, cursor, to, timeoutMs, correlationId) {
4768
+ this.reload();
4621
4769
  const ready = this.since(channelId, cursor, to, correlationId);
4622
4770
  if (ready.length) return Promise.resolve({ replies: ready, cursor: this.cursor(channelId) });
4623
4771
  return new Promise((resolve) => {
@@ -4626,12 +4774,23 @@ class ChannelOutbox {
4626
4774
  cleanup();
4627
4775
  resolve({ replies: this.since(channelId, cursor, to, correlationId), cursor: this.cursor(channelId) });
4628
4776
  };
4777
+ const diskPoll = setInterval(() => {
4778
+ if (this.reload()) {
4779
+ const more = this.since(channelId, cursor, to, correlationId);
4780
+ if (more.length) {
4781
+ cleanup();
4782
+ resolve({ replies: more, cursor: this.cursor(channelId) });
4783
+ }
4784
+ }
4785
+ }, 1e3);
4786
+ diskPoll.unref?.();
4629
4787
  const timer = setTimeout(() => {
4630
4788
  cleanup();
4631
4789
  resolve({ replies: [], cursor: this.cursor(channelId) });
4632
4790
  }, Math.max(0, timeoutMs));
4633
4791
  const cleanup = () => {
4634
4792
  clearTimeout(timer);
4793
+ clearInterval(diskPoll);
4635
4794
  this.emitter.off(channelId, onReply);
4636
4795
  };
4637
4796
  this.emitter.on(channelId, onReply);
@@ -4650,11 +4809,57 @@ class ChannelOutbox {
4650
4809
  if (existsSync(this.file)) this.reload();
4651
4810
  this.byChannel.delete(channelId);
4652
4811
  this.seqByChannel.delete(channelId);
4812
+ if (this.highWater.delete(channelId)) this._persistHighWater();
4653
4813
  if (!existsSync(this.file)) return;
4654
4814
  this._compact();
4655
4815
  }
4656
4816
  }
4657
4817
 
4818
+ const SESSION_METHOD_MIN_ROLE = {
4819
+ // interact
4820
+ sendMessage: "interact",
4821
+ abort: "interact",
4822
+ permissionResponse: "interact",
4823
+ regenerateSummary: "interact",
4824
+ keepAlive: "interact",
4825
+ sessionEnd: "interact",
4826
+ markInboxRead: "interact",
4827
+ sendInboxMessage: "interact",
4828
+ // admin
4829
+ updateMetadata: "admin",
4830
+ updateConfig: "admin",
4831
+ saveChannel: "admin",
4832
+ removeChannel: "admin",
4833
+ setChannelEnabled: "admin",
4834
+ addChannelCaller: "admin",
4835
+ outpostConnectInfo: "admin",
4836
+ outpostExec: "admin",
4837
+ channelReply: "admin",
4838
+ updateAgentState: "admin",
4839
+ switchMode: "admin",
4840
+ restartClaude: "admin",
4841
+ archiveSession: "admin",
4842
+ readFile: "admin",
4843
+ writeFile: "admin",
4844
+ listDirectory: "admin",
4845
+ bash: "admin",
4846
+ issue: "admin",
4847
+ workflow: "admin",
4848
+ ripgrep: "admin",
4849
+ getDirectoryTree: "admin",
4850
+ updateSharing: "admin",
4851
+ updateSecurityContext: "admin",
4852
+ setClaudeChrome: "admin",
4853
+ applySystemPrompt: "admin",
4854
+ clearInbox: "admin",
4855
+ btw: "admin",
4856
+ editMessage: "admin",
4857
+ refineLastReply: "admin",
4858
+ undoLastEdit: "admin"
4859
+ };
4860
+ function sessionMethodMinRole(method) {
4861
+ return SESSION_METHOD_MIN_ROLE[method] ?? "view";
4862
+ }
4658
4863
  function getParamNames(fn) {
4659
4864
  const src = fn.toString();
4660
4865
  const match = src.match(/^(?:async\s+)?(?:function\s*\w*)?\s*\(([^)]*)\)/);
@@ -4909,9 +5114,10 @@ async function registerMachineService(server, machineId, metadata, daemonState,
4909
5114
  } catch {
4910
5115
  }
4911
5116
  if (hasMachineAccess) {
5117
+ const { securityContextConfig: _sc2, ...safeMetadata2 } = currentMetadata;
4912
5118
  return {
4913
5119
  machineId,
4914
- metadata: currentMetadata,
5120
+ metadata: safeMetadata2,
4915
5121
  metadataVersion,
4916
5122
  daemonState: currentDaemonState,
4917
5123
  daemonStateVersion
@@ -4922,11 +5128,9 @@ async function registerMachineService(server, machineId, metadata, daemonState,
4922
5128
  for (const sid of sessionIds) {
4923
5129
  const rpc = handlers.getSessionRPCHandlers?.(sid);
4924
5130
  if (!rpc) continue;
4925
- try {
4926
- await rpc.getMetadata(context);
5131
+ if (await hasExplicitSessionAccess(rpc, "view", context)) {
4927
5132
  hasSessionAccess = true;
4928
5133
  break;
4929
- } catch {
4930
5134
  }
4931
5135
  }
4932
5136
  if (!hasSessionAccess) {
@@ -5031,10 +5235,11 @@ async function registerMachineService(server, machineId, metadata, daemonState,
5031
5235
  if (!rpc) {
5032
5236
  throw new Error(`Session ${sessionId} not found on this machine`);
5033
5237
  }
5238
+ const requiredRole = sessionMethodMinRole(method);
5034
5239
  try {
5035
- authorizeRequest(context, currentMetadata.sharing, "view");
5240
+ authorizeRequest(context, currentMetadata.sharing, requiredRole);
5036
5241
  } catch (machineErr) {
5037
- if (!await hasExplicitSessionAccess(rpc, "view", context)) throw machineErr;
5242
+ if (!await hasExplicitSessionAccess(rpc, requiredRole, context)) throw machineErr;
5038
5243
  }
5039
5244
  const handler = rpc[method];
5040
5245
  if (typeof handler !== "function") {
@@ -5883,7 +6088,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
5883
6088
  const tunnels = handlers.tunnels;
5884
6089
  if (!tunnels) throw new Error("Tunnel management not available");
5885
6090
  if (tunnels.has(params.name)) throw new Error(`Tunnel '${params.name}' already running`);
5886
- const { FrpcTunnel } = await import('./frpc-Dsada5ox.mjs');
6091
+ const { FrpcTunnel } = await import('./frpc-C1jS23Sn.mjs');
5887
6092
  const tunnel = new FrpcTunnel({
5888
6093
  name: params.name,
5889
6094
  ports: params.ports,
@@ -5998,6 +6203,14 @@ async function registerMachineService(server, machineId, metadata, daemonState,
5998
6203
  const { homedir } = await import('os');
5999
6204
  assertNonAdminMountDirSafe(params.directory, homedir());
6000
6205
  }
6206
+ const existingMount = sm.getMount(params.name);
6207
+ if (existingMount && !serveCallerTrusted(context)) {
6208
+ const callerEmail = (context?.user?.email || "").toLowerCase();
6209
+ const mountOwner = (existingMount.ownerEmail || "").toLowerCase();
6210
+ if (!callerEmail || !mountOwner || callerEmail !== mountOwner) {
6211
+ throw new Error(`Not authorized to replace mount '${params.name}' (owned by another user)`);
6212
+ }
6213
+ }
6001
6214
  const ownerEmail2 = params.ownerEmail || context?.user?.email || currentMetadata.sharing?.owner || process.env.SVAMP_OWNER_EMAIL || void 0;
6002
6215
  const access = params.access ?? "owner";
6003
6216
  return sm.applyMount({
@@ -6014,6 +6227,14 @@ async function registerMachineService(server, machineId, metadata, daemonState,
6014
6227
  authorizeRequest(context, currentMetadata.sharing, "interact");
6015
6228
  const sm = handlers.serveManager;
6016
6229
  if (!sm) throw new Error("Serve manager not available");
6230
+ const target = sm.getMount(params.name);
6231
+ if (target && !serveCallerTrusted(context)) {
6232
+ const callerEmail = (context?.user?.email || "").toLowerCase();
6233
+ const mountOwner = (target.ownerEmail || "").toLowerCase();
6234
+ if (!callerEmail || !mountOwner || callerEmail !== mountOwner) {
6235
+ throw new Error(`Not authorized to remove mount '${params.name}' (owned by another user)`);
6236
+ }
6237
+ }
6017
6238
  await sm.removeMount(params.name);
6018
6239
  return { removed: true };
6019
6240
  },
@@ -6033,7 +6254,9 @@ async function registerMachineService(server, machineId, metadata, daemonState,
6033
6254
  if (!sm) throw new Error("Serve manager not available");
6034
6255
  const info = sm.getInfo();
6035
6256
  const isAdmin = serveCallerTrusted(context);
6036
- return { ...info, mounts: (info.mounts || []).map((m) => sanitizeMountForRole(m, isAdmin)) };
6257
+ const mounts = (info.mounts || []).map((m) => sanitizeMountForRole(m, isAdmin));
6258
+ const topUrl = isAdmin ? info.url : mounts[0]?.url ?? null;
6259
+ return { ...info, url: topUrl, mounts };
6037
6260
  },
6038
6261
  /**
6039
6262
  * Aggregate frpc tunnel health for all serve mounts.
@@ -6359,7 +6582,7 @@ QUESTION: ${params.question || "Summarize this concisely."}` }
6359
6582
  }
6360
6583
  const deps = buildSessionDeps(rpc, { cwd, ownerEmail: owner });
6361
6584
  const sender = { name: context?.user?.email || context?.user?.id || "user", kind: "user", verified: true };
6362
- const { toolsForRole } = await import('./sideband-CSwHDjzp.mjs');
6585
+ const { toolsForRole } = await import('./sideband-DM1-IAV7.mjs');
6363
6586
  const r2 = await runWiseAgent({ message: params.message, sender, config: { tools: toolsForRole(role2) }, deps, transport, model: resolved.model });
6364
6587
  return fmt(r2);
6365
6588
  }
@@ -6464,7 +6687,7 @@ QUESTION: ${params.question || "Summarize this concisely."}` }
6464
6687
  return { ok: false, call_id: callId, status: "busy", error: "channel is busy (too many concurrent requests) \u2014 retry shortly" };
6465
6688
  }
6466
6689
  const rendered = renderMessage(c, { sender: r.sender, body: { message: kwargs.message }, callId });
6467
- const { queryCore } = await import('./commands--WPYI2sm.mjs');
6690
+ const { queryCore } = await import('./commands-CvwLQ3MM.mjs');
6468
6691
  const timeout = c.reply?.timeout_sec || 120;
6469
6692
  let result;
6470
6693
  try {
@@ -6742,6 +6965,48 @@ const AWAIT_MAX = num("SVAMP_INBOX_AWAIT_MAX", 200);
6742
6965
  function awaitPath(sessionId) {
6743
6966
  return join$1(SVAMP_HOME$2, "awaiting", `${sessionId}.json`);
6744
6967
  }
6968
+ const _sleepSab = new Int32Array(new SharedArrayBuffer(4));
6969
+ function sleepSync(ms) {
6970
+ try {
6971
+ Atomics.wait(_sleepSab, 0, 0, ms);
6972
+ } catch {
6973
+ }
6974
+ }
6975
+ function withAwaitLock(sessionId, fn) {
6976
+ const lock = awaitPath(sessionId) + ".lock";
6977
+ try {
6978
+ mkdirSync$1(join$1(SVAMP_HOME$2, "awaiting"), { recursive: true });
6979
+ } catch {
6980
+ }
6981
+ const deadline = Date.now() + 50;
6982
+ let held = false;
6983
+ while (Date.now() < deadline) {
6984
+ try {
6985
+ mkdirSync$1(lock);
6986
+ held = true;
6987
+ break;
6988
+ } catch {
6989
+ try {
6990
+ if (Date.now() - statSync(lock).mtimeMs > 5e3) {
6991
+ rmSync$1(lock, { recursive: true, force: true });
6992
+ continue;
6993
+ }
6994
+ } catch {
6995
+ }
6996
+ sleepSync(5);
6997
+ }
6998
+ }
6999
+ try {
7000
+ return fn();
7001
+ } finally {
7002
+ if (held) {
7003
+ try {
7004
+ rmSync$1(lock, { recursive: true, force: true });
7005
+ } catch {
7006
+ }
7007
+ }
7008
+ }
7009
+ }
6745
7010
  function readAwaiting(sessionId) {
6746
7011
  try {
6747
7012
  const p = awaitPath(sessionId);
@@ -6764,14 +7029,16 @@ function writeAwaiting(sessionId, map) {
6764
7029
  }
6765
7030
  function registerAwaitingReply(sessionId, threadId, now = Date.now()) {
6766
7031
  if (!sessionId || !threadId) return;
6767
- const map = readAwaiting(sessionId);
6768
- for (const k of Object.keys(map)) if (now - map[k] > AWAIT_TTL_MS) delete map[k];
6769
- map[threadId] = now;
6770
- const keys = Object.keys(map);
6771
- if (keys.length > AWAIT_MAX) {
6772
- keys.sort((a, b) => map[a] - map[b]).slice(0, keys.length - AWAIT_MAX).forEach((k) => delete map[k]);
6773
- }
6774
- writeAwaiting(sessionId, map);
7032
+ withAwaitLock(sessionId, () => {
7033
+ const map = readAwaiting(sessionId);
7034
+ for (const k of Object.keys(map)) if (now - map[k] > AWAIT_TTL_MS) delete map[k];
7035
+ map[threadId] = now;
7036
+ const keys = Object.keys(map);
7037
+ if (keys.length > AWAIT_MAX) {
7038
+ keys.sort((a, b) => map[a] - map[b]).slice(0, keys.length - AWAIT_MAX).forEach((k) => delete map[k]);
7039
+ }
7040
+ writeAwaiting(sessionId, map);
7041
+ });
6775
7042
  }
6776
7043
  function peekAwaitingReply(sessionId, threadId, now = Date.now()) {
6777
7044
  if (!sessionId || !threadId) return false;
@@ -6782,10 +7049,12 @@ function peekAwaitingReply(sessionId, threadId, now = Date.now()) {
6782
7049
  }
6783
7050
  function consumeAwaitingReply(sessionId, threadId) {
6784
7051
  if (!sessionId || !threadId) return;
6785
- const map = readAwaiting(sessionId);
6786
- if (!(threadId in map)) return;
6787
- delete map[threadId];
6788
- writeAwaiting(sessionId, map);
7052
+ withAwaitLock(sessionId, () => {
7053
+ const map = readAwaiting(sessionId);
7054
+ if (!(threadId in map)) return;
7055
+ delete map[threadId];
7056
+ writeAwaiting(sessionId, map);
7057
+ });
6789
7058
  }
6790
7059
  function computeOutboundHop(sessionId) {
6791
7060
  if (!sessionId) return { hopCount: 0, fromInboxTurn: false };
@@ -6908,7 +7177,7 @@ function saveInbox(projectDir, sessionId, inbox) {
6908
7177
  try {
6909
7178
  const p = inboxFilePath(projectDir, sessionId);
6910
7179
  mkdirSync$1(dirname(p), { recursive: true });
6911
- const tmp = `${p}.tmp-${process.pid}`;
7180
+ const tmp = `${p}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
6912
7181
  writeFileSync$1(tmp, JSON.stringify(inbox));
6913
7182
  renameSync(tmp, p);
6914
7183
  } catch {
@@ -7188,6 +7457,7 @@ class OutpostCoordinator {
7188
7457
  clearTimer;
7189
7458
  execTimeoutMs;
7190
7459
  staleMs;
7460
+ sweeper = null;
7191
7461
  constructor(opts = {}) {
7192
7462
  this.now = opts.now || (() => Date.now());
7193
7463
  this.setTimer = opts.setTimer || ((fn, ms) => setTimeout(fn, ms));
@@ -7208,6 +7478,35 @@ class OutpostCoordinator {
7208
7478
  }
7209
7479
  }
7210
7480
  }
7481
+ /** Public sweep — prune stale connections and fail their in-flight execs. Safe to call anytime. */
7482
+ sweepStale() {
7483
+ this.prune();
7484
+ }
7485
+ /**
7486
+ * Start a periodic background sweep so an in-flight exec to a machine that STOPS polling is
7487
+ * failed FAST (#0612) — the header's "rejected immediately with a clear disconnected error"
7488
+ * promise. Without this driver, prune() only ran on-demand (register/list/isConnected), so an
7489
+ * already-enqueued exec waited the full OUTPOST_EXEC_TIMEOUT_MS backstop. Opt-in (the daemon
7490
+ * enables it; the pure unit tests drive time via injected timers and never call this). Unref'd
7491
+ * so it never keeps the process alive; idempotent.
7492
+ */
7493
+ startSweeper(intervalMs = Math.max(5e3, Math.floor(this.staleMs / 3))) {
7494
+ if (this.sweeper) return;
7495
+ this.sweeper = setInterval(() => {
7496
+ try {
7497
+ this.prune();
7498
+ } catch {
7499
+ }
7500
+ }, intervalMs);
7501
+ this.sweeper.unref?.();
7502
+ }
7503
+ /** Stop the background sweep (e.g. on session teardown). Idempotent. */
7504
+ dispose() {
7505
+ if (this.sweeper) {
7506
+ clearInterval(this.sweeper);
7507
+ this.sweeper = null;
7508
+ }
7509
+ }
7211
7510
  /** Reject every pending exec on a machine with a synthetic disconnect result (exitCode -1). */
7212
7511
  failMachine(machine, reason) {
7213
7512
  for (const [id, p] of [...this.pending]) {
@@ -7309,6 +7608,7 @@ class OutpostCoordinator {
7309
7608
  resolveResult(result) {
7310
7609
  const p = this.pending.get(result.id);
7311
7610
  if (!p) return false;
7611
+ if (result.machine && result.machine !== p.machine) return false;
7312
7612
  this.clearTimer(p.timer);
7313
7613
  this.pending.delete(result.id);
7314
7614
  this.touch(p.machine);
@@ -7734,6 +8034,7 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
7734
8034
  const meetingActivity = [];
7735
8035
  const channelOutbox = new ChannelOutbox(initialMetadata.path);
7736
8036
  const outpostCoordinator = new OutpostCoordinator();
8037
+ outpostCoordinator.startSweeper();
7737
8038
  const outpostProjectDir = () => metadata.path || process.cwd();
7738
8039
  const announceOutpost = (conn) => {
7739
8040
  const who = conn.label || conn.machine;
@@ -7804,7 +8105,10 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
7804
8105
  const ensureParticipantsChannel = (sharing) => {
7805
8106
  try {
7806
8107
  if (sharing?.enabled) channelStore.save(buildParticipantsChannel(sharing));
7807
- else channelStore.remove(PARTICIPANTS_CHANNEL_ID);
8108
+ else {
8109
+ channelStore.remove(PARTICIPANTS_CHANNEL_ID);
8110
+ channelOutbox.purge(PARTICIPANTS_CHANNEL_ID);
8111
+ }
7808
8112
  } catch {
7809
8113
  }
7810
8114
  };
@@ -8228,7 +8532,7 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
8228
8532
  const rec = loadOutpostToken(outpostProjectDir(), sessionId);
8229
8533
  if (!outpostTokenMatches(rec, params?.token)) return { error: "unauthorized" };
8230
8534
  if (!params?.id) return { error: "missing result id" };
8231
- const ok = outpostCoordinator.resolveResult({ id: params.id, stdout: params.stdout, stderr: params.stderr, exitCode: params.exitCode });
8535
+ const ok = outpostCoordinator.resolveResult({ id: params.id, stdout: params.stdout, stderr: params.stderr, exitCode: params.exitCode, machine: params.machine });
8232
8536
  return { ok };
8233
8537
  },
8234
8538
  outpostExec: async (params, context) => {
@@ -8743,7 +9047,7 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
8743
9047
  // ── Inbox ──
8744
9048
  sendInboxMessage: async (message, context) => {
8745
9049
  authorizeRequest(context, metadata.sharing, "interact");
8746
- const trusted = getEffectiveRole(context, metadata.sharing) === "admin";
9050
+ const trusted = !context?.user || isSameOwnerWorkspace(context) || !!metadata.sharing && getEffectiveRole(context, metadata.sharing) === "admin";
8747
9051
  const msg = sanitizeInboundInboxMessage(message, { trusted, callerEmail: context?.user?.email });
8748
9052
  inbox.push(msg);
8749
9053
  while (inbox.length > INBOX_MAX) inbox.shift();
@@ -13517,7 +13821,13 @@ const MAX_RESTART_DELAY_S = 300;
13517
13821
  const BACKOFF_RESET_WINDOW_MS = 6e4;
13518
13822
  const MAX_LOG_LINES = 300;
13519
13823
  const CRASH_LOOP_FAILURE_THRESHOLD = 10;
13520
- const CRASH_LOOP_WINDOW_MS = 3e5;
13824
+ function accumulateCrashLoopFailure(prev, healthy, now) {
13825
+ if (healthy) return { consecutiveFailures: 0, failureWindowStart: void 0 };
13826
+ return {
13827
+ consecutiveFailures: prev.consecutiveFailures + 1,
13828
+ failureWindowStart: prev.failureWindowStart ?? now
13829
+ };
13830
+ }
13521
13831
  class ProcessSupervisor {
13522
13832
  entries = /* @__PURE__ */ new Map();
13523
13833
  persistDir;
@@ -13924,17 +14234,13 @@ class ProcessSupervisor {
13924
14234
  if (!spec.keepAlive) return;
13925
14235
  const uptime = state.startedAt ? Date.now() - state.startedAt : 0;
13926
14236
  const now = Date.now();
13927
- if (uptime > BACKOFF_RESET_WINDOW_MS) {
13928
- state.consecutiveFailures = 0;
13929
- entry.failureWindowStart = void 0;
13930
- } else {
13931
- if (!entry.failureWindowStart || now - entry.failureWindowStart > CRASH_LOOP_WINDOW_MS) {
13932
- entry.failureWindowStart = now;
13933
- state.consecutiveFailures = 1;
13934
- } else {
13935
- state.consecutiveFailures++;
13936
- }
13937
- }
14237
+ const acct = accumulateCrashLoopFailure(
14238
+ { consecutiveFailures: state.consecutiveFailures, failureWindowStart: entry.failureWindowStart },
14239
+ uptime > BACKOFF_RESET_WINDOW_MS,
14240
+ now
14241
+ );
14242
+ state.consecutiveFailures = acct.consecutiveFailures;
14243
+ entry.failureWindowStart = acct.failureWindowStart;
13938
14244
  if (state.consecutiveFailures >= CRASH_LOOP_FAILURE_THRESHOLD) {
13939
14245
  const windowS = Math.round((now - (entry.failureWindowStart ?? now)) / 1e3);
13940
14246
  state.status = "crash-loop-backoff";
@@ -14193,22 +14499,29 @@ function parseIssue(content) {
14193
14499
  body: (m[2] || "").trim() || void 0
14194
14500
  };
14195
14501
  }
14196
- function readDir(dir) {
14502
+ function readDirWithMtime(dir) {
14197
14503
  if (!existsSync(dir)) return [];
14198
14504
  const out = [];
14199
14505
  for (const name of readdirSync(dir)) {
14200
14506
  if (!name.endsWith(".md")) continue;
14201
14507
  try {
14202
- const issue = parseIssue(readFileSync(join$1(dir, name), "utf-8"));
14203
- if (issue) out.push(issue);
14508
+ const p = join$1(dir, name);
14509
+ const issue = parseIssue(readFileSync(p, "utf-8"));
14510
+ if (issue) out.push({ issue, mtimeMs: statSync(p).mtimeMs });
14204
14511
  } catch {
14205
14512
  }
14206
14513
  }
14207
14514
  return out;
14208
14515
  }
14209
14516
  function listIssues(projectRoot, opts = {}) {
14210
- let items = readDir(issuesDir(projectRoot));
14211
- if (opts.includeArchived) items = items.concat(readDir(archiveDir(projectRoot)));
14517
+ const entries = readDirWithMtime(issuesDir(projectRoot));
14518
+ if (opts.includeArchived) entries.push(...readDirWithMtime(archiveDir(projectRoot)));
14519
+ const byId = /* @__PURE__ */ new Map();
14520
+ for (const e of entries) {
14521
+ const prev = byId.get(e.issue.id);
14522
+ if (!prev || e.mtimeMs >= prev.mtimeMs) byId.set(e.issue.id, e);
14523
+ }
14524
+ let items = Array.from(byId.values()).map((e) => e.issue);
14212
14525
  if (opts.status) items = items.filter((i) => i.status === opts.status);
14213
14526
  if (opts.label) items = items.filter((i) => i.labels.includes(opts.label));
14214
14527
  if (opts.scope) items = items.filter((i) => i.scope === opts.scope);
@@ -14223,20 +14536,22 @@ function isVisibleTo(issue, session) {
14223
14536
  }
14224
14537
  function getIssue(projectRoot, id) {
14225
14538
  const padded = /^\d+$/.test(id) ? id.padStart(4, "0") : id;
14539
+ let best = null;
14226
14540
  for (const p of [issuePath(projectRoot, padded), issuePath(projectRoot, padded, true)]) {
14227
- if (existsSync(p)) {
14228
- try {
14229
- return parseIssue(readFileSync(p, "utf-8"));
14230
- } catch {
14231
- return null;
14232
- }
14541
+ if (!existsSync(p)) continue;
14542
+ try {
14543
+ const issue = parseIssue(readFileSync(p, "utf-8"));
14544
+ if (!issue) continue;
14545
+ const mtimeMs = statSync(p).mtimeMs;
14546
+ if (!best || mtimeMs >= best.mtimeMs) best = { issue, mtimeMs };
14547
+ } catch {
14233
14548
  }
14234
14549
  }
14235
- return null;
14550
+ return best ? best.issue : null;
14236
14551
  }
14237
14552
  function atomicWrite(path, content) {
14238
14553
  mkdirSync$1(dirname(path), { recursive: true });
14239
- const tmp = `${path}.tmp-${process.pid}`;
14554
+ const tmp = `${path}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
14240
14555
  writeFileSync$1(tmp, content);
14241
14556
  renameSync(tmp, path);
14242
14557
  }
@@ -14280,7 +14595,18 @@ function addIssue(projectRoot, fields) {
14280
14595
  }
14281
14596
  throw new Error("addIssue: could not allocate a free issue id");
14282
14597
  }
14283
- function updateIssue(projectRoot, id, patch) {
14598
+ function issueLockPath(projectRoot, id) {
14599
+ const padded = /^\d+$/.test(id) ? id.padStart(4, "0") : id;
14600
+ return join$1(issuesDir(projectRoot), `${padded}.lock`);
14601
+ }
14602
+ function withIssueLock(projectRoot, id, fn) {
14603
+ try {
14604
+ mkdirSync$1(issuesDir(projectRoot), { recursive: true });
14605
+ } catch {
14606
+ }
14607
+ return withFileLock(issueLockPath(projectRoot, id), fn);
14608
+ }
14609
+ function _updateIssueUnlocked(projectRoot, id, patch) {
14284
14610
  const cur = getIssue(projectRoot, id);
14285
14611
  if (!cur) return null;
14286
14612
  const wasArchived = cur.status === "archived";
@@ -14299,17 +14625,23 @@ function updateIssue(projectRoot, id, patch) {
14299
14625
  }
14300
14626
  return next;
14301
14627
  }
14628
+ function updateIssue(projectRoot, id, patch) {
14629
+ return withIssueLock(projectRoot, id, () => _updateIssueUnlocked(projectRoot, id, patch));
14630
+ }
14302
14631
  function addComment(projectRoot, id, text) {
14303
- const cur = getIssue(projectRoot, id);
14304
- if (!cur || !text.trim()) return cur;
14305
- const entry = `
14632
+ if (!text.trim()) return getIssue(projectRoot, id);
14633
+ return withIssueLock(projectRoot, id, () => {
14634
+ const cur = getIssue(projectRoot, id);
14635
+ if (!cur) return null;
14636
+ const entry = `
14306
14637
 
14307
14638
  ---
14308
14639
  **Follow-up \xB7 ${(/* @__PURE__ */ new Date()).toISOString()}**
14309
14640
 
14310
14641
  ${text.trim()}`;
14311
- const body = (cur.body ? cur.body.replace(/\s+$/, "") : "") + entry;
14312
- return updateIssue(projectRoot, id, { body });
14642
+ const body = (cur.body ? cur.body.replace(/\s+$/, "") : "") + entry;
14643
+ return _updateIssueUnlocked(projectRoot, id, { body });
14644
+ });
14313
14645
  }
14314
14646
  function pauseIssue(projectRoot, id, question) {
14315
14647
  const cur = getIssue(projectRoot, id);
@@ -14376,8 +14708,14 @@ function workflowCrons(wf) {
14376
14708
  function workflowsDir(projectRoot) {
14377
14709
  return join$1(projectRoot, ".svamp", "workflows");
14378
14710
  }
14711
+ function validateWorkflowName(name) {
14712
+ if (typeof name !== "string" || !/^[A-Za-z0-9._-]+$/.test(name) || name === "." || name === "..") {
14713
+ throw new Error(`Invalid workflow name "${name}" \u2014 allowed: letters, digits, dot, dash, underscore (no path separators).`);
14714
+ }
14715
+ return name;
14716
+ }
14379
14717
  function workflowPath(projectRoot, name) {
14380
- return join$1(workflowsDir(projectRoot), `${name}.yaml`);
14718
+ return join$1(workflowsDir(projectRoot), `${validateWorkflowName(name)}.yaml`);
14381
14719
  }
14382
14720
  function normalizeOn(on) {
14383
14721
  if (!on || typeof on !== "object") {
@@ -14469,7 +14807,12 @@ function listWorkflows(projectRoot) {
14469
14807
  return out.sort((a, b) => a.name.localeCompare(b.name));
14470
14808
  }
14471
14809
  function getWorkflow(projectRoot, name) {
14472
- const p = workflowPath(projectRoot, name);
14810
+ let p;
14811
+ try {
14812
+ p = workflowPath(projectRoot, name);
14813
+ } catch {
14814
+ return null;
14815
+ }
14473
14816
  if (!existsSync(p)) return null;
14474
14817
  try {
14475
14818
  return parseWorkflow(readFileSync(p, "utf-8"));
@@ -14478,14 +14821,19 @@ function getWorkflow(projectRoot, name) {
14478
14821
  }
14479
14822
  }
14480
14823
  function rawWorkflow(projectRoot, name) {
14481
- const p = workflowPath(projectRoot, name);
14824
+ let p;
14825
+ try {
14826
+ p = workflowPath(projectRoot, name);
14827
+ } catch {
14828
+ return null;
14829
+ }
14482
14830
  return existsSync(p) ? readFileSync(p, "utf-8") : null;
14483
14831
  }
14484
14832
  function saveWorkflow(projectRoot, wf) {
14485
14833
  const dir = workflowsDir(projectRoot);
14486
14834
  mkdirSync$1(dir, { recursive: true });
14487
14835
  const path = workflowPath(projectRoot, wf.name);
14488
- const tmp = `${path}.tmp-${process.pid}`;
14836
+ const tmp = `${path}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
14489
14837
  writeFileSync$1(tmp, serializeWorkflow(wf));
14490
14838
  renameSync(tmp, path);
14491
14839
  }
@@ -14517,6 +14865,10 @@ function maxRunsPerWorkflow() {
14517
14865
  }
14518
14866
  const MAX_STREAM_CHARS = 8e3;
14519
14867
  const STEP_TIMEOUT_MS = 12e4;
14868
+ function stepTimeoutMs() {
14869
+ const v = Number(process.env.SVAMP_WORKFLOW_STEP_TIMEOUT_MS);
14870
+ return Number.isFinite(v) && v > 0 ? v : STEP_TIMEOUT_MS;
14871
+ }
14520
14872
  function capStream(s, max = MAX_STREAM_CHARS) {
14521
14873
  if (s.length <= max) return s;
14522
14874
  const head = Math.floor(max * 0.6);
@@ -14546,14 +14898,16 @@ function recordRun(projectRoot, run) {
14546
14898
  const dir = runsDir(projectRoot);
14547
14899
  if (!existsSync(dir)) mkdirSync$1(dir, { recursive: true });
14548
14900
  const file = runsFile(projectRoot, run.workflow);
14549
- const existing = loadRuns(projectRoot, run.workflow);
14550
- const idx = existing.findIndex((r) => r.id === run.id);
14551
- if (idx >= 0) existing[idx] = run;
14552
- else existing.push(run);
14553
- const kept = existing.slice(-maxRunsPerWorkflow());
14554
- const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
14555
- writeFileSync$1(tmp, kept.map((r) => JSON.stringify(r)).join("\n") + "\n");
14556
- renameSync(tmp, file);
14901
+ withFileLock(`${file}.lock`, () => {
14902
+ const existing = loadRuns(projectRoot, run.workflow);
14903
+ const idx = existing.findIndex((r) => r.id === run.id);
14904
+ if (idx >= 0) existing[idx] = run;
14905
+ else existing.push(run);
14906
+ const kept = existing.slice(-maxRunsPerWorkflow());
14907
+ const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
14908
+ writeFileSync$1(tmp, kept.map((r) => JSON.stringify(r)).join("\n") + "\n");
14909
+ renameSync(tmp, file);
14910
+ });
14557
14911
  } catch {
14558
14912
  }
14559
14913
  }
@@ -14589,6 +14943,48 @@ function listRuns(projectRoot, workflow, opts = {}) {
14589
14943
  function getRun(projectRoot, workflow, runId) {
14590
14944
  return loadRuns(projectRoot, workflow).find((r) => r.id === runId) || null;
14591
14945
  }
14946
+ function reconcileInterruptedRuns(projectRoot) {
14947
+ let fixed = 0;
14948
+ try {
14949
+ const dir = runsDir(projectRoot);
14950
+ if (!existsSync(dir)) return 0;
14951
+ for (const entry of readdirSync(dir)) {
14952
+ if (!entry.endsWith(".jsonl")) continue;
14953
+ const file = join$1(dir, entry);
14954
+ let runs;
14955
+ try {
14956
+ runs = readFileSync(file, "utf-8").split("\n").filter(Boolean).map((l) => {
14957
+ try {
14958
+ return JSON.parse(l);
14959
+ } catch {
14960
+ return null;
14961
+ }
14962
+ }).filter((r) => !!r);
14963
+ } catch {
14964
+ continue;
14965
+ }
14966
+ let changed = false;
14967
+ for (const r of runs) {
14968
+ if (r.status !== "running") continue;
14969
+ if (isWorkflowInFlight(projectRoot, r.workflow)) continue;
14970
+ r.status = "failed";
14971
+ r.error = r.error || "interrupted by daemon restart";
14972
+ r.finishedAt = r.finishedAt || r.startedAt;
14973
+ changed = true;
14974
+ fixed++;
14975
+ }
14976
+ if (!changed) continue;
14977
+ try {
14978
+ const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
14979
+ writeFileSync$1(tmp, runs.map((r) => JSON.stringify(r)).join("\n") + "\n");
14980
+ renameSync(tmp, file);
14981
+ } catch {
14982
+ }
14983
+ }
14984
+ } catch {
14985
+ }
14986
+ return fixed;
14987
+ }
14592
14988
  function defaultExecStep(root, cmd, env) {
14593
14989
  return new Promise((resolve) => {
14594
14990
  let stdout = "", stderr = "", timedOut = false, settled = false;
@@ -14598,14 +14994,18 @@ function defaultExecStep(root, cmd, env) {
14598
14994
  resolve({ exitCode, stdout, stderr, timedOut });
14599
14995
  };
14600
14996
  try {
14601
- const child = spawn$1("sh", ["-c", cmd], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] });
14997
+ const child = spawn$1("sh", ["-c", cmd], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
14602
14998
  const timer = setTimeout(() => {
14603
14999
  timedOut = true;
14604
15000
  try {
14605
- child.kill("SIGKILL");
15001
+ if (child.pid) process.kill(-child.pid, "SIGKILL");
14606
15002
  } catch {
15003
+ try {
15004
+ child.kill("SIGKILL");
15005
+ } catch {
15006
+ }
14607
15007
  }
14608
- }, STEP_TIMEOUT_MS);
15008
+ }, stepTimeoutMs());
14609
15009
  child.stdout?.on("data", (d) => {
14610
15010
  stdout += d.toString();
14611
15011
  if (stdout.length > MAX_STREAM_CHARS * 4) stdout = capStream(stdout);
@@ -14684,6 +15084,9 @@ const inFlightRuns = /* @__PURE__ */ new Map();
14684
15084
  function workflowLockKey(projectRoot, name) {
14685
15085
  return `${projectRoot}\0${name}`;
14686
15086
  }
15087
+ function isWorkflowInFlight(projectRoot, name) {
15088
+ return inFlightRuns.has(workflowLockKey(projectRoot, name));
15089
+ }
14687
15090
  function runWorkflow(projectRoot, wf, opts) {
14688
15091
  if (opts.allowConcurrent) return runWorkflowInner(projectRoot, wf, opts);
14689
15092
  const key = workflowLockKey(projectRoot, wf.name);
@@ -14760,6 +15163,25 @@ async function runWorkflowInner(projectRoot, wf, opts) {
14760
15163
  return run;
14761
15164
  }
14762
15165
 
15166
+ var runStore = /*#__PURE__*/Object.freeze({
15167
+ __proto__: null,
15168
+ MAX_RUNS_PER_WORKFLOW: MAX_RUNS_PER_WORKFLOW,
15169
+ MAX_STREAM_CHARS: MAX_STREAM_CHARS,
15170
+ STEP_TIMEOUT_MS: STEP_TIMEOUT_MS,
15171
+ capStream: capStream,
15172
+ escalateWorkflowFailure: escalateWorkflowFailure,
15173
+ getRun: getRun,
15174
+ isWorkflowInFlight: isWorkflowInFlight,
15175
+ listRuns: listRuns,
15176
+ loadRuns: loadRuns,
15177
+ maxRunsPerWorkflow: maxRunsPerWorkflow,
15178
+ reconcileInterruptedRuns: reconcileInterruptedRuns,
15179
+ recordRun: recordRun,
15180
+ runWorkflow: runWorkflow,
15181
+ runsDir: runsDir,
15182
+ stepTimeoutMs: stepTimeoutMs
15183
+ });
15184
+
14763
15185
  const DEFAULT_IDLE_COOLDOWN_MS = 15e3;
14764
15186
  function idleCooldownMs() {
14765
15187
  const raw = Number(process.env.SVAMP_WORKFLOW_IDLE_COOLDOWN_MS);
@@ -15162,7 +15584,9 @@ async function readCredentials() {
15162
15584
  }
15163
15585
  async function writeCredentials(creds) {
15164
15586
  const path = getCredentialsPath();
15165
- await writeFile(path, JSON.stringify(creds), { mode: 384 });
15587
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
15588
+ await writeFile(tmp, JSON.stringify(creds), { mode: 384 });
15589
+ await rename$1(tmp, path);
15166
15590
  }
15167
15591
  async function checkAndRefreshOAuthToken(force = false, logger) {
15168
15592
  const log = logger?.log ?? (() => {
@@ -17051,7 +17475,7 @@ async function startDaemon(options) {
17051
17475
  try {
17052
17476
  const dir = loadSessionIndex()[sessionId]?.directory;
17053
17477
  if (!dir) return;
17054
- const { reconcileServiceLinks } = await import('./agentCommands-B3FTfsg-.mjs');
17478
+ const { reconcileServiceLinks } = await import('./agentCommands-CUcyA_KE.mjs');
17055
17479
  const configPath = getSvampConfigPath(dir, sessionId);
17056
17480
  const config = readSvampConfig(configPath);
17057
17481
  const entries = Array.from(urls.entries());
@@ -17069,7 +17493,7 @@ async function startDaemon(options) {
17069
17493
  }
17070
17494
  }
17071
17495
  async function createExposedTunnel(spec) {
17072
- const { FrpcTunnel } = await import('./frpc-Dsada5ox.mjs');
17496
+ const { FrpcTunnel } = await import('./frpc-C1jS23Sn.mjs');
17073
17497
  const tunnel = new FrpcTunnel({
17074
17498
  name: spec.name,
17075
17499
  ports: spec.ports,
@@ -17138,10 +17562,14 @@ async function startDaemon(options) {
17138
17562
  const routeSupervisionVerdict = (parentId, v) => {
17139
17563
  try {
17140
17564
  if (!parentId) return;
17141
- const parent = Array.from(pidToTrackedSession.values()).find((t) => t.svampSessionId === parentId);
17142
- const send = parent?.sessionRPCHandlers?.sendInboxMessage;
17143
- if (!send) return;
17144
- Promise.resolve(send({
17565
+ const tracked = Array.from(pidToTrackedSession.values());
17566
+ const parent = tracked.find((t) => t.svampSessionId === parentId);
17567
+ const liveParent = tracked.find((t) => t.svampSessionId === parentId && !t.stopped);
17568
+ const send = liveParent?.sessionRPCHandlers?.sendInboxMessage;
17569
+ if (!send) {
17570
+ logger.log(`[supervision] parent ${parentId.slice(0, 8)} not live \u2014 recording verdict on the backlog issue only (no in-memory wake)`);
17571
+ }
17572
+ if (send) Promise.resolve(send({
17145
17573
  messageId: randomUUID$1(),
17146
17574
  from: `agent:${v.sessionId}`,
17147
17575
  fromSession: v.sessionId,
@@ -17680,24 +18108,22 @@ ${parts.join("\n")}`);
17680
18108
  const STUCK_CHECK_INTERVAL_MS = 2 * 60 * 1e3;
17681
18109
  let lastOutputTime = Date.now();
17682
18110
  let stuckWatchdogTimer = null;
17683
- const hasActiveChildren = (pid) => {
17684
- try {
17685
- const result = execSync(`pgrep -P ${pid}`, { encoding: "utf8", timeout: 5e3 });
17686
- return result.trim().length > 0;
17687
- } catch {
17688
- return false;
17689
- }
17690
- };
18111
+ const hasActiveChildren = (pid) => new Promise((resolve2) => {
18112
+ execFile("pgrep", ["-P", String(pid)], { encoding: "utf8", timeout: 5e3 }, (err, stdout) => {
18113
+ resolve2(!err && String(stdout).trim().length > 0);
18114
+ });
18115
+ });
17691
18116
  const startStuckWatchdog = () => {
17692
18117
  if (stuckWatchdogTimer) return;
17693
- stuckWatchdogTimer = setInterval(() => {
18118
+ stuckWatchdogTimer = setInterval(async () => {
17694
18119
  if (!claudeProcess || claudeProcess.exitCode !== null) return;
17695
18120
  if (!sessionWasProcessing) return;
17696
18121
  if (!isLoopActive(directory, sessionId)) return;
17697
- if (claudeProcess.pid && hasActiveChildren(claudeProcess.pid)) {
18122
+ if (claudeProcess.pid && await hasActiveChildren(claudeProcess.pid)) {
17698
18123
  lastOutputTime = Date.now();
17699
18124
  return;
17700
18125
  }
18126
+ if (!claudeProcess || claudeProcess.exitCode !== null) return;
17701
18127
  const elapsed = Date.now() - lastOutputTime;
17702
18128
  if (elapsed > STUCK_PROCESS_TIMEOUT_MS) {
17703
18129
  logger.log(`[Session ${sessionId}] Loop stuck: mid-turn, no output for ${Math.round(elapsed / 1e3)}s, no child processes \u2014 killing to resume the loop`);
@@ -18398,7 +18824,7 @@ ${parts.join("\n")}`);
18398
18824
  auto_resumes: prog.auto_resumes
18399
18825
  });
18400
18826
  const extNote = summarizeExtensions(ls.extensions, maxExt);
18401
- const resumeHint = iterStop ? `Extend with a reason: svamp session loop-extend ${sessionId} --reason "<why>" \xB7 or set a new cap: svamp session loop-resume ${sessionId} --max <N>` : `Resume & extend with: svamp session loop-resume ${sessionId} --max <N>`;
18827
+ const resumeHint = iterStop ? `Extend with a reason: svamp session loop-extend ${sessionId} --reason "<why>" \xB7 or set a new cap: svamp session loop-resume ${sessionId} --max <N>` : `Raise the cost cap to resume (--max won't \u2014 it only raises the iteration cap): svamp session loop ${sessionId} --max-runtime-sec <N> and/or --max-tokens-per-hour <N>`;
18402
18828
  const detail = `${budgetCheck.reason} (hard ${iterStop ? "iteration ceiling" : "cost ceiling"} \u2014 will not auto-resume)${extNote ? ` [${extNote}]` : ""}. ${resumeHint}`;
18403
18829
  sessionService.pushMessage({ type: "message", message: `\u{1F6D1} Loop stopped \u2014 ${detail}`, level: "warning" }, "event");
18404
18830
  checkSvampConfig?.();
@@ -19285,19 +19711,19 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
19285
19711
  });
19286
19712
  },
19287
19713
  onIssue: async (params) => {
19288
- const { issueRpc } = await import('./rpc--eK356Xz.mjs');
19714
+ const { issueRpc } = await import('./rpc-iNafaHoh.mjs');
19289
19715
  return issueRpc(params?.cwd || directory, params || {}, { notifySession: notifyIssueOwner, rekickLoopOwner });
19290
19716
  },
19291
19717
  onWorkflow: async (params) => {
19292
- const { workflowRpc } = await import('./rpc-DOVHyr_6.mjs');
19718
+ const { workflowRpc } = await import('./rpc-BxmbyDga.mjs');
19293
19719
  return workflowRpc(params?.cwd || directory, params || {});
19294
19720
  },
19295
19721
  onRipgrep: async (args, cwd) => {
19296
- const { execFile } = await import('child_process');
19722
+ const { execFile: execFile2 } = await import('child_process');
19297
19723
  const rgCwd = cwd || directory;
19298
19724
  const argv = Array.isArray(args) ? args : String(args).split(/\s+/).filter(Boolean);
19299
19725
  return new Promise((resolve2, reject) => {
19300
- execFile("rg", argv, { cwd: rgCwd, timeout: 3e4, maxBuffer: 5 * 1024 * 1024 }, (err, stdout) => {
19726
+ execFile2("rg", argv, { cwd: rgCwd, timeout: 3e4, maxBuffer: 5 * 1024 * 1024 }, (err, stdout) => {
19301
19727
  if (err && !stdout) {
19302
19728
  reject(new Error(err.message));
19303
19729
  } else {
@@ -19921,19 +20347,19 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
19921
20347
  });
19922
20348
  },
19923
20349
  onIssue: async (params) => {
19924
- const { issueRpc } = await import('./rpc--eK356Xz.mjs');
20350
+ const { issueRpc } = await import('./rpc-iNafaHoh.mjs');
19925
20351
  return issueRpc(params?.cwd || directory, params || {}, { notifySession: notifyIssueOwner, rekickLoopOwner });
19926
20352
  },
19927
20353
  onWorkflow: async (params) => {
19928
- const { workflowRpc } = await import('./rpc-DOVHyr_6.mjs');
20354
+ const { workflowRpc } = await import('./rpc-BxmbyDga.mjs');
19929
20355
  return workflowRpc(params?.cwd || directory, params || {});
19930
20356
  },
19931
20357
  onRipgrep: async (args, cwd) => {
19932
- const { execFile } = await import('child_process');
20358
+ const { execFile: execFile2 } = await import('child_process');
19933
20359
  const rgCwd = cwd || directory;
19934
20360
  const argv = Array.isArray(args) ? args : String(args).split(/\s+/).filter(Boolean);
19935
20361
  return new Promise((resolve2, reject) => {
19936
- execFile("rg", argv, { cwd: rgCwd, timeout: 3e4, maxBuffer: 5 * 1024 * 1024 }, (err, stdout) => {
20362
+ execFile2("rg", argv, { cwd: rgCwd, timeout: 3e4, maxBuffer: 5 * 1024 * 1024 }, (err, stdout) => {
19937
20363
  if (err && !stdout) {
19938
20364
  reject(new Error(err.message));
19939
20365
  } else {
@@ -20208,7 +20634,8 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
20208
20634
  const startedAt = typeof ls.started_at === "number" ? ls.started_at : typeof ls.resumed_at === "number" ? ls.resumed_at : void 0;
20209
20635
  const oracleCmd = typeof ls.oracle === "string" && ls.oracle.trim() ? ls.oracle.trim() : void 0;
20210
20636
  const acpCumTokens = agentBackend.getCumulativeTokens?.() ?? 0;
20211
- const acpPrevCum = typeof ls.acp_last_cum_tokens === "number" ? ls.acp_last_cum_tokens : 0;
20637
+ let acpPrevCum = typeof ls.acp_last_cum_tokens === "number" ? ls.acp_last_cum_tokens : 0;
20638
+ if (acpCumTokens < acpPrevCum) acpPrevCum = 0;
20212
20639
  const acpDeltaTokens = Math.max(0, acpCumTokens - acpPrevCum);
20213
20640
  ls.acp_last_cum_tokens = acpCumTokens;
20214
20641
  const ledger = accumulateLedger(ls.ledger, { turns: 1, tokens: acpDeltaTokens, ts: now });
@@ -20242,7 +20669,7 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
20242
20669
  const iterStop = !isCostCeiling(budgetCheck.kind);
20243
20670
  writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "gave_up", completed_at: now, gave_up_reason: `resource budget exhausted \u2014 ${budgetCheck.reason}`, ledger });
20244
20671
  const acpExtNote = summarizeExtensions(ls.extensions, acpMaxExt);
20245
- const acpResumeHint = iterStop ? `Extend with a reason: svamp session loop-extend ${sessionId} --reason "<why>" \xB7 or set a new cap: svamp session loop-resume ${sessionId} --max <N>` : `Resume & extend with: svamp session loop-resume ${sessionId} --max <N>`;
20672
+ const acpResumeHint = iterStop ? `Extend with a reason: svamp session loop-extend ${sessionId} --reason "<why>" \xB7 or set a new cap: svamp session loop-resume ${sessionId} --max <N>` : `Raise the cost cap to resume (--max won't \u2014 it only raises the iteration cap): svamp session loop ${sessionId} --max-runtime-sec <N> and/or --max-tokens-per-hour <N>`;
20246
20673
  sessionService.pushMessage({ type: "message", message: `\u{1F6D1} Loop stopped \u2014 ${budgetCheck.reason} (hard ${iterStop ? "iteration ceiling" : "cost ceiling"} \u2014 will not auto-resume)${acpExtNote ? ` [${acpExtNote}]` : ""}. ${acpResumeHint}`, level: "warning" }, "event");
20247
20674
  checkSvampConfig?.();
20248
20675
  return;
@@ -20750,7 +21177,7 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
20750
21177
  const channelHttpPort = Number(process.env.SVAMP_CHANNEL_HTTP_PORT) || 0;
20751
21178
  if (channelHttpPort > 0) {
20752
21179
  try {
20753
- const { createChannelHttpServer } = await import('./httpServer-CSMZTpoh.mjs');
21180
+ const { createChannelHttpServer } = await import('./httpServer-1XjB2h3K.mjs');
20754
21181
  const channelHttpServer = createChannelHttpServer({
20755
21182
  getSessionIds: () => {
20756
21183
  const ids = [];
@@ -20991,7 +21418,10 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
20991
21418
  }
20992
21419
  if (sessionsToAutoContinue.length > 0 && !options?.noAutoContinue) {
20993
21420
  logger.log(`Auto-continuing ${sessionsToAutoContinue.length} interrupted session(s)...`);
20994
- for (const sessionId of sessionsToAutoContinue) {
21421
+ const AUTO_CONTINUE_STEP_MS = Number(process.env.SVAMP_AUTO_CONTINUE_STEP_MS) || 300;
21422
+ const AUTO_CONTINUE_MAX_SPREAD_MS = Number(process.env.SVAMP_AUTO_CONTINUE_MAX_SPREAD_MS) || 6e4;
21423
+ sessionsToAutoContinue.forEach((sessionId, i) => {
21424
+ const delay = 2e3 + Math.min(i * AUTO_CONTINUE_STEP_MS, AUTO_CONTINUE_MAX_SPREAD_MS) + Math.floor(Math.random() * AUTO_CONTINUE_STEP_MS);
20995
21425
  setTimeout(async () => {
20996
21426
  try {
20997
21427
  const tracked = Array.from(pidToTrackedSession.values()).find((s) => s.svampSessionId === sessionId);
@@ -21008,8 +21438,8 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
21008
21438
  } catch (err) {
21009
21439
  logger.log(`Failed to auto-continue session ${sessionId}: ${err.message}`);
21010
21440
  }
21011
- }, 2e3);
21012
- }
21441
+ }, delay);
21442
+ });
21013
21443
  } else if (sessionsToAutoContinue.length > 0) {
21014
21444
  logger.log(`Skipping auto-continue for ${sessionsToAutoContinue.length} interrupted session(s) (--no-auto-continue)`);
21015
21445
  }
@@ -21072,15 +21502,24 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
21072
21502
  const PING_TIMEOUT_MS = 15e3;
21073
21503
  const POST_RECONNECT_GRACE_MS = 2e4;
21074
21504
  const RECONNECT_JITTER_MS = 2500;
21075
- const { WorkflowScheduler } = await import('./scheduler-DAkzJVgr.mjs');
21505
+ const { WorkflowScheduler } = await import('./scheduler-CZYOanXt.mjs');
21506
+ const workflowProjectRoots = () => {
21507
+ const dirs = /* @__PURE__ */ new Set();
21508
+ for (const s of pidToTrackedSession.values()) {
21509
+ if (!s.stopped && s.directory) dirs.add(s.directory);
21510
+ }
21511
+ return [...dirs];
21512
+ };
21513
+ try {
21514
+ const { reconcileInterruptedRuns } = await Promise.resolve().then(function () { return runStore; });
21515
+ let reconciled = 0;
21516
+ for (const root of workflowProjectRoots()) reconciled += reconcileInterruptedRuns(root);
21517
+ if (reconciled > 0) logger.log(`[workflow] reconciled ${reconciled} interrupted run(s) (running \u2192 failed) after restart`);
21518
+ } catch (e) {
21519
+ logger.log(`[workflow] interrupted-run reconcile skipped: ${e?.message || e}`);
21520
+ }
21076
21521
  const workflowScheduler = new WorkflowScheduler({
21077
- projectRoots: () => {
21078
- const dirs = /* @__PURE__ */ new Set();
21079
- for (const s of pidToTrackedSession.values()) {
21080
- if (!s.stopped && s.directory) dirs.add(s.directory);
21081
- }
21082
- return [...dirs];
21083
- },
21522
+ projectRoots: workflowProjectRoots,
21084
21523
  log: (m) => logger.log(m)
21085
21524
  });
21086
21525
  const workflowSchedulerInterval = setInterval(() => {
@@ -21696,4 +22135,4 @@ var run = /*#__PURE__*/Object.freeze({
21696
22135
  writeStopMarker: writeStopMarker
21697
22136
  });
21698
22137
 
21699
- export { listSkillFiles as $, removeWorkflow as A, saveWorkflow as B, rawWorkflow as C, listWorkflows as D, isWorkflowEnabled as E, workflowCrons as F, cronMatches as G, summarize as H, workflowSteps as I, parseJwtEmail as J, computeCollectionConfigUpdate as K, SYSTEM_COLLECTION_CONFIG as L, loadMachineContext as M, buildMachineInstructions as N, machineToolsForRole as O, buildMachineTools as P, parseFrontmatter as Q, READ_ONLY_TOOLS as R, SharingNotificationSync as S, getSkillsServer as T, getSkillsWorkspaceName as U, getSkillsCollectionName as V, fetchWithTimeout as W, searchSkills as X, SKILLS_DIR as Y, getSkillInfo as Z, downloadSkillFile as _, createSessionStore as a, resolveModel as a0, clearStopMarker as a1, stopMarkerExists as a2, formatHandle as a3, normalizeAllowedUser as a4, loadSecurityContextConfig as a5, resolveSecurityContext as a6, buildSecurityContextFromFlags as a7, mergeSecurityContexts as a8, buildSessionShareUrl as a9, computeOutboundHop as aa, registerAwaitingReply as ab, buildMachineShareUrl as ac, parseHandle as ad, handleMatchesMetadata as ae, describeMisconfiguration as af, buildMachineDeps as ag, applyClaudeProxyEnv as ah, composeSessionId as ai, generateFriendlyName as aj, generateHookSettings as ak, staticFileServer as al, instanceConfig as am, claudeAuth as an, codexProvider as ao, projectInfo as ap, DefaultTransport$1 as aq, acpBackend as ar, acpAgentConfig as as, codexAppServerBackend as at, GeminiTransport$1 as au, api as av, run as aw, stopDaemon as b, connectToHypha as c, daemonStatus as d, getFrpsSubdomainHost as e, getFrpsServerPort as f, getHyphaServerUrl$1 as g, getFrpsServerAddr as h, shortId as i, resolveProjectRoot as j, getIssue as k, resumeIssue as l, addComment as m, addIssue as n, listIssues as o, pauseIssue as p, searchIssues as q, registerMachineService as r, startDaemon as s, isVisibleTo as t, updateIssue as u, getRun as v, listRuns as w, getWorkflow as x, runWorkflow as y, setWorkflowEnabled as z };
22138
+ export { downloadSkillFile as $, removeWorkflow as A, validateWorkflowName as B, saveWorkflow as C, rawWorkflow as D, listWorkflows as E, isWorkflowEnabled as F, workflowCrons as G, cronMatches as H, summarize as I, workflowSteps as J, parseJwtEmail as K, computeCollectionConfigUpdate as L, SYSTEM_COLLECTION_CONFIG as M, loadMachineContext as N, buildMachineInstructions as O, machineToolsForRole as P, buildMachineTools as Q, READ_ONLY_TOOLS as R, SharingNotificationSync as S, parseFrontmatter as T, getSkillsServer as U, getSkillsWorkspaceName as V, getSkillsCollectionName as W, fetchWithTimeout as X, searchSkills as Y, SKILLS_DIR as Z, getSkillInfo as _, createSessionStore as a, listSkillFiles as a0, resolveModel as a1, clearStopMarker as a2, stopMarkerExists as a3, formatHandle as a4, normalizeAllowedUser as a5, loadSecurityContextConfig as a6, resolveSecurityContext as a7, buildSecurityContextFromFlags as a8, mergeSecurityContexts as a9, buildSessionShareUrl as aa, computeOutboundHop as ab, registerAwaitingReply as ac, buildMachineShareUrl as ad, parseHandle as ae, handleMatchesMetadata as af, describeMisconfiguration as ag, buildMachineDeps as ah, applyClaudeProxyEnv as ai, composeSessionId as aj, generateFriendlyName as ak, generateHookSettings as al, staticFileServer as am, instanceConfig as an, claudeAuth as ao, codexProvider as ap, projectInfo as aq, DefaultTransport$1 as ar, acpBackend as as, acpAgentConfig as at, codexAppServerBackend as au, GeminiTransport$1 as av, api as aw, run as ax, stopDaemon as b, connectToHypha as c, daemonStatus as d, getFrpsSubdomainHost as e, getFrpsServerPort as f, getHyphaServerUrl$1 as g, getFrpsServerAddr as h, shortId as i, resolveProjectRoot as j, getIssue as k, resumeIssue as l, addComment as m, addIssue as n, listIssues as o, pauseIssue as p, searchIssues as q, registerMachineService as r, startDaemon as s, isVisibleTo as t, updateIssue as u, getRun as v, listRuns as w, getWorkflow as x, runWorkflow as y, setWorkflowEnabled as z };