opencode-doom-loop-detector 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 metal-huang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,53 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # opencode-doom-loop-detector
2
+
3
+ Doom-loop detection for opencode v2, as an external plugin.
4
+
5
+ opencode v2 currently has no doom-loop detection: the `doom_loop` action is still accepted by the permission schema, but no code produces it. This plugin restores detection for anyone who installs it, with no changes to the opencode runner.
6
+
7
+ ## What it catches
8
+
9
+ Four complementary detectors:
10
+
11
+ 1. **Same-message guard** (the v1 behavior, unchanged): the same call emitted 3 times inside one assistant message asks once.
12
+ 2. **Signal A** — per-signature identical-output streak (warn 3, block 6). Counting is per signature, so periodic rotations and interleaved calls never reset it. A repeat only counts when the output is byte-identical, so a read-edit-test cycle with real progress never builds a streak.
13
+ 3. **Signal B** — line-level information novelty (warn 3/5, surgical ask at 8 only when that signature's A streak is ≥3, circuit breaker at 12). Outputs shorter than 3 lines are exempt.
14
+ 4. **Signal C** — per-signature failure-equivalence streak (warn 4, block 6). Volatile error tokens are normalized, and successes of other signatures do not reset it.
15
+
16
+ Every stop goes through the existing `doom_loop` permission ask, so intentional polling always has an escape hatch. A user message resets A/C as well as B and the breaker (per-turn scope), so a periodic legitimate re-check cannot accumulate into a block over days.
17
+
18
+ ## Install
19
+
20
+ Either use the CLI:
21
+
22
+ ```bash
23
+ opencode plugin add opencode-doom-loop-detector
24
+ ```
25
+
26
+ Or add the package to your `opencode.jsonc` directly (opencode installs missing npm plugins automatically at startup):
27
+
28
+ ```jsonc
29
+ {
30
+ "plugin": ["opencode-doom-loop-detector"]
31
+ }
32
+ ```
33
+
34
+ No configuration is required. The default thresholds are the ones above.
35
+
36
+ ## How it works
37
+
38
+ - The detector is a zero-dependency pure module plus a thin plugin on the public `@opencode/plugin` API.
39
+ - State is kept in memory with bounded maps (LRU caps) and a bounded line store, and is rebuilt from durable session history after a restart.
40
+ - Cost is about 0.1 ms per tool call on a real 199-call incident corpus, with no I/O and no runtime dependencies beyond the opencode plugin/schema packages and Effect.
41
+ - A denial of the circuit-breaker prompt makes the stop sticky (until the user speaks); a rejection with feedback (`Permission.CorrectedError`) returns the feedback to the model instead of stopping.
42
+
43
+ ## Verification
44
+
45
+ - This package: 45 tests pass (37 detector + 8 integration), `tsc --noEmit` clean.
46
+ - Upstream PR: 67 tests pass, typecheck clean; the review bot confirmed all four threads it raised are fixed.
47
+ - Upstream PR: https://github.com/anomalyco/opencode/pull/61328
48
+ - Incident analysis and call-by-call replays: https://metal-huang.github.io/opencode/doom-loop-report/
49
+ - Prior-art review of 14 harnesses: https://metal-huang.github.io/opencode/doom-loop-report/prior-art.html
50
+
51
+ ## License
52
+
53
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,449 @@
1
+ // src/index.ts
2
+ import { define } from "@opencode/plugin/effect/plugin";
3
+ import { Effect, Exit, Option, Scope, Stream } from "effect";
4
+ import { Tool } from "@opencode/schema/tool";
5
+
6
+ // src/detector.ts
7
+ var A_WARN = 3;
8
+ var A_BLOCK = 6;
9
+ var B_WARN_LOW = 3;
10
+ var B_WARN_HIGH = 5;
11
+ var B_BLOCK = 8;
12
+ var B_CIRCUIT = 12;
13
+ var B_MIN_LINES = 3;
14
+ var C_WARN = 4;
15
+ var C_BLOCK = 6;
16
+ var GUARD_TRIGGER = 3;
17
+ var UNOBSERVED_PROMPT = 6;
18
+ var LINE_LIMIT = 1e4;
19
+ var LINE_CAP = 1024;
20
+ var TIGHTENED_LINE_KEEP = 1000;
21
+ var SIGNATURE_LIMIT = 4096;
22
+ var FNV_OFFSET = 0xcbf29ce484222325n;
23
+ var FNV_PRIME = 0x100000001b3n;
24
+ var FNV_MASK = 0xffffffffffffffffn;
25
+ function fnv64(text) {
26
+ let hash = FNV_OFFSET;
27
+ for (let index = 0;index < text.length; index++) {
28
+ hash ^= BigInt(text.charCodeAt(index));
29
+ hash = hash * FNV_PRIME & FNV_MASK;
30
+ }
31
+ return hash.toString(16).padStart(16, "0");
32
+ }
33
+ function canonical(value) {
34
+ if (value === null || typeof value !== "object") {
35
+ const primitive = JSON.stringify(value === undefined ? null : value);
36
+ return primitive === undefined ? "null" : primitive;
37
+ }
38
+ if (Array.isArray(value))
39
+ return `[${value.map(canonical).join(",")}]`;
40
+ const record = value;
41
+ const body = Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",");
42
+ return `{${body}}`;
43
+ }
44
+ var signature = (tool, input) => fnv64(`${tool}:${canonical(input)}`);
45
+ var normalizeVolatile = (line) => line.replace(/[0-9a-f]{8,}/gi, "<id>").replace(/\b\d+(?:\.\d+)?(?:ns|us|ms|s|m|h)\b/g, "<duration>").replace(/\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b/g, "<timestamp>").replace(/\b\d+(?:\.\d+)?\b/g, "<num>");
46
+ var SEPARATOR = /^[-=_*~ ]{3,}$/;
47
+ var contentLines = (text) => text.split(`
48
+ `).map((line) => line.trim()).filter((line) => line.length > 0 && !SEPARATOR.test(line)).map((line) => normalizeVolatile(line).slice(0, LINE_CAP));
49
+ var stringify = (value) => {
50
+ if (typeof value === "string")
51
+ return value;
52
+ try {
53
+ return JSON.stringify(value) ?? String(value);
54
+ } catch {
55
+ return String(value);
56
+ }
57
+ };
58
+ function outputText(content, output) {
59
+ if (typeof content === "string")
60
+ return content;
61
+ if (content !== undefined && content.length > 0)
62
+ return content.flatMap((item) => item.type === "text" && typeof item.text === "string" ? [item.text] : []).join(`
63
+ `);
64
+ return stringify(output);
65
+ }
66
+ var copy = {
67
+ warnA: (tool, streak) => `The ${tool} call with these exact arguments has returned byte-identical output ${streak} times in a row. It is not producing new information. Change approach: inspect the actual current state, or ask the user.`,
68
+ warnB3: `Your recent tool calls have produced no new information. Before making another call, write down what new information you expect the next call to reveal.`,
69
+ warnB5: `These repeated calls keep returning nothing new. Choose one: run a falsification check on your current hypothesis, ask the user for the missing input, or conclude based on the evidence you already have.`,
70
+ warnC: (tool, streak) => `The ${tool} tool has failed ${streak} times in a row with the same error. Stop retrying it unchanged. Inspect the target or ask the user before trying again.`,
71
+ blockA: (tool, streak) => `This exact ${tool} call has returned identical output ${streak} times in a row and is blocked as a suspected repetition loop. If this repetition is intentional (for example user-requested polling), approve the doom_loop permission to continue.`,
72
+ blockB: `Recent calls keep producing nothing new, and this exact call has already returned identical output repeatedly. It is blocked as suspected non-progress. Run a falsification check, ask the user for missing input, or conclude with the evidence at hand.`,
73
+ blockC: (tool, streak) => `The ${tool} tool has failed ${streak} times in a row with the same error and is blocked as a suspected retry loop. Inspect the target state or ask the user before trying again.`,
74
+ circuit: `Twelve recent tool calls in a row produced no new information. Stop making tool calls entirely. Summarize the current state, the hypotheses you have ruled out, and what input you need from the user, in plain text.`,
75
+ guard: (count, tool) => `This exact ${tool} call has already been emitted ${count} times within the current assistant message. If the same invocation is repeating without progress, stop and report to the user instead.`,
76
+ guardMerged: (count, tool, aStreak) => `This exact ${tool} call has been emitted ${count} times within the current assistant message and returned identical output ${aStreak} times before. Stop repeating it and report to the user instead.`,
77
+ unobserved: `This call's progress cannot be observed from the session output (for example a background shell). If you are running it in a loop: write progress to a file and report from it, report the current status directly to the user, or stop and summarize.`
78
+ };
79
+ var resourceSuffix = { guard: ":repeat", unobserved: ":unobserved", a: "", b: "", c: "" };
80
+ var VALIDATION_PREFIX = "Invalid arguments for tool";
81
+ function injectedWarnings(tool) {
82
+ const a = copy.warnA(tool, A_WARN);
83
+ return [a, copy.warnB3, copy.warnB5, `${a}
84
+
85
+ ${copy.warnB3}`, `${a}
86
+
87
+ ${copy.warnB5}`];
88
+ }
89
+ var injectedFailureSuffix = (tool) => `
90
+
91
+ ${copy.warnC(tool, C_WARN)}`;
92
+ var blockedMetadata = { doom_loop: "blocked" };
93
+ var hasBlockedMetadata = (metadata) => metadata?.doom_loop === blockedMetadata.doom_loop;
94
+ function state() {
95
+ return {
96
+ signatures: new Map,
97
+ lines: { set: new Set, order: [] },
98
+ bStreak: 0,
99
+ failures: new Map,
100
+ emitted: { messageID: "", counts: new Map },
101
+ unobserved: new Map,
102
+ highTierPrompted: false,
103
+ circuitBroken: false
104
+ };
105
+ }
106
+ function beforeCall(current, call) {
107
+ if (current.circuitBroken)
108
+ return { kind: "circuit", message: copy.circuit, sticky: true };
109
+ if (current.bStreak >= B_CIRCUIT)
110
+ return { kind: "circuit", message: copy.circuit, sticky: false };
111
+ if (current.emitted.messageID !== call.messageID) {
112
+ current.emitted = { messageID: call.messageID, counts: new Map };
113
+ }
114
+ const emitted = (current.emitted.counts.get(call.sigHash) ?? 0) + 1;
115
+ current.emitted.counts.set(call.sigHash, emitted);
116
+ if (emitted === GUARD_TRIGGER) {
117
+ const entry = current.signatures.get(call.sigHash);
118
+ const message = entry !== undefined && entry.streak >= A_WARN ? copy.guardMerged(emitted, call.tool, entry.streak) : copy.guard(emitted, call.tool);
119
+ return { kind: "assert", signal: "guard", message };
120
+ }
121
+ const unobserved = current.unobserved.get(call.sigHash) ?? 0;
122
+ if (unobserved >= UNOBSERVED_PROMPT && !current.highTierPrompted) {
123
+ current.highTierPrompted = true;
124
+ return { kind: "assert", signal: "unobserved", message: copy.unobserved };
125
+ }
126
+ const entry = current.signatures.get(call.sigHash);
127
+ if (entry !== undefined && entry.streak >= A_BLOCK) {
128
+ return { kind: "assert", signal: "a", message: copy.blockA(call.tool, entry.streak) };
129
+ }
130
+ if (current.bStreak >= B_BLOCK && entry !== undefined && entry.streak >= A_WARN) {
131
+ return { kind: "assert", signal: "b", message: copy.blockB };
132
+ }
133
+ const failure = current.failures.get(call.sigHash);
134
+ if (failure !== undefined && failure.streak >= C_BLOCK) {
135
+ return { kind: "assert", signal: "c", message: copy.blockC(call.tool, failure.streak) };
136
+ }
137
+ return { kind: "pass" };
138
+ }
139
+ function lruSet(map, key, value) {
140
+ map.delete(key);
141
+ map.set(key, value);
142
+ if (map.size <= SIGNATURE_LIMIT)
143
+ return;
144
+ const oldest = map.keys().next().value;
145
+ if (oldest !== undefined)
146
+ map.delete(oldest);
147
+ }
148
+ function recordCompleted(current, call) {
149
+ current.failures.delete(call.sigHash);
150
+ const outputHash = fnv64(call.text);
151
+ const previous = current.signatures.get(call.sigHash);
152
+ const entry = previous !== undefined && previous.lastOutputHash === outputHash ? { lastOutputHash: outputHash, streak: previous.streak + 1 } : { lastOutputHash: outputHash, streak: 1 };
153
+ lruSet(current.signatures, call.sigHash, entry);
154
+ const warnings = [];
155
+ if (entry.streak === A_WARN)
156
+ warnings.push(copy.warnA(call.tool, entry.streak));
157
+ const lines = contentLines(call.text);
158
+ if (lines.length >= B_MIN_LINES) {
159
+ const distinct = [...new Set(lines)];
160
+ let novel = 0;
161
+ for (const line of distinct) {
162
+ if (!current.lines.set.has(line))
163
+ novel++;
164
+ }
165
+ const productive = novel >= 2 || novel / lines.length >= 0.5;
166
+ if (productive) {
167
+ current.bStreak = 0;
168
+ } else {
169
+ current.bStreak++;
170
+ if (current.bStreak === B_WARN_LOW)
171
+ warnings.push(copy.warnB3);
172
+ if (current.bStreak === B_WARN_HIGH)
173
+ warnings.push(copy.warnB5);
174
+ }
175
+ for (const line of distinct)
176
+ addLine(current, line);
177
+ }
178
+ return warnings.length === 0 ? undefined : warnings.join(`
179
+
180
+ `);
181
+ }
182
+ function addLine(current, line) {
183
+ if (current.lines.set.has(line))
184
+ return;
185
+ current.lines.set.add(line);
186
+ current.lines.order.push(line);
187
+ if (current.lines.order.length > LINE_LIMIT) {
188
+ const evicted = current.lines.order.splice(0, LINE_LIMIT / 2);
189
+ for (const old of evicted)
190
+ current.lines.set.delete(old);
191
+ }
192
+ }
193
+ function recordFailed(current, call) {
194
+ if (call.validation)
195
+ return;
196
+ const failureHash = fnv64(normalizeVolatile(call.errorText));
197
+ const previous = current.failures.get(call.sigHash);
198
+ const entry = previous !== undefined && previous.lastFailureHash === failureHash ? { lastFailureHash: failureHash, streak: previous.streak + 1 } : { lastFailureHash: failureHash, streak: 1 };
199
+ lruSet(current.failures, call.sigHash, entry);
200
+ if (entry.streak === C_WARN)
201
+ return copy.warnC(call.tool, entry.streak);
202
+ return;
203
+ }
204
+ function recordUnobserved(current, sigHash) {
205
+ lruSet(current.unobserved, sigHash, (current.unobserved.get(sigHash) ?? 0) + 1);
206
+ }
207
+ function compacted(current) {
208
+ for (const entry of current.signatures.values())
209
+ entry.streak = 0;
210
+ current.lines.set.clear();
211
+ current.lines.order = [];
212
+ current.bStreak = 0;
213
+ }
214
+ function userMessage(current) {
215
+ current.signatures.clear();
216
+ current.failures.clear();
217
+ current.bStreak = 0;
218
+ current.circuitBroken = false;
219
+ }
220
+ function filesChanged(current) {
221
+ current.bStreak = 0;
222
+ }
223
+ function tighten(current) {
224
+ if (current.lines.order.length <= TIGHTENED_LINE_KEEP)
225
+ return;
226
+ const evicted = current.lines.order.splice(0, current.lines.order.length - TIGHTENED_LINE_KEEP);
227
+ for (const old of evicted)
228
+ current.lines.set.delete(old);
229
+ }
230
+ // src/fold.ts
231
+ var UNEXECUTED_ERROR_TYPES = new Set(["aborted", "tool.input-json", "tool.input-incomplete"]);
232
+ function fold(state, messages) {
233
+ for (const message of messages) {
234
+ if (message.type === "user") {
235
+ userMessage(state);
236
+ continue;
237
+ }
238
+ if (message.type !== "assistant")
239
+ continue;
240
+ for (const part of message.content) {
241
+ if (part.type !== "tool" || part.name === "execute")
242
+ continue;
243
+ if (part.state.status === "error") {
244
+ if (hasBlockedMetadata(part.state.metadata) || UNEXECUTED_ERROR_TYPES.has(part.state.error.type))
245
+ continue;
246
+ const suffix = injectedFailureSuffix(part.name);
247
+ const message = part.state.error.message;
248
+ const errorText = message.endsWith(suffix) ? message.slice(0, -suffix.length) : message;
249
+ recordFailed(state, {
250
+ tool: part.name,
251
+ sigHash: signature(part.name, part.state.input),
252
+ errorText,
253
+ validation: errorText.startsWith(VALIDATION_PREFIX)
254
+ });
255
+ continue;
256
+ }
257
+ if (part.state.status !== "completed")
258
+ continue;
259
+ const sigHash = signature(part.name, part.state.input);
260
+ if (part.name === "question" || part.name === "shell" && part.state.metadata?.status === "running") {
261
+ recordUnobserved(state, sigHash);
262
+ continue;
263
+ }
264
+ const first = part.state.content[0];
265
+ const content = first !== undefined && first.type === "text" && injectedWarnings(part.name).includes(first.text) ? part.state.content.slice(1) : part.state.content;
266
+ recordCompleted(state, {
267
+ tool: part.name,
268
+ sigHash,
269
+ text: outputText(content)
270
+ });
271
+ }
272
+ }
273
+ tighten(state);
274
+ state.bStreak = 0;
275
+ }
276
+
277
+ // src/index.ts
278
+ var DEFAULT_MAX_TRACKED_SESSIONS = 64;
279
+ var circuitKey = (sessionID) => `circuit:${sessionID}`;
280
+ function makePlugin(options) {
281
+ const cap = options?.maxTrackedSessions ?? DEFAULT_MAX_TRACKED_SESSIONS;
282
+ return define({
283
+ id: "opencode.doom.loop",
284
+ effect: Effect.fn(function* (ctx) {
285
+ const scope = yield* Scope.Scope;
286
+ const sessions = new Map;
287
+ const folded = (sessionID) => Effect.runPromise(Effect.gen(function* () {
288
+ const entry = { state: state() };
289
+ const messages = yield* ctx.session.context({ sessionID });
290
+ fold(entry.state, messages);
291
+ if ((yield* ctx.storage.get(circuitKey(sessionID))) === true)
292
+ entry.state.circuitBroken = true;
293
+ return entry;
294
+ })).catch(() => ({ state: state() }));
295
+ const session = (sessionID) => {
296
+ const existing = sessions.get(sessionID);
297
+ if (existing) {
298
+ sessions.delete(sessionID);
299
+ sessions.set(sessionID, existing);
300
+ return existing;
301
+ }
302
+ const created = folded(sessionID);
303
+ sessions.set(sessionID, created);
304
+ while (sessions.size > cap) {
305
+ const oldest = sessions.keys().next().value;
306
+ if (oldest === undefined)
307
+ break;
308
+ sessions.delete(oldest);
309
+ }
310
+ return created;
311
+ };
312
+ const mutate = (sessionID, fn) => {
313
+ const existing = sessions.get(sessionID);
314
+ if (existing)
315
+ sessions.set(sessionID, existing.then((entry) => {
316
+ fn(entry.state);
317
+ return entry;
318
+ }));
319
+ };
320
+ yield* ctx.tool.hook("execute.before", (event) => Effect.gen(function* () {
321
+ if (event.tool === "execute")
322
+ return;
323
+ const current = yield* Effect.promise(() => session(event.sessionID));
324
+ const sigHash = signature(event.tool, event.input);
325
+ const verdict = beforeCall(current.state, {
326
+ tool: event.tool,
327
+ sigHash,
328
+ messageID: event.messageID
329
+ });
330
+ if (verdict.kind === "pass")
331
+ return;
332
+ if (verdict.kind === "circuit") {
333
+ if (verdict.sticky) {
334
+ yield* ctx.session.interrupt({ sessionID: event.sessionID }).pipe(Effect.ignore, Effect.forkIn(scope));
335
+ return yield* new Tool.Error({ message: verdict.message, metadata: blockedMetadata });
336
+ }
337
+ const resource = `${event.tool}:${sigHash}${resourceSuffix.b}`;
338
+ const outcome = yield* Effect.exit(ctx.permission.assert({
339
+ sessionID: event.sessionID,
340
+ agent: event.agent,
341
+ action: "doom_loop",
342
+ resources: [resource],
343
+ save: [resource],
344
+ source: { type: "tool", messageID: event.messageID, id: event.id }
345
+ }));
346
+ if (Exit.isSuccess(outcome)) {
347
+ current.state.bStreak = 0;
348
+ return;
349
+ }
350
+ const error = Option.getOrUndefined(Exit.findErrorOption(outcome));
351
+ const tagOf = (value) => typeof value === "object" && value !== null && ("_tag" in value) ? value._tag : undefined;
352
+ const corrected = tagOf(error) === "Permission.CorrectedError" || error instanceof Tool.Error && tagOf(error.error) === "Permission.CorrectedError";
353
+ if (!corrected) {
354
+ yield* ctx.storage.set(circuitKey(event.sessionID), true).pipe(Effect.ignore);
355
+ current.state.circuitBroken = true;
356
+ yield* ctx.session.interrupt({ sessionID: event.sessionID }).pipe(Effect.ignore, Effect.forkIn(scope));
357
+ }
358
+ return yield* new Tool.Error({
359
+ message: `${verdict.message}
360
+
361
+ ${error?.message ?? "rejected"}`,
362
+ metadata: blockedMetadata
363
+ });
364
+ }
365
+ const resource = `${event.tool}:${sigHash}${resourceSuffix[verdict.signal]}`;
366
+ return yield* ctx.permission.assert({
367
+ sessionID: event.sessionID,
368
+ agent: event.agent,
369
+ action: "doom_loop",
370
+ resources: [resource],
371
+ save: [resource],
372
+ source: { type: "tool", messageID: event.messageID, id: event.id }
373
+ }).pipe(Effect.mapError((error) => new Tool.Error({
374
+ message: `${verdict.message}
375
+
376
+ ${error.message}`,
377
+ metadata: blockedMetadata
378
+ })));
379
+ }));
380
+ yield* ctx.tool.hook("execute.after", (event) => Effect.gen(function* () {
381
+ if (event.tool === "execute")
382
+ return;
383
+ const current = yield* Effect.promise(() => session(event.sessionID));
384
+ const sigHash = signature(event.tool, event.input);
385
+ if (event.status === "error") {
386
+ const warning = recordFailed(current.state, {
387
+ tool: event.tool,
388
+ sigHash,
389
+ errorText: event.error.message,
390
+ validation: event.error.message.startsWith(VALIDATION_PREFIX)
391
+ });
392
+ if (warning !== undefined)
393
+ event.error.message = `${event.error.message}
394
+
395
+ ${warning}`;
396
+ return;
397
+ }
398
+ const unobserved = event.tool === "question" || event.tool === "shell" && event.result.metadata !== undefined && event.result.metadata.status === "running";
399
+ if (unobserved) {
400
+ recordUnobserved(current.state, sigHash);
401
+ return;
402
+ }
403
+ const text = outputText(event.result.content, event.result.output);
404
+ const warning = recordCompleted(current.state, { tool: event.tool, sigHash, text });
405
+ if (warning === undefined)
406
+ return;
407
+ const existing = event.result.content;
408
+ event.result = {
409
+ ...event.result,
410
+ content: [
411
+ { type: "text", text: warning },
412
+ ...typeof existing === "string" ? [{ type: "text", text: existing }] : existing ?? []
413
+ ]
414
+ };
415
+ }));
416
+ yield* ctx.event.subscribe().pipe(Stream.filter((event) => event.type === "session.deleted" || event.type === "session.inbox.enqueued" || event.type === "session.step.ended" || event.type === "session.compaction.ended" || event.type === "session.execution.succeeded" || event.type === "session.execution.failed" || event.type === "session.execution.interrupted"), Stream.runForEach((event) => Effect.gen(function* () {
417
+ const sessionID = event.data.sessionID;
418
+ if (event.type === "session.deleted") {
419
+ sessions.delete(sessionID);
420
+ yield* ctx.storage.remove(circuitKey(sessionID)).pipe(Effect.ignore);
421
+ return;
422
+ }
423
+ if (event.type === "session.inbox.enqueued") {
424
+ if (event.data.item.type !== "user")
425
+ return;
426
+ yield* ctx.storage.remove(circuitKey(sessionID)).pipe(Effect.ignore);
427
+ mutate(sessionID, userMessage);
428
+ return;
429
+ }
430
+ if (event.type === "session.step.ended") {
431
+ if (event.data.files === undefined || event.data.files.length === 0)
432
+ return;
433
+ mutate(sessionID, filesChanged);
434
+ return;
435
+ }
436
+ if (event.type === "session.compaction.ended") {
437
+ mutate(sessionID, compacted);
438
+ return;
439
+ }
440
+ mutate(sessionID, tighten);
441
+ })), Effect.forkScoped({ startImmediately: true }));
442
+ })
443
+ });
444
+ }
445
+ var Plugin = makePlugin();
446
+ export {
447
+ Plugin,
448
+ makePlugin
449
+ };
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "opencode-doom-loop-detector",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Doom-loop detection for opencode v2: catches repeated tool-call loops (consecutive, periodic rotation, failure streaks) and stops them through the existing doom_loop permission ask.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": "github:metal-huang/opencode-doom-loop-detector",
8
+ "main": "./dist/index.js",
9
+ "exports": {
10
+ ".": "./dist/index.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "src",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "build": "bun build src/index.ts --outfile dist/index.js --external @opencode/plugin --external @opencode/schema --external effect",
20
+ "typecheck": "tsc --noEmit",
21
+ "test": "bun test"
22
+ },
23
+ "peerDependencies": {
24
+ "@opencode/plugin": "^2.2.5",
25
+ "@opencode/schema": "^2.2.5",
26
+ "effect": "4.0.6"
27
+ },
28
+ "devDependencies": {
29
+ "@opencode/plugin": "^2.2.5",
30
+ "@opencode/schema": "^2.2.5",
31
+ "effect": "4.0.6",
32
+ "typescript": "^5.9.0"
33
+ },
34
+ "keywords": [
35
+ "opencode",
36
+ "opencode-plugin",
37
+ "plugin",
38
+ "doom-loop",
39
+ "loop-detection",
40
+ "agent"
41
+ ]
42
+ }
@@ -0,0 +1,382 @@
1
+ export * as DoomLoopDetector from "./detector.js"
2
+
3
+ // Pure doom-loop detection core: no Effect, no opencode imports, portable across hosts.
4
+ // Four detectors:
5
+ // - Signal A: per-signature (tool + canonical input) identical-output streak. Warn 3, block 6.
6
+ // - Signal B: global line-level information novelty. Warn 3/5, surgical block 8 (needs A streak >= 3
7
+ // as corroboration), circuit breaker 12 (hard stop until the user speaks).
8
+ // - Signal C: per-tool failure-equivalence streak (volatile tokens normalized before hashing).
9
+ // Warn 4, block 6. Interleaved successes of other tools do not reset it; a success of the same
10
+ // tool does.
11
+ // - Same-message repeat guard: identical (tool, input) emitted 3 times within one assistant message
12
+ // asks exactly once (== 3, not >= 3, so later high-tier prompts cannot be starved by it).
13
+ // Thresholds are accounted in after hooks and read in before hooks: "block at N" means the call
14
+ // emitted after the Nth accounting (the N+1th emission) is the one challenged in before.
15
+
16
+ const A_WARN = 3
17
+ const A_BLOCK = 6
18
+ const B_WARN_LOW = 3
19
+ const B_WARN_HIGH = 5
20
+ const B_BLOCK = 8
21
+ const B_CIRCUIT = 12
22
+ const B_MIN_LINES = 3
23
+ const C_WARN = 4
24
+ const C_BLOCK = 6
25
+ const GUARD_TRIGGER = 3
26
+ const UNOBSERVED_PROMPT = 6
27
+ const LINE_LIMIT = 10_000
28
+ const LINE_CAP = 1024
29
+ const TIGHTENED_LINE_KEEP = 1_000
30
+ // Per-session cap for the signature (A) and unobserved maps, each bounded independently. An
31
+ // entry is ~100 bytes, so a session worst-cases at ~400KB per map.
32
+ const SIGNATURE_LIMIT = 4_096
33
+
34
+ const FNV_OFFSET = 0xcbf29ce484222325n
35
+ const FNV_PRIME = 0x100000001b3n
36
+ const FNV_MASK = 0xffffffffffffffffn
37
+
38
+ export function fnv64(text: string): string {
39
+ let hash = FNV_OFFSET
40
+ for (let index = 0; index < text.length; index++) {
41
+ hash ^= BigInt(text.charCodeAt(index))
42
+ hash = (hash * FNV_PRIME) & FNV_MASK
43
+ }
44
+ return hash.toString(16).padStart(16, "0")
45
+ }
46
+
47
+ // Canonical JSON with recursively sorted keys so semantically identical inputs hash identically
48
+ // regardless of key order. Bash AST normalization is deliberately omitted (decided): `ls` vs
49
+ // `ls -la` are distinct signatures and escape A, which signal B covers instead.
50
+ export function canonical(value: unknown): string {
51
+ if (value === null || typeof value !== "object") {
52
+ const primitive = JSON.stringify(value === undefined ? null : value)
53
+ return primitive === undefined ? "null" : primitive
54
+ }
55
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
56
+ const record = value as Record<string, unknown>
57
+ const body = Object.keys(record)
58
+ .sort()
59
+ .map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`)
60
+ .join(",")
61
+ return `{${body}}`
62
+ }
63
+
64
+ export const signature = (tool: string, input: unknown) => fnv64(`${tool}:${canonical(input)}`)
65
+
66
+ // Volatile tokens (ids, durations, timestamps, plain numbers) collapse to placeholders so that
67
+ // "in 0.03s" and "in 0.05s" normalize to the same line and the same failure identity.
68
+ const normalizeVolatile = (line: string): string =>
69
+ line
70
+ .replace(/[0-9a-f]{8,}/gi, "<id>")
71
+ .replace(/\b\d+(?:\.\d+)?(?:ns|us|ms|s|m|h)\b/g, "<duration>")
72
+ .replace(/\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b/g, "<timestamp>")
73
+ .replace(/\b\d+(?:\.\d+)?\b/g, "<num>")
74
+
75
+ const SEPARATOR = /^[-=_*~ ]{3,}$/
76
+
77
+ // Lines enter the global novelty store normalized and capped at 1KB each (memory bound).
78
+ const contentLines = (text: string): string[] =>
79
+ text
80
+ .split("\n")
81
+ .map((line) => line.trim())
82
+ .filter((line) => line.length > 0 && !SEPARATOR.test(line))
83
+ .map((line) => normalizeVolatile(line).slice(0, LINE_CAP))
84
+
85
+ const stringify = (value: unknown): string => {
86
+ if (typeof value === "string") return value
87
+ try {
88
+ return JSON.stringify(value) ?? String(value)
89
+ } catch {
90
+ return String(value)
91
+ }
92
+ }
93
+
94
+ // Mirrors the runtime content normalization: a tool result carries either string content, content
95
+ // parts, or (for output-only tools) falls back to the stringified machine output. Hashing must use
96
+ // this normalized view so output-only tools do not all hash as one constant empty string.
97
+ export function outputText(
98
+ content: string | ReadonlyArray<{ readonly type: string; readonly text?: string }> | undefined,
99
+ output?: unknown,
100
+ ): string {
101
+ if (typeof content === "string") return content
102
+ if (content !== undefined && content.length > 0)
103
+ return content
104
+ .flatMap((item) => (item.type === "text" && typeof item.text === "string" ? [item.text] : []))
105
+ .join("\n")
106
+ return stringify(output)
107
+ }
108
+
109
+ export const copy = {
110
+ warnA: (tool: string, streak: number): string =>
111
+ `The ${tool} call with these exact arguments has returned byte-identical output ${streak} times in a row. It is not producing new information. Change approach: inspect the actual current state, or ask the user.`,
112
+ warnB3: `Your recent tool calls have produced no new information. Before making another call, write down what new information you expect the next call to reveal.`,
113
+ warnB5: `These repeated calls keep returning nothing new. Choose one: run a falsification check on your current hypothesis, ask the user for the missing input, or conclude based on the evidence you already have.`,
114
+ warnC: (tool: string, streak: number): string =>
115
+ `The ${tool} tool has failed ${streak} times in a row with the same error. Stop retrying it unchanged. Inspect the target or ask the user before trying again.`,
116
+ blockA: (tool: string, streak: number): string =>
117
+ `This exact ${tool} call has returned identical output ${streak} times in a row and is blocked as a suspected repetition loop. If this repetition is intentional (for example user-requested polling), approve the doom_loop permission to continue.`,
118
+ blockB: `Recent calls keep producing nothing new, and this exact call has already returned identical output repeatedly. It is blocked as suspected non-progress. Run a falsification check, ask the user for missing input, or conclude with the evidence at hand.`,
119
+ blockC: (tool: string, streak: number): string =>
120
+ `The ${tool} tool has failed ${streak} times in a row with the same error and is blocked as a suspected retry loop. Inspect the target state or ask the user before trying again.`,
121
+ circuit: `Twelve recent tool calls in a row produced no new information. Stop making tool calls entirely. Summarize the current state, the hypotheses you have ruled out, and what input you need from the user, in plain text.`,
122
+ guard: (count: number, tool: string): string =>
123
+ `This exact ${tool} call has already been emitted ${count} times within the current assistant message. If the same invocation is repeating without progress, stop and report to the user instead.`,
124
+ guardMerged: (count: number, tool: string, aStreak: number): string =>
125
+ `This exact ${tool} call has been emitted ${count} times within the current assistant message and returned identical output ${aStreak} times before. Stop repeating it and report to the user instead.`,
126
+ unobserved: `This call's progress cannot be observed from the session output (for example a background shell). If you are running it in a loop: write progress to a file and report from it, report the current status directly to the user, or stop and summarize.`,
127
+ } as const
128
+
129
+ // Resource/save strings are `${tool}:${sigHash}` with a per-signal suffix so an "always" answer on
130
+ // one prompt never silently exempts another signal's prompt for the same signature.
131
+ export const resourceSuffix = { guard: ":repeat", unobserved: ":unobserved", a: "", b: "", c: "" } as const
132
+
133
+ // Host-specific strings that mark argument-validation rejections (the tool never executed).
134
+ // Kept in one place so a host copy change is a one-line fix; an unmatched message is treated as a
135
+ // real failure (conservative: counts into C rather than silently exempting it).
136
+ export const VALIDATION_PREFIX = "Invalid arguments for tool"
137
+
138
+ // Texts the live after hook can inject into a persisted result: warnings prepend to a completed
139
+ // result's content and append to a failed call's error message. They fire only at fixed streaks,
140
+ // so the complete set is enumerable. The fold strips them to hash the same text the live path
141
+ // accounted — live hashes before injecting.
142
+ export function injectedWarnings(tool: string): ReadonlyArray<string> {
143
+ const a = copy.warnA(tool, A_WARN)
144
+ return [a, copy.warnB3, copy.warnB5, `${a}\n\n${copy.warnB3}`, `${a}\n\n${copy.warnB5}`]
145
+ }
146
+
147
+ export const injectedFailureSuffix = (tool: string): string => `\n\n${copy.warnC(tool, C_WARN)}`
148
+
149
+ // Marker the plugin attaches to Tool.Errors raised by its before hook. It persists on the error
150
+ // part (runner failTool -> Tool.Failed metadata), letting the fold tell a blocked call — never
151
+ // executed, never accounted live — apart from a real execution failure.
152
+ export const blockedMetadata = { doom_loop: "blocked" } as const
153
+
154
+ export const hasBlockedMetadata = (metadata: Record<string, unknown> | undefined): boolean =>
155
+ metadata?.doom_loop === blockedMetadata.doom_loop
156
+
157
+ export type Verdict =
158
+ | { readonly kind: "pass" }
159
+ | { readonly kind: "circuit"; readonly message: string; readonly sticky: boolean }
160
+ | {
161
+ readonly kind: "assert"
162
+ readonly signal: keyof typeof resourceSuffix
163
+ readonly message: string
164
+ }
165
+
166
+ export interface SignatureEntry {
167
+ lastOutputHash: string
168
+ streak: number
169
+ }
170
+
171
+ export interface FailureEntry {
172
+ lastFailureHash: string
173
+ streak: number
174
+ }
175
+
176
+ export interface SessionState {
177
+ signatures: Map<string, SignatureEntry>
178
+ lines: { set: Set<string>; order: string[] }
179
+ bStreak: number
180
+ failures: Map<string, FailureEntry>
181
+ // Only the current message's bucket is retained; a new messageID drops the old one.
182
+ emitted: { messageID: string; counts: Map<string, number> }
183
+ unobserved: Map<string, number>
184
+ highTierPrompted: boolean
185
+ circuitBroken: boolean
186
+ }
187
+
188
+ export function state(): SessionState {
189
+ return {
190
+ signatures: new Map(),
191
+ lines: { set: new Set(), order: [] },
192
+ bStreak: 0,
193
+ failures: new Map(),
194
+ emitted: { messageID: "", counts: new Map() },
195
+ unobserved: new Map(),
196
+ highTierPrompted: false,
197
+ circuitBroken: false,
198
+ }
199
+ }
200
+
201
+ export interface Call {
202
+ readonly tool: string
203
+ readonly sigHash: string
204
+ readonly messageID: string
205
+ }
206
+
207
+ // Short-circuit order: circuit breaker -> same-message guard (==3, one-shot) -> unobserved-layer
208
+ // one-time prompt -> A >= 6 -> B >= 8 surgical (requires A streak >= 3 corroboration) -> C >= 6.
209
+ // The first hit is the only action for this call. A circuit hit is a permission ask like every
210
+ // other signal: allow resets B and lets the run continue; a denial makes it sticky (hard stop).
211
+ export function beforeCall(current: SessionState, call: Call): Verdict {
212
+ if (current.circuitBroken) return { kind: "circuit", message: copy.circuit, sticky: true }
213
+ if (current.bStreak >= B_CIRCUIT) return { kind: "circuit", message: copy.circuit, sticky: false }
214
+ if (current.emitted.messageID !== call.messageID) {
215
+ current.emitted = { messageID: call.messageID, counts: new Map() }
216
+ }
217
+ const emitted = (current.emitted.counts.get(call.sigHash) ?? 0) + 1
218
+ current.emitted.counts.set(call.sigHash, emitted)
219
+ if (emitted === GUARD_TRIGGER) {
220
+ const entry = current.signatures.get(call.sigHash)
221
+ const message =
222
+ entry !== undefined && entry.streak >= A_WARN
223
+ ? copy.guardMerged(emitted, call.tool, entry.streak)
224
+ : copy.guard(emitted, call.tool)
225
+ return { kind: "assert", signal: "guard", message }
226
+ }
227
+ const unobserved = current.unobserved.get(call.sigHash) ?? 0
228
+ if (unobserved >= UNOBSERVED_PROMPT && !current.highTierPrompted) {
229
+ current.highTierPrompted = true
230
+ return { kind: "assert", signal: "unobserved", message: copy.unobserved }
231
+ }
232
+ const entry = current.signatures.get(call.sigHash)
233
+ if (entry !== undefined && entry.streak >= A_BLOCK) {
234
+ return { kind: "assert", signal: "a", message: copy.blockA(call.tool, entry.streak) }
235
+ }
236
+ if (current.bStreak >= B_BLOCK && entry !== undefined && entry.streak >= A_WARN) {
237
+ return { kind: "assert", signal: "b", message: copy.blockB }
238
+ }
239
+ const failure = current.failures.get(call.sigHash)
240
+ if (failure !== undefined && failure.streak >= C_BLOCK) {
241
+ return { kind: "assert", signal: "c", message: copy.blockC(call.tool, failure.streak) }
242
+ }
243
+ return { kind: "pass" }
244
+ }
245
+
246
+ export interface Completed {
247
+ readonly tool: string
248
+ readonly sigHash: string
249
+ readonly text: string
250
+ }
251
+
252
+ // LRU write for the signature-keyed maps: a write moves the key to the newest position, and the
253
+ // oldest entry is evicted past SIGNATURE_LIMIT. Eviction is indistinguishable from the
254
+ // output-changed branch — a recurred signature restarts at streak 1 — so the loss is
255
+ // miss-direction only and never a false block. A genuinely looping signature re-fires on every
256
+ // call and stays hot; only one not seen for 4096 distinct others is dropped.
257
+ function lruSet<V>(map: Map<string, V>, key: string, value: V) {
258
+ map.delete(key)
259
+ map.set(key, value)
260
+ if (map.size <= SIGNATURE_LIMIT) return
261
+ const oldest = map.keys().next().value
262
+ if (oldest !== undefined) map.delete(oldest)
263
+ }
264
+
265
+ // Accounts a successful call into A and B. Error events never reach this function: failures feed
266
+ // signal C only, so a run of failed calls cannot masquerade as a run of identical empty outputs.
267
+ // Returns warning text to append to the model-visible output, if any.
268
+ export function recordCompleted(current: SessionState, call: Completed): string | undefined {
269
+ // Only a success of the same signature proves the retry loop is over.
270
+ current.failures.delete(call.sigHash)
271
+
272
+ const outputHash = fnv64(call.text)
273
+ const previous = current.signatures.get(call.sigHash)
274
+ const entry: SignatureEntry =
275
+ previous !== undefined && previous.lastOutputHash === outputHash
276
+ ? { lastOutputHash: outputHash, streak: previous.streak + 1 }
277
+ : { lastOutputHash: outputHash, streak: 1 }
278
+ lruSet(current.signatures, call.sigHash, entry)
279
+
280
+ const warnings: string[] = []
281
+ if (entry.streak === A_WARN) warnings.push(copy.warnA(call.tool, entry.streak))
282
+
283
+ const lines = contentLines(call.text)
284
+ // Outputs shorter than 3 lines are exempt from B: one-line receipts (edit/write) never streak.
285
+ if (lines.length >= B_MIN_LINES) {
286
+ // Novelty counts distinct lines: one new line repeated k times inside a single output is still
287
+ // one new fact and must not inflate the count. The ratio keeps the total line count as its
288
+ // denominator — "1 new line out of 4" is a decorator variant, not productivity.
289
+ const distinct = [...new Set(lines)]
290
+ let novel = 0
291
+ for (const line of distinct) {
292
+ if (!current.lines.set.has(line)) novel++
293
+ }
294
+ const productive = novel >= 2 || novel / lines.length >= 0.5
295
+ if (productive) {
296
+ current.bStreak = 0
297
+ } else {
298
+ current.bStreak++
299
+ if (current.bStreak === B_WARN_LOW) warnings.push(copy.warnB3)
300
+ if (current.bStreak === B_WARN_HIGH) warnings.push(copy.warnB5)
301
+ }
302
+ for (const line of distinct) addLine(current, line)
303
+ }
304
+ return warnings.length === 0 ? undefined : warnings.join("\n\n")
305
+ }
306
+
307
+ function addLine(current: SessionState, line: string) {
308
+ if (current.lines.set.has(line)) return
309
+ current.lines.set.add(line)
310
+ current.lines.order.push(line)
311
+ // FIFO eviction of the oldest half at the 10k cap keeps the store bounded.
312
+ if (current.lines.order.length > LINE_LIMIT) {
313
+ const evicted = current.lines.order.splice(0, LINE_LIMIT / 2)
314
+ for (const old of evicted) current.lines.set.delete(old)
315
+ }
316
+ }
317
+
318
+ export interface Failed {
319
+ readonly tool: string
320
+ readonly sigHash: string
321
+ readonly errorText: string
322
+ // Argument-validation rejections that never executed do not count (OpenClaw argument-validation
323
+ // semantics); the model must be free to fix its arguments.
324
+ readonly validation: boolean
325
+ }
326
+
327
+ // Failure equivalence is keyed by signature (tool + canonical input), not by tool name alone:
328
+ // different commands of one tool failing with the same normalized error would otherwise block an
329
+ // unrelated call of that tool. A different error means progress and resets the streak. Interleaved
330
+ // successes of other tools deliberately do not reset it.
331
+ export function recordFailed(current: SessionState, call: Failed): string | undefined {
332
+ if (call.validation) return undefined
333
+ const failureHash = fnv64(normalizeVolatile(call.errorText))
334
+ const previous = current.failures.get(call.sigHash)
335
+ const entry: FailureEntry =
336
+ previous !== undefined && previous.lastFailureHash === failureHash
337
+ ? { lastFailureHash: failureHash, streak: previous.streak + 1 }
338
+ : { lastFailureHash: failureHash, streak: 1 }
339
+ lruSet(current.failures, call.sigHash, entry)
340
+ if (entry.streak === C_WARN) return copy.warnC(call.tool, entry.streak)
341
+ return undefined
342
+ }
343
+
344
+ // Unobserved layer (background shell, question): A/B/C abstain; only the accounting for the
345
+ // one-time high-tier prompt happens here.
346
+ export function recordUnobserved(current: SessionState, sigHash: string) {
347
+ lruSet(current.unobserved, sigHash, (current.unobserved.get(sigHash) ?? 0) + 1)
348
+ }
349
+
350
+ // Context discontinuity (compaction): A's per-signature streaks reset but keep their output hashes,
351
+ // B's line store and streak reset. C intentionally survives: failure equivalence does not depend on
352
+ // context content.
353
+ export function compacted(current: SessionState) {
354
+ for (const entry of current.signatures.values()) entry.streak = 0
355
+ current.lines.set.clear()
356
+ current.lines.order = []
357
+ current.bStreak = 0
358
+ }
359
+
360
+ // User message: the circuit breaker's defined escape hatch and the per-turn scope boundary. B and
361
+ // the breaker reset, and A/C counters reset too — a new user turn opens a fresh observation window,
362
+ // so a periodic legitimate re-check cannot accumulate into a block over days. A real loop
363
+ // re-accumulates within seconds after an interrupt, so detection is preserved.
364
+ export function userMessage(current: SessionState) {
365
+ current.signatures.clear()
366
+ current.failures.clear()
367
+ current.bStreak = 0
368
+ current.circuitBroken = false
369
+ }
370
+
371
+ // Files actually changed on disk: the world-observable escape for B.
372
+ export function filesChanged(current: SessionState) {
373
+ current.bStreak = 0
374
+ }
375
+
376
+ // Execution-end sweep (memory bound): tighten the line store of the session that just finished.
377
+ // A/C counters are preserved here — they only reset on a new user message (per-turn scoping).
378
+ export function tighten(current: SessionState) {
379
+ if (current.lines.order.length <= TIGHTENED_LINE_KEEP) return
380
+ const evicted = current.lines.order.splice(0, current.lines.order.length - TIGHTENED_LINE_KEEP)
381
+ for (const old of evicted) current.lines.set.delete(old)
382
+ }
package/src/fold.ts ADDED
@@ -0,0 +1,90 @@
1
+ export * as DoomLoopFold from "./fold.js"
2
+
3
+ import type { SessionMessage } from "@opencode/schema/session-message"
4
+ import { DoomLoopDetector } from "./detector.js"
5
+
6
+ // Rebuilds detector state from durable session history when a session is first touched after a
7
+ // restart. The history read is SessionHistory's "latest" view — it starts at the most recent
8
+ // completed compaction — and only the accounting half is replayed. Two calibrations keep the
9
+ // replay faithful to the live path:
10
+ //
11
+ // - Calls the before hook rejected DID persist (as error parts) but were never accounted live,
12
+ // because a rejected call never reaches execute.after (tool.ts runs before hooks first). The
13
+ // plugin marks its own rejections (blockedMetadata) and declines/interrupts/input failures carry
14
+ // non-execution error types; both are skipped here.
15
+ // - Warnings the after hook injected into results persist with them, while live hashes before
16
+ // injecting; they are stripped so streaks rebuild exactly.
17
+ //
18
+ // Declared losses: C's failure streaks from before the latest compaction are invisible (miss
19
+ // direction; the streak self-heals within a few calls); B restarts with a rebuilt line store but
20
+ // a zero streak — safe because B's surgical block still requires the corroborating A streak,
21
+ // which IS rebuilt; Code Mode inner calls are invisible (the outer "execute" part is skipped,
22
+ // mirroring the live exemption, but the real tool names it re-entered with are not in history).
23
+ // The circuit breaker cannot be folded from history; the plugin persists it separately.
24
+
25
+ // Error types a persisted error part can carry without the call ever having executed — the live
26
+ // after hook (and thus signal C) never saw them: declines and interruptions ("aborted" —
27
+ // runner step.ts declines, llm.ts settleStaleToolCalls) and input failures that never reached
28
+ // the tool ("tool.input-json" / "tool.input-incomplete" — runner INPUT_FAILURES).
29
+ const UNEXECUTED_ERROR_TYPES = new Set(["aborted", "tool.input-json", "tool.input-incomplete"])
30
+
31
+ export function fold(state: DoomLoopDetector.SessionState, messages: ReadonlyArray<SessionMessage.Info>) {
32
+ for (const message of messages) {
33
+ if (message.type === "user") {
34
+ DoomLoopDetector.userMessage(state)
35
+ continue
36
+ }
37
+ // Standalone shell messages are user-run commands (Session.shell), not model tool calls; the
38
+ // model's background shell arrives as a "shell" tool part with metadata.status "running".
39
+ if (message.type !== "assistant") continue
40
+ for (const part of message.content) {
41
+ // The outer Code Mode tool re-enters with real tool names; its own call is exempt, as live.
42
+ if (part.type !== "tool" || part.name === "execute") continue
43
+ if (part.state.status === "error") {
44
+ if (
45
+ DoomLoopDetector.hasBlockedMetadata(part.state.metadata) ||
46
+ UNEXECUTED_ERROR_TYPES.has(part.state.error.type)
47
+ )
48
+ continue
49
+ // The live C warning rides the persisted error message; strip it so the failure hash
50
+ // matches what live accounted. recordFailed excludes argument-validation rejections via
51
+ // the same message prefix the live path matches on.
52
+ const suffix = DoomLoopDetector.injectedFailureSuffix(part.name)
53
+ const message = part.state.error.message
54
+ const errorText = message.endsWith(suffix) ? message.slice(0, -suffix.length) : message
55
+ DoomLoopDetector.recordFailed(state, {
56
+ tool: part.name,
57
+ sigHash: DoomLoopDetector.signature(part.name, part.state.input),
58
+ errorText,
59
+ validation: errorText.startsWith(DoomLoopDetector.VALIDATION_PREFIX),
60
+ })
61
+ continue
62
+ }
63
+ if (part.state.status !== "completed") continue
64
+ const sigHash = DoomLoopDetector.signature(part.name, part.state.input)
65
+ if (part.name === "question" || (part.name === "shell" && part.state.metadata?.status === "running")) {
66
+ DoomLoopDetector.recordUnobserved(state, sigHash)
67
+ continue
68
+ }
69
+ // The persisted content carries any injected warning prepended by the live after hook;
70
+ // strip a leading warning part so the hash matches what live accounted.
71
+ const first = part.state.content[0]
72
+ const content =
73
+ first !== undefined &&
74
+ first.type === "text" &&
75
+ DoomLoopDetector.injectedWarnings(part.name).includes(first.text)
76
+ ? part.state.content.slice(1)
77
+ : part.state.content
78
+ DoomLoopDetector.recordCompleted(state, {
79
+ tool: part.name,
80
+ sigHash,
81
+ text: DoomLoopDetector.outputText(content),
82
+ })
83
+ }
84
+ }
85
+ // Match the live steady state: an execution end tightens the store to the newest 1000 lines,
86
+ // and a fold from a long history could otherwise fill it to the 10k cap.
87
+ DoomLoopDetector.tighten(state)
88
+ // Fresh streak over the rebuilt line store is the self-consistent restart state; see header.
89
+ state.bStreak = 0
90
+ }
package/src/index.ts ADDED
@@ -0,0 +1,278 @@
1
+ import { define } from "@opencode/plugin/effect/plugin"
2
+ import type { Session } from "@opencode/schema/session"
3
+ import type { SessionEvent } from "@opencode/schema/session-event"
4
+ import { Effect, Exit, Option, Scope, Stream } from "effect"
5
+ import { Tool } from "@opencode/schema/tool"
6
+ import { DoomLoopDetector } from "./detector.js"
7
+ import { DoomLoopFold } from "./fold.js"
8
+
9
+ // Session state is rebuilt lazily from durable history on first tool-hook touch (fold), so a
10
+ // process restart continues A/C streaks instead of silently zeroing them. The one fact history
11
+ // cannot rebuild is a tripped circuit breaker (B's streak restarts at zero), so it is persisted
12
+ // in plugin KV and re-armed only by a real user message.
13
+ //
14
+ // Because every entry can be re-folded, the sessions map is a pure cache: past
15
+ // MAX_TRACKED_SESSIONS the least recently touched entry is dropped and re-folded on its next
16
+ // touch. Every eviction loss is miss-direction — bStreak restarts at zero exactly as after a
17
+ // restart, and calls accounted since the last durable write replay short. The breaker survives
18
+ // in KV, and an executing session touches the map on every hook, so the LRU tail is always a
19
+ // session no hook is touching.
20
+
21
+ const DEFAULT_MAX_TRACKED_SESSIONS = 64
22
+
23
+ type Tracked =
24
+ | SessionEvent.Deleted
25
+ | SessionEvent.InboxEnqueued
26
+ | SessionEvent.Step.Ended
27
+ | SessionEvent.Compaction.Ended
28
+ | SessionEvent.Execution.Succeeded
29
+ | SessionEvent.Execution.Failed
30
+ | SessionEvent.Execution.Interrupted
31
+
32
+ interface Entry {
33
+ state: DoomLoopDetector.SessionState
34
+ }
35
+
36
+ const circuitKey = (sessionID: Session.ID) => `circuit:${sessionID}`
37
+
38
+ // Factory-injected cap: tests construct a plugin with a small cap instead of mutating shared state.
39
+ export function makePlugin(options?: { maxTrackedSessions?: number }) {
40
+ const cap = options?.maxTrackedSessions ?? DEFAULT_MAX_TRACKED_SESSIONS
41
+
42
+ return define({
43
+ id: "opencode.doom.loop",
44
+ effect: Effect.fn(function* (ctx) {
45
+ const scope = yield* Scope.Scope
46
+ const sessions = new Map<Session.ID, Promise<Entry>>()
47
+
48
+ const folded = (sessionID: Session.ID): Promise<Entry> =>
49
+ Effect.runPromise(
50
+ Effect.gen(function* () {
51
+ const entry: Entry = { state: DoomLoopDetector.state() }
52
+ const messages = yield* ctx.session.context({ sessionID })
53
+ DoomLoopFold.fold(entry.state, messages)
54
+ if ((yield* ctx.storage.get(circuitKey(sessionID))) === true) entry.state.circuitBroken = true
55
+ return entry
56
+ }),
57
+ // A history read failure must not fail the tool call: fall back to a fresh state (the
58
+ // pre-fold behavior) rather than dropping detection entirely.
59
+ ).catch((): Entry => ({ state: DoomLoopDetector.state() }))
60
+
61
+ // The promise enters the map synchronously so concurrent first touches share one fold.
62
+ const session = (sessionID: Session.ID) => {
63
+ const existing = sessions.get(sessionID)
64
+ if (existing) {
65
+ // LRU touch: Map.set on an existing key does not reorder, so reinsert at the tail.
66
+ sessions.delete(sessionID)
67
+ sessions.set(sessionID, existing)
68
+ return existing
69
+ }
70
+ const created = folded(sessionID)
71
+ sessions.set(sessionID, created)
72
+ // Eviction is a bare delete: the KV breaker key belongs to the session lifecycle, not to
73
+ // this cache, and everything else re-folds from durable history on the next touch.
74
+ while (sessions.size > cap) {
75
+ const oldest = sessions.keys().next().value
76
+ if (oldest === undefined) break
77
+ sessions.delete(oldest)
78
+ }
79
+ return created
80
+ }
81
+
82
+ // Event-time mutations chain onto the in-flight fold so they apply after it settles. Sessions
83
+ // never touched by a hook are not created here: a fresh state is already the zeroed state.
84
+ const mutate = (sessionID: Session.ID, fn: (state: DoomLoopDetector.SessionState) => void) => {
85
+ const existing = sessions.get(sessionID)
86
+ if (existing)
87
+ sessions.set(
88
+ sessionID,
89
+ existing.then((entry) => {
90
+ fn(entry.state)
91
+ return entry
92
+ }),
93
+ )
94
+ }
95
+
96
+ yield* ctx.tool.hook("execute.before", (event) =>
97
+ Effect.gen(function* () {
98
+ // The outer Code Mode tool is built per snapshot and re-enters with real tool names; its
99
+ // own `execute` call carries no observable signature. Skipping by name also exempts an
100
+ // MCP/custom tool literally named `execute` — a false-negative, conservative direction.
101
+ if (event.tool === "execute") return
102
+ const current = yield* Effect.promise(() => session(event.sessionID))
103
+ const sigHash = DoomLoopDetector.signature(event.tool, event.input)
104
+ const verdict = DoomLoopDetector.beforeCall(current.state, {
105
+ tool: event.tool,
106
+ sigHash,
107
+ messageID: event.messageID,
108
+ })
109
+ if (verdict.kind === "pass") return
110
+ if (verdict.kind === "circuit") {
111
+ // A sticky circuit (a previous denial) stops without re-asking, until the user speaks.
112
+ if (verdict.sticky) {
113
+ yield* ctx.session.interrupt({ sessionID: event.sessionID }).pipe(Effect.ignore, Effect.forkIn(scope))
114
+ return yield* new Tool.Error({ message: verdict.message, metadata: DoomLoopDetector.blockedMetadata })
115
+ }
116
+ // The breaker goes through doom_loop like every other signal: allow resets B and lets
117
+ // the run continue; a denial makes the stop sticky and explains why it happened.
118
+ const resource = `${event.tool}:${sigHash}${DoomLoopDetector.resourceSuffix.b}`
119
+ const outcome = yield* Effect.exit(
120
+ ctx.permission.assert({
121
+ sessionID: event.sessionID,
122
+ agent: event.agent,
123
+ action: "doom_loop",
124
+ resources: [resource],
125
+ save: [resource],
126
+ source: { type: "tool", messageID: event.messageID, id: event.id },
127
+ }),
128
+ )
129
+ if (Exit.isSuccess(outcome)) {
130
+ current.state.bStreak = 0
131
+ return
132
+ }
133
+ const error = Option.getOrUndefined(Exit.findErrorOption(outcome))
134
+ // A rule-based denial makes the stop sticky; a CorrectedError carries model feedback and
135
+ // returns as a plain Tool.Error so the model can act on it (no sticky stop, no interrupt).
136
+ // Only a CorrectedError returns as plain feedback for the model (no sticky stop).
137
+ // The public API errors are Tool.Error, so identify a correction by its tag (both a bare
138
+ // error and the host-wrapped `error` field), rather than by class identity.
139
+ const tagOf = (value: unknown): string | undefined =>
140
+ typeof value === "object" && value !== null && "_tag" in value
141
+ ? (value as { _tag: string })._tag
142
+ : undefined
143
+ const corrected =
144
+ tagOf(error) === "Permission.CorrectedError" ||
145
+ (error instanceof Tool.Error && tagOf(error.error) === "Permission.CorrectedError")
146
+ if (!corrected) {
147
+ yield* ctx.storage.set(circuitKey(event.sessionID), true).pipe(Effect.ignore)
148
+ current.state.circuitBroken = true
149
+ yield* ctx.session.interrupt({ sessionID: event.sessionID }).pipe(Effect.ignore, Effect.forkIn(scope))
150
+ }
151
+ return yield* new Tool.Error({
152
+ message: `${verdict.message}\n\n${error?.message ?? "rejected"}`,
153
+ metadata: DoomLoopDetector.blockedMetadata,
154
+ })
155
+ }
156
+ const resource = `${event.tool}:${sigHash}${DoomLoopDetector.resourceSuffix[verdict.signal]}`
157
+ // host.ts maps BlockedError/CorrectedError to Tool.Error and keeps a plain rejection a
158
+ // defect; stacking our copy on the mapped message preserves the "why blocked + what next"
159
+ // guidance for both typed outcomes. Deliberately no inner `error`: toSessionError unwraps
160
+ // to the innermost error's message when one is attached, which would discard our copy.
161
+ // The blockedMetadata marker persists on the error part so the fold skips this call — it
162
+ // never executed, so the live path never accounted it.
163
+ return yield* ctx.permission
164
+ .assert({
165
+ sessionID: event.sessionID,
166
+ agent: event.agent,
167
+ action: "doom_loop",
168
+ resources: [resource],
169
+ save: [resource],
170
+ source: { type: "tool", messageID: event.messageID, id: event.id },
171
+ })
172
+ .pipe(
173
+ Effect.mapError(
174
+ (error) =>
175
+ new Tool.Error({
176
+ message: `${verdict.message}\n\n${error.message}`,
177
+ metadata: DoomLoopDetector.blockedMetadata,
178
+ }),
179
+ ),
180
+ )
181
+ }),
182
+ )
183
+
184
+ yield* ctx.tool.hook("execute.after", (event) =>
185
+ Effect.gen(function* () {
186
+ if (event.tool === "execute") return
187
+ const current = yield* Effect.promise(() => session(event.sessionID))
188
+ const sigHash = DoomLoopDetector.signature(event.tool, event.input)
189
+ if (event.status === "error") {
190
+ // Failure events feed signal C only — they never enter A/B, or a run of failures with
191
+ // distinct messages would count as identical empty outputs. The warning lands on the
192
+ // error message because a failed call has no result content.
193
+ // Deviation from ⑦, declared: error events carry no metadata, so a background shell's
194
+ // launch failure cannot be distinguished and is counted into C. Deliberately conservative
195
+ // direction (detect more; C still only ever asks, never hard-blocks silently).
196
+ const warning = DoomLoopDetector.recordFailed(current.state, {
197
+ tool: event.tool,
198
+ sigHash,
199
+ errorText: event.error.message,
200
+ validation: event.error.message.startsWith(DoomLoopDetector.VALIDATION_PREFIX),
201
+ })
202
+ if (warning !== undefined) event.error.message = `${event.error.message}\n\n${warning}`
203
+ return
204
+ }
205
+ // Unobserved layer (background shell — registered under the name "shell"; question):
206
+ // A/B/C abstain — the receipt text is fixed, so counting it would false-positive signal A
207
+ // immediately.
208
+ const unobserved =
209
+ event.tool === "question" ||
210
+ (event.tool === "shell" && event.result.metadata !== undefined && event.result.metadata.status === "running")
211
+ if (unobserved) {
212
+ DoomLoopDetector.recordUnobserved(current.state, sigHash)
213
+ return
214
+ }
215
+ // Hash and account before touching content so our own text never feeds back in.
216
+ const text = DoomLoopDetector.outputText(event.result.content, event.result.output)
217
+ const warning = DoomLoopDetector.recordCompleted(current.state, { tool: event.tool, sigHash, text })
218
+ if (warning === undefined) return
219
+ // Prepend rather than append: the runner's ToolOutput.truncate keeps the head of oversized
220
+ // outputs (tool-output.ts), so a tail warning would be cut exactly on the huge outputs of
221
+ // looping MCP-style tools.
222
+ const existing = event.result.content
223
+ event.result = {
224
+ ...event.result,
225
+ content: [
226
+ { type: "text" as const, text: warning },
227
+ ...(typeof existing === "string" ? [{ type: "text" as const, text: existing }] : (existing ?? [])),
228
+ ],
229
+ }
230
+ }),
231
+ )
232
+
233
+ yield* ctx.event.subscribe().pipe(
234
+ Stream.filter(
235
+ (event): event is Tracked =>
236
+ event.type === "session.deleted" ||
237
+ event.type === "session.inbox.enqueued" ||
238
+ event.type === "session.step.ended" ||
239
+ event.type === "session.compaction.ended" ||
240
+ event.type === "session.execution.succeeded" ||
241
+ event.type === "session.execution.failed" ||
242
+ event.type === "session.execution.interrupted",
243
+ ),
244
+ Stream.runForEach((event) =>
245
+ Effect.gen(function* () {
246
+ const sessionID = event.data.sessionID
247
+ if (event.type === "session.deleted") {
248
+ sessions.delete(sessionID)
249
+ yield* ctx.storage.remove(circuitKey(sessionID)).pipe(Effect.ignore)
250
+ return
251
+ }
252
+ if (event.type === "session.inbox.enqueued") {
253
+ // Only real user messages re-arm the breaker; our own synthetic injections must not.
254
+ // The persisted key clears even when this process never folded the session.
255
+ if (event.data.item.type !== "user") return
256
+ yield* ctx.storage.remove(circuitKey(sessionID)).pipe(Effect.ignore)
257
+ mutate(sessionID, DoomLoopDetector.userMessage)
258
+ return
259
+ }
260
+ if (event.type === "session.step.ended") {
261
+ if (event.data.files === undefined || event.data.files.length === 0) return
262
+ mutate(sessionID, DoomLoopDetector.filesChanged)
263
+ return
264
+ }
265
+ if (event.type === "session.compaction.ended") {
266
+ mutate(sessionID, DoomLoopDetector.compacted)
267
+ return
268
+ }
269
+ mutate(sessionID, DoomLoopDetector.tighten)
270
+ }),
271
+ ),
272
+ Effect.forkScoped({ startImmediately: true }),
273
+ )
274
+ }),
275
+ })
276
+ }
277
+
278
+ export const Plugin = makePlugin()