opencode-immune 1.0.0
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 +786 -0
- package/package.json +32 -0
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// .opencode/plugin.ts — opencode-immune plugin
|
|
3
|
+
// Hybrid single-file architecture with factory functions, explicit state, error boundaries
|
|
4
|
+
// See: memory-bank/creative/creative-plugin-architecture.md (Option C)
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const promises_1 = require("fs/promises");
|
|
7
|
+
const path_1 = require("path");
|
|
8
|
+
function createState(input) {
|
|
9
|
+
return {
|
|
10
|
+
input,
|
|
11
|
+
recoveryContext: null,
|
|
12
|
+
managedUltraworkSessions: new Map(),
|
|
13
|
+
retryTimers: new Map(),
|
|
14
|
+
retryCount: new Map(),
|
|
15
|
+
managedSessionsCachePath: (0, path_1.join)(input.directory, ".opencode", "state", "opencode-immune-managed-sessions.json"),
|
|
16
|
+
lastEditAttempt: null,
|
|
17
|
+
toolCallCount: 0,
|
|
18
|
+
todoWriteUsed: false,
|
|
19
|
+
approximateTokens: 0,
|
|
20
|
+
sessionActive: false,
|
|
21
|
+
cycleCount: 0,
|
|
22
|
+
commitPending: false,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const ULTRAWORK_AGENT = "0-ultrawork";
|
|
26
|
+
const MANAGED_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
27
|
+
function isManagedUltraworkSession(state, sessionID) {
|
|
28
|
+
return !!sessionID && state.managedUltraworkSessions.has(sessionID);
|
|
29
|
+
}
|
|
30
|
+
function pruneExpiredManagedSessions(state, now = Date.now()) {
|
|
31
|
+
let removed = 0;
|
|
32
|
+
for (const [sessionID, record] of state.managedUltraworkSessions.entries()) {
|
|
33
|
+
if (now - record.updatedAt <= MANAGED_SESSION_TTL_MS) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
cancelRetry(state, sessionID, "managed session TTL expired");
|
|
37
|
+
state.retryCount.delete(sessionID);
|
|
38
|
+
state.managedUltraworkSessions.delete(sessionID);
|
|
39
|
+
removed++;
|
|
40
|
+
}
|
|
41
|
+
return removed;
|
|
42
|
+
}
|
|
43
|
+
async function writeManagedSessionsCache(state) {
|
|
44
|
+
const removed = pruneExpiredManagedSessions(state);
|
|
45
|
+
const cacheDir = (0, path_1.join)(state.input.directory, ".opencode", "state");
|
|
46
|
+
const tempPath = `${state.managedSessionsCachePath}.tmp`;
|
|
47
|
+
const payload = {
|
|
48
|
+
version: 1,
|
|
49
|
+
sessions: Object.fromEntries(state.managedUltraworkSessions.entries()),
|
|
50
|
+
};
|
|
51
|
+
await (0, promises_1.mkdir)(cacheDir, { recursive: true });
|
|
52
|
+
await (0, promises_1.writeFile)(tempPath, JSON.stringify(payload, null, 2), "utf-8");
|
|
53
|
+
await (0, promises_1.rename)(tempPath, state.managedSessionsCachePath);
|
|
54
|
+
if (removed > 0) {
|
|
55
|
+
console.log(`[opencode-immune] Pruned ${removed} expired managed ultrawork session(s) while writing cache.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function loadManagedSessionsCache(state) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = await (0, promises_1.readFile)(state.managedSessionsCachePath, "utf-8");
|
|
61
|
+
const parsed = JSON.parse(raw);
|
|
62
|
+
if (parsed.version !== 1 || !parsed.sessions) {
|
|
63
|
+
console.warn(`[opencode-immune] Managed sessions cache at ${state.managedSessionsCachePath} has unsupported format. Ignoring.`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const [sessionID, record] of Object.entries(parsed.sessions)) {
|
|
67
|
+
if (!record || record.agent !== ULTRAWORK_AGENT)
|
|
68
|
+
continue;
|
|
69
|
+
state.managedUltraworkSessions.set(sessionID, {
|
|
70
|
+
agent: ULTRAWORK_AGENT,
|
|
71
|
+
createdAt: typeof record.createdAt === "number" ? record.createdAt : Date.now(),
|
|
72
|
+
updatedAt: typeof record.updatedAt === "number" ? record.updatedAt : Date.now(),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const removed = pruneExpiredManagedSessions(state);
|
|
76
|
+
console.log(`[opencode-immune] Loaded ${state.managedUltraworkSessions.size} managed ultrawork session(s) from cache.`);
|
|
77
|
+
if (removed > 0) {
|
|
78
|
+
console.log(`[opencode-immune] Pruned ${removed} expired managed ultrawork session(s) on startup.`);
|
|
79
|
+
await writeManagedSessionsCache(state);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
84
|
+
if (message.includes("ENOENT"))
|
|
85
|
+
return;
|
|
86
|
+
console.warn(`[opencode-immune] Failed to read managed sessions cache. Starting fresh.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function addManagedUltraworkSession(state, sessionID, timestamp = Date.now()) {
|
|
90
|
+
const existing = state.managedUltraworkSessions.get(sessionID);
|
|
91
|
+
const nextRecord = {
|
|
92
|
+
agent: ULTRAWORK_AGENT,
|
|
93
|
+
createdAt: existing?.createdAt ?? timestamp,
|
|
94
|
+
updatedAt: timestamp,
|
|
95
|
+
};
|
|
96
|
+
if (existing &&
|
|
97
|
+
existing.agent === nextRecord.agent &&
|
|
98
|
+
existing.createdAt === nextRecord.createdAt &&
|
|
99
|
+
existing.updatedAt === nextRecord.updatedAt) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
state.managedUltraworkSessions.set(sessionID, nextRecord);
|
|
103
|
+
await writeManagedSessionsCache(state);
|
|
104
|
+
}
|
|
105
|
+
function cancelRetry(state, sessionID, reason) {
|
|
106
|
+
const timer = state.retryTimers.get(sessionID);
|
|
107
|
+
if (!timer)
|
|
108
|
+
return;
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
state.retryTimers.delete(sessionID);
|
|
111
|
+
console.log(`[opencode-immune] Cancelled pending retry for session ${sessionID}: ${reason}`);
|
|
112
|
+
}
|
|
113
|
+
async function removeManagedUltraworkSession(state, sessionID, reason) {
|
|
114
|
+
cancelRetry(state, sessionID, reason);
|
|
115
|
+
state.retryCount.delete(sessionID);
|
|
116
|
+
const existed = state.managedUltraworkSessions.delete(sessionID);
|
|
117
|
+
if (!existed)
|
|
118
|
+
return;
|
|
119
|
+
await writeManagedSessionsCache(state);
|
|
120
|
+
console.log(`[opencode-immune] Removed managed ultrawork session ${sessionID}: ${reason}`);
|
|
121
|
+
}
|
|
122
|
+
function markUltraworkSessionActive(state, sessionID) {
|
|
123
|
+
const existing = state.managedUltraworkSessions.get(sessionID);
|
|
124
|
+
if (!existing)
|
|
125
|
+
return false;
|
|
126
|
+
const nextUpdatedAt = Date.now();
|
|
127
|
+
if (existing.updatedAt === nextUpdatedAt) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
state.managedUltraworkSessions.set(sessionID, {
|
|
131
|
+
...existing,
|
|
132
|
+
updatedAt: nextUpdatedAt,
|
|
133
|
+
});
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
function isRetryableApiError(error) {
|
|
137
|
+
if (!error || typeof error !== "object")
|
|
138
|
+
return false;
|
|
139
|
+
const maybeError = error;
|
|
140
|
+
return (maybeError.name === "APIError" &&
|
|
141
|
+
maybeError.data?.isRetryable === true);
|
|
142
|
+
}
|
|
143
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
144
|
+
// UTILITY: ERROR BOUNDARY
|
|
145
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
146
|
+
/**
|
|
147
|
+
* Wraps a hook handler in a try/catch to prevent any single hook failure
|
|
148
|
+
* from crashing the entire agent session.
|
|
149
|
+
*/
|
|
150
|
+
function withErrorBoundary(hookName, handler) {
|
|
151
|
+
return (async (...args) => {
|
|
152
|
+
try {
|
|
153
|
+
return await handler(...args);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
console.error(`[opencode-immune] Hook "${hookName}" error:`, err);
|
|
157
|
+
// Error is swallowed — hook failure must not crash agent session
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
162
|
+
// UTILITY: HOOK COMPOSITION
|
|
163
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
164
|
+
/**
|
|
165
|
+
* Composes multiple tool.execute.after handlers into a single handler.
|
|
166
|
+
* Needed because the plugin API provides one slot per hook name,
|
|
167
|
+
* but we have Todo Enforcer + Ralph Loop + Comment Checker sharing it.
|
|
168
|
+
*/
|
|
169
|
+
function compositeToolAfter(handlers) {
|
|
170
|
+
return async (input, output) => {
|
|
171
|
+
for (const handler of handlers) {
|
|
172
|
+
await handler(input, output);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Composes multiple chat.message handlers into a single handler.
|
|
178
|
+
* Needed for Todo Enforcer check + Keyword Detector + Context Monitor.
|
|
179
|
+
*/
|
|
180
|
+
function compositeChatMessage(handlers) {
|
|
181
|
+
return async (input, output) => {
|
|
182
|
+
for (const handler of handlers) {
|
|
183
|
+
await handler(input, output);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
188
|
+
// UTILITY: TASKS.MD PARSER
|
|
189
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
190
|
+
/**
|
|
191
|
+
* Reads and parses memory-bank/tasks.md to extract active task metadata.
|
|
192
|
+
* Used by Session Recovery and potentially other hooks.
|
|
193
|
+
* Returns null if file doesn't exist or has no active task.
|
|
194
|
+
*/
|
|
195
|
+
async function parseTasksFile(directory) {
|
|
196
|
+
try {
|
|
197
|
+
const tasksPath = (0, path_1.join)(directory, "memory-bank", "tasks.md");
|
|
198
|
+
const content = await (0, promises_1.readFile)(tasksPath, "utf-8");
|
|
199
|
+
// Check for active task
|
|
200
|
+
if (!content.includes("## Active Task") ||
|
|
201
|
+
content.includes("No active tasks")) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
// Extract task name
|
|
205
|
+
const taskMatch = content.match(/- \*\*Task\*\*:\s*(.+)/);
|
|
206
|
+
const task = taskMatch?.[1]?.trim() ?? "Unknown task";
|
|
207
|
+
// Extract level
|
|
208
|
+
const levelMatch = content.match(/- \*\*Level\*\*:\s*(\d+)/);
|
|
209
|
+
const level = levelMatch?.[1] ?? "?";
|
|
210
|
+
// Extract intent (may not exist for legacy tasks)
|
|
211
|
+
const intentMatch = content.match(/- \*\*Intent\*\*:\s*(\w+)/);
|
|
212
|
+
const intent = intentMatch?.[1];
|
|
213
|
+
// Extract category (may not exist for legacy tasks)
|
|
214
|
+
const categoryMatch = content.match(/- \*\*Category\*\*:\s*(\w+)/);
|
|
215
|
+
const category = categoryMatch?.[1];
|
|
216
|
+
// Extract Phase Status block
|
|
217
|
+
const phaseStatusMatch = content.match(/<!-- PHASE_STATUS_START -->([\s\S]*?)<!-- PHASE_STATUS_END -->/);
|
|
218
|
+
const phaseStatus = phaseStatusMatch?.[1]?.trim() ?? "Unknown";
|
|
219
|
+
// Find current phase (first IN_PROGRESS or first NOT_STARTED)
|
|
220
|
+
let currentPhase = "UNKNOWN";
|
|
221
|
+
const phaseLines = phaseStatus.split("\n");
|
|
222
|
+
for (const line of phaseLines) {
|
|
223
|
+
if (line.includes("IN_PROGRESS")) {
|
|
224
|
+
const match = line.match(/- (\w+): IN_PROGRESS/);
|
|
225
|
+
if (match) {
|
|
226
|
+
currentPhase = match[1];
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (currentPhase === "UNKNOWN") {
|
|
232
|
+
for (const line of phaseLines) {
|
|
233
|
+
if (line.includes("NOT_STARTED")) {
|
|
234
|
+
const match = line.match(/- (\w+): NOT_STARTED/);
|
|
235
|
+
if (match) {
|
|
236
|
+
currentPhase = match[1];
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
task,
|
|
244
|
+
level,
|
|
245
|
+
phase: currentPhase,
|
|
246
|
+
phaseStatus,
|
|
247
|
+
intent,
|
|
248
|
+
category,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// File doesn't exist or can't be read — that's fine
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
257
|
+
// HOOK 1: TODO ENFORCER
|
|
258
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
259
|
+
/**
|
|
260
|
+
* tool.execute.after part: counts tool calls and flags todowrite usage.
|
|
261
|
+
* Per-message counters are reset by the chat.message handler.
|
|
262
|
+
*/
|
|
263
|
+
function createTodoEnforcerToolAfter(state) {
|
|
264
|
+
return async (input, _output) => {
|
|
265
|
+
state.toolCallCount++;
|
|
266
|
+
if (input.tool === "todowrite" || input.tool === "TodoWrite") {
|
|
267
|
+
state.todoWriteUsed = true;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* chat.message part: checks if multi-step work happened without todo list.
|
|
273
|
+
* Also resets per-message counters for the next assistant turn.
|
|
274
|
+
*/
|
|
275
|
+
function createTodoEnforcerChatMessage(state) {
|
|
276
|
+
return async (input, _output) => {
|
|
277
|
+
const sessionID = input.sessionID;
|
|
278
|
+
const agent = input.agent;
|
|
279
|
+
if (sessionID && agent === ULTRAWORK_AGENT) {
|
|
280
|
+
await addManagedUltraworkSession(state, sessionID);
|
|
281
|
+
}
|
|
282
|
+
else if (sessionID && agent && isManagedUltraworkSession(state, sessionID)) {
|
|
283
|
+
await removeManagedUltraworkSession(state, sessionID, `session taken over by agent \"${agent}\"`);
|
|
284
|
+
}
|
|
285
|
+
// On user message, check previous assistant turn's counters
|
|
286
|
+
// then reset for next turn
|
|
287
|
+
if (state.toolCallCount > 3 && !state.todoWriteUsed) {
|
|
288
|
+
console.warn(`[opencode-immune] Todo Enforcer: ${state.toolCallCount} tool calls without TodoWrite. ` +
|
|
289
|
+
`Consider using todo list for multi-step tasks.`);
|
|
290
|
+
}
|
|
291
|
+
// Reset per-message counters for the next assistant turn
|
|
292
|
+
state.toolCallCount = 0;
|
|
293
|
+
state.todoWriteUsed = false;
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
297
|
+
// HOOK 2: SESSION RECOVERY
|
|
298
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
299
|
+
/**
|
|
300
|
+
* event handler: detects session.create and reads tasks.md for recovery context.
|
|
301
|
+
* If an active task with incomplete phases is found, automatically sends
|
|
302
|
+
* a resume prompt ONLY to managed ultrawork sessions.
|
|
303
|
+
*/
|
|
304
|
+
function createSessionRecoveryEvent(state) {
|
|
305
|
+
return async (input) => {
|
|
306
|
+
const event = input.event;
|
|
307
|
+
const eventType = event.type ?? "";
|
|
308
|
+
if (eventType === "session.created") {
|
|
309
|
+
state.sessionActive = true;
|
|
310
|
+
const sessionInfo = event.properties?.info;
|
|
311
|
+
const sessionID = sessionInfo?.id ?? event.properties?.sessionID;
|
|
312
|
+
if (!isManagedUltraworkSession(state, sessionID)) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
console.log(`[opencode-immune] Managed ultrawork session created, checking for active task...`);
|
|
316
|
+
const recovery = await parseTasksFile(state.input.directory);
|
|
317
|
+
if (recovery) {
|
|
318
|
+
state.recoveryContext = recovery;
|
|
319
|
+
console.log(`[opencode-immune] Active task found: "${recovery.task}" (Level ${recovery.level}, Phase: ${recovery.phase})`);
|
|
320
|
+
if (sessionID && recovery.phase !== "ARCHIVE: DONE") {
|
|
321
|
+
setTimeout(async () => {
|
|
322
|
+
try {
|
|
323
|
+
if (!isManagedUltraworkSession(state, sessionID)) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
await state.input.client.session.promptAsync({
|
|
327
|
+
body: {
|
|
328
|
+
agent: ULTRAWORK_AGENT,
|
|
329
|
+
parts: [
|
|
330
|
+
{
|
|
331
|
+
type: "text",
|
|
332
|
+
text: `[AUTO-RESUME] Previous session was interrupted. Read memory-bank/tasks.md, check the Phase Status block, and continue the pipeline. Use ONLY the Phase Status block to determine the next phase. Do NOT analyze or evaluate the content of tasks.md. Call the appropriate router with the exact neutral prompt from your Step 5 table.`,
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
},
|
|
336
|
+
path: { id: sessionID },
|
|
337
|
+
});
|
|
338
|
+
console.log(`[opencode-immune] Auto-resume prompt sent to managed ultrawork session ${sessionID}`);
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
console.log(`[opencode-immune] Auto-resume failed (session may have been taken over):`, err);
|
|
342
|
+
}
|
|
343
|
+
}, 3_000);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
state.recoveryContext = null;
|
|
348
|
+
console.log("[opencode-immune] No active task found.");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* experimental.chat.system.transform: injects recovery context if active task exists.
|
|
355
|
+
* Also injects Ralph Loop hints if a previous edit failed.
|
|
356
|
+
*/
|
|
357
|
+
function createSystemTransform(state) {
|
|
358
|
+
return async (input, output) => {
|
|
359
|
+
// Session Recovery injection
|
|
360
|
+
if (state.recoveryContext && isManagedUltraworkSession(state, input.sessionID)) {
|
|
361
|
+
const ctx = state.recoveryContext;
|
|
362
|
+
const intentInfo = ctx.intent ? `, Intent: ${ctx.intent}` : "";
|
|
363
|
+
const categoryInfo = ctx.category ? `, Category: ${ctx.category}` : "";
|
|
364
|
+
output.system.push(`[Session Recovery] Active Memory Bank task detected:\n` +
|
|
365
|
+
`- Task: ${ctx.task}\n` +
|
|
366
|
+
`- Level: ${ctx.level}${intentInfo}${categoryInfo}\n` +
|
|
367
|
+
`- Current Phase: ${ctx.phase}\n` +
|
|
368
|
+
`- Phase Status:\n${ctx.phaseStatus}\n` +
|
|
369
|
+
`Read memory-bank/tasks.md and memory-bank/activeContext.md to resume work.`);
|
|
370
|
+
}
|
|
371
|
+
// Ralph Loop injection
|
|
372
|
+
if (state.lastEditAttempt) {
|
|
373
|
+
const edit = state.lastEditAttempt;
|
|
374
|
+
output.system.push(`[Edit Error Recovery] Previous edit failed for "${edit.filePath}". ` +
|
|
375
|
+
`The oldString was not found in the file. ` +
|
|
376
|
+
`Read the file first to get the correct content, then retry the edit with the exact oldString from the file.`);
|
|
377
|
+
// Clear after injection to avoid repeated hints
|
|
378
|
+
state.lastEditAttempt = null;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
383
|
+
// HOOK 3: RALPH LOOP (EDIT ERROR RECOVERY)
|
|
384
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
385
|
+
/**
|
|
386
|
+
* tool.execute.after: detects failed edit operations and stores context for recovery.
|
|
387
|
+
*/
|
|
388
|
+
function createRalphLoopToolAfter(state) {
|
|
389
|
+
return async (input, output) => {
|
|
390
|
+
if (input.tool !== "edit")
|
|
391
|
+
return;
|
|
392
|
+
// Check if the edit failed with "oldString not found"
|
|
393
|
+
const outputText = typeof output.output === "string" ? output.output : "";
|
|
394
|
+
if (outputText.includes("oldString not found") ||
|
|
395
|
+
outputText.includes("Found multiple matches")) {
|
|
396
|
+
state.lastEditAttempt = {
|
|
397
|
+
filePath: input.args?.filePath ?? "unknown",
|
|
398
|
+
oldString: input.args?.oldString ?? "",
|
|
399
|
+
newString: input.args?.newString ?? "",
|
|
400
|
+
timestamp: Date.now(),
|
|
401
|
+
};
|
|
402
|
+
console.warn(`[opencode-immune] Ralph Loop: Edit failed for "${state.lastEditAttempt.filePath}". ` +
|
|
403
|
+
`Recovery hint will be injected in next system transform.`);
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
// Successful edit clears any pending recovery
|
|
407
|
+
state.lastEditAttempt = null;
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
412
|
+
// HOOK 4: CONTEXT WINDOW MONITOR
|
|
413
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
414
|
+
const ESTIMATED_CONTEXT_LIMIT = 128000; // tokens (approximate for most models)
|
|
415
|
+
const CONTEXT_WARNING_THRESHOLD = 0.8; // 80%
|
|
416
|
+
/**
|
|
417
|
+
* chat.message part: estimates token count from message parts.
|
|
418
|
+
*/
|
|
419
|
+
function createContextMonitorChatMessage(state) {
|
|
420
|
+
return async (_input, output) => {
|
|
421
|
+
// Rough token estimate: stringify parts, divide by 4
|
|
422
|
+
try {
|
|
423
|
+
const partsStr = JSON.stringify(output.parts ?? []);
|
|
424
|
+
const estimatedTokens = Math.ceil(partsStr.length / 4);
|
|
425
|
+
state.approximateTokens += estimatedTokens;
|
|
426
|
+
if (state.approximateTokens >
|
|
427
|
+
ESTIMATED_CONTEXT_LIMIT * CONTEXT_WARNING_THRESHOLD) {
|
|
428
|
+
const pct = Math.round((state.approximateTokens / ESTIMATED_CONTEXT_LIMIT) * 100);
|
|
429
|
+
console.warn(`[opencode-immune] Context Monitor: ~${state.approximateTokens} tokens estimated (${pct}% of ~${ESTIMATED_CONTEXT_LIMIT}). ` +
|
|
430
|
+
`Consider compacting the session.`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
// Token estimation is best-effort
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* experimental.session.compacting: provides Memory Bank-aware compaction context.
|
|
440
|
+
*/
|
|
441
|
+
function createCompactionHandler(state) {
|
|
442
|
+
return async (_input, output) => {
|
|
443
|
+
output.context.push("IMPORTANT: During compaction, preserve the following Memory Bank context:\n" +
|
|
444
|
+
"1. The current task from memory-bank/tasks.md (task name, level, intent, category)\n" +
|
|
445
|
+
"2. The Phase Status block (which phases are DONE/IN_PROGRESS/NOT_STARTED)\n" +
|
|
446
|
+
"3. The active pipeline log entries from the current phase\n" +
|
|
447
|
+
"4. Any file paths that were recently modified or are actively being worked on\n" +
|
|
448
|
+
"5. Key architectural decisions from memory-bank/activeContext.md\n" +
|
|
449
|
+
"After compaction, the agent should still be able to resume the current phase without re-reading all Memory Bank files.");
|
|
450
|
+
// Reset token counter after compaction
|
|
451
|
+
state.approximateTokens = 0;
|
|
452
|
+
console.log("[opencode-immune] Context Monitor: Compaction triggered, token counter reset.");
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
456
|
+
// HOOK 5: COMMENT CHECKER
|
|
457
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
458
|
+
// Simple emoji detection regex (covers most common Unicode emoji ranges)
|
|
459
|
+
const EMOJI_PATTERN = /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{200D}\u{20E3}]/u;
|
|
460
|
+
const TODO_PATTERN = /\b(TODO|FIXME|HACK|XXX)\b/i;
|
|
461
|
+
/**
|
|
462
|
+
* tool.execute.after: checks edit/write content for emoji and TODO comments.
|
|
463
|
+
*/
|
|
464
|
+
function createCommentCheckerToolAfter(state) {
|
|
465
|
+
return async (input, _output) => {
|
|
466
|
+
if (input.tool !== "edit" && input.tool !== "write")
|
|
467
|
+
return;
|
|
468
|
+
// Get the content being written
|
|
469
|
+
const content = input.tool === "edit"
|
|
470
|
+
? input.args?.newString ?? ""
|
|
471
|
+
: input.args?.content ?? "";
|
|
472
|
+
if (!content)
|
|
473
|
+
return;
|
|
474
|
+
// Check for emoji
|
|
475
|
+
if (EMOJI_PATTERN.test(content)) {
|
|
476
|
+
console.warn(`[opencode-immune] Comment Checker: Emoji detected in ${input.tool} operation. ` +
|
|
477
|
+
`Avoid emojis in code unless the user explicitly requested them.`);
|
|
478
|
+
}
|
|
479
|
+
// Check for TODO/FIXME/HACK
|
|
480
|
+
const todoMatch = content.match(TODO_PATTERN);
|
|
481
|
+
if (todoMatch) {
|
|
482
|
+
console.warn(`[opencode-immune] Comment Checker: "${todoMatch[0]}" comment found in ${input.tool} operation. ` +
|
|
483
|
+
`Consider resolving it or tracking it in the todo list.`);
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
488
|
+
// HOOK 6: KEYWORD DETECTOR
|
|
489
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
490
|
+
const ERROR_KEYWORDS = /\b(error|bug|broken|not working|не работает|сломал|ошибка|баг|починить)\b/i;
|
|
491
|
+
const DEPLOY_KEYWORDS = /\b(deploy|release|деплой|релиз|выкатить|продакшен|production)\b/i;
|
|
492
|
+
/**
|
|
493
|
+
* chat.message part: scans user messages for keywords suggesting specific workflows.
|
|
494
|
+
* Extracts text from output.parts (TextPart[]) since UserMessage has no content field.
|
|
495
|
+
*/
|
|
496
|
+
function createKeywordDetectorChatMessage(_state) {
|
|
497
|
+
return async (_input, output) => {
|
|
498
|
+
// Only scan user messages
|
|
499
|
+
if (output.message?.role !== "user")
|
|
500
|
+
return;
|
|
501
|
+
// Extract text content from parts (TextPart has type: "text" and text: string)
|
|
502
|
+
const parts = output.parts ?? [];
|
|
503
|
+
let messageContent = "";
|
|
504
|
+
for (const p of parts) {
|
|
505
|
+
if ("type" in p && p.type === "text" && "text" in p) {
|
|
506
|
+
messageContent += " " + p.text;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
messageContent = messageContent.trim();
|
|
510
|
+
if (!messageContent)
|
|
511
|
+
return;
|
|
512
|
+
if (ERROR_KEYWORDS.test(messageContent)) {
|
|
513
|
+
console.log(`[opencode-immune] Keyword Detector: Error-related keywords found. ` +
|
|
514
|
+
`Consider using 1-van to analyze the issue systematically.`);
|
|
515
|
+
}
|
|
516
|
+
if (DEPLOY_KEYWORDS.test(messageContent)) {
|
|
517
|
+
console.log(`[opencode-immune] Keyword Detector: Deploy/release keywords found. ` +
|
|
518
|
+
`Consider running 5-reflect first to verify implementation quality.`);
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
523
|
+
// HOOK 7: FALLBACK MODELS (OBSERVATIONAL)
|
|
524
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
525
|
+
/**
|
|
526
|
+
* chat.params: observational logging of model and agent info.
|
|
527
|
+
* NOTE: chat.params output does NOT expose the `model` field.
|
|
528
|
+
* Primary model routing is handled by BUILD router + alias agents.
|
|
529
|
+
* This hook only provides observability.
|
|
530
|
+
*/
|
|
531
|
+
function createFallbackModels(state) {
|
|
532
|
+
return async (input, _output) => {
|
|
533
|
+
if (input.agent === ULTRAWORK_AGENT) {
|
|
534
|
+
await addManagedUltraworkSession(state, input.sessionID);
|
|
535
|
+
}
|
|
536
|
+
else if (isManagedUltraworkSession(state, input.sessionID)) {
|
|
537
|
+
await removeManagedUltraworkSession(state, input.sessionID, `session switched to agent \"${input.agent}\"`);
|
|
538
|
+
}
|
|
539
|
+
// Log model and agent for observability
|
|
540
|
+
const modelId = input.model && "id" in input.model
|
|
541
|
+
? input.model.id
|
|
542
|
+
: "unknown";
|
|
543
|
+
const providerId = input.provider?.info && "id" in input.provider.info
|
|
544
|
+
? input.provider.info.id
|
|
545
|
+
: "unknown";
|
|
546
|
+
console.log(`[opencode-immune] Model Observer: agent="${input.agent}", ` +
|
|
547
|
+
`model="${modelId}", provider="${providerId}"`);
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
551
|
+
// HOOK 8: EVENT LOGGER
|
|
552
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
553
|
+
/**
|
|
554
|
+
* Shared event handler: combines Session Recovery event detection,
|
|
555
|
+
* auto-retry on API errors with exponential backoff, and general event logging.
|
|
556
|
+
*/
|
|
557
|
+
function createEventHandler(state) {
|
|
558
|
+
const sessionRecovery = createSessionRecoveryEvent(state);
|
|
559
|
+
const MAX_RETRIES = 10;
|
|
560
|
+
// Base delay 5s, grows exponentially: 5s, 10s, 20s, 40s, 60s (capped)
|
|
561
|
+
const BASE_DELAY_MS = 5_000;
|
|
562
|
+
const MAX_DELAY_MS = 60_000;
|
|
563
|
+
return async (input) => {
|
|
564
|
+
// Session Recovery — detect session.create
|
|
565
|
+
await sessionRecovery(input);
|
|
566
|
+
const event = input.event;
|
|
567
|
+
const eventType = event.type ?? "unknown";
|
|
568
|
+
const info = event.properties?.info;
|
|
569
|
+
const sessionID = event.properties?.sessionID ?? info?.id;
|
|
570
|
+
// ── Auto-retry on retryable API error for managed ultrawork sessions ──
|
|
571
|
+
if (eventType === "session.error" && sessionID) {
|
|
572
|
+
const error = event.properties?.error;
|
|
573
|
+
if (!isManagedUltraworkSession(state, sessionID)) {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (!isRetryableApiError(error)) {
|
|
577
|
+
cancelRetry(state, sessionID, "non-retryable or user-aborted error");
|
|
578
|
+
state.retryCount.delete(sessionID);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (state.retryTimers.has(sessionID)) {
|
|
582
|
+
console.log(`[opencode-immune] Retry already pending for session ${sessionID}, skipping duplicate.`);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const count = state.retryCount.get(sessionID) ?? 0;
|
|
586
|
+
if (count < MAX_RETRIES) {
|
|
587
|
+
const delay = Math.min(BASE_DELAY_MS * Math.pow(2, count), MAX_DELAY_MS);
|
|
588
|
+
state.retryCount.set(sessionID, count + 1);
|
|
589
|
+
console.log(`[opencode-immune] Session error detected (attempt ${count + 1}/${MAX_RETRIES}). ` +
|
|
590
|
+
`Waiting ${delay / 1000}s before retry...`);
|
|
591
|
+
const timer = setTimeout(async () => {
|
|
592
|
+
state.retryTimers.delete(sessionID);
|
|
593
|
+
if (!isManagedUltraworkSession(state, sessionID)) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
await state.input.client.session.promptAsync({
|
|
598
|
+
body: {
|
|
599
|
+
agent: ULTRAWORK_AGENT,
|
|
600
|
+
parts: [
|
|
601
|
+
{
|
|
602
|
+
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.]",
|
|
604
|
+
},
|
|
605
|
+
],
|
|
606
|
+
},
|
|
607
|
+
path: { id: sessionID },
|
|
608
|
+
});
|
|
609
|
+
console.log(`[opencode-immune] Auto-retry message sent to session ${sessionID}`);
|
|
610
|
+
}
|
|
611
|
+
catch (err) {
|
|
612
|
+
state.retryCount.set(sessionID, Math.max((state.retryCount.get(sessionID) ?? 1) - 1, 0));
|
|
613
|
+
console.log(`[opencode-immune] Auto-retry failed (still offline?). Will retry on next error event.`);
|
|
614
|
+
}
|
|
615
|
+
}, delay);
|
|
616
|
+
state.retryTimers.set(sessionID, timer);
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
console.log(`[opencode-immune] Max retries (${MAX_RETRIES}) reached for session ${sessionID}. Not retrying.`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// Reset retry counter on successful activity
|
|
623
|
+
if (eventType === "session.updated" && sessionID) {
|
|
624
|
+
cancelRetry(state, sessionID, "session updated");
|
|
625
|
+
state.retryCount.delete(sessionID);
|
|
626
|
+
if (markUltraworkSessionActive(state, sessionID)) {
|
|
627
|
+
await writeManagedSessionsCache(state);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (eventType === "session.deleted" && sessionID) {
|
|
631
|
+
await removeManagedUltraworkSession(state, sessionID, "session deleted");
|
|
632
|
+
}
|
|
633
|
+
// Log significant events (not all, to avoid noise)
|
|
634
|
+
const significantEvents = [
|
|
635
|
+
"session.created",
|
|
636
|
+
"session.updated",
|
|
637
|
+
"session.deleted",
|
|
638
|
+
"session.error",
|
|
639
|
+
"session.compacted",
|
|
640
|
+
"file.edited",
|
|
641
|
+
];
|
|
642
|
+
if (significantEvents.includes(eventType)) {
|
|
643
|
+
console.log(`[opencode-immune] Event: ${eventType}`);
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
648
|
+
// HOOK 9: MULTI-CYCLE AUTOMATION (PRE_COMMIT + CYCLE_COMPLETE)
|
|
649
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
650
|
+
const MAX_CYCLES = 10;
|
|
651
|
+
const PRE_COMMIT_MARKER = "0-ULTRAWORK: PRE_COMMIT";
|
|
652
|
+
const CYCLE_COMPLETE_MARKER = "0-ULTRAWORK: CYCLE_COMPLETE";
|
|
653
|
+
const NEXT_TASK_PATTERN = /Next task:\s*(.+)/;
|
|
654
|
+
/**
|
|
655
|
+
* chat.message part: scans assistant messages for PRE_COMMIT and CYCLE_COMPLETE markers.
|
|
656
|
+
*
|
|
657
|
+
* PRE_COMMIT → executes /commit via client.session.command()
|
|
658
|
+
* CYCLE_COMPLETE → creates a new session and sends bootstrap prompt
|
|
659
|
+
*/
|
|
660
|
+
function createMultiCycleHandler(state) {
|
|
661
|
+
return async (input, output) => {
|
|
662
|
+
const sessionID = input.sessionID;
|
|
663
|
+
if (!isManagedUltraworkSession(state, sessionID))
|
|
664
|
+
return;
|
|
665
|
+
// Extract text content from parts
|
|
666
|
+
const parts = output.parts ?? [];
|
|
667
|
+
let messageContent = "";
|
|
668
|
+
for (const p of parts) {
|
|
669
|
+
if ("type" in p && p.type === "text" && "text" in p) {
|
|
670
|
+
messageContent += " " + p.text;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
messageContent = messageContent.trim();
|
|
674
|
+
if (!messageContent)
|
|
675
|
+
return;
|
|
676
|
+
// ── PRE_COMMIT: execute /commit ──
|
|
677
|
+
if (messageContent.includes(PRE_COMMIT_MARKER) && !state.commitPending) {
|
|
678
|
+
state.commitPending = true;
|
|
679
|
+
console.log("[opencode-immune] Multi-Cycle: PRE_COMMIT detected, executing /commit...");
|
|
680
|
+
// Small delay to let the message finish rendering
|
|
681
|
+
setTimeout(async () => {
|
|
682
|
+
try {
|
|
683
|
+
await state.input.client.session.command({
|
|
684
|
+
body: {
|
|
685
|
+
command: "/commit",
|
|
686
|
+
arguments: "",
|
|
687
|
+
},
|
|
688
|
+
path: { id: sessionID },
|
|
689
|
+
});
|
|
690
|
+
console.log("[opencode-immune] Multi-Cycle: /commit executed successfully.");
|
|
691
|
+
}
|
|
692
|
+
catch (err) {
|
|
693
|
+
console.error("[opencode-immune] Multi-Cycle: /commit failed:", err);
|
|
694
|
+
}
|
|
695
|
+
finally {
|
|
696
|
+
state.commitPending = false;
|
|
697
|
+
}
|
|
698
|
+
}, 2_000);
|
|
699
|
+
}
|
|
700
|
+
// ── CYCLE_COMPLETE: create new session ──
|
|
701
|
+
if (messageContent.includes(CYCLE_COMPLETE_MARKER)) {
|
|
702
|
+
state.cycleCount++;
|
|
703
|
+
if (state.cycleCount >= MAX_CYCLES) {
|
|
704
|
+
console.log(`[opencode-immune] Multi-Cycle: MAX_CYCLES (${MAX_CYCLES}) reached. Not creating new session.`);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
// Extract next task description
|
|
708
|
+
const taskMatch = messageContent.match(NEXT_TASK_PATTERN);
|
|
709
|
+
const nextTask = taskMatch?.[1]?.trim() ?? "Continue processing task backlog";
|
|
710
|
+
console.log(`[opencode-immune] Multi-Cycle: CYCLE_COMPLETE detected (cycle ${state.cycleCount}/${MAX_CYCLES}). ` +
|
|
711
|
+
`Creating new session for: "${nextTask}"`);
|
|
712
|
+
// Delay to let commit finish
|
|
713
|
+
setTimeout(async () => {
|
|
714
|
+
try {
|
|
715
|
+
// Create a new session
|
|
716
|
+
const createResult = await state.input.client.session.create({
|
|
717
|
+
body: {
|
|
718
|
+
title: `Ultrawork Cycle ${state.cycleCount + 1}`,
|
|
719
|
+
},
|
|
720
|
+
});
|
|
721
|
+
// Extract new session ID from the response
|
|
722
|
+
const newSessionData = createResult?.data;
|
|
723
|
+
const newSessionID = newSessionData?.id;
|
|
724
|
+
if (!newSessionID) {
|
|
725
|
+
console.error("[opencode-immune] Multi-Cycle: Failed to create new session — no session ID returned.");
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
console.log(`[opencode-immune] Multi-Cycle: New session created: ${newSessionID}`);
|
|
729
|
+
await addManagedUltraworkSession(state, newSessionID);
|
|
730
|
+
// Send bootstrap prompt to the new session
|
|
731
|
+
await state.input.client.session.promptAsync({
|
|
732
|
+
body: {
|
|
733
|
+
agent: ULTRAWORK_AGENT,
|
|
734
|
+
parts: [
|
|
735
|
+
{
|
|
736
|
+
type: "text",
|
|
737
|
+
text: `[AUTO-CYCLE] Continue processing task backlog. Read memory-bank/tasks.md and memory-bank/backlog.md, pick the next pending task, and run the full pipeline.`,
|
|
738
|
+
},
|
|
739
|
+
],
|
|
740
|
+
},
|
|
741
|
+
path: { id: newSessionID },
|
|
742
|
+
});
|
|
743
|
+
console.log(`[opencode-immune] Multi-Cycle: Bootstrap prompt sent to new session ${newSessionID}`);
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
console.error("[opencode-immune] Multi-Cycle: Failed to create new session or send prompt:", err);
|
|
747
|
+
}
|
|
748
|
+
}, 8_000); // 8s delay: let /commit finish first
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
753
|
+
// PLUGIN MODULE EXPORT
|
|
754
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
755
|
+
async function server(input) {
|
|
756
|
+
const state = createState(input);
|
|
757
|
+
await loadManagedSessionsCache(state);
|
|
758
|
+
console.log(`[opencode-immune] Plugin initialized. Directory: ${input.directory}`);
|
|
759
|
+
// Compose tool.execute.after handlers:
|
|
760
|
+
// Todo Enforcer (counter) + Ralph Loop (edit error) + Comment Checker
|
|
761
|
+
const toolAfterHandlers = [
|
|
762
|
+
createTodoEnforcerToolAfter(state),
|
|
763
|
+
createRalphLoopToolAfter(state),
|
|
764
|
+
createCommentCheckerToolAfter(state),
|
|
765
|
+
];
|
|
766
|
+
// Compose chat.message handlers:
|
|
767
|
+
// Todo Enforcer (check) + Keyword Detector + Context Monitor + Multi-Cycle
|
|
768
|
+
const chatMessageHandlers = [
|
|
769
|
+
createTodoEnforcerChatMessage(state),
|
|
770
|
+
createKeywordDetectorChatMessage(state),
|
|
771
|
+
createContextMonitorChatMessage(state),
|
|
772
|
+
createMultiCycleHandler(state),
|
|
773
|
+
];
|
|
774
|
+
return {
|
|
775
|
+
event: withErrorBoundary("event", createEventHandler(state)),
|
|
776
|
+
"chat.message": withErrorBoundary("chat.message", compositeChatMessage(chatMessageHandlers)),
|
|
777
|
+
"chat.params": withErrorBoundary("chat.params", createFallbackModels(state)),
|
|
778
|
+
"tool.execute.after": withErrorBoundary("tool.execute.after", compositeToolAfter(toolAfterHandlers)),
|
|
779
|
+
"experimental.chat.system.transform": withErrorBoundary("experimental.chat.system.transform", createSystemTransform(state)),
|
|
780
|
+
"experimental.session.compacting": withErrorBoundary("experimental.session.compacting", createCompactionHandler(state)),
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
exports.default = {
|
|
784
|
+
id: "opencode-immune",
|
|
785
|
+
server,
|
|
786
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-immune",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenCode plugin: session recovery, auto-retry, multi-cycle automation, context monitoring",
|
|
5
|
+
"exports": {
|
|
6
|
+
"./server": "./dist/plugin.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist/plugin.js"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "node ./node_modules/typescript/bin/tsc --project tsconfig.json",
|
|
13
|
+
"dev": "node ./node_modules/typescript/bin/tsc --project tsconfig.json --watch",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@opencode-ai/plugin": "^1.4.1"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^25.5.2",
|
|
21
|
+
"typescript": "^5.7.0"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"opencode",
|
|
25
|
+
"opencode-plugin",
|
|
26
|
+
"ai",
|
|
27
|
+
"agent",
|
|
28
|
+
"recovery",
|
|
29
|
+
"retry"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT"
|
|
32
|
+
}
|