opencode-auto-resume 1.1.19 → 1.1.21
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 +11 -8
- package/dist/index.js +74 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ LLM sessions fail in predictable ways. This plugin monitors all sessions and aut
|
|
|
10
10
|
|
|
11
11
|
### Stall recovery
|
|
12
12
|
|
|
13
|
-
The stream goes silent but the session stays "busy". The UI shows a blinking cursor with no progress. If no events arrive for
|
|
13
|
+
The stream goes silent but the session stays "busy". The UI shows a blinking cursor with no progress. If no events arrive for ~3 minutes (`chunkTimeoutMs` + `gracePeriodMs`), the plugin sends `"continue"` with exponential backoff. After 3 failed attempts it gives up.
|
|
14
14
|
|
|
15
15
|
The `busyStallStrategy` option controls this path: `"continue"` (default), `"abort"` (abort-first), or `"off"` — see [Recovery model](#recovery-model) for what a busy prompt can and cannot do while the runner is live.
|
|
16
16
|
|
|
@@ -247,10 +247,11 @@ If the assistant claims the task is done ("task done", "finished", "all complete
|
|
|
247
247
|
|
|
248
248
|
### User-input awareness
|
|
249
249
|
|
|
250
|
-
The session is not stalled while the ball is in the user's court.
|
|
250
|
+
The session is not stalled while the ball is in the user's court. Three gates stand down idle nudges:
|
|
251
251
|
|
|
252
252
|
- **Awaiting input**: the newest assistant message holds a `tool_use` part with `state.status: "pending"` (e.g. an open `question` tool call). All idle checks, the periodic recheck, delayed action-intent callbacks, and the tool-text scan skip prompting until a newer user message clears the gate. Completed tool calls never engage it.
|
|
253
|
-
- **Recently active user**: any inbound user message within `activeUserWindowMs` (default
|
|
253
|
+
- **Recently active user**: any inbound user message within `activeUserWindowMs` (default 5 minutes) means the user is engaged — likely composing a reply, which leaves no pending tool call behind. Open-todos nudges (idle + periodic), the tool-text reminder fallback, and action-intent callbacks stand down until the window expires.
|
|
254
|
+
- **Explicit stop line** (opt-in): when the newest assistant message's text matches one of `stopPatterns`, the agent has deliberately handed the ball to the user — typical for agent setups whose prompts end a turn with a protocol line such as `STOP: NEEDS_DECISION <question>` or `STOP: BLOCKED <reason>`, while the todo for that decision is still open. It counts exactly like the awaiting-input gate. Default: no patterns (unchanged behaviour).
|
|
254
255
|
|
|
255
256
|
---
|
|
256
257
|
|
|
@@ -367,7 +368,7 @@ With options:
|
|
|
367
368
|
{
|
|
368
369
|
"plugin": [
|
|
369
370
|
["opencode-auto-resume", {
|
|
370
|
-
"chunkTimeoutMs":
|
|
371
|
+
"chunkTimeoutMs": 180000,
|
|
371
372
|
"gracePeriodMs": 3000,
|
|
372
373
|
"maxRetries": 3
|
|
373
374
|
}]
|
|
@@ -382,7 +383,7 @@ With options:
|
|
|
382
383
|
"plugin": [
|
|
383
384
|
[
|
|
384
385
|
"file:///home/YOURUSER/.config/opencode/plugins/opencode-auto-resume/dist/index.js",
|
|
385
|
-
{ "chunkTimeoutMs":
|
|
386
|
+
{ "chunkTimeoutMs": 180000, "maxRetries": 3 }
|
|
386
387
|
]
|
|
387
388
|
]
|
|
388
389
|
}
|
|
@@ -392,7 +393,7 @@ With options:
|
|
|
392
393
|
|
|
393
394
|
| Option | Default | Description |
|
|
394
395
|
|---|---|---|
|
|
395
|
-
| `chunkTimeoutMs` | `
|
|
396
|
+
| `chunkTimeoutMs` | `180000` | Inactivity timeout before considering stream stalled |
|
|
396
397
|
| `gracePeriodMs` | `3000` | Extra wait before acting (lets ESC/status events arrive) |
|
|
397
398
|
| `checkIntervalMs` | `5000` | Timer poll interval |
|
|
398
399
|
| `maxRetries` | `3` | Max auto-resume attempts before giving up |
|
|
@@ -407,6 +408,7 @@ With options:
|
|
|
407
408
|
| `toolTextCheckDelayMs` | `3000` | Delay before scanning an idle session for tool-as-text; also the recovery watchdog delay |
|
|
408
409
|
| `minActivityGapMs` | `1000` | Skip recovery if the session was active within this gap |
|
|
409
410
|
| `warmupMs` | `15000` | Action-intent detection disabled while a session is younger than this |
|
|
411
|
+
| `discoveryDelayMs` | `5000` | Delay before the initial session discovery run after plugin attach |
|
|
410
412
|
| `debug` | `false` | Enable `[debug]` console diagnostics |
|
|
411
413
|
| `resumeOnActionIntent` | `true` | Enable action-intent (`:`-terminated line) nudges |
|
|
412
414
|
| `continuePrompt` | `"continue"` | Prompt text for stall/streaming/dead-stream recovery |
|
|
@@ -420,7 +422,8 @@ With options:
|
|
|
420
422
|
| `silentDeadStreamMinTokens` | `200` | Min output tokens to treat a textless `finish:"unknown"` message as a dead stream |
|
|
421
423
|
| `busyStallStrategy` | `"continue"` | Busy-stall response: `"continue"`, `"abort"` (abort-first), or `"off"` (disabled) |
|
|
422
424
|
| `contextSaturationThreshold` | `0.85` | Ratio of used/usable context that routes a saturated parent to magic-context `ctx-wrapup` (only when magic-context is installed) |
|
|
423
|
-
| `activeUserWindowMs` | `
|
|
425
|
+
| `activeUserWindowMs` | `300000` | Inbound-user-message recency window (5 min) during which idle nudges stand down (user likely composing) |
|
|
426
|
+
| `stopPatterns` | `[]` | Array of regex strings (multiline). When the newest assistant message's text matches one, the session counts as awaiting the user and idle nudges stand down — e.g. `["STOP:\\s*(BLOCKED\|NEEDS_DECISION)\\b"]`. Invalid regexes are skipped. |
|
|
424
427
|
| `subagentNativeCompactionEnabled` | `false` | Opt-in native `session.summarize()` for saturated subagent sessions (no magic-context detection required) |
|
|
425
428
|
|
|
426
429
|
Message patterns are matched case-insensitively. Error names use exact match.
|
|
@@ -445,7 +448,7 @@ The plugin handles all recovery automatically — no manual intervention needed.
|
|
|
445
448
|
| Problem | Solution |
|
|
446
449
|
|---|---|
|
|
447
450
|
| Resumes after ESC | Increase `gracePeriodMs` to `5000` |
|
|
448
|
-
|
|
|
451
|
+
| Stall recovery too aggressive | Decrease `chunkTimeoutMs` (e.g. `45000`) |
|
|
449
452
|
| Too slow to react | Decrease `checkIntervalMs` to `2000` |
|
|
450
453
|
| Orphan parent not detected | Increase `subagentWaitMs` to `20000` |
|
|
451
454
|
| Hallucination loop not caught | Decrease `loopMaxContinues` to `2` |
|
package/dist/index.js
CHANGED
|
@@ -12335,9 +12335,10 @@ function tool(input) {
|
|
|
12335
12335
|
}
|
|
12336
12336
|
tool.schema = exports_external;
|
|
12337
12337
|
// src/index.ts
|
|
12338
|
-
var DEFAULT_CHUNK_TIMEOUT_MS =
|
|
12338
|
+
var DEFAULT_CHUNK_TIMEOUT_MS = 180000;
|
|
12339
12339
|
var DEFAULT_CHECK_INTERVAL_MS = 5000;
|
|
12340
|
-
var
|
|
12340
|
+
var DEFAULT_DISCOVERY_DELAY_MS = 5000;
|
|
12341
|
+
var DEFAULT_ACTIVE_USER_WINDOW_MS = 5 * 60000;
|
|
12341
12342
|
var DEFAULT_GRACE_PERIOD_MS = 3000;
|
|
12342
12343
|
var DEFAULT_MAX_RETRIES = 3;
|
|
12343
12344
|
var DEFAULT_MAX_BACKOFF_MS = 8000;
|
|
@@ -12352,6 +12353,7 @@ var DEFAULT_MIN_ACTIVITY_GAP_MS = 1000;
|
|
|
12352
12353
|
var DEFAULT_WARMUP_MS = 15000;
|
|
12353
12354
|
var DEFAULT_SILENT_DEAD_STREAM_MIN_TOKENS = 200;
|
|
12354
12355
|
var DEFAULT_DEBUG = false;
|
|
12356
|
+
var OOC_ERROR_RE = /exceeds the available context size|context size \(\d+\)|too large to compact|too many tokens|prompt is too long/i;
|
|
12355
12357
|
var DEFAULT_STREAMING_FAILURE_ERROR_NAMES = [
|
|
12356
12358
|
"ProviderError",
|
|
12357
12359
|
"APIError",
|
|
@@ -12629,6 +12631,24 @@ function getOpenTodos(todos) {
|
|
|
12629
12631
|
return [];
|
|
12630
12632
|
return todos.filter(isOpenTodo);
|
|
12631
12633
|
}
|
|
12634
|
+
function parseStopPatterns(raw) {
|
|
12635
|
+
if (!Array.isArray(raw))
|
|
12636
|
+
return [];
|
|
12637
|
+
return raw.map((s) => {
|
|
12638
|
+
try {
|
|
12639
|
+
return new RegExp(String(s), "m");
|
|
12640
|
+
} catch {
|
|
12641
|
+
return null;
|
|
12642
|
+
}
|
|
12643
|
+
}).filter((r) => r !== null);
|
|
12644
|
+
}
|
|
12645
|
+
function endsWithStopLine(parts, patterns) {
|
|
12646
|
+
if (!patterns.length)
|
|
12647
|
+
return false;
|
|
12648
|
+
const text = parts.filter((p) => p.type === "text").map((p) => String(p.text ?? "")).join(`
|
|
12649
|
+
`);
|
|
12650
|
+
return patterns.some((re) => re.test(text));
|
|
12651
|
+
}
|
|
12632
12652
|
function buildOpenTodosReminder(todos) {
|
|
12633
12653
|
if (!Array.isArray(todos))
|
|
12634
12654
|
return "continue";
|
|
@@ -12659,6 +12679,7 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
12659
12679
|
const maxRecoveryRetries = options?.maxRecoveryRetries ?? DEFAULT_MAX_RECOVERY_RETRIES;
|
|
12660
12680
|
const minActivityGapMs = options?.minActivityGapMs ?? DEFAULT_MIN_ACTIVITY_GAP_MS;
|
|
12661
12681
|
const warmupMs = options?.warmupMs ?? DEFAULT_WARMUP_MS;
|
|
12682
|
+
const discoveryDelayMs = options?.discoveryDelayMs ?? DEFAULT_DISCOVERY_DELAY_MS;
|
|
12662
12683
|
const debug = options?.debug ?? DEFAULT_DEBUG;
|
|
12663
12684
|
const streamingFailureErrorNames = options?.streamingFailureErrorNames ?? DEFAULT_STREAMING_FAILURE_ERROR_NAMES;
|
|
12664
12685
|
const streamingFailureMessagePatterns = options?.streamingFailureMessagePatterns ?? DEFAULT_STREAMING_FAILURE_MESSAGE_PATTERNS;
|
|
@@ -12699,6 +12720,7 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
12699
12720
|
}
|
|
12700
12721
|
}).filter((r) => r !== null);
|
|
12701
12722
|
})();
|
|
12723
|
+
const stopPatterns = parseStopPatterns(options?.stopPatterns);
|
|
12702
12724
|
const dbg = (...args) => {
|
|
12703
12725
|
if (debug)
|
|
12704
12726
|
console.log("[debug]", ...args);
|
|
@@ -12770,6 +12792,8 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
12770
12792
|
resumeAttempts: 0,
|
|
12771
12793
|
lastRetryAt: 0,
|
|
12772
12794
|
gaveUp: false,
|
|
12795
|
+
oocLocked: false,
|
|
12796
|
+
oocLockReason: null,
|
|
12773
12797
|
orphanWatchStartAt: null,
|
|
12774
12798
|
aborting: false,
|
|
12775
12799
|
pluginAbortInFlight: false,
|
|
@@ -12946,6 +12970,10 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
12946
12970
|
await log("debug", `${short(sid)} - gaveUp latched, refusing further continue prompts`);
|
|
12947
12971
|
return;
|
|
12948
12972
|
}
|
|
12973
|
+
if (w.oocLocked) {
|
|
12974
|
+
await log("debug", `${short(sid)} - oocLocked latched, refusing further continue prompts (reason: ${w.oocLockReason ?? "n/a"})`);
|
|
12975
|
+
return;
|
|
12976
|
+
}
|
|
12949
12977
|
if (!w.continuing)
|
|
12950
12978
|
dbg(`State transition on ${short(sid)}: continuing=false -> true`);
|
|
12951
12979
|
w.continuing = true;
|
|
@@ -13101,6 +13129,8 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
13101
13129
|
const parts = msg.parts;
|
|
13102
13130
|
if (!parts)
|
|
13103
13131
|
return false;
|
|
13132
|
+
if (endsWithStopLine(parts, stopPatterns))
|
|
13133
|
+
return true;
|
|
13104
13134
|
for (const part of parts) {
|
|
13105
13135
|
if (part.type !== "tool_use")
|
|
13106
13136
|
continue;
|
|
@@ -13831,14 +13861,13 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
13831
13861
|
await log("info", `${short(sid)} - max open-todos nudges (${maxRetries}) reached, waiting for activity`);
|
|
13832
13862
|
return;
|
|
13833
13863
|
}
|
|
13834
|
-
w.todoNudgeAttempts++;
|
|
13835
13864
|
} else if (isDoneClaimNoTodos) {
|
|
13836
13865
|
w.doneClaimNoTodosAttempts++;
|
|
13837
13866
|
} else {
|
|
13838
13867
|
w.toolTextRecovered = true;
|
|
13839
13868
|
w.toolTextAttempts++;
|
|
13840
13869
|
}
|
|
13841
|
-
const attemptNum = isOpenTodosReminder ? w.todoNudgeAttempts : isDoneClaimNoTodos ? w.doneClaimNoTodosAttempts : w.toolTextAttempts;
|
|
13870
|
+
const attemptNum = isOpenTodosReminder ? w.todoNudgeAttempts + 1 : isDoneClaimNoTodos ? w.doneClaimNoTodosAttempts : w.toolTextAttempts;
|
|
13842
13871
|
await log("info", `${bestCandidate.source} detected on ${short(sid)}! ` + `Attempt ${attemptNum}/${maxRetries}. Sending recovery prompt...`);
|
|
13843
13872
|
const timeSinceActivity = Date.now() - w.lastActivityAt;
|
|
13844
13873
|
if (timeSinceActivity < minActivityGapMs) {
|
|
@@ -13884,6 +13913,8 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
13884
13913
|
return false;
|
|
13885
13914
|
if (w.aborting)
|
|
13886
13915
|
return false;
|
|
13916
|
+
if (w.oocLocked)
|
|
13917
|
+
return false;
|
|
13887
13918
|
const idleSec = Math.round((Date.now() - (w.orphanWatchStartAt ?? w.lastActivityAt)) / 1000);
|
|
13888
13919
|
await log("info", `Abort+Resume on ${short(sid)} (${idleSec}s idle). Aborting...`);
|
|
13889
13920
|
w.pluginAbortInFlight = true;
|
|
@@ -13968,12 +13999,13 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
13968
13999
|
try {
|
|
13969
14000
|
const response = await ctx.client.session.list();
|
|
13970
14001
|
const list = extractMessages(response);
|
|
14002
|
+
const statusMap = await getSessionStatusMap();
|
|
13971
14003
|
for (const s of list) {
|
|
13972
14004
|
const sid = s.id;
|
|
13973
14005
|
if (sid && typeof sid === "string" && sid.startsWith("ses_")) {
|
|
13974
14006
|
const isNew = !sessions.has(sid);
|
|
13975
14007
|
ensureWatch(sid);
|
|
13976
|
-
const status = s.status;
|
|
14008
|
+
const status = s.status ?? statusMap[sid];
|
|
13977
14009
|
if (status) {
|
|
13978
14010
|
const w = sessions.get(sid);
|
|
13979
14011
|
w.status = status;
|
|
@@ -14216,7 +14248,7 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
14216
14248
|
}, SESSION_DISCOVERY_INTERVAL_MS);
|
|
14217
14249
|
if (discoveryTimer.unref)
|
|
14218
14250
|
discoveryTimer.unref();
|
|
14219
|
-
setTimeout(discoverSessions,
|
|
14251
|
+
setTimeout(discoverSessions, discoveryDelayMs);
|
|
14220
14252
|
}
|
|
14221
14253
|
startTimer();
|
|
14222
14254
|
async function handleEvent(ev) {
|
|
@@ -14237,7 +14269,11 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
14237
14269
|
if (w.pendingRecovery) {
|
|
14238
14270
|
dbg(`Pending recovery cleared on ${short(sid)}: reason=session-busy`);
|
|
14239
14271
|
}
|
|
14240
|
-
|
|
14272
|
+
if (w.continuing) {
|
|
14273
|
+
dbg(`busy on ${short(sid)} via recovery prompt - preserving resumeAttempts=${w.resumeAttempts} gaveUp=${w.gaveUp}`);
|
|
14274
|
+
} else {
|
|
14275
|
+
resetBusyFlags(w);
|
|
14276
|
+
}
|
|
14241
14277
|
prevBusyCount = busyCount();
|
|
14242
14278
|
log("debug", `${short(sid)} -> busy (${prevBusyCount})`);
|
|
14243
14279
|
} else if (statusType === "interrupted") {
|
|
@@ -14379,12 +14415,14 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
14379
14415
|
if (isCelebration) {
|
|
14380
14416
|
await log("info", `${short(sid)} - \uD83C\uDF89 detected but ${open.length} open todos remain, sending nudge`);
|
|
14381
14417
|
const reminder = buildOpenTodosReminder(todos);
|
|
14382
|
-
await tryResume(sid, w, "Idle with open todos (celebration false positive)", reminder);
|
|
14383
|
-
|
|
14418
|
+
const sent = await tryResume(sid, w, "Idle with open todos (celebration false positive)", reminder);
|
|
14419
|
+
if (sent)
|
|
14420
|
+
w.todoNudgeAttempts++;
|
|
14384
14421
|
} else {
|
|
14385
14422
|
const reminder = buildOpenTodosReminder(todos);
|
|
14386
|
-
await tryResume(sid, w, "Idle with open todos", reminder);
|
|
14387
|
-
|
|
14423
|
+
const sent = await tryResume(sid, w, "Idle with open todos", reminder);
|
|
14424
|
+
if (sent)
|
|
14425
|
+
w.todoNudgeAttempts++;
|
|
14388
14426
|
}
|
|
14389
14427
|
}
|
|
14390
14428
|
}
|
|
@@ -14454,8 +14492,16 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
14454
14492
|
break;
|
|
14455
14493
|
}
|
|
14456
14494
|
case "session.updated": {
|
|
14457
|
-
if (sid)
|
|
14458
|
-
|
|
14495
|
+
if (!sid)
|
|
14496
|
+
break;
|
|
14497
|
+
const updatedInfo = ev.properties?.info;
|
|
14498
|
+
if (updatedInfo?.revert) {
|
|
14499
|
+
const was = sessions.get(sid);
|
|
14500
|
+
dbg(`Revert on ${short(sid)}: dropping watch state (resumeAttempts=${was?.resumeAttempts ?? 0} recoveryAttempts=${was?.recoveryAttempts ?? 0} gaveUp=${was?.gaveUp ?? false})`);
|
|
14501
|
+
sessions.delete(sid);
|
|
14502
|
+
break;
|
|
14503
|
+
}
|
|
14504
|
+
ensureWatch(sid);
|
|
14459
14505
|
break;
|
|
14460
14506
|
}
|
|
14461
14507
|
case "session.idle": {
|
|
@@ -14626,6 +14672,11 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
14626
14672
|
if (w) {
|
|
14627
14673
|
w.pendingTools = 0;
|
|
14628
14674
|
w.pendingCommands = 0;
|
|
14675
|
+
if (!w.oocLocked && OOC_ERROR_RE.test(errorMessage ?? "")) {
|
|
14676
|
+
w.oocLocked = true;
|
|
14677
|
+
w.oocLockReason = (errorMessage ?? "").slice(0, 200);
|
|
14678
|
+
await log("warn", `${short(sid)} - OOC error latched, halting recovery (reason: ${w.oocLockReason})`);
|
|
14679
|
+
}
|
|
14629
14680
|
}
|
|
14630
14681
|
}
|
|
14631
14682
|
break;
|
|
@@ -14638,7 +14689,11 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
14638
14689
|
if (w.pendingRecovery) {
|
|
14639
14690
|
dbg(`Pending recovery cleared on ${short(sid)}: reason=user-command`);
|
|
14640
14691
|
}
|
|
14641
|
-
|
|
14692
|
+
if (w.continuing) {
|
|
14693
|
+
dbg(`command on ${short(sid)} via recovery prompt - preserving resumeAttempts=${w.resumeAttempts} gaveUp=${w.gaveUp}`);
|
|
14694
|
+
} else {
|
|
14695
|
+
resetBusyFlags(w);
|
|
14696
|
+
}
|
|
14642
14697
|
w.pendingCommands = Math.max(0, w.pendingCommands - 1);
|
|
14643
14698
|
w.lastActivityAt = Date.now();
|
|
14644
14699
|
}
|
|
@@ -14726,6 +14781,11 @@ ${reminder}`;
|
|
|
14726
14781
|
w.lastActivityAt = Date.now();
|
|
14727
14782
|
if (w.continuing)
|
|
14728
14783
|
return;
|
|
14784
|
+
if (w.oocLocked) {
|
|
14785
|
+
w.oocLocked = false;
|
|
14786
|
+
w.oocLockReason = null;
|
|
14787
|
+
await log("info", `${short(sid)} - genuine user message, clearing oocLocked`);
|
|
14788
|
+
}
|
|
14729
14789
|
if (w.userCancelled || w.completionSignaled) {
|
|
14730
14790
|
w.userCancelled = false;
|
|
14731
14791
|
w.completionSignaled = false;
|
package/package.json
CHANGED