opencode-auto-resume 1.0.5 → 1.0.7

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 +2 -0
  2. package/dist/index.js +130 -37
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,6 +7,8 @@
7
7
  LLM sessions fail in predictable ways. This plugin monitors all sessions and automatically recovers without user intervention.
8
8
 
9
9
  **Stall recovery** — 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, the plugin sends `"continue"` with exponential backoff. After 3 failed attempts it gives up.
10
+
11
+ 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.).
10
12
  _( [#55](https://github.com/opencode-ai/opencode/issues/55), [#199](https://github.com/opencode-ai/opencode/issues/199), [#283](https://github.com/opencode-ai/opencode/issues/283) )_
11
13
 
12
14
  **Tool calls as raw text** — the model prints tool invocations as raw XML (`<function=edit>...`) 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 a specific recovery prompt.
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ var MAX_IDLE_SESSIONS = 50;
15
15
  var IDLE_CLEANUP_MS = 10 * 60000;
16
16
  var SESSION_DISCOVERY_INTERVAL_MS = 60000;
17
17
  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.";
18
+ 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.";
18
19
  var TOOL_TEXT_PATTERNS = [
19
20
  /<function\s*=/i,
20
21
  /<function>/i,
@@ -210,12 +211,53 @@ var AutoResumePlugin = async (ctx, options) => {
210
211
  }
211
212
  w.continuing = true;
212
213
  try {
214
+ let agent2;
215
+ let model2;
216
+ const msgResp = await ctx.client.session.messages({ path: { id: sid } });
217
+ const msgs = extractMessages(msgResp);
218
+ for (let i = msgs.length - 1;i >= 0; i--) {
219
+ const msg = msgs[i];
220
+ const info = msg.info;
221
+ if (info?.role === "user") {
222
+ const rawAgent = info.agent;
223
+ if (typeof rawAgent === "string")
224
+ agent2 = rawAgent;
225
+ const rawModel = info.model;
226
+ if (rawModel && typeof rawModel.providerID === "string" && typeof rawModel.modelID === "string") {
227
+ model2 = {
228
+ providerID: rawModel.providerID,
229
+ modelID: rawModel.modelID
230
+ };
231
+ }
232
+ break;
233
+ }
234
+ }
213
235
  await ctx.client.session.prompt({
214
236
  path: { id: sid },
215
- body: { parts: [{ type: "text", text }] }
237
+ body: {
238
+ parts: [{ type: "text", text }],
239
+ agent: agent2,
240
+ model: model2
241
+ }
216
242
  });
243
+ await log("debug", `${short(sid)} - prompt sent with agent: ${agent2 ?? "(default)"}, model: ${model2 ? `${model2.providerID}/${model2.modelID}` : "(default)"}`);
217
244
  recordContinue(sid);
218
245
  w.lastRetryAt = Date.now();
246
+ } catch (err) {
247
+ const errMsg = err instanceof Error ? err.message : String(err);
248
+ await log("warn", `${short(sid)} - prompt failed: ${errMsg}`);
249
+ try {
250
+ await ctx.client.session.prompt({
251
+ path: { id: sid },
252
+ body: { parts: [{ type: "text", text }], agent, model }
253
+ });
254
+ recordContinue(sid);
255
+ w.lastRetryAt = Date.now();
256
+ } catch (retryErr) {
257
+ const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
258
+ await log("error", `${short(sid)} - prompt retry also failed: ${retryMsg}`);
259
+ throw retryErr;
260
+ }
219
261
  } finally {
220
262
  w.continuing = false;
221
263
  }
@@ -229,6 +271,38 @@ var AutoResumePlugin = async (ctx, options) => {
229
271
  return response.messages;
230
272
  return [];
231
273
  }
274
+ async function checkSubagentStatus(parentSid) {
275
+ try {
276
+ const response = await ctx.client.session.list();
277
+ const allSessions = extractMessages(response);
278
+ let hasBusySubagent = false;
279
+ for (const s of allSessions) {
280
+ const sId = s.id;
281
+ if (!sId || sId === parentSid)
282
+ continue;
283
+ const status = s.status;
284
+ if (status === "busy") {
285
+ hasBusySubagent = true;
286
+ const msgResponse = await ctx.client.session.messages({ path: { id: sId } });
287
+ const messages = extractMessages(msgResponse);
288
+ const lastMsg = messages[messages.length - 1];
289
+ const rawRole = lastMsg?.role ?? lastMsg?.info?.role;
290
+ if (lastMsg && rawRole === "assistant" && (("error" in lastMsg) || lastMsg.info && ("error" in lastMsg.info))) {
291
+ await log("debug", `Subagent ${short(sId)} appears crashed`);
292
+ return "crashed";
293
+ }
294
+ }
295
+ }
296
+ if (!hasBusySubagent) {
297
+ return "idle";
298
+ }
299
+ return "busy";
300
+ } catch (err) {
301
+ const errMsg = err instanceof Error ? err.message : String(err);
302
+ await log("debug", `checkSubagentStatus failed: ${errMsg}`);
303
+ return "unknown";
304
+ }
305
+ }
232
306
  function resetSessionFlags(w) {
233
307
  w.userCancelled = false;
234
308
  w.resumeAttempts = 0;
@@ -267,55 +341,65 @@ var AutoResumePlugin = async (ctx, options) => {
267
341
  });
268
342
  const messages = extractMessages(response);
269
343
  const recent = messages.slice(-3);
344
+ let bestCandidate = null;
270
345
  for (const msg of recent) {
271
- const role = msg.role;
272
- if (role !== "assistant")
346
+ const rawRole = msg.role ?? msg.info?.role;
347
+ if (rawRole !== "assistant")
273
348
  continue;
274
349
  const parts = msg.parts;
275
350
  if (!parts)
276
351
  continue;
277
352
  for (const part of parts) {
278
- if (part.type !== "text")
353
+ const partType = part.type;
354
+ let text = "";
355
+ let isReasoning = false;
356
+ if (partType === "text") {
357
+ text = part.text ?? "";
358
+ } else if (partType === "reasoning") {
359
+ text = part.text ?? "";
360
+ isReasoning = true;
361
+ } else {
279
362
  continue;
280
- const text = part.text ?? "";
363
+ }
281
364
  if (containsToolCallAsText(text)) {
282
- w.toolTextRecovered = true;
283
- w.toolTextAttempts++;
284
- await log("info", `Tool-call-as-text detected on ${short(sid)}! ` + `Attempt ${w.toolTextAttempts}/${maxRetries}. Sending recovery prompt...`);
285
- if (isHallucinationLoop(sid)) {
286
- await log("warn", `Hallucination loop detected on ${short(sid)} \u2014 aborting instead`);
287
- await tryAbortAndResume(sid, w);
288
- } else {
289
- try {
290
- await sendContinuePrompt(sid, TOOL_TEXT_RECOVERY_PROMPT, w);
291
- await log("info", `${short(sid)} - tool-call-as-text recovery sent (attempt ${w.toolTextAttempts})`);
292
- } catch (err) {
293
- const errMsg = err instanceof Error ? err.message : String(err);
294
- await log("warn", `${short(sid)} - tool-call-as-text recovery failed: ${errMsg}`);
295
- }
365
+ const candidate = {
366
+ prompt: isReasoning ? THINKING_TOOL_RECOVERY_PROMPT : TOOL_TEXT_RECOVERY_PROMPT,
367
+ source: isReasoning ? "reasoning" : "text",
368
+ priority: 0
369
+ };
370
+ if (!bestCandidate || candidate.priority < bestCandidate.priority) {
371
+ bestCandidate = candidate;
296
372
  }
297
- return;
298
373
  }
299
374
  if (containsReadyToContinuePattern(text)) {
300
- w.toolTextRecovered = true;
301
- w.toolTextAttempts++;
302
- await log("info", `Ready-to-continue pattern detected on ${short(sid)}! ` + `Attempt ${w.toolTextAttempts}/${maxRetries}. Sending continue...`);
303
- if (isHallucinationLoop(sid)) {
304
- await log("warn", `Hallucination loop detected on ${short(sid)} \u2014 aborting instead`);
305
- await tryAbortAndResume(sid, w);
306
- } else {
307
- try {
308
- await sendContinuePrompt(sid, "continue", w);
309
- await log("info", `${short(sid)} - ready-to-continue recovery sent (attempt ${w.toolTextAttempts})`);
310
- } catch (err) {
311
- const errMsg = err instanceof Error ? err.message : String(err);
312
- await log("warn", `${short(sid)} - ready-to-continue recovery failed: ${errMsg}`);
313
- }
375
+ const candidate = {
376
+ prompt: "continue",
377
+ source: "ready-to-continue",
378
+ priority: 1
379
+ };
380
+ if (!bestCandidate || candidate.priority < bestCandidate.priority) {
381
+ bestCandidate = candidate;
314
382
  }
315
- return;
316
383
  }
317
384
  }
318
385
  }
386
+ if (!bestCandidate)
387
+ return;
388
+ w.toolTextRecovered = true;
389
+ w.toolTextAttempts++;
390
+ await log("info", `${bestCandidate.source} detected on ${short(sid)}! ` + `Attempt ${w.toolTextAttempts}/${maxRetries}. Sending recovery prompt...`);
391
+ if (isHallucinationLoop(sid)) {
392
+ await log("warn", `Hallucination loop detected on ${short(sid)} \u2014 aborting instead`);
393
+ await tryAbortAndResume(sid, w);
394
+ } else {
395
+ try {
396
+ await sendContinuePrompt(sid, bestCandidate.prompt, w);
397
+ await log("info", `${short(sid)} - ${bestCandidate.source} recovery sent (attempt ${w.toolTextAttempts})`);
398
+ } catch (err) {
399
+ const errMsg = err instanceof Error ? err.message : String(err);
400
+ await log("warn", `${short(sid)} - ${bestCandidate.source} recovery failed: ${errMsg}`);
401
+ }
402
+ }
319
403
  } catch (err) {
320
404
  const errMsg = err instanceof Error ? err.message : String(err);
321
405
  log("debug", `${short(sid)} - could not fetch messages: ${errMsg}`);
@@ -412,7 +496,7 @@ var AutoResumePlugin = async (ctx, options) => {
412
496
  function startTimer() {
413
497
  if (timer)
414
498
  return;
415
- timer = setInterval(() => {
499
+ timer = setInterval(async () => {
416
500
  const now = Date.now();
417
501
  const numBusy = busyCount();
418
502
  for (const [sid, w] of sessions) {
@@ -426,7 +510,16 @@ var AutoResumePlugin = async (ctx, options) => {
426
510
  const orphanIdle = now - w.orphanWatchStartAt;
427
511
  if (orphanIdle >= subagentWaitMs + gracePeriodMs) {
428
512
  if (w.resumeAttempts < maxRetries) {
429
- tryAbortAndResume(sid, w);
513
+ const subStatus = await checkSubagentStatus(sid);
514
+ if (subStatus === "crashed") {
515
+ await log("info", `Subagent crashed, triggering abort+resume on ${short(sid)}`);
516
+ tryAbortAndResume(sid, w);
517
+ } else if (subStatus === "idle") {
518
+ await log("info", `All subagents idle, parent ${short(sid)} stuck. Triggering abort+resume.`);
519
+ tryAbortAndResume(sid, w);
520
+ } else {
521
+ await log("debug", `Subagent still running, waiting...`);
522
+ }
430
523
  } else if (!w.gaveUp) {
431
524
  w.gaveUp = true;
432
525
  w.orphanWatchStartAt = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auto-resume",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "OpenCode plugin that automatically resumes stalled LLM sessions when thinking/streaming freezes mid-generation.",
5
5
  "keywords": [
6
6
  "opencode",