opencode-immune 1.0.0 → 1.0.2

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 +109 -11
  2. package/package.json +2 -2
package/dist/plugin.js CHANGED
@@ -24,9 +24,20 @@ function createState(input) {
24
24
  }
25
25
  const ULTRAWORK_AGENT = "0-ultrawork";
26
26
  const MANAGED_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
27
+ const RATE_LIMIT_FALLBACK_MODEL = {
28
+ providerID: "externcash",
29
+ modelID: "gpt-5.4",
30
+ };
27
31
  function isManagedUltraworkSession(state, sessionID) {
28
32
  return !!sessionID && state.managedUltraworkSessions.has(sessionID);
29
33
  }
34
+ function getManagedSession(state, sessionID) {
35
+ return sessionID ? state.managedUltraworkSessions.get(sessionID) : undefined;
36
+ }
37
+ function isManagedRootUltraworkSession(state, sessionID) {
38
+ const record = getManagedSession(state, sessionID);
39
+ return !!record && record.kind === "root";
40
+ }
30
41
  function pruneExpiredManagedSessions(state, now = Date.now()) {
31
42
  let removed = 0;
32
43
  for (const [sessionID, record] of state.managedUltraworkSessions.entries()) {
@@ -64,12 +75,23 @@ async function loadManagedSessionsCache(state) {
64
75
  return;
65
76
  }
66
77
  for (const [sessionID, record] of Object.entries(parsed.sessions)) {
67
- if (!record || record.agent !== ULTRAWORK_AGENT)
78
+ if (!record)
68
79
  continue;
69
80
  state.managedUltraworkSessions.set(sessionID, {
70
- agent: ULTRAWORK_AGENT,
81
+ kind: record.kind === "child" ? "child" : "root",
82
+ agent: typeof record.agent === "string" && record.agent.length > 0
83
+ ? record.agent
84
+ : ULTRAWORK_AGENT,
85
+ rootSessionID: typeof record.rootSessionID === "string" && record.rootSessionID.length > 0
86
+ ? record.rootSessionID
87
+ : sessionID,
71
88
  createdAt: typeof record.createdAt === "number" ? record.createdAt : Date.now(),
72
89
  updatedAt: typeof record.updatedAt === "number" ? record.updatedAt : Date.now(),
90
+ fallbackModel: record.fallbackModel &&
91
+ typeof record.fallbackModel.providerID === "string" &&
92
+ typeof record.fallbackModel.modelID === "string"
93
+ ? record.fallbackModel
94
+ : undefined,
73
95
  });
74
96
  }
75
97
  const removed = pruneExpiredManagedSessions(state);
@@ -89,9 +111,12 @@ async function loadManagedSessionsCache(state) {
89
111
  async function addManagedUltraworkSession(state, sessionID, timestamp = Date.now()) {
90
112
  const existing = state.managedUltraworkSessions.get(sessionID);
91
113
  const nextRecord = {
114
+ kind: existing?.kind ?? "root",
92
115
  agent: ULTRAWORK_AGENT,
116
+ rootSessionID: existing?.rootSessionID ?? sessionID,
93
117
  createdAt: existing?.createdAt ?? timestamp,
94
118
  updatedAt: timestamp,
119
+ fallbackModel: existing?.fallbackModel,
95
120
  };
96
121
  if (existing &&
97
122
  existing.agent === nextRecord.agent &&
@@ -102,6 +127,21 @@ async function addManagedUltraworkSession(state, sessionID, timestamp = Date.now
102
127
  state.managedUltraworkSessions.set(sessionID, nextRecord);
103
128
  await writeManagedSessionsCache(state);
104
129
  }
130
+ async function addManagedChildSession(state, sessionID, parentSessionID, timestamp = Date.now()) {
131
+ const parent = state.managedUltraworkSessions.get(parentSessionID);
132
+ if (!parent)
133
+ return;
134
+ const existing = state.managedUltraworkSessions.get(sessionID);
135
+ state.managedUltraworkSessions.set(sessionID, {
136
+ kind: "child",
137
+ agent: existing?.agent ?? "unknown",
138
+ rootSessionID: parent.rootSessionID,
139
+ createdAt: existing?.createdAt ?? timestamp,
140
+ updatedAt: timestamp,
141
+ fallbackModel: existing?.fallbackModel ?? parent.fallbackModel,
142
+ });
143
+ await writeManagedSessionsCache(state);
144
+ }
105
145
  function cancelRetry(state, sessionID, reason) {
106
146
  const timer = state.retryTimers.get(sessionID);
107
147
  if (!timer)
@@ -119,6 +159,17 @@ async function removeManagedUltraworkSession(state, sessionID, reason) {
119
159
  await writeManagedSessionsCache(state);
120
160
  console.log(`[opencode-immune] Removed managed ultrawork session ${sessionID}: ${reason}`);
121
161
  }
162
+ async function updateManagedSessionAgent(state, sessionID, agent) {
163
+ const existing = state.managedUltraworkSessions.get(sessionID);
164
+ if (!existing || existing.agent === agent)
165
+ return;
166
+ state.managedUltraworkSessions.set(sessionID, {
167
+ ...existing,
168
+ agent,
169
+ updatedAt: Date.now(),
170
+ });
171
+ await writeManagedSessionsCache(state);
172
+ }
122
173
  function markUltraworkSessionActive(state, sessionID) {
123
174
  const existing = state.managedUltraworkSessions.get(sessionID);
124
175
  if (!existing)
@@ -140,6 +191,27 @@ function isRetryableApiError(error) {
140
191
  return (maybeError.name === "APIError" &&
141
192
  maybeError.data?.isRetryable === true);
142
193
  }
194
+ function isRateLimitApiError(error) {
195
+ if (!error || typeof error !== "object")
196
+ return false;
197
+ const maybeError = error;
198
+ const message = `${maybeError.message ?? ""} ${maybeError.data?.message ?? ""}`.toLowerCase();
199
+ const type = `${maybeError.data?.type ?? ""}`.toLowerCase();
200
+ return (maybeError.name === "APIError" &&
201
+ maybeError.data?.isRetryable === true &&
202
+ (type.includes("rate_limit") || message.includes("too many requests") || message.includes("rate limit")));
203
+ }
204
+ async function setSessionFallbackModel(state, sessionID, model) {
205
+ const existing = state.managedUltraworkSessions.get(sessionID);
206
+ if (!existing)
207
+ return;
208
+ state.managedUltraworkSessions.set(sessionID, {
209
+ ...existing,
210
+ updatedAt: Date.now(),
211
+ fallbackModel: model,
212
+ });
213
+ await writeManagedSessionsCache(state);
214
+ }
143
215
  // ═══════════════════════════════════════════════════════════════════════════════
144
216
  // UTILITY: ERROR BOUNDARY
145
217
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -276,12 +348,16 @@ function createTodoEnforcerChatMessage(state) {
276
348
  return async (input, _output) => {
277
349
  const sessionID = input.sessionID;
278
350
  const agent = input.agent;
351
+ const record = getManagedSession(state, sessionID);
279
352
  if (sessionID && agent === ULTRAWORK_AGENT) {
280
353
  await addManagedUltraworkSession(state, sessionID);
281
354
  }
282
- else if (sessionID && agent && isManagedUltraworkSession(state, sessionID)) {
355
+ else if (sessionID && agent && record?.kind === "root") {
283
356
  await removeManagedUltraworkSession(state, sessionID, `session taken over by agent \"${agent}\"`);
284
357
  }
358
+ else if (sessionID && agent && record?.kind === "child") {
359
+ await updateManagedSessionAgent(state, sessionID, agent);
360
+ }
285
361
  // On user message, check previous assistant turn's counters
286
362
  // then reset for next turn
287
363
  if (state.toolCallCount > 3 && !state.todoWriteUsed) {
@@ -309,7 +385,11 @@ function createSessionRecoveryEvent(state) {
309
385
  state.sessionActive = true;
310
386
  const sessionInfo = event.properties?.info;
311
387
  const sessionID = sessionInfo?.id ?? event.properties?.sessionID;
312
- if (!isManagedUltraworkSession(state, sessionID)) {
388
+ const parentID = sessionInfo?.parentID;
389
+ if (sessionID && parentID && isManagedUltraworkSession(state, parentID)) {
390
+ await addManagedChildSession(state, sessionID, parentID);
391
+ }
392
+ if (!isManagedRootUltraworkSession(state, sessionID)) {
313
393
  return;
314
394
  }
315
395
  console.log(`[opencode-immune] Managed ultrawork session created, checking for active task...`);
@@ -320,7 +400,7 @@ function createSessionRecoveryEvent(state) {
320
400
  if (sessionID && recovery.phase !== "ARCHIVE: DONE") {
321
401
  setTimeout(async () => {
322
402
  try {
323
- if (!isManagedUltraworkSession(state, sessionID)) {
403
+ if (!isManagedRootUltraworkSession(state, sessionID)) {
324
404
  return;
325
405
  }
326
406
  await state.input.client.session.promptAsync({
@@ -357,7 +437,7 @@ function createSessionRecoveryEvent(state) {
357
437
  function createSystemTransform(state) {
358
438
  return async (input, output) => {
359
439
  // Session Recovery injection
360
- if (state.recoveryContext && isManagedUltraworkSession(state, input.sessionID)) {
440
+ if (state.recoveryContext && isManagedRootUltraworkSession(state, input.sessionID)) {
361
441
  const ctx = state.recoveryContext;
362
442
  const intentInfo = ctx.intent ? `, Intent: ${ctx.intent}` : "";
363
443
  const categoryInfo = ctx.category ? `, Category: ${ctx.category}` : "";
@@ -533,9 +613,12 @@ function createFallbackModels(state) {
533
613
  if (input.agent === ULTRAWORK_AGENT) {
534
614
  await addManagedUltraworkSession(state, input.sessionID);
535
615
  }
536
- else if (isManagedUltraworkSession(state, input.sessionID)) {
616
+ else if (getManagedSession(state, input.sessionID)?.kind === "root") {
537
617
  await removeManagedUltraworkSession(state, input.sessionID, `session switched to agent \"${input.agent}\"`);
538
618
  }
619
+ else if (getManagedSession(state, input.sessionID)?.kind === "child") {
620
+ await updateManagedSessionAgent(state, input.sessionID, input.agent);
621
+ }
539
622
  // Log model and agent for observability
540
623
  const modelId = input.model && "id" in input.model
541
624
  ? input.model.id
@@ -586,6 +669,17 @@ function createEventHandler(state) {
586
669
  if (count < MAX_RETRIES) {
587
670
  const delay = Math.min(BASE_DELAY_MS * Math.pow(2, count), MAX_DELAY_MS);
588
671
  state.retryCount.set(sessionID, count + 1);
672
+ if (isRateLimitApiError(error)) {
673
+ await setSessionFallbackModel(state, sessionID, RATE_LIMIT_FALLBACK_MODEL);
674
+ console.log(`[opencode-immune] Rate limit detected for session ${sessionID}. ` +
675
+ `Retry will use fallback model ${RATE_LIMIT_FALLBACK_MODEL.providerID}/${RATE_LIMIT_FALLBACK_MODEL.modelID}.`);
676
+ }
677
+ const managedSession = state.managedUltraworkSessions.get(sessionID);
678
+ const fallbackModel = managedSession?.fallbackModel;
679
+ const retryAgent = managedSession?.agent || ULTRAWORK_AGENT;
680
+ const retryText = retryAgent === ULTRAWORK_AGENT
681
+ ? "[SYSTEM: Previous API call failed with a transient error. Re-read memory-bank/tasks.md, check the Phase Status block, and continue the pipeline. Use the exact neutral prompt from your Step 5 table for the next router call. Do NOT analyze or evaluate file contents.]"
682
+ : `[SYSTEM: Previous API call failed with a transient error. Resume the current task in your current role as ${retryAgent}. Continue from the existing session state. Do not restart from scratch unless the current session state is missing.]`;
589
683
  console.log(`[opencode-immune] Session error detected (attempt ${count + 1}/${MAX_RETRIES}). ` +
590
684
  `Waiting ${delay / 1000}s before retry...`);
591
685
  const timer = setTimeout(async () => {
@@ -596,17 +690,21 @@ function createEventHandler(state) {
596
690
  try {
597
691
  await state.input.client.session.promptAsync({
598
692
  body: {
599
- agent: ULTRAWORK_AGENT,
693
+ ...(fallbackModel ? { model: fallbackModel } : {}),
694
+ agent: retryAgent,
600
695
  parts: [
601
696
  {
602
697
  type: "text",
603
- text: "[SYSTEM: Previous API call failed with a transient error. Re-read memory-bank/tasks.md, check the Phase Status block, and continue the pipeline. Use the exact neutral prompt from your Step 5 table for the next router call. Do NOT analyze or evaluate file contents.]",
698
+ text: retryText,
604
699
  },
605
700
  ],
606
701
  },
607
702
  path: { id: sessionID },
608
703
  });
609
- console.log(`[opencode-immune] Auto-retry message sent to session ${sessionID}`);
704
+ console.log(`[opencode-immune] Auto-retry message sent to session ${sessionID}` +
705
+ (fallbackModel
706
+ ? ` using fallback model ${fallbackModel.providerID}/${fallbackModel.modelID}`
707
+ : ""));
610
708
  }
611
709
  catch (err) {
612
710
  state.retryCount.set(sessionID, Math.max((state.retryCount.get(sessionID) ?? 1) - 1, 0));
@@ -660,7 +758,7 @@ const NEXT_TASK_PATTERN = /Next task:\s*(.+)/;
660
758
  function createMultiCycleHandler(state) {
661
759
  return async (input, output) => {
662
760
  const sessionID = input.sessionID;
663
- if (!isManagedUltraworkSession(state, sessionID))
761
+ if (!isManagedRootUltraworkSession(state, sessionID))
664
762
  return;
665
763
  // Extract text content from parts
666
764
  const parts = output.parts ?? [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-immune",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "OpenCode plugin: session recovery, auto-retry, multi-cycle automation, context monitoring",
5
5
  "exports": {
6
6
  "./server": "./dist/plugin.js"
@@ -14,7 +14,7 @@
14
14
  "prepublishOnly": "npm run build"
15
15
  },
16
16
  "dependencies": {
17
- "@opencode-ai/plugin": "^1.4.1"
17
+ "@opencode-ai/plugin": "1.4.1"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@types/node": "^25.5.2",