opencode-auto-resume 1.0.4 → 1.0.6
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 +2 -0
- package/dist/index.js +140 -109
- package/package.json +4 -4
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,
|
|
@@ -119,7 +120,8 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
119
120
|
toolTextRecovered: false,
|
|
120
121
|
toolTextAttempts: 0,
|
|
121
122
|
continueTimestamps: [],
|
|
122
|
-
idleSince: null
|
|
123
|
+
idleSince: null,
|
|
124
|
+
continuing: false
|
|
123
125
|
};
|
|
124
126
|
sessions.set(sid, w);
|
|
125
127
|
}
|
|
@@ -202,6 +204,110 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
202
204
|
log("debug", `Cleaned up ${toDelete.length} idle session(s). Map size: ${sessions.size}`);
|
|
203
205
|
}
|
|
204
206
|
}
|
|
207
|
+
async function sendContinuePrompt(sid, text, w) {
|
|
208
|
+
if (w.continuing) {
|
|
209
|
+
await log("debug", `${short(sid)} - continue already in progress, skipping`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
w.continuing = true;
|
|
213
|
+
try {
|
|
214
|
+
let agent;
|
|
215
|
+
let modelID;
|
|
216
|
+
let providerID;
|
|
217
|
+
const msgResp = await ctx.client.session.messages({ path: { id: sid } });
|
|
218
|
+
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;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
await ctx.client.session.prompt({
|
|
229
|
+
path: { id: sid },
|
|
230
|
+
body: { parts: [{ type: "text", text }] },
|
|
231
|
+
agent,
|
|
232
|
+
modelID,
|
|
233
|
+
providerID
|
|
234
|
+
});
|
|
235
|
+
await log("debug", `${short(sid)} - prompt sent with agent: ${agent}, model: ${modelID}`);
|
|
236
|
+
recordContinue(sid);
|
|
237
|
+
w.lastRetryAt = Date.now();
|
|
238
|
+
} catch (err) {
|
|
239
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
240
|
+
await log("warn", `${short(sid)} - prompt failed: ${errMsg}`);
|
|
241
|
+
try {
|
|
242
|
+
await ctx.client.session.prompt({
|
|
243
|
+
path: { id: sid },
|
|
244
|
+
body: { parts: [{ type: "text", text }] }
|
|
245
|
+
});
|
|
246
|
+
recordContinue(sid);
|
|
247
|
+
w.lastRetryAt = Date.now();
|
|
248
|
+
} catch (retryErr) {
|
|
249
|
+
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
|
250
|
+
await log("error", `${short(sid)} - prompt retry also failed: ${retryMsg}`);
|
|
251
|
+
throw retryErr;
|
|
252
|
+
}
|
|
253
|
+
} finally {
|
|
254
|
+
w.continuing = false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function extractMessages(response) {
|
|
258
|
+
if (Array.isArray(response))
|
|
259
|
+
return response;
|
|
260
|
+
if (Array.isArray(response.data))
|
|
261
|
+
return response.data;
|
|
262
|
+
if (Array.isArray(response.messages))
|
|
263
|
+
return response.messages;
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
async function checkSubagentCrashed(parentSid) {
|
|
267
|
+
try {
|
|
268
|
+
const response = await ctx.client.session.list();
|
|
269
|
+
const sessions2 = extractMessages(response);
|
|
270
|
+
for (const s of sessions2) {
|
|
271
|
+
const sId = s.id;
|
|
272
|
+
if (!sId || sId === parentSid)
|
|
273
|
+
continue;
|
|
274
|
+
const status = s.status;
|
|
275
|
+
if (status === "busy") {
|
|
276
|
+
const msgResponse = await ctx.client.session.messages({ path: { id: sId } });
|
|
277
|
+
const messages = extractMessages(msgResponse);
|
|
278
|
+
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;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} catch (err) {
|
|
288
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
289
|
+
await log("debug", `checkSubagentCrashed failed: ${errMsg}`);
|
|
290
|
+
}
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
function resetSessionFlags(w) {
|
|
294
|
+
w.userCancelled = false;
|
|
295
|
+
w.resumeAttempts = 0;
|
|
296
|
+
w.gaveUp = false;
|
|
297
|
+
w.orphanWatchStartAt = null;
|
|
298
|
+
w.aborting = false;
|
|
299
|
+
w.toolTextRecovered = false;
|
|
300
|
+
w.toolTextAttempts = 0;
|
|
301
|
+
w.continueTimestamps = [];
|
|
302
|
+
w.idleSince = null;
|
|
303
|
+
w.continuing = false;
|
|
304
|
+
}
|
|
305
|
+
function resetIdleFlags(w) {
|
|
306
|
+
w.userCancelled = false;
|
|
307
|
+
w.aborting = false;
|
|
308
|
+
w.orphanWatchStartAt = null;
|
|
309
|
+
w.idleSince = Date.now();
|
|
310
|
+
}
|
|
205
311
|
async function checkForToolCallAsText(sid, w) {
|
|
206
312
|
if (typeof sid !== "string" || !sid)
|
|
207
313
|
return;
|
|
@@ -220,15 +326,7 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
220
326
|
const response = await ctx.client.session.messages({
|
|
221
327
|
path: { id: sid }
|
|
222
328
|
});
|
|
223
|
-
const
|
|
224
|
-
let messages = [];
|
|
225
|
-
if (Array.isArray(data)) {
|
|
226
|
-
messages = data;
|
|
227
|
-
} else if (Array.isArray(data.data)) {
|
|
228
|
-
messages = data.data;
|
|
229
|
-
} else if (Array.isArray(data.messages)) {
|
|
230
|
-
messages = data.messages;
|
|
231
|
-
}
|
|
329
|
+
const messages = extractMessages(response);
|
|
232
330
|
const recent = messages.slice(-3);
|
|
233
331
|
for (const msg of recent) {
|
|
234
332
|
const role = msg.role;
|
|
@@ -238,24 +336,29 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
238
336
|
if (!parts)
|
|
239
337
|
continue;
|
|
240
338
|
for (const part of parts) {
|
|
241
|
-
|
|
339
|
+
const partType = part.type;
|
|
340
|
+
let text = "";
|
|
341
|
+
let isReasoning = false;
|
|
342
|
+
if (partType === "text") {
|
|
343
|
+
text = part.text ?? "";
|
|
344
|
+
} else if (partType === "reasoning") {
|
|
345
|
+
text = part.text ?? "";
|
|
346
|
+
isReasoning = true;
|
|
347
|
+
} else {
|
|
242
348
|
continue;
|
|
243
|
-
|
|
349
|
+
}
|
|
244
350
|
if (containsToolCallAsText(text)) {
|
|
245
351
|
w.toolTextRecovered = true;
|
|
246
352
|
w.toolTextAttempts++;
|
|
247
|
-
|
|
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...`);
|
|
248
356
|
if (isHallucinationLoop(sid)) {
|
|
249
357
|
await log("warn", `Hallucination loop detected on ${short(sid)} \u2014 aborting instead`);
|
|
250
358
|
await tryAbortAndResume(sid, w);
|
|
251
359
|
} else {
|
|
252
360
|
try {
|
|
253
|
-
await
|
|
254
|
-
path: { id: sid },
|
|
255
|
-
body: { agent: typeof w.agent === "string" ? w.agent : undefined, parts: [{ type: "text", text: TOOL_TEXT_RECOVERY_PROMPT }] }
|
|
256
|
-
});
|
|
257
|
-
recordContinue(sid);
|
|
258
|
-
w.lastRetryAt = Date.now();
|
|
361
|
+
await sendContinuePrompt(sid, prompt, w);
|
|
259
362
|
await log("info", `${short(sid)} - tool-call-as-text recovery sent (attempt ${w.toolTextAttempts})`);
|
|
260
363
|
} catch (err) {
|
|
261
364
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -273,12 +376,7 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
273
376
|
await tryAbortAndResume(sid, w);
|
|
274
377
|
} else {
|
|
275
378
|
try {
|
|
276
|
-
await
|
|
277
|
-
path: { id: sid },
|
|
278
|
-
body: { agent: typeof w.agent === "string" ? w.agent : undefined, parts: [{ type: "text", text: "continue" }] }
|
|
279
|
-
});
|
|
280
|
-
recordContinue(sid);
|
|
281
|
-
w.lastRetryAt = Date.now();
|
|
379
|
+
await sendContinuePrompt(sid, "continue", w);
|
|
282
380
|
await log("info", `${short(sid)} - ready-to-continue recovery sent (attempt ${w.toolTextAttempts})`);
|
|
283
381
|
} catch (err) {
|
|
284
382
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -315,13 +413,8 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
315
413
|
if (w.status === "busy")
|
|
316
414
|
w.status = "idle";
|
|
317
415
|
try {
|
|
318
|
-
await
|
|
319
|
-
path: { id: sid },
|
|
320
|
-
body: { agent: typeof w.agent === "string" ? w.agent : undefined, parts: [{ type: "text", text: "continue" }] }
|
|
321
|
-
});
|
|
322
|
-
recordContinue(sid);
|
|
416
|
+
await sendContinuePrompt(sid, "continue", w);
|
|
323
417
|
await log("info", `${short(sid)} - abort+continue done`);
|
|
324
|
-
w.lastRetryAt = Date.now();
|
|
325
418
|
w.orphanWatchStartAt = null;
|
|
326
419
|
w.resumeAttempts++;
|
|
327
420
|
w.aborting = false;
|
|
@@ -350,15 +443,9 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
350
443
|
w.resumeAttempts++;
|
|
351
444
|
const idleSec = Math.round((now - w.lastActivityAt) / 1000);
|
|
352
445
|
await log("info", `${reason} on ${short(sid)} (${idleSec}s, retry ${w.resumeAttempts}/${maxRetries})`);
|
|
353
|
-
const agent = typeof w.agent === "string" ? w.agent : undefined;
|
|
354
446
|
try {
|
|
355
|
-
await
|
|
356
|
-
path: { id: sid },
|
|
357
|
-
body: { agent, model: true, parts: [{ type: "text", text: "continue" }] }
|
|
358
|
-
});
|
|
359
|
-
recordContinue(sid);
|
|
447
|
+
await sendContinuePrompt(sid, "continue", w);
|
|
360
448
|
await log("info", `${short(sid)} - retry sent`);
|
|
361
|
-
w.lastRetryAt = now;
|
|
362
449
|
return true;
|
|
363
450
|
} catch (err) {
|
|
364
451
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -367,44 +454,10 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
367
454
|
return false;
|
|
368
455
|
}
|
|
369
456
|
}
|
|
370
|
-
async function getSessionAgent(sid) {
|
|
371
|
-
if (typeof sid !== "string" || !sid)
|
|
372
|
-
return;
|
|
373
|
-
try {
|
|
374
|
-
const response = await ctx.client.session.messages({
|
|
375
|
-
path: { id: sid }
|
|
376
|
-
});
|
|
377
|
-
const data = response;
|
|
378
|
-
let messages = [];
|
|
379
|
-
if (Array.isArray(data)) {
|
|
380
|
-
messages = data;
|
|
381
|
-
} else if (Array.isArray(data.data)) {
|
|
382
|
-
messages = data.data;
|
|
383
|
-
} else if (Array.isArray(data.messages)) {
|
|
384
|
-
messages = data.messages;
|
|
385
|
-
}
|
|
386
|
-
for (let i = messages.length - 1;i >= 0; i--) {
|
|
387
|
-
const msg = messages[i];
|
|
388
|
-
const role = msg.role;
|
|
389
|
-
if (role === "assistant") {
|
|
390
|
-
const agent = msg.agent;
|
|
391
|
-
if (agent)
|
|
392
|
-
return agent;
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
} catch {}
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
457
|
async function discoverSessions() {
|
|
399
458
|
try {
|
|
400
459
|
const response = await ctx.client.session.list();
|
|
401
|
-
const
|
|
402
|
-
let list = [];
|
|
403
|
-
if (Array.isArray(data)) {
|
|
404
|
-
list = data;
|
|
405
|
-
} else if (Array.isArray(data.data)) {
|
|
406
|
-
list = data.data;
|
|
407
|
-
}
|
|
460
|
+
const list = extractMessages(response);
|
|
408
461
|
for (const s of list) {
|
|
409
462
|
const sid = s.id;
|
|
410
463
|
if (sid) {
|
|
@@ -418,14 +471,7 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
418
471
|
w.idleSince = Date.now();
|
|
419
472
|
}
|
|
420
473
|
if (isNew) {
|
|
421
|
-
|
|
422
|
-
if (agent) {
|
|
423
|
-
const w = sessions.get(sid);
|
|
424
|
-
w.agent = agent;
|
|
425
|
-
log("debug", `Discovered session ${short(sid)} with agent: ${agent}`);
|
|
426
|
-
} else {
|
|
427
|
-
log("debug", `Discovered session ${short(sid)} via list()`);
|
|
428
|
-
}
|
|
474
|
+
log("debug", `Discovered session ${short(sid)} via list()`);
|
|
429
475
|
}
|
|
430
476
|
}
|
|
431
477
|
}
|
|
@@ -437,7 +483,7 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
437
483
|
function startTimer() {
|
|
438
484
|
if (timer)
|
|
439
485
|
return;
|
|
440
|
-
timer = setInterval(() => {
|
|
486
|
+
timer = setInterval(async () => {
|
|
441
487
|
const now = Date.now();
|
|
442
488
|
const numBusy = busyCount();
|
|
443
489
|
for (const [sid, w] of sessions) {
|
|
@@ -451,7 +497,13 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
451
497
|
const orphanIdle = now - w.orphanWatchStartAt;
|
|
452
498
|
if (orphanIdle >= subagentWaitMs + gracePeriodMs) {
|
|
453
499
|
if (w.resumeAttempts < maxRetries) {
|
|
454
|
-
|
|
500
|
+
const crashed = await checkSubagentCrashed(sid);
|
|
501
|
+
if (crashed) {
|
|
502
|
+
await log("info", `Subagent crashed, triggering abort+resume on ${short(sid)}`);
|
|
503
|
+
tryAbortAndResume(sid, w);
|
|
504
|
+
} else {
|
|
505
|
+
await log("debug", `Subagent still running, waiting...`);
|
|
506
|
+
}
|
|
455
507
|
} else if (!w.gaveUp) {
|
|
456
508
|
w.gaveUp = true;
|
|
457
509
|
w.orphanWatchStartAt = null;
|
|
@@ -501,21 +553,11 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
501
553
|
w.status = statusType;
|
|
502
554
|
if (statusType === "busy") {
|
|
503
555
|
w.lastActivityAt = Date.now();
|
|
504
|
-
w
|
|
505
|
-
w.resumeAttempts = 0;
|
|
506
|
-
w.gaveUp = false;
|
|
507
|
-
w.orphanWatchStartAt = null;
|
|
508
|
-
w.aborting = false;
|
|
509
|
-
w.toolTextRecovered = false;
|
|
510
|
-
w.toolTextAttempts = 0;
|
|
511
|
-
w.continueTimestamps = [];
|
|
512
|
-
w.idleSince = null;
|
|
556
|
+
resetSessionFlags(w);
|
|
513
557
|
log("debug", `${short(sid)} -> busy (${busyCount()})`);
|
|
514
558
|
} else if (statusType === "idle") {
|
|
515
559
|
w.status = "idle";
|
|
516
|
-
w
|
|
517
|
-
w.aborting = false;
|
|
518
|
-
w.idleSince = Date.now();
|
|
560
|
+
resetIdleFlags(w);
|
|
519
561
|
const currentBusy = busyCount();
|
|
520
562
|
if (prevBusyCount > 1 && currentBusy === 1) {
|
|
521
563
|
const lone = getLoneBusySession();
|
|
@@ -555,10 +597,7 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
555
597
|
const w = sessions.get(sid);
|
|
556
598
|
if (w) {
|
|
557
599
|
w.status = "idle";
|
|
558
|
-
w
|
|
559
|
-
w.orphanWatchStartAt = null;
|
|
560
|
-
w.aborting = false;
|
|
561
|
-
w.idleSince = Date.now();
|
|
600
|
+
resetIdleFlags(w);
|
|
562
601
|
if (!w.toolTextRecovered && w.toolTextAttempts < maxRetries) {
|
|
563
602
|
setTimeout(() => {
|
|
564
603
|
checkForToolCallAsText(sid, w);
|
|
@@ -576,9 +615,7 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
576
615
|
if (w.status === "busy") {
|
|
577
616
|
w.userCancelled = true;
|
|
578
617
|
w.status = "idle";
|
|
579
|
-
w
|
|
580
|
-
w.aborting = false;
|
|
581
|
-
w.idleSince = Date.now();
|
|
618
|
+
resetIdleFlags(w);
|
|
582
619
|
}
|
|
583
620
|
}
|
|
584
621
|
log("info", "User abort (ESC)");
|
|
@@ -592,13 +629,7 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
592
629
|
}
|
|
593
630
|
case "command.executed": {
|
|
594
631
|
for (const [, w] of sessions) {
|
|
595
|
-
w
|
|
596
|
-
w.resumeAttempts = 0;
|
|
597
|
-
w.gaveUp = false;
|
|
598
|
-
w.orphanWatchStartAt = null;
|
|
599
|
-
w.aborting = false;
|
|
600
|
-
w.toolTextRecovered = false;
|
|
601
|
-
w.toolTextAttempts = 0;
|
|
632
|
+
resetSessionFlags(w);
|
|
602
633
|
}
|
|
603
634
|
break;
|
|
604
635
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-auto-resume",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "OpenCode plugin that automatically resumes stalled LLM sessions when thinking/streaming freezes mid-generation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
"author": "Daniele \"mte90\" Scasciafratte",
|
|
22
22
|
"type": "module",
|
|
23
23
|
"main": "dist/index.js",
|
|
24
|
-
|
|
24
|
+
"scripts": {
|
|
25
25
|
"build": "bun build src/index.ts --outdir dist --target bun",
|
|
26
26
|
"dev": "bun build src/index.ts --outdir dist --target bun --watch",
|
|
27
|
+
"test": "bun test",
|
|
27
28
|
"prepublishOnly": "bun run build"
|
|
28
|
-
|
|
29
|
-
"files": [
|
|
29
|
+
}, "files": [
|
|
30
30
|
"dist/index.js",
|
|
31
31
|
"README.md",
|
|
32
32
|
"LICENSE"
|