opencode-auto-resume 1.0.6 → 1.0.8

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 +6 -0
  2. package/dist/index.js +128 -63
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -30,6 +30,12 @@ _( [#128](https://github.com/opencode-ai/opencode/pulls/128), [#22](https://gith
30
30
 
31
31
  **Session discovery** — periodically calls `session.list()` to pick up sessions that were missed by event tracking. Idle sessions are cleaned up after 10 minutes to prevent memory leaks.
32
32
 
33
+ **Model preservation** — when resuming with "continue", the plugin extracts agent, model and provider from the last session message (not from `info` field) to preserve the user's UI selection.
34
+ _( [#111](https://github.com/opencode-ai/opencode/issues/111), [#277](https://github.com/opencode-ai/opencode/issues/277) )_
35
+
36
+ **Subagent stuck detection** — 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 before triggering abort+resume on the parent.
37
+ _( [#55](https://github.com/opencode-ai/opencode/issues/55), [#60](https://github.com/opencode-ai/opencode/issues/60), [#246](https://github.com/opencode-ai/opencode/issues/246) )_
38
+
33
39
  ## Architecture
34
40
 
35
41
  ```
package/dist/index.js CHANGED
@@ -211,28 +211,39 @@ var AutoResumePlugin = async (ctx, options) => {
211
211
  }
212
212
  w.continuing = true;
213
213
  try {
214
- let agent;
215
- let modelID;
216
- let providerID;
214
+ let agent2;
215
+ let model2;
217
216
  const msgResp = await ctx.client.session.messages({ path: { id: sid } });
218
217
  const msgs = extractMessages(msgResp);
219
- const lastMsg = msgs[msgs.length - 1];
220
- if (lastMsg) {
221
- const info = lastMsg.info;
222
- agent = info?.agent;
223
- if (lastMsg.role === "assistant") {
224
- modelID = lastMsg.modelID;
225
- providerID = lastMsg.providerID;
218
+ for (let i = msgs.length - 1;i >= 0; i--) {
219
+ const msg = msgs[i];
220
+ const role = msg.role ?? msg.info?.role;
221
+ if (role === "user") {
222
+ const rawAgent = msg.agent;
223
+ if (typeof rawAgent === "string")
224
+ agent2 = rawAgent;
225
+ let rawModel = msg.model;
226
+ if (!rawModel) {
227
+ rawModel = msg.info?.model;
228
+ }
229
+ if (rawModel && typeof rawModel.providerID === "string" && typeof rawModel.modelID === "string") {
230
+ model2 = {
231
+ providerID: rawModel.providerID,
232
+ modelID: rawModel.modelID
233
+ };
234
+ }
235
+ break;
226
236
  }
227
237
  }
228
238
  await ctx.client.session.prompt({
229
239
  path: { id: sid },
230
- body: { parts: [{ type: "text", text }] },
231
- agent,
232
- modelID,
233
- providerID
240
+ body: {
241
+ parts: [{ type: "text", text }],
242
+ agent: agent2,
243
+ model: model2
244
+ }
234
245
  });
235
- await log("debug", `${short(sid)} - prompt sent with agent: ${agent}, model: ${modelID}`);
246
+ await log("debug", `${short(sid)} - prompt sent with agent: ${agent2 ?? "(default)"}, model: ${model2 ? `${model2.providerID}/${model2.modelID}` : "(default)"}`);
236
247
  recordContinue(sid);
237
248
  w.lastRetryAt = Date.now();
238
249
  } catch (err) {
@@ -241,7 +252,7 @@ var AutoResumePlugin = async (ctx, options) => {
241
252
  try {
242
253
  await ctx.client.session.prompt({
243
254
  path: { id: sid },
244
- body: { parts: [{ type: "text", text }] }
255
+ body: { parts: [{ type: "text", text }], agent, model }
245
256
  });
246
257
  recordContinue(sid);
247
258
  w.lastRetryAt = Date.now();
@@ -263,32 +274,61 @@ var AutoResumePlugin = async (ctx, options) => {
263
274
  return response.messages;
264
275
  return [];
265
276
  }
266
- async function checkSubagentCrashed(parentSid) {
277
+ const SUBAGENT_STUCK_MS = 60000;
278
+ const SUBAGENT_RECOVERY_PROMPT = "It looks like you may have stalled or timed out. Please retry the last operation or continue with the task.";
279
+ async function recoverSubagent(subagentSid) {
280
+ try {
281
+ await ctx.client.session.prompt({
282
+ path: { id: subagentSid },
283
+ body: { parts: [{ type: "text", text: SUBAGENT_RECOVERY_PROMPT }] }
284
+ });
285
+ await log("info", `Sent recovery prompt to subagent ${short(subagentSid)}`);
286
+ return true;
287
+ } catch (err) {
288
+ const errMsg = err instanceof Error ? err.message : String(err);
289
+ await log("warn", `Failed to recover subagent ${short(subagentSid)}: ${errMsg}`);
290
+ return false;
291
+ }
292
+ }
293
+ async function checkSubagentStatus(parentSid) {
267
294
  try {
268
295
  const response = await ctx.client.session.list();
269
- const sessions2 = extractMessages(response);
270
- for (const s of sessions2) {
296
+ const allSessions = extractMessages(response);
297
+ const now = Date.now();
298
+ let hasBusySubagent = false;
299
+ for (const s of allSessions) {
271
300
  const sId = s.id;
272
301
  if (!sId || sId === parentSid)
273
302
  continue;
274
303
  const status = s.status;
275
304
  if (status === "busy") {
305
+ hasBusySubagent = true;
276
306
  const msgResponse = await ctx.client.session.messages({ path: { id: sId } });
277
307
  const messages = extractMessages(msgResponse);
278
308
  const lastMsg = messages[messages.length - 1];
279
- if (lastMsg && lastMsg.role === "assistant" && "error" in lastMsg) {
280
- const error = lastMsg.error;
281
- const errorName = error?.name;
282
- await log("debug", `Subagent ${short(sId)} appears crashed: ${errorName}`);
283
- return true;
309
+ const rawRole = lastMsg?.role ?? lastMsg?.info?.role;
310
+ if (lastMsg && rawRole === "assistant" && (("error" in lastMsg) || lastMsg.info && ("error" in lastMsg.info))) {
311
+ await log("debug", `Subagent ${short(sId)} appears crashed`);
312
+ return "crashed";
313
+ }
314
+ const msgTime = lastMsg?.time?.created ?? lastMsg?.time;
315
+ const hasToolCall = lastMsg?.toolCall !== undefined || lastMsg?.tool_calls !== undefined || lastMsg?.parts?.some((p) => p.type === "tool-call") !== undefined;
316
+ const isStuck = hasToolCall ? now - msgTime > SUBAGENT_STUCK_MS * 3 : now - msgTime > SUBAGENT_STUCK_MS;
317
+ if (isStuck) {
318
+ await log("debug", `Subagent ${short(sId)} stuck - no new text in >${hasToolCall ? 3 : 1}min`);
319
+ return { status: "crashed", stuckSid: sId };
284
320
  }
285
321
  }
286
322
  }
323
+ if (!hasBusySubagent) {
324
+ return { status: "idle" };
325
+ }
326
+ return { status: "busy" };
287
327
  } catch (err) {
288
328
  const errMsg = err instanceof Error ? err.message : String(err);
289
- await log("debug", `checkSubagentCrashed failed: ${errMsg}`);
329
+ await log("debug", `checkSubagentStatus failed: ${errMsg}`);
330
+ return { status: "unknown" };
290
331
  }
291
- return false;
292
332
  }
293
333
  function resetSessionFlags(w) {
294
334
  w.userCancelled = false;
@@ -328,9 +368,10 @@ var AutoResumePlugin = async (ctx, options) => {
328
368
  });
329
369
  const messages = extractMessages(response);
330
370
  const recent = messages.slice(-3);
371
+ let bestCandidate = null;
331
372
  for (const msg of recent) {
332
- const role = msg.role;
333
- if (role !== "assistant")
373
+ const rawRole = msg.role ?? msg.info?.role;
374
+ if (rawRole !== "assistant")
334
375
  continue;
335
376
  const parts = msg.parts;
336
377
  if (!parts)
@@ -348,45 +389,44 @@ var AutoResumePlugin = async (ctx, options) => {
348
389
  continue;
349
390
  }
350
391
  if (containsToolCallAsText(text)) {
351
- w.toolTextRecovered = true;
352
- w.toolTextAttempts++;
353
- const prompt = isReasoning ? THINKING_TOOL_RECOVERY_PROMPT : TOOL_TEXT_RECOVERY_PROMPT;
354
- const source = isReasoning ? "reasoning" : "text";
355
- await log("info", `Tool-call-as-text in ${source} detected on ${short(sid)}! ` + `Attempt ${w.toolTextAttempts}/${maxRetries}. Sending recovery prompt...`);
356
- if (isHallucinationLoop(sid)) {
357
- await log("warn", `Hallucination loop detected on ${short(sid)} \u2014 aborting instead`);
358
- await tryAbortAndResume(sid, w);
359
- } else {
360
- try {
361
- await sendContinuePrompt(sid, prompt, w);
362
- await log("info", `${short(sid)} - tool-call-as-text recovery sent (attempt ${w.toolTextAttempts})`);
363
- } catch (err) {
364
- const errMsg = err instanceof Error ? err.message : String(err);
365
- await log("warn", `${short(sid)} - tool-call-as-text recovery failed: ${errMsg}`);
366
- }
392
+ const candidate = {
393
+ prompt: isReasoning ? THINKING_TOOL_RECOVERY_PROMPT : TOOL_TEXT_RECOVERY_PROMPT,
394
+ source: isReasoning ? "reasoning" : "text",
395
+ priority: 0
396
+ };
397
+ if (!bestCandidate || candidate.priority < bestCandidate.priority) {
398
+ bestCandidate = candidate;
367
399
  }
368
- return;
369
400
  }
370
401
  if (containsReadyToContinuePattern(text)) {
371
- w.toolTextRecovered = true;
372
- w.toolTextAttempts++;
373
- await log("info", `Ready-to-continue pattern detected on ${short(sid)}! ` + `Attempt ${w.toolTextAttempts}/${maxRetries}. Sending continue...`);
374
- if (isHallucinationLoop(sid)) {
375
- await log("warn", `Hallucination loop detected on ${short(sid)} \u2014 aborting instead`);
376
- await tryAbortAndResume(sid, w);
377
- } else {
378
- try {
379
- await sendContinuePrompt(sid, "continue", w);
380
- await log("info", `${short(sid)} - ready-to-continue recovery sent (attempt ${w.toolTextAttempts})`);
381
- } catch (err) {
382
- const errMsg = err instanceof Error ? err.message : String(err);
383
- await log("warn", `${short(sid)} - ready-to-continue recovery failed: ${errMsg}`);
384
- }
402
+ const candidate = {
403
+ prompt: "continue",
404
+ source: "ready-to-continue",
405
+ priority: 1
406
+ };
407
+ if (!bestCandidate || candidate.priority < bestCandidate.priority) {
408
+ bestCandidate = candidate;
385
409
  }
386
- return;
387
410
  }
388
411
  }
389
412
  }
413
+ if (!bestCandidate)
414
+ return;
415
+ w.toolTextRecovered = true;
416
+ w.toolTextAttempts++;
417
+ await log("info", `${bestCandidate.source} detected on ${short(sid)}! ` + `Attempt ${w.toolTextAttempts}/${maxRetries}. Sending recovery prompt...`);
418
+ if (isHallucinationLoop(sid)) {
419
+ await log("warn", `Hallucination loop detected on ${short(sid)} \u2014 aborting instead`);
420
+ await tryAbortAndResume(sid, w);
421
+ } else {
422
+ try {
423
+ await sendContinuePrompt(sid, bestCandidate.prompt, w);
424
+ await log("info", `${short(sid)} - ${bestCandidate.source} recovery sent (attempt ${w.toolTextAttempts})`);
425
+ } catch (err) {
426
+ const errMsg = err instanceof Error ? err.message : String(err);
427
+ await log("warn", `${short(sid)} - ${bestCandidate.source} recovery failed: ${errMsg}`);
428
+ }
429
+ }
390
430
  } catch (err) {
391
431
  const errMsg = err instanceof Error ? err.message : String(err);
392
432
  log("debug", `${short(sid)} - could not fetch messages: ${errMsg}`);
@@ -497,9 +537,17 @@ var AutoResumePlugin = async (ctx, options) => {
497
537
  const orphanIdle = now - w.orphanWatchStartAt;
498
538
  if (orphanIdle >= subagentWaitMs + gracePeriodMs) {
499
539
  if (w.resumeAttempts < maxRetries) {
500
- const crashed = await checkSubagentCrashed(sid);
501
- if (crashed) {
502
- await log("info", `Subagent crashed, triggering abort+resume on ${short(sid)}`);
540
+ const subStatus = await checkSubagentStatus(sid);
541
+ if (subStatus.status === "crashed" && subStatus.stuckSid) {
542
+ const recovered = await recoverSubagent(subStatus.stuckSid);
543
+ if (recovered) {
544
+ await log("info", `Sent recovery prompt to stuck subagent ${short(subStatus.stuckSid)}, waiting...`);
545
+ } else {
546
+ await log("info", `Subagent crashed, triggering abort+resume on ${short(sid)}`);
547
+ tryAbortAndResume(sid, w);
548
+ }
549
+ } else if (subStatus.status === "idle") {
550
+ await log("info", `All subagents idle, parent ${short(sid)} stuck. Triggering abort+resume.`);
503
551
  tryAbortAndResume(sid, w);
504
552
  } else {
505
553
  await log("debug", `Subagent still running, waiting...`);
@@ -515,6 +563,23 @@ var AutoResumePlugin = async (ctx, options) => {
515
563
  }
516
564
  if (numBusy > 1)
517
565
  continue;
566
+ if (w.lastActivityAt > 0 && now - w.lastActivityAt > subagentWaitMs) {
567
+ const subStatus = await checkSubagentStatus(sid);
568
+ if (subStatus.status === "idle" || subStatus.status === "unknown") {
569
+ await log("info", `Parent ${short(sid)} stuck with no active subagents. Triggering abort+resume.`);
570
+ tryAbortAndResume(sid, w);
571
+ continue;
572
+ } else if (subStatus.status === "crashed" && subStatus.stuckSid) {
573
+ const recovered = await recoverSubagent(subStatus.stuckSid);
574
+ if (recovered) {
575
+ await log("info", `Sent recovery prompt to stuck subagent ${short(subStatus.stuckSid)}, waiting...`);
576
+ } else {
577
+ await log("info", `Parent ${short(sid)} subagent recovery failed. Triggering abort+resume.`);
578
+ tryAbortAndResume(sid, w);
579
+ }
580
+ continue;
581
+ }
582
+ }
518
583
  const idle = now - w.lastActivityAt;
519
584
  if (idle >= chunkTimeoutMs + gracePeriodMs) {
520
585
  if (w.resumeAttempts < maxRetries) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auto-resume",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "OpenCode plugin that automatically resumes stalled LLM sessions when thinking/streaming freezes mid-generation.",
5
5
  "keywords": [
6
6
  "opencode",