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.
- package/README.md +6 -0
- package/dist/index.js +128 -63
- 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
|
|
215
|
-
let
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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: {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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: ${
|
|
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
|
-
|
|
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
|
|
270
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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", `
|
|
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
|
|
333
|
-
if (
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if (
|
|
357
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
501
|
-
if (crashed) {
|
|
502
|
-
|
|
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