opencode-auto-resume 1.1.14 → 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 +18 -2
  2. package/dist/index.js +251 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -156,6 +156,10 @@ _Motivated by:_
156
156
 
157
157
  The model stream can die after emitting only reasoning — no text part, no tool call — finalizing with `finish: "unknown"`. OpenCode treats the message as completed and the session goes idle, so no error or stall path triggers. On idle, if the **newest** assistant message has a finish reason, zero text parts, and at least `silentDeadStreamMinTokens` output tokens, the plugin sends a recovery prompt. Only the newest assistant message is evaluated — a delivered text answer means normal completion, and older tool-call steps are never misread as dead streams. Recovery is also skipped if the session has gone busy/retry again before the prompt is sent (race guard).
158
158
 
159
+ ### Context saturation → magic-context wrapup
160
+
161
+ A session can fill its usable context window without stalling — it just keeps working until it chokes. The plugin tracks token usage from `message.updated` events and computes the ratio against the model's usable window (`context − min(20k, maxOutput)`, mirroring OpenCode's own overflow math). On idle, when the ratio crosses `contextSaturationThreshold` (default 0.85), routing depends on session kind. For parent sessions, only when magic-context is detected in the host's configured plugin list (`config.get().plugin`), the plugin invokes the registered `ctx-wrapup` command through `client.session.command()` — it does not send `/ctx-wrapup` as prompt text, because prompt text is not expanded into a command. For subagent sessions identified by `parentID` on `session.created`, the default is no intervention: magic-context does run bounded cleanup for subagents (structural-noise/cleared-reasoning strips, heuristic drops, ceiling nudges), but its hard protections — historian compartments, emergency fail-closed abort, caveman compression — skip subagents, and on true overflow the error just propagates to the parent with no deterministic reclaim. Terminal reclamation therefore depends on the agent calling `ctx_reduce` when nudged. If `subagentNativeCompactionEnabled` is `true`, the plugin calls native `session.summarize()` as an opt-in safety net. Fail-safe: provider lookup errors, unknown model limits, user cancellation, or completion signals mean no intervention. Magic-context absence additionally disables only the parent path (the `ctx-wrapup` command would not be registered); the opt-in subagent path needs no magic-context detection because `session.summarize()` is native. This path is magic-context-gated on purpose: magic-context's setup disables OpenCode's native compaction, so unconditionally summarizing a magic-context-managed parent would double-compress and fight its cache-aware historian. One intervention per busy cycle.
162
+
159
163
  ---
160
164
 
161
165
  ### Active-tool safety guard
@@ -221,7 +225,16 @@ When the assistant ends a line with `:` ("Next, I will edit the file:") — anno
221
225
 
222
226
  ### Done-claim verification
223
227
 
224
- 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.
225
238
 
226
239
  ---
227
240
 
@@ -261,7 +274,7 @@ All recovery paths fall into three families. Which family fires determines what
261
274
 
262
275
  Fire only after OpenCode reports the session **idle** — the runner has exited. The prompt starts a new run.
263
276
 
264
- Paths: todo nudges, tool-call-as-text recovery, thinking-tool recovery, action-intent nudge, ready-to-continue, done-claim verification, streaming-failure recovery, silent dead-stream recovery.
277
+ Paths: todo nudges, tool-call-as-text recovery, thinking-tool recovery, action-intent nudge, ready-to-continue, done-claim verification, streaming-failure recovery, silent dead-stream recovery, context-saturation routing (magic-context-gated; parent sessions use `session.command`, subagents use opt-in native `session.summarize`).
265
278
 
266
279
  ### 2. Busy-silence continue (stream stall)
267
280
 
@@ -384,6 +397,9 @@ With options:
384
397
  | `doneWithoutDetailsPrompt` | `DONE_WITHOUT_DETAILS_PROMPT` | Override the done-claim-with-no-todos report prompt |
385
398
  | `silentDeadStreamMinTokens` | `200` | Min output tokens to treat a textless `finish:"unknown"` message as a dead stream |
386
399
  | `busyStallStrategy` | `"continue"` | Busy-stall response: `"continue"`, `"abort"` (abort-first), or `"off"` (disabled) |
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) |
402
+ | `subagentNativeCompactionEnabled` | `false` | Opt-in native `session.summarize()` for saturated subagent sessions (no magic-context detection required) |
387
403
 
388
404
  Message patterns are matched case-insensitively. Error names use exact match.
389
405
 
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;
@@ -12370,6 +12371,7 @@ var SESSION_DISCOVERY_INTERVAL_MS = 60000;
12370
12371
  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.";
12371
12372
  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.";
12372
12373
  var TOOL_LOOP_RECOVERY_PROMPT = "I notice you've been calling the same tool multiple times in a row without making progress. " + "Please step back and reassess your approach. Consider: " + "1) Are you stuck in a loop? 2) Do you need different information first? " + "3) Should you try a different tool or break the task into smaller steps? " + "Take a moment to think about what's blocking you and propose a different strategy.";
12374
+ var CTX_WRAPUP_TRIGGER = "ctx-wrapup";
12373
12375
  var TOOL_TEXT_PATTERNS = [
12374
12376
  /<function\s*=/i,
12375
12377
  /<function>/i,
@@ -12455,6 +12457,15 @@ function containsDoneClaimPattern(text) {
12455
12457
  `);
12456
12458
  return DONE_CLAIM_PATTERNS.some((pat) => pat.test(lastLines));
12457
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
+ }
12458
12469
  function isStreamingFailure(errorName, errorMessage, errorNames = DEFAULT_STREAMING_FAILURE_ERROR_NAMES, messagePatterns = DEFAULT_STREAMING_FAILURE_MESSAGE_PATTERNS) {
12459
12470
  if (!errorName && !errorMessage)
12460
12471
  return false;
@@ -12619,6 +12630,9 @@ var AutoResumePlugin = async (ctx, options) => {
12619
12630
  const silentDeadStreamMinTokens = options?.silentDeadStreamMinTokens ?? DEFAULT_SILENT_DEAD_STREAM_MIN_TOKENS;
12620
12631
  const rawBusyStallStrategy = options?.busyStallStrategy ?? "continue";
12621
12632
  const busyStallStrategy = rawBusyStallStrategy === "abort" || rawBusyStallStrategy === "off" ? rawBusyStallStrategy : "continue";
12633
+ const contextSaturationThreshold = options?.contextSaturationThreshold ?? 0.85;
12634
+ const subagentNativeCompactionEnabled = options?.subagentNativeCompactionEnabled ?? false;
12635
+ const activeUserWindowMs = options?.activeUserWindowMs ?? DEFAULT_ACTIVE_USER_WINDOW_MS;
12622
12636
  const dbg = (...args) => {
12623
12637
  if (debug)
12624
12638
  console.log("[debug]", ...args);
@@ -12706,12 +12720,15 @@ var AutoResumePlugin = async (ctx, options) => {
12706
12720
  interruptedContinueCount: 0,
12707
12721
  recentToolCalls: [],
12708
12722
  liveToolSigs: [],
12723
+ lastTokenTotal: 0,
12724
+ contextWrapupAttempts: 0,
12709
12725
  toolLoopAttempts: 0,
12710
12726
  isSubagent: false,
12711
12727
  completionSignaled: false,
12712
12728
  todoNudgeAttempts: 0,
12713
12729
  taskCompleteOverrides: 0,
12714
12730
  doneClaimNoTodosAttempts: 0,
12731
+ lastUserMessageAt: 0,
12715
12732
  pendingTools: 0,
12716
12733
  pendingCommands: 0,
12717
12734
  pendingRecovery: false,
@@ -12892,6 +12909,8 @@ var AutoResumePlugin = async (ctx, options) => {
12892
12909
  }
12893
12910
  }
12894
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;
12895
12914
  const response = await ctx.client.session.prompt({
12896
12915
  path: { id: sid },
12897
12916
  body: {
@@ -12933,7 +12952,12 @@ var AutoResumePlugin = async (ctx, options) => {
12933
12952
  setTimeout(async () => {
12934
12953
  if (w.status !== "busy") {
12935
12954
  if (w.pendingRecovery) {
12936
- 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) {
12937
12961
  w.recoveryAttempts++;
12938
12962
  dbg(`State transition on ${short(sid)}: recoveryAttempts=${w.recoveryAttempts - 1} -> ${w.recoveryAttempts}`);
12939
12963
  w.watchdogRetryGuard = true;
@@ -12989,6 +13013,31 @@ var AutoResumePlugin = async (ctx, options) => {
12989
13013
  return response.messages;
12990
13014
  return [];
12991
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
+ }
12992
13041
  const messagesInflight = new Map;
12993
13042
  async function getSessionMessages(sid) {
12994
13043
  const inflight = messagesInflight.get(sid);
@@ -13124,6 +13173,67 @@ var AutoResumePlugin = async (ctx, options) => {
13124
13173
  return false;
13125
13174
  }
13126
13175
  }
13176
+ let magicContextDetected = null;
13177
+ async function isMagicContextInstalled() {
13178
+ if (magicContextDetected !== null)
13179
+ return magicContextDetected;
13180
+ try {
13181
+ const res = await ctx.client.config?.get?.();
13182
+ const cfg = res?.data;
13183
+ const plugins = cfg?.plugin;
13184
+ if (!Array.isArray(plugins)) {
13185
+ dbg("magic-context detection: plugin list unavailable, treating as not installed");
13186
+ return false;
13187
+ }
13188
+ magicContextDetected = plugins.some((p) => {
13189
+ const spec = typeof p === "string" ? p : Array.isArray(p) ? String(p[0]) : "";
13190
+ return spec.toLowerCase().includes("magic-context");
13191
+ });
13192
+ return magicContextDetected;
13193
+ } catch (e) {
13194
+ const errMsg = e instanceof Error ? e.message : String(e);
13195
+ dbg(`magic-context detection failed, treating as not installed: ${errMsg}`);
13196
+ return false;
13197
+ }
13198
+ }
13199
+ const usableLimitCache = new Map;
13200
+ async function getUsableContextLimit(sid) {
13201
+ try {
13202
+ const msgs = await getSessionMessages(sid);
13203
+ let model;
13204
+ for (let i = msgs.length - 1;i >= 0; i--) {
13205
+ const m = msgs[i];
13206
+ if (m.role === "user") {
13207
+ model = m.model ?? m.info?.model;
13208
+ break;
13209
+ }
13210
+ }
13211
+ if (!model || typeof model.providerID !== "string" || typeof model.modelID !== "string") {
13212
+ return null;
13213
+ }
13214
+ const key = `${model.providerID}/${model.modelID}`;
13215
+ const cached = usableLimitCache.get(key);
13216
+ if (cached !== undefined)
13217
+ return cached;
13218
+ const res = await ctx.client.provider?.get?.();
13219
+ const providers = res?.data ?? res;
13220
+ if (!Array.isArray(providers))
13221
+ return null;
13222
+ const prov = providers.find((p) => p.id === model.providerID);
13223
+ const models = prov?.models;
13224
+ const entry = models?.find((x) => x.id === model.modelID);
13225
+ const limit = entry?.limit;
13226
+ if (!limit || typeof limit.context !== "number" || limit.context === 0)
13227
+ return null;
13228
+ const usable = limit.context - Math.min(20000, limit.output ?? 0);
13229
+ usableLimitCache.set(key, usable);
13230
+ return usable;
13231
+ } catch (e) {
13232
+ const errMsg = e instanceof Error ? e.message : String(e);
13233
+ dbg(`usable-context-limit lookup failed for ${short(sid)}: ${errMsg}`);
13234
+ return null;
13235
+ }
13236
+ }
13127
13237
  async function checkSessionHasActiveTool(sid) {
13128
13238
  try {
13129
13239
  const statusMap = await getSessionStatusMap();
@@ -13234,6 +13344,7 @@ var AutoResumePlugin = async (ctx, options) => {
13234
13344
  w.recentToolCalls = [];
13235
13345
  w.liveToolSigs = [];
13236
13346
  w.toolLoopAttempts = 0;
13347
+ w.contextWrapupAttempts = 0;
13237
13348
  w.pendingRecovery = false;
13238
13349
  w.pendingRecoveryReason = null;
13239
13350
  w.pendingRecoveryAt = 0;
@@ -13244,7 +13355,6 @@ var AutoResumePlugin = async (ctx, options) => {
13244
13355
  w.toolTextTimer = null;
13245
13356
  }
13246
13357
  w.todoNudgeAttempts = 0;
13247
- w.doneClaimNoTodosAttempts = 0;
13248
13358
  w.continueTimestamps = [];
13249
13359
  }
13250
13360
  function resetIdleFlags(w) {
@@ -13321,6 +13431,16 @@ var AutoResumePlugin = async (ctx, options) => {
13321
13431
  await log("debug", `${short(sid)} - checking for tool-call-as-text (attempt ${w.toolTextAttempts + 1})`);
13322
13432
  try {
13323
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
+ }
13324
13444
  const recent = messages.slice(-3);
13325
13445
  let bestCandidate = null;
13326
13446
  let allAssistantText = "";
@@ -13457,6 +13577,8 @@ var AutoResumePlugin = async (ctx, options) => {
13457
13577
  source: "done-claim-no-emoji",
13458
13578
  priority: 1
13459
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...`);
13460
13582
  } else if (w.doneClaimNoTodosAttempts < maxRetries) {
13461
13583
  await log("info", `${short(sid)} - model claims done with no open todos. Sending details prompt (attempt ${w.doneClaimNoTodosAttempts + 1}/${maxRetries})...`);
13462
13584
  bestCandidate = {
@@ -13599,6 +13721,11 @@ var AutoResumePlugin = async (ctx, options) => {
13599
13721
  await new Promise((resolve) => setTimeout(resolve, ABORT_CONTINUE_DELAY_MS));
13600
13722
  if (w.status === "busy")
13601
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
+ }
13602
13729
  try {
13603
13730
  await sendContinuePrompt(sid, continuePrompt, w);
13604
13731
  await log("info", `${short(sid)} - abort+continue done`);
@@ -13851,6 +13978,18 @@ var AutoResumePlugin = async (ctx, options) => {
13851
13978
  continue;
13852
13979
  if (busyCount() !== 0)
13853
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
+ }
13854
13993
  if ((w.todos || []).length === 0) {
13855
13994
  const fetched = await fetchSessionTodos(sid);
13856
13995
  if (fetched.length > 0) {
@@ -13942,8 +14081,46 @@ var AutoResumePlugin = async (ctx, options) => {
13942
14081
  }
13943
14082
  prevBusyCount = currentBusy;
13944
14083
  log("debug", `${short(sid)} -> idle (${currentBusy})`);
14084
+ if (w.isSubagent) {
14085
+ try {
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) {
14097
+ const usable = await getUsableContextLimit(sid);
14098
+ if (usable && w.lastTokenTotal / usable >= contextSaturationThreshold) {
14099
+ if (subagentNativeCompactionEnabled) {
14100
+ w.contextWrapupAttempts++;
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;
14104
+ await ctx.client.session.summarize({ path: { id: sid } });
14105
+ }
14106
+ }
14107
+ }
14108
+ } catch (e) {
14109
+ const errMsg = e instanceof Error ? e.message : String(e);
14110
+ dbg(`session.idle sid=${short(sid)}: context-saturation check error: ${errMsg}`);
14111
+ }
14112
+ }
13945
14113
  if (!w.isSubagent) {
13946
- 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) {
13947
14124
  try {
13948
14125
  const errInfo = getLastAssistantError(await getSessionMessages(sid));
13949
14126
  if (errInfo && isStreamingFailure(errInfo.name, errInfo.message, streamingFailureErrorNames, streamingFailureMessagePatterns)) {
@@ -13982,6 +14159,27 @@ var AutoResumePlugin = async (ctx, options) => {
13982
14159
  const errMsg = e instanceof Error ? e.message : String(e);
13983
14160
  dbg(`session.idle sid=${short(sid)}: silent-dead-stream check error: ${errMsg}`);
13984
14161
  }
14162
+ try {
14163
+ if (w.lastTokenTotal > 0 && w.contextWrapupAttempts < 1 && !w.userCancelled && !w.completionSignaled && !userRecentlyActive(w)) {
14164
+ const usable = await getUsableContextLimit(sid);
14165
+ if (usable && w.lastTokenTotal / usable >= contextSaturationThreshold) {
14166
+ const installed = await isMagicContextInstalled();
14167
+ if (!installed)
14168
+ break;
14169
+ w.contextWrapupAttempts++;
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;
14173
+ await ctx.client.session.command({
14174
+ path: { id: sid },
14175
+ body: { command: CTX_WRAPUP_TRIGGER, arguments: "" }
14176
+ });
14177
+ }
14178
+ }
14179
+ } catch (e) {
14180
+ const errMsg = e instanceof Error ? e.message : String(e);
14181
+ dbg(`session.idle sid=${short(sid)}: context-saturation check error: ${errMsg}`);
14182
+ }
13985
14183
  }
13986
14184
  let todos = w.todos || [];
13987
14185
  if (todos.length === 0) {
@@ -13992,7 +14190,7 @@ var AutoResumePlugin = async (ctx, options) => {
13992
14190
  }
13993
14191
  }
13994
14192
  const open = getOpenTodos(todos);
13995
- 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) {
13996
14194
  const isCelebration = await lastAssistantEndsWithCelebration(sid);
13997
14195
  await log("info", `${short(sid)} - open todos=${open.length}, isCelebration=${isCelebration}, currentBusy=${currentBusy}`);
13998
14196
  if (isCelebration) {
@@ -14018,6 +14216,14 @@ var AutoResumePlugin = async (ctx, options) => {
14018
14216
  return;
14019
14217
  }
14020
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
+ }
14021
14227
  const lastAssistantMsg = msgs.slice().reverse().find((m) => (m.role ?? m.info?.role) === "assistant");
14022
14228
  if (lastAssistantMsg) {
14023
14229
  let lastText = "";
@@ -14058,7 +14264,10 @@ var AutoResumePlugin = async (ctx, options) => {
14058
14264
  const w = ensureWatch(sid);
14059
14265
  w.pendingTools = 0;
14060
14266
  w.pendingCommands = 0;
14061
- log("debug", `New session: ${short(sid)} (${sessions.size})`);
14267
+ const createdProps = ev.properties;
14268
+ const parentID = createdProps?.parentID ?? createdProps?.session?.parentID;
14269
+ w.isSubagent = typeof parentID === "string" && parentID.length > 0;
14270
+ log("debug", `New session: ${short(sid)} (${sessions.size})${w.isSubagent ? " [subagent]" : ""}`);
14062
14271
  break;
14063
14272
  }
14064
14273
  case "session.updated": {
@@ -14082,6 +14291,15 @@ var AutoResumePlugin = async (ctx, options) => {
14082
14291
  return;
14083
14292
  }
14084
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
+ }
14085
14303
  const lastAssistantMsg = msgs.slice().reverse().find((m) => (m.role ?? m.info?.role) === "assistant");
14086
14304
  if (lastAssistantMsg) {
14087
14305
  let lastText = "";
@@ -14131,6 +14349,34 @@ var AutoResumePlugin = async (ctx, options) => {
14131
14349
  }
14132
14350
  break;
14133
14351
  }
14352
+ case "message.updated": {
14353
+ if (!sid)
14354
+ break;
14355
+ const props = ev.properties;
14356
+ const info = props?.info;
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
+ }
14366
+ if (role !== "assistant")
14367
+ break;
14368
+ const tokens = info?.tokens ?? props?.tokens;
14369
+ if (!tokens)
14370
+ break;
14371
+ const cache = tokens.cache;
14372
+ const total = tokens.total ?? (tokens.input ?? 0) + (tokens.output ?? 0) + (cache?.read ?? 0) + (cache?.write ?? 0);
14373
+ if (typeof total === "number" && total > 0) {
14374
+ const w = ensureWatch(sid);
14375
+ w.lastActivityAt = Date.now();
14376
+ w.lastTokenTotal = total;
14377
+ }
14378
+ break;
14379
+ }
14134
14380
  case "todo.updated": {
14135
14381
  if (!sid)
14136
14382
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auto-resume",
3
- "version": "1.1.14",
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",