opencode-auto-resume 1.1.15 → 1.1.16

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 +11 -1
  2. package/dist/index.js +126 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -225,7 +225,16 @@ When the assistant ends a line with `:` ("Next, I will edit the file:") — anno
225
225
 
226
226
  ### Done-claim verification
227
227
 
228
- If the assistant claims the task is done ("task done", "finished", "all complete") but open todos remain, the plugin sends `DONE_WITHOUT_WORK_PROMPT` asking the agent to verify and finish remaining work. If it claims done with **no** open todos but the response contains no description of the work, the plugin sends `DONE_WITHOUT_DETAILS_PROMPT` — an imperative prompt demanding a concrete report (files changed, commands run, results). Both use the real `todo.updated` event state — not regex on the message text.
228
+ If the assistant claims the task is done ("task done", "finished", "all complete") but open todos remain, the plugin sends `DONE_WITHOUT_WORK_PROMPT` asking the agent to verify and finish remaining work. If it claims done with **no** open todos and the response carries no work description, the plugin sends `DONE_WITHOUT_DETAILS_PROMPT` — an imperative prompt demanding a concrete report (files changed, commands run, results). A response that already contains file paths, verification output, or result sections satisfies the demand on its own and never triggers the prompt. The budget is capped at `maxRetries` across busy cycles (going busy no longer re-arms it); only a genuinely new inbound user message re-arms it. Both use the real `todo.updated` event state — not regex on the message text.
229
+
230
+ ---
231
+
232
+ ### User-input awareness
233
+
234
+ The session is not stalled while the ball is in the user's court. Two gates stand down idle nudges:
235
+
236
+ - **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.
237
+ - **Recently active user**: any inbound user message within `activeUserWindowMs` (default 15 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.
229
238
 
230
239
  ---
231
240
 
@@ -389,6 +398,7 @@ With options:
389
398
  | `silentDeadStreamMinTokens` | `200` | Min output tokens to treat a textless `finish:"unknown"` message as a dead stream |
390
399
  | `busyStallStrategy` | `"continue"` | Busy-stall response: `"continue"`, `"abort"` (abort-first), or `"off"` (disabled) |
391
400
  | `contextSaturationThreshold` | `0.85` | Ratio of used/usable context that routes a saturated parent to magic-context `ctx-wrapup` (only when magic-context is installed) |
401
+ | `activeUserWindowMs` | `900000` | Inbound-user-message recency window (15 min) during which idle nudges stand down (user likely composing) |
392
402
  | `subagentNativeCompactionEnabled` | `false` | Opt-in native `session.summarize()` for saturated subagent sessions (no magic-context detection required) |
393
403
 
394
404
  Message patterns are matched case-insensitively. Error names use exact match.
package/dist/index.js CHANGED
@@ -12337,6 +12337,7 @@ tool.schema = exports_external;
12337
12337
  // src/index.ts
12338
12338
  var DEFAULT_CHUNK_TIMEOUT_MS = 45000;
12339
12339
  var DEFAULT_CHECK_INTERVAL_MS = 5000;
12340
+ var DEFAULT_ACTIVE_USER_WINDOW_MS = 15 * 60000;
12340
12341
  var DEFAULT_GRACE_PERIOD_MS = 3000;
12341
12342
  var DEFAULT_MAX_RETRIES = 3;
12342
12343
  var DEFAULT_MAX_BACKOFF_MS = 8000;
@@ -12456,6 +12457,15 @@ function containsDoneClaimPattern(text) {
12456
12457
  `);
12457
12458
  return DONE_CLAIM_PATTERNS.some((pat) => pat.test(lastLines));
12458
12459
  }
12460
+ function containsWorkDescription(text) {
12461
+ if (/`[^`\n]*\.[a-zA-Z0-9]{1,8}[^`\n]*`/.test(text))
12462
+ return true;
12463
+ if (/[\w\-~.][\w\-.~\/]*\/[\w\-.~]*\.[a-zA-Z]{1,8}\b/.test(text))
12464
+ return true;
12465
+ if (/^(changed|modified|deleted|created|updated|renamed|moved|files?\s+changed|verification|verified|tests?(?:\s+run|\s+passing|\s+pass)?|results?|outcome|commands?\s+(?:run|executed))/im.test(text))
12466
+ return true;
12467
+ return false;
12468
+ }
12459
12469
  function isStreamingFailure(errorName, errorMessage, errorNames = DEFAULT_STREAMING_FAILURE_ERROR_NAMES, messagePatterns = DEFAULT_STREAMING_FAILURE_MESSAGE_PATTERNS) {
12460
12470
  if (!errorName && !errorMessage)
12461
12471
  return false;
@@ -12622,6 +12632,7 @@ var AutoResumePlugin = async (ctx, options) => {
12622
12632
  const busyStallStrategy = rawBusyStallStrategy === "abort" || rawBusyStallStrategy === "off" ? rawBusyStallStrategy : "continue";
12623
12633
  const contextSaturationThreshold = options?.contextSaturationThreshold ?? 0.85;
12624
12634
  const subagentNativeCompactionEnabled = options?.subagentNativeCompactionEnabled ?? false;
12635
+ const activeUserWindowMs = options?.activeUserWindowMs ?? DEFAULT_ACTIVE_USER_WINDOW_MS;
12625
12636
  const dbg = (...args) => {
12626
12637
  if (debug)
12627
12638
  console.log("[debug]", ...args);
@@ -12717,6 +12728,7 @@ var AutoResumePlugin = async (ctx, options) => {
12717
12728
  todoNudgeAttempts: 0,
12718
12729
  taskCompleteOverrides: 0,
12719
12730
  doneClaimNoTodosAttempts: 0,
12731
+ lastUserMessageAt: 0,
12720
12732
  pendingTools: 0,
12721
12733
  pendingCommands: 0,
12722
12734
  pendingRecovery: false,
@@ -12897,6 +12909,8 @@ var AutoResumePlugin = async (ctx, options) => {
12897
12909
  }
12898
12910
  }
12899
12911
  dbg(`Recovery prompt sent to ${short(sid)}: prompt="${text.length > 80 ? `${text.slice(0, 80)}...` : text}", agent=${agent ?? "(default)"}, model=${model ? `${model.providerID}/${model.modelID}` : "(default)"}`);
12912
+ if (w.userCancelled || w.completionSignaled)
12913
+ return;
12900
12914
  const response = await ctx.client.session.prompt({
12901
12915
  path: { id: sid },
12902
12916
  body: {
@@ -12938,7 +12952,12 @@ var AutoResumePlugin = async (ctx, options) => {
12938
12952
  setTimeout(async () => {
12939
12953
  if (w.status !== "busy") {
12940
12954
  if (w.pendingRecovery) {
12941
- if (w.recoveryAttempts < maxRecoveryRetries) {
12955
+ if (w.userCancelled || w.completionSignaled) {
12956
+ w.pendingRecovery = false;
12957
+ w.pendingRecoveryReason = null;
12958
+ w.recoveryAttempts = 0;
12959
+ await log("info", `${short(sid)} - recovery disarmed: session cancelled/completed while awaiting watchdog`);
12960
+ } else if (w.recoveryAttempts < maxRecoveryRetries) {
12942
12961
  w.recoveryAttempts++;
12943
12962
  dbg(`State transition on ${short(sid)}: recoveryAttempts=${w.recoveryAttempts - 1} -> ${w.recoveryAttempts}`);
12944
12963
  w.watchdogRetryGuard = true;
@@ -12994,6 +13013,31 @@ var AutoResumePlugin = async (ctx, options) => {
12994
13013
  return response.messages;
12995
13014
  return [];
12996
13015
  }
13016
+ function hasPendingUserInput(messages) {
13017
+ for (let i = messages.length - 1;i >= 0; i--) {
13018
+ const msg = messages[i];
13019
+ const rawRole = msg.role ?? msg.info?.role;
13020
+ if (rawRole === "user")
13021
+ return false;
13022
+ if (rawRole !== "assistant")
13023
+ continue;
13024
+ const parts = msg.parts;
13025
+ if (!parts)
13026
+ return false;
13027
+ for (const part of parts) {
13028
+ if (part.type !== "tool_use")
13029
+ continue;
13030
+ const state = part.state;
13031
+ if (state?.status === "pending")
13032
+ return true;
13033
+ }
13034
+ return false;
13035
+ }
13036
+ return false;
13037
+ }
13038
+ function userRecentlyActive(w) {
13039
+ return w.lastUserMessageAt > 0 && Date.now() - w.lastUserMessageAt < activeUserWindowMs;
13040
+ }
12997
13041
  const messagesInflight = new Map;
12998
13042
  async function getSessionMessages(sid) {
12999
13043
  const inflight = messagesInflight.get(sid);
@@ -13311,7 +13355,6 @@ var AutoResumePlugin = async (ctx, options) => {
13311
13355
  w.toolTextTimer = null;
13312
13356
  }
13313
13357
  w.todoNudgeAttempts = 0;
13314
- w.doneClaimNoTodosAttempts = 0;
13315
13358
  w.continueTimestamps = [];
13316
13359
  }
13317
13360
  function resetIdleFlags(w) {
@@ -13388,6 +13431,16 @@ var AutoResumePlugin = async (ctx, options) => {
13388
13431
  await log("debug", `${short(sid)} - checking for tool-call-as-text (attempt ${w.toolTextAttempts + 1})`);
13389
13432
  try {
13390
13433
  const messages = await getSessionMessages(sid);
13434
+ if (hasPendingUserInput(messages)) {
13435
+ w.checkingToolText = false;
13436
+ await log("info", `${short(sid)} - awaiting user input (pending tool_use), skipping tool-text check`);
13437
+ return;
13438
+ }
13439
+ if (userRecentlyActive(w)) {
13440
+ w.checkingToolText = false;
13441
+ await log("info", `${short(sid)} - user recently active, skipping tool-text check`);
13442
+ return;
13443
+ }
13391
13444
  const recent = messages.slice(-3);
13392
13445
  let bestCandidate = null;
13393
13446
  let allAssistantText = "";
@@ -13524,6 +13577,8 @@ var AutoResumePlugin = async (ctx, options) => {
13524
13577
  source: "done-claim-no-emoji",
13525
13578
  priority: 1
13526
13579
  };
13580
+ } else if (containsWorkDescription(allAssistantText)) {
13581
+ await log("info", `${short(sid)} - model claims done with no open todos, but the response already contains a work description. Skipping details prompt...`);
13527
13582
  } else if (w.doneClaimNoTodosAttempts < maxRetries) {
13528
13583
  await log("info", `${short(sid)} - model claims done with no open todos. Sending details prompt (attempt ${w.doneClaimNoTodosAttempts + 1}/${maxRetries})...`);
13529
13584
  bestCandidate = {
@@ -13666,6 +13721,11 @@ var AutoResumePlugin = async (ctx, options) => {
13666
13721
  await new Promise((resolve) => setTimeout(resolve, ABORT_CONTINUE_DELAY_MS));
13667
13722
  if (w.status === "busy")
13668
13723
  w.status = "idle";
13724
+ if (w.userCancelled || w.completionSignaled) {
13725
+ w.aborting = false;
13726
+ await log("info", `${short(sid)} - abort+resume stood down: session cancelled/completed during abort delay`);
13727
+ return false;
13728
+ }
13669
13729
  try {
13670
13730
  await sendContinuePrompt(sid, continuePrompt, w);
13671
13731
  await log("info", `${short(sid)} - abort+continue done`);
@@ -13918,6 +13978,18 @@ var AutoResumePlugin = async (ctx, options) => {
13918
13978
  continue;
13919
13979
  if (busyCount() !== 0)
13920
13980
  continue;
13981
+ try {
13982
+ if (hasPendingUserInput(await getSessionMessages(sid))) {
13983
+ await log("info", `${short(sid)} - awaiting user input (pending tool_use), skipping periodic open-todos nudge`);
13984
+ continue;
13985
+ }
13986
+ } catch (e) {
13987
+ dbg(`periodic recheck sid=${short(sid)}: awaiting-input check error: ${e}`);
13988
+ }
13989
+ if (userRecentlyActive(w)) {
13990
+ await log("info", `${short(sid)} - user recently active, skipping periodic open-todos nudge`);
13991
+ continue;
13992
+ }
13921
13993
  if ((w.todos || []).length === 0) {
13922
13994
  const fetched = await fetchSessionTodos(sid);
13923
13995
  if (fetched.length > 0) {
@@ -14011,12 +14083,24 @@ var AutoResumePlugin = async (ctx, options) => {
14011
14083
  log("debug", `${short(sid)} -> idle (${currentBusy})`);
14012
14084
  if (w.isSubagent) {
14013
14085
  try {
14014
- if (w.lastTokenTotal > 0 && w.contextWrapupAttempts < 1 && !w.userCancelled && !w.completionSignaled && !w.aborting) {
14086
+ let subAwaitingInput = false;
14087
+ try {
14088
+ subAwaitingInput = hasPendingUserInput(await getSessionMessages(sid));
14089
+ } catch (e) {
14090
+ dbg(`session.idle sid=${short(sid)}: awaiting-input check error: ${e}`);
14091
+ }
14092
+ if (subAwaitingInput) {
14093
+ await log("info", `${short(sid)} - awaiting user input (pending tool_use), skipping subagent saturation check`);
14094
+ } else if (userRecentlyActive(w)) {
14095
+ await log("info", `${short(sid)} - user recently active, skipping subagent saturation check`);
14096
+ } else if (w.lastTokenTotal > 0 && w.contextWrapupAttempts < 1 && !w.userCancelled && !w.completionSignaled && !w.aborting) {
14015
14097
  const usable = await getUsableContextLimit(sid);
14016
14098
  if (usable && w.lastTokenTotal / usable >= contextSaturationThreshold) {
14017
14099
  if (subagentNativeCompactionEnabled) {
14018
14100
  w.contextWrapupAttempts++;
14019
14101
  await log("warn", `${short(sid)} - context saturation (subagent): ${w.lastTokenTotal}/${usable} tokens (${Math.round(w.lastTokenTotal / usable * 100)}% of usable); triggering native compaction`);
14102
+ if (w.userCancelled || w.completionSignaled)
14103
+ break;
14020
14104
  await ctx.client.session.summarize({ path: { id: sid } });
14021
14105
  }
14022
14106
  }
@@ -14027,7 +14111,16 @@ var AutoResumePlugin = async (ctx, options) => {
14027
14111
  }
14028
14112
  }
14029
14113
  if (!w.isSubagent) {
14030
- if (!w.pendingRecovery && !w.completionSignaled && !w.userCancelled && !w.aborting) {
14114
+ let awaitingUserInput = false;
14115
+ try {
14116
+ awaitingUserInput = hasPendingUserInput(await getSessionMessages(sid));
14117
+ } catch (e) {
14118
+ dbg(`session.idle sid=${short(sid)}: awaiting-input check error: ${e}`);
14119
+ }
14120
+ if (awaitingUserInput) {
14121
+ await log("info", `${short(sid)} - awaiting user input (pending tool_use), standing down all idle checks/nudges`);
14122
+ }
14123
+ if (!awaitingUserInput && !w.pendingRecovery && !w.completionSignaled && !w.userCancelled && !w.aborting) {
14031
14124
  try {
14032
14125
  const errInfo = getLastAssistantError(await getSessionMessages(sid));
14033
14126
  if (errInfo && isStreamingFailure(errInfo.name, errInfo.message, streamingFailureErrorNames, streamingFailureMessagePatterns)) {
@@ -14067,7 +14160,7 @@ var AutoResumePlugin = async (ctx, options) => {
14067
14160
  dbg(`session.idle sid=${short(sid)}: silent-dead-stream check error: ${errMsg}`);
14068
14161
  }
14069
14162
  try {
14070
- if (w.lastTokenTotal > 0 && w.contextWrapupAttempts < 1 && !w.userCancelled && !w.completionSignaled) {
14163
+ if (w.lastTokenTotal > 0 && w.contextWrapupAttempts < 1 && !w.userCancelled && !w.completionSignaled && !userRecentlyActive(w)) {
14071
14164
  const usable = await getUsableContextLimit(sid);
14072
14165
  if (usable && w.lastTokenTotal / usable >= contextSaturationThreshold) {
14073
14166
  const installed = await isMagicContextInstalled();
@@ -14075,6 +14168,8 @@ var AutoResumePlugin = async (ctx, options) => {
14075
14168
  break;
14076
14169
  w.contextWrapupAttempts++;
14077
14170
  await log("warn", `${short(sid)} - context saturation: ${w.lastTokenTotal}/${usable} tokens (${Math.round(w.lastTokenTotal / usable * 100)}% of usable); sending magic-context wrapup command`);
14171
+ if (w.userCancelled || w.completionSignaled)
14172
+ break;
14078
14173
  await ctx.client.session.command({
14079
14174
  path: { id: sid },
14080
14175
  body: { command: CTX_WRAPUP_TRIGGER, arguments: "" }
@@ -14095,7 +14190,7 @@ var AutoResumePlugin = async (ctx, options) => {
14095
14190
  }
14096
14191
  }
14097
14192
  const open = getOpenTodos(todos);
14098
- if (open.length > 0 && currentBusy === 0 && !w.completionSignaled && !w.userCancelled && w.todoNudgeAttempts < maxRetries) {
14193
+ if (open.length > 0 && currentBusy === 0 && !awaitingUserInput && !userRecentlyActive(w) && !w.completionSignaled && !w.userCancelled && w.todoNudgeAttempts < maxRetries) {
14099
14194
  const isCelebration = await lastAssistantEndsWithCelebration(sid);
14100
14195
  await log("info", `${short(sid)} - open todos=${open.length}, isCelebration=${isCelebration}, currentBusy=${currentBusy}`);
14101
14196
  if (isCelebration) {
@@ -14121,6 +14216,14 @@ var AutoResumePlugin = async (ctx, options) => {
14121
14216
  return;
14122
14217
  }
14123
14218
  const msgs = await getSessionMessages(idleSid);
14219
+ if (hasPendingUserInput(msgs)) {
14220
+ dbg(`session.idle sid=${short(idleSid)}: awaiting user input, skipping action-intent prompt`);
14221
+ return;
14222
+ }
14223
+ if (userRecentlyActive(idleW)) {
14224
+ dbg(`session.idle sid=${short(idleSid)}: user recently active, skipping action-intent prompt`);
14225
+ return;
14226
+ }
14124
14227
  const lastAssistantMsg = msgs.slice().reverse().find((m) => (m.role ?? m.info?.role) === "assistant");
14125
14228
  if (lastAssistantMsg) {
14126
14229
  let lastText = "";
@@ -14188,6 +14291,15 @@ var AutoResumePlugin = async (ctx, options) => {
14188
14291
  return;
14189
14292
  }
14190
14293
  const msgs = await getSessionMessages(sid);
14294
+ if (hasPendingUserInput(msgs)) {
14295
+ dbg(`session.idle sid=${short(sid)}: awaiting user input, skipping action-intent prompt`);
14296
+ return;
14297
+ }
14298
+ const w2pre = sessions.get(sid);
14299
+ if (w2pre && userRecentlyActive(w2pre)) {
14300
+ dbg(`session.idle sid=${short(sid)}: user recently active, skipping action-intent prompt`);
14301
+ return;
14302
+ }
14191
14303
  const lastAssistantMsg = msgs.slice().reverse().find((m) => (m.role ?? m.info?.role) === "assistant");
14192
14304
  if (lastAssistantMsg) {
14193
14305
  let lastText = "";
@@ -14243,6 +14355,14 @@ var AutoResumePlugin = async (ctx, options) => {
14243
14355
  const props = ev.properties;
14244
14356
  const info = props?.info;
14245
14357
  const role = info?.role ?? props?.role;
14358
+ if (role === "user") {
14359
+ const w = sessions.get(sid);
14360
+ if (w) {
14361
+ w.doneClaimNoTodosAttempts = 0;
14362
+ w.lastUserMessageAt = Date.now();
14363
+ }
14364
+ break;
14365
+ }
14246
14366
  if (role !== "assistant")
14247
14367
  break;
14248
14368
  const tokens = info?.tokens ?? props?.tokens;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auto-resume",
3
- "version": "1.1.15",
3
+ "version": "1.1.16",
4
4
  "description": "OpenCode plugin that automatically resumes stalled LLM sessions when thinking/streaming freezes mid-generation.",
5
5
  "keywords": [
6
6
  "opencode",