svamp-cli 0.2.310 → 0.2.312

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 (29) hide show
  1. package/dist/{adminCommands-BcH3OlSL.mjs → adminCommands-CRQQhXsw.mjs} +1 -1
  2. package/dist/{agentCommands-9GHpj3mp.mjs → agentCommands-hLTce38n.mjs} +5 -5
  3. package/dist/{auth-CuN-K8i9.mjs → auth-B2ZuhK_S.mjs} +1 -1
  4. package/dist/{cli-Co_GXbrT.mjs → cli-HI-J8Y7U.mjs} +70 -70
  5. package/dist/cli.mjs +2 -2
  6. package/dist/{commands-Dx4S6f_G.mjs → commands-12pIcOWK.mjs} +1 -1
  7. package/dist/{commands-D-RsuMqs.mjs → commands-C2zQmf3Y.mjs} +1 -1
  8. package/dist/{commands-DBiIsRQ_.mjs → commands-CmMh6R6S.mjs} +1 -1
  9. package/dist/{commands-DiuHHJ6r.mjs → commands-D6BqvSLe.mjs} +10 -5
  10. package/dist/{commands-DuLlH97k.mjs → commands-DGUgZoou.mjs} +2 -2
  11. package/dist/{commands-Eo0kocpm.mjs → commands-DpfYt_SX.mjs} +2 -2
  12. package/dist/{commands-B0cRa6cc.mjs → commands-IPkpFWbi.mjs} +2 -2
  13. package/dist/{commands-DrVsg0Q1.mjs → commands-ND3vwGkQ.mjs} +8 -8
  14. package/dist/{fleet-CFpET-T5.mjs → fleet-Cuv2uu1t.mjs} +2 -2
  15. package/dist/{frpc-DxIjZ8o_.mjs → frpc-DbZDLphz.mjs} +1 -1
  16. package/dist/{headlessCli-BIY-YePw.mjs → headlessCli-DakQSPzS.mjs} +2 -2
  17. package/dist/index.mjs +1 -1
  18. package/dist/{notifyCommands-DldLQlDU.mjs → notifyCommands-DhRbelQA.mjs} +1 -1
  19. package/dist/package-BKX7lisO.mjs +64 -0
  20. package/dist/{pinnedClaudeCode-zDzkOTi2.mjs → pinnedClaudeCode-mM9Q6Q2G.mjs} +1 -1
  21. package/dist/{rpc-BVw8Tv9l.mjs → rpc-C7uDtxFw.mjs} +1 -1
  22. package/dist/{rpc-D7_Gj0Kt.mjs → rpc-Q4PWHeNn.mjs} +1 -1
  23. package/dist/{run-CqO23ro_.mjs → run-Ccf8_4AF.mjs} +389 -88
  24. package/dist/{run-DLk7NGih.mjs → run-DG0yDh6U.mjs} +1 -1
  25. package/dist/{scheduler-q7Refevo.mjs → scheduler-B1xThM3A.mjs} +1 -1
  26. package/dist/{serveCommands-BJjvhDxp.mjs → serveCommands-DDq9qzoN.mjs} +5 -5
  27. package/dist/{sideband-Dv3qNlQN.mjs → sideband-CDdpZAXO.mjs} +1 -1
  28. package/package.json +2 -2
  29. package/dist/package-BKmrvBsb.mjs +0 -64
@@ -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-DxIjZ8o_.mjs');
3156
+ const { FrpcTunnel } = await import('./frpc-DbZDLphz.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-DxIjZ8o_.mjs');
6289
+ const { FrpcTunnel } = await import('./frpc-DbZDLphz.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-Dv3qNlQN.mjs');
6782
+ const { toolsForRole } = await import('./sideband-CDdpZAXO.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-D-RsuMqs.mjs');
6887
+ const { queryCore } = await import('./commands-C2zQmf3Y.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,15 @@ 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);
15610
15645
  }
15611
15646
  function issuesClosedSince(projectRoot, sinceMs) {
15612
15647
  const all = listIssues(projectRoot, { includeArchived: true, status: "archived" });
@@ -15831,6 +15866,25 @@ function decideLoopContinuation(input) {
15831
15866
  const tail = `no progress across ${stuckCheckpoints} checkpoints; ${maxAutoResumes} auto-resume(s) exhausted`;
15832
15867
  return { action: "stop", kind: "stuck", reason: input.stuckReason ? `${input.stuckReason} \u2014 ${tail}` : tail };
15833
15868
  }
15869
+ function terminalPhaseForDone(durable) {
15870
+ return durable ? "dormant" : "done";
15871
+ }
15872
+ const DEFAULT_LOOP_VERIFY_ERROR_RETRIES = 3;
15873
+ function resolveVerifyErrorRetries(envValue) {
15874
+ if (envValue == null || envValue.trim() === "") return DEFAULT_LOOP_VERIFY_ERROR_RETRIES;
15875
+ const n = Number(envValue);
15876
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
15877
+ return DEFAULT_LOOP_VERIFY_ERROR_RETRIES;
15878
+ }
15879
+ function decideVerifyErrorHandling(input) {
15880
+ const maxRetries = typeof input.maxRetries === "number" && input.maxRetries >= 0 ? Math.floor(input.maxRetries) : DEFAULT_LOOP_VERIFY_ERROR_RETRIES;
15881
+ const attempt = (typeof input.priorErrors === "number" && input.priorErrors > 0 ? Math.floor(input.priorErrors) : 0) + 1;
15882
+ const why = input.reason ? String(input.reason).slice(0, 200) : "verification error";
15883
+ if (attempt <= maxRetries) {
15884
+ return { action: "retry", attempt, reason: `${why} \u2014 retry ${attempt}/${maxRetries} (keeping the loop running, not declaring done)` };
15885
+ }
15886
+ return { action: "park", attempt, reason: `${why} \u2014 ${maxRetries} verification retries exhausted; parking safely instead of a false "done"` };
15887
+ }
15834
15888
  const DEFAULT_LOOP_EVALUATOR_MODEL = "claude-haiku-4-5-20251001";
15835
15889
  function resolveEvaluatorModel(envValue) {
15836
15890
  if (envValue === void 0) return DEFAULT_LOOP_EVALUATOR_MODEL;
@@ -15905,13 +15959,13 @@ Keep working until it is genuinely empty.` };
15905
15959
  try {
15906
15960
  raw = await deps.runEvaluator(prompt);
15907
15961
  } catch (e) {
15908
- log(`[loopVerify] evaluator spawn failed \u2014 failing OPEN (marking done): ${e?.message || e}`);
15909
- return { action: "done" };
15962
+ log(`[loopVerify] evaluator spawn failed \u2014 signalling verify_error (fail-closed, will retry): ${e?.message || e}`);
15963
+ return { action: "verify_error", reason: `evaluator spawn failed: ${e?.message || e}` };
15910
15964
  }
15911
15965
  const verdict = parseEvaluatorVerdict(raw);
15912
15966
  if (!verdict) {
15913
- log(`[loopVerify] could not parse evaluator verdict \u2014 failing OPEN (marking done)`);
15914
- return { action: "done" };
15967
+ log(`[loopVerify] could not parse evaluator verdict \u2014 signalling verify_error (fail-closed, will retry)`);
15968
+ return { action: "verify_error", reason: "could not parse evaluator verdict" };
15915
15969
  }
15916
15970
  if (verdict.verdict === "done") {
15917
15971
  log(`[loopVerify] evaluator confirms done${verdict.reason ? `: ${verdict.reason}` : ""}`);
@@ -16358,6 +16412,28 @@ async function writeSessionFileChunk(directory, sessionMetadata, path, content,
16358
16412
  }
16359
16413
  }
16360
16414
 
16415
+ function isStdinWritable(proc) {
16416
+ if (!proc || proc.exitCode !== null) return false;
16417
+ const stdin = proc.stdin;
16418
+ if (!stdin) return false;
16419
+ if (stdin.destroyed === true) return false;
16420
+ if (stdin.writable === false) return false;
16421
+ return true;
16422
+ }
16423
+ function serializeUserFrame(text) {
16424
+ return JSON.stringify({ type: "user", message: { role: "user", content: text } }) + "\n";
16425
+ }
16426
+ function writeUserFrame(proc, text, onError) {
16427
+ if (!isStdinWritable(proc)) return false;
16428
+ try {
16429
+ proc.stdin.write(serializeUserFrame(text));
16430
+ return true;
16431
+ } catch (err) {
16432
+ onError?.(err);
16433
+ return false;
16434
+ }
16435
+ }
16436
+
16361
16437
  const __filename$1 = fileURLToPath(import.meta.url);
16362
16438
  const __dirname$1 = dirname$1(__filename$1);
16363
16439
  const CLAUDE_SKILLS_DIR = join(os$1.homedir(), ".claude", "skills");
@@ -16900,18 +16976,27 @@ function readLoopState(directory, sessionId) {
16900
16976
  return null;
16901
16977
  }
16902
16978
  }
16979
+ function isTerminalLoopPhase(phase) {
16980
+ return phase === "done" || phase === "gave_up" || phase === "cancelled";
16981
+ }
16903
16982
  function isLoopActive(directory, sessionId) {
16904
16983
  const s = readLoopState(directory, sessionId);
16905
- return !!s && s.active !== false && s.phase !== "done" && s.phase !== "gave_up" && s.phase !== "cancelled";
16984
+ return !!s && s.active !== false && s.phase !== "dormant" && !isTerminalLoopPhase(s.phase);
16906
16985
  }
16907
16986
  function loopOwnerSession(directory, sessionId) {
16908
16987
  const s = readLoopState(directory, sessionId);
16909
- if (!s || s.active === false || s.phase === "done" || s.phase === "gave_up" || s.phase === "cancelled") return null;
16988
+ if (!s || s.active === false || s.phase === "dormant" || isTerminalLoopPhase(s.phase)) return null;
16910
16989
  return typeof s.session_id === "string" ? s.session_id : null;
16911
16990
  }
16912
16991
  function isLoopActiveForSession(directory, sessionId) {
16913
16992
  const s = readLoopState(directory, sessionId);
16914
- if (!s || s.active === false || s.phase === "done" || s.phase === "gave_up" || s.phase === "cancelled") return false;
16993
+ if (!s || s.active === false || s.phase === "dormant" || isTerminalLoopPhase(s.phase)) return false;
16994
+ if (typeof s.session_id !== "string") return true;
16995
+ return s.session_id === sessionId;
16996
+ }
16997
+ function isLoopArmedForSession(directory, sessionId) {
16998
+ const s = readLoopState(directory, sessionId);
16999
+ if (!s || s.active === false || s.phase !== "dormant") return false;
16915
17000
  if (typeof s.session_id !== "string") return true;
16916
17001
  return s.session_id === sessionId;
16917
17002
  }
@@ -16980,6 +17065,14 @@ function loopResumeMessage(directory, sessionId) {
16980
17065
  function stripOldLoopHooks(directory) {
16981
17066
  try {
16982
17067
  const p = join(directory, ".claude", "settings.json");
17068
+ if (!existsSync$1(p)) return false;
17069
+ return withFileLock(p + ".svamp-lock", () => stripOldLoopHooksLocked(p));
17070
+ } catch {
17071
+ return false;
17072
+ }
17073
+ }
17074
+ function stripOldLoopHooksLocked(p) {
17075
+ try {
16983
17076
  if (!existsSync$1(p)) return false;
16984
17077
  const cfg = JSON.parse(readFileSync$1(p, "utf-8"));
16985
17078
  if (!cfg || typeof cfg.hooks !== "object" || !cfg.hooks) return false;
@@ -17003,13 +17096,31 @@ function stripOldLoopHooks(directory) {
17003
17096
  }
17004
17097
  if (changed) {
17005
17098
  if (Object.keys(cfg.hooks).length === 0) delete cfg.hooks;
17006
- writeFileSync(p, JSON.stringify(cfg, null, 2));
17099
+ const tmp = `${p}.${process.pid}.tmp`;
17100
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2));
17101
+ renameSync$1(tmp, p);
17007
17102
  }
17008
17103
  return changed;
17009
17104
  } catch {
17010
17105
  return false;
17011
17106
  }
17012
17107
  }
17108
+ const HELPER_KILL_GRACE_MS = 3e3;
17109
+ const BTW_TIMEOUT_MS = Number(process.env.SVAMP_BTW_TIMEOUT_MS) > 0 ? Number(process.env.SVAMP_BTW_TIMEOUT_MS) : 10 * 6e4;
17110
+ function killHelperTree(proc, graceMs = HELPER_KILL_GRACE_MS) {
17111
+ const pid = proc.pid;
17112
+ if (!pid) return;
17113
+ try {
17114
+ process.kill(-pid, "SIGTERM");
17115
+ } catch {
17116
+ }
17117
+ setTimeout(() => {
17118
+ try {
17119
+ process.kill(-pid, "SIGKILL");
17120
+ } catch {
17121
+ }
17122
+ }, graceMs).unref();
17123
+ }
17013
17124
  function spawnHeadlessEvaluator(prompt, opts) {
17014
17125
  return new Promise((resolve2, reject) => {
17015
17126
  const evalArgs = [
@@ -17040,7 +17151,7 @@ function spawnHeadlessEvaluator(prompt, opts) {
17040
17151
  delete evalEnv.CLAUDECODE;
17041
17152
  let proc;
17042
17153
  try {
17043
- proc = spawn("claude", evalArgs, { cwd: opts.cwd, env: evalEnv, stdio: ["pipe", "pipe", "pipe"] });
17154
+ proc = spawn("claude", evalArgs, { cwd: opts.cwd, env: evalEnv, stdio: ["pipe", "pipe", "pipe"], detached: true });
17044
17155
  } catch (e) {
17045
17156
  reject(e);
17046
17157
  return;
@@ -17049,10 +17160,7 @@ function spawnHeadlessEvaluator(prompt, opts) {
17049
17160
  const timer = setTimeout(() => {
17050
17161
  if (settled) return;
17051
17162
  settled = true;
17052
- try {
17053
- proc.kill("SIGTERM");
17054
- } catch {
17055
- }
17163
+ killHelperTree(proc);
17056
17164
  reject(new Error("evaluator timeout"));
17057
17165
  }, opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : 3e5);
17058
17166
  proc.stderr?.on("data", (c) => {
@@ -17398,11 +17506,12 @@ function createSvampConfigChecker(directory, sessionId, getMetadata, setMetadata
17398
17506
  const softTurnCap = computeSoftTurnCap(hardMax, LOOP_CHECKPOINT_CADENCE);
17399
17507
  const condition = buildGoalCondition({ task, until, oracle, maxIterations: softTurnCap });
17400
17508
  const budget = parseLoopBudget(cfg.budget);
17509
+ const durable = resolveLoopDurable({ durable: cfg.durable, oracle });
17401
17510
  try {
17402
17511
  stripOldLoopHooks(directory);
17403
17512
  } catch {
17404
17513
  }
17405
- 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 });
17514
+ 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 });
17406
17515
  const existingQueue = getMetadata().messageQueue || [];
17407
17516
  setMetadata((m) => ({ ...m, messageQueue: [...existingQueue, { id: randomUUID$1(), text: `/goal ${condition}`, displayText: `\u{1F3AF} Goal: ${(task || until || "").slice(0, 100)}`, createdAt: Date.now() }] }));
17408
17517
  onLoopActivated?.();
@@ -17855,7 +17964,7 @@ async function startDaemon(options) {
17855
17964
  try {
17856
17965
  const dir = loadSessionIndex()[sessionId]?.directory;
17857
17966
  if (!dir) return;
17858
- const { reconcileServiceLinks } = await import('./agentCommands-9GHpj3mp.mjs');
17967
+ const { reconcileServiceLinks } = await import('./agentCommands-hLTce38n.mjs');
17859
17968
  const configPath = getSvampConfigPath(dir, sessionId);
17860
17969
  const config = readSvampConfig(configPath);
17861
17970
  const entries = Array.from(urls.entries());
@@ -17873,7 +17982,7 @@ async function startDaemon(options) {
17873
17982
  }
17874
17983
  }
17875
17984
  async function createExposedTunnel(spec) {
17876
- const { FrpcTunnel } = await import('./frpc-DxIjZ8o_.mjs');
17985
+ const { FrpcTunnel } = await import('./frpc-DbZDLphz.mjs');
17877
17986
  const tunnel = new FrpcTunnel({
17878
17987
  name: spec.name,
17879
17988
  ports: spec.ports,
@@ -17901,7 +18010,7 @@ async function startDaemon(options) {
17901
18010
  ensureAutoInstalledCommands(logger);
17902
18011
  (async () => {
17903
18012
  try {
17904
- const { beginClaudeVersionReconcile } = await import('./pinnedClaudeCode-zDzkOTi2.mjs');
18013
+ const { beginClaudeVersionReconcile } = await import('./pinnedClaudeCode-mM9Q6Q2G.mjs');
17905
18014
  beginClaudeVersionReconcile((msg) => logger.log(msg));
17906
18015
  } catch (e) {
17907
18016
  logger.log(`[claude-version] check failed: ${e?.message || e}`);
@@ -18396,12 +18505,16 @@ ${v.guidance ? v.guidance + "\n" : ""}Criteria: ${v.criteria || "(none)"}
18396
18505
  "json",
18397
18506
  "--max-turns",
18398
18507
  "6"
18399
- ], { cwd, timeout: 6e4, stdio: ["ignore", "pipe", "pipe"], env: forkEnv });
18508
+ // #0820: detached own process group. Node's `timeout` option kills only
18509
+ // the direct child, so without this a tool grandchild survives the 60s cap
18510
+ // and reparents to init. The exit/error handlers below kill the group.
18511
+ ], { cwd, timeout: 6e4, stdio: ["ignore", "pipe", "pipe"], env: forkEnv, detached: true });
18400
18512
  let stdout = "";
18401
18513
  child.stdout?.on("data", (d) => {
18402
18514
  stdout += d.toString();
18403
18515
  });
18404
18516
  child.on("close", () => {
18517
+ killHelperTree(child);
18405
18518
  try {
18406
18519
  const r = JSON.parse(stdout);
18407
18520
  resolve2(r.result || r.text || null);
@@ -18409,7 +18522,10 @@ ${v.guidance ? v.guidance + "\n" : ""}Criteria: ${v.criteria || "(none)"}
18409
18522
  resolve2(null);
18410
18523
  }
18411
18524
  });
18412
- child.on("error", () => resolve2(null));
18525
+ child.on("error", () => {
18526
+ killHelperTree(child);
18527
+ resolve2(null);
18528
+ });
18413
18529
  } catch {
18414
18530
  resolve2(null);
18415
18531
  }
@@ -18492,6 +18608,13 @@ ${parts.join("\n")}`);
18492
18608
  let startupRetryMessage;
18493
18609
  let startupNonJsonLines = [];
18494
18610
  let errorOutputLines = [];
18611
+ const MAX_ERROR_OUTPUT_LINES = 200;
18612
+ const pushErrorOutputLine = (line) => {
18613
+ errorOutputLines.push(line);
18614
+ if (errorOutputLines.length > MAX_ERROR_OUTPUT_LINES) {
18615
+ errorOutputLines.splice(0, errorOutputLines.length - MAX_ERROR_OUTPUT_LINES);
18616
+ }
18617
+ };
18495
18618
  const STUCK_PROCESS_TIMEOUT_MS = 30 * 60 * 1e3;
18496
18619
  const STUCK_CHECK_INTERVAL_MS = 2 * 60 * 1e3;
18497
18620
  let lastOutputTime = Date.now();
@@ -18598,6 +18721,7 @@ ${parts.join("\n")}`);
18598
18721
  const budget = ls.budget;
18599
18722
  const progress_history = curLs.progress_history ?? ls.progress_history;
18600
18723
  const extensions = curLs.extensions ?? ls.extensions;
18724
+ const durable = resolveLoopDurable({ durable: ls.durable, oracle: oracleCmd });
18601
18725
  if (result.action === "rekick") {
18602
18726
  const cond = ls.task || "";
18603
18727
  writeGoalLoopState(directory, sessionId, {
@@ -18615,7 +18739,9 @@ ${parts.join("\n")}`);
18615
18739
  ledger,
18616
18740
  progress_history,
18617
18741
  auto_resumes: ls.auto_resumes,
18618
- extensions
18742
+ extensions,
18743
+ durable: ls.durable,
18744
+ verify_errors: 0
18619
18745
  });
18620
18746
  const guidance = result.guidance.replace(/\s+/g, " ").slice(0, 1200);
18621
18747
  sessionService.pushMessage({ type: "message", message: `\u{1F501} Loop not done \u2014 independent review: ${result.reason}`, level: "warning" }, "event");
@@ -18652,7 +18778,9 @@ ${parts.join("\n")}`);
18652
18778
  ledger,
18653
18779
  progress_history,
18654
18780
  auto_resumes: nextAuto,
18655
- extensions
18781
+ extensions,
18782
+ durable: ls.durable,
18783
+ verify_errors: 0
18656
18784
  });
18657
18785
  const label = decision.kind === "autoresume" ? "\u{1F501} Auto-resuming stalled loop" : "\u{1F501} Loop still progressing";
18658
18786
  sessionService.pushMessage({ type: "message", message: `${label} \u2014 ${decision.reason}` }, "event");
@@ -18665,15 +18793,75 @@ ${parts.join("\n")}`);
18665
18793
  logger.log(`[Session ${sessionId}] [loop-persist] stuck-stop: ${decision.reason}`);
18666
18794
  checkSvampConfig?.();
18667
18795
  }
18796
+ } else if (result.action === "verify_error") {
18797
+ const decision = decideVerifyErrorHandling({
18798
+ priorErrors: ls.verify_errors,
18799
+ maxRetries: resolveVerifyErrorRetries(process.env.SVAMP_LOOP_VERIFY_ERROR_RETRIES),
18800
+ reason: result.reason
18801
+ });
18802
+ if (decision.action === "retry") {
18803
+ const cond = ls.task || "";
18804
+ writeGoalLoopState(directory, sessionId, {
18805
+ active: true,
18806
+ phase: "continue",
18807
+ iteration: ls.iteration || 0,
18808
+ task: ls.task,
18809
+ goal_task: ls.goal_task,
18810
+ until: ls.until,
18811
+ oracle: oracleCmd,
18812
+ max_iterations: ls.max_iterations,
18813
+ budget,
18814
+ started_at: startedAt,
18815
+ holds,
18816
+ ledger,
18817
+ progress_history,
18818
+ auto_resumes: ls.auto_resumes,
18819
+ extensions,
18820
+ durable: ls.durable,
18821
+ verify_errors: decision.attempt
18822
+ });
18823
+ sessionService.pushMessage({ type: "message", message: `\u26A0\uFE0F Loop verification hit a transient error \u2014 ${decision.reason}`, level: "warning" }, "event");
18824
+ logger.log(`[Session ${sessionId}] [loop-verify-error] ${decision.reason}`);
18825
+ 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");
18826
+ if (!trackedSession.stopped) setTimeout(() => processMessageQueueRef?.(), 200);
18827
+ } else {
18828
+ const parkPhase = terminalPhaseForDone(durable);
18829
+ 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 });
18830
+ 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");
18831
+ logger.log(`[Session ${sessionId}] [loop-verify-error] park ${parkPhase}: ${decision.reason}`);
18832
+ checkSvampConfig?.();
18833
+ }
18668
18834
  } else {
18669
- writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: Date.now(), holds, ledger, progress_history, auto_resumes: ls.auto_resumes, extensions });
18835
+ const donePhase = terminalPhaseForDone(durable);
18836
+ if (donePhase === "dormant") {
18837
+ 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 });
18838
+ sessionService.pushMessage({ type: "message", message: "\u{1F319} Backlog clear \u2014 loop is armed and watching for new issues (auto-resumes when work arrives)." }, "event");
18839
+ logger.log(`[Session ${sessionId}] [loop] backlog drained \u2192 dormant (armed)`);
18840
+ } else {
18841
+ 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 });
18842
+ }
18670
18843
  checkSvampConfig?.();
18671
18844
  }
18672
18845
  } catch (e) {
18673
- logger.log(`[Session ${sessionId}] verifyGoalCompletion error \u2014 failing open (done): ${e?.message || e}`);
18846
+ logger.log(`[Session ${sessionId}] verifyGoalCompletion threw \u2014 failing closed (retry/park): ${e?.message || e}`);
18674
18847
  try {
18675
- writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: Date.now() });
18676
- checkSvampConfig?.();
18848
+ const durable2 = resolveLoopDurable({ durable: ls.durable, oracle: oracleCmd });
18849
+ const decision = decideVerifyErrorHandling({
18850
+ priorErrors: ls.verify_errors,
18851
+ maxRetries: resolveVerifyErrorRetries(process.env.SVAMP_LOOP_VERIFY_ERROR_RETRIES),
18852
+ reason: `verify threw: ${e?.message || e}`
18853
+ });
18854
+ if (decision.action === "retry") {
18855
+ const cond = ls.task || "";
18856
+ writeGoalLoopState(directory, sessionId, { ...ls, active: true, phase: "continue", started_at: startedAt, verify_errors: decision.attempt });
18857
+ sessionService.pushMessage({ type: "message", message: `\u26A0\uFE0F Loop verification error \u2014 ${decision.reason}`, level: "warning" }, "event");
18858
+ enqueueLoopMessage(`/goal ${cond} \u2014 the completion check could not run; CONTINUE and it will be re-verified. Not done.`, "\u26A0\uFE0F Re-verifying loop");
18859
+ if (!trackedSession.stopped) setTimeout(() => processMessageQueueRef?.(), 200);
18860
+ } else {
18861
+ const parkPhase = terminalPhaseForDone(durable2);
18862
+ writeGoalLoopState(directory, sessionId, { ...ls, active: parkPhase === "dormant", phase: parkPhase, completed_at: Date.now(), verify_errors: 0 });
18863
+ checkSvampConfig?.();
18864
+ }
18677
18865
  } catch {
18678
18866
  }
18679
18867
  } finally {
@@ -19191,7 +19379,7 @@ ${parts.join("\n")}`);
19191
19379
  clearInboundContext(sessionId);
19192
19380
  try {
19193
19381
  const ls = readLoopState(directory, sessionId);
19194
- if (ls && ls.engine === "goal" && ls.active !== false && ls.phase !== "done" && ls.phase !== "gave_up" && ls.phase !== "cancelled") {
19382
+ if (ls && ls.engine === "goal" && ls.active !== false && ls.phase !== "dormant" && ls.phase !== "done" && ls.phase !== "gave_up" && ls.phase !== "cancelled") {
19195
19383
  const now = Date.now();
19196
19384
  const ledger = accumulateLedger(ls.ledger, {
19197
19385
  turns: Number(msg.num_turns) || 0,
@@ -19225,7 +19413,8 @@ ${parts.join("\n")}`);
19225
19413
  sessionService.pushMessage({ type: "message", message: `\u{1F6D1} Loop stopped \u2014 ${detail}`, level: "warning" }, "event");
19226
19414
  checkSvampConfig?.();
19227
19415
  } else if (process.env.SVAMP_LOOP_VERIFY === "0") {
19228
- writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: now, ledger, progress_history: prog.progress_history, auto_resumes: prog.auto_resumes });
19416
+ const donePhase = terminalPhaseForDone(resolveLoopDurable({ durable: ls.durable, oracle: ls.oracle }));
19417
+ writeGoalLoopState(directory, sessionId, { ...ls, active: donePhase === "dormant", phase: donePhase, completed_at: now, ledger, progress_history: prog.progress_history, auto_resumes: prog.auto_resumes });
19229
19418
  } else {
19230
19419
  writeGoalLoopState(directory, sessionId, { ...ls, ledger, progress_history: prog.progress_history, auto_resumes: prog.auto_resumes });
19231
19420
  void verifyGoalCompletion(ls);
@@ -19361,7 +19550,7 @@ ${parts.join("\n")}`);
19361
19550
  startupNonJsonLines.push(line.slice(0, 500));
19362
19551
  }
19363
19552
  if (looksLikeClaudeError(line)) {
19364
- errorOutputLines.push(line.slice(0, 500));
19553
+ pushErrorOutputLine(line.slice(0, 500));
19365
19554
  }
19366
19555
  }
19367
19556
  }
@@ -19379,14 +19568,12 @@ ${parts.join("\n")}`);
19379
19568
  stdoutBuffer = "";
19380
19569
  }
19381
19570
  });
19382
- let stderrBuffer = "";
19383
19571
  child.stderr?.on("data", (chunk) => {
19384
19572
  const text = chunk.toString();
19385
19573
  logger.log(`[Session ${sessionId}] Claude stderr: ${text.trim()}`);
19386
- stderrBuffer += text;
19387
19574
  for (const line of text.split("\n")) {
19388
19575
  if (looksLikeClaudeError(line)) {
19389
- errorOutputLines.push(line.trim().slice(0, 500));
19576
+ pushErrorOutputLine(line.trim().slice(0, 500));
19390
19577
  }
19391
19578
  }
19392
19579
  });
@@ -19472,6 +19659,9 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
19472
19659
  }
19473
19660
  return child;
19474
19661
  };
19662
+ const writeUserFrameToClaude = (text) => writeUserFrame(claudeProcess, text, (err) => {
19663
+ logger.log(`[Session ${sessionId}] Claude stdin write failed: ${err?.message || err}`);
19664
+ });
19475
19665
  const restartClaudeHandler = async (opts) => {
19476
19666
  logger.log(`[Session ${sessionId}] Restart Claude requested`);
19477
19667
  if (isRestartingClaude || isSwitchingMode) {
@@ -19510,6 +19700,9 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
19510
19700
  spawnClaude(void 0, { permissionMode: currentPermissionMode });
19511
19701
  sessionService.updateMetadata(sessionMetadata);
19512
19702
  logger.log(`[Session ${sessionId}] Claude respawned with --resume ${claudeResumeId}`);
19703
+ if ((sessionMetadata.messageQueue?.length ?? 0) > 0 && !trackedSession?.stopped) {
19704
+ setTimeout(() => processMessageQueueRef?.(), 300);
19705
+ }
19513
19706
  if (beforeRespawnError) {
19514
19707
  return { success: false, message: `Edit failed (session restored): ${beforeRespawnError}` };
19515
19708
  }
@@ -19635,12 +19828,28 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
19635
19828
  const btwProcess = spawn("claude", btwArgs, {
19636
19829
  cwd: directory,
19637
19830
  env: btwEnv,
19638
- stdio: ["pipe", "pipe", "pipe"]
19831
+ stdio: ["pipe", "pipe", "pipe"],
19832
+ detached: true
19833
+ // #0820: own process group so the timeout can kill grandchildren
19639
19834
  });
19640
19835
  let btwResult = "";
19641
19836
  let btwBuffer = "";
19642
19837
  let btwStderr = "";
19643
19838
  let settled = false;
19839
+ const btwTimer = setTimeout(() => {
19840
+ if (settled) return;
19841
+ settled = true;
19842
+ killHelperTree(btwProcess);
19843
+ const err = new Error(`/btw timed out after ${Math.round(BTW_TIMEOUT_MS / 1e3)}s`);
19844
+ err.retryable = false;
19845
+ reject(err);
19846
+ }, BTW_TIMEOUT_MS);
19847
+ const settleBtw = () => {
19848
+ if (settled) return false;
19849
+ settled = true;
19850
+ clearTimeout(btwTimer);
19851
+ return true;
19852
+ };
19644
19853
  btwProcess.stderr?.on("data", (chunk) => {
19645
19854
  btwStderr += chunk.toString();
19646
19855
  });
@@ -19660,8 +19869,7 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
19660
19869
  }
19661
19870
  }
19662
19871
  } else if (msg.type === "result") {
19663
- if (settled) continue;
19664
- settled = true;
19872
+ if (!settleBtw()) continue;
19665
19873
  if (msg.is_error) {
19666
19874
  const err = new Error(String(msg.result || "error"));
19667
19875
  err.retryable = isRetryableRateLimit(String(msg.result || ""), msg.api_error_status);
@@ -19675,8 +19883,7 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
19675
19883
  }
19676
19884
  });
19677
19885
  btwProcess.on("exit", (code) => {
19678
- if (settled) return;
19679
- settled = true;
19886
+ if (!settleBtw()) return;
19680
19887
  if (code === 0) {
19681
19888
  resolve2(btwResult || "(no response)");
19682
19889
  return;
@@ -19687,8 +19894,7 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
19687
19894
  reject(err);
19688
19895
  });
19689
19896
  btwProcess.on("error", (e) => {
19690
- if (settled) return;
19691
- settled = true;
19897
+ if (!settleBtw()) return;
19692
19898
  const err = new Error(e.message);
19693
19899
  err.retryable = false;
19694
19900
  reject(err);
@@ -19734,15 +19940,26 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
19734
19940
  pendingCompactTurn = isCompactCommand(text);
19735
19941
  if (!claudeProcess || claudeProcess.exitCode !== null) {
19736
19942
  spawnClaude(text, msgMeta);
19943
+ signalProcessing(true);
19944
+ sessionWasProcessing = true;
19945
+ } else if (writeUserFrameToClaude(text)) {
19946
+ signalProcessing(true);
19947
+ sessionWasProcessing = true;
19737
19948
  } else {
19738
- const stdinMsg = JSON.stringify({
19739
- type: "user",
19740
- message: { role: "user", content: text }
19741
- });
19742
- claudeProcess.stdin?.write(stdinMsg + "\n");
19949
+ logger.log(`[Session ${sessionId}] Claude stdin unwritable \u2014 requeueing message for respawn delivery`);
19950
+ const existingQueue = sessionMetadata.messageQueue || [];
19951
+ sessionMetadata = {
19952
+ ...sessionMetadata,
19953
+ messageQueue: [...existingQueue, { id: randomUUID$1(), text, createdAt: Date.now(), alreadyStored: true }]
19954
+ };
19955
+ sessionService.updateMetadata(sessionMetadata);
19956
+ signalProcessing(true);
19957
+ sessionWasProcessing = true;
19958
+ try {
19959
+ claudeProcess.kill("SIGTERM");
19960
+ } catch {
19961
+ }
19743
19962
  }
19744
- signalProcessing(true);
19745
- sessionWasProcessing = true;
19746
19963
  },
19747
19964
  onAbort: () => {
19748
19965
  logger.log(`[Session ${sessionId}] Abort requested`);
@@ -19844,6 +20061,9 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
19844
20061
  isKillingClaude = false;
19845
20062
  if (trackedSession?.stopped) return;
19846
20063
  spawnClaude(void 0, { permissionMode: normalizedMode });
20064
+ if ((sessionMetadata.messageQueue?.length ?? 0) > 0 && !trackedSession?.stopped) {
20065
+ setTimeout(() => processMessageQueueRef?.(), 300);
20066
+ }
19847
20067
  } finally {
19848
20068
  isKillingClaude = false;
19849
20069
  isSwitchingMode = false;
@@ -20107,11 +20327,11 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
20107
20327
  });
20108
20328
  },
20109
20329
  onIssue: async (params) => {
20110
- const { issueRpc } = await import('./rpc-D7_Gj0Kt.mjs');
20330
+ const { issueRpc } = await import('./rpc-Q4PWHeNn.mjs');
20111
20331
  return issueRpc(params?.cwd || directory, params || {}, { notifySession: notifyIssueOwner, rekickLoopOwner });
20112
20332
  },
20113
20333
  onWorkflow: async (params) => {
20114
- const { workflowRpc } = await import('./rpc-BVw8Tv9l.mjs');
20334
+ const { workflowRpc } = await import('./rpc-C7uDtxFw.mjs');
20115
20335
  return workflowRpc(params?.cwd || directory, params || {});
20116
20336
  },
20117
20337
  onRipgrep: async (args, cwd) => {
@@ -20257,12 +20477,7 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
20257
20477
  try {
20258
20478
  if (!claudeProcess || claudeProcess.exitCode !== null) {
20259
20479
  spawnClaude(next.text);
20260
- } else {
20261
- const stdinMsg = JSON.stringify({
20262
- type: "user",
20263
- message: { role: "user", content: next.text }
20264
- });
20265
- claudeProcess.stdin?.write(stdinMsg + "\n");
20480
+ } else if (writeUserFrameToClaude(next.text)) {
20266
20481
  if (claudeResumeId && !trackedSession?.stopped) {
20267
20482
  saveSession({
20268
20483
  sessionId,
@@ -20276,6 +20491,15 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
20276
20491
  wasProcessing: true
20277
20492
  });
20278
20493
  }
20494
+ } else {
20495
+ logger.log(`[Session ${sessionId}] Claude stdin unwritable during queue drain \u2014 restoring head + respawning`);
20496
+ sessionMetadata = { ...sessionMetadata, messageQueue: [next, ...remaining] };
20497
+ sessionService.updateMetadata(sessionMetadata);
20498
+ sessionWasProcessing = false;
20499
+ try {
20500
+ claudeProcess.kill("SIGTERM");
20501
+ } catch {
20502
+ }
20279
20503
  }
20280
20504
  } catch (err) {
20281
20505
  logger.log(`[Session ${sessionId}] Error in processMessageQueue spawn: ${err.message}`);
@@ -20316,7 +20540,7 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
20316
20540
  logger.log(`[Session ${sessionId}] re-kick skipped \u2014 loop is resource-limit-stalled; human must run \`loop --max N\``);
20317
20541
  return;
20318
20542
  }
20319
- if (s.active === false || s.phase === "done") {
20543
+ if (s.active === false || s.phase === "done" || s.phase === "dormant") {
20320
20544
  try {
20321
20545
  const lp = join(getLoopDir(directory, sessionId), "loop-state.json");
20322
20546
  atomicWriteLoopState(lp, {
@@ -20326,6 +20550,8 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
20326
20550
  iteration: 0,
20327
20551
  completed_at: void 0,
20328
20552
  gave_up_reason: void 0,
20553
+ verify_errors: 0,
20554
+ // fresh verification budget on re-arm
20329
20555
  stall_hinted: false,
20330
20556
  resumed_at: Date.now()
20331
20557
  // #0156: re-arm the stall hint
@@ -20333,7 +20559,7 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
20333
20559
  } catch {
20334
20560
  }
20335
20561
  }
20336
- logger.log(`[Session ${sessionId}] issue change \u2192 re-kicking loop (budget remaining)`);
20562
+ logger.log(`[Session ${sessionId}] issue change \u2192 re-kicking loop (${s.phase === "dormant" ? "was dormant/armed" : "budget remaining"})`);
20337
20563
  enqueueLoopContinue();
20338
20564
  processMessageQueueRef?.();
20339
20565
  };
@@ -20781,11 +21007,11 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
20781
21007
  });
20782
21008
  },
20783
21009
  onIssue: async (params) => {
20784
- const { issueRpc } = await import('./rpc-D7_Gj0Kt.mjs');
21010
+ const { issueRpc } = await import('./rpc-Q4PWHeNn.mjs');
20785
21011
  return issueRpc(params?.cwd || directory, params || {}, { notifySession: notifyIssueOwner, rekickLoopOwner });
20786
21012
  },
20787
21013
  onWorkflow: async (params) => {
20788
- const { workflowRpc } = await import('./rpc-BVw8Tv9l.mjs');
21014
+ const { workflowRpc } = await import('./rpc-C7uDtxFw.mjs');
20789
21015
  return workflowRpc(params?.cwd || directory, params || {});
20790
21016
  },
20791
21017
  onRipgrep: async (args, cwd) => {
@@ -21129,7 +21355,9 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
21129
21355
  return;
21130
21356
  }
21131
21357
  if (process.env.SVAMP_LOOP_VERIFY === "0") {
21132
- writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: now, ledger });
21358
+ const killPhase = terminalPhaseForDone(resolveLoopDurable({ durable: ls.durable, oracle: oracleCmd }));
21359
+ writeGoalLoopState(directory, sessionId, { ...ls, active: killPhase === "dormant", phase: killPhase, completed_at: now, ledger, verify_errors: 0 });
21360
+ if (killPhase === "dormant") sessionService.pushMessage({ type: "message", message: "\u{1F319} Backlog clear \u2014 loop is armed and watching for new issues." }, "event");
21133
21361
  checkSvampConfig?.();
21134
21362
  return;
21135
21363
  }
@@ -21171,15 +21399,48 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
21171
21399
  logger.log(`[Session ${sessionId}] [loop-persist] stuck-stop: ${decision.reason}`);
21172
21400
  checkSvampConfig?.();
21173
21401
  }
21402
+ } else if (result.action === "verify_error") {
21403
+ const decision = decideVerifyErrorHandling({
21404
+ priorErrors: ls.verify_errors,
21405
+ maxRetries: resolveVerifyErrorRetries(process.env.SVAMP_LOOP_VERIFY_ERROR_RETRIES),
21406
+ reason: result.reason
21407
+ });
21408
+ if (decision.action === "retry") {
21409
+ writeGoalLoopState(directory, sessionId, { ...ls, active: true, phase: "continue", holds, started_at: startedAt, ledger, verify_errors: decision.attempt });
21410
+ sessionService.pushMessage({ type: "message", message: `\u26A0\uFE0F Loop verification hit a transient error \u2014 ${decision.reason}`, level: "warning" }, "event");
21411
+ 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");
21412
+ } else {
21413
+ const parkPhase = terminalPhaseForDone(resolveLoopDurable({ durable: ls.durable, oracle: oracleCmd }));
21414
+ writeGoalLoopState(directory, sessionId, { ...ls, active: parkPhase === "dormant", phase: parkPhase, completed_at: Date.now(), holds, ledger, verify_errors: 0 });
21415
+ 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");
21416
+ checkSvampConfig?.();
21417
+ }
21174
21418
  } else {
21175
- writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: Date.now(), holds, ledger });
21419
+ const donePhase = terminalPhaseForDone(resolveLoopDurable({ durable: ls.durable, oracle: oracleCmd }));
21420
+ if (donePhase === "dormant") {
21421
+ writeGoalLoopState(directory, sessionId, { ...ls, active: true, phase: "dormant", durable: true, completed_at: Date.now(), holds, ledger, verify_errors: 0 });
21422
+ sessionService.pushMessage({ type: "message", message: "\u{1F319} Backlog clear \u2014 loop is armed and watching for new issues." }, "event");
21423
+ } else {
21424
+ writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: Date.now(), holds, ledger, verify_errors: 0 });
21425
+ }
21176
21426
  checkSvampConfig?.();
21177
21427
  }
21178
21428
  } catch (e) {
21179
- logger.log(`[Session ${sessionId}] verifyAcpLoopCompletion error \u2014 failing open (done): ${e?.message || e}`);
21429
+ logger.log(`[Session ${sessionId}] verifyAcpLoopCompletion error \u2014 failing closed (retry/park): ${e?.message || e}`);
21180
21430
  try {
21181
- writeGoalLoopState(directory, sessionId, { ...ls, active: false, phase: "done", completed_at: Date.now(), ledger });
21182
- checkSvampConfig?.();
21431
+ const decision = decideVerifyErrorHandling({
21432
+ priorErrors: ls.verify_errors,
21433
+ maxRetries: resolveVerifyErrorRetries(process.env.SVAMP_LOOP_VERIFY_ERROR_RETRIES),
21434
+ reason: `verify threw: ${e?.message || e}`
21435
+ });
21436
+ if (decision.action === "retry") {
21437
+ writeGoalLoopState(directory, sessionId, { ...ls, active: true, phase: "continue", holds, started_at: startedAt, ledger, verify_errors: decision.attempt });
21438
+ 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");
21439
+ } else {
21440
+ const parkPhase = terminalPhaseForDone(resolveLoopDurable({ durable: ls.durable, oracle: oracleCmd }));
21441
+ writeGoalLoopState(directory, sessionId, { ...ls, active: parkPhase === "dormant", phase: parkPhase, completed_at: Date.now(), ledger, verify_errors: 0 });
21442
+ checkSvampConfig?.();
21443
+ }
21183
21444
  } catch {
21184
21445
  }
21185
21446
  } finally {
@@ -21743,7 +22004,7 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
21743
22004
  }
21744
22005
  if (persistedSessions.length > 0) {
21745
22006
  try {
21746
- const { awaitClaudeVersionReady } = await import('./pinnedClaudeCode-zDzkOTi2.mjs');
22007
+ const { awaitClaudeVersionReady } = await import('./pinnedClaudeCode-mM9Q6Q2G.mjs');
21747
22008
  await awaitClaudeVersionReady();
21748
22009
  } catch {
21749
22010
  }
@@ -21776,12 +22037,26 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
21776
22037
  break;
21777
22038
  }
21778
22039
  }
22040
+ const _loopArmed = !isOrphaned && isLoopArmedForSession(persisted.directory, persisted.sessionId);
21779
22041
  const _restoreAction = classifyRestoreContinuation({
21780
22042
  wasProcessing: !!persisted.wasProcessing,
21781
22043
  hasResumeId: !!persisted.claudeResumeId,
21782
22044
  isOrphaned: !!isOrphaned,
21783
- loopActive: isLoopActiveForSession(persisted.directory, persisted.sessionId)
22045
+ loopActive: isLoopActiveForSession(persisted.directory, persisted.sessionId),
22046
+ loopArmed: _loopArmed,
22047
+ armedHasPendingWork: _loopArmed ? safeBacklogPendingCount(persisted.directory, persisted.sessionId) > 0 : false
21784
22048
  });
22049
+ if (_loopArmed) {
22050
+ logger.log(`[loop] Restored DORMANT loop for ${persisted.sessionId} \u2192 ${_restoreAction === "loop-resume" ? "re-arming (pending work found)" : "staying dormant/watching (backlog empty)"}`);
22051
+ if (_restoreAction === "loop-resume") {
22052
+ try {
22053
+ const lp = join(getLoopDir(persisted.directory, persisted.sessionId), "loop-state.json");
22054
+ const cur = readLoopState(persisted.directory, persisted.sessionId);
22055
+ if (cur) atomicWriteLoopState(lp, { ...cur, active: true, phase: "continue", completed_at: void 0, verify_errors: 0, resumed_at: Date.now() });
22056
+ } catch {
22057
+ }
22058
+ }
22059
+ }
21785
22060
  if (_restoreAction === "auto-continue") {
21786
22061
  sessionsToAutoContinue.push(persisted.sessionId);
21787
22062
  } else if (_restoreAction === "loop-resume") {
@@ -21899,18 +22174,44 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
21899
22174
  logger.log(`Session ${sessionId} RPC handlers not found for loop resume`);
21900
22175
  continue;
21901
22176
  }
22177
+ const loopTurnFingerprint = () => {
22178
+ try {
22179
+ const s = readLoopState(sessDir, sessionId);
22180
+ return `${s?.phase || ""}|${s?.holds || 0}|${s?.ledger?.turns || 0}`;
22181
+ } catch {
22182
+ return "";
22183
+ }
22184
+ };
22185
+ const sendResume = async (tag) => {
22186
+ const prompt = loopResumeMessage(sessDir, sessionId);
22187
+ await rpc.sendMessage(
22188
+ JSON.stringify({
22189
+ role: "user",
22190
+ content: { type: "text", text: prompt },
22191
+ meta: { sentFrom: "svamp-daemon-loop-resume" }
22192
+ })
22193
+ );
22194
+ logger.log(`Resumed loop for session ${sessionId}${tag}`);
22195
+ };
21902
22196
  setTimeout(async () => {
21903
22197
  try {
21904
22198
  if (!isLoopActiveForSession(sessDir, sessionId)) return;
21905
- const prompt = loopResumeMessage(sessDir, sessionId);
21906
- await rpc.sendMessage(
21907
- JSON.stringify({
21908
- role: "user",
21909
- content: { type: "text", text: prompt },
21910
- meta: { sentFrom: "svamp-daemon-loop-resume" }
21911
- })
21912
- );
21913
- logger.log(`Resumed loop for session ${sessionId}`);
22199
+ const fpBefore = loopTurnFingerprint();
22200
+ await sendResume("");
22201
+ const graceMs = Math.max(5e3, Number(process.env.SVAMP_LOOP_RESUME_WATCHDOG_MS) || 45e3);
22202
+ setTimeout(() => {
22203
+ try {
22204
+ if (!isLoopActiveForSession(sessDir, sessionId)) return;
22205
+ const live = Array.from(pidToTrackedSession.values()).find((s) => s.svampSessionId === sessionId && !s.stopped);
22206
+ const lifecycle = live?.hyphaService?.getMetadata()?.lifecycleState;
22207
+ if (lifecycle && lifecycle !== "idle") return;
22208
+ if (loopTurnFingerprint() !== fpBefore) return;
22209
+ logger.log(`[loop] resume watchdog: session ${sessionId} loop still active but never advanced after ${graceMs}ms \u2014 re-issuing /goal once`);
22210
+ void sendResume(" (watchdog re-issue)").catch(() => {
22211
+ });
22212
+ } catch {
22213
+ }
22214
+ }, graceMs);
21914
22215
  } catch (err) {
21915
22216
  logger.log(`Failed to resume loop for session ${sessionId}: ${err.message}`);
21916
22217
  }
@@ -21948,7 +22249,7 @@ ${oracle.output.trim().slice(0, 500)}`, "\u{1F501} Continuing loop");
21948
22249
  const PING_TIMEOUT_MS = 15e3;
21949
22250
  const POST_RECONNECT_GRACE_MS = 2e4;
21950
22251
  const RECONNECT_JITTER_MS = 2500;
21951
- const { WorkflowScheduler } = await import('./scheduler-q7Refevo.mjs');
22252
+ const { WorkflowScheduler } = await import('./scheduler-B1xThM3A.mjs');
21952
22253
  const workflowProjectRoots = () => {
21953
22254
  const dirs = /* @__PURE__ */ new Set();
21954
22255
  for (const s of pidToTrackedSession.values()) {