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.
Files changed (2) hide show
  1. package/dist/plugin.js +786 -0
  2. 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
+ }