opencode-auto-resume 1.1.14 → 1.1.15

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 +7 -1
  2. package/dist/index.js +127 -1
  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
@@ -261,7 +265,7 @@ All recovery paths fall into three families. Which family fires determines what
261
265
 
262
266
  Fire only after OpenCode reports the session **idle** — the runner has exited. The prompt starts a new run.
263
267
 
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.
268
+ 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
269
 
266
270
  ### 2. Busy-silence continue (stream stall)
267
271
 
@@ -384,6 +388,8 @@ With options:
384
388
  | `doneWithoutDetailsPrompt` | `DONE_WITHOUT_DETAILS_PROMPT` | Override the done-claim-with-no-todos report prompt |
385
389
  | `silentDeadStreamMinTokens` | `200` | Min output tokens to treat a textless `finish:"unknown"` message as a dead stream |
386
390
  | `busyStallStrategy` | `"continue"` | Busy-stall response: `"continue"`, `"abort"` (abort-first), or `"off"` (disabled) |
391
+ | `contextSaturationThreshold` | `0.85` | Ratio of used/usable context that routes a saturated parent to magic-context `ctx-wrapup` (only when magic-context is installed) |
392
+ | `subagentNativeCompactionEnabled` | `false` | Opt-in native `session.summarize()` for saturated subagent sessions (no magic-context detection required) |
387
393
 
388
394
  Message patterns are matched case-insensitively. Error names use exact match.
389
395
 
package/dist/index.js CHANGED
@@ -12370,6 +12370,7 @@ var SESSION_DISCOVERY_INTERVAL_MS = 60000;
12370
12370
  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
12371
  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
12372
  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.";
12373
+ var CTX_WRAPUP_TRIGGER = "ctx-wrapup";
12373
12374
  var TOOL_TEXT_PATTERNS = [
12374
12375
  /<function\s*=/i,
12375
12376
  /<function>/i,
@@ -12619,6 +12620,8 @@ var AutoResumePlugin = async (ctx, options) => {
12619
12620
  const silentDeadStreamMinTokens = options?.silentDeadStreamMinTokens ?? DEFAULT_SILENT_DEAD_STREAM_MIN_TOKENS;
12620
12621
  const rawBusyStallStrategy = options?.busyStallStrategy ?? "continue";
12621
12622
  const busyStallStrategy = rawBusyStallStrategy === "abort" || rawBusyStallStrategy === "off" ? rawBusyStallStrategy : "continue";
12623
+ const contextSaturationThreshold = options?.contextSaturationThreshold ?? 0.85;
12624
+ const subagentNativeCompactionEnabled = options?.subagentNativeCompactionEnabled ?? false;
12622
12625
  const dbg = (...args) => {
12623
12626
  if (debug)
12624
12627
  console.log("[debug]", ...args);
@@ -12706,6 +12709,8 @@ var AutoResumePlugin = async (ctx, options) => {
12706
12709
  interruptedContinueCount: 0,
12707
12710
  recentToolCalls: [],
12708
12711
  liveToolSigs: [],
12712
+ lastTokenTotal: 0,
12713
+ contextWrapupAttempts: 0,
12709
12714
  toolLoopAttempts: 0,
12710
12715
  isSubagent: false,
12711
12716
  completionSignaled: false,
@@ -13124,6 +13129,67 @@ var AutoResumePlugin = async (ctx, options) => {
13124
13129
  return false;
13125
13130
  }
13126
13131
  }
13132
+ let magicContextDetected = null;
13133
+ async function isMagicContextInstalled() {
13134
+ if (magicContextDetected !== null)
13135
+ return magicContextDetected;
13136
+ try {
13137
+ const res = await ctx.client.config?.get?.();
13138
+ const cfg = res?.data;
13139
+ const plugins = cfg?.plugin;
13140
+ if (!Array.isArray(plugins)) {
13141
+ dbg("magic-context detection: plugin list unavailable, treating as not installed");
13142
+ return false;
13143
+ }
13144
+ magicContextDetected = plugins.some((p) => {
13145
+ const spec = typeof p === "string" ? p : Array.isArray(p) ? String(p[0]) : "";
13146
+ return spec.toLowerCase().includes("magic-context");
13147
+ });
13148
+ return magicContextDetected;
13149
+ } catch (e) {
13150
+ const errMsg = e instanceof Error ? e.message : String(e);
13151
+ dbg(`magic-context detection failed, treating as not installed: ${errMsg}`);
13152
+ return false;
13153
+ }
13154
+ }
13155
+ const usableLimitCache = new Map;
13156
+ async function getUsableContextLimit(sid) {
13157
+ try {
13158
+ const msgs = await getSessionMessages(sid);
13159
+ let model;
13160
+ for (let i = msgs.length - 1;i >= 0; i--) {
13161
+ const m = msgs[i];
13162
+ if (m.role === "user") {
13163
+ model = m.model ?? m.info?.model;
13164
+ break;
13165
+ }
13166
+ }
13167
+ if (!model || typeof model.providerID !== "string" || typeof model.modelID !== "string") {
13168
+ return null;
13169
+ }
13170
+ const key = `${model.providerID}/${model.modelID}`;
13171
+ const cached = usableLimitCache.get(key);
13172
+ if (cached !== undefined)
13173
+ return cached;
13174
+ const res = await ctx.client.provider?.get?.();
13175
+ const providers = res?.data ?? res;
13176
+ if (!Array.isArray(providers))
13177
+ return null;
13178
+ const prov = providers.find((p) => p.id === model.providerID);
13179
+ const models = prov?.models;
13180
+ const entry = models?.find((x) => x.id === model.modelID);
13181
+ const limit = entry?.limit;
13182
+ if (!limit || typeof limit.context !== "number" || limit.context === 0)
13183
+ return null;
13184
+ const usable = limit.context - Math.min(20000, limit.output ?? 0);
13185
+ usableLimitCache.set(key, usable);
13186
+ return usable;
13187
+ } catch (e) {
13188
+ const errMsg = e instanceof Error ? e.message : String(e);
13189
+ dbg(`usable-context-limit lookup failed for ${short(sid)}: ${errMsg}`);
13190
+ return null;
13191
+ }
13192
+ }
13127
13193
  async function checkSessionHasActiveTool(sid) {
13128
13194
  try {
13129
13195
  const statusMap = await getSessionStatusMap();
@@ -13234,6 +13300,7 @@ var AutoResumePlugin = async (ctx, options) => {
13234
13300
  w.recentToolCalls = [];
13235
13301
  w.liveToolSigs = [];
13236
13302
  w.toolLoopAttempts = 0;
13303
+ w.contextWrapupAttempts = 0;
13237
13304
  w.pendingRecovery = false;
13238
13305
  w.pendingRecoveryReason = null;
13239
13306
  w.pendingRecoveryAt = 0;
@@ -13942,6 +14009,23 @@ var AutoResumePlugin = async (ctx, options) => {
13942
14009
  }
13943
14010
  prevBusyCount = currentBusy;
13944
14011
  log("debug", `${short(sid)} -> idle (${currentBusy})`);
14012
+ if (w.isSubagent) {
14013
+ try {
14014
+ if (w.lastTokenTotal > 0 && w.contextWrapupAttempts < 1 && !w.userCancelled && !w.completionSignaled && !w.aborting) {
14015
+ const usable = await getUsableContextLimit(sid);
14016
+ if (usable && w.lastTokenTotal / usable >= contextSaturationThreshold) {
14017
+ if (subagentNativeCompactionEnabled) {
14018
+ w.contextWrapupAttempts++;
14019
+ await log("warn", `${short(sid)} - context saturation (subagent): ${w.lastTokenTotal}/${usable} tokens (${Math.round(w.lastTokenTotal / usable * 100)}% of usable); triggering native compaction`);
14020
+ await ctx.client.session.summarize({ path: { id: sid } });
14021
+ }
14022
+ }
14023
+ }
14024
+ } catch (e) {
14025
+ const errMsg = e instanceof Error ? e.message : String(e);
14026
+ dbg(`session.idle sid=${short(sid)}: context-saturation check error: ${errMsg}`);
14027
+ }
14028
+ }
13945
14029
  if (!w.isSubagent) {
13946
14030
  if (!w.pendingRecovery && !w.completionSignaled && !w.userCancelled && !w.aborting) {
13947
14031
  try {
@@ -13982,6 +14066,25 @@ var AutoResumePlugin = async (ctx, options) => {
13982
14066
  const errMsg = e instanceof Error ? e.message : String(e);
13983
14067
  dbg(`session.idle sid=${short(sid)}: silent-dead-stream check error: ${errMsg}`);
13984
14068
  }
14069
+ try {
14070
+ if (w.lastTokenTotal > 0 && w.contextWrapupAttempts < 1 && !w.userCancelled && !w.completionSignaled) {
14071
+ const usable = await getUsableContextLimit(sid);
14072
+ if (usable && w.lastTokenTotal / usable >= contextSaturationThreshold) {
14073
+ const installed = await isMagicContextInstalled();
14074
+ if (!installed)
14075
+ break;
14076
+ w.contextWrapupAttempts++;
14077
+ await log("warn", `${short(sid)} - context saturation: ${w.lastTokenTotal}/${usable} tokens (${Math.round(w.lastTokenTotal / usable * 100)}% of usable); sending magic-context wrapup command`);
14078
+ await ctx.client.session.command({
14079
+ path: { id: sid },
14080
+ body: { command: CTX_WRAPUP_TRIGGER, arguments: "" }
14081
+ });
14082
+ }
14083
+ }
14084
+ } catch (e) {
14085
+ const errMsg = e instanceof Error ? e.message : String(e);
14086
+ dbg(`session.idle sid=${short(sid)}: context-saturation check error: ${errMsg}`);
14087
+ }
13985
14088
  }
13986
14089
  let todos = w.todos || [];
13987
14090
  if (todos.length === 0) {
@@ -14058,7 +14161,10 @@ var AutoResumePlugin = async (ctx, options) => {
14058
14161
  const w = ensureWatch(sid);
14059
14162
  w.pendingTools = 0;
14060
14163
  w.pendingCommands = 0;
14061
- log("debug", `New session: ${short(sid)} (${sessions.size})`);
14164
+ const createdProps = ev.properties;
14165
+ const parentID = createdProps?.parentID ?? createdProps?.session?.parentID;
14166
+ w.isSubagent = typeof parentID === "string" && parentID.length > 0;
14167
+ log("debug", `New session: ${short(sid)} (${sessions.size})${w.isSubagent ? " [subagent]" : ""}`);
14062
14168
  break;
14063
14169
  }
14064
14170
  case "session.updated": {
@@ -14131,6 +14237,26 @@ var AutoResumePlugin = async (ctx, options) => {
14131
14237
  }
14132
14238
  break;
14133
14239
  }
14240
+ case "message.updated": {
14241
+ if (!sid)
14242
+ break;
14243
+ const props = ev.properties;
14244
+ const info = props?.info;
14245
+ const role = info?.role ?? props?.role;
14246
+ if (role !== "assistant")
14247
+ break;
14248
+ const tokens = info?.tokens ?? props?.tokens;
14249
+ if (!tokens)
14250
+ break;
14251
+ const cache = tokens.cache;
14252
+ const total = tokens.total ?? (tokens.input ?? 0) + (tokens.output ?? 0) + (cache?.read ?? 0) + (cache?.write ?? 0);
14253
+ if (typeof total === "number" && total > 0) {
14254
+ const w = ensureWatch(sid);
14255
+ w.lastActivityAt = Date.now();
14256
+ w.lastTokenTotal = total;
14257
+ }
14258
+ break;
14259
+ }
14134
14260
  case "todo.updated": {
14135
14261
  if (!sid)
14136
14262
  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.15",
4
4
  "description": "OpenCode plugin that automatically resumes stalled LLM sessions when thinking/streaming freezes mid-generation.",
5
5
  "keywords": [
6
6
  "opencode",