opencode-auto-resume 1.1.20 → 1.2.0
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 +273 -268
- package/dist/index.js +4253 -200
- package/dist/v2/index.js +4010 -0
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -2,29 +2,193 @@
|
|
|
2
2
|
|
|
3
3
|
**Plugin for [OpenCode](https://github.com/anomalyco/opencode) that automatically detects and recovers from LLM session failures — stalls, broken tool calls, hallucination loops, stuck subagent parents, and more. Fully silent, zero UI pollution.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Supports **both OpenCode v1 and v2**.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
### opencode 1.x
|
|
10
|
+
|
|
11
|
+
Add to your `opencode.jsonc`:
|
|
12
|
+
|
|
13
|
+
```jsonc
|
|
14
|
+
{
|
|
15
|
+
"$schema": "https://opencode.ai/config.json",
|
|
16
|
+
"plugin": ["opencode-auto-resume"]
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
With options:
|
|
21
|
+
|
|
22
|
+
```jsonc
|
|
23
|
+
{
|
|
24
|
+
"plugin": [
|
|
25
|
+
["opencode-auto-resume", {
|
|
26
|
+
"chunkTimeoutMs": 180000,
|
|
27
|
+
"gracePeriodMs": 3000,
|
|
28
|
+
"maxRetries": 3
|
|
29
|
+
}]
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### opencode 2.x
|
|
35
|
+
|
|
36
|
+
Requires opencode 2.0.5+. Add to your `opencode.json` — note the **plural `plugins` key** and the object form; options under v1's singular `plugin` key load but are never delivered on v2:
|
|
37
|
+
|
|
38
|
+
```jsonc
|
|
39
|
+
{
|
|
40
|
+
"plugins": [
|
|
41
|
+
{
|
|
42
|
+
"package": "opencode-auto-resume",
|
|
43
|
+
"options": {
|
|
44
|
+
"chunkTimeoutMs": 180000,
|
|
45
|
+
"maxRetries": 3
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
To uninstall, delete the entry from `opencode.jsonc` (v1: the `plugin` array item, v2: the `plugins` object) and restart the server. To clear the cached copy too, remove `~/.cache/opencode/packages/opencode-auto-resume@*`.
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
### How a finished turn is judged
|
|
57
|
+
|
|
58
|
+
`session.idle` does not decide anything by itself. The turn is inspected in two passes, because `session.idle` can arrive while the assistant's closing text is still being written into the message history:
|
|
59
|
+
|
|
60
|
+
1. **Straight away** — is this a dead stream, is it handing control back to the user, is the user already busy. None of these read the final text.
|
|
61
|
+
2. **After `toolTextCheckDelayMs`** (3s default) — the celebration, tool-call-as-text, ready-to-continue, action-intent and done-claim detectors, re-reading the history. This is the pass the option exists for, and it re-runs the guards from step 1, because three seconds is long enough for the user to have replied and a nudge sent over them interrupts their own step.
|
|
62
|
+
|
|
63
|
+
A new turn cancels a pending second pass, and a second idle replaces it rather than stacking another judgement on the same text.
|
|
64
|
+
|
|
65
|
+
The v2 build reads and applies every option in the table below — the last of the gaps closed with `toolTextCheckDelayMs`. Any option it accepts but does not apply is listed in the startup line as `accepted-but-inert=…`; that list is currently empty. An option name this build does not know at all produces a single warning at startup, so a typo or a dropped port is never silent.
|
|
66
|
+
|
|
67
|
+
### Configurable options
|
|
68
|
+
|
|
69
|
+
Defaults are the same on v1 and v2 unless a row says otherwise.
|
|
70
|
+
|
|
71
|
+
| Option | Type | Default | Description |
|
|
72
|
+
|---|---|---|---|
|
|
73
|
+
| `chunkTimeoutMs` | `number` | `180000` | Inactivity timeout before considering stream stalled |
|
|
74
|
+
| `gracePeriodMs` | `number` | `3000` | Extra wait before acting (lets ESC/status events arrive) |
|
|
75
|
+
| `checkIntervalMs` | `number` | `5000` | Timer poll interval |
|
|
76
|
+
| `maxRetries` | `number` | `3` | Max auto-resume attempts before giving up |
|
|
77
|
+
| `baseBackoffMs` | `number` | `1000` | First retry delay (doubles each attempt) |
|
|
78
|
+
| `maxBackoffMs` | `number` | `8000` | Backoff cap |
|
|
79
|
+
| `subagentWaitMs` | `number` | `15000` | Wait before treating orphan parent as stuck |
|
|
80
|
+
| `subagentDeadMs` | `number` | `1800000` | Outer bound: quiet past this with no error evidence reads as dead (v2 only). Inside it, a quiet child is waited on, not aborted — 30 min default for long-thinking models |
|
|
81
|
+
| `loopMaxContinues` | `number` | `3` | Continues in window before triggering abort |
|
|
82
|
+
| `loopWindowMs` | `number` | `600000` | Hallucination loop detection window (10 min) |
|
|
83
|
+
| `lifetimeMaxContinues` | `number` | `9` (fast) / `20` (slow) | Lifetime stall continues per genuine user episode before standing down; resets only on a genuine user message/turn — see Stall recovery |
|
|
84
|
+
| `streamingFailureErrorNames` | `string[]` | `["ProviderError","APIError","StreamError","ConnectionError","TimeoutError"]` | Error names that classify as streaming failures (exact match) |
|
|
85
|
+
| `streamingFailureMessagePatterns` | `string[]` | `["streaming response failed","stream.*fail","connection.*reset","connection.*closed"]` | Regex patterns (case-insensitive) in error messages indicating streaming failure |
|
|
86
|
+
| `maxRecoveryRetries` | `number` | `2` | v1 name for the streaming-failure retry budget; v2 canonical is `maxRetries` (alias accepted) |
|
|
87
|
+
| `toolTextCheckDelayMs` | `number` | `3000` | Settle delay before a finished turn's closing text is judged against the done/tool patterns; also the recovery watchdog delay. On v2 only the pattern half is deferred — dead streams are still caught on the idle event, because a stream that died before delivering any text has nothing for those patterns to read. Set `0` to judge on the idle event |
|
|
88
|
+
| `minActivityGapMs` | `number` | `1000` | Skip recovery if the session was active within this gap |
|
|
89
|
+
| `warmupMs` | `number` | `15000` | Action-intent detection disabled while a session is younger than this |
|
|
90
|
+
| `discoveryDelayMs` | `number` | `5000` | Delay before the initial session discovery run after plugin attach |
|
|
91
|
+
| `debug` | `boolean` | `false` | Enable `[debug]` console diagnostics |
|
|
92
|
+
| `resumeOnActionIntent` | `boolean` | `true` | Enable action-intent (`:`-terminated line) nudges |
|
|
93
|
+
| `continuePrompt` | `string` | `"continue"` | Prompt text for stall/streaming/dead-stream recovery |
|
|
94
|
+
| `actionIntentPrompt` | `string` | same as `continuePrompt` | Prompt sent on action-intent detection |
|
|
95
|
+
| `toolTextRecoveryPrompt` | `string` | `TOOL_TEXT_RECOVERY_PROMPT` | Override the tool-call-as-text recovery prompt |
|
|
96
|
+
| `thinkingToolRecoveryPrompt` | `string` | `THINKING_TOOL_RECOVERY_PROMPT` | Override the thinking-tool recovery prompt |
|
|
97
|
+
| `doneWithoutWorkPrompt` | `string` | `DONE_WITHOUT_WORK_PROMPT` | Override the done-claim-with-open-todos prompt. Needs a todo tool writing `todos/<sessionID>` into storage; without one it never fires |
|
|
98
|
+
| `doneWithoutDetailsPrompt` | `string` | `DONE_WITHOUT_DETAILS_PROMPT` | Override the done-claim-with-no-todos report prompt |
|
|
99
|
+
| `doneClaimPatterns` | `string[]` | `DONE_CLAIM_PATTERNS` | Array of regex strings overriding the default done-claim detection patterns (case-insensitive, multiline). Invalid regexes are skipped. Empty array falls back to defaults. |
|
|
100
|
+
| `readyToContinuePatterns` | `string[]` | `READY_TO_CONTINUE_PATTERNS` | Array of regex strings overriding the default ready-to-continue detection patterns (case-insensitive). Invalid regexes are skipped. Empty array falls back to defaults. |
|
|
101
|
+
| `silentDeadStreamMinTokens` | `number` | `200` | Min output tokens to treat a textless, tool-less finished message as a dead stream. Finished turns carrying tool calls are working, not silent, and tools in flight veto the inject regardless |
|
|
102
|
+
| `busyStallStrategy` | `"continue" \| "abort" \| "off"` | `"continue"` | Busy-stall response: `"continue"`, `"abort"` (abort-first), or `"off"` (disabled) |
|
|
103
|
+
| `contextSaturationThreshold` | `number` | `0.85` | Ratio of used/usable context that routes a saturated session to reclamation: a parent to magic-context `ctx-wrapup` (only when magic-context is installed), a subagent to native compaction (only when `subagentNativeCompactionEnabled`) |
|
|
104
|
+
| `activeUserWindowMs` | `number` | `300000` | Inbound-user-message recency window during which idle nudges stand down (user likely composing) |
|
|
105
|
+
| `stopPatterns` | `string[]` | `[]` | 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. |
|
|
106
|
+
| `subagentNativeCompactionEnabled` | `boolean` | `false` | Opt-in native `session.summarize()` for saturated subagent sessions (no magic-context detection required) |
|
|
107
|
+
| `injectIntervalMs` | `number` | v2 only | Minimum gap between recovery injections for one session. No v1 equivalent |
|
|
108
|
+
| `visibleContinue` | `boolean` | `true`, v2 only | Send stall continue via `session.prompt()` — a real user message visible in session history (cattleprod-style) — instead of hidden `session.synthetic({resume:true})`. Set `false` for the old channel. Explicit option wins, else `AUTO_RESUME_VISIBLE_CONTINUE` (`0`/`false` = hidden) wins over the default |
|
|
109
|
+
| `richContinuePrompt` | `boolean` | `true`, v2 only | Stall continue names the stall reason, attempt count, and remaining todos instead of bare `"continue"`. A custom `continuePrompt` always wins verbatim |
|
|
110
|
+
| `rateLimitCooldownsMs` | `number[]` | `[900000, 1800000, 3600000, 7200000 ×5]` | Per-attempt cooldowns (ms) before a rate-limited session may be retried, evaluated as gates on each failure and watchdog tick (no armed timers to lose on reload). Default ≈ 12h coverage; past the ladder the session stays silent until a genuine user turn. Quota hits never consume the normal retry budget |
|
|
111
|
+
| `sendTimeoutMs` | `number` | `30000` | Bound (ms) on any single recovery send (prompt/synthetic); expiry flows into the normal throw path instead of stacking wedged sends |
|
|
112
|
+
| `stuckToolAgeMs` | `number` | `60000`, v2 only | A tool call with no result older than this reads as stuck (idle path nudges immediately instead of waiting out the stall window) |
|
|
113
|
+
| `neverInterruptTools` | `string[]` | `["compress"]`, v2 only | Tool names the busy-stall path never interrupts while fresh (case-insensitive); older than 30min they count as wedged like any other tool |
|
|
114
|
+
| `shareWatchState` | `boolean` | `false`, v2 only | Share the session watch map across setup re-runs in one process, so stall memory survives reloads |
|
|
115
|
+
| `registryScope` | `string` | unset, v2 only | Selects the process-global registry slot; test-only — production uses the pid-wide slot so reloads supersede cleanly |
|
|
116
|
+
| `quietCorrections` | `boolean` | `true`, v2 only | Feature-correction nudges (unknown-tool, stuck-tool, pattern recoveries) go through the invisible synthetic channel instead of a visible prompt; stall recovery always stays visible |
|
|
117
|
+
| `defaultFast` | `object` | unset | Per-profile overrides for online/fast models (e.g. `{ chunkTimeoutMs: 180000 }`) |
|
|
118
|
+
| `defaultSlow` | `object` | unset | Per-profile overrides for local/slow models (e.g. `{ lifetimeMaxContinues: 20 }`) |
|
|
119
|
+
| `modelProfiles` | `object` | unset | Per-provider overrides keyed by provider name (e.g. `{ "llama-server": { lifetimeMaxContinues: 20 } }`) |
|
|
120
|
+
|
|
121
|
+
Message patterns are matched case-insensitively. Error names use exact match.
|
|
122
|
+
|
|
123
|
+
### Internal constants (not configurable)
|
|
124
|
+
|
|
125
|
+
| Constant | Value | Description |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `ABORT_CONTINUE_DELAY_MS` | `2000` | Delay between abort and continue |
|
|
128
|
+
| `MAX_IDLE_SESSIONS` | `50` | Idle session map cap before cleanup |
|
|
129
|
+
| `IDLE_CLEANUP_MS` | `600000` | Idle session age before cleanup (10 min) |
|
|
130
|
+
| `SESSION_DISCOVERY_INTERVAL_MS` | `60000` | Session discovery sweep interval (60s). Same on both builds; v2 reads the busy set from `session.active()` rather than a `status` field per row |
|
|
131
|
+
|
|
132
|
+
## Migrating from v1 to v2
|
|
133
|
+
|
|
134
|
+
Moving the plugin itself needs no action beyond the config key — the same npm package loads on both server generations and every recovery feature works unchanged. What changes:
|
|
135
|
+
|
|
136
|
+
1. **Config key renamed: `plugin` → `plugins`.** Local paths must start with `./` or `../` and resolve relative to the config file. Disable directives (`"-plugin-id"`, `"*"`) still work and are matched against the plugin's exported `id`. See [Installation](#installation) for the full config syntax.
|
|
137
|
+
2. **Option names.** All v1 options carry over. The only rename is `maxRecoveryRetries` → `maxRetries` (the v1 name is still accepted as an alias, so a v1 config ports unchanged); `activeUserWindowMs` now defaults to 5 minutes.
|
|
138
|
+
3. **Todos.** v2 has no built-in todo tools or events. The open-todos nudges need a todo plugin that persists `todowrite` calls to the session message log — see [Complementary plugins](#complementary-plugins-for-v2). Without one, every other recovery path runs unchanged.
|
|
139
|
+
4. **Verify.** Run `/status` inside opencode — it lists loaded plugins; `opencode-auto-resume` must be there on both v1 and v2.
|
|
140
|
+
|
|
141
|
+
## Features
|
|
6
142
|
|
|
7
143
|
LLM sessions fail in predictable ways. This plugin monitors all sessions and automatically recovers without user intervention. Each recovery path below references the upstream OpenCode issues that motivated it — these are problems not yet resolved in the official project.
|
|
8
144
|
|
|
9
|
-
|
|
145
|
+
### Recovery paths
|
|
10
146
|
|
|
11
|
-
|
|
147
|
+
#### Stall recovery
|
|
12
148
|
|
|
13
149
|
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
150
|
|
|
151
|
+
Three budgets bound this path, because each answers a different question: `maxRetries` (default 3) is the per-episode ladder — it restarts when the model produces text, so a model that keeps replying "everything is done" reads 1/3 forever. `loopMaxContinues` (default 3 per `loopWindowMs`, 10 min) catches rapid-fire loops and escalates to abort+resume. `lifetimeMaxContinues` (default 9) is the lifetime cap per genuine user episode — it only resets on a genuine user message or genuine turn, never on model text, so the "done" loop above stops at 9 total continues and the session stands down until you speak.
|
|
152
|
+
|
|
15
153
|
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
154
|
|
|
155
|
+
Token flow counts as activity. The watchdog measures silence from events, but a turn generating tokens for minutes emits no other events — so every `session.usage.updated` event whose token total grew resets the silence clock. A flat total (nothing resumed) does not reset it, so a genuinely wedged session still trips the watchdog on schedule.
|
|
156
|
+
|
|
17
157
|
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.).
|
|
18
158
|
|
|
159
|
+
Per-session model profiles: Stall thresholds and retry budgets are selected per-session based on the model provider. Local models (running on your hardware) get more patience; online models (cloud APIs) get tighter timeouts.
|
|
160
|
+
|
|
161
|
+
**Local providers** (slow profile): `llama-server`, `ollama`, `vllm`, `lmstudio`, `koboldcpp`, `exllama`, `text-generation-inference`, `llamacpp`, and aliases.
|
|
162
|
+
|
|
163
|
+
**Online providers** (fast profile): everything else, including `opencode/*`.
|
|
164
|
+
|
|
165
|
+
| | Fast (online) | Slow (local) |
|
|
166
|
+
|---|---|---|
|
|
167
|
+
| `chunkTimeoutMs` | 180s | 600s |
|
|
168
|
+
| `gracePeriodMs` | 3s | 60s |
|
|
169
|
+
| `maxRetries` | 3 | 5 |
|
|
170
|
+
| `lifetimeMaxContinues` | 9 | 20 |
|
|
171
|
+
|
|
172
|
+
Override per-profile via `defaultFast` / `defaultSlow`, or per-provider via `modelProfiles`:
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
{
|
|
176
|
+
"modelProfiles": {
|
|
177
|
+
"llama-server": { "lifetimeMaxContinues": 20 }
|
|
178
|
+
},
|
|
179
|
+
"defaultSlow": { "chunkTimeoutMs": 900000 }
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The profile is resolved at stall-detection time (not session start), so mid-session model switches are handled automatically.
|
|
184
|
+
|
|
19
185
|
_Motivated by:_
|
|
20
186
|
- [#34214](https://github.com/anomalyco/opencode/issues/34214) — Opencode freezes / becomes unresponsive mid-session
|
|
21
187
|
- [#35207](https://github.com/anomalyco/opencode/issues/35207) — Session hangs indefinitely after MCP tool-call — no timeout recovery (deadlock)
|
|
22
188
|
- [#31655](https://github.com/anomalyco/opencode/issues/31655) — Window completely frozen/unresponsive after reopening a project with a stalled session
|
|
23
189
|
- [#34460](https://github.com/anomalyco/opencode/issues/34460) — Thread freezes after switching to Go model — requires Escape key to unstick
|
|
24
190
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
### Tool calls as raw text
|
|
191
|
+
#### Tool calls as raw text
|
|
28
192
|
|
|
29
193
|
The model prints tool invocations as raw XML/JSON (`<function=edit>...`, `{"type":"function",...}`) instead of executing them. The session goes idle normally but the tool was never run. On idle, the plugin fetches the last messages and scans for XML tool-call patterns — including truncated and alternative formats. If found, it sends `TOOL_TEXT_RECOVERY_PROMPT` to force a clean tool call.
|
|
30
194
|
|
|
@@ -36,23 +200,19 @@ _Motivated by:_
|
|
|
36
200
|
- [#33959](https://github.com/anomalyco/opencode/issues/33959) — OpenCode Desktop does not execute valid OpenAI tool calls from qwen3-coder:30b (Ollama)
|
|
37
201
|
- [#35689](https://github.com/anomalyco/opencode/issues/35689) — DeepSeek silently stops executing (interleaved reasoning_content dropped in tool call messages)
|
|
38
202
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
### Hallucination loop
|
|
203
|
+
#### Hallucination loop
|
|
42
204
|
|
|
43
205
|
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.
|
|
44
206
|
|
|
45
207
|
A separate **tool-call loop detector** 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 busy turn) to break the loop instead of blindly continuing.
|
|
46
208
|
|
|
47
|
-
Loop detection runs in two places. At idle, tool names are scanned from recent assistant messages. **Live**, every `tool.execute.before` hook fingerprints the call as `tool name + arguments` — so a subagent stuck re-reading the same file/range (even alternating between two near-identical argument sets, which never produces 3 consecutive identical calls) is caught after 6+ calls in the repeating cycle. On live detection the plugin aborts the running turn immediately (this is the sanctioned exception to the never-abort-busy rule: 6+ identical name+args fingerprints prove a hallucinated loop, and the current call has not started yet) and then sends `TOOL_LOOP_RECOVERY_PROMPT`. Esc-cancelled sessions are never touched.
|
|
209
|
+
Loop detection runs in two places. At idle, tool names are scanned from recent assistant messages. **Live**, every `tool.execute.before` hook fingerprints the call as `tool name + arguments` — so a subagent stuck re-reading the same file/range (even alternating between two near-identical argument sets, which never produces 3 consecutive identical calls) is caught after 6+ calls in the repeating cycle. (v1 hooks; v2 fingerprints the same way from `session.tool.called` events via `trackToolCall`.) On live detection the plugin aborts the running turn immediately (this is the sanctioned exception to the never-abort-busy rule: 6+ identical name+args fingerprints prove a hallucinated loop, and the current call has not started yet) and then sends `TOOL_LOOP_RECOVERY_PROMPT`. Esc-cancelled sessions are never touched.
|
|
48
210
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
### Unknown tool suggestion
|
|
211
|
+
#### Unknown tool suggestion
|
|
52
212
|
|
|
53
213
|
When the model calls a tool that does not exist (a typo, a hallucinated name, or a tool from a different plugin that isn't loaded), OpenCode returns a `tool` part with `state.status = "error"`. If the same wrong tool name appears **2 times** in the session's message history, the plugin:
|
|
54
214
|
|
|
55
|
-
1. Fetches the list of available tools via `ctx.client.tool.ids()` (cached for 5 minutes
|
|
215
|
+
1. Fetches the list of available tools — via `ctx.client.tool.ids()` on v1, or `ctx.tool.list()` on v2, cached for 5 minutes. On v2 the names are the registry's *effective* names, so a namespaced tool is quoted back in the form a model would have to call it.
|
|
56
216
|
2. Computes the closest match by Levenshtein distance (case-insensitive, threshold = half the wrong name's length).
|
|
57
217
|
3. Sends a continue prompt that names the wrong tool, states it does not exist, suggests the closest match (if one was found), and lists the first 20 available tools for reference.
|
|
58
218
|
|
|
@@ -60,6 +220,8 @@ The suggestion fires once per busy cycle. A new user message resets the counter
|
|
|
60
220
|
|
|
61
221
|
Threshold and cache are compile-time constants (`UNKNOWN_TOOL_THRESHOLD = 2`, `TOOL_IDS_CACHE_MS = 5 min`). The detection runs at idle, alongside the tool-text recovery check.
|
|
62
222
|
|
|
223
|
+
On v2 the tool list comes from the registry, so the check degrades rather than guesses: no registry, an empty registry, or a registry that throws all mean no suggestion, because with nothing to compare against every name would look invented.
|
|
224
|
+
|
|
63
225
|
_Motivated by:_
|
|
64
226
|
- [#22142](https://github.com/anomalyco/opencode/issues/22142) — Repetitive tool-call loops with alibaba-coding-plan-cn/qwen3.6-plus
|
|
65
227
|
- [#16218](https://github.com/anomalyco/opencode/issues/16218) — Model repeats the same response in a loop after generating an answer
|
|
@@ -67,20 +229,16 @@ _Motivated by:_
|
|
|
67
229
|
- [#35784](https://github.com/anomalyco/opencode/issues/35784) — opencode-go/glm-5.2 + read file loop
|
|
68
230
|
- [#25129](https://github.com/anomalyco/opencode/issues/25129) — Thinking mode gets stuck in infinite repetition loop
|
|
69
231
|
|
|
70
|
-
|
|
232
|
+
#### Orphan parent
|
|
71
233
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
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.
|
|
234
|
+
A subagent finishes but the parent session stays stuck as "busy" forever. The plugin detects when `busyCount` drops from >1 to 1, confirms the survivor really has subagents, waits `subagentWaitMs` + `gracePeriodMs` (18s default), probes the subagent (nudging a crashed child once, then waiting a further `subagentWaitMs` before giving up on it), and aborts and resumes the parent if it is still stuck. The parent is never interrupted while it has a tool in flight or is waiting on the user.
|
|
75
235
|
|
|
76
236
|
_Motivated by:_
|
|
77
237
|
- [#35066](https://github.com/anomalyco/opencode/issues/35066) — notify parent when subagent sessions finish
|
|
78
238
|
- [#33050](https://github.com/anomalyco/opencode/issues/33050) — orphaned sessions continue looping after abort, causing sustained high CPU
|
|
79
239
|
- [#32335](https://github.com/anomalyco/opencode/issues/32335) — opencode run processes don't exit after completing scheduled work, causing memory leak
|
|
80
240
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
### Subagent stuck detection
|
|
241
|
+
#### Subagent stuck detection
|
|
84
242
|
|
|
85
243
|
Detects when a subagent hasn't received new text for >1 minute (or >3 minutes if a tool call is in progress). If stuck, sends a recovery prompt to the subagent before triggering abort+resume on the parent. This avoids killing a parent that merely has a slow child.
|
|
86
244
|
|
|
@@ -89,11 +247,9 @@ _Motivated by:_
|
|
|
89
247
|
- [#35806](https://github.com/anomalyco/opencode/issues/35806) — malformed tool-input stream ordering crashes Session drains
|
|
90
248
|
- [#32580](https://github.com/anomalyco/opencode/issues/32580) — Agent showing repeated thinking
|
|
91
249
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
### Streaming failure recovery
|
|
250
|
+
#### Streaming failure recovery
|
|
95
251
|
|
|
96
|
-
The AI provider's streaming response can fail mid-stream (connection reset, timeout, socket close). When the provider reports an error whose **name** matches `streamingFailureErrorNames` (exact, case-sensitive) or whose **message** matches `streamingFailureMessagePatterns` (regex, case-insensitive), the plugin arms a deferred recovery instead of relying only on the generic stall timeout. Invalid regex patterns fall back to substring matching.
|
|
252
|
+
The AI provider's streaming response can fail mid-stream (connection reset, timeout, socket close). When the provider reports an error whose **name** matches `streamingFailureErrorNames` (exact, case-sensitive) or whose **message** matches `streamingFailureMessagePatterns` (regex, case-insensitive), the plugin arms a deferred recovery instead of relying only on the generic stall timeout. Invalid regex patterns fall back to substring matching. (Same classification on both builds; what happens *after* classification differs — v1 flow below, v2 differences noted inline.)
|
|
97
253
|
|
|
98
254
|
**Default error names:**
|
|
99
255
|
- `ProviderError`, `APIError`, `StreamError`, `ConnectionError`, `TimeoutError`
|
|
@@ -101,7 +257,9 @@ The AI provider's streaming response can fail mid-stream (connection reset, time
|
|
|
101
257
|
**Default message patterns:**
|
|
102
258
|
- `streaming response failed`, `stream.*fail`, `connection.*reset`, `connection.*closed`
|
|
103
259
|
|
|
104
|
-
|
|
260
|
+
**Recovery behavior:**
|
|
261
|
+
|
|
262
|
+
v1 flow (v2 classifies identically — same option names — but feeds the unified `recover()` ladder instead: consecutive failure recoveries with no model output between them count up in `consecFailureRecovers` and latch `failureLoopLatched` rather than cycling forever; there is no `pendingRecovery*` state on v2):
|
|
105
263
|
|
|
106
264
|
1. **Detection**: `session.error` is classified via `isStreamingFailure()` against the configured error names and message patterns
|
|
107
265
|
2. **State transition**: the session's `pendingRecovery` flag is armed with the error name (`pendingRecoveryReason`) and timestamp (`pendingRecoveryAt`)
|
|
@@ -109,178 +267,134 @@ The AI provider's streaming response can fail mid-stream (connection reset, time
|
|
|
109
267
|
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
|
|
110
268
|
5. **Escalation**: when retries are exhausted, the plugin aborts the session and resumes it (`abort+resume`); `gaveUp` is set if that also fails
|
|
111
269
|
|
|
112
|
-
|
|
270
|
+
Recovery chain: `pendingRecovery` → recovery attempt → `recoveryAttempts` retry → `abort+resume` → `gaveUp`.
|
|
113
271
|
|
|
114
|
-
|
|
272
|
+
_Motivated by:_
|
|
273
|
+
- EPIC: Streaming Failure Recovery — recovery requests not consistently creating new assistant executions after mid-stream failures
|
|
115
274
|
|
|
116
|
-
|
|
117
|
-
{
|
|
118
|
-
"streamingFailureErrorNames": ["ProviderError", "APIError", "StreamError", "ConnectionError", "TimeoutError"],
|
|
119
|
-
"streamingFailureMessagePatterns": ["streaming response failed", "stream.*fail", "connection.*reset", "connection.*closed"],
|
|
120
|
-
"maxRecoveryRetries": 2,
|
|
121
|
-
"baseBackoffMs": 1000,
|
|
122
|
-
"maxBackoffMs": 8000
|
|
123
|
-
}
|
|
124
|
-
```
|
|
275
|
+
#### Silent dead-stream recovery
|
|
125
276
|
|
|
126
|
-
|
|
277
|
+
The model stream can die after emitting only reasoning — no text part, no tool call — finalizing with a finish reason such as `finish: "unknown"` (any finish reason qualifies, including `"stop"`). 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, **zero tool-call parts**, and at least `silentDeadStreamMinTokens` output tokens, the plugin sends a recovery prompt. A finished turn that issued tool calls is working, not silent — thinking models routinely end tool-working turns with no chatter text . As a second guard, recovery is refused while tool calls are still in flight, read from a snapshot taken at the idle transition (`markIdle` zeroes the live counter, and idle fires while tools run). 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).
|
|
127
278
|
|
|
128
|
-
|
|
129
|
-
- `pendingRecovery` — failure detected, recovery armed
|
|
130
|
-
- `pendingRecoveryReason` — error name that triggered the recovery
|
|
131
|
-
- `pendingRecoveryAt` — detection timestamp (backoff anchor)
|
|
132
|
-
- `recoveryAttempts` — recovery attempt counter
|
|
133
|
-
- `watchdogRetryGuard` — watchdog retry in progress (keeps the recovery armed)
|
|
279
|
+
### Completion detection
|
|
134
280
|
|
|
135
|
-
|
|
281
|
+
#### Explicit completion via `task_complete`
|
|
136
282
|
|
|
137
|
-
|
|
283
|
+
The agent can call the `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.
|
|
138
284
|
|
|
139
|
-
|
|
285
|
+
On v2 there is no built-in equivalent, so the plugin registers the tool itself through the v2 tool registry (`ctx.tool.transform`). It is registered before the first turn, and a registry that refuses the registration is logged and skipped rather than taking the watchdog down with it.
|
|
140
286
|
|
|
141
|
-
|
|
142
|
-
1. AI provider starts streaming response
|
|
143
|
-
2. Network interruption causes "connection reset" error mid-stream
|
|
144
|
-
3. System detects "ConnectionError" matches streamingFailureErrorNames
|
|
145
|
-
4. Session's pendingRecovery flag is armed (reason=ConnectionError)
|
|
146
|
-
5. Session goes idle; timer loop waits until the backoff delay has elapsed
|
|
147
|
-
6. Recovery prompt sent (recoveryAttempts=1)
|
|
148
|
-
7. Success → session busy → recovery flags cleared
|
|
149
|
-
Still not busy after `toolTextCheckDelayMs` (3s default) → watchdog retry (attempt 2/2)
|
|
150
|
-
Still not busy → maxRecoveryRetries reached → abort + resume
|
|
151
|
-
Abort+continue fails → gaveUp
|
|
152
|
-
```
|
|
287
|
+
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.
|
|
153
288
|
|
|
154
|
-
|
|
289
|
+
Repeat calls with no new user message in between are guarded: the first acknowledgement carries an explicit stop instruction, the second returns a repeat warning, and the third and subsequent calls are rejected as errors. This breaks the ack self-loop where the acknowledgement tool-result is fed back into the turn and a stuck model re-emits `task_complete` instead of ending with text — v1 logged 27 consecutive acked calls with zero new user input. The counter resets on a genuine user message, on the block path above, or when any other tool runs in between.
|
|
155
290
|
|
|
156
|
-
|
|
157
|
-
|--------|------|---------|-------------|
|
|
158
|
-
| `streamingFailureErrorNames` | `string[]` | `["ProviderError","APIError","StreamError","ConnectionError","TimeoutError"]` | Error names that indicate a streaming failure (exact, case-sensitive) |
|
|
159
|
-
| `streamingFailureMessagePatterns` | `string[]` | `["streaming response failed","stream.*fail","connection.*reset","connection.*closed"]` | Regex patterns matching streaming failure messages (case-insensitive) |
|
|
160
|
-
| `maxRecoveryRetries` | `number` | `2` | Maximum streaming-failure recovery attempts before abort+resume escalation |
|
|
161
|
-
| `baseBackoffMs` | `number` | `1000` | Initial backoff delay in milliseconds |
|
|
162
|
-
| `maxBackoffMs` | `number` | `8000` | Maximum backoff delay cap in milliseconds |
|
|
291
|
+
The todo gate is skipped for subagents: a child was never asked to do the parent's open items, so gating its report on them would block it on work outside its scope.
|
|
163
292
|
|
|
164
|
-
|
|
165
|
-
- [EPIC: Streaming Failure Recovery](docs/EPIC-Streaming-Recovery-OpenCode-Auto-Resume-v3.md) — recovery requests not consistently creating new assistant executions after mid-stream failures
|
|
293
|
+
#### 🎉 emoji completion
|
|
166
294
|
|
|
167
|
-
|
|
295
|
+
An assistant message ending with 🎉 latches completion exactly the way a trailing `TASK COMPLETE` does — todo cross-check first, latch plus early return on success, reminder nudge instead when work is listed. Neither path touches the tool-text timer directly; both prevent triggers by latching and ending the inspection. The emoji signals the agent considers the task complete.
|
|
168
296
|
|
|
169
|
-
|
|
297
|
+
The emoji alone is not trusted: a model that finishes early celebrates early, and latching on that turns a false positive into silence. Both builds cross-check the session's todo list first, and with items still open the 🎉 is treated as a false positive — the reminder names what is unfinished instead. See [Todo list nudges](#todo-list-nudges) for how v2 reads that list.
|
|
170
298
|
|
|
171
|
-
|
|
299
|
+
#### Stop statements
|
|
172
300
|
|
|
173
|
-
|
|
301
|
+
End-anchored patterns over the last three lines: bare `TASK COMPLETE`, `no action needed`, `stopping here/there/now`, `nothing further/else/pending…`, `no further action/work…`, `none pending/left…`, `stopping/halted/pausing…`, `that's all…`, `parked/not started…`. A connective clause after the stop ("Stopping here for a moment, then continuing") disqualifies it — a stop is only a stop if it is the last clause. Like every text signal, a stop claim with open todos produces the reminder nudge instead of latching. Standby phrasing (`standing by for next instructions`) and ready-and-waiting phrasing (`ready when you have the next task`, `ready for your next instruction`) latch the same way: the model is out of work, which is done, not ready-to-continue (that family auto-sends `continue`; this one stays quiet). A question mark disqualifies the ready pattern — `ready for the next task?` is still-working, never a stop.
|
|
174
302
|
|
|
175
|
-
|
|
303
|
+
Mined from live stall replies, all end-anchored the same way: "Stopping here." (covered), "Standing by.", "Nothing in progress …" (bare "in progress" alone must never match — "work is still in progress" is the opposite), bare "Done.". Conversely "I'll report X when it lands" is future intent, not completion, and matches nothing — documented here so a future widening doesn't swallow it.
|
|
176
304
|
|
|
177
|
-
|
|
305
|
+
Case discipline: the system's own literal is the one case-sensitive pattern in the build — bare `TASK COMPLETE` must read byte-exact. Every other completion pattern is case-insensitive. A lowercase `task complete` is model prose and latches nothing; past-tense `task completed` (any case) still latches — it was never the literal.
|
|
178
306
|
|
|
179
|
-
|
|
307
|
+
#### Done-claim verification
|
|
180
308
|
|
|
181
|
-
|
|
309
|
+
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 and the response carries no work description, the plugin sends `DONE_WITHOUT_DETAILS_PROMPT` — an imperative prompt demanding a concrete report (files changed, commands run, results). A response that already contains file paths, verification output, or result sections satisfies the demand on its own and never triggers the prompt. The budget is capped at `maxRetries` across busy cycles (going busy no longer re-arms it); only a genuinely new inbound user message re-arms it. v1 reads the real `todo.updated` event state; v2 reads the message log (newest completed `todowrite` call) — neither uses regex on the message text for the list itself.
|
|
182
310
|
|
|
183
|
-
|
|
184
|
-
- [#26063](https://github.com/anomalyco/opencode/issues/26063) — Tool execution aborted/terminated
|
|
185
|
-
- [#31459](https://github.com/anomalyco/opencode/issues/31459) — "Tool execution aborted" during Preparing write
|
|
186
|
-
- [#5937](https://github.com/Mte90/opencode-auto-resume) — (internal) must never abort a tool execution
|
|
311
|
+
#### Ready-to-continue auto-resume
|
|
187
312
|
|
|
188
|
-
|
|
313
|
+
When the assistant prints phrases like "Ready to continue with task" or "Proceeding with task", the plugin automatically sends `"continue"` without waiting for the user. This catches the common pattern where the model stops to ask for permission it doesn't need.
|
|
189
314
|
|
|
190
|
-
|
|
315
|
+
#### Action-intent nudge
|
|
191
316
|
|
|
192
|
-
When
|
|
317
|
+
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`.
|
|
193
318
|
|
|
194
|
-
|
|
195
|
-
- [#35126](https://github.com/anomalyco/opencode/issues/35126) — Subagents launched via task tool ignore their model: frontmatter and inherit parent agent's model
|
|
196
|
-
- [#35899](https://github.com/anomalyco/opencode/issues/35899) — Web: switching sessions overwrites user-selected model with agent default
|
|
197
|
-
- [#34562](https://github.com/anomalyco/opencode/issues/34562) — Model provider not restored correctly when switching projects
|
|
319
|
+
### Safety rails
|
|
198
320
|
|
|
199
|
-
|
|
321
|
+
#### Active-tool safety guard
|
|
322
|
+
|
|
323
|
+
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. (v1 hooks; v2 derives the same state from `session.tool.called`/`success`/`failed` events — `pendingTools` plus per-call start times.) 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.
|
|
324
|
+
|
|
325
|
+
_Motivated by:_
|
|
326
|
+
- [#26063](https://github.com/anomalyco/opencode/issues/26063) — Tool execution aborted/terminated
|
|
327
|
+
- [#31459](https://github.com/anomalyco/opencode/issues/31459) — "Tool execution aborted" during Preparing write
|
|
328
|
+
- [#5937](https://github.com/Mte90/opencode-auto-resume) — (internal) must never abort a tool execution
|
|
200
329
|
|
|
201
|
-
|
|
330
|
+
#### ESC cancel respected
|
|
202
331
|
|
|
203
|
-
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.
|
|
332
|
+
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. (v1 mechanism; v2 reads `session.execution.interrupted` by reason — only genuine user stops latch — and tracks own aborts with the self-abort window instead.) The grace period (`gracePeriodMs`) also lets late ESC/status events arrive before any action.
|
|
204
333
|
|
|
205
|
-
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.
|
|
334
|
+
The back-off lifts as soon as the user sends a new prompt in that session (`chat.message` hook; v2 reads new inbound messages out of history instead): 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.
|
|
206
335
|
|
|
207
336
|
_Motivated by:_
|
|
208
337
|
- [#28453](https://github.com/anomalyco/opencode/issues/28453) — ACP session/cancel emits agent_error for MessageAbortedError before cancelled result
|
|
209
338
|
- [#32432](https://github.com/anomalyco/opencode/issues/32432) — Cancelled subagents can't be opened in TUI + Ctrl+X intermittently fails
|
|
210
339
|
- [#30144](https://github.com/anomalyco/opencode/issues/30144) — Early prompt cancel can poison directory instance
|
|
211
340
|
|
|
212
|
-
|
|
341
|
+
#### User-input awareness
|
|
213
342
|
|
|
214
|
-
|
|
343
|
+
The session is not stalled while the ball is in the user's court. Three gates stand down idle nudges:
|
|
215
344
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
Repeat calls with no new user message in between are guarded: the first acknowledgement carries an explicit stop instruction, the second returns a repeat warning, and the third and subsequent calls are rejected as errors. This breaks the ack self-loop where the acknowledgement tool-result is fed back into the turn and a stuck model re-emits `task_complete` instead of ending with text. The counter resets on a genuine user message, on the block path above, or when any other tool runs in between.
|
|
221
|
-
|
|
222
|
-
---
|
|
223
|
-
|
|
224
|
-
### 🎉 emoji completion
|
|
225
|
-
|
|
226
|
-
An assistant message ending with 🎉 resets the tool-text timer and prevents a trigger — the emoji signals the agent considers the task complete.
|
|
227
|
-
|
|
228
|
-
---
|
|
229
|
-
|
|
230
|
-
### Ready-to-continue auto-resume
|
|
231
|
-
|
|
232
|
-
When the assistant prints phrases like "Ready to continue with task" or "Proceeding with task", the plugin automatically sends `"continue"` without waiting for the user. This catches the common pattern where the model stops to ask for permission it doesn't need.
|
|
233
|
-
|
|
234
|
-
---
|
|
345
|
+
- **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.
|
|
346
|
+
- **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.
|
|
347
|
+
- **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).
|
|
235
348
|
|
|
236
|
-
|
|
349
|
+
#### False-positive protection during subagent work
|
|
237
350
|
|
|
238
|
-
|
|
351
|
+
Long tool execution or active subagents can look like a stall. Only the session emitting events gets its timer reset (not all sessions). When multiple sessions are busy, stall detection is paused entirely. The plugin also clears existing timers before creating a new `setTimeout` in idle handlers to prevent queued continue triggers.
|
|
239
352
|
|
|
240
|
-
|
|
353
|
+
#### Model, agent & provider preservation
|
|
241
354
|
|
|
242
|
-
|
|
355
|
+
When resuming with `"continue"`, the plugin extracts agent, model, and provider from the last session message — falling back to `msg.info.agent` / `msg.info.model` if the top-level field is missing. This preserves the user's UI selection across resumes instead of reverting to the default agent.
|
|
243
356
|
|
|
244
|
-
|
|
357
|
+
_Motivated by:_
|
|
358
|
+
- [#35126](https://github.com/anomalyco/opencode/issues/35126) — Subagents launched via task tool ignore their model: frontmatter and inherit parent agent's model
|
|
359
|
+
- [#35899](https://github.com/anomalyco/opencode/issues/35899) — Web: switching sessions overwrites user-selected model with agent default
|
|
360
|
+
- [#34562](https://github.com/anomalyco/opencode/issues/34562) — Model provider not restored correctly when switching projects
|
|
245
361
|
|
|
246
|
-
|
|
362
|
+
### Session housekeeping
|
|
247
363
|
|
|
248
|
-
|
|
364
|
+
#### Session discovery & cleanup
|
|
249
365
|
|
|
250
|
-
|
|
366
|
+
Periodically calls `session.list()` (every 60s) to pick up sessions that were missed by event tracking. Idle sessions are cleaned up after 10 minutes or when the idle map exceeds 50 entries, preventing memory leaks.
|
|
251
367
|
|
|
252
|
-
|
|
253
|
-
-
|
|
368
|
+
_Motivated by:_
|
|
369
|
+
- [#35750](https://github.com/anomalyco/opencode/issues/35750) — Upgrade to 1.17.x hides pre-existing sessions — new path column not back-filled during migration
|
|
370
|
+
- [#33102](https://github.com/anomalyco/opencode/issues/33102) — OpenCode Go workspace subscription is orphaned/hidden and cannot be managed from dashboard
|
|
371
|
+
- [#27759](https://github.com/anomalyco/opencode/issues/27759) — Session heartbeat for multi-session liveness detection
|
|
254
372
|
|
|
255
|
-
|
|
373
|
+
#### Pausing auto-resume
|
|
256
374
|
|
|
257
|
-
|
|
375
|
+
Long-running quota blocks (or any delicate work) can be paused without deleting the session:
|
|
258
376
|
|
|
259
|
-
|
|
377
|
+
- Slash command: `/auto-resume off` pauses the current session, `/auto-resume on` resumes, `/auto-resume` (or `/auto-resume status`) reports state. While paused, auto-resume will not inject a continue nudge and will not arm the quota ladder.
|
|
378
|
+
- CLI helper (works without opening the chat): `auto-resume-toggle <sessionID> off|on|status`, plus `auto-resume-toggle --all off|on` to pause/resume every session.
|
|
379
|
+
- Pause is sticky within a run: it stays off until an explicit `on`, so a forgotten pause won't silently re-arm. A restart of opencode/OpenChamber clears all pauses (re-arms auto-resume), so a stale pause can never outlive a session. (The marker file is `~/.config/opencode/auto-resume.paused`.)
|
|
260
380
|
|
|
261
|
-
|
|
381
|
+
#### Todo list nudges
|
|
262
382
|
|
|
263
|
-
|
|
383
|
+
Opencode v1 ships todos out of the box — built-in `todowrite`/`todoread` tools, a `todo` table, and a `todo.updated` event the plugin subscribes to directly. OpenCode v2 removed all of it (tools, table, and event), so a v2 session has no todo list by default. auto-resume does not own one either. It reads the session message log, where every `todowrite` call is persisted as a tool part carrying the whole list, and the newest completed call is the current list.
|
|
264
384
|
|
|
265
|
-
|
|
385
|
+
The writer is the todo plugin you install. The upstream [opencode-todolist](https://github.com/aiev/opencode-todolist) restores `todowrite` and `todoread` in v2 (its sidebar strip appears in opencode-cli only — no other surface shows it); the confirmed pairing here is upstream itself (v0.6.0+, verified live — the `opencode-todo-fork` interim fork is retired). You can view the list at any time with the `/todo` command. Any plugin that persists `todowrite` calls to the message log should work, but only upstream has been tested.
|
|
266
386
|
|
|
267
|
-
|
|
268
|
-
- [#33687](https://github.com/anomalyco/opencode/issues/33687) — Interrupted assistant messages retain non-error finish value (orphan tool-input-start abort)
|
|
269
|
-
- [#28958](https://github.com/anomalyco/opencode/issues/28958) — Plugin hook rejection aborts unrelated parallel sessions
|
|
270
|
-
- [#25899](https://github.com/anomalyco/opencode/issues/25899) — ACP prompt() returns stopReason: end_turn + zero usage on user cancel
|
|
387
|
+
The todo plugin is optional, and without it the unfinished-task nudges never trigger. Every other recovery path runs fine on its own; the list gates exactly the nudges that check it. With no `todowrite` call in the log, auto-resume sees no list and falls back to the older behaviour (latch on the 🎉 emoji, demand a report on a bare done-claim). It never nudges on the strength of a list it failed to read.
|
|
271
388
|
|
|
272
|
-
|
|
389
|
+
When a turn ends with items still open, the nudge names them:
|
|
273
390
|
|
|
274
|
-
|
|
391
|
+
> You have 2 unfinished tasks: 1. [pending] Verify the plugin loads under v2 2. [in_progress] Write the todo-nudges section of the README Please continue working on these tasks.
|
|
275
392
|
|
|
276
|
-
|
|
393
|
+
`buildOpenTodosReminder` sends that verbatim, numbering each open item (`pending` or `in_progress`) and falling back to a bare `continue` when the list is unusable. Four paths send it: a 🎉 celebration with items still open, a `task_complete` with items still open, a stop statement with work listed, and a completion claim with work listed. The ready-to-continue check reads the same list in reverse: a "ready" claim while every todo is already closed gets two chances before a plain `continue`.
|
|
277
394
|
|
|
278
|
-
|
|
279
|
-
- [#35750](https://github.com/anomalyco/opencode/issues/35750) — Upgrade to 1.17.x hides pre-existing sessions — new path column not back-filled during migration
|
|
280
|
-
- [#33102](https://github.com/anomalyco/opencode/issues/33102) — OpenCode Go workspace subscription is orphaned/hidden and cannot be managed from dashboard
|
|
281
|
-
- [#27759](https://github.com/anomalyco/opencode/issues/27759) — Session heartbeat for multi-session liveness detection
|
|
395
|
+
#### Context saturation → magic-context wrapup
|
|
282
396
|
|
|
283
|
-
|
|
397
|
+
A session can fill its usable context window without stalling — it just keeps working until it chokes. The plugin tracks token usage from `message.updated` events and computes the ratio against the model's usable window (`context − min(20k, maxOutput)`, mirroring OpenCode's own overflow math). On idle, when the ratio crosses `contextSaturationThreshold` (default 0.85), routing depends on session kind. For parent sessions, only when magic-context is detected in the host's configured plugin list (`config.get().plugin`), the plugin invokes the registered `ctx-wrapup` command through `client.session.command()` — it does not send `/ctx-wrapup` as prompt text, because prompt text is not expanded into a command. For subagent sessions identified by `parentID` on `session.created`, the default is no intervention: magic-context does run bounded cleanup for subagents (structural-noise/cleared-reasoning strips, heuristic drops, ceiling nudges), but its hard protections — historian compartments, emergency fail-closed abort, caveman compression — skip subagents, and on true overflow the error just propagates to the parent with no deterministic reclaim. Terminal reclamation therefore depends on the agent calling `ctx_reduce` when nudged. If `subagentNativeCompactionEnabled` is `true`, the plugin calls native `session.summarize()` as an opt-in safety net. Fail-safe: provider lookup errors, unknown model limits, user cancellation, or completion signals mean no intervention. Magic-context absence additionally disables only the parent path (the `ctx-wrapup` command would not be registered); the opt-in subagent path needs no magic-context detection because `session.summarize()` is native. This path is magic-context-gated on purpose: magic-context's setup disables OpenCode's native compaction, so unconditionally summarizing a magic-context-managed parent would double-compress and fight its cache-aware historian. One intervention per busy cycle.
|
|
284
398
|
|
|
285
399
|
## Recovery model
|
|
286
400
|
|
|
@@ -302,146 +416,31 @@ Fires when the session still reports **busy** but no events arrived for `chunkTi
|
|
|
302
416
|
|
|
303
417
|
**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.
|
|
304
418
|
|
|
419
|
+
**What the prompt looks like.** Stall continues are rich by default (`richContinuePrompt`, v2 only): reason, attempt and episode-retry count (`attempt 1/3 (retry #4)`), idle duration, local timestamp, open todos, and the no-new-work / no-destructive-actions guardrails, e.g. `continue — stalled (no activity for 186s). attempt 1/3 (retry #1); idle 3m 6s; Oct 12, 7:37 PM EDT. Only continue…`. Set `richContinuePrompt: false` for the bare `"continue"` (v1 behavior), or set `continuePrompt` to any custom text, which always wins verbatim.
|
|
420
|
+
|
|
305
421
|
### 3. Abort-first recovery
|
|
306
422
|
|
|
307
423
|
Aborts the active run, then continues. The only family that can unblock a live runner.
|
|
308
424
|
|
|
309
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).
|
|
310
426
|
|
|
311
|
-
##
|
|
427
|
+
## Complementary plugins (for v2)
|
|
312
428
|
|
|
313
|
-
|
|
314
|
-
Any SSE Event
|
|
315
|
-
├─ has sessionID? → touchSession(sid) — reset only that session's timer
|
|
316
|
-
└─ no sessionID → ignore
|
|
317
|
-
|
|
318
|
-
session.status events:
|
|
319
|
-
├─ busy → reset timer, clear retry counters
|
|
320
|
-
├─ retry → touch session
|
|
321
|
-
├─ interrupted → user cancel: back off until the next user message
|
|
322
|
-
└─ idle → schedule tool-text check (3s delay)
|
|
323
|
-
└─ fetch messages → scan for XML / thinking-tool patterns
|
|
324
|
-
├─ found → send recovery prompt (with backoff)
|
|
325
|
-
└─ not found → check ready-to-continue / done-claim patterns
|
|
326
|
-
└─ orphan check: busyCount dropped from >1 to 1?
|
|
327
|
-
└─ subagentWaitMs watch → abort + continue
|
|
328
|
-
|
|
329
|
-
todo.updated events:
|
|
330
|
-
└─ track real todo state (not regex on message text)
|
|
331
|
-
└─ if missing (no event received): idle path + task_complete
|
|
332
|
-
fetch on-demand via session.todo() API so the
|
|
333
|
-
open-todos reminder and the 🎉 completion latch see
|
|
334
|
-
real state, not a stale empty array.
|
|
335
|
-
|
|
336
|
-
Timer loop (every 5s):
|
|
337
|
-
for each busy session:
|
|
338
|
-
├─ orphan watch active? → wait or abort+continue
|
|
339
|
-
├─ busyCount > 1? → skip (subagent running)
|
|
340
|
-
├─ active tool running? → skip (never abort tools)
|
|
341
|
-
└─ idle > 48s? → busyStallStrategy:
|
|
342
|
-
off → skip · abort → abort+continue
|
|
343
|
-
else → hallucination loop? abort : continue with backoff
|
|
344
|
-
|
|
345
|
-
Periodic (every 60s): session.list() to discover missed sessions
|
|
346
|
-
Periodic: cleanup idle sessions older than 10min or >50 entries
|
|
347
|
-
```
|
|
348
|
-
|
|
349
|
-
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`.
|
|
350
|
-
|
|
351
|
-
## Installation
|
|
352
|
-
|
|
353
|
-
### Opencode
|
|
354
|
-
|
|
355
|
-
Add to your `opencode.jsonc`:
|
|
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.
|
|
356
430
|
|
|
357
|
-
|
|
358
|
-
{
|
|
359
|
-
"$schema": "https://opencode.ai/config.json",
|
|
360
|
-
"plugin": ["opencode-auto-resume"]
|
|
361
|
-
}
|
|
362
|
-
```
|
|
363
|
-
|
|
364
|
-
With options:
|
|
365
|
-
|
|
366
|
-
```jsonc
|
|
367
|
-
{
|
|
368
|
-
"plugin": [
|
|
369
|
-
["opencode-auto-resume", {
|
|
370
|
-
"chunkTimeoutMs": 180000,
|
|
371
|
-
"gracePeriodMs": 3000,
|
|
372
|
-
"maxRetries": 3
|
|
373
|
-
}]
|
|
374
|
-
]
|
|
375
|
-
}
|
|
376
|
-
```
|
|
377
|
-
|
|
378
|
-
## Configuration
|
|
379
|
-
|
|
380
|
-
```json
|
|
381
|
-
{
|
|
382
|
-
"plugin": [
|
|
383
|
-
[
|
|
384
|
-
"file:///home/YOURUSER/.config/opencode/plugins/opencode-auto-resume/dist/index.js",
|
|
385
|
-
{ "chunkTimeoutMs": 180000, "maxRetries": 3 }
|
|
386
|
-
]
|
|
387
|
-
]
|
|
388
|
-
}
|
|
389
|
-
```
|
|
390
|
-
|
|
391
|
-
### Configurable options
|
|
392
|
-
|
|
393
|
-
| Option | Default | Description |
|
|
431
|
+
| Plugin | Required? | If absent |
|
|
394
432
|
|---|---|---|
|
|
395
|
-
|
|
|
396
|
-
|
|
|
397
|
-
| `checkIntervalMs` | `5000` | Timer poll interval |
|
|
398
|
-
| `maxRetries` | `3` | Max auto-resume attempts before giving up |
|
|
399
|
-
| `baseBackoffMs` | `1000` | First retry delay (doubles each attempt) |
|
|
400
|
-
| `maxBackoffMs` | `8000` | Backoff cap |
|
|
401
|
-
| `subagentWaitMs` | `15000` | Wait before treating orphan parent as stuck |
|
|
402
|
-
| `loopMaxContinues` | `3` | Continues in window before triggering abort |
|
|
403
|
-
| `loopWindowMs` | `600000` | Hallucination loop detection window (10 min) |
|
|
404
|
-
| `streamingFailureErrorNames` | `["ProviderError","APIError","StreamError","ConnectionError","TimeoutError"]` | Error names that classify as streaming failures (exact match) |
|
|
405
|
-
| `streamingFailureMessagePatterns` | `["streaming response failed","stream.*fail","connection.*reset","connection.*closed"]` | Regex patterns (case-insensitive) in error messages indicating streaming failure |
|
|
406
|
-
| `maxRecoveryRetries` | `2` | Max streaming-failure recovery attempts before abort+resume escalation |
|
|
407
|
-
| `toolTextCheckDelayMs` | `3000` | Delay before scanning an idle session for tool-as-text; also the recovery watchdog delay |
|
|
408
|
-
| `minActivityGapMs` | `1000` | Skip recovery if the session was active within this gap |
|
|
409
|
-
| `warmupMs` | `15000` | Action-intent detection disabled while a session is younger than this |
|
|
410
|
-
| `discoveryDelayMs` | `5000` | Delay before the initial session discovery run after plugin attach |
|
|
411
|
-
| `debug` | `false` | Enable `[debug]` console diagnostics |
|
|
412
|
-
| `resumeOnActionIntent` | `true` | Enable action-intent (`:`-terminated line) nudges |
|
|
413
|
-
| `continuePrompt` | `"continue"` | Prompt text for stall/streaming/dead-stream recovery |
|
|
414
|
-
| `actionIntentPrompt` | same as `continuePrompt` | Prompt sent on action-intent detection |
|
|
415
|
-
| `toolTextRecoveryPrompt` | `TOOL_TEXT_RECOVERY_PROMPT` | Override the tool-call-as-text recovery prompt |
|
|
416
|
-
| `thinkingToolRecoveryPrompt` | `THINKING_TOOL_RECOVERY_PROMPT` | Override the thinking-tool recovery prompt |
|
|
417
|
-
| `doneWithoutWorkPrompt` | `DONE_WITHOUT_WORK_PROMPT` | Override the done-claim-with-open-todos prompt |
|
|
418
|
-
| `doneWithoutDetailsPrompt` | `DONE_WITHOUT_DETAILS_PROMPT` | Override the done-claim-with-no-todos report prompt |
|
|
419
|
-
| `doneClaimPatterns` | `DONE_CLAIM_PATTERNS` | Array of regex strings overriding the default done-claim detection patterns (case-insensitive, multiline). Invalid regexes are skipped. Empty array falls back to defaults. |
|
|
420
|
-
| `readyToContinuePatterns` | `READY_TO_CONTINUE_PATTERNS` | Array of regex strings overriding the default ready-to-continue detection patterns (case-insensitive). Invalid regexes are skipped. Empty array falls back to defaults. |
|
|
421
|
-
| `silentDeadStreamMinTokens` | `200` | Min output tokens to treat a textless `finish:"unknown"` message as a dead stream |
|
|
422
|
-
| `busyStallStrategy` | `"continue"` | Busy-stall response: `"continue"`, `"abort"` (abort-first), or `"off"` (disabled) |
|
|
423
|
-
| `contextSaturationThreshold` | `0.85` | Ratio of used/usable context that routes a saturated parent to magic-context `ctx-wrapup` (only when magic-context is installed) |
|
|
424
|
-
| `activeUserWindowMs` | `300000` | Inbound-user-message recency window (5 min) during which idle nudges stand down (user likely composing) |
|
|
425
|
-
| `subagentNativeCompactionEnabled` | `false` | Opt-in native `session.summarize()` for saturated subagent sessions (no magic-context detection required) |
|
|
426
|
-
|
|
427
|
-
Message patterns are matched case-insensitively. Error names use exact match.
|
|
428
|
-
|
|
429
|
-
### Internal constants (not configurable)
|
|
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 |
|
|
430
435
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
| `ABORT_CONTINUE_DELAY_MS` | `2000` | Delay between abort and continue |
|
|
434
|
-
| `MAX_IDLE_SESSIONS` | `50` | Idle session map cap before cleanup |
|
|
435
|
-
| `IDLE_CLEANUP_MS` | `600000` | Idle session age before cleanup (10 min) |
|
|
436
|
-
| `SESSION_DISCOVERY_INTERVAL_MS` | `60000` | `session.list()` poll interval (60s) |
|
|
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
|
|
437
438
|
|
|
438
|
-
## Verification
|
|
439
|
+
## Verification & troubleshooting
|
|
439
440
|
|
|
440
441
|
To verify the plugin is loaded, run `/status` inside OpenCode — it lists all loaded plugins and their versions. You should see `opencode-auto-resume` in the list.
|
|
441
442
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
## Troubleshooting
|
|
443
|
+
On v2 a clean load writes nothing at startup: warnings and recovery lines go to the server process output, not a file, and there is no ready banner. To confirm the config reached the plugin, trigger any recovery — every action is logged to the process output.
|
|
445
444
|
|
|
446
445
|
| Problem | Solution |
|
|
447
446
|
|---|---|
|
|
@@ -450,6 +449,12 @@ The plugin handles all recovery automatically — no manual intervention needed.
|
|
|
450
449
|
| Too slow to react | Decrease `checkIntervalMs` to `2000` |
|
|
451
450
|
| Orphan parent not detected | Increase `subagentWaitMs` to `20000` |
|
|
452
451
|
| Hallucination loop not caught | Decrease `loopMaxContinues` to `2` |
|
|
453
|
-
|
|
|
452
|
+
| Model keeps saying "done", nudged forever | Decrease `lifetimeMaxContinues` (e.g. `5`); raise it for long unattended runs |
|
|
453
|
+
| Tool-text not detected | Check server logs — requires SDK message fetching (v1). On v2 the history comes from the client message surface or loopback HTTP — check the server process output instead |
|
|
454
454
|
| Long-running tool killed | Should not happen — active-tool guard prevents it. Report a bug. |
|
|
455
|
+
| No plugin activity in the log | The entry is not in a location opencode scans, or not listed in `plugins`; fix the config and restart opencode. |
|
|
456
|
+
| Log says `ctx.app.version is missing` (or `ctx.serverUrl is missing` on v1) | The build was loaded on the wrong server generation. Update the npm package (the universal entry auto-selects the branch) or match the build file to the server. |
|
|
457
|
+
| Never recovers a parent waiting on a subagent | Intentional: parent sessions blocked on a running subagent are left alone. |
|
|
458
|
+
| Never recovers while a permission dialog is open | Intentional: recovery is held until the permission is answered. |
|
|
459
|
+
| Recovers but you don't see a notice (v2) | Your model/provider may reject `session.synthetic()`; the plugin falls back to `session.prompt()` (no TUI banner). |
|
|
455
460
|
| `Unexpected server error` on startup | Update to v1.1.8+. Caused by non-Plugin exports being treated as plugin entrypoints by OpenCode's loader. Clear the plugin cache (`~/.cache/opencode/packages/opencode-auto-resume@*`) and restart. |
|