svamp-cli 0.2.311 → 0.2.313

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-U5D7i8tD.mjs → adminCommands-DINkkchF.mjs} +1 -1
  2. package/dist/{agentCommands-BefFTl6g.mjs → agentCommands-CqHUKyRM.mjs} +5 -5
  3. package/dist/{auth-DgGAjihq.mjs → auth-fmYi12gU.mjs} +1 -1
  4. package/dist/{cli-B0TyKXyU.mjs → cli-C0K7bTFQ.mjs} +70 -70
  5. package/dist/cli.mjs +2 -2
  6. package/dist/{commands-CEOhve0Y.mjs → commands-B2KHp8-n.mjs} +2 -2
  7. package/dist/{commands-CjVl4amM.mjs → commands-BlSsMfW6.mjs} +8 -8
  8. package/dist/{commands-BoGUxb2o.mjs → commands-BspIWEyi.mjs} +1 -1
  9. package/dist/{commands-XQ8-fOSu.mjs → commands-CE9x3OVJ.mjs} +1 -1
  10. package/dist/{commands-CnqL60f7.mjs → commands-DMG7VDBo.mjs} +1 -1
  11. package/dist/{commands-imLC7CK2.mjs → commands-DxwqcKtb.mjs} +2 -2
  12. package/dist/{commands-xoalqGUY.mjs → commands-c57jjMV-.mjs} +10 -5
  13. package/dist/{commands-BCQbBzYU.mjs → commands-lt3_KA5S.mjs} +2 -2
  14. package/dist/{fleet-BS5eS0Wz.mjs → fleet-DX-cpu52.mjs} +2 -2
  15. package/dist/{frpc-DvBVvsZC.mjs → frpc-B5LHRKTD.mjs} +1 -1
  16. package/dist/{headlessCli-C9wTLm1x.mjs → headlessCli-BE4CanO7.mjs} +2 -2
  17. package/dist/index.mjs +1 -1
  18. package/dist/{notifyCommands-0IltLO-S.mjs → notifyCommands-Deh2F41t.mjs} +1 -1
  19. package/dist/{package-CFhmZcVJ.mjs → package-C9HRV5XP.mjs} +1 -1
  20. package/dist/{pinnedClaudeCode-zDzkOTi2.mjs → pinnedClaudeCode-mM9Q6Q2G.mjs} +1 -1
  21. package/dist/{rpc-BXpuORiQ.mjs → rpc-BxOm5nyE.mjs} +1 -1
  22. package/dist/{rpc-BLWr4p8g.mjs → rpc-DOl276lO.mjs} +1 -1
  23. package/dist/{run-CMoQtnIj.mjs → run-CDZ1g-Fa.mjs} +1 -1
  24. package/dist/{run-DtVX2pQN.mjs → run-DI8N2DhV.mjs} +364 -75
  25. package/dist/{scheduler-7aLvf5mn.mjs → scheduler-C0WXBdL_.mjs} +1 -1
  26. package/dist/{serveCommands-D7BieIJ5.mjs → serveCommands-DChM4ubY.mjs} +5 -5
  27. package/dist/{sideband-DjjXlVhw.mjs → sideband-BdHdwflN.mjs} +1 -1
  28. package/package.json +1 -1
@@ -1121,7 +1121,13 @@ function buildSessionDeps(rpc, opts = {}) {
1121
1121
  loop: {
1122
1122
  task: opts2?.prompt?.trim() || "Work the ready issues in this session backlog until none remain (`svamp issue list --status ready`).",
1123
1123
  oracle: "svamp issue pending",
1124
- max_iterations: opts2?.maxIterations ?? 20,
1124
+ // Durable backlog drainer: no forced iteration cap. A finite `?? 20` sent the loop
1125
+ // to a terminal `gave_up` after 20 turns even with issues remaining — a concrete
1126
+ // "loop stops easily" cause. Only an EXPLICIT user-set cap bounds it; otherwise the
1127
+ // loop runs unbounded (real runaway guard = the rolling per-hour token rate cap) and
1128
+ // goes DORMANT (armed) when the backlog clears. `durable:true` marks it re-arming.
1129
+ ...opts2?.maxIterations ? { max_iterations: opts2.maxIterations } : {},
1130
+ durable: true,
1125
1131
  evaluator: true
1126
1132
  }
1127
1133
  }, ctx);
@@ -3147,7 +3153,7 @@ Connection: close\r
3147
3153
  const mount = this.mounts.get(mountName);
3148
3154
  const subdomainOverride = mount?.access === "link" && mount.linkToken ? /* @__PURE__ */ new Map([[this.port, buildLinkSubdomain(subdomainSafe, mount.linkToken)]]) : void 0;
3149
3155
  try {
3150
- const { FrpcTunnel } = await import('./frpc-DvBVvsZC.mjs');
3156
+ const { FrpcTunnel } = await import('./frpc-B5LHRKTD.mjs');
3151
3157
  let tunnel;
3152
3158
  tunnel = new FrpcTunnel({
3153
3159
  name: tunnelName,
@@ -6280,7 +6286,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
6280
6286
  const tunnels = handlers.tunnels;
6281
6287
  if (!tunnels) throw new Error("Tunnel management not available");
6282
6288
  if (tunnels.has(params.name)) throw new Error(`Tunnel '${params.name}' already running`);
6283
- const { FrpcTunnel } = await import('./frpc-DvBVvsZC.mjs');
6289
+ const { FrpcTunnel } = await import('./frpc-B5LHRKTD.mjs');
6284
6290
  const tunnel = new FrpcTunnel({
6285
6291
  name: params.name,
6286
6292
  ports: params.ports,
@@ -6773,7 +6779,7 @@ QUESTION: ${params.question || "Summarize this concisely."}` }
6773
6779
  }
6774
6780
  const deps = buildSessionDeps(rpc, { cwd, ownerEmail: owner });
6775
6781
  const sender = { name: context?.user?.email || context?.user?.id || "user", kind: "user", verified: true };
6776
- const { toolsForRole } = await import('./sideband-DjjXlVhw.mjs');
6782
+ const { toolsForRole } = await import('./sideband-BdHdwflN.mjs');
6777
6783
  const r2 = await runWiseAgent({ message: params.message, sender, config: { tools: toolsForRole(role2) }, deps, transport, model: resolved.model });
6778
6784
  return fmt(r2);
6779
6785
  }
@@ -6872,13 +6878,13 @@ QUESTION: ${params.question || "Summarize this concisely."}` }
6872
6878
  hyphaWorkspace: u?.scope?.current_workspace
6873
6879
  });
6874
6880
  if (r.error || !r.sender) return { error: r.error || "unauthorized" };
6875
- const callId = "call_" + Math.random().toString(16).slice(2, 12);
6881
+ const callId = "call_" + randomUUID().slice(0, 10);
6876
6882
  const senderKey = (r.sender.name || "anonymous").toLowerCase();
6877
6883
  if (!statelessLimiter.tryAcquire(senderKey)) {
6878
6884
  return { ok: false, call_id: callId, status: "busy", error: "channel is busy (too many concurrent requests) \u2014 retry shortly" };
6879
6885
  }
6880
6886
  const rendered = renderMessage(c, { sender: r.sender, body: { message: kwargs.message }, callId });
6881
- const { queryCore } = await import('./commands-BoGUxb2o.mjs');
6887
+ const { queryCore } = await import('./commands-BspIWEyi.mjs');
6882
6888
  const timeout = c.reply?.timeout_sec || 120;
6883
6889
  let result;
6884
6890
  try {
@@ -7018,7 +7024,7 @@ ${d?.error || "not found"}`;
7018
7024
  try {
7019
7025
  const saved = writeUpload(dir, v.target, dec.data);
7020
7026
  try {
7021
- new ChannelStore(dir).recordCall(c.id, { sender: r.sender.name, verified: r.sender.verified, callId: "up_" + Math.random().toString(16).slice(2, 10), outcome: "uploaded" });
7027
+ new ChannelStore(dir).recordCall(c.id, { sender: r.sender.name, verified: r.sender.verified, callId: "up_" + randomUUID().slice(0, 10), outcome: "uploaded" });
7022
7028
  } catch {
7023
7029
  }
7024
7030
  return { ok: true, name: saved.name, path: saved.relPath, bytes: saved.bytes };
@@ -7670,7 +7676,15 @@ async function _mintPairingCodeLocked(opts, resolver, ttlMs) {
7670
7676
  }
7671
7677
 
7672
7678
  const OUTPOST_STALE_MS = 9e4;
7673
- const OUTPOST_EXEC_TIMEOUT_MS = 10 * 6e4;
7679
+ const OUTPOST_EXEC_TIMEOUT_MS = 12 * 6e4;
7680
+ const OUTPOST_MAX_OUTPUT_BYTES = 1 << 20;
7681
+ function capOutpostOutput(s) {
7682
+ if (!s) return "";
7683
+ if (s.length <= OUTPOST_MAX_OUTPUT_BYTES) return s;
7684
+ const dropped = s.length - OUTPOST_MAX_OUTPUT_BYTES;
7685
+ return `[svamp-daemon] output truncated: ${dropped} earlier characters dropped]
7686
+ ${s.slice(-OUTPOST_MAX_OUTPUT_BYTES)}`;
7687
+ }
7674
7688
  class OutpostCoordinator {
7675
7689
  conns = /* @__PURE__ */ new Map();
7676
7690
  queue = /* @__PURE__ */ new Map();
@@ -7816,7 +7830,14 @@ class OutpostCoordinator {
7816
7830
  const timer = this.setTimer(() => {
7817
7831
  if (!this.pending.has(id)) return;
7818
7832
  this.pending.delete(id);
7819
- resolve({ id, stdout: "", stderr: `command timed out after ${Math.round(this.execTimeoutMs / 1e3)}s`, exitCode: -1, synthetic: true, ts: this.now() });
7833
+ resolve({
7834
+ id,
7835
+ stdout: "",
7836
+ stderr: `command timed out after ${Math.round(this.execTimeoutMs / 1e3)}s with no result from the outpost \u2014 it may still be running remotely, so re-running it is NOT necessarily safe`,
7837
+ exitCode: -1,
7838
+ synthetic: true,
7839
+ ts: this.now()
7840
+ });
7820
7841
  }, this.execTimeoutMs);
7821
7842
  this.pending.set(id, { machine, resolve, timer, enqueuedAt: this.now() });
7822
7843
  });
@@ -7840,8 +7861,11 @@ class OutpostCoordinator {
7840
7861
  this.touch(p.machine);
7841
7862
  p.resolve({
7842
7863
  id: result.id,
7843
- stdout: result.stdout ?? "",
7844
- stderr: result.stderr ?? "",
7864
+ // #0814: the outpost caps its own output, but clamp again here — a hostile or
7865
+ // out-of-date outpost must not be able to push an unbounded string through the
7866
+ // daemon and out over Hypha RPC (the #0598 oversized-message class).
7867
+ stdout: capOutpostOutput(result.stdout),
7868
+ stderr: capOutpostOutput(result.stderr),
7845
7869
  exitCode: typeof result.exitCode === "number" ? result.exitCode : 0,
7846
7870
  ts: this.now()
7847
7871
  });
@@ -8273,7 +8297,9 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
8273
8297
  const who = conn.label || conn.machine;
8274
8298
  const plat = [conn.os, conn.arch].filter(Boolean).join("/") || "unknown platform";
8275
8299
  const msg = {
8276
- messageId: "op_" + Math.random().toString(16).slice(2, 10),
8300
+ // #0816: CSPRNG this id routes `svamp session inbox reply`, so a predictable
8301
+ // one lets a third party target another agent's reply thread.
8302
+ messageId: "op_" + randomUUID().slice(0, 10),
8277
8303
  body: `A remote machine connected as an outpost: ${xmlEscape(who)} (${xmlEscape(plat)}). You can now run shell commands on it \u2014 its output returns synchronously, like a local command. Run one with: svamp outpost exec "uname -a"`,
8278
8304
  timestamp: Date.now(),
8279
8305
  read: false,
@@ -13937,6 +13963,7 @@ function shouldIsolate(input) {
13937
13963
  function classifyRestoreContinuation(opts) {
13938
13964
  if (opts.isOrphaned) return "none";
13939
13965
  if (opts.loopActive) return "loop-resume";
13966
+ if (opts.loopArmed) return opts.armedHasPendingWork ? "loop-resume" : "none";
13940
13967
  if (opts.wasProcessing && opts.hasResumeId) return "auto-continue";
13941
13968
  return "none";
13942
13969
  }
@@ -15048,7 +15075,7 @@ function searchIssues(projectRoot, query) {
15048
15075
  }
15049
15076
  function summarize(issues) {
15050
15077
  const by = (s) => issues.filter((i) => i.status === s).length;
15051
- return { total: issues.length, ready: by("ready"), in_progress: by("in_progress"), archived: by("archived") };
15078
+ return { total: issues.length, ready: by("ready"), in_progress: by("in_progress"), paused: by("paused"), archived: by("archived") };
15052
15079
  }
15053
15080
 
15054
15081
  function isWorkflowEnabled(wf) {
@@ -15606,7 +15633,21 @@ function loopCancelledDuringVerify(current) {
15606
15633
  }
15607
15634
  function backlogOraclePending(projectRoot, sessionId) {
15608
15635
  const inScope = (i) => !sessionId || isVisibleTo(i, sessionId);
15609
- return listIssues(projectRoot).filter(inScope).filter((i) => i.status === "ready" || i.status === "in_progress").map((i) => ({ id: i.id }));
15636
+ return listIssues(projectRoot).filter(inScope).filter((i) => i.status !== "archived" && i.status !== "paused").map((i) => ({ id: i.id }));
15637
+ }
15638
+ function isBacklogDrainerOracle(oracle) {
15639
+ if (!oracle || typeof oracle !== "string") return false;
15640
+ return /(^|\s)svamp\s+issue\s+pending(\s|$)/.test(oracle.trim());
15641
+ }
15642
+ function resolveLoopDurable(cfg) {
15643
+ if (typeof cfg.durable === "boolean") return cfg.durable;
15644
+ return isBacklogDrainerOracle(cfg.oracle);
15645
+ }
15646
+ function shouldRearmDormantLoop(s, pendingCount, sessionId) {
15647
+ if (!s || s.phase !== "dormant") return false;
15648
+ if (typeof s.session_id === "string" && sessionId && s.session_id !== sessionId) return false;
15649
+ if (!isBacklogDrainerOracle(typeof s.oracle === "string" ? s.oracle : void 0)) return false;
15650
+ return pendingCount > 0;
15610
15651
  }
15611
15652
  function issuesClosedSince(projectRoot, sinceMs) {
15612
15653
  const all = listIssues(projectRoot, { includeArchived: true, status: "archived" });
@@ -15831,6 +15872,25 @@ function decideLoopContinuation(input) {
15831
15872
  const tail = `no progress across ${stuckCheckpoints} checkpoints; ${maxAutoResumes} auto-resume(s) exhausted`;
15832
15873
  return { action: "stop", kind: "stuck", reason: input.stuckReason ? `${input.stuckReason} \u2014 ${tail}` : tail };
15833
15874
  }
15875
+ function terminalPhaseForDone(durable) {
15876
+ return durable ? "dormant" : "done";
15877
+ }
15878
+ const DEFAULT_LOOP_VERIFY_ERROR_RETRIES = 3;
15879
+ function resolveVerifyErrorRetries(envValue) {
15880
+ if (envValue == null || envValue.trim() === "") return DEFAULT_LOOP_VERIFY_ERROR_RETRIES;
15881
+ const n = Number(envValue);
15882
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
15883
+ return DEFAULT_LOOP_VERIFY_ERROR_RETRIES;
15884
+ }
15885
+ function decideVerifyErrorHandling(input) {
15886
+ const maxRetries = typeof input.maxRetries === "number" && input.maxRetries >= 0 ? Math.floor(input.maxRetries) : DEFAULT_LOOP_VERIFY_ERROR_RETRIES;
15887
+ const attempt = (typeof input.priorErrors === "number" && input.priorErrors > 0 ? Math.floor(input.priorErrors) : 0) + 1;
15888
+ const why = input.reason ? String(input.reason).slice(0, 200) : "verification error";
15889
+ if (attempt <= maxRetries) {
15890
+ return { action: "retry", attempt, reason: `${why} \u2014 retry ${attempt}/${maxRetries} (keeping the loop running, not declaring done)` };
15891
+ }
15892
+ return { action: "park", attempt, reason: `${why} \u2014 ${maxRetries} verification retries exhausted; parking safely instead of a false "done"` };
15893
+ }
15834
15894
  const DEFAULT_LOOP_EVALUATOR_MODEL = "claude-haiku-4-5-20251001";
15835
15895
  function resolveEvaluatorModel(envValue) {
15836
15896
  if (envValue === void 0) return DEFAULT_LOOP_EVALUATOR_MODEL;
@@ -15905,13 +15965,13 @@ Keep working until it is genuinely empty.` };
15905
15965
  try {
15906
15966
  raw = await deps.runEvaluator(prompt);
15907
15967
  } catch (e) {
15908
- log(`[loopVerify] evaluator spawn failed \u2014 failing OPEN (marking done): ${e?.message || e}`);
15909
- return { action: "done" };
15968
+ log(`[loopVerify] evaluator spawn failed \u2014 signalling verify_error (fail-closed, will retry): ${e?.message || e}`);
15969
+ return { action: "verify_error", reason: `evaluator spawn failed: ${e?.message || e}` };
15910
15970
  }
15911
15971
  const verdict = parseEvaluatorVerdict(raw);
15912
15972
  if (!verdict) {
15913
- log(`[loopVerify] could not parse evaluator verdict \u2014 failing OPEN (marking done)`);
15914
- return { action: "done" };
15973
+ log(`[loopVerify] could not parse evaluator verdict \u2014 signalling verify_error (fail-closed, will retry)`);
15974
+ return { action: "verify_error", reason: "could not parse evaluator verdict" };
15915
15975
  }
15916
15976
  if (verdict.verdict === "done") {
15917
15977
  log(`[loopVerify] evaluator confirms done${verdict.reason ? `: ${verdict.reason}` : ""}`);
@@ -16922,18 +16982,27 @@ function readLoopState(directory, sessionId) {
16922
16982
  return null;
16923
16983
  }
16924
16984
  }
16985
+ function isTerminalLoopPhase(phase) {
16986
+ return phase === "done" || phase === "gave_up" || phase === "cancelled";
16987
+ }
16925
16988
  function isLoopActive(directory, sessionId) {
16926
16989
  const s = readLoopState(directory, sessionId);
16927
- return !!s && s.active !== false && s.phase !== "done" && s.phase !== "gave_up" && s.phase !== "cancelled";
16990
+ return !!s && s.active !== false && s.phase !== "dormant" && !isTerminalLoopPhase(s.phase);
16928
16991
  }
16929
16992
  function loopOwnerSession(directory, sessionId) {
16930
16993
  const s = readLoopState(directory, sessionId);
16931
- if (!s || s.active === false || s.phase === "done" || s.phase === "gave_up" || s.phase === "cancelled") return null;
16994
+ if (!s || s.active === false || s.phase === "dormant" || isTerminalLoopPhase(s.phase)) return null;
16932
16995
  return typeof s.session_id === "string" ? s.session_id : null;
16933
16996
  }
16934
16997
  function isLoopActiveForSession(directory, sessionId) {
16935
16998
  const s = readLoopState(directory, sessionId);
16936
- if (!s || s.active === false || s.phase === "done" || s.phase === "gave_up" || s.phase === "cancelled") return false;
16999
+ if (!s || s.active === false || s.phase === "dormant" || isTerminalLoopPhase(s.phase)) return false;
17000
+ if (typeof s.session_id !== "string") return true;
17001
+ return s.session_id === sessionId;
17002
+ }
17003
+ function isLoopArmedForSession(directory, sessionId) {
17004
+ const s = readLoopState(directory, sessionId);
17005
+ if (!s || s.active === false || s.phase !== "dormant") return false;
16937
17006
  if (typeof s.session_id !== "string") return true;
16938
17007
  return s.session_id === sessionId;
16939
17008
  }
@@ -17002,6 +17071,14 @@ function loopResumeMessage(directory, sessionId) {
17002
17071
  function stripOldLoopHooks(directory) {
17003
17072
  try {
17004
17073
  const p = join(directory, ".claude", "settings.json");
17074
+ if (!existsSync$1(p)) return false;
17075
+ return withFileLock(p + ".svamp-lock", () => stripOldLoopHooksLocked(p));
17076
+ } catch {
17077
+ return false;
17078
+ }
17079
+ }
17080
+ function stripOldLoopHooksLocked(p) {
17081
+ try {
17005
17082
  if (!existsSync$1(p)) return false;
17006
17083
  const cfg = JSON.parse(readFileSync$1(p, "utf-8"));
17007
17084
  if (!cfg || typeof cfg.hooks !== "object" || !cfg.hooks) return false;
@@ -17025,13 +17102,31 @@ function stripOldLoopHooks(directory) {
17025
17102
  }
17026
17103
  if (changed) {
17027
17104
  if (Object.keys(cfg.hooks).length === 0) delete cfg.hooks;
17028
- writeFileSync(p, JSON.stringify(cfg, null, 2));
17105
+ const tmp = `${p}.${process.pid}.tmp`;
17106
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2));
17107
+ renameSync$1(tmp, p);
17029
17108
  }
17030
17109
  return changed;
17031
17110
  } catch {
17032
17111
  return false;
17033
17112
  }
17034
17113
  }
17114
+ const HELPER_KILL_GRACE_MS = 3e3;
17115
+ const BTW_TIMEOUT_MS = Number(process.env.SVAMP_BTW_TIMEOUT_MS) > 0 ? Number(process.env.SVAMP_BTW_TIMEOUT_MS) : 10 * 6e4;
17116
+ function killHelperTree(proc, graceMs = HELPER_KILL_GRACE_MS) {
17117
+ const pid = proc.pid;
17118
+ if (!pid) return;
17119
+ try {
17120
+ process.kill(-pid, "SIGTERM");
17121
+ } catch {
17122
+ }
17123
+ setTimeout(() => {
17124
+ try {
17125
+ process.kill(-pid, "SIGKILL");
17126
+ } catch {
17127
+ }
17128
+ }, graceMs).unref();
17129
+ }
17035
17130
  function spawnHeadlessEvaluator(prompt, opts) {
17036
17131
  return new Promise((resolve2, reject) => {
17037
17132
  const evalArgs = [
@@ -17062,7 +17157,7 @@ function spawnHeadlessEvaluator(prompt, opts) {
17062
17157
  delete evalEnv.CLAUDECODE;
17063
17158
  let proc;
17064
17159
  try {
17065
- proc = spawn("claude", evalArgs, { cwd: opts.cwd, env: evalEnv, stdio: ["pipe", "pipe", "pipe"] });
17160
+ proc = spawn("claude", evalArgs, { cwd: opts.cwd, env: evalEnv, stdio: ["pipe", "pipe", "pipe"], detached: true });
17066
17161
  } catch (e) {
17067
17162
  reject(e);
17068
17163
  return;
@@ -17071,10 +17166,7 @@ function spawnHeadlessEvaluator(prompt, opts) {
17071
17166
  const timer = setTimeout(() => {
17072
17167
  if (settled) return;
17073
17168
  settled = true;
17074
- try {
17075
- proc.kill("SIGTERM");
17076
- } catch {
17077
- }
17169
+ killHelperTree(proc);
17078
17170
  reject(new Error("evaluator timeout"));
17079
17171
  }, opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : 3e5);
17080
17172
  proc.stderr?.on("data", (c) => {
@@ -17420,11 +17512,12 @@ function createSvampConfigChecker(directory, sessionId, getMetadata, setMetadata
17420
17512
  const softTurnCap = computeSoftTurnCap(hardMax, LOOP_CHECKPOINT_CADENCE);
17421
17513
  const condition = buildGoalCondition({ task, until, oracle, maxIterations: softTurnCap });
17422
17514
  const budget = parseLoopBudget(cfg.budget);
17515
+ const durable = resolveLoopDurable({ durable: cfg.durable, oracle });
17423
17516
  try {
17424
17517
  stripOldLoopHooks(directory);
17425
17518
  } catch {
17426
17519
  }
17427
- writeGoalLoopState(directory, sessionId, { active: true, phase: "running", iteration: 0, task: condition, goal_task: task, until, oracle, max_iterations: hardMax, budget, started_at: Date.now(), holds: 0, ledger: { turns: 0, tokens: 0, window: [] }, progress_history: [], auto_resumes: 0 });
17520
+ writeGoalLoopState(directory, sessionId, { active: true, phase: "running", durable, iteration: 0, task: condition, goal_task: task, until, oracle, max_iterations: hardMax, budget, started_at: Date.now(), holds: 0, ledger: { turns: 0, tokens: 0, window: [] }, progress_history: [], auto_resumes: 0, verify_errors: 0 });
17428
17521
  const existingQueue = getMetadata().messageQueue || [];
17429
17522
  setMetadata((m) => ({ ...m, messageQueue: [...existingQueue, { id: randomUUID$1(), text: `/goal ${condition}`, displayText: `\u{1F3AF} Goal: ${(task || until || "").slice(0, 100)}`, createdAt: Date.now() }] }));
17430
17523
  onLoopActivated?.();
@@ -17877,7 +17970,7 @@ async function startDaemon(options) {
17877
17970
  try {
17878
17971
  const dir = loadSessionIndex()[sessionId]?.directory;
17879
17972
  if (!dir) return;
17880
- const { reconcileServiceLinks } = await import('./agentCommands-BefFTl6g.mjs');
17973
+ const { reconcileServiceLinks } = await import('./agentCommands-CqHUKyRM.mjs');
17881
17974
  const configPath = getSvampConfigPath(dir, sessionId);
17882
17975
  const config = readSvampConfig(configPath);
17883
17976
  const entries = Array.from(urls.entries());
@@ -17895,7 +17988,7 @@ async function startDaemon(options) {
17895
17988
  }
17896
17989
  }
17897
17990
  async function createExposedTunnel(spec) {
17898
- const { FrpcTunnel } = await import('./frpc-DvBVvsZC.mjs');
17991
+ const { FrpcTunnel } = await import('./frpc-B5LHRKTD.mjs');
17899
17992
  const tunnel = new FrpcTunnel({
17900
17993
  name: spec.name,
17901
17994
  ports: spec.ports,
@@ -17923,7 +18016,7 @@ async function startDaemon(options) {
17923
18016
  ensureAutoInstalledCommands(logger);
17924
18017
  (async () => {
17925
18018
  try {
17926
- const { beginClaudeVersionReconcile } = await import('./pinnedClaudeCode-zDzkOTi2.mjs');
18019
+ const { beginClaudeVersionReconcile } = await import('./pinnedClaudeCode-mM9Q6Q2G.mjs');
17927
18020
  beginClaudeVersionReconcile((msg) => logger.log(msg));
17928
18021
  } catch (e) {
17929
18022
  logger.log(`[claude-version] check failed: ${e?.message || e}`);
@@ -18418,12 +18511,16 @@ ${v.guidance ? v.guidance + "\n" : ""}Criteria: ${v.criteria || "(none)"}
18418
18511
  "json",
18419
18512
  "--max-turns",
18420
18513
  "6"
18421
- ], { cwd, timeout: 6e4, stdio: ["ignore", "pipe", "pipe"], env: forkEnv });
18514
+ // #0820: detached own process group. Node's `timeout` option kills only
18515
+ // the direct child, so without this a tool grandchild survives the 60s cap
18516
+ // and reparents to init. The exit/error handlers below kill the group.
18517
+ ], { cwd, timeout: 6e4, stdio: ["ignore", "pipe", "pipe"], env: forkEnv, detached: true });
18422
18518
  let stdout = "";
18423
18519
  child.stdout?.on("data", (d) => {
18424
18520
  stdout += d.toString();
18425
18521
  });
18426
18522
  child.on("close", () => {
18523
+ killHelperTree(child);
18427
18524
  try {
18428
18525
  const r = JSON.parse(stdout);
18429
18526
  resolve2(r.result || r.text || null);
@@ -18431,7 +18528,10 @@ ${v.guidance ? v.guidance + "\n" : ""}Criteria: ${v.criteria || "(none)"}
18431
18528
  resolve2(null);
18432
18529
  }
18433
18530
  });
18434
- child.on("error", () => resolve2(null));
18531
+ child.on("error", () => {
18532
+ killHelperTree(child);
18533
+ resolve2(null);
18534
+ });
18435
18535
  } catch {
18436
18536
  resolve2(null);
18437
18537
  }
@@ -18514,6 +18614,13 @@ ${parts.join("\n")}`);
18514
18614
  let startupRetryMessage;
18515
18615
  let startupNonJsonLines = [];
18516
18616
  let errorOutputLines = [];
18617
+ const MAX_ERROR_OUTPUT_LINES = 200;
18618
+ const pushErrorOutputLine = (line) => {
18619
+ errorOutputLines.push(line);
18620
+ if (errorOutputLines.length > MAX_ERROR_OUTPUT_LINES) {
18621
+ errorOutputLines.splice(0, errorOutputLines.length - MAX_ERROR_OUTPUT_LINES);
18622
+ }
18623
+ };
18517
18624
  const STUCK_PROCESS_TIMEOUT_MS = 30 * 60 * 1e3;
18518
18625
  const STUCK_CHECK_INTERVAL_MS = 2 * 60 * 1e3;
18519
18626
  let lastOutputTime = Date.now();
@@ -18620,6 +18727,7 @@ ${parts.join("\n")}`);
18620
18727
  const budget = ls.budget;
18621
18728
  const progress_history = curLs.progress_history ?? ls.progress_history;
18622
18729
  const extensions = curLs.extensions ?? ls.extensions;
18730
+ const durable = resolveLoopDurable({ durable: ls.durable, oracle: oracleCmd });
18623
18731
  if (result.action === "rekick") {
18624
18732
  const cond = ls.task || "";
18625
18733
  writeGoalLoopState(directory, sessionId, {
@@ -18637,7 +18745,9 @@ ${parts.join("\n")}`);
18637
18745
  ledger,
18638
18746
  progress_history,
18639
18747
  auto_resumes: ls.auto_resumes,
18640
- extensions
18748
+ extensions,
18749
+ durable: ls.durable,
18750
+ verify_errors: 0
18641
18751
  });
18642
18752
  const guidance = result.guidance.replace(/\s+/g, " ").slice(0, 1200);
18643
18753
  sessionService.pushMessage({ type: "message", message: `\u{1F501} Loop not done \u2014 independent review: ${result.reason}`, level: "warning" }, "event");
@@ -18674,7 +18784,9 @@ ${parts.join("\n")}`);
18674
18784
  ledger,
18675
18785
  progress_history,
18676
18786
  auto_resumes: nextAuto,
18677
- extensions
18787
+ extensions,
18788
+ durable: ls.durable,
18789
+ verify_errors: 0
18678
18790
  });
18679
18791
  const label = decision.kind === "autoresume" ? "\u{1F501} Auto-resuming stalled loop" : "\u{1F501} Loop still progressing";
18680
18792
  sessionService.pushMessage({ type: "message", message: `${label} \u2014 ${decision.reason}` }, "event");
@@ -18687,15 +18799,75 @@ ${parts.join("\n")}`);
18687
18799
  logger.log(`[Session ${sessionId}] [loop-persist] stuck-stop: ${decision.reason}`);
18688
18800
  checkSvampConfig?.();
18689
18801
  }
18802
+ } else if (result.action === "verify_error") {
18803
+ const decision = decideVerifyErrorHandling({
18804
+ priorErrors: ls.verify_errors,
18805
+ maxRetries: resolveVerifyErrorRetries(process.env.SVAMP_LOOP_VERIFY_ERROR_RETRIES),
18806
+ reason: result.reason
18807
+ });
18808
+ if (decision.action === "retry") {
18809
+ const cond = ls.task || "";
18810
+ writeGoalLoopState(directory, sessionId, {
18811
+ active: true,
18812
+ phase: "continue",
18813
+ iteration: ls.iteration || 0,
18814
+ task: ls.task,
18815
+ goal_task: ls.goal_task,
18816
+ until: ls.until,
18817
+ oracle: oracleCmd,
18818
+ max_iterations: ls.max_iterations,
18819
+ budget,
18820
+ started_at: startedAt,
18821
+ holds,
18822
+ ledger,
18823
+ progress_history,
18824
+ auto_resumes: ls.auto_resumes,
18825
+ extensions,
18826
+ durable: ls.durable,
18827
+ verify_errors: decision.attempt
18828
+ });
18829
+ sessionService.pushMessage({ type: "message", message: `\u26A0\uFE0F Loop verification hit a transient error \u2014 ${decision.reason}`, level: "warning" }, "event");
18830
+ logger.log(`[Session ${sessionId}] [loop-verify-error] ${decision.reason}`);
18831
+ enqueueLoopMessage(`/goal ${cond} \u2014 the independent completion check could not run (${result.reason}); CONTINUE the remaining work and it will be re-verified. Do NOT treat this as done.`, "\u26A0\uFE0F Re-verifying loop");
18832
+ if (!trackedSession.stopped) setTimeout(() => processMessageQueueRef?.(), 200);
18833
+ } else {
18834
+ const parkPhase = terminalPhaseForDone(durable);
18835
+ writeGoalLoopState(directory, sessionId, { ...ls, active: parkPhase === "dormant", phase: parkPhase, completed_at: Date.now(), holds, ledger, progress_history, auto_resumes: ls.auto_resumes, extensions, durable: ls.durable, verify_errors: 0 });
18836
+ sessionService.pushMessage({ type: "message", message: parkPhase === "dormant" ? `\u{1F319} Loop parked (armed) after repeated verification errors \u2014 it will re-arm when new work arrives. ${decision.reason}` : `\u26A0\uFE0F Loop stopped after repeated verification errors \u2014 ${decision.reason}`, level: "warning" }, "event");
18837
+ logger.log(`[Session ${sessionId}] [loop-verify-error] park ${parkPhase}: ${decision.reason}`);
18838
+ checkSvampConfig?.();
18839
+ }
18690
18840
  } else {
18691
- writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: Date.now(), holds, ledger, progress_history, auto_resumes: ls.auto_resumes, extensions });
18841
+ const donePhase = terminalPhaseForDone(durable);
18842
+ if (donePhase === "dormant") {
18843
+ writeGoalLoopState(directory, sessionId, { ...ls, active: true, phase: "dormant", durable: true, completed_at: Date.now(), holds, ledger, progress_history, auto_resumes: ls.auto_resumes, extensions, verify_errors: 0 });
18844
+ sessionService.pushMessage({ type: "message", message: "\u{1F319} Backlog clear \u2014 loop is armed and watching for new issues (auto-resumes when work arrives)." }, "event");
18845
+ logger.log(`[Session ${sessionId}] [loop] backlog drained \u2192 dormant (armed)`);
18846
+ } else {
18847
+ writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: Date.now(), holds, ledger, progress_history, auto_resumes: ls.auto_resumes, extensions, verify_errors: 0 });
18848
+ }
18692
18849
  checkSvampConfig?.();
18693
18850
  }
18694
18851
  } catch (e) {
18695
- logger.log(`[Session ${sessionId}] verifyGoalCompletion error \u2014 failing open (done): ${e?.message || e}`);
18852
+ logger.log(`[Session ${sessionId}] verifyGoalCompletion threw \u2014 failing closed (retry/park): ${e?.message || e}`);
18696
18853
  try {
18697
- writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: Date.now() });
18698
- checkSvampConfig?.();
18854
+ const durable2 = resolveLoopDurable({ durable: ls.durable, oracle: oracleCmd });
18855
+ const decision = decideVerifyErrorHandling({
18856
+ priorErrors: ls.verify_errors,
18857
+ maxRetries: resolveVerifyErrorRetries(process.env.SVAMP_LOOP_VERIFY_ERROR_RETRIES),
18858
+ reason: `verify threw: ${e?.message || e}`
18859
+ });
18860
+ if (decision.action === "retry") {
18861
+ const cond = ls.task || "";
18862
+ writeGoalLoopState(directory, sessionId, { ...ls, active: true, phase: "continue", started_at: startedAt, verify_errors: decision.attempt });
18863
+ sessionService.pushMessage({ type: "message", message: `\u26A0\uFE0F Loop verification error \u2014 ${decision.reason}`, level: "warning" }, "event");
18864
+ enqueueLoopMessage(`/goal ${cond} \u2014 the completion check could not run; CONTINUE and it will be re-verified. Not done.`, "\u26A0\uFE0F Re-verifying loop");
18865
+ if (!trackedSession.stopped) setTimeout(() => processMessageQueueRef?.(), 200);
18866
+ } else {
18867
+ const parkPhase = terminalPhaseForDone(durable2);
18868
+ writeGoalLoopState(directory, sessionId, { ...ls, active: parkPhase === "dormant", phase: parkPhase, completed_at: Date.now(), verify_errors: 0 });
18869
+ checkSvampConfig?.();
18870
+ }
18699
18871
  } catch {
18700
18872
  }
18701
18873
  } finally {
@@ -19213,7 +19385,7 @@ ${parts.join("\n")}`);
19213
19385
  clearInboundContext(sessionId);
19214
19386
  try {
19215
19387
  const ls = readLoopState(directory, sessionId);
19216
- if (ls && ls.engine === "goal" && ls.active !== false && ls.phase !== "done" && ls.phase !== "gave_up" && ls.phase !== "cancelled") {
19388
+ if (ls && ls.engine === "goal" && ls.active !== false && ls.phase !== "dormant" && ls.phase !== "done" && ls.phase !== "gave_up" && ls.phase !== "cancelled") {
19217
19389
  const now = Date.now();
19218
19390
  const ledger = accumulateLedger(ls.ledger, {
19219
19391
  turns: Number(msg.num_turns) || 0,
@@ -19247,7 +19419,8 @@ ${parts.join("\n")}`);
19247
19419
  sessionService.pushMessage({ type: "message", message: `\u{1F6D1} Loop stopped \u2014 ${detail}`, level: "warning" }, "event");
19248
19420
  checkSvampConfig?.();
19249
19421
  } else if (process.env.SVAMP_LOOP_VERIFY === "0") {
19250
- writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: now, ledger, progress_history: prog.progress_history, auto_resumes: prog.auto_resumes });
19422
+ const donePhase = terminalPhaseForDone(resolveLoopDurable({ durable: ls.durable, oracle: ls.oracle }));
19423
+ writeGoalLoopState(directory, sessionId, { ...ls, active: donePhase === "dormant", phase: donePhase, completed_at: now, ledger, progress_history: prog.progress_history, auto_resumes: prog.auto_resumes });
19251
19424
  } else {
19252
19425
  writeGoalLoopState(directory, sessionId, { ...ls, ledger, progress_history: prog.progress_history, auto_resumes: prog.auto_resumes });
19253
19426
  void verifyGoalCompletion(ls);
@@ -19383,7 +19556,7 @@ ${parts.join("\n")}`);
19383
19556
  startupNonJsonLines.push(line.slice(0, 500));
19384
19557
  }
19385
19558
  if (looksLikeClaudeError(line)) {
19386
- errorOutputLines.push(line.slice(0, 500));
19559
+ pushErrorOutputLine(line.slice(0, 500));
19387
19560
  }
19388
19561
  }
19389
19562
  }
@@ -19401,14 +19574,12 @@ ${parts.join("\n")}`);
19401
19574
  stdoutBuffer = "";
19402
19575
  }
19403
19576
  });
19404
- let stderrBuffer = "";
19405
19577
  child.stderr?.on("data", (chunk) => {
19406
19578
  const text = chunk.toString();
19407
19579
  logger.log(`[Session ${sessionId}] Claude stderr: ${text.trim()}`);
19408
- stderrBuffer += text;
19409
19580
  for (const line of text.split("\n")) {
19410
19581
  if (looksLikeClaudeError(line)) {
19411
- errorOutputLines.push(line.trim().slice(0, 500));
19582
+ pushErrorOutputLine(line.trim().slice(0, 500));
19412
19583
  }
19413
19584
  }
19414
19585
  });
@@ -19663,12 +19834,28 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
19663
19834
  const btwProcess = spawn("claude", btwArgs, {
19664
19835
  cwd: directory,
19665
19836
  env: btwEnv,
19666
- stdio: ["pipe", "pipe", "pipe"]
19837
+ stdio: ["pipe", "pipe", "pipe"],
19838
+ detached: true
19839
+ // #0820: own process group so the timeout can kill grandchildren
19667
19840
  });
19668
19841
  let btwResult = "";
19669
19842
  let btwBuffer = "";
19670
19843
  let btwStderr = "";
19671
19844
  let settled = false;
19845
+ const btwTimer = setTimeout(() => {
19846
+ if (settled) return;
19847
+ settled = true;
19848
+ killHelperTree(btwProcess);
19849
+ const err = new Error(`/btw timed out after ${Math.round(BTW_TIMEOUT_MS / 1e3)}s`);
19850
+ err.retryable = false;
19851
+ reject(err);
19852
+ }, BTW_TIMEOUT_MS);
19853
+ const settleBtw = () => {
19854
+ if (settled) return false;
19855
+ settled = true;
19856
+ clearTimeout(btwTimer);
19857
+ return true;
19858
+ };
19672
19859
  btwProcess.stderr?.on("data", (chunk) => {
19673
19860
  btwStderr += chunk.toString();
19674
19861
  });
@@ -19688,8 +19875,7 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
19688
19875
  }
19689
19876
  }
19690
19877
  } else if (msg.type === "result") {
19691
- if (settled) continue;
19692
- settled = true;
19878
+ if (!settleBtw()) continue;
19693
19879
  if (msg.is_error) {
19694
19880
  const err = new Error(String(msg.result || "error"));
19695
19881
  err.retryable = isRetryableRateLimit(String(msg.result || ""), msg.api_error_status);
@@ -19703,8 +19889,7 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
19703
19889
  }
19704
19890
  });
19705
19891
  btwProcess.on("exit", (code) => {
19706
- if (settled) return;
19707
- settled = true;
19892
+ if (!settleBtw()) return;
19708
19893
  if (code === 0) {
19709
19894
  resolve2(btwResult || "(no response)");
19710
19895
  return;
@@ -19715,8 +19900,7 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
19715
19900
  reject(err);
19716
19901
  });
19717
19902
  btwProcess.on("error", (e) => {
19718
- if (settled) return;
19719
- settled = true;
19903
+ if (!settleBtw()) return;
19720
19904
  const err = new Error(e.message);
19721
19905
  err.retryable = false;
19722
19906
  reject(err);
@@ -20149,11 +20333,11 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
20149
20333
  });
20150
20334
  },
20151
20335
  onIssue: async (params) => {
20152
- const { issueRpc } = await import('./rpc-BLWr4p8g.mjs');
20336
+ const { issueRpc } = await import('./rpc-DOl276lO.mjs');
20153
20337
  return issueRpc(params?.cwd || directory, params || {}, { notifySession: notifyIssueOwner, rekickLoopOwner });
20154
20338
  },
20155
20339
  onWorkflow: async (params) => {
20156
- const { workflowRpc } = await import('./rpc-BXpuORiQ.mjs');
20340
+ const { workflowRpc } = await import('./rpc-BxOm5nyE.mjs');
20157
20341
  return workflowRpc(params?.cwd || directory, params || {});
20158
20342
  },
20159
20343
  onRipgrep: async (args, cwd) => {
@@ -20362,7 +20546,7 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
20362
20546
  logger.log(`[Session ${sessionId}] re-kick skipped \u2014 loop is resource-limit-stalled; human must run \`loop --max N\``);
20363
20547
  return;
20364
20548
  }
20365
- if (s.active === false || s.phase === "done") {
20549
+ if (s.active === false || s.phase === "done" || s.phase === "dormant") {
20366
20550
  try {
20367
20551
  const lp = join(getLoopDir(directory, sessionId), "loop-state.json");
20368
20552
  atomicWriteLoopState(lp, {
@@ -20372,6 +20556,8 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
20372
20556
  iteration: 0,
20373
20557
  completed_at: void 0,
20374
20558
  gave_up_reason: void 0,
20559
+ verify_errors: 0,
20560
+ // fresh verification budget on re-arm
20375
20561
  stall_hinted: false,
20376
20562
  resumed_at: Date.now()
20377
20563
  // #0156: re-arm the stall hint
@@ -20379,7 +20565,7 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
20379
20565
  } catch {
20380
20566
  }
20381
20567
  }
20382
- logger.log(`[Session ${sessionId}] issue change \u2192 re-kicking loop (budget remaining)`);
20568
+ logger.log(`[Session ${sessionId}] issue change \u2192 re-kicking loop (${s.phase === "dormant" ? "was dormant/armed" : "budget remaining"})`);
20383
20569
  enqueueLoopContinue();
20384
20570
  processMessageQueueRef?.();
20385
20571
  };
@@ -20428,6 +20614,34 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
20428
20614
  if (INBOX_RECONCILE_INTERVAL_MS > 0) {
20429
20615
  inboxReconcileTimer = setInterval(reconcileIdleInbox, INBOX_RECONCILE_INTERVAL_MS);
20430
20616
  }
20617
+ const dormantSweepMs = Number(process.env.SVAMP_LOOP_DORMANT_SWEEP_MS);
20618
+ const DORMANT_SWEEP_INTERVAL_MS = Number.isFinite(dormantSweepMs) && dormantSweepMs >= 0 ? dormantSweepMs : 2e4;
20619
+ let dormantRearmTimer = null;
20620
+ const sweepDormantRearm = () => {
20621
+ if (trackedSession.stopped) {
20622
+ if (dormantRearmTimer) clearInterval(dormantRearmTimer);
20623
+ return;
20624
+ }
20625
+ let s = null;
20626
+ try {
20627
+ s = readLoopState(directory, sessionId);
20628
+ } catch {
20629
+ return;
20630
+ }
20631
+ if (!s || s.phase !== "dormant") return;
20632
+ let pending = [];
20633
+ try {
20634
+ pending = backlogOraclePending(directory, sessionId);
20635
+ } catch {
20636
+ return;
20637
+ }
20638
+ if (!shouldRearmDormantLoop(s, pending.length, sessionId)) return;
20639
+ logger.log(`[Session ${sessionId}] Dormant re-arm sweep: ${pending.length} pending issue(s) on disk (added outside the daemon RPC) \u2014 re-arming loop`);
20640
+ trackedSession.rekickLoop?.();
20641
+ };
20642
+ if (DORMANT_SWEEP_INTERVAL_MS > 0) {
20643
+ dormantRearmTimer = setInterval(sweepDormantRearm, DORMANT_SWEEP_INTERVAL_MS);
20644
+ }
20431
20645
  return {
20432
20646
  type: "success",
20433
20647
  sessionId,
@@ -20827,11 +21041,11 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
20827
21041
  });
20828
21042
  },
20829
21043
  onIssue: async (params) => {
20830
- const { issueRpc } = await import('./rpc-BLWr4p8g.mjs');
21044
+ const { issueRpc } = await import('./rpc-DOl276lO.mjs');
20831
21045
  return issueRpc(params?.cwd || directory, params || {}, { notifySession: notifyIssueOwner, rekickLoopOwner });
20832
21046
  },
20833
21047
  onWorkflow: async (params) => {
20834
- const { workflowRpc } = await import('./rpc-BXpuORiQ.mjs');
21048
+ const { workflowRpc } = await import('./rpc-BxOm5nyE.mjs');
20835
21049
  return workflowRpc(params?.cwd || directory, params || {});
20836
21050
  },
20837
21051
  onRipgrep: async (args, cwd) => {
@@ -21175,7 +21389,9 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
21175
21389
  return;
21176
21390
  }
21177
21391
  if (process.env.SVAMP_LOOP_VERIFY === "0") {
21178
- writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: now, ledger });
21392
+ const killPhase = terminalPhaseForDone(resolveLoopDurable({ durable: ls.durable, oracle: oracleCmd }));
21393
+ writeGoalLoopState(directory, sessionId, { ...ls, active: killPhase === "dormant", phase: killPhase, completed_at: now, ledger, verify_errors: 0 });
21394
+ if (killPhase === "dormant") sessionService.pushMessage({ type: "message", message: "\u{1F319} Backlog clear \u2014 loop is armed and watching for new issues." }, "event");
21179
21395
  checkSvampConfig?.();
21180
21396
  return;
21181
21397
  }
@@ -21217,15 +21433,48 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
21217
21433
  logger.log(`[Session ${sessionId}] [loop-persist] stuck-stop: ${decision.reason}`);
21218
21434
  checkSvampConfig?.();
21219
21435
  }
21436
+ } else if (result.action === "verify_error") {
21437
+ const decision = decideVerifyErrorHandling({
21438
+ priorErrors: ls.verify_errors,
21439
+ maxRetries: resolveVerifyErrorRetries(process.env.SVAMP_LOOP_VERIFY_ERROR_RETRIES),
21440
+ reason: result.reason
21441
+ });
21442
+ if (decision.action === "retry") {
21443
+ writeGoalLoopState(directory, sessionId, { ...ls, active: true, phase: "continue", holds, started_at: startedAt, ledger, verify_errors: decision.attempt });
21444
+ sessionService.pushMessage({ type: "message", message: `\u26A0\uFE0F Loop verification hit a transient error \u2014 ${decision.reason}`, level: "warning" }, "event");
21445
+ dispatchAcpPrompt(`Continue toward the goal: ${ls.goal_task || ls.until || "the task"}. The independent completion check could not run (${result.reason}); keep working \u2014 it will be re-verified. Do NOT treat this as done.`, "\u26A0\uFE0F Re-verifying loop");
21446
+ } else {
21447
+ const parkPhase = terminalPhaseForDone(resolveLoopDurable({ durable: ls.durable, oracle: oracleCmd }));
21448
+ writeGoalLoopState(directory, sessionId, { ...ls, active: parkPhase === "dormant", phase: parkPhase, completed_at: Date.now(), holds, ledger, verify_errors: 0 });
21449
+ sessionService.pushMessage({ type: "message", message: parkPhase === "dormant" ? `\u{1F319} Loop parked (armed) after repeated verification errors \u2014 re-arms on new work. ${decision.reason}` : `\u26A0\uFE0F Loop stopped after repeated verification errors \u2014 ${decision.reason}`, level: "warning" }, "event");
21450
+ checkSvampConfig?.();
21451
+ }
21220
21452
  } else {
21221
- writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: Date.now(), holds, ledger });
21453
+ const donePhase = terminalPhaseForDone(resolveLoopDurable({ durable: ls.durable, oracle: oracleCmd }));
21454
+ if (donePhase === "dormant") {
21455
+ writeGoalLoopState(directory, sessionId, { ...ls, active: true, phase: "dormant", durable: true, completed_at: Date.now(), holds, ledger, verify_errors: 0 });
21456
+ sessionService.pushMessage({ type: "message", message: "\u{1F319} Backlog clear \u2014 loop is armed and watching for new issues." }, "event");
21457
+ } else {
21458
+ writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: Date.now(), holds, ledger, verify_errors: 0 });
21459
+ }
21222
21460
  checkSvampConfig?.();
21223
21461
  }
21224
21462
  } catch (e) {
21225
- logger.log(`[Session ${sessionId}] verifyAcpLoopCompletion error \u2014 failing open (done): ${e?.message || e}`);
21463
+ logger.log(`[Session ${sessionId}] verifyAcpLoopCompletion error \u2014 failing closed (retry/park): ${e?.message || e}`);
21226
21464
  try {
21227
- writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: Date.now(), ledger });
21228
- checkSvampConfig?.();
21465
+ const decision = decideVerifyErrorHandling({
21466
+ priorErrors: ls.verify_errors,
21467
+ maxRetries: resolveVerifyErrorRetries(process.env.SVAMP_LOOP_VERIFY_ERROR_RETRIES),
21468
+ reason: `verify threw: ${e?.message || e}`
21469
+ });
21470
+ if (decision.action === "retry") {
21471
+ writeGoalLoopState(directory, sessionId, { ...ls, active: true, phase: "continue", holds, started_at: startedAt, ledger, verify_errors: decision.attempt });
21472
+ dispatchAcpPrompt(`Continue toward the goal: ${ls.goal_task || ls.until || "the task"}. The completion check could not run; keep working \u2014 it will be re-verified. Not done.`, "\u26A0\uFE0F Re-verifying loop");
21473
+ } else {
21474
+ const parkPhase = terminalPhaseForDone(resolveLoopDurable({ durable: ls.durable, oracle: oracleCmd }));
21475
+ writeGoalLoopState(directory, sessionId, { ...ls, active: parkPhase === "dormant", phase: parkPhase, completed_at: Date.now(), ledger, verify_errors: 0 });
21476
+ checkSvampConfig?.();
21477
+ }
21229
21478
  } catch {
21230
21479
  }
21231
21480
  } finally {
@@ -21789,7 +22038,7 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
21789
22038
  }
21790
22039
  if (persistedSessions.length > 0) {
21791
22040
  try {
21792
- const { awaitClaudeVersionReady } = await import('./pinnedClaudeCode-zDzkOTi2.mjs');
22041
+ const { awaitClaudeVersionReady } = await import('./pinnedClaudeCode-mM9Q6Q2G.mjs');
21793
22042
  await awaitClaudeVersionReady();
21794
22043
  } catch {
21795
22044
  }
@@ -21822,12 +22071,26 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
21822
22071
  break;
21823
22072
  }
21824
22073
  }
22074
+ const _loopArmed = !isOrphaned && isLoopArmedForSession(persisted.directory, persisted.sessionId);
21825
22075
  const _restoreAction = classifyRestoreContinuation({
21826
22076
  wasProcessing: !!persisted.wasProcessing,
21827
22077
  hasResumeId: !!persisted.claudeResumeId,
21828
22078
  isOrphaned: !!isOrphaned,
21829
- loopActive: isLoopActiveForSession(persisted.directory, persisted.sessionId)
22079
+ loopActive: isLoopActiveForSession(persisted.directory, persisted.sessionId),
22080
+ loopArmed: _loopArmed,
22081
+ armedHasPendingWork: _loopArmed ? safeBacklogPendingCount(persisted.directory, persisted.sessionId) > 0 : false
21830
22082
  });
22083
+ if (_loopArmed) {
22084
+ logger.log(`[loop] Restored DORMANT loop for ${persisted.sessionId} \u2192 ${_restoreAction === "loop-resume" ? "re-arming (pending work found)" : "staying dormant/watching (backlog empty)"}`);
22085
+ if (_restoreAction === "loop-resume") {
22086
+ try {
22087
+ const lp = join(getLoopDir(persisted.directory, persisted.sessionId), "loop-state.json");
22088
+ const cur = readLoopState(persisted.directory, persisted.sessionId);
22089
+ if (cur) atomicWriteLoopState(lp, { ...cur, active: true, phase: "continue", completed_at: void 0, verify_errors: 0, resumed_at: Date.now() });
22090
+ } catch {
22091
+ }
22092
+ }
22093
+ }
21831
22094
  if (_restoreAction === "auto-continue") {
21832
22095
  sessionsToAutoContinue.push(persisted.sessionId);
21833
22096
  } else if (_restoreAction === "loop-resume") {
@@ -21945,18 +22208,44 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
21945
22208
  logger.log(`Session ${sessionId} RPC handlers not found for loop resume`);
21946
22209
  continue;
21947
22210
  }
22211
+ const loopTurnFingerprint = () => {
22212
+ try {
22213
+ const s = readLoopState(sessDir, sessionId);
22214
+ return `${s?.phase || ""}|${s?.holds || 0}|${s?.ledger?.turns || 0}`;
22215
+ } catch {
22216
+ return "";
22217
+ }
22218
+ };
22219
+ const sendResume = async (tag) => {
22220
+ const prompt = loopResumeMessage(sessDir, sessionId);
22221
+ await rpc.sendMessage(
22222
+ JSON.stringify({
22223
+ role: "user",
22224
+ content: { type: "text", text: prompt },
22225
+ meta: { sentFrom: "svamp-daemon-loop-resume" }
22226
+ })
22227
+ );
22228
+ logger.log(`Resumed loop for session ${sessionId}${tag}`);
22229
+ };
21948
22230
  setTimeout(async () => {
21949
22231
  try {
21950
22232
  if (!isLoopActiveForSession(sessDir, sessionId)) return;
21951
- const prompt = loopResumeMessage(sessDir, sessionId);
21952
- await rpc.sendMessage(
21953
- JSON.stringify({
21954
- role: "user",
21955
- content: { type: "text", text: prompt },
21956
- meta: { sentFrom: "svamp-daemon-loop-resume" }
21957
- })
21958
- );
21959
- logger.log(`Resumed loop for session ${sessionId}`);
22233
+ const fpBefore = loopTurnFingerprint();
22234
+ await sendResume("");
22235
+ const graceMs = Math.max(5e3, Number(process.env.SVAMP_LOOP_RESUME_WATCHDOG_MS) || 45e3);
22236
+ setTimeout(() => {
22237
+ try {
22238
+ if (!isLoopActiveForSession(sessDir, sessionId)) return;
22239
+ const live = Array.from(pidToTrackedSession.values()).find((s) => s.svampSessionId === sessionId && !s.stopped);
22240
+ const lifecycle = live?.hyphaService?.getMetadata()?.lifecycleState;
22241
+ if (lifecycle && lifecycle !== "idle") return;
22242
+ if (loopTurnFingerprint() !== fpBefore) return;
22243
+ logger.log(`[loop] resume watchdog: session ${sessionId} loop still active but never advanced after ${graceMs}ms \u2014 re-issuing /goal once`);
22244
+ void sendResume(" (watchdog re-issue)").catch(() => {
22245
+ });
22246
+ } catch {
22247
+ }
22248
+ }, graceMs);
21960
22249
  } catch (err) {
21961
22250
  logger.log(`Failed to resume loop for session ${sessionId}: ${err.message}`);
21962
22251
  }
@@ -21994,7 +22283,7 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
21994
22283
  const PING_TIMEOUT_MS = 15e3;
21995
22284
  const POST_RECONNECT_GRACE_MS = 2e4;
21996
22285
  const RECONNECT_JITTER_MS = 2500;
21997
- const { WorkflowScheduler } = await import('./scheduler-7aLvf5mn.mjs');
22286
+ const { WorkflowScheduler } = await import('./scheduler-C0WXBdL_.mjs');
21998
22287
  const workflowProjectRoots = () => {
21999
22288
  const dirs = /* @__PURE__ */ new Set();
22000
22289
  for (const s of pidToTrackedSession.values()) {