opencode-immune 1.0.15 → 1.0.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 (2) hide show
  1. package/dist/plugin.js +54 -12
  2. package/package.json +2 -2
package/dist/plugin.js CHANGED
@@ -30,6 +30,10 @@ const RATE_LIMIT_FALLBACK_MODEL = {
30
30
  providerID: "externcash",
31
31
  modelID: "gpt-5.4",
32
32
  };
33
+ const CHILD_SESSION_FALLBACK_MODEL = {
34
+ providerID: "externcash",
35
+ modelID: "gpt-5.4",
36
+ };
33
37
  function isManagedUltraworkSession(state, sessionID) {
34
38
  return !!sessionID && state.managedUltraworkSessions.has(sessionID);
35
39
  }
@@ -173,8 +177,36 @@ function isRetryableApiError(error) {
173
177
  if (!error || typeof error !== "object")
174
178
  return false;
175
179
  const maybeError = error;
176
- return (maybeError.name === "APIError" &&
177
- maybeError.data?.isRetryable === true);
180
+ // Structured retryable flag
181
+ if (maybeError.name === "APIError" &&
182
+ maybeError.data?.isRetryable === true) {
183
+ return true;
184
+ }
185
+ // Text-based detection for model access errors (not marked as retryable
186
+ // by the API but retryable with a fallback model)
187
+ const message = `${maybeError.message ?? ""} ${maybeError.data?.message ?? ""}`.toLowerCase();
188
+ if (message.includes("не разрешен") ||
189
+ message.includes("not allowed") ||
190
+ message.includes("model not available") ||
191
+ message.includes("model_not_found") ||
192
+ message.includes("access denied")) {
193
+ return true;
194
+ }
195
+ return false;
196
+ }
197
+ function isModelAccessError(error) {
198
+ if (!error || typeof error !== "object")
199
+ return false;
200
+ const maybeError = error;
201
+ const message = `${maybeError.message ?? ""} ${maybeError.data?.message ?? ""}`.toLowerCase();
202
+ const type = `${maybeError.data?.type ?? ""}`.toLowerCase();
203
+ return (message.includes("не разрешен") ||
204
+ message.includes("not allowed") ||
205
+ message.includes("model not available") ||
206
+ message.includes("model_not_found") ||
207
+ message.includes("access denied") ||
208
+ type.includes("model_not_found") ||
209
+ type.includes("invalid_model"));
178
210
  }
179
211
  function isRateLimitApiError(error) {
180
212
  if (!error || typeof error !== "object")
@@ -263,7 +295,7 @@ async function sendManagedSessionRetryPrompt(state, sessionID, reason, options =
263
295
  return true;
264
296
  }
265
297
  function scheduleManagedSessionRetry(state, sessionID, options) {
266
- if (!isManagedRootUltraworkSession(state, sessionID)) {
298
+ if (!isManagedUltraworkSession(state, sessionID)) {
267
299
  return false;
268
300
  }
269
301
  if (state.sessionRetryTimers.has(sessionID)) {
@@ -275,7 +307,7 @@ function scheduleManagedSessionRetry(state, sessionID, options) {
275
307
  `Waiting ${options.delayMs / 1000}s before retry...`);
276
308
  const timer = setTimeout(async () => {
277
309
  state.sessionRetryTimers.delete(sessionID);
278
- if (!isManagedRootUltraworkSession(state, sessionID)) {
310
+ if (!isManagedUltraworkSession(state, sessionID)) {
279
311
  return;
280
312
  }
281
313
  try {
@@ -796,10 +828,13 @@ function createEventHandler(state) {
796
828
  const eventType = event.type ?? "unknown";
797
829
  const info = event.properties?.info;
798
830
  const sessionID = event.properties?.sessionID ?? info?.id;
799
- // ── Auto-retry on retryable API error for managed root ultrawork sessions ──
831
+ // ── Auto-retry on retryable API error for managed ultrawork sessions ──
800
832
  if (eventType === "session.error" && sessionID) {
801
833
  const error = event.properties?.error;
802
- if (!isManagedRootUltraworkSession(state, sessionID)) {
834
+ const managedSession = getManagedSession(state, sessionID);
835
+ const isRoot = managedSession?.kind === "root";
836
+ const isChild = managedSession?.kind === "child";
837
+ if (!managedSession) {
803
838
  return;
804
839
  }
805
840
  if (!isRetryableApiError(error)) {
@@ -808,22 +843,29 @@ function createEventHandler(state) {
808
843
  return;
809
844
  }
810
845
  if (state.sessionRetryTimers.has(sessionID)) {
811
- console.log(`[opencode-immune] Retry already pending for session ${sessionID}, skipping duplicate.`);
846
+ console.log(`[opencode-immune] Retry already pending for ${isChild ? "child" : "root"} session ${sessionID}, skipping duplicate.`);
812
847
  return;
813
848
  }
814
849
  const count = state.sessionErrorRetryCount.get(sessionID) ?? 0;
815
850
  if (count < MAX_RETRIES) {
816
851
  const delay = Math.min(BASE_DELAY_MS * Math.pow(2, count), MAX_DELAY_MS);
817
852
  state.sessionErrorRetryCount.set(sessionID, count + 1);
818
- if (isRateLimitApiError(error)) {
853
+ // Pin fallback model: for child sessions always, for root on rate-limit
854
+ if (isChild) {
855
+ await setSessionFallbackModel(state, sessionID, CHILD_SESSION_FALLBACK_MODEL);
856
+ const errorType = isModelAccessError(error) ? "model access error" : isRateLimitApiError(error) ? "rate limit" : "retryable error";
857
+ console.log(`[opencode-immune] Child session ${sessionID}: ${errorType} detected. ` +
858
+ `Retry will use fallback model ${CHILD_SESSION_FALLBACK_MODEL.providerID}/${CHILD_SESSION_FALLBACK_MODEL.modelID}.`);
859
+ }
860
+ else if (isRoot && isRateLimitApiError(error)) {
819
861
  await setSessionFallbackModel(state, sessionID, RATE_LIMIT_FALLBACK_MODEL);
820
- console.log(`[opencode-immune] Rate limit detected for session ${sessionID}. ` +
862
+ console.log(`[opencode-immune] Rate limit detected for root session ${sessionID}. ` +
821
863
  `Retry will use fallback model ${RATE_LIMIT_FALLBACK_MODEL.providerID}/${RATE_LIMIT_FALLBACK_MODEL.modelID}.`);
822
864
  }
823
865
  const scheduled = scheduleManagedSessionRetry(state, sessionID, {
824
866
  delayMs: delay,
825
- reason: "session.error",
826
- attemptLabel: `attempt ${count + 1}/${MAX_RETRIES}`,
867
+ reason: isChild ? "child session.error" : "session.error",
868
+ attemptLabel: `${isChild ? "child " : ""}attempt ${count + 1}/${MAX_RETRIES}`,
827
869
  countAgainstBudget: true,
828
870
  });
829
871
  if (!scheduled) {
@@ -831,7 +873,7 @@ function createEventHandler(state) {
831
873
  }
832
874
  }
833
875
  else {
834
- console.log(`[opencode-immune] Max retries (${MAX_RETRIES}) reached for session ${sessionID}. Not retrying.`);
876
+ console.log(`[opencode-immune] Max retries (${MAX_RETRIES}) reached for ${isChild ? "child" : "root"} session ${sessionID}. Not retrying.`);
835
877
  }
836
878
  }
837
879
  // Reset retry counter on successful activity
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-immune",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "description": "OpenCode plugin: session recovery, auto-retry, multi-cycle automation, context monitoring",
5
5
  "exports": {
6
6
  "./server": "./dist/plugin.js"
@@ -14,7 +14,7 @@
14
14
  "prepublishOnly": "npm run build"
15
15
  },
16
16
  "dependencies": {
17
- "@opencode-ai/plugin": "1.4.1"
17
+ "@opencode-ai/plugin": "1.4.2"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@types/node": "^25.5.2",