opencode-immune 1.0.53 → 1.0.55

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 +100 -18
  2. package/package.json +1 -1
package/dist/plugin.js CHANGED
@@ -11,9 +11,17 @@ import { execFile } from "child_process";
11
11
  // ═══════════════════════════════════════════════════════════════════════════════
12
12
  // PLUGIN VERSION CHECK
13
13
  // ═══════════════════════════════════════════════════════════════════════════════
14
- const PLUGIN_VERSION = "1.0.53";
14
+ const PLUGIN_VERSION = "1.0.55";
15
15
  const PLUGIN_PACKAGE_NAME = "opencode-immune";
16
16
  const PLUGIN_DIRNAME = dirname(fileURLToPath(import.meta.url));
17
+ function getServerAuthHeaders() {
18
+ const password = process.env.OPENCODE_SERVER_PASSWORD;
19
+ if (!password)
20
+ return undefined;
21
+ return {
22
+ Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`,
23
+ };
24
+ }
17
25
  /**
18
26
  * Read plugin version from package.json at runtime.
19
27
  * Falls back to PLUGIN_VERSION constant if read fails.
@@ -80,6 +88,7 @@ function createState(input) {
80
88
  client: createOpencodeClientV2({
81
89
  baseUrl: input.serverUrl.toString(),
82
90
  directory: input.directory,
91
+ headers: getServerAuthHeaders(),
83
92
  }),
84
93
  recoveryContext: null,
85
94
  managedUltraworkSessions: new Map(),
@@ -126,12 +135,17 @@ const RETRY_PROMPT_DELIVERY_ATTEMPTS = 3;
126
135
  const CHILD_FALLBACK_REQUEST_TTL_MS = 10 * 60 * 1000;
127
136
  const RATE_LIMIT_FALLBACK_MODEL = {
128
137
  providerID: "codexsale",
129
- modelID: "gpt-5.5",
138
+ modelID: "gpt-5.4-mini",
130
139
  };
131
140
  const CHILD_SESSION_FALLBACK_MODEL = {
132
- providerID: "codexsale",
133
- modelID: "gpt-5.5",
141
+ providerID: "claudehub",
142
+ modelID: "claude-opus-4-7",
134
143
  };
144
+ const FALLBACK_MODEL_CANDIDATES = [
145
+ CHILD_SESSION_FALLBACK_MODEL,
146
+ RATE_LIMIT_FALLBACK_MODEL,
147
+ { providerID: "codexsale", modelID: "gpt-5.5" },
148
+ ];
135
149
  function isManagedUltraworkSession(state, sessionID) {
136
150
  return !!sessionID && state.managedUltraworkSessions.has(sessionID);
137
151
  }
@@ -308,6 +322,7 @@ async function addManagedUltraworkSession(state, sessionID, timestamp = Date.now
308
322
  rootSessionID: existing?.rootSessionID ?? sessionID,
309
323
  createdAt: existing?.createdAt ?? timestamp,
310
324
  updatedAt: timestamp,
325
+ currentModel: existing?.currentModel,
311
326
  fallbackModel: existing?.fallbackModel,
312
327
  };
313
328
  if (existing &&
@@ -335,6 +350,7 @@ async function addManagedChildSession(state, sessionID, parentSessionID, timesta
335
350
  rootSessionID: parent.rootSessionID,
336
351
  createdAt: existing?.createdAt ?? timestamp,
337
352
  updatedAt: timestamp,
353
+ currentModel: existing?.currentModel ?? parent.currentModel,
338
354
  fallbackModel: existing?.fallbackModel ?? parent.fallbackModel,
339
355
  });
340
356
  }
@@ -387,6 +403,60 @@ function markUltraworkSessionActive(state, sessionID) {
387
403
  });
388
404
  return true;
389
405
  }
406
+ function getModelProviderID(model) {
407
+ return model?.providerID || model?.modelID.split("/")[0];
408
+ }
409
+ function isSameModel(a, b) {
410
+ if (!a || !b)
411
+ return false;
412
+ return a.providerID === b.providerID && a.modelID === b.modelID;
413
+ }
414
+ function selectFallbackModel(currentModel) {
415
+ const currentProviderID = getModelProviderID(currentModel);
416
+ const otherProviderModel = FALLBACK_MODEL_CANDIDATES.find((candidate) => !isSameModel(candidate, currentModel) &&
417
+ getModelProviderID(candidate) !== currentProviderID);
418
+ if (otherProviderModel)
419
+ return otherProviderModel;
420
+ return (FALLBACK_MODEL_CANDIDATES.find((candidate) => !isSameModel(candidate, currentModel)) ?? RATE_LIMIT_FALLBACK_MODEL);
421
+ }
422
+ function getFailedModelFromError(error) {
423
+ if (!error || typeof error !== "object")
424
+ return undefined;
425
+ const maybeError = error;
426
+ const message = `${maybeError.message ?? ""} ${maybeError.data?.message ?? ""} ${maybeError.error?.message ?? ""}`;
427
+ const providerModelMatch = message.match(/\b([a-z0-9_-]+)\/([a-z0-9._-]+)\b/i);
428
+ if (providerModelMatch?.[1] && providerModelMatch[2]) {
429
+ return {
430
+ providerID: providerModelMatch[1].toLowerCase(),
431
+ modelID: providerModelMatch[2].toLowerCase(),
432
+ };
433
+ }
434
+ const unsupportedModelMatch = message.match(/["']([^"']+)["']\s+model\s+is\s+not\s+supported/i);
435
+ if (unsupportedModelMatch?.[1] && /\bcodex\b/i.test(message)) {
436
+ return {
437
+ providerID: "codexsale",
438
+ modelID: unsupportedModelMatch[1].toLowerCase(),
439
+ };
440
+ }
441
+ return undefined;
442
+ }
443
+ async function updateManagedSessionModel(state, sessionID, model) {
444
+ const existing = state.managedUltraworkSessions.get(sessionID);
445
+ if (!existing || isSameModel(existing.currentModel, model))
446
+ return;
447
+ state.managedUltraworkSessions.set(sessionID, {
448
+ ...existing,
449
+ currentModel: model,
450
+ updatedAt: Date.now(),
451
+ });
452
+ }
453
+ function getSessionFallbackModel(state, sessionID, preferredModel) {
454
+ const currentModel = state.managedUltraworkSessions.get(sessionID)?.currentModel;
455
+ if (preferredModel && !isSameModel(preferredModel, currentModel)) {
456
+ return preferredModel;
457
+ }
458
+ return selectFallbackModel(currentModel);
459
+ }
390
460
  function isRetryableApiError(error) {
391
461
  if (!error || typeof error !== "object")
392
462
  return false;
@@ -422,6 +492,7 @@ function isRetryableApiError(error) {
422
492
  if (message.includes("api_error") ||
423
493
  message.includes("не разрешен") ||
424
494
  message.includes("not allowed") ||
495
+ message.includes("not supported") ||
425
496
  message.includes("internal error") ||
426
497
  message.includes("internal server error") ||
427
498
  message.includes("expected") ||
@@ -476,6 +547,7 @@ function isModelAccessError(error) {
476
547
  const type = `${maybeError.data?.type ?? ""}`.toLowerCase();
477
548
  return (message.includes("не разрешен") ||
478
549
  message.includes("not allowed") ||
550
+ message.includes("not supported") ||
479
551
  message.includes("model not available") ||
480
552
  message.includes("model_not_found") ||
481
553
  message.includes("access denied") ||
@@ -525,12 +597,13 @@ function getRetryableErrorType(error) {
525
597
  return "retryable provider error";
526
598
  }
527
599
  function recordChildFallbackRequest(state, managedSession, childSessionID, error) {
600
+ const fallbackModel = selectFallbackModel(getFailedModelFromError(error) ?? managedSession.currentModel);
528
601
  const request = {
529
602
  childSessionID,
530
603
  rootSessionID: managedSession.rootSessionID,
531
604
  agent: managedSession.agent || "unknown",
532
605
  errorType: getRetryableErrorType(error),
533
- fallbackModel: CHILD_SESSION_FALLBACK_MODEL,
606
+ fallbackModel,
534
607
  createdAt: Date.now(),
535
608
  };
536
609
  state.childFallbackRequests.set(managedSession.rootSessionID, request);
@@ -556,10 +629,11 @@ function scheduleProviderRetryWatchdog(state, sessionID, model) {
556
629
  return;
557
630
  if (state.sessionRetryTimers.has(sessionID))
558
631
  return;
559
- await setSessionFallbackModel(state, sessionID, model);
632
+ const fallbackModel = getSessionFallbackModel(state, sessionID, model);
633
+ await setSessionFallbackModel(state, sessionID, fallbackModel);
560
634
  await writeDiagnosticLog(state, "provider-retry-watchdog:fired", {
561
635
  sessionID,
562
- fallbackModel: model,
636
+ fallbackModel,
563
637
  });
564
638
  scheduleManagedSessionRetry(state, sessionID, {
565
639
  delayMs: 1_000,
@@ -1255,7 +1329,7 @@ function createSystemTransform(state) {
1255
1329
  `- Agent: ${childFallbackRequest.agent}\n` +
1256
1330
  `- Error type: ${childFallbackRequest.errorType}\n` +
1257
1331
  `- Required fallback model: ${childFallbackRequest.fallbackModel.providerID}/${childFallbackRequest.fallbackModel.modelID}\n` +
1258
- `Router action: retry the SAME agent/slot once using the fallback model if available; if it still fails or cannot be retried, record DECLINE/failed-provider-retry for that slot before continuing. ` +
1332
+ `Router action: retry the SAME agent/slot once using the required fallback model from a different provider; never reuse the failed model for this retry. If it still fails or cannot be retried, record DECLINE/failed-provider-retry for that slot before continuing. ` +
1259
1333
  `Never resume the failed child session directly; create a router-owned replacement attempt instead.`);
1260
1334
  }
1261
1335
  }
@@ -1429,7 +1503,7 @@ function createFallbackModels(state) {
1429
1503
  const wasAlreadyManaged = isManagedUltraworkSession(state, input.sessionID);
1430
1504
  await addManagedUltraworkSession(state, input.sessionID);
1431
1505
  await writeUltraworkMarker(state);
1432
- scheduleProviderRetryWatchdog(state, input.sessionID, RATE_LIMIT_FALLBACK_MODEL);
1506
+ scheduleProviderRetryWatchdog(state, input.sessionID);
1433
1507
  // First contact with 0-ultrawork after plugin restart:
1434
1508
  // if marker is active and tasks.md has incomplete work, send AUTO-RESUME prompt.
1435
1509
  if (!wasAlreadyManaged && !state.autoResumeAttempted) {
@@ -1463,11 +1537,17 @@ function createFallbackModels(state) {
1463
1537
  // Subagent calls from 0-ultrawork (1-van, 7-backlog, etc.) use the same session.
1464
1538
  // Log model and agent for observability
1465
1539
  const modelId = input.model && "id" in input.model
1466
- ? input.model.id
1540
+ ? input.model.id ?? "unknown"
1467
1541
  : "unknown";
1468
1542
  const providerId = input.provider?.info && "id" in input.provider.info
1469
- ? input.provider.info.id
1543
+ ? input.provider.info.id ?? "unknown"
1470
1544
  : "unknown";
1545
+ if (input.sessionID && modelId !== "unknown" && providerId !== "unknown") {
1546
+ await updateManagedSessionModel(state, input.sessionID, {
1547
+ providerID: providerId,
1548
+ modelID: modelId,
1549
+ });
1550
+ }
1471
1551
  pluginLog.info(`[opencode-immune] Model Observer: agent="${input.agent}", ` +
1472
1552
  `model="${modelId}", provider="${providerId}"`);
1473
1553
  };
@@ -1555,11 +1635,12 @@ function createEventHandler(state) {
1555
1635
  state.sessionErrorRetryCount.set(sessionID, count);
1556
1636
  return;
1557
1637
  }
1558
- else if (isRoot && (isRateLimitApiError(error) || isCertificateApiError(error))) {
1559
- await setSessionFallbackModel(state, sessionID, RATE_LIMIT_FALLBACK_MODEL);
1560
- const errorType = isCertificateApiError(error) ? "certificate error" : "rate limit";
1638
+ else if (isRoot && (isModelAccessError(error) || isRateLimitApiError(error) || isCertificateApiError(error))) {
1639
+ const selectedFallbackModel = selectFallbackModel(getFailedModelFromError(error) ?? managedSession.currentModel);
1640
+ await setSessionFallbackModel(state, sessionID, selectedFallbackModel);
1641
+ const errorType = getRetryableErrorType(error);
1561
1642
  pluginLog.info(`[opencode-immune] ${errorType} detected for root session ${sessionID}. ` +
1562
- `Retry will use fallback model ${RATE_LIMIT_FALLBACK_MODEL.providerID}/${RATE_LIMIT_FALLBACK_MODEL.modelID}.`);
1643
+ `Retry will use fallback model ${selectedFallbackModel.providerID}/${selectedFallbackModel.modelID}.`);
1563
1644
  }
1564
1645
  const scheduled = scheduleManagedSessionRetry(state, sessionID, {
1565
1646
  delayMs: delay,
@@ -1737,7 +1818,7 @@ function createTextCompleteHandler(state) {
1737
1818
  // Some provider/SDK failures render as a retry banner instead of a
1738
1819
  // session.error event, leaving the UI waiting for long internal backoff.
1739
1820
  if (isProviderRetryBanner(text) && isManagedRootUltraworkSession(state, sessionID)) {
1740
- const fallbackModel = RATE_LIMIT_FALLBACK_MODEL;
1821
+ const fallbackModel = getSessionFallbackModel(state, sessionID);
1741
1822
  await setSessionFallbackModel(state, sessionID, fallbackModel);
1742
1823
  scheduleManagedSessionRetry(state, sessionID, {
1743
1824
  delayMs: 1_000,
@@ -1848,9 +1929,10 @@ function createMultiCycleHandler(state) {
1848
1929
  if (sessionID &&
1849
1930
  RATE_LIMIT_MESSAGE_PATTERN.test(messageContent)) {
1850
1931
  if (managedSession && !managedSession.fallbackModel) {
1851
- await setSessionFallbackModel(state, sessionID, RATE_LIMIT_FALLBACK_MODEL);
1932
+ const fallbackModel = getSessionFallbackModel(state, sessionID);
1933
+ await setSessionFallbackModel(state, sessionID, fallbackModel);
1852
1934
  pluginLog.info(`[opencode-immune] Rate limit message detected in chat output for session ${sessionID}. ` +
1853
- `Fallback model pinned to ${RATE_LIMIT_FALLBACK_MODEL.providerID}/${RATE_LIMIT_FALLBACK_MODEL.modelID}.`);
1935
+ `Fallback model pinned to ${fallbackModel.providerID}/${fallbackModel.modelID}.`);
1854
1936
  }
1855
1937
  if (managedSession) {
1856
1938
  scheduleManagedSessionRetry(state, sessionID, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-immune",
3
- "version": "1.0.53",
3
+ "version": "1.0.55",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin: session recovery, auto-retry, multi-cycle automation, context monitoring",
6
6
  "exports": {