opencode-auto-resume 1.0.23 → 1.1.1
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 +163 -30
- package/dist/index.js +122 -71
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,42 +1,162 @@
|
|
|
1
1
|
# opencode-auto-resume
|
|
2
2
|
|
|
3
|
-
**Plugin for [OpenCode](https://github.com/anomalyco/opencode) that automatically detects and recovers from LLM session failures — stalls, broken tool calls, hallucination loops,
|
|
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
|
## What it does
|
|
6
6
|
|
|
7
|
-
LLM sessions fail in predictable ways. This plugin monitors all sessions and automatically recovers without user intervention.
|
|
7
|
+
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
8
|
|
|
9
|
-
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
### Stall recovery
|
|
12
|
+
|
|
13
|
+
The stream goes silent but the session stays "busy". The UI shows a blinking cursor with no progress. If no events arrive for 48 seconds (`chunkTimeoutMs` + `gracePeriodMs`), the plugin sends `"continue"` with exponential backoff. After 3 failed attempts it gives up.
|
|
10
14
|
|
|
11
15
|
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.).
|
|
12
|
-
_( [#55](https://github.com/anomalyco/opencode/issues/55), [#199](https://github.com/anomalyco/opencode/issues/199), [#283](https://github.com/anomalyco/opencode/issues/283) )_
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
_Motivated by:_
|
|
18
|
+
- [#34214](https://github.com/anomalyco/opencode/issues/34214) — Opencode freezes / becomes unresponsive mid-session
|
|
19
|
+
- [#35207](https://github.com/anomalyco/opencode/issues/35207) — Session hangs indefinitely after MCP tool-call — no timeout recovery (deadlock)
|
|
20
|
+
- [#31655](https://github.com/anomalyco/opencode/issues/31655) — Window completely frozen/unresponsive after reopening a project with a stalled session
|
|
21
|
+
- [#34460](https://github.com/anomalyco/opencode/issues/34460) — Thread freezes after switching to Go model — requires Escape key to unstick
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
### Tool calls as raw text
|
|
26
|
+
|
|
27
|
+
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.
|
|
28
|
+
|
|
29
|
+
Also detects tool calls trapped inside **thinking/reasoning** parts and emits `THINKING_TOOL_RECOVERY_PROMPT` to extract them.
|
|
30
|
+
|
|
31
|
+
_Motivated by:_
|
|
32
|
+
- [#31247](https://github.com/anomalyco/opencode/issues/31247) — Copilot Claude Opus 4.8 emits pseudo tool-call text instead of structured tool calls
|
|
33
|
+
- [#34126](https://github.com/anomalyco/opencode/issues/34126) — OpenAI Chat parser treats standalone text before tool_calls as assistant text
|
|
34
|
+
- [#33959](https://github.com/anomalyco/opencode/issues/33959) — OpenCode Desktop does not execute valid OpenAI tool calls from qwen3-coder:30b (Ollama)
|
|
35
|
+
- [#35689](https://github.com/anomalyco/opencode/issues/35689) — DeepSeek silently stops executing (interleaved reasoning_content dropped in tool call messages)
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
### Hallucination loop
|
|
40
|
+
|
|
41
|
+
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
|
+
|
|
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.
|
|
44
|
+
|
|
45
|
+
_Motivated by:_
|
|
46
|
+
- [#22142](https://github.com/anomalyco/opencode/issues/22142) — Repetitive tool-call loops with alibaba-coding-plan-cn/qwen3.6-plus
|
|
47
|
+
- [#16218](https://github.com/anomalyco/opencode/issues/16218) — Model repeats the same response in a loop after generating an answer
|
|
48
|
+
- [#33216](https://github.com/anomalyco/opencode/issues/33216) — OpenCode Repeatedly Ignores Instructions and Loops Responses
|
|
49
|
+
- [#35784](https://github.com/anomalyco/opencode/issues/35784) — opencode-go/glm-5.2 + read file loop
|
|
50
|
+
- [#25129](https://github.com/anomalyco/opencode/issues/25129) — Thinking mode gets stuck in infinite repetition loop
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
### Orphan parent
|
|
55
|
+
|
|
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.
|
|
57
|
+
|
|
58
|
+
_Motivated by:_
|
|
59
|
+
- [#35066](https://github.com/anomalyco/opencode/issues/35066) — notify parent when subagent sessions finish
|
|
60
|
+
- [#33050](https://github.com/anomalyco/opencode/issues/33050) — orphaned sessions continue looping after abort, causing sustained high CPU
|
|
61
|
+
- [#32335](https://github.com/anomalyco/opencode/issues/32335) — opencode run processes don't exit after completing scheduled work, causing memory leak
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
### Subagent stuck detection
|
|
66
|
+
|
|
67
|
+
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.
|
|
68
|
+
|
|
69
|
+
_Motivated by:_
|
|
70
|
+
- [#35073](https://github.com/anomalyco/opencode/issues/35073) — subagent permission asks hang indefinitely (sync subagents treated as interactive)
|
|
71
|
+
- [#35806](https://github.com/anomalyco/opencode/issues/35806) — malformed tool-input stream ordering crashes Session drains
|
|
72
|
+
- [#32580](https://github.com/anomalyco/opencode/issues/32580) — Agent showing repeated thinking
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
### Active-tool safety guard
|
|
77
|
+
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
_Motivated by:_
|
|
81
|
+
- [#26063](https://github.com/anomalyco/opencode/issues/26063) — Tool execution aborted/terminated
|
|
82
|
+
- [#31459](https://github.com/anomalyco/opencode/issues/31459) — "Tool execution aborted" during Preparing write
|
|
83
|
+
- [#5937](https://github.com/Mte90/opencode-auto-resume) — (internal) must never abort a tool execution
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
### Model, agent & provider preservation
|
|
88
|
+
|
|
89
|
+
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.
|
|
16
90
|
|
|
17
|
-
|
|
18
|
-
|
|
91
|
+
_Motivated by:_
|
|
92
|
+
- [#35126](https://github.com/anomalyco/opencode/issues/35126) — Subagents launched via task tool ignore their model: frontmatter and inherit parent agent's model
|
|
93
|
+
- [#35899](https://github.com/anomalyco/opencode/issues/35899) — Web: switching sessions overwrites user-selected model with agent default
|
|
94
|
+
- [#34562](https://github.com/anomalyco/opencode/issues/34562) — Model provider not restored correctly when switching projects
|
|
19
95
|
|
|
20
|
-
|
|
21
|
-
_( [#122](https://github.com/anomalyco/opencode/issues/122), [#199](https://github.com/anomalyco/opencode/issues/199), [#246](https://github.com/anomalyco/opencode/issues/246) )_
|
|
96
|
+
---
|
|
22
97
|
|
|
23
|
-
|
|
24
|
-
_( [#55](https://github.com/anomalyco/opencode/issues/55), [#221](https://github.com/anomalyco/opencode/issues/221) )_
|
|
98
|
+
### ESC cancel respected
|
|
25
99
|
|
|
26
|
-
|
|
100
|
+
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.
|
|
27
101
|
|
|
28
|
-
|
|
102
|
+
_Motivated by:_
|
|
103
|
+
- [#28453](https://github.com/anomalyco/opencode/issues/28453) — ACP session/cancel emits agent_error for MessageAbortedError before cancelled result
|
|
104
|
+
- [#32432](https://github.com/anomalyco/opencode/issues/32432) — Cancelled subagents can't be opened in TUI + Ctrl+X intermittently fails
|
|
105
|
+
- [#30144](https://github.com/anomalyco/opencode/issues/30144) — Early prompt cancel can poison directory instance
|
|
29
106
|
|
|
30
|
-
|
|
31
|
-
_( [#128](https://github.com/anomalyco/opencode/pulls/128), [#22](https://github.com/anomalyco/opencode/pulls/22) )_
|
|
107
|
+
---
|
|
32
108
|
|
|
33
|
-
|
|
109
|
+
### Explicit completion via `task_complete`
|
|
34
110
|
|
|
35
|
-
|
|
36
|
-
_( [#111](https://github.com/anomalyco/opencode/issues/111), [#277](https://github.com/anomalyco/opencode/issues/277) )_
|
|
111
|
+
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.
|
|
37
112
|
|
|
38
|
-
|
|
39
|
-
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
### 🎉 emoji completion
|
|
116
|
+
|
|
117
|
+
An assistant message ending with 🎉 resets the tool-text timer and prevents a trigger — the emoji signals the agent considers the task complete.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
### Ready-to-continue auto-resume
|
|
122
|
+
|
|
123
|
+
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.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
### Done-claim verification
|
|
128
|
+
|
|
129
|
+
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.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
### False-positive protection during subagent work
|
|
134
|
+
|
|
135
|
+
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.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
### Spurious error suppression
|
|
140
|
+
|
|
141
|
+
After normal completion, OpenCode sometimes fires a `session.error`. All logging goes through `ctx.client.app.log()` (zero `console.log`), and errors on already-idle sessions are silently ignored.
|
|
142
|
+
|
|
143
|
+
_Motivated by:_
|
|
144
|
+
- [#33687](https://github.com/anomalyco/opencode/issues/33687) — Interrupted assistant messages retain non-error finish value (orphan tool-input-start abort)
|
|
145
|
+
- [#28958](https://github.com/anomalyco/opencode/issues/28958) — Plugin hook rejection aborts unrelated parallel sessions
|
|
146
|
+
- [#25899](https://github.com/anomalyco/opencode/issues/25899) — ACP prompt() returns stopReason: end_turn + zero usage on user cancel
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
### Session discovery & cleanup
|
|
151
|
+
|
|
152
|
+
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.
|
|
153
|
+
|
|
154
|
+
_Motivated by:_
|
|
155
|
+
- [#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
|
|
156
|
+
- [#33102](https://github.com/anomalyco/opencode/issues/33102) — OpenCode Go workspace subscription is orphaned/hidden and cannot be managed from dashboard
|
|
157
|
+
- [#27759](https://github.com/anomalyco/opencode/issues/27759) — Session heartbeat for multi-session liveness detection
|
|
158
|
+
|
|
159
|
+
---
|
|
40
160
|
|
|
41
161
|
## Architecture
|
|
42
162
|
|
|
@@ -47,17 +167,21 @@ Any SSE Event
|
|
|
47
167
|
|
|
48
168
|
session.status events:
|
|
49
169
|
├─ busy → reset timer, clear retry counters
|
|
50
|
-
└─ idle → schedule tool-text check (
|
|
51
|
-
└─ fetch messages → scan for XML patterns
|
|
170
|
+
└─ idle → schedule tool-text check (3s delay)
|
|
171
|
+
└─ fetch messages → scan for XML / thinking-tool patterns
|
|
52
172
|
├─ found → send recovery prompt (with backoff)
|
|
53
|
-
└─ not found →
|
|
173
|
+
└─ not found → check ready-to-continue / done-claim patterns
|
|
54
174
|
└─ orphan check: busyCount dropped from >1 to 1?
|
|
55
|
-
└─
|
|
175
|
+
└─ subagentWaitMs watch → abort + continue
|
|
176
|
+
|
|
177
|
+
todo.updated events:
|
|
178
|
+
└─ track real todo state (not regex on message text)
|
|
56
179
|
|
|
57
180
|
Timer loop (every 5s):
|
|
58
181
|
for each busy session:
|
|
59
182
|
├─ orphan watch active? → wait or abort+continue
|
|
60
183
|
├─ busyCount > 1? → skip (subagent running)
|
|
184
|
+
├─ active tool running? → skip (never abort tools)
|
|
61
185
|
└─ idle > 48s? → hallucination loop? abort : continue with backoff
|
|
62
186
|
|
|
63
187
|
Periodic (every 60s): session.list() to discover missed sessions
|
|
@@ -119,6 +243,8 @@ bun run build
|
|
|
119
243
|
}
|
|
120
244
|
```
|
|
121
245
|
|
|
246
|
+
### Configurable options
|
|
247
|
+
|
|
122
248
|
| Option | Default | Description |
|
|
123
249
|
|---|---|---|
|
|
124
250
|
| `chunkTimeoutMs` | `45000` | Inactivity timeout before considering stream stalled |
|
|
@@ -131,13 +257,19 @@ bun run build
|
|
|
131
257
|
| `loopMaxContinues` | `3` | Continues in window before triggering abort |
|
|
132
258
|
| `loopWindowMs` | `600000` | Hallucination loop detection window (10 min) |
|
|
133
259
|
|
|
134
|
-
|
|
260
|
+
### Internal constants (not configurable)
|
|
135
261
|
|
|
136
|
-
|
|
262
|
+
| Constant | Value | Description |
|
|
263
|
+
|---|---|---|
|
|
264
|
+
| `TOOL_TEXT_CHECK_DELAY_MS` | `3000` | Delay before scanning idle session for tool-as-text |
|
|
265
|
+
| `ABORT_CONTINUE_DELAY_MS` | `2000` | Delay between abort and continue |
|
|
266
|
+
| `MAX_IDLE_SESSIONS` | `50` | Idle session map cap before cleanup |
|
|
267
|
+
| `IDLE_CLEANUP_MS` | `600000` | Idle session age before cleanup (10 min) |
|
|
268
|
+
| `SESSION_DISCOVERY_INTERVAL_MS` | `60000` | `session.list()` poll interval (60s) |
|
|
269
|
+
|
|
270
|
+
## Verification
|
|
137
271
|
|
|
138
|
-
|
|
139
|
-
2. Let a session idle for 48 seconds — it should auto-resume
|
|
140
|
-
3. Check logs for `Stream stall` or `Ready-to-continue pattern detected`
|
|
272
|
+
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.
|
|
141
273
|
|
|
142
274
|
The plugin handles all recovery automatically — no manual intervention needed.
|
|
143
275
|
|
|
@@ -151,3 +283,4 @@ The plugin handles all recovery automatically — no manual intervention needed.
|
|
|
151
283
|
| Orphan parent not detected | Increase `subagentWaitMs` to `20000` |
|
|
152
284
|
| Hallucination loop not caught | Decrease `loopMaxContinues` to `2` |
|
|
153
285
|
| Tool-text not detected | Check server logs — requires SDK message fetching |
|
|
286
|
+
| Long-running tool killed | Should not happen — active-tool guard prevents it. Report a bug. |
|
package/dist/index.js
CHANGED
|
@@ -12346,6 +12346,7 @@ var ABORT_CONTINUE_DELAY_MS = 2000;
|
|
|
12346
12346
|
var DEFAULT_LOOP_MAX_CONTINUES = 3;
|
|
12347
12347
|
var DEFAULT_LOOP_WINDOW_MS = 10 * 60000;
|
|
12348
12348
|
var TOOL_TEXT_CHECK_DELAY_MS = 3000;
|
|
12349
|
+
var MIN_ACTIVITY_GAP_MS = 1000;
|
|
12349
12350
|
var MAX_IDLE_SESSIONS = 50;
|
|
12350
12351
|
var IDLE_CLEANUP_MS = 10 * 60000;
|
|
12351
12352
|
var SESSION_DISCOVERY_INTERVAL_MS = 60000;
|
|
@@ -12533,9 +12534,17 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
12533
12534
|
const props = ev.properties;
|
|
12534
12535
|
return ev.error ?? props?.error;
|
|
12535
12536
|
}
|
|
12536
|
-
function
|
|
12537
|
+
function getStatusType(ev) {
|
|
12537
12538
|
const props = ev.properties;
|
|
12538
|
-
|
|
12539
|
+
const rawStatus = ev.status ?? props?.status;
|
|
12540
|
+
if (typeof rawStatus === "string")
|
|
12541
|
+
return rawStatus;
|
|
12542
|
+
if (rawStatus && typeof rawStatus === "object") {
|
|
12543
|
+
const type = rawStatus.type;
|
|
12544
|
+
if (typeof type === "string")
|
|
12545
|
+
return type;
|
|
12546
|
+
}
|
|
12547
|
+
return "unknown";
|
|
12539
12548
|
}
|
|
12540
12549
|
function short(sid) {
|
|
12541
12550
|
return sid.length > 12 ? `...${sid.slice(-8)}` : sid;
|
|
@@ -12586,8 +12595,7 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
12586
12595
|
let agent;
|
|
12587
12596
|
let model;
|
|
12588
12597
|
try {
|
|
12589
|
-
const
|
|
12590
|
-
const msgs = extractMessages(msgResp);
|
|
12598
|
+
const msgs = await getSessionMessages(sid);
|
|
12591
12599
|
for (let i = msgs.length - 1;i >= 0; i--) {
|
|
12592
12600
|
const msg = msgs[i];
|
|
12593
12601
|
const role = msg.role ?? msg.info?.role;
|
|
@@ -12663,6 +12671,60 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
12663
12671
|
return response.messages;
|
|
12664
12672
|
return [];
|
|
12665
12673
|
}
|
|
12674
|
+
async function getSessionMessages(sid) {
|
|
12675
|
+
const response = await ctx.client.session.messages({ path: { id: sid } });
|
|
12676
|
+
return extractMessages(response);
|
|
12677
|
+
}
|
|
12678
|
+
function roleOf(msg) {
|
|
12679
|
+
if (!msg)
|
|
12680
|
+
return;
|
|
12681
|
+
return msg.role ?? msg.info?.role;
|
|
12682
|
+
}
|
|
12683
|
+
async function lastAssistantEndsWithCelebration(sid) {
|
|
12684
|
+
try {
|
|
12685
|
+
const msgs = await getSessionMessages(sid);
|
|
12686
|
+
for (let i = msgs.length - 1;i >= 0; i--) {
|
|
12687
|
+
const msg = msgs[i];
|
|
12688
|
+
if (roleOf(msg) !== "assistant")
|
|
12689
|
+
continue;
|
|
12690
|
+
const parts = msg.parts;
|
|
12691
|
+
if (!parts)
|
|
12692
|
+
continue;
|
|
12693
|
+
let text = "";
|
|
12694
|
+
for (const part of parts) {
|
|
12695
|
+
if (part.type === "text") {
|
|
12696
|
+
text += part.text ?? "";
|
|
12697
|
+
}
|
|
12698
|
+
}
|
|
12699
|
+
const normalized = text.trim().replace(/[.!?]+$/, "");
|
|
12700
|
+
return normalized.endsWith("\uD83C\uDF89");
|
|
12701
|
+
}
|
|
12702
|
+
} catch {}
|
|
12703
|
+
return false;
|
|
12704
|
+
}
|
|
12705
|
+
async function getSessionStatusMap() {
|
|
12706
|
+
try {
|
|
12707
|
+
const response = await ctx.client.session.status();
|
|
12708
|
+
const raw = response.data ?? response;
|
|
12709
|
+
const result = {};
|
|
12710
|
+
if (raw && typeof raw === "object") {
|
|
12711
|
+
for (const [sid, val] of Object.entries(raw)) {
|
|
12712
|
+
if (typeof val === "string") {
|
|
12713
|
+
result[sid] = val;
|
|
12714
|
+
} else if (val && typeof val === "object") {
|
|
12715
|
+
const type = val.type;
|
|
12716
|
+
if (typeof type === "string")
|
|
12717
|
+
result[sid] = type;
|
|
12718
|
+
}
|
|
12719
|
+
}
|
|
12720
|
+
}
|
|
12721
|
+
return result;
|
|
12722
|
+
} catch (err) {
|
|
12723
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
12724
|
+
await log("debug", `session.status() failed: ${errMsg}`);
|
|
12725
|
+
return {};
|
|
12726
|
+
}
|
|
12727
|
+
}
|
|
12666
12728
|
const SUBAGENT_STUCK_MS = 60000;
|
|
12667
12729
|
const SUBAGENT_RECOVERY_PROMPT = "It looks like you may have stalled or timed out. Please retry the last operation or continue with the task.";
|
|
12668
12730
|
async function recoverSubagent(subagentSid) {
|
|
@@ -12681,14 +12743,11 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
12681
12743
|
}
|
|
12682
12744
|
async function hasBusySubagents(parentSid) {
|
|
12683
12745
|
try {
|
|
12684
|
-
const
|
|
12685
|
-
const
|
|
12686
|
-
for (const s of allSessions) {
|
|
12687
|
-
const sId = s.id;
|
|
12746
|
+
const statusMap = await getSessionStatusMap();
|
|
12747
|
+
for (const [sId, statusType] of Object.entries(statusMap)) {
|
|
12688
12748
|
if (!sId || sId === parentSid)
|
|
12689
12749
|
continue;
|
|
12690
|
-
|
|
12691
|
-
if (status === "busy")
|
|
12750
|
+
if (statusType === "busy")
|
|
12692
12751
|
return true;
|
|
12693
12752
|
}
|
|
12694
12753
|
return false;
|
|
@@ -12700,21 +12759,16 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
12700
12759
|
}
|
|
12701
12760
|
async function checkSessionHasActiveTool(sid) {
|
|
12702
12761
|
try {
|
|
12703
|
-
const
|
|
12704
|
-
|
|
12705
|
-
const currentSession = sessions2.find((s) => s.id === sid);
|
|
12706
|
-
const status = currentSession?.status;
|
|
12707
|
-
if (status === "busy") {
|
|
12762
|
+
const statusMap = await getSessionStatusMap();
|
|
12763
|
+
if (statusMap[sid] === "busy") {
|
|
12708
12764
|
await log("debug", `Session ${short(sid)} is busy, likely executing a tool`);
|
|
12709
12765
|
return true;
|
|
12710
12766
|
}
|
|
12711
|
-
const
|
|
12712
|
-
const messages = extractMessages(response);
|
|
12767
|
+
const messages = await getSessionMessages(sid);
|
|
12713
12768
|
const lastMsg = messages[messages.length - 1];
|
|
12714
12769
|
if (!lastMsg)
|
|
12715
12770
|
return false;
|
|
12716
|
-
|
|
12717
|
-
if (rawRole !== "assistant")
|
|
12771
|
+
if (roleOf(lastMsg) !== "assistant")
|
|
12718
12772
|
return false;
|
|
12719
12773
|
const toolCall = lastMsg.toolCall;
|
|
12720
12774
|
const toolCalls = lastMsg.tool_calls;
|
|
@@ -12729,22 +12783,17 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
12729
12783
|
}
|
|
12730
12784
|
async function checkSubagentStatus(parentSid) {
|
|
12731
12785
|
try {
|
|
12732
|
-
const
|
|
12733
|
-
const allSessions = extractMessages(response);
|
|
12786
|
+
const statusMap = await getSessionStatusMap();
|
|
12734
12787
|
const now = Date.now();
|
|
12735
12788
|
let hasBusySubagent = false;
|
|
12736
|
-
for (const
|
|
12737
|
-
const sId = s.id;
|
|
12789
|
+
for (const [sId, statusType] of Object.entries(statusMap)) {
|
|
12738
12790
|
if (!sId || sId === parentSid)
|
|
12739
12791
|
continue;
|
|
12740
|
-
|
|
12741
|
-
if (status === "busy") {
|
|
12792
|
+
if (statusType === "busy") {
|
|
12742
12793
|
hasBusySubagent = true;
|
|
12743
|
-
const
|
|
12744
|
-
const messages = extractMessages(msgResponse);
|
|
12794
|
+
const messages = await getSessionMessages(sId);
|
|
12745
12795
|
const lastMsg = messages[messages.length - 1];
|
|
12746
|
-
|
|
12747
|
-
if (lastMsg && rawRole === "assistant" && (("error" in lastMsg) || lastMsg.info && ("error" in lastMsg.info))) {
|
|
12796
|
+
if (lastMsg && roleOf(lastMsg) === "assistant" && (("error" in lastMsg) || lastMsg.info && ("error" in lastMsg.info))) {
|
|
12748
12797
|
await log("debug", `Subagent ${short(sId)} appears crashed`);
|
|
12749
12798
|
return { status: "crashed" };
|
|
12750
12799
|
}
|
|
@@ -12856,10 +12905,7 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
12856
12905
|
return;
|
|
12857
12906
|
await log("debug", `${short(sid)} - checking for tool-call-as-text (attempt ${w.toolTextAttempts + 1})`);
|
|
12858
12907
|
try {
|
|
12859
|
-
const
|
|
12860
|
-
path: { id: sid }
|
|
12861
|
-
});
|
|
12862
|
-
const messages = extractMessages(response);
|
|
12908
|
+
const messages = await getSessionMessages(sid);
|
|
12863
12909
|
const recent = messages.slice(-3);
|
|
12864
12910
|
let bestCandidate = null;
|
|
12865
12911
|
let allAssistantText = "";
|
|
@@ -13014,14 +13060,14 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
13014
13060
|
if (!bestCandidate) {
|
|
13015
13061
|
const todos = w.todos || [];
|
|
13016
13062
|
const hasOpenTodos = todos.some((t) => t.status === "pending" || t.status === "in_progress");
|
|
13017
|
-
if (hasOpenTodos && busyCount() ===
|
|
13063
|
+
if (hasOpenTodos && busyCount() === 0) {
|
|
13018
13064
|
await log("info", `${short(sid)} - no activity detected but todos remain open (${todos.filter((t) => t.status === "pending" || t.status === "in_progress").length} tasks). Sending continue...`);
|
|
13019
13065
|
bestCandidate = {
|
|
13020
13066
|
prompt: "continue",
|
|
13021
13067
|
source: "idle-with-open-todos",
|
|
13022
13068
|
priority: 2
|
|
13023
13069
|
};
|
|
13024
|
-
} else if (hasOpenTodos && busyCount() >
|
|
13070
|
+
} else if (hasOpenTodos && busyCount() > 0) {
|
|
13025
13071
|
await log("debug", `${short(sid)} - todos remain open but ${busyCount()} sessions busy (subagents running), skipping continue`);
|
|
13026
13072
|
}
|
|
13027
13073
|
}
|
|
@@ -13031,7 +13077,7 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
13031
13077
|
w.toolTextAttempts++;
|
|
13032
13078
|
await log("info", `${bestCandidate.source} detected on ${short(sid)}! ` + `Attempt ${w.toolTextAttempts}/${maxRetries}. Sending recovery prompt...`);
|
|
13033
13079
|
const timeSinceActivity = Date.now() - w.lastActivityAt;
|
|
13034
|
-
if (timeSinceActivity <
|
|
13080
|
+
if (timeSinceActivity < MIN_ACTIVITY_GAP_MS) {
|
|
13035
13081
|
await log("info", `${short(sid)} - skipping ${bestCandidate.source}, session was active ${Math.round(timeSinceActivity / 1000)}s ago`);
|
|
13036
13082
|
return;
|
|
13037
13083
|
}
|
|
@@ -13161,18 +13207,14 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
13161
13207
|
timer = setInterval(async () => {
|
|
13162
13208
|
const now = Date.now();
|
|
13163
13209
|
const numBusy = busyCount();
|
|
13210
|
+
const statusMap = await getSessionStatusMap();
|
|
13164
13211
|
for (const [sid, w] of sessions) {
|
|
13165
|
-
|
|
13166
|
-
|
|
13167
|
-
|
|
13168
|
-
|
|
13169
|
-
|
|
13170
|
-
|
|
13171
|
-
w.status = realStatus;
|
|
13172
|
-
if (realStatus === "busy")
|
|
13173
|
-
w.idleSince = null;
|
|
13174
|
-
}
|
|
13175
|
-
} catch {}
|
|
13212
|
+
const realStatus = statusMap[sid];
|
|
13213
|
+
if (realStatus && realStatus !== w.status) {
|
|
13214
|
+
w.status = realStatus;
|
|
13215
|
+
if (realStatus === "busy")
|
|
13216
|
+
w.idleSince = null;
|
|
13217
|
+
}
|
|
13176
13218
|
if (w.status !== "busy")
|
|
13177
13219
|
continue;
|
|
13178
13220
|
if (w.userCancelled)
|
|
@@ -13183,6 +13225,12 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
13183
13225
|
const orphanIdle = now - w.orphanWatchStartAt;
|
|
13184
13226
|
if (orphanIdle >= subagentWaitMs + gracePeriodMs) {
|
|
13185
13227
|
if (w.resumeAttempts < maxRetries) {
|
|
13228
|
+
const hasActiveTool = await checkSessionHasActiveTool(sid);
|
|
13229
|
+
if (hasActiveTool) {
|
|
13230
|
+
await log("debug", `Parent ${short(sid)} has active tool call, skipping orphan-watch abort`);
|
|
13231
|
+
w.orphanWatchStartAt = now;
|
|
13232
|
+
continue;
|
|
13233
|
+
}
|
|
13186
13234
|
const subStatus = await checkSubagentStatus(sid);
|
|
13187
13235
|
if (subStatus.status === "crashed" && subStatus.stuckSid) {
|
|
13188
13236
|
const recovered = await recoverSubagent(subStatus.stuckSid);
|
|
@@ -13218,16 +13266,11 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
13218
13266
|
continue;
|
|
13219
13267
|
w.lastSubagentCheckAt = now;
|
|
13220
13268
|
if (w.lastActivityAt > 0 && now - w.lastActivityAt > subagentWaitMs) {
|
|
13221
|
-
|
|
13222
|
-
|
|
13223
|
-
|
|
13224
|
-
|
|
13225
|
-
|
|
13226
|
-
await log("debug", `Session ${short(sid)} is still busy (real status), skipping abort`);
|
|
13227
|
-
w.lastSubagentCheckAt = now;
|
|
13228
|
-
continue;
|
|
13229
|
-
}
|
|
13230
|
-
} catch {}
|
|
13269
|
+
if (realStatus === "busy") {
|
|
13270
|
+
await log("debug", `Session ${short(sid)} is still busy (real status), skipping abort`);
|
|
13271
|
+
w.lastSubagentCheckAt = now;
|
|
13272
|
+
continue;
|
|
13273
|
+
}
|
|
13231
13274
|
const hasActiveTool = await checkSessionHasActiveTool(sid);
|
|
13232
13275
|
if (hasActiveTool) {
|
|
13233
13276
|
await log("debug", `Session ${short(sid)} has active tool call, skipping abort check`);
|
|
@@ -13286,14 +13329,14 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
13286
13329
|
case "session.status": {
|
|
13287
13330
|
if (!sid)
|
|
13288
13331
|
break;
|
|
13289
|
-
const
|
|
13290
|
-
const statusType = status?.type ?? "unknown";
|
|
13332
|
+
const statusType = getStatusType(ev);
|
|
13291
13333
|
const w = ensureWatch(sid);
|
|
13292
13334
|
w.status = statusType;
|
|
13293
13335
|
if (statusType === "busy") {
|
|
13294
13336
|
w.lastActivityAt = Date.now();
|
|
13295
13337
|
resetSessionFlags(w);
|
|
13296
|
-
|
|
13338
|
+
prevBusyCount = busyCount();
|
|
13339
|
+
log("debug", `${short(sid)} -> busy (${prevBusyCount})`);
|
|
13297
13340
|
} else if (statusType === "idle" || statusType === "interrupted") {
|
|
13298
13341
|
w.status = "idle";
|
|
13299
13342
|
resetIdleFlags(w);
|
|
@@ -13311,9 +13354,19 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
13311
13354
|
if (!w.isSubagent) {
|
|
13312
13355
|
const todos = w.todos || [];
|
|
13313
13356
|
const hasOpenTodos = todos.some((t) => t.status === "pending" || t.status === "in_progress");
|
|
13314
|
-
if (hasOpenTodos && currentBusy ===
|
|
13315
|
-
|
|
13316
|
-
|
|
13357
|
+
if (hasOpenTodos && currentBusy === 0 && !w.toolTextRecovered) {
|
|
13358
|
+
const isCelebration = await lastAssistantEndsWithCelebration(sid);
|
|
13359
|
+
if (isCelebration) {
|
|
13360
|
+
await log("info", `${short(sid)} - \uD83C\uDF89 detected in idle handler, skipping continue`);
|
|
13361
|
+
w.toolTextRecovered = true;
|
|
13362
|
+
if (w.toolTextTimer) {
|
|
13363
|
+
clearTimeout(w.toolTextTimer);
|
|
13364
|
+
w.toolTextTimer = null;
|
|
13365
|
+
}
|
|
13366
|
+
} else {
|
|
13367
|
+
await log("info", `${short(sid)} - idle with ${todos.filter((t) => t.status === "pending" || t.status === "in_progress").length} open todos. Sending continue...`);
|
|
13368
|
+
tryResume(sid, w, "Idle with open todos");
|
|
13369
|
+
}
|
|
13317
13370
|
}
|
|
13318
13371
|
}
|
|
13319
13372
|
if (!w.toolTextRecovered && w.toolTextAttempts < maxRetries) {
|
|
@@ -13399,14 +13452,12 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
13399
13452
|
break;
|
|
13400
13453
|
const props = ev.properties;
|
|
13401
13454
|
const todos = props?.todos ?? [];
|
|
13402
|
-
const w =
|
|
13403
|
-
|
|
13404
|
-
|
|
13405
|
-
|
|
13406
|
-
|
|
13407
|
-
|
|
13408
|
-
}));
|
|
13409
|
-
}
|
|
13455
|
+
const w = ensureWatch(sid);
|
|
13456
|
+
w.todos = todos.map((t) => ({
|
|
13457
|
+
content: t.content ?? "",
|
|
13458
|
+
status: t.status ?? "pending",
|
|
13459
|
+
priority: t.priority ?? "medium"
|
|
13460
|
+
}));
|
|
13410
13461
|
break;
|
|
13411
13462
|
}
|
|
13412
13463
|
case "session.error": {
|
package/package.json
CHANGED