svamp-cli 0.2.326 → 0.2.328

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-D1NzA4Hb.mjs → adminCommands-DotiO7jx.mjs} +1 -1
  2. package/dist/{agentCommands-DUHUBzrj.mjs → agentCommands-DefL1pIo.mjs} +5 -5
  3. package/dist/{auth-DogM3PPm.mjs → auth-DLK3tc_f.mjs} +1 -1
  4. package/dist/{cli-PWoHWBkF.mjs → cli-BCN1uAze.mjs} +77 -77
  5. package/dist/cli.mjs +2 -2
  6. package/dist/{commands-D-4ry2I6.mjs → commands-BD6Qfv3u.mjs} +11 -11
  7. package/dist/{commands-CqqQYRzg.mjs → commands-BEnXz1U_.mjs} +2 -2
  8. package/dist/{commands-Dt_MecCB.mjs → commands-BFlfbiey.mjs} +1 -1
  9. package/dist/{commands-CxfUr2Qr.mjs → commands-DEZLlXJ3.mjs} +1 -1
  10. package/dist/{commands-B5bC9eXz.mjs → commands-DH2WCdPH.mjs} +3 -3
  11. package/dist/{commands-Dm9KNB-p.mjs → commands-gO42Ijz3.mjs} +3 -3
  12. package/dist/{commands-CBk4dLUP.mjs → commands-uN_gulw0.mjs} +3 -3
  13. package/dist/{commands-Ck4pP86Q.mjs → commands-xhA5FWM8.mjs} +2 -2
  14. package/dist/{fleet-Bzg3YxM4.mjs → fleet-CbKnESdR.mjs} +2 -2
  15. package/dist/{headlessCli-DvJI9DRh.mjs → headlessCli-2wTVI-Kj.mjs} +2 -2
  16. package/dist/{httpServer-1XjB2h3K.mjs → httpServer-Be1Hcddr.mjs} +59 -38
  17. package/dist/index.mjs +1 -1
  18. package/dist/{notifyCommands-BpMErHcv.mjs → notifyCommands--tpDRdrL.mjs} +1 -1
  19. package/dist/package-BORyBEey.mjs +64 -0
  20. package/dist/{rpc-27xBajCe.mjs → rpc-BCLZpI5H.mjs} +1 -1
  21. package/dist/{rpc-Qcj4M_LQ.mjs → rpc-CZqCc9Pr.mjs} +1 -1
  22. package/dist/{run-CocRShOR.mjs → run-BAYXQ4-N.mjs} +1 -1
  23. package/dist/{run-BBBr4jkI.mjs → run-CSV6OgQ8.mjs} +274 -56
  24. package/dist/{scheduler-DjAVEyn3.mjs → scheduler-s_j089nA.mjs} +1 -1
  25. package/dist/{serveCommands-BK-wWc_i.mjs → serveCommands-C4779W5n.mjs} +19 -11
  26. package/dist/{sideband-5ck3CDWM.mjs → sideband-B7sDP61Z.mjs} +1 -1
  27. package/package.json +3 -3
  28. package/dist/package-D_BgajkI.mjs +0 -64
@@ -3001,6 +3001,13 @@ function assertNonAdminMountDirSafe(directory, homeDir, resolveReal = defaultRes
3001
3001
  }
3002
3002
  }
3003
3003
  }
3004
+ function hiddenSegmentOf(relPath) {
3005
+ for (const seg of relPath.split(/[\\/]+/)) {
3006
+ if (!seg || seg === ".") continue;
3007
+ if (seg.startsWith(".")) return seg;
3008
+ }
3009
+ return null;
3010
+ }
3004
3011
  function defaultResolveReal(p) {
3005
3012
  try {
3006
3013
  return fs.realpathSync(p);
@@ -3203,8 +3210,19 @@ class ServeManager {
3203
3210
  * Extract a channel capability token from the request, if one is present. A channel caller
3204
3211
  * key is `ck_`-prefixed; we ONLY return a token that carries that prefix so a Hypha JWT
3205
3212
  * bearer (used by the email-identity path) never collides with the capability authorizer.
3206
- * Accepted carriers: `Authorization: Bearer ck_…` header, or `?key=ck_…` query param.
3207
- * Returns null when no capability token is presented. NEVER log the returned value.
3213
+ * Accepted carriers: `Authorization: Bearer ck_…` header (any verb), or `?key=ck_…` query
3214
+ * param (READ verbs only). Returns null when no capability token is presented.
3215
+ * NEVER log the returned value.
3216
+ *
3217
+ * #1047: the query carrier is restricted to GET/HEAD. A query string is a poor place for a
3218
+ * credential — it lands in browser history, in the `Referer` of any HTML the gateway serves,
3219
+ * and in every tunnel/intermediary access log on the path (and the gateway is reached over an
3220
+ * frps tunnel whose logs the user does not control). This is the same exposure class #1006
3221
+ * closed for the Hypha JWT in download URLs. It is kept for reads because a browser-embeddable
3222
+ * URL genuinely needs it (an `<img src>` or a download link cannot set a header), but a leaked
3223
+ * URL must not also confer PUT/DELETE over the whole project. The generated skill already
3224
+ * documents only the Authorization form (see `fileSection` in channel/store.ts), so this
3225
+ * narrows an unused carrier rather than breaking the documented path.
3208
3226
  */
3209
3227
  extractCapabilityToken(req, url) {
3210
3228
  const auth = req.headers["authorization"];
@@ -3212,6 +3230,8 @@ class ServeManager {
3212
3230
  const m = /^Bearer\s+(\S+)$/i.exec(auth.trim());
3213
3231
  if (m && m[1].startsWith("ck_")) return m[1];
3214
3232
  }
3233
+ const method = (req.method || "GET").toUpperCase();
3234
+ if (method !== "GET" && method !== "HEAD") return null;
3215
3235
  const q = url.searchParams.get("key");
3216
3236
  if (q && q.startsWith("ck_")) return q;
3217
3237
  return null;
@@ -3321,7 +3341,8 @@ class ServeManager {
3321
3341
  if (replaced) {
3322
3342
  await this.removeMount(spec.name, { replacing: true });
3323
3343
  }
3324
- if (replaced?.sessionId && replaced.sessionId !== spec.sessionId) {
3344
+ const crossSessionTakeover = !!(replaced?.sessionId && replaced.sessionId !== spec.sessionId);
3345
+ if (crossSessionTakeover) {
3325
3346
  this.log(`Mount '${spec.name}': taken over by session ${spec.sessionId || "(none)"} from ${replaced.sessionId} \u2014 the previous owner's Launch Pad link is being dropped (its URL no longer resolves).`);
3326
3347
  this.fireMountUnbound(replaced.sessionId, spec.name);
3327
3348
  }
@@ -3329,6 +3350,7 @@ class ServeManager {
3329
3350
  if (access === "owner" && !spec.ownerEmail) {
3330
3351
  this.log(`\u26A0 Mount '${spec.name}': access='owner' but no owner email could be resolved \u2014 NO ONE will be able to sign in. Use an explicit email allowlist instead, e.g. access: ["you@example.com"].`);
3331
3352
  }
3353
+ const reusableToken = replaced && access === "link" && replaced.linkToken && !spec.regenerateUrl && !crossSessionTakeover ? replaced.linkToken : void 0;
3332
3354
  const mount = {
3333
3355
  name: spec.name,
3334
3356
  directory: resolvedDir,
@@ -3336,10 +3358,10 @@ class ServeManager {
3336
3358
  sessionId: spec.sessionId,
3337
3359
  ownerEmail: spec.ownerEmail,
3338
3360
  access,
3339
- // Generate a capability token if access is 'link' and we don't
3340
- // already have one from persisted state (token is stable across
3341
- // restarts).
3342
- linkToken: access === "link" ? spec.linkToken || generateLinkToken() : void 0,
3361
+ // Capability token for 'link' access. Precedence: an explicit spec.linkToken
3362
+ // (used by restore to preserve URLs across daemon restarts) → the existing
3363
+ // mount's token on a stable re-apply (#1072) → a freshly generated token.
3364
+ linkToken: access === "link" ? spec.linkToken || reusableToken || generateLinkToken() : void 0,
3343
3365
  addedAt: Date.now()
3344
3366
  };
3345
3367
  this.mounts.set(spec.name, mount);
@@ -3857,6 +3879,14 @@ a{color:#0969da;text-decoration:none;font-weight:500}a:hover{text-decoration:und
3857
3879
  }
3858
3880
  const rootDir = target.cwd;
3859
3881
  const normRel = this.toSessionRelative(rootDir, m[2] || "");
3882
+ if (capabilityGranted) {
3883
+ const badSeg = hiddenSegmentOf(normRel);
3884
+ if (badSeg) {
3885
+ this.log(`Files-gateway DENY '${sessionId}': channel capability may not reach a hidden path (${badSeg})`);
3886
+ deny(403, `Forbidden: channel file access may not reach hidden paths (${badSeg})`);
3887
+ return;
3888
+ }
3889
+ }
3860
3890
  if (method === "GET" || method === "HEAD") {
3861
3891
  if (url.searchParams.get("list") === "1") {
3862
3892
  const lex = this.resolveContainedPath(rootDir, normRel);
@@ -3878,6 +3908,7 @@ a{color:#0969da;text-decoration:none;font-weight:500}a:hover{text-decoration:und
3878
3908
  }
3879
3909
  return { name: d.name, isDir, size, mtime };
3880
3910
  });
3911
+ if (capabilityGranted) entries = entries.filter((e) => !e.name.startsWith("."));
3881
3912
  } catch (err) {
3882
3913
  deny(err?.code === "ENOENT" ? 404 : 500, `Listing failed: ${err?.message || err}`);
3883
3914
  return;
@@ -4458,6 +4489,7 @@ var serveManager = /*#__PURE__*/Object.freeze({
4458
4489
  ServeManager: ServeManager,
4459
4490
  assertNonAdminMountDirSafe: assertNonAdminMountDirSafe,
4460
4491
  buildLinkSubdomain: buildLinkSubdomain,
4492
+ hiddenSegmentOf: hiddenSegmentOf,
4461
4493
  resolveMountOwnerEmail: resolveMountOwnerEmail,
4462
4494
  sanitizeMountForRole: sanitizeMountForRole
4463
4495
  });
@@ -4636,8 +4668,13 @@ function applyPromptCacheTtl(spawnEnv) {
4636
4668
  const cacheOverride = spawnEnv.ENABLE_PROMPT_CACHING_1H ?? process.env.ENABLE_PROMPT_CACHING_1H ?? spawnEnv.FORCE_PROMPT_CACHING_5M ?? process.env.FORCE_PROMPT_CACHING_5M;
4637
4669
  if (cacheOverride === void 0) spawnEnv.ENABLE_PROMPT_CACHING_1H = "1";
4638
4670
  }
4671
+ function applyTodoTools(spawnEnv) {
4672
+ const override = spawnEnv.CLAUDE_CODE_ENABLE_TODO_TOOLS ?? process.env.CLAUDE_CODE_ENABLE_TODO_TOOLS;
4673
+ if (override === void 0) spawnEnv.CLAUDE_CODE_ENABLE_TODO_TOOLS = "1";
4674
+ }
4639
4675
  function applyClaudeProxyEnv(spawnEnv) {
4640
4676
  const mode = currentMode();
4677
+ applyTodoTools(spawnEnv);
4641
4678
  if (mode === "hypha") {
4642
4679
  const proxyUrl = resolveHyphaProxyUrl();
4643
4680
  if (!proxyUrl) {
@@ -5692,20 +5729,22 @@ function channelKeyGrantsFileAccess(channels, token) {
5692
5729
  }
5693
5730
  };
5694
5731
  for (const ch of channels) {
5732
+ if (ch.enabled === false) continue;
5695
5733
  if (!ch.fileAccess?.enabled) continue;
5696
5734
  if (eq(ch.identity?.shared_key)) return true;
5697
5735
  for (const caller of ch.identity?.callers || []) if (eq(caller.key)) return true;
5698
5736
  }
5699
5737
  return false;
5700
5738
  }
5739
+ const _channelListCache = /* @__PURE__ */ new Map();
5740
+ const _CHANNEL_CACHE_TTL_MS = 1e3;
5741
+ function invalidateChannelCache(dir) {
5742
+ _channelListCache.delete(dir);
5743
+ }
5701
5744
  class ChannelStore {
5702
5745
  dir;
5703
5746
  constructor(projectDir) {
5704
5747
  this.dir = join$1(projectDir, ".svamp", "channels");
5705
- try {
5706
- mkdirSync$1(this.dir, { recursive: true });
5707
- } catch {
5708
- }
5709
5748
  }
5710
5749
  // #0982: reject a traversing/oversized id at the path boundary itself, so no
5711
5750
  // caller (RPC, HTTP, CLI) can reach outside this.dir regardless of its own checks.
@@ -5728,17 +5767,41 @@ class ChannelStore {
5728
5767
  const tmp = `${this._path(c.id)}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
5729
5768
  writeFileSync$1(tmp, JSON.stringify(c, null, 2), { mode: 384 });
5730
5769
  renameSync(tmp, this._path(c.id));
5770
+ invalidateChannelCache(this.dir);
5731
5771
  return c;
5732
5772
  }
5773
+ /**
5774
+ * All channels in this project, parsed. Cached per directory and revalidated with ONE statSync
5775
+ * — see the #1049/#1068 note above for why the uncached version was a daemon-wide stall.
5776
+ */
5733
5777
  list() {
5734
- if (!existsSync(this.dir)) return [];
5735
- return readdirSync(this.dir).filter((f) => f.endsWith(".json")).map((f) => {
5736
- try {
5737
- return normalizeBind(JSON.parse(readFileSync(join$1(this.dir, f), "utf8")));
5738
- } catch {
5739
- return null;
5740
- }
5741
- }).filter((c) => !!c);
5778
+ let st;
5779
+ try {
5780
+ const s = statSync(this.dir);
5781
+ st = { mtimeMs: s.mtimeMs, size: s.size };
5782
+ } catch {
5783
+ _channelListCache.delete(this.dir);
5784
+ return [];
5785
+ }
5786
+ const hit = _channelListCache.get(this.dir);
5787
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size && Date.now() - hit.at < _CHANNEL_CACHE_TTL_MS) {
5788
+ return hit.channels;
5789
+ }
5790
+ let channels;
5791
+ try {
5792
+ channels = readdirSync(this.dir).filter((f) => f.endsWith(".json")).map((f) => {
5793
+ try {
5794
+ return normalizeBind(JSON.parse(readFileSync(join$1(this.dir, f), "utf8")));
5795
+ } catch {
5796
+ return null;
5797
+ }
5798
+ }).filter((c) => !!c);
5799
+ } catch {
5800
+ _channelListCache.delete(this.dir);
5801
+ return [];
5802
+ }
5803
+ _channelListCache.set(this.dir, { mtimeMs: st.mtimeMs, size: st.size, at: Date.now(), channels });
5804
+ return channels;
5742
5805
  }
5743
5806
  get(id) {
5744
5807
  try {
@@ -5756,6 +5819,7 @@ class ChannelStore {
5756
5819
  const p = this._path(id);
5757
5820
  if (existsSync(p)) {
5758
5821
  rmSync$1(p);
5822
+ invalidateChannelCache(this.dir);
5759
5823
  return true;
5760
5824
  }
5761
5825
  return false;
@@ -5796,6 +5860,28 @@ class ChannelStore {
5796
5860
  return caller;
5797
5861
  });
5798
5862
  }
5863
+ /**
5864
+ * #1036: revoke ONE caller's key without destroying the channel.
5865
+ *
5866
+ * Before this, the only removal primitive was remove(id) — so withdrawing a single leaked key
5867
+ * meant deleting the whole channel and every other caller with it. That is a bad enough trade
5868
+ * that in practice a leaked key just stayed live, which matters much more now that a caller key
5869
+ * can carry whole-project file access (see channelKeyGrantsFileAccess).
5870
+ *
5871
+ * Same per-channel lock as the sibling mutators (#0679), so a concurrent addCaller cannot
5872
+ * resurrect the revoked entry by writing a stale base. Returns true when a caller was removed.
5873
+ */
5874
+ removeCaller(id, name) {
5875
+ return withFileLock(this._lock(id), () => {
5876
+ const c = this.get(id);
5877
+ if (!c?.identity?.callers) return false;
5878
+ const before = c.identity.callers.length;
5879
+ c.identity.callers = c.identity.callers.filter((x) => x.name !== name);
5880
+ if (c.identity.callers.length === before) return false;
5881
+ this._writeChannel(c);
5882
+ return true;
5883
+ });
5884
+ }
5799
5885
  }
5800
5886
  function routingSession(channel, ctx) {
5801
5887
  const mode = bindMode(channel);
@@ -6435,6 +6521,9 @@ function getParamNames(fn) {
6435
6521
  const terminalSessions = /* @__PURE__ */ new Map();
6436
6522
  let ptyModule = null;
6437
6523
  const TERMINAL_IDLE_MS = 30 * 60 * 1e3;
6524
+ const TERMINAL_CLIENT_ABSENT_MS = 4 * 60 * 60 * 1e3;
6525
+ const TERMINAL_BUFFER_MAX_BYTES = 1024 * 1024;
6526
+ const TERMINAL_MAX_SESSIONS = 64;
6438
6527
  let terminalReaper = null;
6439
6528
  function ensureTerminalReaper() {
6440
6529
  if (terminalReaper) return;
@@ -6442,7 +6531,8 @@ function ensureTerminalReaper() {
6442
6531
  const now = Date.now();
6443
6532
  for (const [id, s] of terminalSessions) {
6444
6533
  if (s.exited) continue;
6445
- if (now - s.lastActivity > TERMINAL_IDLE_MS) {
6534
+ const abandoned = now - (s.lastClientActivity ?? s.lastActivity) > TERMINAL_CLIENT_ABSENT_MS;
6535
+ if (now - s.lastActivity > TERMINAL_IDLE_MS || abandoned) {
6446
6536
  try {
6447
6537
  s.pty.kill();
6448
6538
  } catch {
@@ -7296,8 +7386,29 @@ async function registerMachineService(server, machineId, metadata, daemonState,
7296
7386
  rows,
7297
7387
  createdAt: Date.now(),
7298
7388
  lastActivity: Date.now(),
7389
+ lastClientActivity: Date.now(),
7390
+ outputBytes: 0,
7299
7391
  cwd
7300
7392
  };
7393
+ if (terminalSessions.size >= TERMINAL_MAX_SESSIONS) {
7394
+ let victim = null, oldest = Infinity;
7395
+ for (const [id, s] of terminalSessions) {
7396
+ const seen = s.lastClientActivity ?? s.lastActivity;
7397
+ if (seen < oldest) {
7398
+ oldest = seen;
7399
+ victim = id;
7400
+ }
7401
+ }
7402
+ if (victim) {
7403
+ const v = terminalSessions.get(victim);
7404
+ try {
7405
+ v?.pty.kill();
7406
+ } catch {
7407
+ }
7408
+ terminalSessions.delete(victim);
7409
+ console.log(`[terminal] evicted idlest session ${victim} \u2014 at the ${TERMINAL_MAX_SESSIONS}-PTY ceiling`);
7410
+ }
7411
+ }
7301
7412
  terminalSessions.set(sessionId, session);
7302
7413
  ensureTerminalReaper();
7303
7414
  let outputBatch = "";
@@ -7308,8 +7419,9 @@ async function registerMachineService(server, machineId, metadata, daemonState,
7308
7419
  const batch = outputBatch;
7309
7420
  outputBatch = "";
7310
7421
  session.outputBuffer.push(batch);
7311
- if (session.outputBuffer.length > 1e3) {
7312
- session.outputBuffer.splice(0, session.outputBuffer.length - 500);
7422
+ session.outputBytes += batch.length;
7423
+ while (session.outputBytes > TERMINAL_BUFFER_MAX_BYTES && session.outputBuffer.length > 1) {
7424
+ session.outputBytes -= (session.outputBuffer.shift() || "").length;
7313
7425
  }
7314
7426
  if (terminalClient) {
7315
7427
  try {
@@ -7358,6 +7470,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
7358
7470
  const filtered = filterTerminalResponses(params.data);
7359
7471
  if (filtered) session.pty.write(filtered);
7360
7472
  session.lastActivity = Date.now();
7473
+ session.lastClientActivity = Date.now();
7361
7474
  return { success: true };
7362
7475
  },
7363
7476
  /** Resize a terminal session. */
@@ -7379,7 +7492,9 @@ async function registerMachineService(server, machineId, metadata, daemonState,
7379
7492
  const session = terminalSessions.get(params.sessionId);
7380
7493
  if (!session) throw new Error(`Terminal session ${params.sessionId} not found`);
7381
7494
  const output = session.outputBuffer.splice(0).join("");
7495
+ session.outputBytes = 0;
7382
7496
  session.lastActivity = Date.now();
7497
+ session.lastClientActivity = Date.now();
7383
7498
  const result = {
7384
7499
  output,
7385
7500
  exited: session.exited
@@ -7827,7 +7942,8 @@ async function registerMachineService(server, machineId, metadata, daemonState,
7827
7942
  process: params.process,
7828
7943
  sessionId: params.sessionId,
7829
7944
  access,
7830
- ownerEmail: ownerEmail2
7945
+ ownerEmail: ownerEmail2,
7946
+ regenerateUrl: params.regenerateUrl
7831
7947
  });
7832
7948
  },
7833
7949
  /** Remove a mount from the shared static file server. */
@@ -8189,7 +8305,7 @@ QUESTION: ${params.question || "Summarize this concisely."}` }
8189
8305
  }
8190
8306
  const deps = buildSessionDeps(rpc, { cwd, ownerEmail: owner });
8191
8307
  const sender = { name: context?.user?.email || context?.user?.id || "user", kind: "user", verified: true };
8192
- const { toolsForRole } = await import('./sideband-5ck3CDWM.mjs');
8308
+ const { toolsForRole } = await import('./sideband-B7sDP61Z.mjs');
8193
8309
  const r2 = await runWiseAgent({ message: params.message, sender, config: { tools: toolsForRole(role2) }, deps, transport, model: resolved.model });
8194
8310
  return fmt(r2);
8195
8311
  }
@@ -8294,7 +8410,7 @@ QUESTION: ${params.question || "Summarize this concisely."}` }
8294
8410
  return { ok: false, call_id: callId, status: "busy", error: "channel is busy (too many concurrent requests) \u2014 retry shortly" };
8295
8411
  }
8296
8412
  const rendered = renderMessage(c, { sender: r.sender, body: { message: kwargs.message }, callId });
8297
- const { queryCore } = await import('./commands-CxfUr2Qr.mjs');
8413
+ const { queryCore } = await import('./commands-DEZLlXJ3.mjs');
8298
8414
  const timeout = c.reply?.timeout_sec || 120;
8299
8415
  let result;
8300
8416
  try {
@@ -8858,6 +8974,11 @@ function nextBackoffMs(prevMs) {
8858
8974
  if (!prevMs || prevMs <= 0) return RL_BACKOFF_MIN_MS;
8859
8975
  return Math.min(prevMs * 2, RL_BACKOFF_MAX_MS);
8860
8976
  }
8977
+ const LISTENER_ERROR_STRIKE_LIMIT = 3;
8978
+ function decideListenerErrorAction(prevStrikes, limit = LISTENER_ERROR_STRIKE_LIMIT) {
8979
+ const strikes = (prevStrikes > 0 ? prevStrikes : 0) + 1;
8980
+ return { strikes, drop: strikes >= limit };
8981
+ }
8861
8982
  function retryDecision(listenerPresent, now, backoffUntil) {
8862
8983
  if (!listenerPresent) return "drop";
8863
8984
  if (backoffUntil !== void 0 && now < backoffUntil) return "reschedule";
@@ -9442,6 +9563,7 @@ function loadMessagesFromDiskReverse(messagesDir, beforeSeq, limit) {
9442
9563
  }
9443
9564
  }
9444
9565
  const messageCountCache = /* @__PURE__ */ new Map();
9566
+ const MESSAGE_COUNT_CACHE_MAX = 2e3;
9445
9567
  function countMessagesOnDisk(messagesDir, fallbackInMemory) {
9446
9568
  const filePath = join$1(messagesDir, "messages.jsonl");
9447
9569
  if (!existsSync(filePath)) return fallbackInMemory;
@@ -9469,7 +9591,13 @@ function countMessagesOnDisk(messagesDir, fallbackInMemory) {
9469
9591
  for (let i = 0; i < data.length; i++) if (data.charCodeAt(i) === 10) count++;
9470
9592
  if (data.length > 0 && data[data.length - 1] !== "\n") count++;
9471
9593
  }
9594
+ messageCountCache.delete(filePath);
9472
9595
  messageCountCache.set(filePath, { size: st.size, mtimeMs: st.mtimeMs, count });
9596
+ while (messageCountCache.size > MESSAGE_COUNT_CACHE_MAX) {
9597
+ const oldest = messageCountCache.keys().next();
9598
+ if (oldest.done) break;
9599
+ messageCountCache.delete(oldest.value);
9600
+ }
9473
9601
  return count;
9474
9602
  } catch {
9475
9603
  return fallbackInMemory;
@@ -9592,11 +9720,13 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
9592
9720
  const listeners = [];
9593
9721
  const rateLimitBackoff = /* @__PURE__ */ new Map();
9594
9722
  const rateLimitRetry = /* @__PURE__ */ new Map();
9723
+ const listenerErrorStrikes = /* @__PURE__ */ new Map();
9595
9724
  const removeListener = (listener, reason) => {
9596
9725
  const idx = listeners.indexOf(listener);
9597
9726
  if (idx >= 0) {
9598
9727
  listeners.splice(idx, 1);
9599
9728
  rateLimitBackoff.delete(listener);
9729
+ listenerErrorStrikes.delete(listener);
9600
9730
  const t = rateLimitRetry.get(listener);
9601
9731
  if (t) {
9602
9732
  clearTimeout(t);
@@ -9621,8 +9751,15 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
9621
9751
  } else if (kind === "stale") {
9622
9752
  removeListener(listener, "stale connection");
9623
9753
  } else {
9624
- console.error(`[HYPHA SESSION ${sessionId}] Async listener error:`, err);
9625
- removeListener(listener, "async error");
9754
+ const { strikes, drop } = decideListenerErrorAction(listenerErrorStrikes.get(listener) || 0);
9755
+ if (drop) {
9756
+ listenerErrorStrikes.delete(listener);
9757
+ console.error(`[HYPHA SESSION ${sessionId}] Listener error (strike ${strikes}/${LISTENER_ERROR_STRIKE_LIMIT}, dropping):`, err);
9758
+ removeListener(listener, "async error");
9759
+ } else {
9760
+ listenerErrorStrikes.set(listener, strikes);
9761
+ console.warn(`[HYPHA SESSION ${sessionId}] Listener error (strike ${strikes}/${LISTENER_ERROR_STRIKE_LIMIT}, keeping listener):`, String(err?.message || err || ""));
9762
+ }
9626
9763
  }
9627
9764
  };
9628
9765
  const scheduleBackoffRetry = (listener) => {
@@ -9670,11 +9807,13 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
9670
9807
  if (result && typeof result.catch === "function") {
9671
9808
  result.then(() => {
9672
9809
  rateLimitBackoff.delete(listener);
9810
+ listenerErrorStrikes.delete(listener);
9673
9811
  }).catch((err) => handleListenerRejection(listener, err));
9812
+ } else {
9813
+ listenerErrorStrikes.delete(listener);
9674
9814
  }
9675
9815
  } catch (err) {
9676
- console.error(`[HYPHA SESSION ${sessionId}] Listener error:`, err);
9677
- removeListener(listener, "sync error");
9816
+ handleListenerRejection(listener, err);
9678
9817
  }
9679
9818
  }
9680
9819
  };
@@ -10820,7 +10959,9 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
10820
10959
  // ── Listener Registration ──
10821
10960
  registerListener: async (callback, context) => {
10822
10961
  authorizeRequest(context, metadata.sharing, "view");
10962
+ const MAX_LISTENERS = 64;
10823
10963
  listeners.push(callback);
10964
+ while (listeners.length > MAX_LISTENERS) listeners.shift();
10824
10965
  const replayMessages = messages.slice(-50);
10825
10966
  const REPLAY_MESSAGE_TIMEOUT_MS = 1e4;
10826
10967
  for (const msg of replayMessages) {
@@ -13698,6 +13839,25 @@ var acpAgentConfig = /*#__PURE__*/Object.freeze({
13698
13839
  resolveAcpAgentConfig: resolveAcpAgentConfig
13699
13840
  });
13700
13841
 
13842
+ const COMPLETED_REQUESTS_MAX = 500;
13843
+ function recordCompletedRequest(existing, id, entry, max = COMPLETED_REQUESTS_MAX) {
13844
+ const out = { ...existing || {} };
13845
+ delete out[id];
13846
+ const slim = {
13847
+ completedAt: typeof entry.completedAt === "number" ? entry.completedAt : Date.now(),
13848
+ status: String(entry.status ?? "")
13849
+ };
13850
+ if (typeof entry.tool === "string") slim.tool = entry.tool;
13851
+ if (entry.reason !== void 0) slim.reason = entry.reason;
13852
+ if (entry.mode !== void 0) slim.mode = entry.mode;
13853
+ if (entry.decision !== void 0) slim.decision = entry.decision;
13854
+ if (Array.isArray(entry.allowedTools)) slim.allowedTools = entry.allowedTools;
13855
+ out[id] = slim;
13856
+ const keys = Object.keys(out);
13857
+ for (let i = 0; i < keys.length - max; i++) delete out[keys[i]];
13858
+ return out;
13859
+ }
13860
+
13701
13861
  function applyPermissionResolution(sessionService, requestId, approved) {
13702
13862
  const reqs = { ...sessionService._agentState?.requests };
13703
13863
  if (!(requestId in reqs)) return;
@@ -13707,14 +13867,12 @@ function applyPermissionResolution(sessionService, requestId, approved) {
13707
13867
  sessionService.updateAgentState({
13708
13868
  controlledByUser: false,
13709
13869
  requests: reqs,
13710
- completedRequests: {
13711
- ...completedReqs,
13712
- [requestId]: {
13713
- ...existingReq || {},
13714
- completedAt: Date.now(),
13715
- status: approved ? "approved" : "denied"
13716
- }
13717
- }
13870
+ // #1041: bounded + slimmed, same as the Claude permission path in daemon/run.ts.
13871
+ completedRequests: recordCompletedRequest(completedReqs, requestId, {
13872
+ ...existingReq || {},
13873
+ completedAt: Date.now(),
13874
+ status: approved ? "approved" : "denied"
13875
+ })
13718
13876
  });
13719
13877
  }
13720
13878
  function bridgeAcpToSession(backend, sessionService, getMetadata, setMetadata, log, onTurnEnd, getModelLabel) {
@@ -15732,6 +15890,30 @@ function classifyRestoreContinuation(opts) {
15732
15890
  if (opts.wasProcessing && opts.hasResumeId) return "auto-continue";
15733
15891
  return "none";
15734
15892
  }
15893
+ const PERMANENT_RESTORE_PATTERNS = [
15894
+ /failed to create directory/i,
15895
+ /no such file or directory/i,
15896
+ /\benoent\b/i,
15897
+ // spawn ENOENT = launcher/path missing
15898
+ /no agent launchers?/i,
15899
+ /cli (?:is )?(?:not )?installed|command not found/i,
15900
+ /not authorized|permission denied|\beacces\b/i,
15901
+ /\binvalid\b|malformed|unsupported/i
15902
+ ];
15903
+ function classifyRestoreFailure(errorMessage) {
15904
+ const m = errorMessage || "";
15905
+ for (const re of PERMANENT_RESTORE_PATTERNS) if (re.test(m)) return "permanent";
15906
+ return "transient";
15907
+ }
15908
+ const RESTORE_MAX_RETRIES = 3;
15909
+ const RESTORE_BACKOFF_MS = [500, 1500, 4e3];
15910
+ function restoreBackoffMs(attempt) {
15911
+ const i = attempt < 0 ? 0 : Math.min(attempt, RESTORE_BACKOFF_MS.length - 1);
15912
+ return RESTORE_BACKOFF_MS[i];
15913
+ }
15914
+ function shouldRetryRestore(kind, attempt, maxRetries = RESTORE_MAX_RETRIES) {
15915
+ return kind === "transient" && attempt < maxRetries;
15916
+ }
15735
15917
 
15736
15918
  function getShareBaseUrl() {
15737
15919
  return getSvampWebBaseUrl();
@@ -18035,6 +18217,13 @@ function shouldRunZombieProbe(args) {
18035
18217
  function nextFailuresAfterZombieProbeFailure(consecutiveHeartbeatFailures) {
18036
18218
  return consecutiveHeartbeatFailures + 1;
18037
18219
  }
18220
+ function shouldRunServiceProbe(args) {
18221
+ return !args.inGrace && !args.zombieProbeRan;
18222
+ }
18223
+ function decideDisconnectRecovery(args) {
18224
+ if (args.shutdownRequested) return "stay";
18225
+ return args.supervised ? "exit-for-respawn" : "stay";
18226
+ }
18038
18227
  function shouldForceReconnect(consecutiveHeartbeatFailures) {
18039
18228
  if (consecutiveHeartbeatFailures < 2) return false;
18040
18229
  return consecutiveHeartbeatFailures === 2 || consecutiveHeartbeatFailures % 3 === 0;
@@ -20082,7 +20271,7 @@ async function startDaemon(options) {
20082
20271
  try {
20083
20272
  const dir = loadSessionIndex()[sessionId]?.directory;
20084
20273
  if (!dir) return;
20085
- const { reconcileServiceLinks } = await import('./agentCommands-DUHUBzrj.mjs');
20274
+ const { reconcileServiceLinks } = await import('./agentCommands-DefL1pIo.mjs');
20086
20275
  const configPath = getSvampConfigPath(dir, sessionId);
20087
20276
  const config = readSvampConfig(configPath);
20088
20277
  const entries = Array.from(urls.entries());
@@ -20104,7 +20293,7 @@ async function startDaemon(options) {
20104
20293
  try {
20105
20294
  const dir = loadSessionIndex()[sessionId]?.directory;
20106
20295
  if (!dir) return;
20107
- const { reconcileServiceLinks } = await import('./agentCommands-DUHUBzrj.mjs');
20296
+ const { reconcileServiceLinks } = await import('./agentCommands-DefL1pIo.mjs');
20108
20297
  const configPath = getSvampConfigPath(dir, sessionId);
20109
20298
  const config = readSvampConfig(configPath);
20110
20299
  const incoming = [{
@@ -20125,7 +20314,7 @@ async function startDaemon(options) {
20125
20314
  try {
20126
20315
  const dir = loadSessionIndex()[sessionId]?.directory;
20127
20316
  if (!dir) return;
20128
- const { dropServiceLinks } = await import('./agentCommands-DUHUBzrj.mjs');
20317
+ const { dropServiceLinks } = await import('./agentCommands-DefL1pIo.mjs');
20129
20318
  const configPath = getSvampConfigPath(dir, sessionId);
20130
20319
  const config = readSvampConfig(configPath);
20131
20320
  if (dropServiceLinks(config, "serve", mountName)) {
@@ -20199,7 +20388,14 @@ async function startDaemon(options) {
20199
20388
  supervised: process.env.SVAMP_SUPERVISED === "1"
20200
20389
  });
20201
20390
  server.on("disconnected", (reason) => {
20202
- logger.log(`Hypha connection permanently lost: ${reason}. Daemon continues running \u2014 restart manually to reconnect.`);
20391
+ const supervised2 = process.env.SVAMP_SUPERVISED === "1";
20392
+ const recovery = decideDisconnectRecovery({ supervised: supervised2, shutdownRequested });
20393
+ if (recovery === "exit-for-respawn") {
20394
+ logger.log(`Hypha connection permanently lost: ${reason}. Exiting for a clean supervised respawn (sessions restore via --resume).`);
20395
+ setTimeout(() => process.exit(1), 250);
20396
+ } else {
20397
+ logger.log(`Hypha connection permanently lost: ${reason}. Daemon continues running (unsupervised) \u2014 restart manually to reconnect.`);
20398
+ }
20203
20399
  });
20204
20400
  const pidToTrackedSession = /* @__PURE__ */ new Map();
20205
20401
  let sessionCoreRef = {
@@ -21388,15 +21584,19 @@ ${parts.join("\n")}`);
21388
21584
  sessionService.updateAgentState({
21389
21585
  controlledByUser: false,
21390
21586
  requests: reqs,
21391
- completedRequests: {
21392
- ...sessionService._agentState?.completedRequests,
21393
- [correlationId]: {
21587
+ // #1041: bounded + slimmed. `arguments: toolInput` used to
21588
+ // be stored here — the whole tool input, e.g. a full Write
21589
+ // body — in a map that grew forever and is re-walked by the
21590
+ // reducer on every streamed batch.
21591
+ completedRequests: recordCompletedRequest(
21592
+ sessionService._agentState?.completedRequests,
21593
+ correlationId,
21594
+ {
21394
21595
  tool: toolName,
21395
- arguments: toolInput,
21396
21596
  completedAt: Date.now(),
21397
21597
  status: result.behavior === "allow" ? "approved" : "denied"
21398
21598
  }
21399
- }
21599
+ )
21400
21600
  });
21401
21601
  }).catch((err) => {
21402
21602
  logger.log(`[Session ${sessionId}] Permission handler error (request ${requestId}): ${err}`);
@@ -22497,11 +22697,11 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
22497
22697
  });
22498
22698
  },
22499
22699
  onIssue: async (params) => {
22500
- const { issueRpc } = await import('./rpc-Qcj4M_LQ.mjs');
22700
+ const { issueRpc } = await import('./rpc-CZqCc9Pr.mjs');
22501
22701
  return issueRpc(params?.cwd || directory, params || {}, { notifySession: notifyIssueOwner, rekickLoopOwner });
22502
22702
  },
22503
22703
  onWorkflow: async (params) => {
22504
- const { workflowRpc } = await import('./rpc-27xBajCe.mjs');
22704
+ const { workflowRpc } = await import('./rpc-BCLZpI5H.mjs');
22505
22705
  return workflowRpc(params?.cwd || directory, params || {});
22506
22706
  },
22507
22707
  onRipgrep: async (args, cwd) => {
@@ -23207,11 +23407,11 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
23207
23407
  });
23208
23408
  },
23209
23409
  onIssue: async (params) => {
23210
- const { issueRpc } = await import('./rpc-Qcj4M_LQ.mjs');
23410
+ const { issueRpc } = await import('./rpc-CZqCc9Pr.mjs');
23211
23411
  return issueRpc(params?.cwd || directory, params || {}, { notifySession: notifyIssueOwner, rekickLoopOwner });
23212
23412
  },
23213
23413
  onWorkflow: async (params) => {
23214
- const { workflowRpc } = await import('./rpc-27xBajCe.mjs');
23414
+ const { workflowRpc } = await import('./rpc-BCLZpI5H.mjs');
23215
23415
  return workflowRpc(params?.cwd || directory, params || {});
23216
23416
  },
23217
23417
  onRipgrep: async (args, cwd) => {
@@ -24119,7 +24319,7 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
24119
24319
  const channelHttpPort = Number(process.env.SVAMP_CHANNEL_HTTP_PORT) || 0;
24120
24320
  if (channelHttpPort > 0) {
24121
24321
  try {
24122
- const { createChannelHttpServer } = await import('./httpServer-1XjB2h3K.mjs');
24322
+ const { createChannelHttpServer } = await import('./httpServer-Be1Hcddr.mjs');
24123
24323
  const channelHttpServer = createChannelHttpServer({
24124
24324
  getSessionIds: () => {
24125
24325
  const ids = [];
@@ -24297,7 +24497,7 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
24297
24497
  }
24298
24498
  logger.log(`Restoring ${persistedSessions.length} persisted session(s)...`);
24299
24499
  const restoreConcurrency = Math.max(1, parseInt(process.env.SVAMP_RESTORE_CONCURRENCY || "8", 10) || 8);
24300
- const restoreOne = async (persisted) => {
24500
+ const restoreOne = async (persisted, attempt = 0) => {
24301
24501
  try {
24302
24502
  const isOrphaned = persisted.machineId && persisted.machineId !== machineId;
24303
24503
  if (isOrphaned) {
@@ -24354,10 +24554,26 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
24354
24554
  sessionsToLoopResume.push({ sessionId: persisted.sessionId, directory: persisted.directory });
24355
24555
  }
24356
24556
  } else {
24357
- logger.log(`Failed to restore session ${persisted.sessionId}: ${result.type}`);
24557
+ const reason = result.errorMessage || result.type;
24558
+ const kind = classifyRestoreFailure(reason);
24559
+ if (shouldRetryRestore(kind, attempt)) {
24560
+ const wait = restoreBackoffMs(attempt);
24561
+ logger.log(`Restore of ${persisted.sessionId} failed (${reason}) \u2014 transient, retrying in ${wait}ms (attempt ${attempt + 1}/${RESTORE_MAX_RETRIES})`);
24562
+ await new Promise((r) => setTimeout(r, wait));
24563
+ return restoreOne(persisted, attempt + 1);
24564
+ }
24565
+ logger.log(`Failed to restore session ${persisted.sessionId}: ${reason} (${kind}; preserved on disk for a later restart)`);
24358
24566
  }
24359
24567
  } catch (err) {
24360
- logger.error(`Error restoring session ${persisted.sessionId}:`, err.message);
24568
+ const reason = err?.message || String(err);
24569
+ const kind = classifyRestoreFailure(reason);
24570
+ if (shouldRetryRestore(kind, attempt)) {
24571
+ const wait = restoreBackoffMs(attempt);
24572
+ logger.log(`Error restoring ${persisted.sessionId}: ${reason} \u2014 transient, retrying in ${wait}ms (attempt ${attempt + 1}/${RESTORE_MAX_RETRIES})`);
24573
+ await new Promise((r) => setTimeout(r, wait));
24574
+ return restoreOne(persisted, attempt + 1);
24575
+ }
24576
+ logger.error(`Error restoring session ${persisted.sessionId} (${kind}, giving up this lifetime):`, reason);
24361
24577
  }
24362
24578
  };
24363
24579
  let restoreCursor = 0;
@@ -24548,7 +24764,7 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
24548
24764
  const PING_TIMEOUT_MS = 15e3;
24549
24765
  const POST_RECONNECT_GRACE_MS = 2e4;
24550
24766
  const RECONNECT_JITTER_MS = 2500;
24551
- const { WorkflowScheduler } = await import('./scheduler-DjAVEyn3.mjs');
24767
+ const { WorkflowScheduler } = await import('./scheduler-s_j089nA.mjs');
24552
24768
  const workflowProjectRoots = () => {
24553
24769
  const dirs = /* @__PURE__ */ new Set();
24554
24770
  for (const s of pidToTrackedSession.values()) {
@@ -24627,7 +24843,9 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
24627
24843
  const INBOUND_SILENCE_THRESHOLD_MS = 12e4;
24628
24844
  const inboundSilenceMs = Date.now() - machineService.getLastInboundRpcAt();
24629
24845
  const hasActiveSessions = pidToTrackedSession.size > 0;
24846
+ let zombieProbeRan = false;
24630
24847
  if (shouldRunZombieProbe({ hasActiveSessions, inboundSilenceMs, consecutiveHeartbeatFailures, inGrace, thresholdMs: INBOUND_SILENCE_THRESHOLD_MS })) {
24848
+ zombieProbeRan = true;
24631
24849
  logger.log(`No inbound RPC for ${Math.round(inboundSilenceMs / 1e3)}s with ${pidToTrackedSession.size} active session(s) \u2014 zombie probe`);
24632
24850
  try {
24633
24851
  const machineServiceId = `${server.config.client_id}:default`;
@@ -24640,7 +24858,7 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
24640
24858
  consecutiveHeartbeatFailures = nextFailuresAfterZombieProbeFailure(consecutiveHeartbeatFailures);
24641
24859
  }
24642
24860
  }
24643
- if (!inGrace) {
24861
+ if (shouldRunServiceProbe({ inGrace, zombieProbeRan })) {
24644
24862
  try {
24645
24863
  const pingStart = Date.now();
24646
24864
  const machineServiceId = `${server.config.client_id}:default`;