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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/index.js +140 -109
  3. 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 data = response;
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
- if (part.type !== "text")
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
- const text = part.text ?? "";
349
+ }
244
350
  if (containsToolCallAsText(text)) {
245
351
  w.toolTextRecovered = true;
246
352
  w.toolTextAttempts++;
247
- await log("info", `Tool-call-as-text detected on ${short(sid)}! ` + `Attempt ${w.toolTextAttempts}/${maxRetries}. Sending recovery prompt...`);
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 ctx.client.session.prompt({
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 ctx.client.session.prompt({
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 ctx.client.session.prompt({
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 ctx.client.session.prompt({
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 data = response;
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
- const agent = await getSessionAgent(sid);
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
- tryAbortAndResume(sid, w);
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.userCancelled = false;
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.userCancelled = false;
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.userCancelled = false;
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.orphanWatchStartAt = null;
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.userCancelled = false;
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.4",
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
- "scripts": {
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"