opencode-auto-resume 1.2.1 → 1.2.2

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.
package/README.md CHANGED
@@ -424,17 +424,9 @@ Aborts the active run, then continues. The only family that can unblock a live r
424
424
 
425
425
  Paths: orphan parent, subagent stuck (parent side), hallucination loop, streaming-failure escalation. All aborts pass the active-tool guards first, and plugin-initiated aborts are marked so they are not mistaken for user ESC (`session.error` → `MessageAbortedError` race).
426
426
 
427
- ## Complementary plugins (for v2)
427
+ <<<<<<< HEAD
428
+ ## Architecture
428
429
 
429
- Auto-resume works on its own, but two companion plugins make it sharper. Both are optional — auto-resume detects their presence and degrades gracefully when they are absent.
430
-
431
- | Plugin | Required? | If absent |
432
- |---|---|---|
433
- | opencode-todolist | No | No task-aware nudges; other paths unaffected |
434
- | opencode-task-complete | No | Built-in fallback `task_complete` tool; text/emoji completion |
435
-
436
- - [opencode-todolist](https://github.com/aiev/opencode-todolist) — Restores `todowrite` / `todoread` in opencode v2
437
- - [opencode-task-complete](https://github.com/famewolf/opencode-task-complete) — Provides the `task_complete` tool for explicit completion signalling
438
430
 
439
431
  ## Verification & troubleshooting
440
432
 
package/dist/index.js CHANGED
@@ -14911,7 +14911,7 @@ ${reminder}`;
14911
14911
  };
14912
14912
 
14913
14913
  // src/v2/index.ts
14914
- import { readFileSync, mkdirSync, statSync, writeFileSync } from "fs";
14914
+ import { readFileSync, mkdirSync, writeFileSync } from "fs";
14915
14915
  import { dirname, join } from "path";
14916
14916
  import { fileURLToPath } from "url";
14917
14917
  import { readFile } from "fs/promises";
@@ -15017,7 +15017,6 @@ var DONE_CLAIM_PATTERN_SOURCES = [
15017
15017
  "\\b(?:all|everything)\\s+(?:[\\w-]+\\s+)?(?:is\\s+)?(?:complete|done|finished)",
15018
15018
  "\\bnothing\\s+(?:else\\s+)?(?:left|remaining|to do)"
15019
15019
  ];
15020
- var DEFAULT_DONE_CLAIM_PATTERNS = [...DONE_CLAIM_PATTERN_SOURCES];
15021
15020
  var DEFAULT_READY_TO_CONTINUE_PATTERNS = [
15022
15021
  "ready to continue with task",
15023
15022
  "continuing with task",
@@ -15055,7 +15054,8 @@ var DEFAULT_INJECT_INTERVAL_MS = 15000;
15055
15054
  var OWN_PROMPT_TIME_MS = 30000;
15056
15055
  var MAX_IDLE_SESSIONS2 = 50;
15057
15056
  var IDLE_CLEANUP_MS2 = 10 * 60000;
15058
- var COMPACTION_STALE_TTL_MS = 30 * 60000;
15057
+ var COMPACTION_STALE_TTL_MS = 5 * 60000;
15058
+ var COMPLETION_LATCH_TTL_MS = 5 * 60000;
15059
15059
  var PERMISSION_STALE_TTL_MS = 30 * 60000;
15060
15060
  var NEVER_INTERRUPT_STALE_TTL_MS = 30 * 60000;
15061
15061
  var TEXT_BUFFER_TRIM_LEN = 20000;
@@ -15389,9 +15389,6 @@ function isUserHandoff(text) {
15389
15389
  ];
15390
15390
  return patterns.some((p) => p.test(last));
15391
15391
  }
15392
- function backoffMs2(attempt, base, max) {
15393
- return Math.min(base * Math.pow(2, attempt - 1), max);
15394
- }
15395
15392
  function isSilenceReason(reason) {
15396
15393
  return /^no activity for \d+s/i.test(reason);
15397
15394
  }
@@ -15582,6 +15579,7 @@ var v2_default = define({
15582
15579
  const checkIntervalMs = opts.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS2;
15583
15580
  const gracePeriodMs = opts.gracePeriodMs ?? DEFAULT_GRACE_PERIOD_MS2;
15584
15581
  const stuckToolAgeMs = opts.stuckToolAgeMs ?? DEFAULT_STUCK_TOOL_AGE_MS;
15582
+ const completionLatchTtlMs = opts.completionLatchTtlMs ?? COMPLETION_LATCH_TTL_MS;
15585
15583
  const neverInterruptTools = (opts.neverInterruptTools ?? ["compress"]).map((n) => String(n).toLowerCase());
15586
15584
  const maxRetries = opts.maxRetries ?? opts.maxRecoveryRetries ?? DEFAULT_MAX_RETRIES2;
15587
15585
  const baseBackoff = opts.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS2;
@@ -15627,12 +15625,13 @@ var v2_default = define({
15627
15625
  }
15628
15626
  return out;
15629
15627
  }
15630
- const doneClaimPatterns = compilePatterns(opts.doneClaimPatterns, DEFAULT_DONE_CLAIM_PATTERNS, "im");
15628
+ const doneClaimPatterns = compilePatterns(opts.doneClaimPatterns, DONE_CLAIM_PATTERN_SOURCES, "im");
15631
15629
  const readyToContinuePatterns = compilePatterns(opts.readyToContinuePatterns, DEFAULT_READY_TO_CONTINUE_PATTERNS, "i");
15632
15630
  const streamingFailureMessageRegexes = compilePatterns(streamingFailureMessagePatterns, DEFAULT_STREAMING_FAILURE_MESSAGE_PATTERNS2, "i");
15633
15631
  const FEATURE_GATED_OPTIONS = [];
15634
15632
  const RECOGNISED_OPTIONS = new Set([
15635
15633
  "chunkTimeoutMs",
15634
+ "completionLatchTtlMs",
15636
15635
  "checkIntervalMs",
15637
15636
  "gracePeriodMs",
15638
15637
  "maxRetries",
@@ -15761,7 +15760,6 @@ var v2_default = define({
15761
15760
  providerRetryAt: 0,
15762
15761
  quotaNoticedAt: 0,
15763
15762
  gaveUp: false,
15764
- lastQuotaNoticeAt: 0,
15765
15763
  paused: false,
15766
15764
  lastGenuineUserAt: 0,
15767
15765
  consecFailureRecovers: 0,
@@ -15795,6 +15793,8 @@ var v2_default = define({
15795
15793
  intentNudgeAttempts: 0,
15796
15794
  completionSignaled: false,
15797
15795
  completionSignaledAt: 0,
15796
+ completionHard: false,
15797
+ latchReopened: false,
15798
15798
  emptyIdleNudged: false,
15799
15799
  todos: [],
15800
15800
  todosFetchedAt: 0,
@@ -15852,14 +15852,8 @@ var v2_default = define({
15852
15852
  const lifetimeMaxContinues = opts.lifetimeMaxContinues ?? base.lifetimeMaxContinues;
15853
15853
  return { ...base, chunkTimeoutMs, gracePeriodMs, maxRetries, lifetimeMaxContinues };
15854
15854
  }
15855
- let pausedCacheMtime = 0;
15856
- let pausedCacheSet = new Set;
15857
15855
  function readPausedSet() {
15858
15856
  try {
15859
- const st = statSync(PAUSED_MARKER_FILE);
15860
- if (st.mtimeMs === pausedCacheMtime)
15861
- return pausedCacheSet;
15862
- pausedCacheMtime = st.mtimeMs;
15863
15857
  const raw = readFileSync(PAUSED_MARKER_FILE, "utf8");
15864
15858
  const s = new Set;
15865
15859
  for (const line of raw.split(/\r?\n/)) {
@@ -15867,12 +15861,9 @@ var v2_default = define({
15867
15861
  if (t)
15868
15862
  s.add(t);
15869
15863
  }
15870
- pausedCacheSet = s;
15871
15864
  return s;
15872
15865
  } catch {
15873
- pausedCacheMtime = 0;
15874
- pausedCacheSet = new Set;
15875
- return pausedCacheSet;
15866
+ return new Set;
15876
15867
  }
15877
15868
  }
15878
15869
  function isPaused(sid) {
@@ -15892,7 +15883,6 @@ var v2_default = define({
15892
15883
  writeFileSync(PAUSED_MARKER_FILE, [...s].join(`
15893
15884
  `) + (s.size ? `
15894
15885
  ` : ""), "utf8");
15895
- pausedCacheMtime = 0;
15896
15886
  } catch (e) {
15897
15887
  dbg(`${short(sid)} pause write failed: ${e instanceof Error ? e.message : String(e)}`);
15898
15888
  }
@@ -15939,6 +15929,22 @@ var v2_default = define({
15939
15929
  }
15940
15930
  clearStalePermissionFlag(sid, w);
15941
15931
  }
15932
+ function softLatchExpired(sid, w, now) {
15933
+ if (!w.completionSignaled)
15934
+ return false;
15935
+ if (w.completionHard || w.latchReopened)
15936
+ return false;
15937
+ if (!w.completionSignaledAt)
15938
+ return false;
15939
+ if (now - w.completionSignaledAt < completionLatchTtlMs)
15940
+ return false;
15941
+ w.latchReopened = true;
15942
+ w.completionSignaled = false;
15943
+ w.completionSignaledAt = 0;
15944
+ w.pendingRecoveryArmed = true;
15945
+ log("warn", `${short(sid)} soft completion latch older than ${Math.round(completionLatchTtlMs / 1000)}s with no user message \u2014 re-opening once for a self-check`);
15946
+ return true;
15947
+ }
15942
15948
  function clearStalePermissionFlag(sid, w) {
15943
15949
  if (!w.permissionPending)
15944
15950
  return;
@@ -16006,6 +16012,36 @@ var v2_default = define({
16006
16012
  const cooldownMin = Math.max(1, Math.round(ladder[rung] / 60000));
16007
16013
  recover(sid, `rate-limit cooldown served (rung ${rung + 1}/${ladder.length}, ${cooldownMin}m)`);
16008
16014
  }
16015
+ function quotaGuard(sid, w, messages) {
16016
+ try {
16017
+ const last = messages.length ? messages[messages.length - 1] : null;
16018
+ let qtype = "";
16019
+ let qmsg = "";
16020
+ if (last && msgRole(last) === "assistant") {
16021
+ const qe = last.error ?? last.info?.error;
16022
+ if (typeof qe === "string")
16023
+ qmsg = qe;
16024
+ else if (qe && typeof qe === "object") {
16025
+ qtype = String(qe.name ?? qe.type ?? "");
16026
+ qmsg = String(qe.message ?? "");
16027
+ }
16028
+ const qtxt = lastAssistantTextFrom(messages);
16029
+ if (qtxt)
16030
+ qmsg = qmsg ? `${qmsg} ${qtxt}` : qtxt;
16031
+ }
16032
+ if (isRateLimitError(qtype, qmsg)) {
16033
+ handleRateLimitFailure(sid, w, qtype, qmsg);
16034
+ return true;
16035
+ }
16036
+ } catch (e) {
16037
+ dbg(`${short(sid)} quota guard failed: ${e instanceof Error ? e.message : String(e)}`);
16038
+ }
16039
+ if (w.awaitingQuotaRetry || w.rateLimitedAt > 0) {
16040
+ dbg(`${short(sid)} quota-armed \u2014 skipping stall recover`);
16041
+ return true;
16042
+ }
16043
+ return false;
16044
+ }
16009
16045
  function handleRateLimitFailure(sid, w, errType, errMsg) {
16010
16046
  if (isPaused(sid)) {
16011
16047
  dbg(`${short(sid)} paused \u2014 not arming quota ladder`);
@@ -16018,9 +16054,8 @@ var v2_default = define({
16018
16054
  if (n >= ladder.length) {
16019
16055
  w.awaitingQuotaRetry = false;
16020
16056
  log("warn", `${short(sid)} ${tag} budget exhausted (${ladder.length} attempts) \u2014 standing down until user turn`);
16021
- if (!w.quotaNoticedAt && Date.now() - w.lastQuotaNoticeAt > 100) {
16057
+ if (!w.quotaNoticedAt) {
16022
16058
  w.quotaNoticedAt = Date.now();
16023
- w.lastQuotaNoticeAt = Date.now();
16024
16059
  sendQuotaNotice(sid, `Rate limited \u2014 standing down until you return (budget exhausted after ${ladder.length} attempts).`);
16025
16060
  }
16026
16061
  return;
@@ -16038,9 +16073,8 @@ var v2_default = define({
16038
16073
  w.awaitingQuotaRetry = true;
16039
16074
  const waitMs = Math.max(0, ladder[n] - (now - w.rateLimitedAt));
16040
16075
  log("warn", `${short(sid)} rate limited (${errType || "error"}) \u2014 standing down, next attempt in ${Math.ceil(waitMs / 1000)}s (${n + 1}/${ladder.length})`);
16041
- if (!w.quotaNoticedAt && Date.now() - w.lastQuotaNoticeAt > 100) {
16076
+ if (!w.quotaNoticedAt) {
16042
16077
  w.quotaNoticedAt = Date.now();
16043
- w.lastQuotaNoticeAt = Date.now();
16044
16078
  const mins = Math.max(1, Math.round(waitMs / 60000));
16045
16079
  const when = mins >= 60 ? `~${Math.floor(mins / 60)}h ${mins % 60}m` : `~${mins}m`;
16046
16080
  sendQuotaNotice(sid, `Rate limited, retrying in ${when} (attempt ${n + 1}/${ladder.length}).`);
@@ -16520,13 +16554,12 @@ ${lines.join(`
16520
16554
  });
16521
16555
  const elapsed = w.lastActivityAt ? Math.round((Date.now() - w.lastActivityAt) / 1000) : 0;
16522
16556
  const hms = (n) => n < 60 ? `${n}s` : n < 3600 ? `${Math.floor(n / 60)}m ${n % 60}s` : `${Math.floor(n / 3600)}h ${Math.floor(n % 3600 / 60)}m`;
16523
- const isRateLimitReason = /rate-limit/i.test(reason);
16524
16557
  const isSilence = isSilenceReason(reason);
16525
16558
  const shown = providerFault ? attempt : attempt;
16526
16559
  const retries = prospectiveRetries ?? w.workRetries;
16527
16560
  const head = [
16561
+ /rate-limit/i.test(reason) ? null : `attempt ${Math.min(shown, maxRetries)}/${maxRetries} (retry #${retries})`,
16528
16562
  isSilence ? null : reason,
16529
- isRateLimitReason ? null : `attempt ${Math.min(shown, maxRetries)}/${maxRetries} (retry #${retries})`,
16530
16563
  isSilence ? `idle ${hms(elapsed)}` : null,
16531
16564
  now
16532
16565
  ].filter((x) => typeof x === "string" && x.length > 0).join("; ");
@@ -16607,7 +16640,7 @@ ${suffix}`).slice(0, 2000);
16607
16640
  }
16608
16641
  w.recovering = true;
16609
16642
  const attempt = providerFault ? Math.max(1, w.resumeAttempts) : w.resumeAttempts + 1;
16610
- const delay = backoffMs2(attempt, baseBackoff, maxBackoff);
16643
+ const delay = Math.min(baseBackoff * 2 ** (attempt - 1), maxBackoff);
16611
16644
  if (providerFault) {
16612
16645
  log("info", `${short(sid)} provider-fault stall (${reason}) \u2014 retry scheduled ${Math.round((Date.now() - w.providerRetryAt) / 1000)}s ago, holding the ladder at ${attempt}/${profileMaxRetries}`);
16613
16646
  }
@@ -17094,6 +17127,7 @@ ${suffix}`).slice(0, 2000);
17094
17127
  if (w.completionSignaled) {
17095
17128
  w.completionSignaled = false;
17096
17129
  w.completionSignaledAt = 0;
17130
+ w.latchReopened = false;
17097
17131
  dbg(`${short(sid)} genuine new user message \u2014 re-opening latched completion`);
17098
17132
  }
17099
17133
  if (w.resumeAttempts > 0) {
@@ -17379,6 +17413,7 @@ ${reminder}`;
17379
17413
  }
17380
17414
  w.completionSignaled = true;
17381
17415
  w.completionSignaledAt = Date.now();
17416
+ w.completionHard = true;
17382
17417
  log("info", `${short(sid)} task_complete called, ${await isSubAgentSession(sid) ? "subagent" : "agent"} done`);
17383
17418
  w.taskCompleteSignals++;
17384
17419
  if (w.taskCompleteSignals === 2)
@@ -17430,6 +17465,7 @@ ${reminder}`;
17430
17465
  }
17431
17466
  w.completionSignaled = true;
17432
17467
  w.completionSignaledAt = Date.now();
17468
+ w.completionHard = true;
17433
17469
  w.taskCompleteSignals = 0;
17434
17470
  w.taskCompleteOverrides = 0;
17435
17471
  log("info", `${short(sid)} task_complete called, ${await isSubAgentSession(sid) ? "subagent" : "agent"} done`);
@@ -17468,6 +17504,8 @@ ${reminder}`;
17468
17504
  if (open.length === 0 && !w.completionSignaled) {
17469
17505
  w.completionSignaled = true;
17470
17506
  w.completionSignaledAt = Date.now();
17507
+ w.completionHard = /\btask\s+complete(d)?\b[.!]*$/i.test(stripBoundaryMarkers(tailText).split(`
17508
+ `).map((l) => l.trim()).filter((l) => l.length > 0).pop() ?? "");
17471
17509
  log("info", `${short(sid)} turn ends with a completion claim inside the activity window \u2014 latching completion, standing down`);
17472
17510
  }
17473
17511
  } else {
@@ -17506,6 +17544,7 @@ ${reminder}`;
17506
17544
  if (!w.completionSignaled) {
17507
17545
  w.completionSignaled = true;
17508
17546
  w.completionSignaledAt = Date.now();
17547
+ w.completionHard = false;
17509
17548
  log("info", `${short(sid)} turn ends with a celebration and no open todos \u2014 latching completion, not nudging`);
17510
17549
  } else {
17511
17550
  dbg(`${short(sid)} completion already latched \u2014 skipping`);
@@ -17538,6 +17577,8 @@ ${reminder}`;
17538
17577
  if (!w.completionSignaled) {
17539
17578
  w.completionSignaled = true;
17540
17579
  w.completionSignaledAt = Date.now();
17580
+ w.completionHard = /\btask\s+complete(d)?\b[.!]*$/i.test(stripBoundaryMarkers(text).split(`
17581
+ `).map((l) => l.trim()).filter((l) => l.length > 0).pop() ?? "");
17541
17582
  log("info", `${short(sid)} turn ends with an explicit stop and no open todos \u2014 latching completion, not nudging`);
17542
17583
  } else {
17543
17584
  dbg(`${short(sid)} completion already latched \u2014 skipping`);
@@ -17884,7 +17925,10 @@ ${reminder}`;
17884
17925
  if (w.lastGenuineUserAt > w.completionSignaledAt) {
17885
17926
  w.completionSignaled = false;
17886
17927
  w.completionSignaledAt = 0;
17928
+ w.latchReopened = false;
17887
17929
  dbg(`${short(sid)} genuine new user message after latched completion \u2014 re-opening stall detection`);
17930
+ } else if (softLatchExpired(sid, w, now)) {
17931
+ dbg(`${short(sid)} soft completion latch expired \u2014 re-opening for one self-check`);
17888
17932
  } else {
17889
17933
  dbg(`${short(sid)} completion latched \u2014 skipping stall detection`);
17890
17934
  }
@@ -17904,33 +17948,8 @@ ${reminder}`;
17904
17948
  const baseInterval = (profileChunkTimeoutMs + profileGracePeriodMs) * fuseMult;
17905
17949
  const jitter = baseInterval > 60000 ? Math.floor(Math.random() * STALL_JITTER_MS * 2) - STALL_JITTER_MS : 0;
17906
17950
  if (silence >= baseInterval + jitter) {
17907
- try {
17908
- const _qlast2 = history.length ? history[history.length - 1] : null;
17909
- let _qtype2 = "";
17910
- let _qmsg2 = "";
17911
- if (_qlast2 && msgRole(_qlast2) === "assistant") {
17912
- const _qe2 = _qlast2.error ?? _qlast2.info?.error;
17913
- if (typeof _qe2 === "string")
17914
- _qmsg2 = _qe2;
17915
- else if (_qe2 && typeof _qe2 === "object") {
17916
- _qtype2 = String(_qe2.name ?? _qe2.type ?? "");
17917
- _qmsg2 = String(_qe2.message ?? "");
17918
- }
17919
- const _qtxt2 = lastAssistantTextFrom(history);
17920
- if (_qtxt2)
17921
- _qmsg2 = _qmsg2 ? `${_qmsg2} ${_qtxt2}` : _qtxt2;
17922
- }
17923
- if (isRateLimitError(_qtype2, _qmsg2)) {
17924
- handleRateLimitFailure(sid, w, _qtype2, _qmsg2);
17925
- continue;
17926
- }
17927
- } catch (e) {
17928
- dbg(`${short(sid)} quota guard (idle) failed: ${e instanceof Error ? e.message : String(e)}`);
17929
- }
17930
- if (w.awaitingQuotaRetry || w.rateLimitedAt > 0) {
17931
- dbg(`${short(sid)} quota-armed \u2014 skipping stall recover`);
17951
+ if (quotaGuard(sid, w, history))
17932
17952
  continue;
17933
- }
17934
17953
  await recover(sid, `no activity for ${Math.ceil(silence / 1000)}s`);
17935
17954
  }
17936
17955
  }
@@ -17954,7 +17973,10 @@ ${reminder}`;
17954
17973
  if (w.lastGenuineUserAt > w.completionSignaledAt) {
17955
17974
  w.completionSignaled = false;
17956
17975
  w.completionSignaledAt = 0;
17976
+ w.latchReopened = false;
17957
17977
  dbg(`${short(sid)} genuine new user message after latched completion \u2014 re-opening stall detection`);
17978
+ } else if (softLatchExpired(sid, w, now)) {
17979
+ dbg(`${short(sid)} soft completion latch expired \u2014 re-opening for one self-check`);
17958
17980
  } else {
17959
17981
  dbg(`${short(sid)} completion latched \u2014 skipping stall detection`);
17960
17982
  continue;
@@ -18051,34 +18073,9 @@ ${reminder}`;
18051
18073
  log("info", `${short(sid)} stall (busyStallStrategy=abort): aborting before continue`);
18052
18074
  await tryAbortAndResume(sid, w, true);
18053
18075
  } else {
18054
- try {
18055
- const _qm = await loadMessages(sid);
18056
- const _qlast = _qm.length ? _qm[_qm.length - 1] : null;
18057
- let _qtype = "";
18058
- let _qmsg = "";
18059
- if (_qlast && msgRole(_qlast) === "assistant") {
18060
- const _qe = _qlast.error ?? _qlast.info?.error;
18061
- if (typeof _qe === "string")
18062
- _qmsg = _qe;
18063
- else if (_qe && typeof _qe === "object") {
18064
- _qtype = String(_qe.name ?? _qe.type ?? "");
18065
- _qmsg = String(_qe.message ?? "");
18066
- }
18067
- const _qtxt = lastAssistantTextFrom(_qm);
18068
- if (_qtxt)
18069
- _qmsg = _qmsg ? `${_qmsg} ${_qtxt}` : _qtxt;
18070
- }
18071
- if (isRateLimitError(_qtype, _qmsg)) {
18072
- handleRateLimitFailure(sid, w, _qtype, _qmsg);
18073
- continue;
18074
- }
18075
- } catch (e) {
18076
- dbg(`${short(sid)} quota guard failed: ${e instanceof Error ? e.message : String(e)}`);
18077
- }
18078
- if (w.awaitingQuotaRetry || w.rateLimitedAt > 0) {
18079
- dbg(`${short(sid)} quota-armed \u2014 skipping stall recover`);
18076
+ const _qm = await loadMessages(sid);
18077
+ if (quotaGuard(sid, w, _qm))
18080
18078
  continue;
18081
- }
18082
18079
  await recover(sid, `no activity for ${Math.ceil(silence / 1000)}s`);
18083
18080
  }
18084
18081
  }
@@ -18415,11 +18412,21 @@ ${reminder}`;
18415
18412
  let ownRung = !providerRetryFresh && selfInjectFresh;
18416
18413
  if (ownRung) {
18417
18414
  try {
18418
- for (const m of await loadMessages(sid)) {
18419
- if (msgRole(m) === "user" && msgAt(m) > w.lastInjectAt) {
18420
- ownRung = false;
18421
- break;
18422
- }
18415
+ const hist = await loadMessages(sid);
18416
+ for (const m of hist) {
18417
+ if (msgRole(m) !== "user")
18418
+ continue;
18419
+ if (msgAt(m) <= w.lastInjectAt)
18420
+ continue;
18421
+ const text = messageTextOf(m);
18422
+ const ownByText = typeof text === "string" && text.length > 0 && w.ownPromptTexts.includes(text);
18423
+ const ownByShape = looksLikeOwnPrompt(text);
18424
+ const ownByTime = msgAt(m) >= w.lastInjectAt && msgAt(m) - w.lastInjectAt < OWN_PROMPT_TIME_MS;
18425
+ const ownByTimeOnly = ownByTime && !(typeof text === "string" && text.length > 0);
18426
+ if (ownByText || ownByShape || ownByTimeOnly)
18427
+ continue;
18428
+ ownRung = false;
18429
+ break;
18423
18430
  }
18424
18431
  } catch {
18425
18432
  ownRung = false;
package/dist/v2/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // @bun
2
2
  // src/v2/index.ts
3
- import { readFileSync, mkdirSync, statSync, writeFileSync } from "fs";
3
+ import { readFileSync, mkdirSync, writeFileSync } from "fs";
4
4
  import { dirname, join } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { readFile } from "fs/promises";
@@ -106,7 +106,6 @@ var DONE_CLAIM_PATTERN_SOURCES = [
106
106
  "\\b(?:all|everything)\\s+(?:[\\w-]+\\s+)?(?:is\\s+)?(?:complete|done|finished)",
107
107
  "\\bnothing\\s+(?:else\\s+)?(?:left|remaining|to do)"
108
108
  ];
109
- var DEFAULT_DONE_CLAIM_PATTERNS = [...DONE_CLAIM_PATTERN_SOURCES];
110
109
  var DEFAULT_READY_TO_CONTINUE_PATTERNS = [
111
110
  "ready to continue with task",
112
111
  "continuing with task",
@@ -144,7 +143,8 @@ var DEFAULT_INJECT_INTERVAL_MS = 15000;
144
143
  var OWN_PROMPT_TIME_MS = 30000;
145
144
  var MAX_IDLE_SESSIONS = 50;
146
145
  var IDLE_CLEANUP_MS = 10 * 60000;
147
- var COMPACTION_STALE_TTL_MS = 30 * 60000;
146
+ var COMPACTION_STALE_TTL_MS = 5 * 60000;
147
+ var COMPLETION_LATCH_TTL_MS = 5 * 60000;
148
148
  var PERMISSION_STALE_TTL_MS = 30 * 60000;
149
149
  var NEVER_INTERRUPT_STALE_TTL_MS = 30 * 60000;
150
150
  var TEXT_BUFFER_TRIM_LEN = 20000;
@@ -478,9 +478,6 @@ function isUserHandoff(text) {
478
478
  ];
479
479
  return patterns.some((p) => p.test(last));
480
480
  }
481
- function backoffMs(attempt, base, max) {
482
- return Math.min(base * Math.pow(2, attempt - 1), max);
483
- }
484
481
  function isSilenceReason(reason) {
485
482
  return /^no activity for \d+s/i.test(reason);
486
483
  }
@@ -671,6 +668,7 @@ var v2_default = define({
671
668
  const checkIntervalMs = opts.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS;
672
669
  const gracePeriodMs = opts.gracePeriodMs ?? DEFAULT_GRACE_PERIOD_MS;
673
670
  const stuckToolAgeMs = opts.stuckToolAgeMs ?? DEFAULT_STUCK_TOOL_AGE_MS;
671
+ const completionLatchTtlMs = opts.completionLatchTtlMs ?? COMPLETION_LATCH_TTL_MS;
674
672
  const neverInterruptTools = (opts.neverInterruptTools ?? ["compress"]).map((n) => String(n).toLowerCase());
675
673
  const maxRetries = opts.maxRetries ?? opts.maxRecoveryRetries ?? DEFAULT_MAX_RETRIES;
676
674
  const baseBackoff = opts.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS;
@@ -716,12 +714,13 @@ var v2_default = define({
716
714
  }
717
715
  return out;
718
716
  }
719
- const doneClaimPatterns = compilePatterns(opts.doneClaimPatterns, DEFAULT_DONE_CLAIM_PATTERNS, "im");
717
+ const doneClaimPatterns = compilePatterns(opts.doneClaimPatterns, DONE_CLAIM_PATTERN_SOURCES, "im");
720
718
  const readyToContinuePatterns = compilePatterns(opts.readyToContinuePatterns, DEFAULT_READY_TO_CONTINUE_PATTERNS, "i");
721
719
  const streamingFailureMessageRegexes = compilePatterns(streamingFailureMessagePatterns, DEFAULT_STREAMING_FAILURE_MESSAGE_PATTERNS, "i");
722
720
  const FEATURE_GATED_OPTIONS = [];
723
721
  const RECOGNISED_OPTIONS = new Set([
724
722
  "chunkTimeoutMs",
723
+ "completionLatchTtlMs",
725
724
  "checkIntervalMs",
726
725
  "gracePeriodMs",
727
726
  "maxRetries",
@@ -850,7 +849,6 @@ var v2_default = define({
850
849
  providerRetryAt: 0,
851
850
  quotaNoticedAt: 0,
852
851
  gaveUp: false,
853
- lastQuotaNoticeAt: 0,
854
852
  paused: false,
855
853
  lastGenuineUserAt: 0,
856
854
  consecFailureRecovers: 0,
@@ -884,6 +882,8 @@ var v2_default = define({
884
882
  intentNudgeAttempts: 0,
885
883
  completionSignaled: false,
886
884
  completionSignaledAt: 0,
885
+ completionHard: false,
886
+ latchReopened: false,
887
887
  emptyIdleNudged: false,
888
888
  todos: [],
889
889
  todosFetchedAt: 0,
@@ -941,14 +941,8 @@ var v2_default = define({
941
941
  const lifetimeMaxContinues = opts.lifetimeMaxContinues ?? base.lifetimeMaxContinues;
942
942
  return { ...base, chunkTimeoutMs, gracePeriodMs, maxRetries, lifetimeMaxContinues };
943
943
  }
944
- let pausedCacheMtime = 0;
945
- let pausedCacheSet = new Set;
946
944
  function readPausedSet() {
947
945
  try {
948
- const st = statSync(PAUSED_MARKER_FILE);
949
- if (st.mtimeMs === pausedCacheMtime)
950
- return pausedCacheSet;
951
- pausedCacheMtime = st.mtimeMs;
952
946
  const raw = readFileSync(PAUSED_MARKER_FILE, "utf8");
953
947
  const s = new Set;
954
948
  for (const line of raw.split(/\r?\n/)) {
@@ -956,12 +950,9 @@ var v2_default = define({
956
950
  if (t)
957
951
  s.add(t);
958
952
  }
959
- pausedCacheSet = s;
960
953
  return s;
961
954
  } catch {
962
- pausedCacheMtime = 0;
963
- pausedCacheSet = new Set;
964
- return pausedCacheSet;
955
+ return new Set;
965
956
  }
966
957
  }
967
958
  function isPaused(sid) {
@@ -981,7 +972,6 @@ var v2_default = define({
981
972
  writeFileSync(PAUSED_MARKER_FILE, [...s].join(`
982
973
  `) + (s.size ? `
983
974
  ` : ""), "utf8");
984
- pausedCacheMtime = 0;
985
975
  } catch (e) {
986
976
  dbg(`${short(sid)} pause write failed: ${e instanceof Error ? e.message : String(e)}`);
987
977
  }
@@ -1028,6 +1018,22 @@ var v2_default = define({
1028
1018
  }
1029
1019
  clearStalePermissionFlag(sid, w);
1030
1020
  }
1021
+ function softLatchExpired(sid, w, now) {
1022
+ if (!w.completionSignaled)
1023
+ return false;
1024
+ if (w.completionHard || w.latchReopened)
1025
+ return false;
1026
+ if (!w.completionSignaledAt)
1027
+ return false;
1028
+ if (now - w.completionSignaledAt < completionLatchTtlMs)
1029
+ return false;
1030
+ w.latchReopened = true;
1031
+ w.completionSignaled = false;
1032
+ w.completionSignaledAt = 0;
1033
+ w.pendingRecoveryArmed = true;
1034
+ log("warn", `${short(sid)} soft completion latch older than ${Math.round(completionLatchTtlMs / 1000)}s with no user message \u2014 re-opening once for a self-check`);
1035
+ return true;
1036
+ }
1031
1037
  function clearStalePermissionFlag(sid, w) {
1032
1038
  if (!w.permissionPending)
1033
1039
  return;
@@ -1095,6 +1101,36 @@ var v2_default = define({
1095
1101
  const cooldownMin = Math.max(1, Math.round(ladder[rung] / 60000));
1096
1102
  recover(sid, `rate-limit cooldown served (rung ${rung + 1}/${ladder.length}, ${cooldownMin}m)`);
1097
1103
  }
1104
+ function quotaGuard(sid, w, messages) {
1105
+ try {
1106
+ const last = messages.length ? messages[messages.length - 1] : null;
1107
+ let qtype = "";
1108
+ let qmsg = "";
1109
+ if (last && msgRole(last) === "assistant") {
1110
+ const qe = last.error ?? last.info?.error;
1111
+ if (typeof qe === "string")
1112
+ qmsg = qe;
1113
+ else if (qe && typeof qe === "object") {
1114
+ qtype = String(qe.name ?? qe.type ?? "");
1115
+ qmsg = String(qe.message ?? "");
1116
+ }
1117
+ const qtxt = lastAssistantTextFrom(messages);
1118
+ if (qtxt)
1119
+ qmsg = qmsg ? `${qmsg} ${qtxt}` : qtxt;
1120
+ }
1121
+ if (isRateLimitError(qtype, qmsg)) {
1122
+ handleRateLimitFailure(sid, w, qtype, qmsg);
1123
+ return true;
1124
+ }
1125
+ } catch (e) {
1126
+ dbg(`${short(sid)} quota guard failed: ${e instanceof Error ? e.message : String(e)}`);
1127
+ }
1128
+ if (w.awaitingQuotaRetry || w.rateLimitedAt > 0) {
1129
+ dbg(`${short(sid)} quota-armed \u2014 skipping stall recover`);
1130
+ return true;
1131
+ }
1132
+ return false;
1133
+ }
1098
1134
  function handleRateLimitFailure(sid, w, errType, errMsg) {
1099
1135
  if (isPaused(sid)) {
1100
1136
  dbg(`${short(sid)} paused \u2014 not arming quota ladder`);
@@ -1107,9 +1143,8 @@ var v2_default = define({
1107
1143
  if (n >= ladder.length) {
1108
1144
  w.awaitingQuotaRetry = false;
1109
1145
  log("warn", `${short(sid)} ${tag} budget exhausted (${ladder.length} attempts) \u2014 standing down until user turn`);
1110
- if (!w.quotaNoticedAt && Date.now() - w.lastQuotaNoticeAt > 100) {
1146
+ if (!w.quotaNoticedAt) {
1111
1147
  w.quotaNoticedAt = Date.now();
1112
- w.lastQuotaNoticeAt = Date.now();
1113
1148
  sendQuotaNotice(sid, `Rate limited \u2014 standing down until you return (budget exhausted after ${ladder.length} attempts).`);
1114
1149
  }
1115
1150
  return;
@@ -1127,9 +1162,8 @@ var v2_default = define({
1127
1162
  w.awaitingQuotaRetry = true;
1128
1163
  const waitMs = Math.max(0, ladder[n] - (now - w.rateLimitedAt));
1129
1164
  log("warn", `${short(sid)} rate limited (${errType || "error"}) \u2014 standing down, next attempt in ${Math.ceil(waitMs / 1000)}s (${n + 1}/${ladder.length})`);
1130
- if (!w.quotaNoticedAt && Date.now() - w.lastQuotaNoticeAt > 100) {
1165
+ if (!w.quotaNoticedAt) {
1131
1166
  w.quotaNoticedAt = Date.now();
1132
- w.lastQuotaNoticeAt = Date.now();
1133
1167
  const mins = Math.max(1, Math.round(waitMs / 60000));
1134
1168
  const when = mins >= 60 ? `~${Math.floor(mins / 60)}h ${mins % 60}m` : `~${mins}m`;
1135
1169
  sendQuotaNotice(sid, `Rate limited, retrying in ${when} (attempt ${n + 1}/${ladder.length}).`);
@@ -1609,13 +1643,12 @@ ${lines.join(`
1609
1643
  });
1610
1644
  const elapsed = w.lastActivityAt ? Math.round((Date.now() - w.lastActivityAt) / 1000) : 0;
1611
1645
  const hms = (n) => n < 60 ? `${n}s` : n < 3600 ? `${Math.floor(n / 60)}m ${n % 60}s` : `${Math.floor(n / 3600)}h ${Math.floor(n % 3600 / 60)}m`;
1612
- const isRateLimitReason = /rate-limit/i.test(reason);
1613
1646
  const isSilence = isSilenceReason(reason);
1614
1647
  const shown = providerFault ? attempt : attempt;
1615
1648
  const retries = prospectiveRetries ?? w.workRetries;
1616
1649
  const head = [
1650
+ /rate-limit/i.test(reason) ? null : `attempt ${Math.min(shown, maxRetries)}/${maxRetries} (retry #${retries})`,
1617
1651
  isSilence ? null : reason,
1618
- isRateLimitReason ? null : `attempt ${Math.min(shown, maxRetries)}/${maxRetries} (retry #${retries})`,
1619
1652
  isSilence ? `idle ${hms(elapsed)}` : null,
1620
1653
  now
1621
1654
  ].filter((x) => typeof x === "string" && x.length > 0).join("; ");
@@ -1696,7 +1729,7 @@ ${suffix}`).slice(0, 2000);
1696
1729
  }
1697
1730
  w.recovering = true;
1698
1731
  const attempt = providerFault ? Math.max(1, w.resumeAttempts) : w.resumeAttempts + 1;
1699
- const delay = backoffMs(attempt, baseBackoff, maxBackoff);
1732
+ const delay = Math.min(baseBackoff * 2 ** (attempt - 1), maxBackoff);
1700
1733
  if (providerFault) {
1701
1734
  log("info", `${short(sid)} provider-fault stall (${reason}) \u2014 retry scheduled ${Math.round((Date.now() - w.providerRetryAt) / 1000)}s ago, holding the ladder at ${attempt}/${profileMaxRetries}`);
1702
1735
  }
@@ -2183,6 +2216,7 @@ ${suffix}`).slice(0, 2000);
2183
2216
  if (w.completionSignaled) {
2184
2217
  w.completionSignaled = false;
2185
2218
  w.completionSignaledAt = 0;
2219
+ w.latchReopened = false;
2186
2220
  dbg(`${short(sid)} genuine new user message \u2014 re-opening latched completion`);
2187
2221
  }
2188
2222
  if (w.resumeAttempts > 0) {
@@ -2468,6 +2502,7 @@ ${reminder}`;
2468
2502
  }
2469
2503
  w.completionSignaled = true;
2470
2504
  w.completionSignaledAt = Date.now();
2505
+ w.completionHard = true;
2471
2506
  log("info", `${short(sid)} task_complete called, ${await isSubAgentSession(sid) ? "subagent" : "agent"} done`);
2472
2507
  w.taskCompleteSignals++;
2473
2508
  if (w.taskCompleteSignals === 2)
@@ -2519,6 +2554,7 @@ ${reminder}`;
2519
2554
  }
2520
2555
  w.completionSignaled = true;
2521
2556
  w.completionSignaledAt = Date.now();
2557
+ w.completionHard = true;
2522
2558
  w.taskCompleteSignals = 0;
2523
2559
  w.taskCompleteOverrides = 0;
2524
2560
  log("info", `${short(sid)} task_complete called, ${await isSubAgentSession(sid) ? "subagent" : "agent"} done`);
@@ -2557,6 +2593,8 @@ ${reminder}`;
2557
2593
  if (open.length === 0 && !w.completionSignaled) {
2558
2594
  w.completionSignaled = true;
2559
2595
  w.completionSignaledAt = Date.now();
2596
+ w.completionHard = /\btask\s+complete(d)?\b[.!]*$/i.test(stripBoundaryMarkers(tailText).split(`
2597
+ `).map((l) => l.trim()).filter((l) => l.length > 0).pop() ?? "");
2560
2598
  log("info", `${short(sid)} turn ends with a completion claim inside the activity window \u2014 latching completion, standing down`);
2561
2599
  }
2562
2600
  } else {
@@ -2595,6 +2633,7 @@ ${reminder}`;
2595
2633
  if (!w.completionSignaled) {
2596
2634
  w.completionSignaled = true;
2597
2635
  w.completionSignaledAt = Date.now();
2636
+ w.completionHard = false;
2598
2637
  log("info", `${short(sid)} turn ends with a celebration and no open todos \u2014 latching completion, not nudging`);
2599
2638
  } else {
2600
2639
  dbg(`${short(sid)} completion already latched \u2014 skipping`);
@@ -2627,6 +2666,8 @@ ${reminder}`;
2627
2666
  if (!w.completionSignaled) {
2628
2667
  w.completionSignaled = true;
2629
2668
  w.completionSignaledAt = Date.now();
2669
+ w.completionHard = /\btask\s+complete(d)?\b[.!]*$/i.test(stripBoundaryMarkers(text).split(`
2670
+ `).map((l) => l.trim()).filter((l) => l.length > 0).pop() ?? "");
2630
2671
  log("info", `${short(sid)} turn ends with an explicit stop and no open todos \u2014 latching completion, not nudging`);
2631
2672
  } else {
2632
2673
  dbg(`${short(sid)} completion already latched \u2014 skipping`);
@@ -2973,7 +3014,10 @@ ${reminder}`;
2973
3014
  if (w.lastGenuineUserAt > w.completionSignaledAt) {
2974
3015
  w.completionSignaled = false;
2975
3016
  w.completionSignaledAt = 0;
3017
+ w.latchReopened = false;
2976
3018
  dbg(`${short(sid)} genuine new user message after latched completion \u2014 re-opening stall detection`);
3019
+ } else if (softLatchExpired(sid, w, now)) {
3020
+ dbg(`${short(sid)} soft completion latch expired \u2014 re-opening for one self-check`);
2977
3021
  } else {
2978
3022
  dbg(`${short(sid)} completion latched \u2014 skipping stall detection`);
2979
3023
  }
@@ -2993,33 +3037,8 @@ ${reminder}`;
2993
3037
  const baseInterval = (profileChunkTimeoutMs + profileGracePeriodMs) * fuseMult;
2994
3038
  const jitter = baseInterval > 60000 ? Math.floor(Math.random() * STALL_JITTER_MS * 2) - STALL_JITTER_MS : 0;
2995
3039
  if (silence >= baseInterval + jitter) {
2996
- try {
2997
- const _qlast2 = history.length ? history[history.length - 1] : null;
2998
- let _qtype2 = "";
2999
- let _qmsg2 = "";
3000
- if (_qlast2 && msgRole(_qlast2) === "assistant") {
3001
- const _qe2 = _qlast2.error ?? _qlast2.info?.error;
3002
- if (typeof _qe2 === "string")
3003
- _qmsg2 = _qe2;
3004
- else if (_qe2 && typeof _qe2 === "object") {
3005
- _qtype2 = String(_qe2.name ?? _qe2.type ?? "");
3006
- _qmsg2 = String(_qe2.message ?? "");
3007
- }
3008
- const _qtxt2 = lastAssistantTextFrom(history);
3009
- if (_qtxt2)
3010
- _qmsg2 = _qmsg2 ? `${_qmsg2} ${_qtxt2}` : _qtxt2;
3011
- }
3012
- if (isRateLimitError(_qtype2, _qmsg2)) {
3013
- handleRateLimitFailure(sid, w, _qtype2, _qmsg2);
3014
- continue;
3015
- }
3016
- } catch (e) {
3017
- dbg(`${short(sid)} quota guard (idle) failed: ${e instanceof Error ? e.message : String(e)}`);
3018
- }
3019
- if (w.awaitingQuotaRetry || w.rateLimitedAt > 0) {
3020
- dbg(`${short(sid)} quota-armed \u2014 skipping stall recover`);
3040
+ if (quotaGuard(sid, w, history))
3021
3041
  continue;
3022
- }
3023
3042
  await recover(sid, `no activity for ${Math.ceil(silence / 1000)}s`);
3024
3043
  }
3025
3044
  }
@@ -3043,7 +3062,10 @@ ${reminder}`;
3043
3062
  if (w.lastGenuineUserAt > w.completionSignaledAt) {
3044
3063
  w.completionSignaled = false;
3045
3064
  w.completionSignaledAt = 0;
3065
+ w.latchReopened = false;
3046
3066
  dbg(`${short(sid)} genuine new user message after latched completion \u2014 re-opening stall detection`);
3067
+ } else if (softLatchExpired(sid, w, now)) {
3068
+ dbg(`${short(sid)} soft completion latch expired \u2014 re-opening for one self-check`);
3047
3069
  } else {
3048
3070
  dbg(`${short(sid)} completion latched \u2014 skipping stall detection`);
3049
3071
  continue;
@@ -3140,34 +3162,9 @@ ${reminder}`;
3140
3162
  log("info", `${short(sid)} stall (busyStallStrategy=abort): aborting before continue`);
3141
3163
  await tryAbortAndResume(sid, w, true);
3142
3164
  } else {
3143
- try {
3144
- const _qm = await loadMessages(sid);
3145
- const _qlast = _qm.length ? _qm[_qm.length - 1] : null;
3146
- let _qtype = "";
3147
- let _qmsg = "";
3148
- if (_qlast && msgRole(_qlast) === "assistant") {
3149
- const _qe = _qlast.error ?? _qlast.info?.error;
3150
- if (typeof _qe === "string")
3151
- _qmsg = _qe;
3152
- else if (_qe && typeof _qe === "object") {
3153
- _qtype = String(_qe.name ?? _qe.type ?? "");
3154
- _qmsg = String(_qe.message ?? "");
3155
- }
3156
- const _qtxt = lastAssistantTextFrom(_qm);
3157
- if (_qtxt)
3158
- _qmsg = _qmsg ? `${_qmsg} ${_qtxt}` : _qtxt;
3159
- }
3160
- if (isRateLimitError(_qtype, _qmsg)) {
3161
- handleRateLimitFailure(sid, w, _qtype, _qmsg);
3162
- continue;
3163
- }
3164
- } catch (e) {
3165
- dbg(`${short(sid)} quota guard failed: ${e instanceof Error ? e.message : String(e)}`);
3166
- }
3167
- if (w.awaitingQuotaRetry || w.rateLimitedAt > 0) {
3168
- dbg(`${short(sid)} quota-armed \u2014 skipping stall recover`);
3165
+ const _qm = await loadMessages(sid);
3166
+ if (quotaGuard(sid, w, _qm))
3169
3167
  continue;
3170
- }
3171
3168
  await recover(sid, `no activity for ${Math.ceil(silence / 1000)}s`);
3172
3169
  }
3173
3170
  }
@@ -3504,11 +3501,21 @@ ${reminder}`;
3504
3501
  let ownRung = !providerRetryFresh && selfInjectFresh;
3505
3502
  if (ownRung) {
3506
3503
  try {
3507
- for (const m of await loadMessages(sid)) {
3508
- if (msgRole(m) === "user" && msgAt(m) > w.lastInjectAt) {
3509
- ownRung = false;
3510
- break;
3511
- }
3504
+ const hist = await loadMessages(sid);
3505
+ for (const m of hist) {
3506
+ if (msgRole(m) !== "user")
3507
+ continue;
3508
+ if (msgAt(m) <= w.lastInjectAt)
3509
+ continue;
3510
+ const text = messageTextOf(m);
3511
+ const ownByText = typeof text === "string" && text.length > 0 && w.ownPromptTexts.includes(text);
3512
+ const ownByShape = looksLikeOwnPrompt(text);
3513
+ const ownByTime = msgAt(m) >= w.lastInjectAt && msgAt(m) - w.lastInjectAt < OWN_PROMPT_TIME_MS;
3514
+ const ownByTimeOnly = ownByTime && !(typeof text === "string" && text.length > 0);
3515
+ if (ownByText || ownByShape || ownByTimeOnly)
3516
+ continue;
3517
+ ownRung = false;
3518
+ break;
3512
3519
  }
3513
3520
  } catch {
3514
3521
  ownRung = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auto-resume",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "OpenCode plugin that automatically resumes stalled LLM sessions when thinking/streaming freezes mid-generation.",
5
5
  "keywords": [
6
6
  "opencode",