opencode-auto-resume 1.1.11 → 1.1.13

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 +72 -10
  2. package/dist/index.js +43 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -12,6 +12,8 @@ LLM sessions fail in predictable ways. This plugin monitors all sessions and aut
12
12
 
13
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.
14
14
 
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
+
15
17
  The plugin extracts the **agent, model, and provider** from the last session message, so it resumes with the exact same configuration the user was using (build, sisyphus, prometheus, etc.).
16
18
 
17
19
  _Motivated by:_
@@ -40,7 +42,7 @@ _Motivated by:_
40
42
 
41
43
  The model generates the same broken output repeatedly. Each `continue` just picks up the broken generation. If a session needs 3+ continues within 10 minutes, the plugin aborts the request and sends `"continue"` fresh, forcing a clean restart.
42
44
 
43
- A separate **tool-call loop detector** also catches the model calling the same tool 3+ consecutive times (or repeating patterns of length 2-5 occurring at least three times). When detected, it emits `TOOL_LOOP_RECOVERY_PROMPT` to break the loop instead of blindly continuing.
45
+ A separate **tool-call loop detector** also catches the model calling the same tool 3+ consecutive times (or repeating patterns of length 2-5 occurring at least three times). When detected, it emits `TOOL_LOOP_RECOVERY_PROMPT` (at most twice per session) to break the loop instead of blindly continuing.
44
46
 
45
47
  _Motivated by:_
46
48
  - [#22142](https://github.com/anomalyco/opencode/issues/22142) — Repetitive tool-call loops with alibaba-coding-plan-cn/qwen3.6-plus
@@ -53,7 +55,7 @@ _Motivated by:_
53
55
 
54
56
  ### Orphan parent
55
57
 
56
- A subagent finishes but the parent session stays stuck as "busy" forever. The plugin detects when `busyCount` drops from >1 to 1, waits 15 seconds (`subagentWaitMs`), then aborts and resumes the parent.
58
+ A subagent finishes but the parent session stays stuck as "busy" forever. The plugin detects when `busyCount` drops from >1 to 1, waits `subagentWaitMs` + `gracePeriodMs` (18s default), probes the subagent (recovering a crashed child first if possible), then aborts and resumes the parent.
57
59
 
58
60
  _Motivated by:_
59
61
  - [#35066](https://github.com/anomalyco/opencode/issues/35066) — notify parent when subagent sessions finish
@@ -88,7 +90,7 @@ The AI provider's streaming response can fail mid-stream (connection reset, time
88
90
  1. **Detection**: `session.error` is classified via `isStreamingFailure()` against the configured error names and message patterns
89
91
  2. **State transition**: the session's `pendingRecovery` flag is armed with the error name (`pendingRecoveryReason`) and timestamp (`pendingRecoveryAt`)
90
92
  3. **Recovery attempt**: once the session is idle and the backoff delay has elapsed, the timer loop sends a recovery prompt
91
- 4. **Watchdog**: if the session is still not busy 3 seconds after the prompt, the recovery is retried (up to `maxRecoveryRetries`) with exponential backoff
93
+ 4. **Watchdog**: if the session is still not busy `toolTextCheckDelayMs` (3s default) after the prompt, the recovery is retried (up to `maxRecoveryRetries`) with exponential backoff
92
94
  5. **Escalation**: when retries are exhausted, the plugin aborts the session and resumes it (`abort+resume`); `gaveUp` is set if that also fails
93
95
 
94
96
  #### Configuration
@@ -128,8 +130,8 @@ See [Recovery Flow Documentation](docs/architecture/recovery-flow.md) for the fu
128
130
  5. Session goes idle; timer loop waits until the backoff delay has elapsed
129
131
  6. Recovery prompt sent (recoveryAttempts=1)
130
132
  7. Success → session busy → recovery flags cleared
131
- Still not busy after 3s → watchdog retry (attempt 2/2)
132
- Still not busy after 3s → maxRecoveryRetries reached → abort + resume
133
+ Still not busy after `toolTextCheckDelayMs` (3s default) → watchdog retry (attempt 2/2)
134
+ Still not busy → maxRecoveryRetries reached → abort + resume
133
135
  Abort+continue fails → gaveUp
134
136
  ```
135
137
 
@@ -148,9 +150,15 @@ _Motivated by:_
148
150
 
149
151
  ---
150
152
 
153
+ ### Silent dead-stream recovery
154
+
155
+ The model stream can die after emitting only reasoning — no text part, no tool call — finalizing with `finish: "unknown"`. OpenCode treats the message as completed and the session goes idle, so no error or stall path triggers. On idle, if the **newest** assistant message has a finish reason, zero text parts, and at least `silentDeadStreamMinTokens` output tokens, the plugin sends a recovery prompt. Only the newest assistant message is evaluated — a delivered text answer means normal completion, and older tool-call steps are never misread as dead streams. Recovery is also skipped if the session has gone busy/retry again before the prompt is sent (race guard).
156
+
157
+ ---
158
+
151
159
  ### Active-tool safety guard
152
160
 
153
- Before **any** abort, the plugin calls `checkSessionHasActiveTool()` to verify the session isn't mid-tool-execution. If a tool is running, the abort is skipped. This prevents the plugin from killing a long-running build, test suite, or command — even when it looks like a stall.
161
+ Before **any** abort, two guards run in order: the primary deterministic in-flight counter maintained by the `tool.execute.before`/`tool.execute.after` hooks (`hasInflightTools()`), then a polled `checkSessionHasActiveTool()` fallback for sessions discovered without hooks. If a tool is running, the abort is skipped. This prevents the plugin from killing a long-running build, test suite, or command — even when it looks like a stall.
154
162
 
155
163
  _Motivated by:_
156
164
  - [#26063](https://github.com/anomalyco/opencode/issues/26063) — Tool execution aborted/terminated
@@ -172,7 +180,9 @@ _Motivated by:_
172
180
 
173
181
  ### ESC cancel respected
174
182
 
175
- User presses ESC to cancel a request. The plugin detects `MessageAbortedError` and marks all busy sessions as cancelled, never resuming them. The grace period (`gracePeriodMs`) also lets late ESC/status events arrive before any action.
183
+ User presses ESC to cancel a request. The plugin detects `MessageAbortedError` and marks sessions as cancelled — regardless of their tracked status, so a late status flip to idle before the error cannot miss the latch — and never resumes them. Aborts initiated by the plugin itself (`pluginAbortInFlight`) are excluded, so recovery aborts are not mistaken for ESC. The grace period (`gracePeriodMs`) also lets late ESC/status events arrive before any action.
184
+
185
+ The back-off lifts as soon as the user sends a new prompt in that session (`chat.message` hook): a fresh user message starts a new round of work, so auto-resume re-arms. The plugin's own recovery prompts do not re-arm it. The same applies to the `task_complete` latch.
176
186
 
177
187
  _Motivated by:_
178
188
  - [#28453](https://github.com/anomalyco/opencode/issues/28453) — ACP session/cancel emits agent_error for MessageAbortedError before cancelled result
@@ -185,6 +195,8 @@ _Motivated by:_
185
195
 
186
196
  The agent can call the built-in `task_complete` tool to signal that all work is done. When invoked, the plugin stops sending any further `"continue"` prompts, clears all pending timers, and marks the session as complete. This replaces fragile text-based heuristics (emoji patterns, language detection) with a deterministic signal.
187
197
 
198
+ If `task_complete` is called while open todos remain, the call is rejected (up to `maxRetries` times) with a message asking the agent to finish the remaining work first.
199
+
188
200
  ---
189
201
 
190
202
  ### 🎉 emoji completion
@@ -199,9 +211,15 @@ When the assistant prints phrases like "Ready to continue with task" or "Proceed
199
211
 
200
212
  ---
201
213
 
214
+ ### Action-intent nudge
215
+
216
+ When the assistant ends a line with `:` ("Next, I will edit the file:") — announcing intent without acting — the plugin sends `actionIntentPrompt` after a short delay, nudging the model to execute. Disable with `resumeOnActionIntent: false`. Detection is skipped while a session is younger than `warmupMs`.
217
+
218
+ ---
219
+
202
220
  ### Done-claim verification
203
221
 
204
- If the assistant claims the task is done ("task done", "finished", "all complete") but open todos remain, the plugin sends `DONE_WITHOUT_WORK_PROMPT` asking the agent to verify and finish remaining work. This uses the real `todo.updated` event state — not regex on the message text.
222
+ If the assistant claims the task is done ("task done", "finished", "all complete") but open todos remain, the plugin sends `DONE_WITHOUT_WORK_PROMPT` asking the agent to verify and finish remaining work. If it claims done with **no** open todos but the response contains no description of the work, the plugin sends `DONE_WITHOUT_DETAILS_PROMPT` — an imperative prompt demanding a concrete report (files changed, commands run, results). Both use the real `todo.updated` event state — not regex on the message text.
205
223
 
206
224
  ---
207
225
 
@@ -233,6 +251,32 @@ _Motivated by:_
233
251
 
234
252
  ---
235
253
 
254
+ ## Recovery model
255
+
256
+ All recovery paths fall into three families. Which family fires determines what the prompt can actually do.
257
+
258
+ ### 1. Idle-boundary nudges (safe)
259
+
260
+ Fire only after OpenCode reports the session **idle** — the runner has exited. The prompt starts a new run.
261
+
262
+ Paths: todo nudges, tool-call-as-text recovery, thinking-tool recovery, action-intent nudge, ready-to-continue, done-claim verification, streaming-failure recovery, silent dead-stream recovery.
263
+
264
+ ### 2. Busy-silence continue (stream stall)
265
+
266
+ Fires when the session still reports **busy** but no events arrived for `chunkTimeoutMs` + `gracePeriodMs`. Controlled by `busyStallStrategy`:
267
+
268
+ - `"continue"` (default) — sends a prompt while busy. This only helps when the busy status is **stale** (the runner already exited but the status was never updated); the prompt then starts a new run.
269
+ - `"abort"` — aborts first (`session.abort()`), then sends the prompt. Required to unblock a runner that is genuinely stuck (hung provider stream). Subject to the same active-tool guards as every abort: never fires while a tool is in-flight.
270
+ - `"off"` — disables busy-silence recovery entirely; stalls are then handled only by idle-boundary recovery (if the session ever goes idle) or manually.
271
+
272
+ **Why `"continue"` cannot unblock a live runner:** in OpenCode ≥ 1.18, `session.prompt()` while the runner is `Running` joins the existing run — the message is admitted to the session inbox and promoted at the next provider-turn boundary. A hung stream never reaches that boundary, so the parked message cannot unblock it; if the run later exits, the message may surface as an unwanted extra turn (token cost, race risk). True transport stalls need abort-first.
273
+
274
+ ### 3. Abort-first recovery
275
+
276
+ Aborts the active run, then continues. The only family that can unblock a live runner.
277
+
278
+ 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).
279
+
236
280
  ## Architecture
237
281
 
238
282
  ```
@@ -242,6 +286,8 @@ Any SSE Event
242
286
 
243
287
  session.status events:
244
288
  ├─ busy → reset timer, clear retry counters
289
+ ├─ retry → touch session
290
+ ├─ interrupted → user cancel: back off until the next user message
245
291
  └─ idle → schedule tool-text check (3s delay)
246
292
  └─ fetch messages → scan for XML / thinking-tool patterns
247
293
  ├─ found → send recovery prompt (with backoff)
@@ -257,12 +303,16 @@ Timer loop (every 5s):
257
303
  ├─ orphan watch active? → wait or abort+continue
258
304
  ├─ busyCount > 1? → skip (subagent running)
259
305
  ├─ active tool running? → skip (never abort tools)
260
- └─ idle > 48s? → hallucination loop? abort : continue with backoff
306
+ └─ idle > 48s? → busyStallStrategy:
307
+ off → skip · abort → abort+continue
308
+ else → hallucination loop? abort : continue with backoff
261
309
 
262
310
  Periodic (every 60s): session.list() to discover missed sessions
263
311
  Periodic: cleanup idle sessions older than 10min or >50 entries
264
312
  ```
265
313
 
314
+ Events consumed: `session.status`, `session.created`, `session.updated`, `session.idle`, `session.interrupted`, `session.error`, `todo.updated`, `command.executed`; hooks: `chat.message`, `tool.execute.before`/`after`, `command.execute.before`.
315
+
266
316
  ## Installation
267
317
 
268
318
  ### Opencode
@@ -319,6 +369,19 @@ With options:
319
369
  | `streamingFailureErrorNames` | `["ProviderError","APIError","StreamError","ConnectionError","TimeoutError"]` | Error names that classify as streaming failures (exact match) |
320
370
  | `streamingFailureMessagePatterns` | `["streaming response failed","stream.*fail","connection.*reset","connection.*closed"]` | Regex patterns (case-insensitive) in error messages indicating streaming failure |
321
371
  | `maxRecoveryRetries` | `2` | Max streaming-failure recovery attempts before abort+resume escalation |
372
+ | `toolTextCheckDelayMs` | `3000` | Delay before scanning an idle session for tool-as-text; also the recovery watchdog delay |
373
+ | `minActivityGapMs` | `1000` | Skip recovery if the session was active within this gap |
374
+ | `warmupMs` | `15000` | Action-intent detection disabled while a session is younger than this |
375
+ | `debug` | `false` | Enable `[debug]` console diagnostics |
376
+ | `resumeOnActionIntent` | `true` | Enable action-intent (`:`-terminated line) nudges |
377
+ | `continuePrompt` | `"continue"` | Prompt text for stall/streaming/dead-stream recovery |
378
+ | `actionIntentPrompt` | same as `continuePrompt` | Prompt sent on action-intent detection |
379
+ | `toolTextRecoveryPrompt` | `TOOL_TEXT_RECOVERY_PROMPT` | Override the tool-call-as-text recovery prompt |
380
+ | `thinkingToolRecoveryPrompt` | `THINKING_TOOL_RECOVERY_PROMPT` | Override the thinking-tool recovery prompt |
381
+ | `doneWithoutWorkPrompt` | `DONE_WITHOUT_WORK_PROMPT` | Override the done-claim-with-open-todos prompt |
382
+ | `doneWithoutDetailsPrompt` | `DONE_WITHOUT_DETAILS_PROMPT` | Override the done-claim-with-no-todos report prompt |
383
+ | `silentDeadStreamMinTokens` | `200` | Min output tokens to treat a textless `finish:"unknown"` message as a dead stream |
384
+ | `busyStallStrategy` | `"continue"` | Busy-stall response: `"continue"`, `"abort"` (abort-first), or `"off"` (disabled) |
322
385
 
323
386
  Message patterns are matched case-insensitively. Error names use exact match.
324
387
 
@@ -326,7 +389,6 @@ Message patterns are matched case-insensitively. Error names use exact match.
326
389
 
327
390
  | Constant | Value | Description |
328
391
  |---|---|---|
329
- | `TOOL_TEXT_CHECK_DELAY_MS` | `3000` | Delay before scanning idle session for tool-as-text |
330
392
  | `ABORT_CONTINUE_DELAY_MS` | `2000` | Delay between abort and continue |
331
393
  | `MAX_IDLE_SESSIONS` | `50` | Idle session map cap before cleanup |
332
394
  | `IDLE_CLEANUP_MS` | `600000` | Idle session age before cleanup (10 min) |
package/dist/index.js CHANGED
@@ -12523,7 +12523,7 @@ function getLastSilentDeadStream(messages) {
12523
12523
  return t.type === "text" && typeof t.text === "string" && t.text.length > 0;
12524
12524
  });
12525
12525
  if (hasText)
12526
- continue;
12526
+ return null;
12527
12527
  const tokens = msg.tokens;
12528
12528
  const tInfo = info?.tokens;
12529
12529
  const output = (tokens?.output ?? 0) + (tInfo?.output ?? 0);
@@ -12598,6 +12598,8 @@ var AutoResumePlugin = async (ctx, options) => {
12598
12598
  const doneWithoutWorkPrompt = options?.doneWithoutWorkPrompt ?? DONE_WITHOUT_WORK_PROMPT;
12599
12599
  const doneWithoutDetailsPrompt = options?.doneWithoutDetailsPrompt ?? DONE_WITHOUT_DETAILS_PROMPT;
12600
12600
  const silentDeadStreamMinTokens = options?.silentDeadStreamMinTokens ?? DEFAULT_SILENT_DEAD_STREAM_MIN_TOKENS;
12601
+ const rawBusyStallStrategy = options?.busyStallStrategy ?? "continue";
12602
+ const busyStallStrategy = rawBusyStallStrategy === "abort" || rawBusyStallStrategy === "off" ? rawBusyStallStrategy : "continue";
12601
12603
  const dbg = (...args) => {
12602
12604
  if (debug)
12603
12605
  console.log("[debug]", ...args);
@@ -13757,6 +13759,10 @@ var AutoResumePlugin = async (ctx, options) => {
13757
13759
  }
13758
13760
  const idle = now - w.lastActivityAt;
13759
13761
  if (idle >= chunkTimeoutMs + gracePeriodMs) {
13762
+ if (busyStallStrategy === "off") {
13763
+ dbg(`Stream stall on ${short(sid)} ignored (busyStallStrategy=off)`);
13764
+ continue;
13765
+ }
13760
13766
  if (hasInflightTools(w)) {
13761
13767
  await log("debug", `Session ${short(sid)} has ${w.pendingTools} tool(s) in-flight, skipping stall recovery`);
13762
13768
  w.lastSubagentCheckAt = now;
@@ -13766,7 +13772,12 @@ var AutoResumePlugin = async (ctx, options) => {
13766
13772
  await log("debug", `Session ${short(sid)} has active tool call, skipping stall recovery`);
13767
13773
  w.lastSubagentCheckAt = now;
13768
13774
  } else if (w.resumeAttempts < maxRetries) {
13769
- tryResume(sid, w, "Stream stall");
13775
+ if (busyStallStrategy === "abort") {
13776
+ await log("info", `Stream stall on ${short(sid)} (busyStallStrategy=abort): aborting before continue`);
13777
+ tryAbortAndResume(sid, w);
13778
+ } else {
13779
+ tryResume(sid, w, "Stream stall");
13780
+ }
13770
13781
  } else if (!w.gaveUp) {
13771
13782
  w.gaveUp = true;
13772
13783
  dbg(`State transition on ${short(sid)}: gaveUp=false -> true`);
@@ -13918,11 +13929,22 @@ var AutoResumePlugin = async (ctx, options) => {
13918
13929
  try {
13919
13930
  const dead = getLastSilentDeadStream(await getSessionMessages(sid));
13920
13931
  if (dead && dead.outputTokens >= silentDeadStreamMinTokens) {
13921
- w.pendingRecovery = true;
13922
- w.pendingRecoveryReason = `silent-${dead.finish}`;
13923
- w.pendingRecoveryAt = Date.now();
13924
- await log("info", `${short(sid)} - silent dead stream: finish=${dead.finish}, ${dead.outputTokens} output tokens, no text parts; resuming`);
13925
- await tryResume(sid, w, `Silent dead stream (${dead.finish})`, continuePrompt);
13932
+ let busyAgain = false;
13933
+ try {
13934
+ const liveStatus = (await getSessionStatusMap())[sid];
13935
+ busyAgain = liveStatus === "busy" || liveStatus === "retry";
13936
+ } catch (e) {
13937
+ dbg(`session.idle sid=${short(sid)}: silent-dead-stream status check failed: ${e instanceof Error ? e.message : String(e)}`);
13938
+ }
13939
+ if (busyAgain) {
13940
+ dbg(`session.idle sid=${short(sid)}: silent-dead-stream recovery skipped, session is busy/retry again`);
13941
+ } else {
13942
+ w.pendingRecovery = true;
13943
+ w.pendingRecoveryReason = `silent-${dead.finish}`;
13944
+ w.pendingRecoveryAt = Date.now();
13945
+ await log("info", `${short(sid)} - silent dead stream: finish=${dead.finish}, ${dead.outputTokens} output tokens, no text parts; resuming`);
13946
+ await tryResume(sid, w, `Silent dead stream (${dead.finish})`, continuePrompt);
13947
+ }
13926
13948
  }
13927
13949
  } catch (e) {
13928
13950
  const errMsg = e instanceof Error ? e.message : String(e);
@@ -14194,6 +14216,20 @@ var AutoResumePlugin = async (ctx, options) => {
14194
14216
  tool: {
14195
14217
  task_complete: taskCompleteTool
14196
14218
  },
14219
+ "chat.message": async (input) => {
14220
+ const sid = input?.sessionID;
14221
+ if (!sid)
14222
+ return;
14223
+ const w = ensureWatch(sid);
14224
+ w.lastActivityAt = Date.now();
14225
+ if (w.continuing)
14226
+ return;
14227
+ if (w.userCancelled || w.completionSignaled) {
14228
+ w.userCancelled = false;
14229
+ w.completionSignaled = false;
14230
+ await log("info", `${short(sid)} - new user message, re-arming auto-resume`);
14231
+ }
14232
+ },
14197
14233
  "tool.execute.before": async (input) => {
14198
14234
  if (!input?.sessionID)
14199
14235
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auto-resume",
3
- "version": "1.1.11",
3
+ "version": "1.1.13",
4
4
  "description": "OpenCode plugin that automatically resumes stalled LLM sessions when thinking/streaming freezes mid-generation.",
5
5
  "keywords": [
6
6
  "opencode",