opencode-immune 1.0.54 → 1.0.56

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 +235 -22
  2. package/package.json +1 -1
package/dist/plugin.js CHANGED
@@ -11,7 +11,7 @@ import { execFile } from "child_process";
11
11
  // ═══════════════════════════════════════════════════════════════════════════════
12
12
  // PLUGIN VERSION CHECK
13
13
  // ═══════════════════════════════════════════════════════════════════════════════
14
- const PLUGIN_VERSION = "1.0.54";
14
+ const PLUGIN_VERSION = "1.0.56";
15
15
  const PLUGIN_PACKAGE_NAME = "opencode-immune";
16
16
  const PLUGIN_DIRNAME = dirname(fileURLToPath(import.meta.url));
17
17
  function getServerAuthHeaders() {
@@ -96,6 +96,8 @@ function createState(input) {
96
96
  providerRetryWatchdogs: new Map(),
97
97
  childFallbackRequests: new Map(),
98
98
  sessionErrorRetryCount: new Map(),
99
+ fallbackAgentByAgent: new Map(),
100
+ baseAgentByFallbackAgent: new Map(),
99
101
  ultraworkMarkerPath: join(input.directory, ".opencode", "state", "ultrawork-active.json"),
100
102
  diagnosticsLogPath: join(input.directory, ".opencode", "state", "opencode-immune-debug.log"),
101
103
  lastEditAttempt: null,
@@ -135,12 +137,18 @@ const RETRY_PROMPT_DELIVERY_ATTEMPTS = 3;
135
137
  const CHILD_FALLBACK_REQUEST_TTL_MS = 10 * 60 * 1000;
136
138
  const RATE_LIMIT_FALLBACK_MODEL = {
137
139
  providerID: "codexsale",
138
- modelID: "gpt-5.5",
140
+ modelID: "gpt-5.4-mini",
139
141
  };
140
142
  const CHILD_SESSION_FALLBACK_MODEL = {
141
- providerID: "codexsale",
142
- modelID: "gpt-5.5",
143
+ providerID: "claudehub",
144
+ modelID: "claude-opus-4-7",
143
145
  };
146
+ const FALLBACK_MODEL_CANDIDATES = [
147
+ CHILD_SESSION_FALLBACK_MODEL,
148
+ RATE_LIMIT_FALLBACK_MODEL,
149
+ { providerID: "codexsale", modelID: "gpt-5.5" },
150
+ ];
151
+ const FALLBACK_AGENT_SUFFIX = "-provider-fallback";
144
152
  function isManagedUltraworkSession(state, sessionID) {
145
153
  return !!sessionID && state.managedUltraworkSessions.has(sessionID);
146
154
  }
@@ -315,8 +323,10 @@ async function addManagedUltraworkSession(state, sessionID, timestamp = Date.now
315
323
  kind: existing?.kind ?? "root",
316
324
  agent: ULTRAWORK_AGENT,
317
325
  rootSessionID: existing?.rootSessionID ?? sessionID,
326
+ parentSessionID: existing?.parentSessionID,
318
327
  createdAt: existing?.createdAt ?? timestamp,
319
328
  updatedAt: timestamp,
329
+ currentModel: existing?.currentModel,
320
330
  fallbackModel: existing?.fallbackModel,
321
331
  };
322
332
  if (existing &&
@@ -342,8 +352,10 @@ async function addManagedChildSession(state, sessionID, parentSessionID, timesta
342
352
  kind: "child",
343
353
  agent: existing?.agent ?? "unknown",
344
354
  rootSessionID: parent.rootSessionID,
355
+ parentSessionID,
345
356
  createdAt: existing?.createdAt ?? timestamp,
346
357
  updatedAt: timestamp,
358
+ currentModel: existing?.currentModel ?? parent.currentModel,
347
359
  fallbackModel: existing?.fallbackModel ?? parent.fallbackModel,
348
360
  });
349
361
  }
@@ -396,6 +408,66 @@ function markUltraworkSessionActive(state, sessionID) {
396
408
  });
397
409
  return true;
398
410
  }
411
+ function getModelProviderID(model) {
412
+ return model?.providerID || model?.modelID.split("/")[0];
413
+ }
414
+ function isSameModel(a, b) {
415
+ if (!a || !b)
416
+ return false;
417
+ return a.providerID === b.providerID && a.modelID === b.modelID;
418
+ }
419
+ function selectFallbackModel(currentModel) {
420
+ const currentProviderID = getModelProviderID(currentModel);
421
+ const otherProviderModel = FALLBACK_MODEL_CANDIDATES.find((candidate) => !isSameModel(candidate, currentModel) &&
422
+ getModelProviderID(candidate) !== currentProviderID);
423
+ if (otherProviderModel)
424
+ return otherProviderModel;
425
+ return (FALLBACK_MODEL_CANDIDATES.find((candidate) => !isSameModel(candidate, currentModel)) ?? RATE_LIMIT_FALLBACK_MODEL);
426
+ }
427
+ function getFailedModelFromError(error) {
428
+ if (!error || typeof error !== "object")
429
+ return undefined;
430
+ const maybeError = error;
431
+ const message = `${maybeError.message ?? ""} ${maybeError.data?.message ?? ""} ${maybeError.error?.message ?? ""}`;
432
+ const providerModelMatch = message.match(/\b([a-z0-9_-]+)\/([a-z0-9._-]+)\b/i);
433
+ if (providerModelMatch?.[1] && providerModelMatch[2]) {
434
+ return {
435
+ providerID: providerModelMatch[1].toLowerCase(),
436
+ modelID: providerModelMatch[2].toLowerCase(),
437
+ };
438
+ }
439
+ const unsupportedModelMatch = message.match(/["']([^"']+)["']\s+model\s+is\s+not\s+supported/i);
440
+ if (unsupportedModelMatch?.[1] && /\bcodex\b/i.test(message)) {
441
+ return {
442
+ providerID: "codexsale",
443
+ modelID: unsupportedModelMatch[1].toLowerCase(),
444
+ };
445
+ }
446
+ return undefined;
447
+ }
448
+ async function updateManagedSessionModel(state, sessionID, model) {
449
+ const existing = state.managedUltraworkSessions.get(sessionID);
450
+ if (!existing || isSameModel(existing.currentModel, model))
451
+ return;
452
+ state.managedUltraworkSessions.set(sessionID, {
453
+ ...existing,
454
+ currentModel: model,
455
+ updatedAt: Date.now(),
456
+ });
457
+ }
458
+ function getSessionFallbackModel(state, sessionID, preferredModel) {
459
+ const currentModel = state.managedUltraworkSessions.get(sessionID)?.currentModel;
460
+ if (preferredModel && !isSameModel(preferredModel, currentModel)) {
461
+ return preferredModel;
462
+ }
463
+ return selectFallbackModel(currentModel);
464
+ }
465
+ function getFallbackAgentForAgent(state, agent) {
466
+ return state.fallbackAgentByAgent.get(agent) ?? agent;
467
+ }
468
+ function getRouterSessionIDForChild(record) {
469
+ return record.parentSessionID ?? record.rootSessionID;
470
+ }
399
471
  function isRetryableApiError(error) {
400
472
  if (!error || typeof error !== "object")
401
473
  return false;
@@ -431,6 +503,7 @@ function isRetryableApiError(error) {
431
503
  if (message.includes("api_error") ||
432
504
  message.includes("не разрешен") ||
433
505
  message.includes("not allowed") ||
506
+ message.includes("not supported") ||
434
507
  message.includes("internal error") ||
435
508
  message.includes("internal server error") ||
436
509
  message.includes("expected") ||
@@ -485,6 +558,7 @@ function isModelAccessError(error) {
485
558
  const type = `${maybeError.data?.type ?? ""}`.toLowerCase();
486
559
  return (message.includes("не разрешен") ||
487
560
  message.includes("not allowed") ||
561
+ message.includes("not supported") ||
488
562
  message.includes("model not available") ||
489
563
  message.includes("model_not_found") ||
490
564
  message.includes("access denied") ||
@@ -534,22 +608,26 @@ function getRetryableErrorType(error) {
534
608
  return "retryable provider error";
535
609
  }
536
610
  function recordChildFallbackRequest(state, managedSession, childSessionID, error) {
611
+ const fallbackModel = selectFallbackModel(getFailedModelFromError(error) ?? managedSession.currentModel);
612
+ const routerSessionID = getRouterSessionIDForChild(managedSession);
537
613
  const request = {
538
614
  childSessionID,
539
615
  rootSessionID: managedSession.rootSessionID,
616
+ routerSessionID,
540
617
  agent: managedSession.agent || "unknown",
618
+ fallbackAgent: getFallbackAgentForAgent(state, managedSession.agent || "unknown"),
541
619
  errorType: getRetryableErrorType(error),
542
- fallbackModel: CHILD_SESSION_FALLBACK_MODEL,
620
+ fallbackModel,
543
621
  createdAt: Date.now(),
544
622
  };
545
- state.childFallbackRequests.set(managedSession.rootSessionID, request);
623
+ state.childFallbackRequests.set(routerSessionID, request);
546
624
  }
547
- function getChildFallbackRequest(state, rootSessionID, now = Date.now()) {
548
- const request = state.childFallbackRequests.get(rootSessionID);
625
+ function getChildFallbackRequest(state, sessionID, now = Date.now()) {
626
+ const request = state.childFallbackRequests.get(sessionID);
549
627
  if (!request)
550
628
  return undefined;
551
629
  if (now - request.createdAt > CHILD_FALLBACK_REQUEST_TTL_MS) {
552
- state.childFallbackRequests.delete(rootSessionID);
630
+ state.childFallbackRequests.delete(sessionID);
553
631
  return undefined;
554
632
  }
555
633
  return request;
@@ -565,10 +643,11 @@ function scheduleProviderRetryWatchdog(state, sessionID, model) {
565
643
  return;
566
644
  if (state.sessionRetryTimers.has(sessionID))
567
645
  return;
568
- await setSessionFallbackModel(state, sessionID, model);
646
+ const fallbackModel = getSessionFallbackModel(state, sessionID, model);
647
+ await setSessionFallbackModel(state, sessionID, fallbackModel);
569
648
  await writeDiagnosticLog(state, "provider-retry-watchdog:fired", {
570
649
  sessionID,
571
- fallbackModel: model,
650
+ fallbackModel,
572
651
  });
573
652
  scheduleManagedSessionRetry(state, sessionID, {
574
653
  delayMs: 1_000,
@@ -766,6 +845,129 @@ function compositeChatMessage(handlers) {
766
845
  }
767
846
  };
768
847
  }
848
+ function isRecord(value) {
849
+ return !!value && typeof value === "object";
850
+ }
851
+ function getTaskAgentFromArgs(args) {
852
+ if (!isRecord(args))
853
+ return undefined;
854
+ const subagentType = args.subagent_type;
855
+ if (typeof subagentType === "string")
856
+ return subagentType;
857
+ const agent = args.agent;
858
+ if (typeof agent === "string")
859
+ return agent;
860
+ return undefined;
861
+ }
862
+ function setTaskAgentInArgs(args, agent) {
863
+ if (!isRecord(args))
864
+ return;
865
+ if ("subagent_type" in args) {
866
+ args.subagent_type = agent;
867
+ return;
868
+ }
869
+ if ("agent" in args) {
870
+ args.agent = agent;
871
+ return;
872
+ }
873
+ args.subagent_type = agent;
874
+ }
875
+ function createProviderFallbackToolBefore(state) {
876
+ return async (input, output) => {
877
+ if (input.tool !== "task" && input.tool !== "Task")
878
+ return;
879
+ const request = getChildFallbackRequest(state, input.sessionID);
880
+ if (!request)
881
+ return;
882
+ const requestedAgent = getTaskAgentFromArgs(output.args);
883
+ if (!requestedAgent || requestedAgent !== request.agent)
884
+ return;
885
+ if (request.fallbackAgent === request.agent)
886
+ return;
887
+ setTaskAgentInArgs(output.args, request.fallbackAgent);
888
+ state.childFallbackRequests.delete(input.sessionID);
889
+ await writeDiagnosticLog(state, "provider-fallback-task:rewrite", {
890
+ sessionID: input.sessionID,
891
+ childSessionID: request.childSessionID,
892
+ callID: input.callID,
893
+ fromAgent: request.agent,
894
+ toAgent: request.fallbackAgent,
895
+ fallbackModel: request.fallbackModel,
896
+ });
897
+ writePluginLog(state, "warn", `[opencode-immune] Rewrote provider retry Task agent ${request.agent} -> ${request.fallbackAgent} for session ${input.sessionID}.`);
898
+ };
899
+ }
900
+ function modelRefToString(model) {
901
+ return `${model.providerID}/${model.modelID}`;
902
+ }
903
+ function parseModelRef(model) {
904
+ if (!model)
905
+ return undefined;
906
+ const [providerID, ...modelParts] = model.split("/");
907
+ const modelID = modelParts.join("/");
908
+ if (!providerID || !modelID)
909
+ return undefined;
910
+ return { providerID, modelID };
911
+ }
912
+ function canCreateFallbackForAgent(agentID, agentConfig) {
913
+ if (agentID.endsWith(FALLBACK_AGENT_SUFFIX))
914
+ return false;
915
+ if (!isRecord(agentConfig))
916
+ return false;
917
+ return typeof agentConfig.model === "string";
918
+ }
919
+ function globPatternMatchesAgent(pattern, agentID) {
920
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
921
+ const regex = new RegExp(`^${escaped.replace(/\\\*/g, ".*")}$`);
922
+ return regex.test(agentID);
923
+ }
924
+ function allowFallbackAgentInTaskPermissions(config, baseAgentID, fallbackAgentID) {
925
+ if (!config.agent)
926
+ return;
927
+ for (const agentConfig of Object.values(config.agent)) {
928
+ if (!isRecord(agentConfig))
929
+ continue;
930
+ const permission = agentConfig.permission;
931
+ if (!isRecord(permission))
932
+ continue;
933
+ const taskPermission = permission.task;
934
+ if (!isRecord(taskPermission))
935
+ continue;
936
+ const hasExplicitAllow = taskPermission[baseAgentID] === "allow";
937
+ const hasGlobAllow = Object.entries(taskPermission).some(([pattern, action]) => action === "allow" &&
938
+ pattern.includes("*") &&
939
+ globPatternMatchesAgent(pattern, baseAgentID));
940
+ if (hasExplicitAllow || hasGlobAllow) {
941
+ taskPermission[fallbackAgentID] = "allow";
942
+ }
943
+ }
944
+ }
945
+ function createConfigHandler(state) {
946
+ return async (config) => {
947
+ if (!config.agent)
948
+ return;
949
+ for (const [agentID, agentConfig] of Object.entries(config.agent)) {
950
+ if (!canCreateFallbackForAgent(agentID, agentConfig))
951
+ continue;
952
+ const baseModel = parseModelRef(agentConfig.model);
953
+ const fallbackModel = selectFallbackModel(baseModel);
954
+ if (!baseModel || isSameModel(baseModel, fallbackModel))
955
+ continue;
956
+ const fallbackAgentID = `${agentID}${FALLBACK_AGENT_SUFFIX}`;
957
+ if (config.agent[fallbackAgentID])
958
+ continue;
959
+ config.agent[fallbackAgentID] = {
960
+ ...agentConfig,
961
+ hidden: true,
962
+ model: modelRefToString(fallbackModel),
963
+ description: `${String(agentConfig.description ?? agentID)} (provider fallback)`,
964
+ };
965
+ state.fallbackAgentByAgent.set(agentID, fallbackAgentID);
966
+ state.baseAgentByFallbackAgent.set(fallbackAgentID, agentID);
967
+ allowFallbackAgentInTaskPermissions(config, agentID, fallbackAgentID);
968
+ }
969
+ };
970
+ }
769
971
  // ═══════════════════════════════════════════════════════════════════════════════
770
972
  // UTILITY: TASKS.MD PARSER
771
973
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -1262,9 +1464,10 @@ function createSystemTransform(state) {
1262
1464
  `Do not advance to the next sequential pipeline slot until this failed slot is explicitly resolved.\n` +
1263
1465
  `- Failed child session: ${childFallbackRequest.childSessionID}\n` +
1264
1466
  `- Agent: ${childFallbackRequest.agent}\n` +
1467
+ `- Runtime fallback agent: ${childFallbackRequest.fallbackAgent}\n` +
1265
1468
  `- Error type: ${childFallbackRequest.errorType}\n` +
1266
1469
  `- Required fallback model: ${childFallbackRequest.fallbackModel.providerID}/${childFallbackRequest.fallbackModel.modelID}\n` +
1267
- `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. ` +
1470
+ `Router action: retry the SAME pipeline slot once by creating a NEW Task call to the same original agent. Do not resume the failed task_id. The plugin will rewrite that one Task call to the runtime fallback agent above so the model actually changes. If it still fails, record DECLINE/failed-provider-retry for that slot before continuing. ` +
1268
1471
  `Never resume the failed child session directly; create a router-owned replacement attempt instead.`);
1269
1472
  }
1270
1473
  }
@@ -1438,7 +1641,7 @@ function createFallbackModels(state) {
1438
1641
  const wasAlreadyManaged = isManagedUltraworkSession(state, input.sessionID);
1439
1642
  await addManagedUltraworkSession(state, input.sessionID);
1440
1643
  await writeUltraworkMarker(state);
1441
- scheduleProviderRetryWatchdog(state, input.sessionID, RATE_LIMIT_FALLBACK_MODEL);
1644
+ scheduleProviderRetryWatchdog(state, input.sessionID);
1442
1645
  // First contact with 0-ultrawork after plugin restart:
1443
1646
  // if marker is active and tasks.md has incomplete work, send AUTO-RESUME prompt.
1444
1647
  if (!wasAlreadyManaged && !state.autoResumeAttempted) {
@@ -1472,11 +1675,17 @@ function createFallbackModels(state) {
1472
1675
  // Subagent calls from 0-ultrawork (1-van, 7-backlog, etc.) use the same session.
1473
1676
  // Log model and agent for observability
1474
1677
  const modelId = input.model && "id" in input.model
1475
- ? input.model.id
1678
+ ? input.model.id ?? "unknown"
1476
1679
  : "unknown";
1477
1680
  const providerId = input.provider?.info && "id" in input.provider.info
1478
- ? input.provider.info.id
1681
+ ? input.provider.info.id ?? "unknown"
1479
1682
  : "unknown";
1683
+ if (input.sessionID && modelId !== "unknown" && providerId !== "unknown") {
1684
+ await updateManagedSessionModel(state, input.sessionID, {
1685
+ providerID: providerId,
1686
+ modelID: modelId,
1687
+ });
1688
+ }
1480
1689
  pluginLog.info(`[opencode-immune] Model Observer: agent="${input.agent}", ` +
1481
1690
  `model="${modelId}", provider="${providerId}"`);
1482
1691
  };
@@ -1564,11 +1773,12 @@ function createEventHandler(state) {
1564
1773
  state.sessionErrorRetryCount.set(sessionID, count);
1565
1774
  return;
1566
1775
  }
1567
- else if (isRoot && (isRateLimitApiError(error) || isCertificateApiError(error))) {
1568
- await setSessionFallbackModel(state, sessionID, RATE_LIMIT_FALLBACK_MODEL);
1569
- const errorType = isCertificateApiError(error) ? "certificate error" : "rate limit";
1776
+ else if (isRoot && (isModelAccessError(error) || isRateLimitApiError(error) || isCertificateApiError(error))) {
1777
+ const selectedFallbackModel = selectFallbackModel(getFailedModelFromError(error) ?? managedSession.currentModel);
1778
+ await setSessionFallbackModel(state, sessionID, selectedFallbackModel);
1779
+ const errorType = getRetryableErrorType(error);
1570
1780
  pluginLog.info(`[opencode-immune] ${errorType} detected for root session ${sessionID}. ` +
1571
- `Retry will use fallback model ${RATE_LIMIT_FALLBACK_MODEL.providerID}/${RATE_LIMIT_FALLBACK_MODEL.modelID}.`);
1781
+ `Retry will use fallback model ${selectedFallbackModel.providerID}/${selectedFallbackModel.modelID}.`);
1572
1782
  }
1573
1783
  const scheduled = scheduleManagedSessionRetry(state, sessionID, {
1574
1784
  delayMs: delay,
@@ -1746,7 +1956,7 @@ function createTextCompleteHandler(state) {
1746
1956
  // Some provider/SDK failures render as a retry banner instead of a
1747
1957
  // session.error event, leaving the UI waiting for long internal backoff.
1748
1958
  if (isProviderRetryBanner(text) && isManagedRootUltraworkSession(state, sessionID)) {
1749
- const fallbackModel = RATE_LIMIT_FALLBACK_MODEL;
1959
+ const fallbackModel = getSessionFallbackModel(state, sessionID);
1750
1960
  await setSessionFallbackModel(state, sessionID, fallbackModel);
1751
1961
  scheduleManagedSessionRetry(state, sessionID, {
1752
1962
  delayMs: 1_000,
@@ -1857,9 +2067,10 @@ function createMultiCycleHandler(state) {
1857
2067
  if (sessionID &&
1858
2068
  RATE_LIMIT_MESSAGE_PATTERN.test(messageContent)) {
1859
2069
  if (managedSession && !managedSession.fallbackModel) {
1860
- await setSessionFallbackModel(state, sessionID, RATE_LIMIT_FALLBACK_MODEL);
2070
+ const fallbackModel = getSessionFallbackModel(state, sessionID);
2071
+ await setSessionFallbackModel(state, sessionID, fallbackModel);
1861
2072
  pluginLog.info(`[opencode-immune] Rate limit message detected in chat output for session ${sessionID}. ` +
1862
- `Fallback model pinned to ${RATE_LIMIT_FALLBACK_MODEL.providerID}/${RATE_LIMIT_FALLBACK_MODEL.modelID}.`);
2073
+ `Fallback model pinned to ${fallbackModel.providerID}/${fallbackModel.modelID}.`);
1863
2074
  }
1864
2075
  if (managedSession) {
1865
2076
  scheduleManagedSessionRetry(state, sessionID, {
@@ -1984,9 +2195,11 @@ async function server(input) {
1984
2195
  createMultiCycleHandler(state),
1985
2196
  ];
1986
2197
  return {
2198
+ config: withErrorBoundary(state, "config", createConfigHandler(state)),
1987
2199
  event: withErrorBoundary(state, "event", createEventHandler(state)),
1988
2200
  "chat.message": withErrorBoundary(state, "chat.message", compositeChatMessage(chatMessageHandlers)),
1989
2201
  "chat.params": withErrorBoundary(state, "chat.params", createFallbackModels(state)),
2202
+ "tool.execute.before": withErrorBoundary(state, "tool.execute.before", createProviderFallbackToolBefore(state)),
1990
2203
  "tool.execute.after": withErrorBoundary(state, "tool.execute.after", compositeToolAfter(toolAfterHandlers)),
1991
2204
  "experimental.chat.system.transform": withErrorBoundary(state, "experimental.chat.system.transform", createSystemTransform(state)),
1992
2205
  "experimental.session.compacting": withErrorBoundary(state, "experimental.session.compacting", createCompactionHandler(state)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-immune",
3
- "version": "1.0.54",
3
+ "version": "1.0.56",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin: session recovery, auto-retry, multi-cycle automation, context monitoring",
6
6
  "exports": {