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.
- package/README.md +72 -10
- package/dist/index.js +43 -7
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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? →
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13922
|
-
|
|
13923
|
-
|
|
13924
|
-
|
|
13925
|
-
|
|
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