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.
- package/dist/plugin.js +100 -18
- 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.
|
|
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.
|
|
138
|
+
modelID: "gpt-5.4-mini",
|
|
130
139
|
};
|
|
131
140
|
const CHILD_SESSION_FALLBACK_MODEL = {
|
|
132
|
-
providerID: "
|
|
133
|
-
modelID: "
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1560
|
-
|
|
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 ${
|
|
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 =
|
|
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
|
-
|
|
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 ${
|
|
1935
|
+
`Fallback model pinned to ${fallbackModel.providerID}/${fallbackModel.modelID}.`);
|
|
1854
1936
|
}
|
|
1855
1937
|
if (managedSession) {
|
|
1856
1938
|
scheduleManagedSessionRetry(state, sessionID, {
|