@polderlabs/bizar-plugin 0.5.4

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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +448 -0
  3. package/bun.lock +88 -0
  4. package/index.ts +1113 -0
  5. package/package.json +42 -0
  6. package/scripts/check-forbidden-imports.sh +33 -0
  7. package/src/background-state.ts +463 -0
  8. package/src/background.ts +964 -0
  9. package/src/commands-impl.ts +369 -0
  10. package/src/commands.ts +880 -0
  11. package/src/event-stream.ts +574 -0
  12. package/src/fingerprint.ts +120 -0
  13. package/src/handoff.ts +79 -0
  14. package/src/http-client.ts +467 -0
  15. package/src/logger.ts +144 -0
  16. package/src/loop.ts +176 -0
  17. package/src/options.ts +421 -0
  18. package/src/plan-fs.ts +323 -0
  19. package/src/report.ts +178 -0
  20. package/src/research-prompt.ts +35 -0
  21. package/src/serve.ts +476 -0
  22. package/src/settings.ts +349 -0
  23. package/src/state.ts +298 -0
  24. package/src/tools/bg-collect.ts +104 -0
  25. package/src/tools/bg-get-comments.ts +239 -0
  26. package/src/tools/bg-kill.ts +87 -0
  27. package/src/tools/bg-spawn.ts +263 -0
  28. package/src/tools/bg-status.ts +99 -0
  29. package/src/tools/plan-action.ts +767 -0
  30. package/src/tools/wait-for-feedback.ts +402 -0
  31. package/tests/attach-handler-bug.test.ts +166 -0
  32. package/tests/background-state.test.ts +277 -0
  33. package/tests/background.test.ts +402 -0
  34. package/tests/block.test.ts +193 -0
  35. package/tests/canonical-key-order.test.ts +71 -0
  36. package/tests/commands-impl.test.ts +442 -0
  37. package/tests/commands.test.ts +548 -0
  38. package/tests/config.test.ts +122 -0
  39. package/tests/dispose.test.ts +336 -0
  40. package/tests/event-stream.test.ts +409 -0
  41. package/tests/event.test.ts +262 -0
  42. package/tests/fingerprint.test.ts +161 -0
  43. package/tests/http-client.test.ts +403 -0
  44. package/tests/init-helpers.test.ts +203 -0
  45. package/tests/integration/slash-command.test.ts +348 -0
  46. package/tests/integration/tool-routing.test.ts +314 -0
  47. package/tests/loop.test.ts +397 -0
  48. package/tests/options.test.ts +274 -0
  49. package/tests/serve.test.ts +335 -0
  50. package/tests/settings.test.ts +351 -0
  51. package/tests/stall-think.test.ts +749 -0
  52. package/tests/state.test.ts +275 -0
  53. package/tests/tools/bg-collect.test.ts +337 -0
  54. package/tests/tools/bg-get-comments.test.ts +485 -0
  55. package/tests/tools/bg-kill.test.ts +231 -0
  56. package/tests/tools/bg-spawn.test.ts +311 -0
  57. package/tests/tools/bg-status.test.ts +216 -0
  58. package/tests/tools/plan-action.test.ts +599 -0
  59. package/tests/tools/wait-for-feedback.test.ts +390 -0
  60. package/tsconfig.json +29 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DrB0rk
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 ADDED
@@ -0,0 +1,448 @@
1
+ # Bizar opencode plugin
2
+
3
+ A BizarHarness-bundled opencode plugin that gives Odin better visibility into
4
+ subagent activity and a mechanism to stop or reassign agents that are stuck.
5
+ It does three things:
6
+
7
+ 1. **Loop detection** — fingerprint tool calls and warn/block on repetition.
8
+ 2. **Periodic status reporting** — log subagent activity so Odin (and the
9
+ human) can see what is happening.
10
+ 3. **Handoff signal** — when a subagent is clearly stuck, inject a message
11
+ that nudges it (or the parent) to reassign via the `task` tool.
12
+
13
+ The plugin is specified in `.bizar/plugin-architecture-v0.3.md` and
14
+ implemented against that contract. v0.4.0 adds slash commands and the
15
+ visual-plan flow (settings store, slash command parser, plan action
16
+ tool, and wait-for-feedback tool).
17
+
18
+ ## Install
19
+
20
+ The plugin is copied to `<project>/.opencode/plugins/bizar/` by the
21
+ BizarHarness install script. After install, the layout is:
22
+
23
+ ```
24
+ <project-root>/
25
+ └── .opencode/
26
+ ├── opencode.json
27
+ └── plugins/
28
+ └── bizar/
29
+ ├── package.json
30
+ ├── tsconfig.json
31
+ ├── README.md
32
+ ├── index.ts
33
+ ├── scripts/
34
+ │ └── check-forbidden-imports.sh
35
+ ├── src/
36
+ │ ├── loop.ts
37
+ │ ├── handoff.ts
38
+ │ ├── logger.ts
39
+ │ ├── options.ts
40
+ │ ├── fingerprint.ts
41
+ │ ├── state.ts
42
+ │ └── report.ts
43
+ └── tests/
44
+ ├── loop.test.ts
45
+ ├── block.test.ts
46
+ ├── fingerprint.test.ts
47
+ ├── state.test.ts
48
+ ├── event.test.ts
49
+ ├── options.test.ts
50
+ └── integration.test.ts
51
+ ```
52
+
53
+ `opencode.json` references the plugin via the relative path
54
+ `./plugins/bizar/index.ts` (relative to the config directory).
55
+
56
+ ## Options
57
+
58
+ Plugin options are passed as the second element of the `plugin` tuple in
59
+ `opencode.json`:
60
+
61
+ ```jsonc
62
+ "plugin": [
63
+ ["./plugins/bizar/index.ts", {
64
+ "loopThresholdWarn": 5,
65
+ "loopThresholdEscalate": 8,
66
+ "loopThresholdBlock": 12,
67
+ "loopWindowSize": 10,
68
+ "logDir": "~/.cache/bizar/logs",
69
+ "stateDir": "~/.cache/bizar",
70
+ "logRotationBytes": 10485760
71
+ }]
72
+ ]
73
+ ```
74
+
75
+ All options are optional. Missing options fall back to the defaults. Bad
76
+ input is clamped, never rejected — the plugin never throws on bad config.
77
+
78
+ | Option | Default | Clamp / behavior |
79
+ |---|---|---|
80
+ | `loopThresholdWarn` | `5` | `Math.max(1, Math.floor(value))`. Out-of-order: `escalate = warn + 1`, `block = escalate + 1`. |
81
+ | `loopThresholdEscalate` | `8` | `Math.max(1, Math.floor(value))`. |
82
+ | `loopThresholdBlock` | `12` | `Math.max(1, Math.floor(value))`. Constrained: `block <= loopWindowSize + 2`. |
83
+ | `loopWindowSize` | `10` | Clamped to `[3, 50]`. |
84
+ | `logDir` | `~/.cache/bizar/logs` | Refused if inside `~/.ssh/`, `~/.gnupg/`, `~/.aws/`, `~/.kube/`. |
85
+ | `stateDir` | `~/.cache/bizar` | Refused if inside a secret directory (same list as `logDir`). |
86
+ | `logRotationBytes` | `10485760` (10 MB) | `Math.max(1024, Math.floor(value))`. |
87
+ | `backgroundStallTimeoutMs` | `180000` (3 min) | v0.3.0 — stall timeout in ms. Clamped to `[10000, 600000]`. |
88
+ | `backgroundThinkingLoopTimeoutMs` | `300000` (5 min) | v0.3.0 — thinking-loop timeout in ms. Clamped to `[30000, 900000]`. |
89
+ | `backgroundMaxInterventions` | `1` | v0.3.0 — max research interventions before forced abort. Clamped to `[1, 3]`. |
90
+
91
+ ## Environment variables
92
+
93
+ | Env var | Effect |
94
+ |---|---|
95
+ | `BIZAR_DISABLE=1` | Disables the plugin entirely. The plugin returns empty hooks and logs once at debug level. |
96
+ | `BIZAR_DISABLE_LOOP=1` | Loop guard disabled (no fingerprint, no threshold check, no throw). Status reporting still active. |
97
+ | `BIZAR_DISABLE_LOG=1` | Status reporting disabled. Loop guard still active. |
98
+ | `BIZAR_LOG_LEVEL=debug\|info\|warn\|error` | Sets log verbosity. Default `info`. Invalid values fall back to `info` with a warning. |
99
+ | `BIZAR_STALL_TIMEOUT_MS` | v0.3.0 — overrides `backgroundStallTimeoutMs`. |
100
+ | `BIZAR_THINKING_LOOP_TIMEOUT_MS` | v0.3.0 — overrides `backgroundThinkingLoopTimeoutMs`. |
101
+ | `BIZAR_MAX_INTERVENTIONS` | v0.3.0 — overrides `backgroundMaxInterventions`. |
102
+
103
+ Env vars are read once at plugin init. Mid-session changes are ignored.
104
+
105
+ ## How loop detection works
106
+
107
+ The plugin fingerprints every `tool.execute.before` call as a stable hash of
108
+ the tool name and the normalized arguments. It keeps a rolling window of the
109
+ last 10 (default) tool calls per session. When the count of matching
110
+ fingerprints in the window crosses a threshold, the plugin acts:
111
+
112
+ | Repetitions | Action | Mechanism |
113
+ |---|---|---|
114
+ | 3 (default) | Log a warning via `client.app.log`. **No injection.** | Diagnostic only. |
115
+ | 5 (default) | Inject a system message via `experimental.chat.system.transform`. | Subagent sees it on its next turn. |
116
+ | 8 (default) | Inject a stronger system message via `experimental.chat.system.transform`. | Subagent sees it on its next turn. |
117
+ | 12 (default) | **Block.** Throw from `tool.execute.before`. | Surfaces in the TUI as a tool error. |
118
+
119
+ The plugin's hard-block at threshold 12 runs BEFORE opencode's soft
120
+ `doom_loop` recovery. The plugin wins. See `.bizar/plugin-architecture-v0.3.md`
121
+ §3.3 for the interaction with the `doom_loop` permission.
122
+
123
+ ## What the plugin does NOT do
124
+
125
+ - It does **not** modify user files.
126
+ - It does **not** call external APIs (no LLM calls, no telemetry).
127
+ - It does **not** override agent prompts (only injects ephemeral system
128
+ messages into the current turn's context).
129
+ - It does **not** manage subagent lifecycle (opencode does that).
130
+ - It does **not** read environment variables other than the four listed above.
131
+ - It does **not** write outside `~/.cache/bizar/` by default.
132
+ - It does **not** register slash commands in v0.1.
133
+
134
+ ## Background Agents
135
+
136
+ v0.4 adds **background agents** — asynchronous subagent execution via a single long-running `opencode serve` instance. Background agents enable Odin to parallelize independent work without blocking the main conversation.
137
+
138
+ ### Architecture
139
+
140
+ The plugin starts one `opencode serve` process on init (single-serve, multi-session). All background sessions share this process. Each background instance is tracked in `BackgroundState` at `~/.cache/bizar/bg/<instanceId>.json`.
141
+
142
+ ### The 4 tools
143
+
144
+ | Tool | Who can call | What it does |
145
+ |---|---|---|
146
+ | `bizar_spawn_background` | Odin only | Spawns a background agent; returns `{ instanceId, sessionId, status: "pending" }` |
147
+ | `bizar_status` | Any agent | Lists all instances or a single one; read-only |
148
+ | `bizar_collect` | Odin only | Blocks until instance completes or times out; returns `{ instanceId, status, result, toolCallCount, durationMs }` |
149
+ | `bizar_kill` | Odin only | Sends `POST /session/{id}/abort`; marks instance `killed` |
150
+
151
+ #### Spawning a background agent
152
+
153
+ ```typescript
154
+ // From within Odin's task decomposition:
155
+ const result = await bizarre_spawn_background({
156
+ agent: "mimir", // which agent to run
157
+ prompt: "Research X and return findings", // what to do
158
+ model: "minimax/MiniMax-M3", // optional: override model
159
+ timeoutMs: 300_000, // optional: default 5 min, max 30 min
160
+ }, ctx);
161
+ console.log(result.instanceId); // "bgr_01ARSH3J5V..."
162
+ ```
163
+
164
+ #### Checking status
165
+
166
+ ```typescript
167
+ // All instances
168
+ const all = await bizarre_status({}, ctx);
169
+
170
+ // One instance
171
+ const one = await bizarre_status({ instanceId: "bgr_01ARSH..." }, ctx);
172
+ ```
173
+
174
+ #### Collecting results
175
+
176
+ ```typescript
177
+ // Block until done/failed/killed/timeout
178
+ const r = await bizarre_collect({
179
+ instanceId: "bgr_01ARSH...",
180
+ timeoutMs: 120_000, // optional override
181
+ }, ctx);
182
+ console.log(r.result); // concatenated assistant text
183
+ console.log(r.status); // "done" | "failed" | "killed" | "timed_out"
184
+ ```
185
+
186
+ #### Killing an instance
187
+
188
+ ```typescript
189
+ await bizarre_kill({ instanceId: "bgr_01ARSH..." }, ctx);
190
+ ```
191
+
192
+ ### Security Model
193
+
194
+ - **Localhost only** — `opencode serve` binds to `127.0.0.1`, never exposed externally.
195
+ - **Random shared secret** — a 32-byte secret is generated at plugin init and passed as `OPENCODE_SERVER_PASSWORD` to the serve child. Every HTTP call from the plugin authenticates with `Authorization: Basic base64("opencode:<secret>")`.
196
+ - **Password is in-memory only** — not written to disk. On process exit, the serve child is killed and the secret becomes invalid.
197
+ - **`--dangerously-skip-permissions` opt-in** — by default the serve child respects the user's agent permission config. Set `BIZAR_BACKGROUND_SKIP_PERMISSIONS=1` to skip (not recommended).
198
+ - **Odin-only spawn** — only Odin can spawn background agents. Other agents (Vör, Frigg, Mimir, etc.) can call `bizar_status` (read-only).
199
+
200
+ ## Stall and thinking loop protection
201
+
202
+ Background agents can hang in two ways:
203
+
204
+ 1. **Stall** — the LLM provider drops the connection or the model stops emitting tokens. No SSE events arrive. The session is "alive" but not making progress.
205
+ 2. **Thinking loop** — the model is in its internal reasoning phase, emitting `thinking` parts, but never calling tools or producing output. Events are flowing, just not useful ones.
206
+
207
+ v0.3.0 adds automatic protection against both:
208
+
209
+ | Guard | Default | Triggers when | Action |
210
+ |---|---|---|---|
211
+ | `backgroundStallTimeoutMs` | 180000 (3 min) | No SSE event arrives for N seconds | Abort session, mark `failed` with "No activity for N ms — LLM appears stalled" |
212
+ | `backgroundThinkingLoopTimeoutMs` | 300000 (5 min) | No tool/text part arrives for N minutes (only `thinking` parts) | Send research intervention prompt (1 by default), then abort if still stuck |
213
+
214
+ The intervention prompt instructs the LLM to:
215
+ - Spawn a Mimir agent for research
216
+ - Or use read/grep/glob for codebase info
217
+ - Or use bash for observable commands
218
+
219
+ This nudges the agent out of pure thinking and toward concrete progress. The intervention counter is reset to 0 the moment the agent makes progress (any `tool` or `text` part after an intervention), so a single intervention followed by activity does not count against the next thinking loop.
220
+
221
+ ### Limitations
222
+
223
+ 1. **Threshold-5/8 in background are not visible to Odin.** They happen in the background session's LLM context. Odin only sees the threshold-12 marker in the result. Developers can see threshold-5/8 in the plugin log.
224
+ 2. **Loop-guard markers are added at collect time, not stored.** `resultPreview` does not contain the marker; `bizar_collect` prepends it.
225
+ 3. **File-level races between concurrent background agents are the user's problem.** The plugin does not coordinate file access between instances.
226
+ 4. **Loop-guard detection requires SSE events.** If the SSE stream is dropped, the plugin may miss the threshold-12 throw until reconnect.
227
+ 5. **No nested spawns in v0.4.** `parentInstanceId` is reserved but no tool exposes background-to-background spawning.
228
+ 6. **Serve child is per-process.** Multiple worktrees in the same plugin process share one SSE subscription — not supported for multi-worktree setups.
229
+ 7. **Password is in-memory only.** Restarting the plugin generates a new password; old `BackgroundState` files point to sessions in the old serve child.
230
+ 8. **`bizar_collect` on a killed/failed instance returns the partial result.** It does not retry.
231
+ 9. **The `model` parameter is not validated.** opencode will reject unknown providers/models with a 4xx.
232
+ 10. **Custom agents without loop-guard instructions will not see the marker as a task cue.**
233
+ 11. **v0.3.0 — Stall and thinking-loop detection has a 15-second polling latency.** The periodic checker runs every 15 seconds, so the actual detection happens within `timeout + 15s`. Adjust the timeouts downward if you need tighter SLAs.
234
+ 12. **v0.3.0 — A stalled-but-already-closing serve child may briefly show `failed` with the stall message.** The abort call is best-effort; if the serve child has just died, the stall message is the user-visible reason. The underlying cause is captured in the plugin log.
235
+
236
+ ## Slash commands and visual plan flow
237
+
238
+ v0.4.0 introduces a "visual plan that waits for feedback" feature. The
239
+ plugin supports slash commands in any user message and ships two new
240
+ tools for the agent to drive the visual plan canvas.
241
+
242
+ ### Slash commands
243
+
244
+ The `chat.message` hook detects slash commands before state seeding.
245
+ A recognized command runs to completion; the response text is surfaced
246
+ to the user (the host renders it as a tool error, the same pattern
247
+ `tool.execute.before` uses for loop blocks).
248
+
249
+ | Command | Effect |
250
+ |---|---|
251
+ | `/visual-plan on` / `/off` | Toggle visual plan mode. Persists to settings. |
252
+ | `/visual-plan` | Show current state (mode, default template, last slug). |
253
+ | `/plan new <slug> [template]` | Create a new plan. Optional template: `blank`, `feature-design`, `bug-investigation`, `decision-record`. |
254
+ | `/plan list` | List all plans in the worktree's `plans/` directory. |
255
+ | `/plan open <slug>` | Return the URL for a plan. (Server startup is deferred to v0.5.0 — the URL is informational.) |
256
+ | `/help` or `/commands` | List available commands. |
257
+
258
+ Unknown commands return an error response (not a crash). Non-slash
259
+ messages pass through to the LLM unchanged.
260
+
261
+ ### The visual plan flow
262
+
263
+ When visual plan mode is enabled, the agent's first action on a complex
264
+ task is to create a plan canvas (using `bizar_plan_action`), present
265
+ it to the user (via `bizar_wait_for_feedback`), and **wait for approval
266
+ or feedback** before continuing.
267
+
268
+ The agent uses two new tools to interact with the plan:
269
+
270
+ - `bizar_plan_action` — CRUD on the canvas (add elements, comments,
271
+ connections, set plan status). Pure file I/O — does not require the
272
+ `opencode serve` child, so it works in any environment.
273
+ - `bizar_wait_for_feedback` — polls every 2 seconds until a new comment
274
+ appears, status becomes `approved` / `rejected`, or the timeout fires.
275
+ Default timeout 10 minutes; range `[5 s, 30 min]`.
276
+
277
+ ### Settings persistence
278
+
279
+ Plan settings are persisted at
280
+ `~/.cache/bizar/plan-settings.json`:
281
+
282
+ ```json
283
+ {
284
+ "visualPlanEnabled": true,
285
+ "defaultTemplate": "blank",
286
+ "lastUsedSlug": "my-feature"
287
+ }
288
+ ```
289
+
290
+ The `SettingsStore` writes atomically (via `writeFileSync(tmp) + renameSync`)
291
+ and falls back to defaults on missing or corrupt files. All methods
292
+ are async and never throw on bad input.
293
+
294
+ ### Odin usage example
295
+
296
+ ```typescript
297
+ // Odin decomposes a task and spots two independent research branches
298
+ const [resultA, resultB] = await Promise.all([
299
+ // Branch 1: Mimir researches feature X
300
+ bizarre_spawn_background({ agent: "mimir", prompt: "Research X", timeoutMs: 300_000 }, odinCtx),
301
+ // Branch 2: Thor implements feature Y (sync, doesn't block)
302
+ // ... use task tool for sync work ...
303
+ ]);
304
+
305
+ // Continue the main conversation while background work runs
306
+ appendMessage("Background agents launched for independent branches.");
307
+
308
+ // Later, collect results when needed
309
+ const findings = await bizar_collect({ instanceId: resultA.instanceId, timeoutMs: 120_000 }, odinCtx);
310
+ console.log(findings.result);
311
+ ```
312
+
313
+ ## Network and forbidden imports
314
+
315
+ The plugin does not import `node:dns`, `node:net`, `node:http`, or
316
+ `node:https`. The plugin makes zero outbound calls.
317
+
318
+ This is enforced by `scripts/check-forbidden-imports.sh`, which is run as
319
+ part of the `test` script (it fails the build if any match is found):
320
+
321
+ ```bash
322
+ if grep -rE 'from "node:(dns|net|http|https)"' src/; then
323
+ echo "FAIL: src/ contains a forbidden node: import (dns|net|http|https)"
324
+ exit 1
325
+ fi
326
+ ```
327
+
328
+ Other `node:` modules (`fs`, `path`, `os`, `util`, etc.) are allowed because
329
+ they are not network-bearing.
330
+
331
+ ## What gets logged
332
+
333
+ The per-call log line at `~/.cache/bizar/logs/<sessionId>.log` is
334
+ metadata only:
335
+
336
+ ```
337
+ 2026-06-17T14:30:01.123Z session=<sid> tool=read fingerprint=ab12cd outcome=ok duration=45ms
338
+ ```
339
+
340
+ It contains the ISO timestamp, session ID, tool name, fingerprint hash,
341
+ outcome, and duration. It does **not** contain raw tool args, session
342
+ content, environment values, or LLM output. This is the §7.6 invariant and
343
+ is verified by the integration test.
344
+
345
+ ## Limitations
346
+
347
+ The following are known limitations of v0.1. They are part of the release
348
+ contract; custom integrations must work around them, not against them.
349
+
350
+ 1. **Syntactically different but semantically identical args are not caught.**
351
+ Example: `ls -la` and `ls -la .` produce different fingerprints. The
352
+ plugin only catches identical-args loops.
353
+
354
+ 2. **Cross-tool loops are not caught.** Example: `read foo` → `grep foo` →
355
+ `read foo` → `grep foo` is not detected as a loop. The fingerprint
356
+ includes the tool name, so different tools produce different fingerprints.
357
+
358
+ 3. **Arg-mutating loops are not caught.** Example: `read foo1`, `read foo2`,
359
+ `read foo3` produces 3 distinct fingerprints even if the agent's
360
+ *intent* is to loop. The fingerprint is on the actual call, not on
361
+ inferred intent.
362
+
363
+ 4. **Custom agents without loop-guard instructions will loop indefinitely
364
+ past threshold 12.** The plugin throws at threshold 12, but if the
365
+ subagent has no `## Loop Guard Handling` section in its prompt, it may
366
+ simply retry the same call indefinitely — each retry blocked, but the
367
+ agent does not progress. All BizarHarness subagents (Thor, Tyr, Mimir,
368
+ Heimdall, Hermod, Baldr, Vör, Frigg, Forseti, Quick, Vidarr) include the
369
+ canonical section. **Users who add custom agents without this section
370
+ will experience infinite-block loops.** This warning is mandatory.
371
+
372
+ 5. **Corrupt state files are not auto-recovered.** A corrupt JSON state
373
+ file is logged and ignored; the session starts with empty state. The
374
+ corrupt file is preserved on disk for forensic inspection, not deleted.
375
+
376
+ 6. **Out-of-worktree paths are hashed, not stored.** A loop involving files
377
+ outside the worktree will produce stable fingerprints across runs (good)
378
+ but the original path is not recoverable from the log (acceptable since
379
+ args are not logged at all).
380
+
381
+ 7. **Stale session cleanup is best-effort.** If `client.session.list()`
382
+ fails, the age-based cleanup still runs but the "session no longer in
383
+ opencode" branch is skipped.
384
+
385
+ 8. **Single-host state.** State files are local to `~/.cache/bizar/`.
386
+ A user with multiple machines will have separate state on each.
387
+ Cross-host loop detection is out of scope.
388
+
389
+ 9. **Env var changes mid-session are ignored.** Env vars (`BIZAR_DISABLE`,
390
+ `BIZAR_LOG_LEVEL`, etc.) are read once at plugin init.
391
+
392
+ 10. **Log rotation is best-effort.** If a `renameSync` fails, that step is
393
+ skipped and a warning logged. The log may grow past `logRotationBytes`
394
+ in degenerate cases.
395
+
396
+ 11. **Canonical handoff messages hardcode the default threshold numbers.**
397
+ The warn, escalate, and block message templates contain the literal
398
+ text `"5 identical calls"`, `"8 identical calls"`, and
399
+ `"12 identical calls"`. If a user reconfigures the thresholds
400
+ (`loopThresholdWarn`, `loopThresholdEscalate`, `loopThresholdBlock`),
401
+ the action still fires at the configured counts, but the message text
402
+ still says the default numbers. The agent prompts' recognition
403
+ patterns in §11.2 of the spec match the default text, so non-default
404
+ thresholds may cause subagents to fail to recognize the handoff.
405
+ Leave the thresholds at their defaults unless you also update the
406
+ agent prompts.
407
+
408
+ ## For plugin developers
409
+
410
+ ```
411
+ plugins/bizar/
412
+ ├── package.json (ESM; deps: zod for option validation)
413
+ ├── tsconfig.json (ES2022, strict, declaration files)
414
+ ├── index.ts (Plugin entry; hook wiring; init try/catch)
415
+ ├── src/
416
+ │ ├── loop.ts (decide() — threshold table, decision tree)
417
+ │ ├── handoff.ts (3 static message templates; only tool name interpolated)
418
+ │ ├── logger.ts (thin wrapper over client.app.log; BIZAR_LOG_LEVEL)
419
+ │ ├── options.ts (clamping, validation, secret-dir refusal, env vars)
420
+ │ ├── fingerprint.ts (stable hash of (tool, args))
421
+ │ ├── state.ts (per-session state, per-session mutex)
422
+ │ └── report.ts (per-session log writer)
423
+ ├── scripts/
424
+ │ └── check-forbidden-imports.sh (CI: forbids node:dns/net/http/https imports)
425
+ └── tests/
426
+ ├── loop.test.ts (decision tree, window rolling, edge cases)
427
+ ├── block.test.ts (threshold-12 throw)
428
+ ├── fingerprint.test.ts
429
+ ├── state.test.ts
430
+ ├── event.test.ts
431
+ ├── options.test.ts
432
+ └── integration.test.ts (Docker-based; runs against a real opencode install)
433
+ ```
434
+
435
+ To run the unit tests:
436
+
437
+ ```bash
438
+ cd plugins/bizar
439
+ npm install # if not already installed
440
+ npm test # runs the CI import check + bun test
441
+ ```
442
+
443
+ To typecheck only:
444
+
445
+ ```bash
446
+ cd plugins/bizar
447
+ npm run typecheck
448
+ ```
package/bun.lock ADDED
@@ -0,0 +1,88 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "@bizarharness/bizar-plugin",
7
+ "dependencies": {
8
+ "zod": "4.1.8",
9
+ },
10
+ "devDependencies": {
11
+ "@opencode-ai/plugin": "^1.17.7",
12
+ "@types/bun": "latest",
13
+ "typescript": "^5.6.0",
14
+ },
15
+ },
16
+ },
17
+ "packages": {
18
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
19
+
20
+ "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
21
+
22
+ "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="],
23
+
24
+ "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="],
25
+
26
+ "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="],
27
+
28
+ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
29
+
30
+ "@opencode-ai/plugin": ["@opencode-ai/plugin@1.17.7", "", { "dependencies": { "@opencode-ai/sdk": "1.17.7", "effect": "4.0.0-beta.74", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.3.4", "@opentui/keymap": ">=0.3.4", "@opentui/solid": ">=0.3.4" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-/MXRdz5z5tDySwMM4v02cN0om1QgALyE8FTXFU93zKV4I/oW5a0IjQ7dK8Iue3NpRc9e5UHhgO5ELeNLqnpWPA=="],
31
+
32
+ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.7", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-7q7StGM+N0OwUgRsmDc8Gyz3hMIH1XGig+qZ4lzWUpmSgFEjLx8U7R14GXY7KiMJVdbVf6FeaYloRz2Rcsma4A=="],
33
+
34
+ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
35
+
36
+ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
37
+
38
+ "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
39
+
40
+ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
41
+
42
+ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
43
+
44
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
45
+
46
+ "effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
47
+
48
+ "fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
49
+
50
+ "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
51
+
52
+ "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
53
+
54
+ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
55
+
56
+ "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
57
+
58
+ "msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
59
+
60
+ "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
61
+
62
+ "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
63
+
64
+ "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
65
+
66
+ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
67
+
68
+ "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="],
69
+
70
+ "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
71
+
72
+ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
73
+
74
+ "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="],
75
+
76
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
77
+
78
+ "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
79
+
80
+ "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
81
+
82
+ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
83
+
84
+ "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
85
+
86
+ "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
87
+ }
88
+ }