opencode-immune 1.0.57 → 1.0.59

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 +115 -137
  2. package/package.json +1 -1
package/dist/plugin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // .opencode/plugin.ts — opencode-immune plugin
2
2
  // Hybrid single-file architecture with factory functions, explicit state, error boundaries
3
3
  // See: memory-bank/creative/creative-plugin-architecture.md (Option C)
4
- import { createOpencodeClient as createOpencodeClientV2 } from "@opencode-ai/sdk/v2/client";
4
+ import { createOpencodeClient as createOpencodeClientV2 } from "@opencode-ai/sdk/v2";
5
5
  import { appendFile, mkdir, readFile, unlink, writeFile, stat, rm, rename, readdir, copyFile } from "fs/promises";
6
6
  import { join, dirname } from "path";
7
7
  import { fileURLToPath } from "url";
@@ -11,7 +11,7 @@ import { execFile } from "child_process";
11
11
  // ═══════════════════════════════════════════════════════════════════════════════
12
12
  // PLUGIN VERSION CHECK
13
13
  // ═══════════════════════════════════════════════════════════════════════════════
14
- const PLUGIN_VERSION = "1.0.56";
14
+ const PLUGIN_VERSION = "1.0.59";
15
15
  const PLUGIN_PACKAGE_NAME = "opencode-immune";
16
16
  const PLUGIN_DIRNAME = dirname(fileURLToPath(import.meta.url));
17
17
  function getServerAuthHeaders() {
@@ -468,146 +468,114 @@ function getFallbackAgentForAgent(state, agent) {
468
468
  function getRouterSessionIDForChild(record) {
469
469
  return record.parentSessionID ?? record.rootSessionID;
470
470
  }
471
- function isRetryableApiError(error) {
472
- if (!error || typeof error !== "object")
473
- return false;
474
- const maybeError = error;
475
- // Structured retryable flag
476
- if (maybeError.name === "APIError" &&
477
- maybeError.data?.isRetryable === true) {
478
- return true;
479
- }
480
- // HTTP status code based detection (retryable server/gateway errors + rate limits)
481
- const status = maybeError.status ?? maybeError.statusCode ?? maybeError.data?.status ?? maybeError.error?.status;
482
- if (status && (status === 404 || // transient endpoint not found (API gateway issues)
483
- status === 429 || // rate limit
484
- status === 500 || // internal server error
485
- status === 502 || // bad gateway
486
- status === 503 || // service unavailable
487
- status === 504 // gateway timeout
488
- )) {
489
- return true;
471
+ function stringifyErrorField(value) {
472
+ if (value === undefined || value === null)
473
+ return undefined;
474
+ if (typeof value === "string")
475
+ return value;
476
+ if (typeof value === "number" || typeof value === "boolean")
477
+ return String(value);
478
+ return undefined;
479
+ }
480
+ function numberFromErrorField(value) {
481
+ if (typeof value === "number")
482
+ return value;
483
+ if (typeof value !== "string")
484
+ return undefined;
485
+ const parsed = Number(value);
486
+ return Number.isFinite(parsed) ? parsed : undefined;
487
+ }
488
+ function firstDefinedNumber(...values) {
489
+ for (const value of values) {
490
+ const parsed = numberFromErrorField(value);
491
+ if (parsed !== undefined)
492
+ return parsed;
490
493
  }
491
- // Text-based detection for model access errors (not marked as retryable
492
- // by the API but retryable with a fallback model)
494
+ return undefined;
495
+ }
496
+ function extractManagedErrorDetails(error) {
497
+ if (!isRecord(error))
498
+ return undefined;
499
+ const data = isRecord(error.data) ? error.data : undefined;
500
+ const nestedError = isRecord(error.error) ? error.error : undefined;
501
+ const name = stringifyErrorField(error.name) ?? "";
502
+ const status = firstDefinedNumber(error.status, error.statusCode, data?.status, nestedError?.status);
503
+ const code = [error.code, data?.code, nestedError?.code]
504
+ .map(stringifyErrorField)
505
+ .find(Boolean);
506
+ const type = [error.type, data?.type, nestedError?.type]
507
+ .map(stringifyErrorField)
508
+ .find(Boolean);
509
+ const isRetryable = error.isRetryable === true || data?.isRetryable === true || nestedError?.isRetryable === true
510
+ ? true
511
+ : undefined;
493
512
  const message = [
494
- maybeError.message,
495
- maybeError.code,
496
- maybeError.data?.message,
497
- maybeError.data?.type,
498
- maybeError.data?.code,
499
- maybeError.error?.message,
500
- maybeError.error?.type,
501
- maybeError.error?.code,
502
- ].filter((value) => value !== undefined && value !== null).join(" ").toLowerCase();
503
- if (message.includes("api_error") ||
504
- message.includes("не разрешен") ||
505
- message.includes("not allowed") ||
506
- message.includes("not supported") ||
507
- message.includes("no active accounts available") ||
508
- message.includes("internal error") ||
509
- message.includes("internal server error") ||
510
- message.includes("expected") ||
511
- message.includes("to be a string") ||
512
- message.includes("invalid_type") ||
513
- message.includes("validation") ||
514
- message.includes("schema") ||
515
- message.includes("zod") ||
516
- message.includes("parse error") ||
517
- message.includes("invalid id") ||
518
- message.includes("expected id") ||
519
- message.includes("model not available") ||
520
- message.includes("model_not_found") ||
521
- message.includes("access denied") ||
522
- message.includes("404") ||
523
- message.includes("not found") ||
524
- message.includes("page not found") ||
525
- message.includes("502") ||
526
- message.includes("bad gateway") ||
527
- message.includes("503") ||
528
- message.includes("service unavailable") ||
529
- message.includes("504") ||
530
- message.includes("gateway timeout") ||
531
- message.includes("econnrefused") ||
532
- message.includes("econnreset") ||
533
- message.includes("etimedout") ||
534
- message.includes("fetch failed") ||
535
- message.includes("timed out") ||
536
- message.includes("timeout") ||
537
- message.includes("sse read") ||
538
- message.includes("stream error") ||
539
- // Certificate/TLS provider failures must pass this primary retry gate
540
- // before the managed-session fallback model branch can run.
541
- message.includes("unknown certificate verification error") ||
542
- message.includes("unknown_certificate_verification_error") ||
543
- message.includes("certificate has expired") ||
544
- message.includes("certificate verification") ||
545
- message.includes("tls") ||
546
- message.includes("ssl") ||
547
- message.includes("connection reset") ||
548
- message.includes("socket hang up") ||
549
- message.includes("aborted")) {
550
- return true;
551
- }
552
- return false;
513
+ error.message,
514
+ data?.message,
515
+ nestedError?.message,
516
+ code,
517
+ type,
518
+ ].map(stringifyErrorField).filter(Boolean).join(" ").toLowerCase();
519
+ return {
520
+ name,
521
+ message,
522
+ status,
523
+ code,
524
+ type,
525
+ isRetryable,
526
+ hasApiShape: name === "APIError" ||
527
+ status !== undefined ||
528
+ isRetryable !== undefined ||
529
+ data !== undefined ||
530
+ nestedError !== undefined,
531
+ };
553
532
  }
554
- function isModelAccessError(error) {
555
- if (!error || typeof error !== "object")
556
- return false;
557
- const maybeError = error;
558
- const message = `${maybeError.message ?? ""} ${maybeError.data?.message ?? ""}`.toLowerCase();
559
- const type = `${maybeError.data?.type ?? ""}`.toLowerCase();
560
- return (message.includes("не разрешен") ||
561
- message.includes("not allowed") ||
562
- message.includes("not supported") ||
563
- message.includes("no active accounts available") ||
564
- message.includes("model not available") ||
565
- message.includes("model_not_found") ||
566
- message.includes("access denied") ||
567
- type.includes("model_not_found") ||
568
- type.includes("invalid_model"));
569
- }
570
- function isRateLimitApiError(error) {
571
- if (!error || typeof error !== "object")
572
- return false;
573
- const maybeError = error;
574
- const message = `${maybeError.message ?? ""} ${maybeError.data?.message ?? ""} ${maybeError.error?.message ?? ""}`.toLowerCase();
575
- const type = `${maybeError.data?.type ?? ""} ${maybeError.error?.type ?? ""}`.toLowerCase();
576
- return ((type.includes("rate_limit") || message.includes("too many requests") || message.includes("rate limit")));
533
+ function getManagedSessionErrorAction(error) {
534
+ const details = extractManagedErrorDetails(error);
535
+ if (!details)
536
+ return "none";
537
+ if (details.name === "AbortError" || details.code === "ABORT_ERR") {
538
+ return "none";
539
+ }
540
+ if (details.status !== undefined) {
541
+ if (details.status === 408 || details.status === 409 || details.status === 425 || details.status >= 500) {
542
+ return "retry";
543
+ }
544
+ if (details.status >= 400 && details.status < 500) {
545
+ return "fallback";
546
+ }
547
+ }
548
+ if (details.isRetryable === true)
549
+ return "retry";
550
+ if (details.code && /^E[A-Z0-9_]+$/.test(details.code))
551
+ return "retry";
552
+ // Managed session errors originate from the active model/provider request.
553
+ // If the server emitted an API-shaped error but did not expose a stable status
554
+ // or retryability flag, prefer one fallback-model attempt over hard-stopping.
555
+ if (details.hasApiShape || details.message)
556
+ return "fallback";
557
+ return "none";
558
+ }
559
+ function shouldUseFallbackForManagedError(error, managedSession, retryCount) {
560
+ if (managedSession.kind === "child")
561
+ return true;
562
+ return getManagedSessionErrorAction(error) === "fallback" || retryCount > 0;
577
563
  }
578
- function isCertificateApiError(error) {
579
- if (!error || typeof error !== "object")
580
- return false;
581
- const maybeError = error;
582
- const message = `${maybeError.message ?? ""} ${maybeError.data?.message ?? ""} ${maybeError.error?.message ?? ""}`.toLowerCase();
583
- const code = `${maybeError.code ?? ""} ${maybeError.data?.code ?? ""} ${maybeError.error?.code ?? ""}`.toLowerCase();
584
- const type = `${maybeError.data?.type ?? ""} ${maybeError.error?.type ?? ""}`.toLowerCase();
585
- return (message.includes("unknown certificate verification error") ||
586
- message.includes("unknown_certificate_verification_error") ||
587
- message.includes("certificate has expired") ||
588
- message.includes("certificate verification") ||
589
- message.includes("tls") ||
590
- message.includes("ssl") ||
591
- code.includes("cert_has_expired") ||
592
- code.includes("unknown_certificate_verification_error") ||
593
- code.includes("unable_to_verify_leaf_signature") ||
594
- code.includes("self_signed_cert") ||
595
- code.includes("tls") ||
596
- type.includes("certificate") ||
597
- type.includes("tls") ||
598
- type.includes("ssl"));
564
+ function isRetryableApiError(error) {
565
+ return getManagedSessionErrorAction(error) !== "none";
599
566
  }
600
567
  function isProviderRetryBanner(text) {
601
568
  return /(?:<none>\s*)?retrying in \d+s\s*-\s*attempt #\d+/i.test(text);
602
569
  }
603
570
  function getRetryableErrorType(error) {
604
- if (isModelAccessError(error))
605
- return "model access error";
606
- if (isRateLimitApiError(error))
607
- return "rate limit";
608
- if (isCertificateApiError(error))
609
- return "certificate error";
610
- return "retryable provider error";
571
+ const action = getManagedSessionErrorAction(error);
572
+ const status = extractManagedErrorDetails(error)?.status;
573
+ const statusSuffix = status !== undefined ? ` (${status})` : "";
574
+ if (action === "fallback")
575
+ return `provider fallback error${statusSuffix}`;
576
+ if (action === "retry")
577
+ return `transient provider error${statusSuffix}`;
578
+ return "non-retryable provider error";
611
579
  }
612
580
  function recordChildFallbackRequest(state, managedSession, childSessionID, error) {
613
581
  const fallbackModel = selectFallbackModel(getFailedModelFromError(error) ?? managedSession.currentModel);
@@ -1713,19 +1681,29 @@ function createEventHandler(state) {
1713
1681
  const info = event.properties?.info;
1714
1682
  const sessionID = event.properties?.sessionID ?? info?.id;
1715
1683
  const error = event.properties?.error;
1684
+ if (eventType === "session.error") {
1685
+ await writeDiagnosticLog(state, "session-error:observed", {
1686
+ sessionID,
1687
+ action: getManagedSessionErrorAction(error),
1688
+ errorType: getRetryableErrorType(error),
1689
+ });
1690
+ }
1716
1691
  // Fallback: some SDK/schema errors can arrive without a valid sessionID
1717
1692
  // (for example: "Expected 'id' to be a string."). If there is exactly one
1718
1693
  // active managed root session, retry it as a best-effort recovery path.
1719
1694
  if (eventType === "session.error" && !sessionID && isRetryableApiError(error)) {
1720
1695
  const rootCandidates = Array.from(state.managedUltraworkSessions.entries())
1721
- .filter(([, record]) => record.kind === "root")
1722
- .map(([id]) => id);
1696
+ .filter(([, record]) => record.kind === "root");
1723
1697
  if (rootCandidates.length === 1) {
1724
- const fallbackSessionID = rootCandidates[0];
1698
+ const [fallbackSessionID, managedSession] = rootCandidates[0];
1725
1699
  const count = state.sessionErrorRetryCount.get(fallbackSessionID) ?? 0;
1726
1700
  if (count < MAX_RETRIES && !state.sessionRetryTimers.has(fallbackSessionID)) {
1727
1701
  const delay = Math.min(BASE_DELAY_MS * Math.pow(2, count), MAX_DELAY_MS);
1728
1702
  state.sessionErrorRetryCount.set(fallbackSessionID, count + 1);
1703
+ if (shouldUseFallbackForManagedError(error, managedSession, count)) {
1704
+ const fallbackModel = selectFallbackModel(getFailedModelFromError(error) ?? managedSession.currentModel);
1705
+ await setSessionFallbackModel(state, fallbackSessionID, fallbackModel);
1706
+ }
1729
1707
  pluginLog.warn(`[opencode-immune] session.error without sessionID matched retryable error. ` +
1730
1708
  `Retrying sole managed root session ${fallbackSessionID}.`);
1731
1709
  scheduleManagedSessionRetry(state, fallbackSessionID, {
@@ -1775,7 +1753,7 @@ function createEventHandler(state) {
1775
1753
  state.sessionErrorRetryCount.set(sessionID, count);
1776
1754
  return;
1777
1755
  }
1778
- else if (isRoot && (isModelAccessError(error) || isRateLimitApiError(error) || isCertificateApiError(error))) {
1756
+ else if (isRoot && shouldUseFallbackForManagedError(error, managedSession, count)) {
1779
1757
  const selectedFallbackModel = selectFallbackModel(getFailedModelFromError(error) ?? managedSession.currentModel);
1780
1758
  await setSessionFallbackModel(state, sessionID, selectedFallbackModel);
1781
1759
  const errorType = getRetryableErrorType(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-immune",
3
- "version": "1.0.57",
3
+ "version": "1.0.59",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin: session recovery, auto-retry, multi-cycle automation, context monitoring",
6
6
  "exports": {