opencode-auto-resume 1.1.19 → 1.1.21

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 (3) hide show
  1. package/README.md +11 -8
  2. package/dist/index.js +74 -14
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -10,7 +10,7 @@ LLM sessions fail in predictable ways. This plugin monitors all sessions and aut
10
10
 
11
11
  ### Stall recovery
12
12
 
13
- The stream goes silent but the session stays "busy". The UI shows a blinking cursor with no progress. If no events arrive for 48 seconds (`chunkTimeoutMs` + `gracePeriodMs`), the plugin sends `"continue"` with exponential backoff. After 3 failed attempts it gives up.
13
+ The stream goes silent but the session stays "busy". The UI shows a blinking cursor with no progress. If no events arrive for ~3 minutes (`chunkTimeoutMs` + `gracePeriodMs`), the plugin sends `"continue"` with exponential backoff. After 3 failed attempts it gives up.
14
14
 
15
15
  The `busyStallStrategy` option controls this path: `"continue"` (default), `"abort"` (abort-first), or `"off"` — see [Recovery model](#recovery-model) for what a busy prompt can and cannot do while the runner is live.
16
16
 
@@ -247,10 +247,11 @@ If the assistant claims the task is done ("task done", "finished", "all complete
247
247
 
248
248
  ### User-input awareness
249
249
 
250
- The session is not stalled while the ball is in the user's court. Two gates stand down idle nudges:
250
+ The session is not stalled while the ball is in the user's court. Three gates stand down idle nudges:
251
251
 
252
252
  - **Awaiting input**: the newest assistant message holds a `tool_use` part with `state.status: "pending"` (e.g. an open `question` tool call). All idle checks, the periodic recheck, delayed action-intent callbacks, and the tool-text scan skip prompting until a newer user message clears the gate. Completed tool calls never engage it.
253
- - **Recently active user**: any inbound user message within `activeUserWindowMs` (default 15 minutes) means the user is engaged — likely composing a reply, which leaves no pending tool call behind. Open-todos nudges (idle + periodic), the tool-text reminder fallback, and action-intent callbacks stand down until the window expires.
253
+ - **Recently active user**: any inbound user message within `activeUserWindowMs` (default 5 minutes) means the user is engaged — likely composing a reply, which leaves no pending tool call behind. Open-todos nudges (idle + periodic), the tool-text reminder fallback, and action-intent callbacks stand down until the window expires.
254
+ - **Explicit stop line** (opt-in): when the newest assistant message's text matches one of `stopPatterns`, the agent has deliberately handed the ball to the user — typical for agent setups whose prompts end a turn with a protocol line such as `STOP: NEEDS_DECISION <question>` or `STOP: BLOCKED <reason>`, while the todo for that decision is still open. It counts exactly like the awaiting-input gate. Default: no patterns (unchanged behaviour).
254
255
 
255
256
  ---
256
257
 
@@ -367,7 +368,7 @@ With options:
367
368
  {
368
369
  "plugin": [
369
370
  ["opencode-auto-resume", {
370
- "chunkTimeoutMs": 45000,
371
+ "chunkTimeoutMs": 180000,
371
372
  "gracePeriodMs": 3000,
372
373
  "maxRetries": 3
373
374
  }]
@@ -382,7 +383,7 @@ With options:
382
383
  "plugin": [
383
384
  [
384
385
  "file:///home/YOURUSER/.config/opencode/plugins/opencode-auto-resume/dist/index.js",
385
- { "chunkTimeoutMs": 45000, "maxRetries": 3 }
386
+ { "chunkTimeoutMs": 180000, "maxRetries": 3 }
386
387
  ]
387
388
  ]
388
389
  }
@@ -392,7 +393,7 @@ With options:
392
393
 
393
394
  | Option | Default | Description |
394
395
  |---|---|---|
395
- | `chunkTimeoutMs` | `45000` | Inactivity timeout before considering stream stalled |
396
+ | `chunkTimeoutMs` | `180000` | Inactivity timeout before considering stream stalled |
396
397
  | `gracePeriodMs` | `3000` | Extra wait before acting (lets ESC/status events arrive) |
397
398
  | `checkIntervalMs` | `5000` | Timer poll interval |
398
399
  | `maxRetries` | `3` | Max auto-resume attempts before giving up |
@@ -407,6 +408,7 @@ With options:
407
408
  | `toolTextCheckDelayMs` | `3000` | Delay before scanning an idle session for tool-as-text; also the recovery watchdog delay |
408
409
  | `minActivityGapMs` | `1000` | Skip recovery if the session was active within this gap |
409
410
  | `warmupMs` | `15000` | Action-intent detection disabled while a session is younger than this |
411
+ | `discoveryDelayMs` | `5000` | Delay before the initial session discovery run after plugin attach |
410
412
  | `debug` | `false` | Enable `[debug]` console diagnostics |
411
413
  | `resumeOnActionIntent` | `true` | Enable action-intent (`:`-terminated line) nudges |
412
414
  | `continuePrompt` | `"continue"` | Prompt text for stall/streaming/dead-stream recovery |
@@ -420,7 +422,8 @@ With options:
420
422
  | `silentDeadStreamMinTokens` | `200` | Min output tokens to treat a textless `finish:"unknown"` message as a dead stream |
421
423
  | `busyStallStrategy` | `"continue"` | Busy-stall response: `"continue"`, `"abort"` (abort-first), or `"off"` (disabled) |
422
424
  | `contextSaturationThreshold` | `0.85` | Ratio of used/usable context that routes a saturated parent to magic-context `ctx-wrapup` (only when magic-context is installed) |
423
- | `activeUserWindowMs` | `900000` | Inbound-user-message recency window (15 min) during which idle nudges stand down (user likely composing) |
425
+ | `activeUserWindowMs` | `300000` | Inbound-user-message recency window (5 min) during which idle nudges stand down (user likely composing) |
426
+ | `stopPatterns` | `[]` | Array of regex strings (multiline). When the newest assistant message's text matches one, the session counts as awaiting the user and idle nudges stand down — e.g. `["STOP:\\s*(BLOCKED\|NEEDS_DECISION)\\b"]`. Invalid regexes are skipped. |
424
427
  | `subagentNativeCompactionEnabled` | `false` | Opt-in native `session.summarize()` for saturated subagent sessions (no magic-context detection required) |
425
428
 
426
429
  Message patterns are matched case-insensitively. Error names use exact match.
@@ -445,7 +448,7 @@ The plugin handles all recovery automatically — no manual intervention needed.
445
448
  | Problem | Solution |
446
449
  |---|---|
447
450
  | Resumes after ESC | Increase `gracePeriodMs` to `5000` |
448
- | Too aggressive | Increase `chunkTimeoutMs` to `60000` |
451
+ | Stall recovery too aggressive | Decrease `chunkTimeoutMs` (e.g. `45000`) |
449
452
  | Too slow to react | Decrease `checkIntervalMs` to `2000` |
450
453
  | Orphan parent not detected | Increase `subagentWaitMs` to `20000` |
451
454
  | Hallucination loop not caught | Decrease `loopMaxContinues` to `2` |
package/dist/index.js CHANGED
@@ -12335,9 +12335,10 @@ function tool(input) {
12335
12335
  }
12336
12336
  tool.schema = exports_external;
12337
12337
  // src/index.ts
12338
- var DEFAULT_CHUNK_TIMEOUT_MS = 45000;
12338
+ var DEFAULT_CHUNK_TIMEOUT_MS = 180000;
12339
12339
  var DEFAULT_CHECK_INTERVAL_MS = 5000;
12340
- var DEFAULT_ACTIVE_USER_WINDOW_MS = 15 * 60000;
12340
+ var DEFAULT_DISCOVERY_DELAY_MS = 5000;
12341
+ var DEFAULT_ACTIVE_USER_WINDOW_MS = 5 * 60000;
12341
12342
  var DEFAULT_GRACE_PERIOD_MS = 3000;
12342
12343
  var DEFAULT_MAX_RETRIES = 3;
12343
12344
  var DEFAULT_MAX_BACKOFF_MS = 8000;
@@ -12352,6 +12353,7 @@ var DEFAULT_MIN_ACTIVITY_GAP_MS = 1000;
12352
12353
  var DEFAULT_WARMUP_MS = 15000;
12353
12354
  var DEFAULT_SILENT_DEAD_STREAM_MIN_TOKENS = 200;
12354
12355
  var DEFAULT_DEBUG = false;
12356
+ var OOC_ERROR_RE = /exceeds the available context size|context size \(\d+\)|too large to compact|too many tokens|prompt is too long/i;
12355
12357
  var DEFAULT_STREAMING_FAILURE_ERROR_NAMES = [
12356
12358
  "ProviderError",
12357
12359
  "APIError",
@@ -12629,6 +12631,24 @@ function getOpenTodos(todos) {
12629
12631
  return [];
12630
12632
  return todos.filter(isOpenTodo);
12631
12633
  }
12634
+ function parseStopPatterns(raw) {
12635
+ if (!Array.isArray(raw))
12636
+ return [];
12637
+ return raw.map((s) => {
12638
+ try {
12639
+ return new RegExp(String(s), "m");
12640
+ } catch {
12641
+ return null;
12642
+ }
12643
+ }).filter((r) => r !== null);
12644
+ }
12645
+ function endsWithStopLine(parts, patterns) {
12646
+ if (!patterns.length)
12647
+ return false;
12648
+ const text = parts.filter((p) => p.type === "text").map((p) => String(p.text ?? "")).join(`
12649
+ `);
12650
+ return patterns.some((re) => re.test(text));
12651
+ }
12632
12652
  function buildOpenTodosReminder(todos) {
12633
12653
  if (!Array.isArray(todos))
12634
12654
  return "continue";
@@ -12659,6 +12679,7 @@ var AutoResumePlugin = async (ctx, options) => {
12659
12679
  const maxRecoveryRetries = options?.maxRecoveryRetries ?? DEFAULT_MAX_RECOVERY_RETRIES;
12660
12680
  const minActivityGapMs = options?.minActivityGapMs ?? DEFAULT_MIN_ACTIVITY_GAP_MS;
12661
12681
  const warmupMs = options?.warmupMs ?? DEFAULT_WARMUP_MS;
12682
+ const discoveryDelayMs = options?.discoveryDelayMs ?? DEFAULT_DISCOVERY_DELAY_MS;
12662
12683
  const debug = options?.debug ?? DEFAULT_DEBUG;
12663
12684
  const streamingFailureErrorNames = options?.streamingFailureErrorNames ?? DEFAULT_STREAMING_FAILURE_ERROR_NAMES;
12664
12685
  const streamingFailureMessagePatterns = options?.streamingFailureMessagePatterns ?? DEFAULT_STREAMING_FAILURE_MESSAGE_PATTERNS;
@@ -12699,6 +12720,7 @@ var AutoResumePlugin = async (ctx, options) => {
12699
12720
  }
12700
12721
  }).filter((r) => r !== null);
12701
12722
  })();
12723
+ const stopPatterns = parseStopPatterns(options?.stopPatterns);
12702
12724
  const dbg = (...args) => {
12703
12725
  if (debug)
12704
12726
  console.log("[debug]", ...args);
@@ -12770,6 +12792,8 @@ var AutoResumePlugin = async (ctx, options) => {
12770
12792
  resumeAttempts: 0,
12771
12793
  lastRetryAt: 0,
12772
12794
  gaveUp: false,
12795
+ oocLocked: false,
12796
+ oocLockReason: null,
12773
12797
  orphanWatchStartAt: null,
12774
12798
  aborting: false,
12775
12799
  pluginAbortInFlight: false,
@@ -12946,6 +12970,10 @@ var AutoResumePlugin = async (ctx, options) => {
12946
12970
  await log("debug", `${short(sid)} - gaveUp latched, refusing further continue prompts`);
12947
12971
  return;
12948
12972
  }
12973
+ if (w.oocLocked) {
12974
+ await log("debug", `${short(sid)} - oocLocked latched, refusing further continue prompts (reason: ${w.oocLockReason ?? "n/a"})`);
12975
+ return;
12976
+ }
12949
12977
  if (!w.continuing)
12950
12978
  dbg(`State transition on ${short(sid)}: continuing=false -> true`);
12951
12979
  w.continuing = true;
@@ -13101,6 +13129,8 @@ var AutoResumePlugin = async (ctx, options) => {
13101
13129
  const parts = msg.parts;
13102
13130
  if (!parts)
13103
13131
  return false;
13132
+ if (endsWithStopLine(parts, stopPatterns))
13133
+ return true;
13104
13134
  for (const part of parts) {
13105
13135
  if (part.type !== "tool_use")
13106
13136
  continue;
@@ -13831,14 +13861,13 @@ var AutoResumePlugin = async (ctx, options) => {
13831
13861
  await log("info", `${short(sid)} - max open-todos nudges (${maxRetries}) reached, waiting for activity`);
13832
13862
  return;
13833
13863
  }
13834
- w.todoNudgeAttempts++;
13835
13864
  } else if (isDoneClaimNoTodos) {
13836
13865
  w.doneClaimNoTodosAttempts++;
13837
13866
  } else {
13838
13867
  w.toolTextRecovered = true;
13839
13868
  w.toolTextAttempts++;
13840
13869
  }
13841
- const attemptNum = isOpenTodosReminder ? w.todoNudgeAttempts : isDoneClaimNoTodos ? w.doneClaimNoTodosAttempts : w.toolTextAttempts;
13870
+ const attemptNum = isOpenTodosReminder ? w.todoNudgeAttempts + 1 : isDoneClaimNoTodos ? w.doneClaimNoTodosAttempts : w.toolTextAttempts;
13842
13871
  await log("info", `${bestCandidate.source} detected on ${short(sid)}! ` + `Attempt ${attemptNum}/${maxRetries}. Sending recovery prompt...`);
13843
13872
  const timeSinceActivity = Date.now() - w.lastActivityAt;
13844
13873
  if (timeSinceActivity < minActivityGapMs) {
@@ -13884,6 +13913,8 @@ var AutoResumePlugin = async (ctx, options) => {
13884
13913
  return false;
13885
13914
  if (w.aborting)
13886
13915
  return false;
13916
+ if (w.oocLocked)
13917
+ return false;
13887
13918
  const idleSec = Math.round((Date.now() - (w.orphanWatchStartAt ?? w.lastActivityAt)) / 1000);
13888
13919
  await log("info", `Abort+Resume on ${short(sid)} (${idleSec}s idle). Aborting...`);
13889
13920
  w.pluginAbortInFlight = true;
@@ -13968,12 +13999,13 @@ var AutoResumePlugin = async (ctx, options) => {
13968
13999
  try {
13969
14000
  const response = await ctx.client.session.list();
13970
14001
  const list = extractMessages(response);
14002
+ const statusMap = await getSessionStatusMap();
13971
14003
  for (const s of list) {
13972
14004
  const sid = s.id;
13973
14005
  if (sid && typeof sid === "string" && sid.startsWith("ses_")) {
13974
14006
  const isNew = !sessions.has(sid);
13975
14007
  ensureWatch(sid);
13976
- const status = s.status;
14008
+ const status = s.status ?? statusMap[sid];
13977
14009
  if (status) {
13978
14010
  const w = sessions.get(sid);
13979
14011
  w.status = status;
@@ -14216,7 +14248,7 @@ var AutoResumePlugin = async (ctx, options) => {
14216
14248
  }, SESSION_DISCOVERY_INTERVAL_MS);
14217
14249
  if (discoveryTimer.unref)
14218
14250
  discoveryTimer.unref();
14219
- setTimeout(discoverSessions, 5000);
14251
+ setTimeout(discoverSessions, discoveryDelayMs);
14220
14252
  }
14221
14253
  startTimer();
14222
14254
  async function handleEvent(ev) {
@@ -14237,7 +14269,11 @@ var AutoResumePlugin = async (ctx, options) => {
14237
14269
  if (w.pendingRecovery) {
14238
14270
  dbg(`Pending recovery cleared on ${short(sid)}: reason=session-busy`);
14239
14271
  }
14240
- resetBusyFlags(w);
14272
+ if (w.continuing) {
14273
+ dbg(`busy on ${short(sid)} via recovery prompt - preserving resumeAttempts=${w.resumeAttempts} gaveUp=${w.gaveUp}`);
14274
+ } else {
14275
+ resetBusyFlags(w);
14276
+ }
14241
14277
  prevBusyCount = busyCount();
14242
14278
  log("debug", `${short(sid)} -> busy (${prevBusyCount})`);
14243
14279
  } else if (statusType === "interrupted") {
@@ -14379,12 +14415,14 @@ var AutoResumePlugin = async (ctx, options) => {
14379
14415
  if (isCelebration) {
14380
14416
  await log("info", `${short(sid)} - \uD83C\uDF89 detected but ${open.length} open todos remain, sending nudge`);
14381
14417
  const reminder = buildOpenTodosReminder(todos);
14382
- await tryResume(sid, w, "Idle with open todos (celebration false positive)", reminder);
14383
- w.todoNudgeAttempts++;
14418
+ const sent = await tryResume(sid, w, "Idle with open todos (celebration false positive)", reminder);
14419
+ if (sent)
14420
+ w.todoNudgeAttempts++;
14384
14421
  } else {
14385
14422
  const reminder = buildOpenTodosReminder(todos);
14386
- await tryResume(sid, w, "Idle with open todos", reminder);
14387
- w.todoNudgeAttempts++;
14423
+ const sent = await tryResume(sid, w, "Idle with open todos", reminder);
14424
+ if (sent)
14425
+ w.todoNudgeAttempts++;
14388
14426
  }
14389
14427
  }
14390
14428
  }
@@ -14454,8 +14492,16 @@ var AutoResumePlugin = async (ctx, options) => {
14454
14492
  break;
14455
14493
  }
14456
14494
  case "session.updated": {
14457
- if (sid)
14458
- ensureWatch(sid);
14495
+ if (!sid)
14496
+ break;
14497
+ const updatedInfo = ev.properties?.info;
14498
+ if (updatedInfo?.revert) {
14499
+ const was = sessions.get(sid);
14500
+ dbg(`Revert on ${short(sid)}: dropping watch state (resumeAttempts=${was?.resumeAttempts ?? 0} recoveryAttempts=${was?.recoveryAttempts ?? 0} gaveUp=${was?.gaveUp ?? false})`);
14501
+ sessions.delete(sid);
14502
+ break;
14503
+ }
14504
+ ensureWatch(sid);
14459
14505
  break;
14460
14506
  }
14461
14507
  case "session.idle": {
@@ -14626,6 +14672,11 @@ var AutoResumePlugin = async (ctx, options) => {
14626
14672
  if (w) {
14627
14673
  w.pendingTools = 0;
14628
14674
  w.pendingCommands = 0;
14675
+ if (!w.oocLocked && OOC_ERROR_RE.test(errorMessage ?? "")) {
14676
+ w.oocLocked = true;
14677
+ w.oocLockReason = (errorMessage ?? "").slice(0, 200);
14678
+ await log("warn", `${short(sid)} - OOC error latched, halting recovery (reason: ${w.oocLockReason})`);
14679
+ }
14629
14680
  }
14630
14681
  }
14631
14682
  break;
@@ -14638,7 +14689,11 @@ var AutoResumePlugin = async (ctx, options) => {
14638
14689
  if (w.pendingRecovery) {
14639
14690
  dbg(`Pending recovery cleared on ${short(sid)}: reason=user-command`);
14640
14691
  }
14641
- resetBusyFlags(w);
14692
+ if (w.continuing) {
14693
+ dbg(`command on ${short(sid)} via recovery prompt - preserving resumeAttempts=${w.resumeAttempts} gaveUp=${w.gaveUp}`);
14694
+ } else {
14695
+ resetBusyFlags(w);
14696
+ }
14642
14697
  w.pendingCommands = Math.max(0, w.pendingCommands - 1);
14643
14698
  w.lastActivityAt = Date.now();
14644
14699
  }
@@ -14726,6 +14781,11 @@ ${reminder}`;
14726
14781
  w.lastActivityAt = Date.now();
14727
14782
  if (w.continuing)
14728
14783
  return;
14784
+ if (w.oocLocked) {
14785
+ w.oocLocked = false;
14786
+ w.oocLockReason = null;
14787
+ await log("info", `${short(sid)} - genuine user message, clearing oocLocked`);
14788
+ }
14729
14789
  if (w.userCancelled || w.completionSignaled) {
14730
14790
  w.userCancelled = false;
14731
14791
  w.completionSignaled = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auto-resume",
3
- "version": "1.1.19",
3
+ "version": "1.1.21",
4
4
  "description": "OpenCode plugin that automatically resumes stalled LLM sessions when thinking/streaming freezes mid-generation.",
5
5
  "keywords": [
6
6
  "opencode",