opencode-auto-resume 1.1.17 → 1.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +22 -0
  2. package/dist/index.js +245 -22
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -46,6 +46,20 @@ A separate **tool-call loop detector** catches the model calling the same tool 3
46
46
 
47
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.
48
48
 
49
+ ---
50
+
51
+ ### Unknown tool suggestion
52
+
53
+ 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
+
55
+ 1. Fetches the list of available tools via `ctx.client.tool.ids()` (cached for 5 minutes).
56
+ 2. Computes the closest match by Levenshtein distance (case-insensitive, threshold = half the wrong name's length).
57
+ 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
+
59
+ The suggestion fires once per busy cycle. A new user message resets the counter so the suggestion can fire again on a fresh cycle. Tool parts that already succeeded (`state.status = "completed"`) and parts for tools that do exist are skipped.
60
+
61
+ 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
+
49
63
  _Motivated by:_
50
64
  - [#22142](https://github.com/anomalyco/opencode/issues/22142) — Repetitive tool-call loops with alibaba-coding-plan-cn/qwen3.6-plus
51
65
  - [#16218](https://github.com/anomalyco/opencode/issues/16218) — Model repeats the same response in a loop after generating an answer
@@ -203,6 +217,8 @@ The agent can call the built-in `task_complete` tool to signal that all work is
203
217
 
204
218
  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.
205
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
+
206
222
  ---
207
223
 
208
224
  ### 🎉 emoji completion
@@ -312,6 +328,10 @@ session.status events:
312
328
 
313
329
  todo.updated events:
314
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.
315
335
 
316
336
  Timer loop (every 5s):
317
337
  for each busy session:
@@ -395,6 +415,8 @@ With options:
395
415
  | `thinkingToolRecoveryPrompt` | `THINKING_TOOL_RECOVERY_PROMPT` | Override the thinking-tool recovery prompt |
396
416
  | `doneWithoutWorkPrompt` | `DONE_WITHOUT_WORK_PROMPT` | Override the done-claim-with-open-todos prompt |
397
417
  | `doneWithoutDetailsPrompt` | `DONE_WITHOUT_DETAILS_PROMPT` | Override the done-claim-with-no-todos report prompt |
418
+ | `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. |
419
+ | `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. |
398
420
  | `silentDeadStreamMinTokens` | `200` | Min output tokens to treat a textless `finish:"unknown"` message as a dead stream |
399
421
  | `busyStallStrategy` | `"continue"` | Busy-stall response: `"continue"`, `"abort"` (abort-first), or `"off"` (disabled) |
400
422
  | `contextSaturationThreshold` | `0.85` | Ratio of used/usable context that routes a saturated parent to magic-context `ctx-wrapup` (only when magic-context is installed) |
package/dist/index.js CHANGED
@@ -12363,7 +12363,8 @@ var DEFAULT_STREAMING_FAILURE_MESSAGE_PATTERNS = [
12363
12363
  "streaming response failed",
12364
12364
  "stream.*fail",
12365
12365
  "connection.*reset",
12366
- "connection.*closed"
12366
+ "connection.*closed",
12367
+ "aborted due to timeout"
12367
12368
  ];
12368
12369
  var MAX_IDLE_SESSIONS = 50;
12369
12370
  var IDLE_CLEANUP_MS = 10 * 60000;
@@ -12371,7 +12372,12 @@ var SESSION_DISCOVERY_INTERVAL_MS = 60000;
12371
12372
  var TOOL_TEXT_RECOVERY_PROMPT = "Your last message contained a raw tool call printed as text instead of being executed. " + "Please use the proper tool calling mechanism to execute it.";
12372
12373
  var THINKING_TOOL_RECOVERY_PROMPT = "I noticed you have a tool call generated in your thinking/reasoning. " + "Please execute it using the proper tool calling mechanism instead of keeping it in reasoning.";
12373
12374
  var TOOL_LOOP_RECOVERY_PROMPT = "I notice you've been calling the same tool multiple times in a row without making progress. " + "Please step back and reassess your approach. Consider: " + "1) Are you stuck in a loop? 2) Do you need different information first? " + "3) Should you try a different tool or break the task into smaller steps? " + "Take a moment to think about what's blocking you and propose a different strategy.";
12375
+ var TASK_COMPLETE_ACK = "Task completion acknowledged. No further continuation will be sent. " + "End your turn now with a brief text reply \u2014 do not call task_complete or any other tool again " + "unless the user sends a new message.";
12376
+ var TASK_COMPLETE_REPEAT_WARNING = "Task completion acknowledged already on your previous call \u2014 completion is recorded. " + "End your turn now with a brief text reply. Do not call task_complete again; " + "further repeat calls are rejected as errors.";
12377
+ var TASK_COMPLETE_REPEAT_ERROR = "task_complete already acknowledged twice with no new user message or tool work since \u2014 " + "completion is recorded. End your turn with text and make no further tool calls.";
12374
12378
  var CTX_WRAPUP_TRIGGER = "ctx-wrapup";
12379
+ var UNKNOWN_TOOL_THRESHOLD = 2;
12380
+ var TOOL_IDS_CACHE_MS = 5 * 60000;
12375
12381
  var TOOL_TEXT_PATTERNS = [
12376
12382
  /<function\s*=/i,
12377
12383
  /<function>/i,
@@ -12422,7 +12428,7 @@ function containsToolCallAsText(text) {
12422
12428
  }
12423
12429
  return false;
12424
12430
  }
12425
- function containsReadyToContinuePattern(text) {
12431
+ function containsReadyToContinuePattern(text, patterns = READY_TO_CONTINUE_PATTERNS) {
12426
12432
  const lines = text.split(`
12427
12433
  `);
12428
12434
  const lastLine = lines[lines.length - 1]?.trim();
@@ -12430,7 +12436,7 @@ function containsReadyToContinuePattern(text) {
12430
12436
  return false;
12431
12437
  const lastLines = lines.slice(-3).join(`
12432
12438
  `);
12433
- return READY_TO_CONTINUE_PATTERNS.some((pat) => pat.test(lastLines));
12439
+ return patterns.some((pat) => pat.test(lastLines));
12434
12440
  }
12435
12441
  var DONE_CLAIM_PATTERNS = [
12436
12442
  /^task\s+done[.!]*$/im,
@@ -12450,12 +12456,12 @@ var DONE_CLAIM_PATTERNS = [
12450
12456
  ];
12451
12457
  var DONE_WITHOUT_WORK_PROMPT = "I need you to verify more carefully that you have actually completed all the required tasks. " + "Your response indicated you're done, but no work was detected. Please check your todo list " + "and complete any remaining work.";
12452
12458
  var DONE_WITHOUT_DETAILS_PROMPT = "Your last response claimed the task is complete but contained no work description. This is not acceptable. " + "You MUST respond now with a full, detailed report of everything you did: " + "for each file you modified, state the full path and the exact changes; " + "list every command you ran to verify and its result; state the final outcome. " + "Do NOT reply with 'done', 'task completed', or any short acknowledgment \u2014 " + "your ONLY acceptable response right now is this detailed report. Write it now.";
12453
- function containsDoneClaimPattern(text) {
12459
+ function containsDoneClaimPattern(text, patterns = DONE_CLAIM_PATTERNS) {
12454
12460
  const lines = text.split(`
12455
12461
  `);
12456
12462
  const lastLines = lines.slice(-5).join(`
12457
12463
  `);
12458
- return DONE_CLAIM_PATTERNS.some((pat) => pat.test(lastLines));
12464
+ return patterns.some((pat) => pat.test(lastLines));
12459
12465
  }
12460
12466
  function containsWorkDescription(text) {
12461
12467
  if (/`[^`\n]*\.[a-zA-Z0-9]{1,8}[^`\n]*`/.test(text))
@@ -12466,6 +12472,42 @@ function containsWorkDescription(text) {
12466
12472
  return true;
12467
12473
  return false;
12468
12474
  }
12475
+ function levenshtein(a, b) {
12476
+ const m = a.length, n = b.length;
12477
+ if (m === 0)
12478
+ return n;
12479
+ if (n === 0)
12480
+ return m;
12481
+ let prev = new Array(n + 1);
12482
+ let curr = new Array(n + 1);
12483
+ for (let j = 0;j <= n; j++)
12484
+ prev[j] = j;
12485
+ for (let i = 1;i <= m; i++) {
12486
+ curr[0] = i;
12487
+ for (let j = 1;j <= n; j++) {
12488
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
12489
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
12490
+ }
12491
+ const tmp = prev;
12492
+ prev = curr;
12493
+ curr = tmp;
12494
+ }
12495
+ return prev[n];
12496
+ }
12497
+ function suggestClosestTool(wrongName, available) {
12498
+ const lower = wrongName.toLowerCase();
12499
+ let best = null;
12500
+ let bestDist = Infinity;
12501
+ for (const id of available) {
12502
+ const dist = levenshtein(lower, id.toLowerCase());
12503
+ const threshold = Math.max(2, Math.floor(lower.length / 2));
12504
+ if (dist < bestDist && dist <= threshold) {
12505
+ bestDist = dist;
12506
+ best = id;
12507
+ }
12508
+ }
12509
+ return best;
12510
+ }
12469
12511
  function isStreamingFailure(errorName, errorMessage, errorNames = DEFAULT_STREAMING_FAILURE_ERROR_NAMES, messagePatterns = DEFAULT_STREAMING_FAILURE_MESSAGE_PATTERNS) {
12470
12512
  if (!errorName && !errorMessage)
12471
12513
  return false;
@@ -12633,6 +12675,30 @@ var AutoResumePlugin = async (ctx, options) => {
12633
12675
  const contextSaturationThreshold = options?.contextSaturationThreshold ?? 0.85;
12634
12676
  const subagentNativeCompactionEnabled = options?.subagentNativeCompactionEnabled ?? false;
12635
12677
  const activeUserWindowMs = options?.activeUserWindowMs ?? DEFAULT_ACTIVE_USER_WINDOW_MS;
12678
+ const doneClaimPatterns = (() => {
12679
+ const raw = options?.doneClaimPatterns;
12680
+ if (!Array.isArray(raw) || raw.length === 0)
12681
+ return DONE_CLAIM_PATTERNS;
12682
+ return raw.map((s) => {
12683
+ try {
12684
+ return new RegExp(s, "im");
12685
+ } catch {
12686
+ return null;
12687
+ }
12688
+ }).filter((r) => r !== null);
12689
+ })();
12690
+ const readyToContinuePatterns = (() => {
12691
+ const raw = options?.readyToContinuePatterns;
12692
+ if (!Array.isArray(raw) || raw.length === 0)
12693
+ return READY_TO_CONTINUE_PATTERNS;
12694
+ return raw.map((s) => {
12695
+ try {
12696
+ return new RegExp(s, "i");
12697
+ } catch {
12698
+ return null;
12699
+ }
12700
+ }).filter((r) => r !== null);
12701
+ })();
12636
12702
  const dbg = (...args) => {
12637
12703
  if (debug)
12638
12704
  console.log("[debug]", ...args);
@@ -12707,6 +12773,7 @@ var AutoResumePlugin = async (ctx, options) => {
12707
12773
  orphanWatchStartAt: null,
12708
12774
  aborting: false,
12709
12775
  pluginAbortInFlight: false,
12776
+ pluginAbortAt: 0,
12710
12777
  toolTextRecovered: false,
12711
12778
  toolTextAttempts: 0,
12712
12779
  continueTimestamps: [],
@@ -12727,6 +12794,7 @@ var AutoResumePlugin = async (ctx, options) => {
12727
12794
  completionSignaled: false,
12728
12795
  todoNudgeAttempts: 0,
12729
12796
  taskCompleteOverrides: 0,
12797
+ taskCompleteSignals: 0,
12730
12798
  doneClaimNoTodosAttempts: 0,
12731
12799
  lastUserMessageAt: 0,
12732
12800
  pendingTools: 0,
@@ -12735,7 +12803,10 @@ var AutoResumePlugin = async (ctx, options) => {
12735
12803
  pendingRecoveryReason: null,
12736
12804
  pendingRecoveryAt: 0,
12737
12805
  recoveryAttempts: 0,
12738
- watchdogRetryGuard: false
12806
+ watchdogRetryGuard: false,
12807
+ unknownToolErrors: new Map,
12808
+ unknownToolSuggestionSent: false,
12809
+ checkedToolPartIDs: new Set
12739
12810
  };
12740
12811
  sessions.set(sid, w);
12741
12812
  }
@@ -12871,6 +12942,10 @@ var AutoResumePlugin = async (ctx, options) => {
12871
12942
  }
12872
12943
  if (w.userCancelled || w.completionSignaled)
12873
12944
  return;
12945
+ if (w.gaveUp) {
12946
+ await log("debug", `${short(sid)} - gaveUp latched, refusing further continue prompts`);
12947
+ return;
12948
+ }
12874
12949
  if (!w.continuing)
12875
12950
  dbg(`State transition on ${short(sid)}: continuing=false -> true`);
12876
12951
  w.continuing = true;
@@ -12972,7 +13047,6 @@ var AutoResumePlugin = async (ctx, options) => {
12972
13047
  } catch (err) {
12973
13048
  const errMsg = err instanceof Error ? err.message : String(err);
12974
13049
  await log("warn", `${short(sid)} - recovery retry failed: ${errMsg}`);
12975
- w.recoveryAttempts = 0;
12976
13050
  }
12977
13051
  w.watchdogRetryGuard = false;
12978
13052
  } else {
@@ -12984,8 +13058,11 @@ var AutoResumePlugin = async (ctx, options) => {
12984
13058
  dbg(`Watchdog check on ${short(sid)}: status=${w.status}, recoveryAttempts=${w.recoveryAttempts}, maxRetries=${maxRecoveryRetries}, watchdogLatencyMs=${Date.now() - w.lastRetryAt} -> ABORT_RESUME`);
12985
13059
  const resumed = await tryAbortAndResume(sid, w);
12986
13060
  if (!resumed && !w.aborting) {
12987
- await log("warn", `Recovery exhausted on ${short(sid)}: attempts=${w.recoveryAttempts}, lastError=abort+resume failed`);
12988
- dbg(`Watchdog check on ${short(sid)}: status=${w.status} -> GAVE_UP`);
13061
+ w.gaveUp = true;
13062
+ w.pendingRecovery = false;
13063
+ w.pendingRecoveryReason = null;
13064
+ await log("warn", `Recovery exhausted on ${short(sid)}: attempts=${w.recoveryAttempts}, lastError=abort+resume failed \u2014 GAVE UP (re-arms on next user message)`);
13065
+ dbg(`Watchdog check on ${short(sid)}: status=${w.status} -> GAVE_UP (latched)`);
12989
13066
  if (w.pendingRecoveryAt > 0) {
12990
13067
  dbg(`Total recovery cycle on ${short(sid)} (failed): totalCycleMs=${Date.now() - w.pendingRecoveryAt}`);
12991
13068
  }
@@ -13054,6 +13131,73 @@ var AutoResumePlugin = async (ctx, options) => {
13054
13131
  messagesInflight.set(sid, p);
13055
13132
  return p;
13056
13133
  }
13134
+ let cachedToolIds = null;
13135
+ let cachedToolIdsAt = 0;
13136
+ async function getAvailableToolIds() {
13137
+ if (cachedToolIds && Date.now() - cachedToolIdsAt < TOOL_IDS_CACHE_MS) {
13138
+ return cachedToolIds;
13139
+ }
13140
+ try {
13141
+ const result = await ctx.client.tool.ids();
13142
+ const ids = result.data ?? [];
13143
+ cachedToolIds = ids;
13144
+ cachedToolIdsAt = Date.now();
13145
+ return ids;
13146
+ } catch (e) {
13147
+ const msg = e instanceof Error ? e.message : String(e);
13148
+ await log("warn", `Failed to fetch tool IDs: ${msg}`);
13149
+ return cachedToolIds ?? [];
13150
+ }
13151
+ }
13152
+ async function checkForUnknownToolCalls(sid, w) {
13153
+ if (w.unknownToolSuggestionSent)
13154
+ return false;
13155
+ if (w.userCancelled || w.completionSignaled)
13156
+ return false;
13157
+ try {
13158
+ const available = await getAvailableToolIds();
13159
+ if (available.length === 0)
13160
+ return false;
13161
+ const messages = await getSessionMessages(sid);
13162
+ for (const msg of messages) {
13163
+ const parts = msg.parts;
13164
+ if (!parts)
13165
+ continue;
13166
+ for (const part of parts) {
13167
+ const partType = part.type;
13168
+ if (partType !== "tool")
13169
+ continue;
13170
+ const partId = part.id ?? part.callID ?? "";
13171
+ if (!partId || w.checkedToolPartIDs.has(partId))
13172
+ continue;
13173
+ w.checkedToolPartIDs.add(partId);
13174
+ const state = part.state;
13175
+ if (!state || state.status !== "error")
13176
+ continue;
13177
+ const toolName = part.tool ?? "";
13178
+ if (!toolName || available.includes(toolName))
13179
+ continue;
13180
+ const count = (w.unknownToolErrors.get(toolName) ?? 0) + 1;
13181
+ w.unknownToolErrors.set(toolName, count);
13182
+ if (count < UNKNOWN_TOOL_THRESHOLD)
13183
+ continue;
13184
+ const suggestion = suggestClosestTool(toolName, available);
13185
+ const toolList = available.slice(0, 20).join(", ");
13186
+ const prompt = suggestion ? `You tried to use the tool "${toolName}" ${count} times, but it does not exist. ` + `The closest matching tool is "${suggestion}". ` + `Please use "${suggestion}" instead and adjust your arguments accordingly. ` + `Available tools include: ${toolList}.` : `You tried to use the tool "${toolName}" ${count} times, but it does not exist. ` + `Please check the available tools and use the correct one. ` + `Available tools include: ${toolList}.`;
13187
+ w.unknownToolSuggestionSent = true;
13188
+ w.toolTextRecovered = true;
13189
+ await log("warn", `${short(sid)} - unknown tool "${toolName}" called ${count}x, suggesting "${suggestion ?? "(none)"}"`);
13190
+ await sendContinuePrompt(sid, prompt, w);
13191
+ return true;
13192
+ }
13193
+ }
13194
+ return false;
13195
+ } catch (e) {
13196
+ const msg = e instanceof Error ? e.message : String(e);
13197
+ await log("warn", `${short(sid)} - checkForUnknownToolCalls error: ${msg}`);
13198
+ return false;
13199
+ }
13200
+ }
13057
13201
  function roleOf(msg) {
13058
13202
  if (!msg)
13059
13203
  return;
@@ -13441,6 +13585,15 @@ var AutoResumePlugin = async (ctx, options) => {
13441
13585
  await log("info", `${short(sid)} - user recently active, skipping tool-text check`);
13442
13586
  return;
13443
13587
  }
13588
+ if ((w.todos || []).length === 0) {
13589
+ try {
13590
+ const fetched = await fetchSessionTodos(sid);
13591
+ if (fetched.length > 0)
13592
+ w.todos = fetched;
13593
+ } catch (e) {
13594
+ dbg(`checkForToolCallAsText sid=${short(sid)}: lazy todo fetch error: ${e}`);
13595
+ }
13596
+ }
13444
13597
  const recent = messages.slice(-3);
13445
13598
  let bestCandidate = null;
13446
13599
  let allAssistantText = "";
@@ -13538,7 +13691,7 @@ var AutoResumePlugin = async (ctx, options) => {
13538
13691
  bestCandidate = candidate;
13539
13692
  }
13540
13693
  }
13541
- if (containsReadyToContinuePattern(text)) {
13694
+ if (containsReadyToContinuePattern(text, readyToContinuePatterns)) {
13542
13695
  const todos = w.todos || [];
13543
13696
  const hasOpenTodos = todos.some(isOpenTodo);
13544
13697
  if (!hasOpenTodos && todos.length > 0) {
@@ -13558,16 +13711,42 @@ var AutoResumePlugin = async (ctx, options) => {
13558
13711
  await log("info", `${short(sid)} - skipping continue, todos appear completed (attempt ${w.todoCheckAttempts}/2)`);
13559
13712
  continue;
13560
13713
  }
13561
- const candidate = {
13562
- prompt: containsDoneClaimPattern(text) ? doneWithoutWorkPrompt : continuePrompt,
13563
- source: containsDoneClaimPattern(text) ? "done-claim" : "ready-to-continue",
13564
- priority: 1
13565
- };
13566
- if (!bestCandidate || candidate.priority < bestCandidate.priority) {
13567
- bestCandidate = candidate;
13714
+ if (containsDoneClaimPattern(text, doneClaimPatterns)) {
13715
+ const todos = w.todos || [];
13716
+ const hasOpenTodos = todos.some(isOpenTodo);
13717
+ if (hasOpenTodos) {
13718
+ const candidate = {
13719
+ prompt: doneWithoutWorkPrompt,
13720
+ source: "done-claim",
13721
+ priority: 1
13722
+ };
13723
+ if (!bestCandidate || candidate.priority < bestCandidate.priority) {
13724
+ bestCandidate = candidate;
13725
+ }
13726
+ } else if (!containsWorkDescription(allAssistantText)) {
13727
+ if (w.doneClaimNoTodosAttempts < maxRetries) {
13728
+ const candidate = {
13729
+ prompt: doneWithoutDetailsPrompt,
13730
+ source: "done-claim-no-todos",
13731
+ priority: 1
13732
+ };
13733
+ if (!bestCandidate || candidate.priority < bestCandidate.priority) {
13734
+ bestCandidate = candidate;
13735
+ }
13736
+ }
13737
+ }
13738
+ } else {
13739
+ const candidate = {
13740
+ prompt: continuePrompt,
13741
+ source: "ready-to-continue",
13742
+ priority: 1
13743
+ };
13744
+ if (!bestCandidate || candidate.priority < bestCandidate.priority) {
13745
+ bestCandidate = candidate;
13746
+ }
13568
13747
  }
13569
13748
  }
13570
- if (!bestCandidate && containsDoneClaimPattern(text)) {
13749
+ if (!bestCandidate && containsDoneClaimPattern(text, doneClaimPatterns)) {
13571
13750
  const todos = w.todos || [];
13572
13751
  const hasOpenTodos = todos.some(isOpenTodo);
13573
13752
  if (hasOpenTodos) {
@@ -13652,6 +13831,7 @@ var AutoResumePlugin = async (ctx, options) => {
13652
13831
  await log("info", `${short(sid)} - max open-todos nudges (${maxRetries}) reached, waiting for activity`);
13653
13832
  return;
13654
13833
  }
13834
+ w.todoNudgeAttempts++;
13655
13835
  } else if (isDoneClaimNoTodos) {
13656
13836
  w.doneClaimNoTodosAttempts++;
13657
13837
  } else {
@@ -13707,6 +13887,7 @@ var AutoResumePlugin = async (ctx, options) => {
13707
13887
  const idleSec = Math.round((Date.now() - (w.orphanWatchStartAt ?? w.lastActivityAt)) / 1000);
13708
13888
  await log("info", `Abort+Resume on ${short(sid)} (${idleSec}s idle). Aborting...`);
13709
13889
  w.pluginAbortInFlight = true;
13890
+ w.pluginAbortAt = Date.now();
13710
13891
  try {
13711
13892
  await ctx.client.session.abort({ path: { id: sid } });
13712
13893
  await log("info", `${short(sid)} - abort OK`);
@@ -13716,6 +13897,7 @@ var AutoResumePlugin = async (ctx, options) => {
13716
13897
  await log("warn", `${short(sid)} - abort failed: ${errMsg}`);
13717
13898
  w.aborting = false;
13718
13899
  w.pluginAbortInFlight = false;
13900
+ w.pluginAbortAt = 0;
13719
13901
  return false;
13720
13902
  }
13721
13903
  await new Promise((resolve) => setTimeout(resolve, ABORT_CONTINUE_DELAY_MS));
@@ -13740,6 +13922,7 @@ var AutoResumePlugin = async (ctx, options) => {
13740
13922
  return false;
13741
13923
  } finally {
13742
13924
  w.pluginAbortInFlight = false;
13925
+ w.pluginAbortAt = 0;
13743
13926
  }
13744
13927
  }
13745
13928
  async function tryResume(sid, w, reason, prompt) {
@@ -14283,6 +14466,9 @@ var AutoResumePlugin = async (ctx, options) => {
14283
14466
  w.status = "idle";
14284
14467
  resetIdleFlags(w);
14285
14468
  dbg(`session.idle sid=${short(sid)}: resetIdleFlags done, toolTextRecovered=${w.toolTextRecovered}, toolTextAttempts=${w.toolTextAttempts}, maxRetries=${maxRetries}`);
14469
+ if (!w.unknownToolSuggestionSent && !w.completionSignaled && !w.userCancelled) {
14470
+ checkForUnknownToolCalls(sid, w);
14471
+ }
14286
14472
  if (resumeOnActionIntent && !w.toolTextRecovered && !w.completionSignaled) {
14287
14473
  setTimeout(async () => {
14288
14474
  try {
@@ -14360,6 +14546,9 @@ var AutoResumePlugin = async (ctx, options) => {
14360
14546
  if (w) {
14361
14547
  w.doneClaimNoTodosAttempts = 0;
14362
14548
  w.lastUserMessageAt = Date.now();
14549
+ w.unknownToolErrors.clear();
14550
+ w.unknownToolSuggestionSent = false;
14551
+ w.checkedToolPartIDs.clear();
14363
14552
  }
14364
14553
  break;
14365
14554
  }
@@ -14398,10 +14587,16 @@ var AutoResumePlugin = async (ctx, options) => {
14398
14587
  const isMessageAborted = errorName === "MessageAbortedError";
14399
14588
  if (isMessageAborted) {
14400
14589
  for (const [wSid, w] of sessions) {
14401
- if (!w.pluginAbortInFlight) {
14590
+ const PLUGIN_ABORT_GRACE_MS = 1000;
14591
+ const isOwnAbort = w.pluginAbortInFlight && Date.now() - (w.pluginAbortAt || 0) < PLUGIN_ABORT_GRACE_MS;
14592
+ if (!isOwnAbort) {
14402
14593
  w.userCancelled = true;
14403
14594
  w.status = "idle";
14404
14595
  resetIdleFlags(w);
14596
+ if (w.toolTextTimer) {
14597
+ clearTimeout(w.toolTextTimer);
14598
+ w.toolTextTimer = null;
14599
+ }
14405
14600
  }
14406
14601
  }
14407
14602
  log("info", "User abort (ESC)");
@@ -14452,20 +14647,40 @@ var AutoResumePlugin = async (ctx, options) => {
14452
14647
  }
14453
14648
  }
14454
14649
  const taskCompleteTool = tool({
14455
- description: "Signal that all work is complete and stop automatic continuation prompts. Call this ONLY after finishing everything requested.",
14650
+ description: "Signal that all work is complete and stop automatic continuation prompts. Call this ONLY after finishing everything requested. Call exactly once per completed round of work \u2014 repeat calls with no new user message in between are rejected.",
14456
14651
  args: {},
14457
14652
  execute: async (_args, ctx) => {
14458
14653
  const w = sessions.get(ctx.sessionID);
14459
14654
  if (w) {
14460
14655
  if (!w.isSubagent) {
14656
+ if ((w.todos || []).length === 0) {
14657
+ try {
14658
+ const fetched = await fetchSessionTodos(ctx.sessionID);
14659
+ if (fetched.length > 0)
14660
+ w.todos = fetched;
14661
+ } catch (e) {
14662
+ dbg(`task_complete sid=${short(ctx.sessionID)}: lazy todo fetch error: ${e}`);
14663
+ }
14664
+ }
14461
14665
  const openTodos = (w.todos || []).filter((t) => t.status === "pending" || t.status === "in_progress");
14462
14666
  if (openTodos.length > 0 && w.taskCompleteOverrides < maxRetries) {
14463
14667
  w.taskCompleteOverrides++;
14668
+ w.taskCompleteSignals = 0;
14464
14669
  const reminder = buildOpenTodosReminder(w.todos);
14465
14670
  const blockMsg = `Mark any finished todos complete and do not redo completed work.
14466
14671
  ${reminder}`;
14467
14672
  await log("info", `${short(ctx.sessionID)} - task_complete blocked: ${openTodos.length} open todos remain (override ${w.taskCompleteOverrides}/${maxRetries})`);
14468
- await sendContinuePrompt(ctx.sessionID, blockMsg, w);
14673
+ let awaitingInput = false;
14674
+ try {
14675
+ awaitingInput = hasPendingUserInput(await getSessionMessages(ctx.sessionID));
14676
+ } catch (e) {
14677
+ dbg(`task_complete block: awaiting-input check error: ${e}`);
14678
+ }
14679
+ if (awaitingInput) {
14680
+ await log("info", `${short(ctx.sessionID)} - task_complete blocked but user input pending, skipping visible nudge`);
14681
+ } else {
14682
+ await sendContinuePrompt(ctx.sessionID, blockMsg, w);
14683
+ }
14469
14684
  return blockMsg;
14470
14685
  }
14471
14686
  }
@@ -14476,8 +14691,13 @@ ${reminder}`;
14476
14691
  w.toolTextTimer = null;
14477
14692
  }
14478
14693
  log("info", `${short(ctx.sessionID)} - task_complete called, ${w.isSubagent ? "subagent" : "agent"} done`);
14694
+ w.taskCompleteSignals++;
14695
+ if (w.taskCompleteSignals === 2)
14696
+ return TASK_COMPLETE_REPEAT_WARNING;
14697
+ if (w.taskCompleteSignals > 2)
14698
+ throw new Error(TASK_COMPLETE_REPEAT_ERROR);
14479
14699
  }
14480
- return "Task completion acknowledged. No further continuation will be sent.";
14700
+ return TASK_COMPLETE_ACK;
14481
14701
  }
14482
14702
  });
14483
14703
  return {
@@ -14509,6 +14729,7 @@ ${reminder}`;
14509
14729
  if (w.userCancelled || w.completionSignaled) {
14510
14730
  w.userCancelled = false;
14511
14731
  w.completionSignaled = false;
14732
+ w.taskCompleteSignals = 0;
14512
14733
  await log("info", `${short(sid)} - new user message, re-arming auto-resume`);
14513
14734
  }
14514
14735
  },
@@ -14519,6 +14740,8 @@ ${reminder}`;
14519
14740
  w.pendingTools++;
14520
14741
  w.lastActivityAt = Date.now();
14521
14742
  const toolName = input.tool ?? "unknown";
14743
+ if (toolName !== "task_complete")
14744
+ w.taskCompleteSignals = 0;
14522
14745
  const rawArgs = hookArgs?.args ?? input.args;
14523
14746
  w.liveToolSigs.push(toolCallSignature(toolName, rawArgs));
14524
14747
  if (w.liveToolSigs.length > 15)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auto-resume",
3
- "version": "1.1.17",
3
+ "version": "1.1.19",
4
4
  "description": "OpenCode plugin that automatically resumes stalled LLM sessions when thinking/streaming freezes mid-generation.",
5
5
  "keywords": [
6
6
  "opencode",