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.
- package/dist/plugin.js +109 -11
- 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
|
|
78
|
+
if (!record)
|
|
68
79
|
continue;
|
|
69
80
|
state.managedUltraworkSessions.set(sessionID, {
|
|
70
|
-
|
|
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 &&
|
|
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
|
-
|
|
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 (!
|
|
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 &&
|
|
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 (
|
|
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
|
-
|
|
693
|
+
...(fallbackModel ? { model: fallbackModel } : {}),
|
|
694
|
+
agent: retryAgent,
|
|
600
695
|
parts: [
|
|
601
696
|
{
|
|
602
697
|
type: "text",
|
|
603
|
-
text:
|
|
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 (!
|
|
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.
|
|
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": "
|
|
17
|
+
"@opencode-ai/plugin": "1.4.1"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@types/node": "^25.5.2",
|