opencode-immune 1.0.55 → 1.0.57

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 +148 -6
  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.55";
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,
@@ -146,6 +148,7 @@ const FALLBACK_MODEL_CANDIDATES = [
146
148
  RATE_LIMIT_FALLBACK_MODEL,
147
149
  { providerID: "codexsale", modelID: "gpt-5.5" },
148
150
  ];
151
+ const FALLBACK_AGENT_SUFFIX = "-provider-fallback";
149
152
  function isManagedUltraworkSession(state, sessionID) {
150
153
  return !!sessionID && state.managedUltraworkSessions.has(sessionID);
151
154
  }
@@ -320,6 +323,7 @@ async function addManagedUltraworkSession(state, sessionID, timestamp = Date.now
320
323
  kind: existing?.kind ?? "root",
321
324
  agent: ULTRAWORK_AGENT,
322
325
  rootSessionID: existing?.rootSessionID ?? sessionID,
326
+ parentSessionID: existing?.parentSessionID,
323
327
  createdAt: existing?.createdAt ?? timestamp,
324
328
  updatedAt: timestamp,
325
329
  currentModel: existing?.currentModel,
@@ -348,6 +352,7 @@ async function addManagedChildSession(state, sessionID, parentSessionID, timesta
348
352
  kind: "child",
349
353
  agent: existing?.agent ?? "unknown",
350
354
  rootSessionID: parent.rootSessionID,
355
+ parentSessionID,
351
356
  createdAt: existing?.createdAt ?? timestamp,
352
357
  updatedAt: timestamp,
353
358
  currentModel: existing?.currentModel ?? parent.currentModel,
@@ -457,6 +462,12 @@ function getSessionFallbackModel(state, sessionID, preferredModel) {
457
462
  }
458
463
  return selectFallbackModel(currentModel);
459
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
+ }
460
471
  function isRetryableApiError(error) {
461
472
  if (!error || typeof error !== "object")
462
473
  return false;
@@ -493,6 +504,7 @@ function isRetryableApiError(error) {
493
504
  message.includes("не разрешен") ||
494
505
  message.includes("not allowed") ||
495
506
  message.includes("not supported") ||
507
+ message.includes("no active accounts available") ||
496
508
  message.includes("internal error") ||
497
509
  message.includes("internal server error") ||
498
510
  message.includes("expected") ||
@@ -548,6 +560,7 @@ function isModelAccessError(error) {
548
560
  return (message.includes("не разрешен") ||
549
561
  message.includes("not allowed") ||
550
562
  message.includes("not supported") ||
563
+ message.includes("no active accounts available") ||
551
564
  message.includes("model not available") ||
552
565
  message.includes("model_not_found") ||
553
566
  message.includes("access denied") ||
@@ -598,22 +611,25 @@ function getRetryableErrorType(error) {
598
611
  }
599
612
  function recordChildFallbackRequest(state, managedSession, childSessionID, error) {
600
613
  const fallbackModel = selectFallbackModel(getFailedModelFromError(error) ?? managedSession.currentModel);
614
+ const routerSessionID = getRouterSessionIDForChild(managedSession);
601
615
  const request = {
602
616
  childSessionID,
603
617
  rootSessionID: managedSession.rootSessionID,
618
+ routerSessionID,
604
619
  agent: managedSession.agent || "unknown",
620
+ fallbackAgent: getFallbackAgentForAgent(state, managedSession.agent || "unknown"),
605
621
  errorType: getRetryableErrorType(error),
606
622
  fallbackModel,
607
623
  createdAt: Date.now(),
608
624
  };
609
- state.childFallbackRequests.set(managedSession.rootSessionID, request);
625
+ state.childFallbackRequests.set(routerSessionID, request);
610
626
  }
611
- function getChildFallbackRequest(state, rootSessionID, now = Date.now()) {
612
- const request = state.childFallbackRequests.get(rootSessionID);
627
+ function getChildFallbackRequest(state, sessionID, now = Date.now()) {
628
+ const request = state.childFallbackRequests.get(sessionID);
613
629
  if (!request)
614
630
  return undefined;
615
631
  if (now - request.createdAt > CHILD_FALLBACK_REQUEST_TTL_MS) {
616
- state.childFallbackRequests.delete(rootSessionID);
632
+ state.childFallbackRequests.delete(sessionID);
617
633
  return undefined;
618
634
  }
619
635
  return request;
@@ -831,6 +847,129 @@ function compositeChatMessage(handlers) {
831
847
  }
832
848
  };
833
849
  }
850
+ function isRecord(value) {
851
+ return !!value && typeof value === "object";
852
+ }
853
+ function getTaskAgentFromArgs(args) {
854
+ if (!isRecord(args))
855
+ return undefined;
856
+ const subagentType = args.subagent_type;
857
+ if (typeof subagentType === "string")
858
+ return subagentType;
859
+ const agent = args.agent;
860
+ if (typeof agent === "string")
861
+ return agent;
862
+ return undefined;
863
+ }
864
+ function setTaskAgentInArgs(args, agent) {
865
+ if (!isRecord(args))
866
+ return;
867
+ if ("subagent_type" in args) {
868
+ args.subagent_type = agent;
869
+ return;
870
+ }
871
+ if ("agent" in args) {
872
+ args.agent = agent;
873
+ return;
874
+ }
875
+ args.subagent_type = agent;
876
+ }
877
+ function createProviderFallbackToolBefore(state) {
878
+ return async (input, output) => {
879
+ if (input.tool !== "task" && input.tool !== "Task")
880
+ return;
881
+ const request = getChildFallbackRequest(state, input.sessionID);
882
+ if (!request)
883
+ return;
884
+ const requestedAgent = getTaskAgentFromArgs(output.args);
885
+ if (!requestedAgent || requestedAgent !== request.agent)
886
+ return;
887
+ if (request.fallbackAgent === request.agent)
888
+ return;
889
+ setTaskAgentInArgs(output.args, request.fallbackAgent);
890
+ state.childFallbackRequests.delete(input.sessionID);
891
+ await writeDiagnosticLog(state, "provider-fallback-task:rewrite", {
892
+ sessionID: input.sessionID,
893
+ childSessionID: request.childSessionID,
894
+ callID: input.callID,
895
+ fromAgent: request.agent,
896
+ toAgent: request.fallbackAgent,
897
+ fallbackModel: request.fallbackModel,
898
+ });
899
+ writePluginLog(state, "warn", `[opencode-immune] Rewrote provider retry Task agent ${request.agent} -> ${request.fallbackAgent} for session ${input.sessionID}.`);
900
+ };
901
+ }
902
+ function modelRefToString(model) {
903
+ return `${model.providerID}/${model.modelID}`;
904
+ }
905
+ function parseModelRef(model) {
906
+ if (!model)
907
+ return undefined;
908
+ const [providerID, ...modelParts] = model.split("/");
909
+ const modelID = modelParts.join("/");
910
+ if (!providerID || !modelID)
911
+ return undefined;
912
+ return { providerID, modelID };
913
+ }
914
+ function canCreateFallbackForAgent(agentID, agentConfig) {
915
+ if (agentID.endsWith(FALLBACK_AGENT_SUFFIX))
916
+ return false;
917
+ if (!isRecord(agentConfig))
918
+ return false;
919
+ return typeof agentConfig.model === "string";
920
+ }
921
+ function globPatternMatchesAgent(pattern, agentID) {
922
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
923
+ const regex = new RegExp(`^${escaped.replace(/\\\*/g, ".*")}$`);
924
+ return regex.test(agentID);
925
+ }
926
+ function allowFallbackAgentInTaskPermissions(config, baseAgentID, fallbackAgentID) {
927
+ if (!config.agent)
928
+ return;
929
+ for (const agentConfig of Object.values(config.agent)) {
930
+ if (!isRecord(agentConfig))
931
+ continue;
932
+ const permission = agentConfig.permission;
933
+ if (!isRecord(permission))
934
+ continue;
935
+ const taskPermission = permission.task;
936
+ if (!isRecord(taskPermission))
937
+ continue;
938
+ const hasExplicitAllow = taskPermission[baseAgentID] === "allow";
939
+ const hasGlobAllow = Object.entries(taskPermission).some(([pattern, action]) => action === "allow" &&
940
+ pattern.includes("*") &&
941
+ globPatternMatchesAgent(pattern, baseAgentID));
942
+ if (hasExplicitAllow || hasGlobAllow) {
943
+ taskPermission[fallbackAgentID] = "allow";
944
+ }
945
+ }
946
+ }
947
+ function createConfigHandler(state) {
948
+ return async (config) => {
949
+ if (!config.agent)
950
+ return;
951
+ for (const [agentID, agentConfig] of Object.entries(config.agent)) {
952
+ if (!canCreateFallbackForAgent(agentID, agentConfig))
953
+ continue;
954
+ const baseModel = parseModelRef(agentConfig.model);
955
+ const fallbackModel = selectFallbackModel(baseModel);
956
+ if (!baseModel || isSameModel(baseModel, fallbackModel))
957
+ continue;
958
+ const fallbackAgentID = `${agentID}${FALLBACK_AGENT_SUFFIX}`;
959
+ if (config.agent[fallbackAgentID])
960
+ continue;
961
+ config.agent[fallbackAgentID] = {
962
+ ...agentConfig,
963
+ hidden: true,
964
+ model: modelRefToString(fallbackModel),
965
+ description: `${String(agentConfig.description ?? agentID)} (provider fallback)`,
966
+ };
967
+ state.fallbackAgentByAgent.set(agentID, fallbackAgentID);
968
+ state.baseAgentByFallbackAgent.set(fallbackAgentID, agentID);
969
+ allowFallbackAgentInTaskPermissions(config, agentID, fallbackAgentID);
970
+ }
971
+ };
972
+ }
834
973
  // ═══════════════════════════════════════════════════════════════════════════════
835
974
  // UTILITY: TASKS.MD PARSER
836
975
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -1327,9 +1466,10 @@ function createSystemTransform(state) {
1327
1466
  `Do not advance to the next sequential pipeline slot until this failed slot is explicitly resolved.\n` +
1328
1467
  `- Failed child session: ${childFallbackRequest.childSessionID}\n` +
1329
1468
  `- Agent: ${childFallbackRequest.agent}\n` +
1469
+ `- Runtime fallback agent: ${childFallbackRequest.fallbackAgent}\n` +
1330
1470
  `- Error type: ${childFallbackRequest.errorType}\n` +
1331
1471
  `- Required fallback model: ${childFallbackRequest.fallbackModel.providerID}/${childFallbackRequest.fallbackModel.modelID}\n` +
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. ` +
1472
+ `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. ` +
1333
1473
  `Never resume the failed child session directly; create a router-owned replacement attempt instead.`);
1334
1474
  }
1335
1475
  }
@@ -2057,9 +2197,11 @@ async function server(input) {
2057
2197
  createMultiCycleHandler(state),
2058
2198
  ];
2059
2199
  return {
2200
+ config: withErrorBoundary(state, "config", createConfigHandler(state)),
2060
2201
  event: withErrorBoundary(state, "event", createEventHandler(state)),
2061
2202
  "chat.message": withErrorBoundary(state, "chat.message", compositeChatMessage(chatMessageHandlers)),
2062
2203
  "chat.params": withErrorBoundary(state, "chat.params", createFallbackModels(state)),
2204
+ "tool.execute.before": withErrorBoundary(state, "tool.execute.before", createProviderFallbackToolBefore(state)),
2063
2205
  "tool.execute.after": withErrorBoundary(state, "tool.execute.after", compositeToolAfter(toolAfterHandlers)),
2064
2206
  "experimental.chat.system.transform": withErrorBoundary(state, "experimental.chat.system.transform", createSystemTransform(state)),
2065
2207
  "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.55",
3
+ "version": "1.0.57",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin: session recovery, auto-retry, multi-cycle automation, context monitoring",
6
6
  "exports": {