@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/index.ts ADDED
@@ -0,0 +1,1113 @@
1
+ /**
2
+ * Bizar plugin — opencode plugin entry point.
3
+ *
4
+ * Spec contract (cumulative):
5
+ * v0.3.1:
6
+ * - §3.1 — hook surface used. The plugin wires up `config`, `event`,
7
+ * `chat.message`, `tool.execute.before`, `tool.execute.after`, and
8
+ * `experimental.chat.system.transform`.
9
+ * - §4.3 — per-session async mutex. All state reads/writes go through
10
+ * `stateStore.withLock(sessionID, …)`.
11
+ * - §4.5 — `chat.message` dedupes by message ID and seeds `parentAgent`
12
+ * on the first message per session.
13
+ * - §4.5.1 — state file is created on the `chat.message` seed (not on
14
+ * `session.created`). A lazy fallback creates the file on the first
15
+ * `tool.execute.before` for subagent-only sessions.
16
+ * - §4.6 — stale session cleanup runs on init.
17
+ * - §4.7 — corrupt-state fallback. StateStore handles this; we never
18
+ * see a corrupt file from the hook side.
19
+ * - §5.4 — thresholds 5 and 8 inject via `experimental.chat.system.transform`;
20
+ * threshold 12 throws from `tool.execute.before`.
21
+ * - §6.4 — refuse to start if `logDir` or `stateDir` is inside a secret
22
+ * directory. Return empty hooks in that case.
23
+ * - §6.5 — honor `BIZAR_DISABLE`, `BIZAR_DISABLE_LOOP`, `BIZAR_DISABLE_LOG`,
24
+ * `BIZAR_LOG_LEVEL`. Read once at init.
25
+ * - §7.6 — never log raw tool args. The logger only accepts message strings.
26
+ * - §8.1 — wrap init in try/catch. Return empty hooks on any init error.
27
+ * - §8.2 — create directories on init. If creation fails, return empty hooks.
28
+ * - §10.1 — per-call log line via `LogWriter.write`.
29
+ *
30
+ * v0.4.0 (visual plan flow — slash commands + plan tools):
31
+ * - §v4.1 — `chat.message` hook detects slash commands BEFORE state
32
+ * seeding. Settings changes are applied silently; commands with
33
+ * a response text are surfaced by throwing from the hook (the
34
+ * same pattern `tool.execute.before` uses for block decisions).
35
+ * - §v4.2 — `SettingsStore` persists user-controlled plan settings
36
+ * (visualPlanEnabled, defaultTemplate, lastUsedSlug) at
37
+ * `~/.cache/bizar/plan-settings.json`. Atomic writes,
38
+ * corrupt-file fallback to defaults, no throw on bad input.
39
+ * - §v4.3 — `parseSlashCommand` is a pure function (no I/O). The
40
+ * hook gathers context (current settings, available plan slugs)
41
+ * and feeds it to the parser.
42
+ * - §v4.4 — `bizar_plan_action` tool exposes CRUD on the v2
43
+ * canvas (`plan.json`) and the plan metadata (`meta.json`).
44
+ * Pure file I/O — no serve child required.
45
+ * - §v4.5 — `bizar_wait_for_feedback` tool polls every 2 s until
46
+ * a new comment appears, status becomes approved/rejected, or
47
+ * the timeout fires. Never throws.
48
+ *
49
+ * v0.5.0 (visual plan wiring — chat hook executes side effects):
50
+ * - §v5.1 — `chat.message` hook now invokes the parser, then
51
+ * calls `executeSideEffect(result.sideEffect, ctx, opts)` from
52
+ * `src/commands-impl.ts` BEFORE throwing the response. Side
53
+ * effects include `create_plan` (mkdir + write meta/canvas
54
+ * via `src/plan-fs.ts`), `list_plans` (re-read directory and
55
+ * return rich list), `open_plan_url` (no I/O), and
56
+ * `tool_invocation` (build synthetic `ToolContext`, validate
57
+ * args via the tool's Zod schema, then call `tool.execute`).
58
+ * - §v5.2 — synthetic `ToolContext` is built from the runtime
59
+ * context's `worktree` and `directory`, a fresh
60
+ * `AbortController().signal`, and no-op `metadata`/`ask`
61
+ * stubs. NOT from the chat-message input. Session/message/agent
62
+ * IDs use a `"slash-command"` sentinel so downstream code can
63
+ * recognize out-of-band calls.
64
+ * - §v5.3 — tool key names use the `bizar_*` form (single `r`)
65
+ * throughout the plugin, matching the docs and
66
+ * `config/opencode.json`. The earlier `bizarre_*` typo silently
67
+ * disabled the plan tools at runtime; the rename brings the
68
+ * runtime registry back in sync.
69
+ * - §v5.4 — subcommand form: `/plan get|add|update|delete|comment|
70
+ * comments|status|wait` route through `bizar_plan_action` (or
71
+ * `bizar_get_plan_comments`) via the new `tool_invocation`
72
+ * side-effect. `/plan wait` is deferred from MVP and returns
73
+ * a clear "use bizar_wait_for_feedback directly" response.
74
+ *
75
+ * v0.4.2 (background agents):
76
+ * - §1 — start `opencode serve` on init; spawn background sessions
77
+ * via `POST /session` + `POST /session/{id}/prompt_async`.
78
+ * - §2.1 — open ONE global SSE subscription to `GET /event`.
79
+ * - §2.2 — `InstanceManager.add()` is atomic.
80
+ * - §5.1 — serve child on 127.0.0.1 with `--hostname` hardcoded.
81
+ * - §5.3 — SIGTERM/SIGINT trap walks the in-memory map, aborts
82
+ * running sessions, kills the serve child, exits.
83
+ * - §5.4 — on init, scan `bg/*.json`; rebuild in-memory map; mark
84
+ * orphaned `running`/`pending` as `failed`.
85
+ * - §6.1 — 32-byte secret for the serve child; `node:crypto` only in `serve.ts`.
86
+ * - §6.3 — only Odin may call `bizar_spawn_background`.
87
+ * - §7.1 — register 4 background tools: `bizar_spawn_background`,
88
+ * `bizar_status`, `bizar_collect`, `bizar_kill`.
89
+ * - §v2.1 — register 1 read-only tool: `bizar_get_plan_comments`.
90
+ * Reads `plans/<slug>/plan.json` so background agents can pick up
91
+ * user feedback pinned to the elements they're working on.
92
+ * Available to all agents (read-only — no serve child required).
93
+ * - §5.5 — `--hostname 127.0.0.1` hardcoded.
94
+ */
95
+
96
+ import type { Plugin, Hooks, PluginInput, PluginOptions } from "@opencode-ai/plugin";
97
+
98
+ import { createLogger, type Logger } from "./src/logger.js";
99
+ import { decide, isLogOnlyWarn } from "./src/loop.js";
100
+ import { fingerprint } from "./src/fingerprint.js";
101
+ import { StateStore, type SessionState } from "./src/state.js";
102
+ import { LogWriter } from "./src/report.js";
103
+ import {
104
+ normalizeOptions,
105
+ readEnvFlags,
106
+ findOffendingPath,
107
+ type NormalizedOptions,
108
+ type EnvFlags,
109
+ } from "./src/options.js";
110
+
111
+ import { ServeLifecycle } from "./src/serve.js";
112
+ import { HttpClient } from "./src/http-client.js";
113
+ import { EventStream } from "./src/event-stream.js";
114
+ import { BackgroundStateStore, type BackgroundState } from "./src/background-state.js";
115
+ import { InstanceManager } from "./src/background.js";
116
+ import { createBgSpawnTool } from "./src/tools/bg-spawn.js";
117
+ import { createBgStatusTool } from "./src/tools/bg-status.js";
118
+ import { createBgCollectTool } from "./src/tools/bg-collect.js";
119
+ import { createBgKillTool } from "./src/tools/bg-kill.js";
120
+ import { createBgGetCommentsTool } from "./src/tools/bg-get-comments.js";
121
+
122
+ // v0.4.0 — visual plan flow: settings, slash commands, plan tools
123
+ import { SettingsStore } from "./src/settings.js";
124
+ import { parseSlashCommand } from "./src/commands.js";
125
+ import { createPlanActionTool } from "./src/tools/plan-action.js";
126
+ import { createWaitForFeedbackTool } from "./src/tools/wait-for-feedback.js";
127
+
128
+ // v0.5.0 — visual plan wiring: side-effect executor + plan-fs
129
+ import { executeSideEffect, type ExecuteOptions } from "./src/commands-impl.js";
130
+
131
+ // --- Env-var constants (per spec §8) -------------------------------------
132
+
133
+ /** `BIZAR_SERVE_PORT` — default 0 (random). */
134
+ function readServePort(): number {
135
+ const raw = process.env.BIZAR_SERVE_PORT;
136
+ if (raw === undefined || raw === "") return 0;
137
+ const n = Number(raw);
138
+ if (!Number.isFinite(n) || n < 0 || n > 65535) return 0;
139
+ return Math.floor(n);
140
+ }
141
+
142
+ /** `BIZAR_SERVE_DISABLE=1` disables the serve child entirely. */
143
+ function readServeDisabled(): boolean {
144
+ return process.env.BIZAR_SERVE_DISABLE === "1";
145
+ }
146
+
147
+ /** `BIZAR_MAX_CONCURRENT_INSTANCES` — default 8. */
148
+ function readMaxConcurrent(): number {
149
+ const raw = process.env.BIZAR_MAX_CONCURRENT_INSTANCES;
150
+ if (raw === undefined || raw === "") return 8;
151
+ const n = Number(raw);
152
+ if (!Number.isFinite(n) || n < 1) return 8;
153
+ return Math.floor(n);
154
+ }
155
+
156
+ /** `BIZAR_BACKGROUND_TOOL_CALL_CAP` — default 500. */
157
+ function readToolCallCap(): number {
158
+ const raw = process.env.BIZAR_BACKGROUND_TOOL_CALL_CAP;
159
+ if (raw === undefined || raw === "") return 500;
160
+ const n = Number(raw);
161
+ if (!Number.isFinite(n) || n < 1) return 500;
162
+ return Math.floor(n);
163
+ }
164
+
165
+ /**
166
+ * v0.3.0 — `BIZAR_STALL_TIMEOUT_MS` — default 180000 (3 min).
167
+ * Range [10000, 600000]; out-of-range falls back to default.
168
+ */
169
+ function readStallTimeoutMs(): number {
170
+ const raw = process.env.BIZAR_STALL_TIMEOUT_MS;
171
+ if (raw === undefined || raw === "") return 180_000;
172
+ const n = Number(raw);
173
+ if (!Number.isFinite(n) || n < 10_000) return 180_000;
174
+ return Math.min(Math.floor(n), 600_000);
175
+ }
176
+
177
+ /**
178
+ * v0.3.0 — `BIZAR_THINKING_LOOP_TIMEOUT_MS` — default 300000 (5 min).
179
+ * Range [30000, 900000]; out-of-range falls back to default.
180
+ */
181
+ function readThinkingLoopTimeoutMs(): number {
182
+ const raw = process.env.BIZAR_THINKING_LOOP_TIMEOUT_MS;
183
+ if (raw === undefined || raw === "") return 300_000;
184
+ const n = Number(raw);
185
+ if (!Number.isFinite(n) || n < 30_000) return 300_000;
186
+ return Math.min(Math.floor(n), 900_000);
187
+ }
188
+
189
+ /**
190
+ * v0.3.0 — `BIZAR_MAX_INTERVENTIONS` — default 1.
191
+ * Range [1, 3]; out-of-range falls back to default.
192
+ */
193
+ function readMaxInterventions(): number {
194
+ const raw = process.env.BIZAR_MAX_INTERVENTIONS;
195
+ if (raw === undefined || raw === "") return 1;
196
+ const n = Number(raw);
197
+ if (!Number.isFinite(n) || n < 1) return 1;
198
+ return Math.min(Math.floor(n), 3);
199
+ }
200
+
201
+ /** `BIZAR_HTTP_TIMEOUT_MS` — default 30000. */
202
+ function readHttpTimeoutMs(): number {
203
+ const raw = process.env.BIZAR_HTTP_TIMEOUT_MS;
204
+ if (raw === undefined || raw === "") return 30_000;
205
+ const n = Number(raw);
206
+ if (!Number.isFinite(n) || n < 1000) return 30_000;
207
+ return Math.floor(n);
208
+ }
209
+
210
+ // --- Shutdown coordination -----------------------------------------------
211
+
212
+ /** Module-level guard for SIGTERM/SIGINT reentry (spec §5.3). */
213
+ let shuttingDown = false;
214
+ /** Module-level handle to the InstanceManager for the signal handlers. */
215
+ let instanceManagerHandle: InstanceManager | null = null;
216
+ let serveHandle: ServeLifecycle | null = null;
217
+ let streamHandle: EventStream | null = null;
218
+ let loggerHandle: Logger | null = null;
219
+
220
+ // --- Plugin entry point ---------------------------------------------------
221
+
222
+ /**
223
+ * Runtime context shared across all hooks for one plugin instance. Created
224
+ * during init and closed over by every hook closure.
225
+ */
226
+ interface RuntimeContext {
227
+ logger: Logger;
228
+ options: NormalizedOptions;
229
+ envFlags: EnvFlags;
230
+ stateStore: StateStore;
231
+ settingsStore: SettingsStore;
232
+ logWriter: LogWriter;
233
+ worktree: string;
234
+ /** Project directory (often equal to `worktree`; the viewer / TUI
235
+ * may use this to display paths differently). v0.5.0 — the synthetic
236
+ * `ToolContext` built for slash-command tool invocations reads this. */
237
+ directory: string;
238
+ /** sessionID → set of message IDs already processed (spec §4.5). */
239
+ seenMessageIds: Map<string, Set<string>>;
240
+ /** sessionID → pending system-transform message, set at warn/escalate. */
241
+ pendingInjections: Map<string, string>;
242
+ }
243
+
244
+ /**
245
+ * Default-exported Plugin function. The whole body is wrapped in try/catch
246
+ * so that any initialization error logs via the SDK and returns empty
247
+ * hooks — opencode never crashes on a broken plugin (spec §8.1).
248
+ */
249
+ const plugin: Plugin = async (
250
+ input: PluginInput,
251
+ rawOptions?: PluginOptions,
252
+ ) => {
253
+ try {
254
+ return await init(input, rawOptions);
255
+ } catch (err) {
256
+ try {
257
+ const client = input.client as unknown as {
258
+ app?: { log?: (input: unknown) => unknown };
259
+ };
260
+ client.app?.log?.({
261
+ body: {
262
+ service: "bizar",
263
+ level: "error",
264
+ message: `bizar: init failed: ${err instanceof Error ? err.message : String(err)}`,
265
+ },
266
+ });
267
+ } catch {
268
+ // ignore — logging must never throw
269
+ }
270
+ return {};
271
+ }
272
+ };
273
+
274
+ export default plugin;
275
+
276
+ // --- Init ------------------------------------------------------------------
277
+
278
+ /**
279
+ * Initialize the plugin and return the hooks object. Separated from the
280
+ * top-level `plugin` function so the try/catch wrapper is unambiguous.
281
+ */
282
+ async function init(
283
+ input: PluginInput,
284
+ rawOptions?: PluginOptions,
285
+ ): Promise<Hooks> {
286
+ const envFlags = readEnvFlags();
287
+ const { options, notes } = normalizeOptions(rawOptions as never);
288
+
289
+ const logger = createLogger(input.client as unknown as Parameters<typeof createLogger>[0]);
290
+ loggerHandle = logger;
291
+
292
+ // §6.4 — refuse to start if logDir or stateDir is inside a secret dir.
293
+ const offending = findOffendingPath(options);
294
+ if (offending !== null) {
295
+ logger.error(
296
+ `bizar: refusing to start — logDir/stateDir ${offending.path} is inside a secret directory (${offending.kind}). Set BIZAR_DISABLE=1 or specify a different path.`,
297
+ );
298
+ return {};
299
+ }
300
+
301
+ // §6.5 — BIZAR_DISABLE=1 disables the plugin entirely.
302
+ if (envFlags.disable) {
303
+ logger.debug("bizar: disabled via BIZAR_DISABLE=1");
304
+ return {};
305
+ }
306
+
307
+ // Log any non-default normalization notes (spec §6.2).
308
+ for (const note of notes) {
309
+ logger.warn(`bizar: ${note}`);
310
+ }
311
+
312
+ const stateStore = new StateStore(options.stateDir, logger);
313
+ const settingsStore = new SettingsStore(options.stateDir, logger);
314
+ const logWriter = new LogWriter(options.logDir, options.logRotationBytes, logger);
315
+
316
+ // §4.6 — stale session cleanup (best-effort, on init).
317
+ try {
318
+ const validIds = await readValidSessionIds(input);
319
+ const deleted = await stateStore.cleanup(7, validIds);
320
+ if (deleted > 0) {
321
+ logger.info(`bizar: cleaned up ${deleted} stale session file(s)`);
322
+ }
323
+ } catch (err) {
324
+ logger.warn(
325
+ `bizar: stale session cleanup failed: ${err instanceof Error ? err.message : String(err)}`,
326
+ );
327
+ }
328
+
329
+ // --- Background agents (v0.4.2) -----------------------------------------
330
+
331
+ let instanceManager: InstanceManager | null = null;
332
+ let serve: ServeLifecycle | null = null;
333
+ let stream: EventStream | null = null;
334
+ let bgAvailable = false;
335
+
336
+ if (readServeDisabled()) {
337
+ logger.info("bizar: background agents disabled via BIZAR_SERVE_DISABLE=1");
338
+ } else {
339
+ try {
340
+ const servePort = readServePort();
341
+ const bgStateStore = new BackgroundStateStore(options.stateDir, logger);
342
+ const backgroundStateCleanup = bgStateStore.cleanup(7).catch((err: unknown) => {
343
+ logger.warn(
344
+ `bizar: background state cleanup failed: ${err instanceof Error ? err.message : String(err)}`,
345
+ );
346
+ return 0;
347
+ });
348
+ const maxConcurrent = readMaxConcurrent();
349
+ const toolCallCap = readToolCallCap();
350
+ const httpTimeoutMs = readHttpTimeoutMs();
351
+ const stallTimeoutMs = readStallTimeoutMs();
352
+ const thinkingLoopTimeoutMs = readThinkingLoopTimeoutMs();
353
+ const maxInterventions = readMaxInterventions();
354
+
355
+ serve = new ServeLifecycle({
356
+ port: servePort,
357
+ worktree: input.worktree,
358
+ logger,
359
+ });
360
+ serveHandle = serve;
361
+ const serveInfo = await serve.start();
362
+ const http = new HttpClient({
363
+ baseUrl: `http://127.0.0.1:${serveInfo.port}`,
364
+ password: serveInfo.password,
365
+ logger,
366
+ timeoutMs: httpTimeoutMs,
367
+ });
368
+ const authHeader = `Basic ${btoa(`opencode:${serveInfo.password}`)}`;
369
+ stream = new EventStream({
370
+ baseUrl: `http://127.0.0.1:${serveInfo.port}`,
371
+ directory: input.worktree,
372
+ authHeader,
373
+ logger,
374
+ http,
375
+ });
376
+ streamHandle = stream;
377
+
378
+ instanceManager = new InstanceManager({
379
+ stateStore: bgStateStore,
380
+ maxConcurrent,
381
+ toolCallCap,
382
+ logger,
383
+ serve,
384
+ http,
385
+ stream,
386
+ stallTimeoutMs,
387
+ thinkingLoopTimeoutMs,
388
+ maxInterventions,
389
+ });
390
+ instanceManagerHandle = instanceManager;
391
+
392
+ // §5.4 — rebuild in-memory map from disk.
393
+ await instanceManager.rebuildInMemoryMap();
394
+
395
+ // §5.2 — crash-recovery handler.
396
+ serve.onUnexpectedExit(() => {
397
+ if (!instanceManager) return;
398
+ void (async () => {
399
+ try {
400
+ await instanceManager.shutdownAll();
401
+ } catch (err: unknown) {
402
+ logger.warn(
403
+ `bizar: shutdownAll on serve crash failed: ${
404
+ err instanceof Error ? err.message : String(err)
405
+ }`,
406
+ );
407
+ }
408
+ })();
409
+ });
410
+
411
+ // Open the SSE connection (best-effort — failure does not abort init).
412
+ try {
413
+ await stream.connect();
414
+ bgAvailable = true;
415
+ } catch (err: unknown) {
416
+ logger.warn(
417
+ `bizar: SSE connection failed: ${err instanceof Error ? err.message : String(err)}; background agents will still accept new spawns (will reconnect on demand)`,
418
+ );
419
+ }
420
+
421
+ // Surface the cleanup result.
422
+ const deletedBg = await backgroundStateCleanup;
423
+ if (deletedBg > 0) {
424
+ logger.info(`bizar: cleaned up ${deletedBg} stale background state file(s)`);
425
+ }
426
+
427
+ logger.info(
428
+ `bizar: background agents ready (port=${serveInfo.port}, cap=${maxConcurrent}, toolCallCap=${toolCallCap}, stallTimeoutMs=${stallTimeoutMs}, thinkingLoopTimeoutMs=${thinkingLoopTimeoutMs}, maxInterventions=${maxInterventions})`,
429
+ );
430
+ } catch (err: unknown) {
431
+ logger.warn(
432
+ `bizar: background agents unavailable: ${err instanceof Error ? err.message : String(err)}`,
433
+ );
434
+ // Leave `bgAvailable = false`. The tools will return a clear error.
435
+ }
436
+ }
437
+
438
+ // --- Signal traps (spec §5.3) ------------------------------------------
439
+
440
+ installSignalHandlers(logger, instanceManager, serve, stream);
441
+
442
+ const ctx: RuntimeContext = {
443
+ logger,
444
+ options,
445
+ envFlags,
446
+ stateStore,
447
+ settingsStore,
448
+ logWriter,
449
+ worktree: input.worktree,
450
+ directory: input.directory,
451
+ seenMessageIds: new Map(),
452
+ pendingInjections: new Map(),
453
+ };
454
+
455
+ return buildHooks(ctx, { instanceManager, bgAvailable });
456
+ }
457
+
458
+ // --- Signal handling (spec §5.3) -----------------------------------------
459
+
460
+ function installSignalHandlers(
461
+ logger: Logger,
462
+ instanceManager: InstanceManager | null,
463
+ serve: ServeLifecycle | null,
464
+ stream: EventStream | null,
465
+ ): void {
466
+ const onSignal = async (sig: "SIGTERM" | "SIGINT") => {
467
+ if (shuttingDown) return;
468
+ shuttingDown = true;
469
+ logger.warn(`bizar: received ${sig}; shutting down`);
470
+
471
+ // 1. Mark all in-memory instances as failed (spec §5.3 step 1).
472
+ if (instanceManager !== null) {
473
+ try {
474
+ await instanceManager.shutdownAll();
475
+ } catch (err: unknown) {
476
+ logger.warn(
477
+ `bizar: shutdownAll on ${sig} failed: ${
478
+ err instanceof Error ? err.message : String(err)
479
+ }`,
480
+ );
481
+ }
482
+ }
483
+
484
+ // 2. Close SSE.
485
+ if (stream !== null) {
486
+ try {
487
+ await stream.disconnect();
488
+ } catch {
489
+ // ignore
490
+ }
491
+ }
492
+
493
+ // 3. Kill serve child.
494
+ if (serve !== null) {
495
+ try {
496
+ await serve.stop();
497
+ } catch (err: unknown) {
498
+ logger.warn(
499
+ `bizar: serve.stop on ${sig} failed: ${
500
+ err instanceof Error ? err.message : String(err)
501
+ }`,
502
+ );
503
+ }
504
+ }
505
+
506
+ // 4. Exit. (Note: the host may keep the process alive if other work
507
+ // is pending, but for the plugin process this is the end.)
508
+ try {
509
+ process.exit(0);
510
+ } catch {
511
+ // process.exit may not be available in all environments; ignore.
512
+ }
513
+ };
514
+
515
+ // Idempotent registration — if the plugin is reloaded, we don't want
516
+ // duplicate handlers. Use `process.once` so each handler runs at most
517
+ // once per signal; the `shuttingDown` guard catches reentry.
518
+ for (const sig of ["SIGTERM", "SIGINT"] as const) {
519
+ try {
520
+ process.removeAllListeners(sig);
521
+ } catch {
522
+ // ignore
523
+ }
524
+ process.on(sig, () => {
525
+ void onSignal(sig);
526
+ });
527
+ }
528
+ }
529
+
530
+ // --- Init helpers ---------------------------------------------------------
531
+
532
+ /**
533
+ * Race a promise against a timeout. Returns the promise's value if it
534
+ * resolves in time; throws a labeled `Error` otherwise.
535
+ *
536
+ * The original promise is intentionally NOT cancelled (we don't have
537
+ * an `AbortSignal` to pass to the opencode client). If the underlying
538
+ * call eventually rejects after we've already returned, the caller
539
+ * should attach a no-op `.catch(() => undefined)` to suppress the
540
+ * unhandled-rejection warning.
541
+ *
542
+ * v0.5.2: extracted from `readValidSessionIds` so it can be unit
543
+ * tested in isolation. See `tests/init-helpers.test.ts`.
544
+ */
545
+ export async function withTimeout<T>(
546
+ promise: Promise<T>,
547
+ timeoutMs: number,
548
+ label: string,
549
+ ): Promise<T> {
550
+ let timer: ReturnType<typeof setTimeout> | undefined;
551
+ const timeoutPromise = new Promise<never>((_, reject) => {
552
+ timer = setTimeout(
553
+ () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
554
+ timeoutMs,
555
+ );
556
+ });
557
+ try {
558
+ return await Promise.race([promise, timeoutPromise]);
559
+ } finally {
560
+ if (timer !== undefined) clearTimeout(timer);
561
+ }
562
+ }
563
+
564
+ // --- Hooks ----------------------------------------------------------------
565
+
566
+ /**
567
+ * Best-effort read of valid session IDs from opencode. If `client.session`
568
+ * is unavailable or the call fails or times out, return an empty set —
569
+ * the age-based branch of the cleanup still runs (spec §4.6).
570
+ *
571
+ * v0.5.2 FIX (postmortem 2026-06-18, Layer 1): the previous version
572
+ * called `client.session.list()` with no timeout. If the session
573
+ * store was slow, busy, or in a broken state, the call would hang
574
+ * forever, blocking the plugin's `init()` and stalling the UI on a
575
+ * blank screen. We now race the call against a 1-second timeout and
576
+ * fall back to an empty set on timeout.
577
+ */
578
+ export async function readValidSessionIds(input: PluginInput): Promise<Set<string>> {
579
+ try {
580
+ const client = input.client as unknown as {
581
+ session?: { list?: () => Promise<{ data?: Array<{ id: string }> } | Array<{ id: string }>> };
582
+ };
583
+ if (!client.session || typeof client.session.list !== "function") {
584
+ return new Set();
585
+ }
586
+ // Suppress unhandled rejection if the call eventually rejects after
587
+ // the timeout has already fired (see `withTimeout` note above).
588
+ const listPromise = client.session.list();
589
+ listPromise.catch(() => undefined);
590
+ const result = await withTimeout(
591
+ listPromise,
592
+ 1000,
593
+ "client.session.list",
594
+ );
595
+ const list = Array.isArray(result) ? result : (result.data ?? []);
596
+ return new Set(list.map((s) => s.id));
597
+ } catch {
598
+ return new Set();
599
+ }
600
+ }
601
+
602
+ interface BgDeps {
603
+ instanceManager: InstanceManager | null;
604
+ bgAvailable: boolean;
605
+ }
606
+
607
+ // --- Slash-command helpers (v0.4.0) ------------------------------------
608
+
609
+ /**
610
+ * Read the user-typed text from a `chat.message` hook output.
611
+ *
612
+ * `output.parts` is a discriminated union (`Part[]`). We concatenate any
613
+ * TextPart entries. Other part types (file, tool, etc.) are skipped.
614
+ *
615
+ * Returns `null` if no text could be extracted (e.g. the message is a
616
+ * file-only attachment, or the parts array is missing/malformed).
617
+ */
618
+ function readMessageText(
619
+ output: { message?: unknown; parts?: unknown } | undefined,
620
+ ): string | null {
621
+ if (!output || !Array.isArray(output.parts)) return null;
622
+ const parts = output.parts as Array<{ type?: string; text?: string }>;
623
+ const fragments: string[] = [];
624
+ for (const part of parts) {
625
+ if (part && part.type === "text" && typeof part.text === "string") {
626
+ fragments.push(part.text);
627
+ }
628
+ }
629
+ const joined = fragments.join("\n").trim();
630
+ return joined === "" ? null : joined;
631
+ }
632
+
633
+ /**
634
+ * List the slugs of plans in the worktree's `plans/` directory.
635
+ *
636
+ * Pure best-effort: returns `[]` on missing dir, read errors, or any
637
+ * I/O exception. The slash-command parser uses this only for the
638
+ * `/plan list` response, so a missing list should not throw.
639
+ */
640
+ async function listPlanSlugs(worktree: string, logger: Logger): Promise<string[]> {
641
+ try {
642
+ const { readdirSync, statSync } = await import("node:fs");
643
+ const { join } = await import("node:path");
644
+ const plansDir = join(worktree, "plans");
645
+ let entries: string[];
646
+ try {
647
+ entries = readdirSync(plansDir);
648
+ } catch {
649
+ return [];
650
+ }
651
+ const slugs: string[] = [];
652
+ for (const name of entries) {
653
+ try {
654
+ const stat = statSync(join(plansDir, name));
655
+ if (stat.isDirectory()) slugs.push(name);
656
+ } catch {
657
+ // skip unreadable entries
658
+ }
659
+ }
660
+ slugs.sort();
661
+ return slugs;
662
+ } catch (err: unknown) {
663
+ logger.debug(
664
+ `bizar: listPlanSlugs failed: ${err instanceof Error ? err.message : String(err)}`,
665
+ );
666
+ return [];
667
+ }
668
+ }
669
+
670
+ /**
671
+ * Build the hooks object. Each hook is a small async function that
672
+ * delegates to the runtime context and the supporting modules.
673
+ */
674
+ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
675
+ // Build the 7 tools. We always register them; if the serve child is
676
+ // not available, the background tools return a clear error. The
677
+ // bizar_get_plan_comments, bizar_plan_action, and
678
+ // bizar_wait_for_feedback tools only need the worktree, so they
679
+ // work regardless of the serve child's state.
680
+ //
681
+ // v0.4.0 — added `bizar_plan_action` (CRUD on the v2 canvas) and
682
+ // `bizar_wait_for_feedback` (poll until feedback). Both are pure
683
+ // file I/O — no serve child required.
684
+ //
685
+ // v0.5.0 — renamed `bizarre_*` → `bizar_*` (single `r`) to match
686
+ // the docs and `config/opencode.json`. The earlier typo silently
687
+ // disabled the plan tools at runtime; this fix brings the registry
688
+ // in sync.
689
+ const basePlanTools = {
690
+ bizar_get_plan_comments: createBgGetCommentsTool({
691
+ worktree: ctx.worktree,
692
+ logger: ctx.logger,
693
+ }),
694
+ bizar_plan_action: createPlanActionTool({
695
+ worktree: ctx.worktree,
696
+ logger: ctx.logger,
697
+ }),
698
+ bizar_wait_for_feedback: createWaitForFeedbackTool({
699
+ worktree: ctx.worktree,
700
+ logger: ctx.logger,
701
+ }),
702
+ };
703
+ const tools = bg.instanceManager
704
+ ? {
705
+ ...basePlanTools,
706
+ bizar_spawn_background: createBgSpawnTool({
707
+ instanceManager: bg.instanceManager,
708
+ http: (bg.instanceManager as unknown as { http: HttpClient }).http,
709
+ worktree: ctx.worktree,
710
+ logger: ctx.logger,
711
+ }),
712
+ bizar_status: createBgStatusTool({
713
+ instanceManager: bg.instanceManager,
714
+ logger: ctx.logger,
715
+ }),
716
+ bizar_collect: createBgCollectTool({
717
+ instanceManager: bg.instanceManager,
718
+ logger: ctx.logger,
719
+ }),
720
+ bizar_kill: createBgKillTool({
721
+ instanceManager: bg.instanceManager,
722
+ logger: ctx.logger,
723
+ }),
724
+ }
725
+ : {
726
+ ...basePlanTools,
727
+ ...bgDisabledTools(ctx.logger),
728
+ };
729
+
730
+ return {
731
+ // §3.1 — config: no mutation. We already resolved options in init().
732
+ config: async () => {
733
+ // intentionally empty — options are resolved at init time
734
+ },
735
+
736
+ // §3.1, §4.5.1 — event: track session boundaries. We do NOT create
737
+ // the state file here (canonical lifecycle: file is created at the
738
+ // `chat.message` seed, per spec §4.5.1).
739
+ event: async ({ event }) => {
740
+ try {
741
+ const ev = event as { type?: string; sessionID?: string };
742
+ const type = ev.type;
743
+ const sessionID = ev.sessionID;
744
+ if (!type || !sessionID) return;
745
+
746
+ if (type === "session.deleted") {
747
+ await ctx.stateStore.withLock(sessionID, async () => {
748
+ await ctx.stateStore.delete(sessionID);
749
+ });
750
+ ctx.pendingInjections.delete(sessionID);
751
+ ctx.seenMessageIds.delete(sessionID);
752
+ }
753
+ // Other event types are no-ops on the hook side. The state file
754
+ // is updated by `chat.message` and `tool.execute.before/after`.
755
+ } catch (err) {
756
+ ctx.logger.warn(
757
+ `bizar: event hook error: ${err instanceof Error ? err.message : String(err)}`,
758
+ );
759
+ }
760
+ },
761
+
762
+ // §4.5 — seed session state on first user message per session.
763
+ // The hook key is the literal string "chat.message" (with a dot) per
764
+ // the opencode plugin API.
765
+ //
766
+ // §v4.1 (v0.4.0) — Slash command detection happens FIRST, before the
767
+ // existing state-seeding logic. If the user typed a slash command we:
768
+ // 1. Apply any settings patch via `SettingsStore`.
769
+ // 2. Execute the side-effect (v0.5.0 — previously silently dropped).
770
+ // 3. Throw the response text. The host (TUI/CLI) surfaces it to
771
+ // the user; the LLM sees it on the next turn as a tool error.
772
+ //
773
+ // We chose throw-over-mutate because:
774
+ // - Throwing is the same pattern `tool.execute.before` uses for
775
+ // loop-detection blocks (see §5.4). It's well-tested in production.
776
+ // - Mutating `output.parts` / `output.message` is brittle — the
777
+ // shapes differ between opencode versions, and the host may not
778
+ // honor a synthetic `text` part from a hook.
779
+ "chat.message": async (input, output) => {
780
+ const sessionID = input.sessionID;
781
+ const messageID = input.messageID;
782
+ const agent = input.agent;
783
+ if (!sessionID) return;
784
+
785
+ // --- v0.4.0: slash command detection -----------------------------
786
+ // Runs before the disableLoop/disableLog check — slash commands
787
+ // should work even when loop detection / logging is off.
788
+ try {
789
+ const messageText = readMessageText(output);
790
+ if (messageText !== null) {
791
+ const currentSettings = await ctx.settingsStore.get();
792
+ const availableSlugs = await listPlanSlugs(ctx.worktree, ctx.logger);
793
+ const result = parseSlashCommand(messageText, {
794
+ currentSettings,
795
+ availablePlanSlugs: availableSlugs,
796
+ defaultPort: 4321,
797
+ });
798
+ if (result !== null) {
799
+ if (result.settingsPatch) {
800
+ await ctx.settingsStore.update(result.settingsPatch);
801
+ }
802
+ // --- v0.5.0: execute the side-effect (was silently dropped
803
+ // in v0.4.0). The executor returns an optional
804
+ // override/suffix that replaces/appends the
805
+ // parser's response. Tool invocations build a
806
+ // synthetic ToolContext and pre-validate args.
807
+ let finalResponse = result.response;
808
+ if (result.sideEffect !== undefined) {
809
+ const execOpts: ExecuteOptions = {
810
+ tools,
811
+ defaultTemplate: currentSettings.defaultTemplate,
812
+ defaultPort: 4321,
813
+ };
814
+ try {
815
+ const exec = await executeSideEffect(
816
+ result.sideEffect,
817
+ {
818
+ worktree: ctx.worktree,
819
+ directory: ctx.directory,
820
+ logger: ctx.logger,
821
+ },
822
+ execOpts,
823
+ );
824
+ if (exec.responseOverride !== undefined) {
825
+ finalResponse = exec.responseOverride;
826
+ } else if (exec.responseSuffix !== undefined) {
827
+ finalResponse = `${result.response}${exec.responseSuffix}`;
828
+ }
829
+ } catch (execErr: unknown) {
830
+ // Defense-in-depth — `executeSideEffect` already catches
831
+ // its own errors, but if it ever throws (e.g. a bug in
832
+ // a future handler) we stringify into the response
833
+ // rather than crashing the chat hook.
834
+ const msg =
835
+ execErr instanceof Error ? execErr.message : String(execErr);
836
+ ctx.logger.warn(`bizar: side-effect crashed: ${msg}`);
837
+ finalResponse = `Command failed: ${msg}`;
838
+ }
839
+ }
840
+ // Surface the response to the user/host. We throw so the
841
+ // message is treated as handled; the LLM does not process
842
+ // it further. The host renders the throw message.
843
+ throw new Error(finalResponse);
844
+ }
845
+ }
846
+ } catch (err) {
847
+ // Re-throw — if it's our slash-command response, propagate it.
848
+ // If it's an unexpected I/O error, log and fall through.
849
+ if (err instanceof Error && err.message !== "" && err.message !== undefined) {
850
+ // Heuristic: errors we throw ourselves contain a non-technical
851
+ // response (starts with one of the canonical prefixes OR is
852
+ // simply a human-readable sentence). Errors from I/O contain
853
+ // "ENOENT", "EACCES", etc. We always re-throw errors that the
854
+ // parser produced (response starts with known prefixes or
855
+ // doesn't contain a colon+code pattern).
856
+ const msg = err.message;
857
+ const looksLikeIoError = /(ENOENT|EACCES|EROFS|EISDIR|EPERM|Error:)/.test(msg);
858
+ if (!looksLikeIoError) {
859
+ throw err;
860
+ }
861
+ }
862
+ ctx.logger.warn(
863
+ `bizar: slash-command handling failed: ${err instanceof Error ? err.message : String(err)}`,
864
+ );
865
+ // Fall through to normal state seeding.
866
+ }
867
+
868
+ // --- v0.3.0: state seeding ----------------------------------------
869
+ if (ctx.envFlags.disableLoop && ctx.envFlags.disableLog) return;
870
+
871
+ // Dedupe by message ID (spec §4.5).
872
+ if (messageID) {
873
+ let seen = ctx.seenMessageIds.get(sessionID);
874
+ if (!seen) {
875
+ seen = new Set();
876
+ ctx.seenMessageIds.set(sessionID, seen);
877
+ }
878
+ if (seen.has(messageID)) return; // duplicate event — no-op
879
+ seen.add(messageID);
880
+ }
881
+
882
+ // Seed parentAgent and create the state file on the first
883
+ // message per session (spec §4.5.1 — canonical lifecycle).
884
+ await ctx.stateStore.withLock(sessionID, async () => {
885
+ const existing = await ctx.stateStore.load(sessionID);
886
+ if (existing.parentAgent !== null) return; // already seeded
887
+ const now = Date.now();
888
+ const seeded: SessionState = {
889
+ sessionId: sessionID,
890
+ parentAgent: agent ?? null,
891
+ startedAt: now,
892
+ lastActivityAt: now,
893
+ turnCount: 1,
894
+ toolCalls: [],
895
+ warningsIssued: 0,
896
+ blocksTriggered: 0,
897
+ };
898
+ await ctx.stateStore.save(seeded);
899
+ ctx.logger.debug(`bizar: seeded session ${sessionID} with parentAgent=${seeded.parentAgent}`);
900
+ });
901
+ },
902
+
903
+ // §3.1, §5.1, §5.4 — primary loop-detection point.
904
+ "tool.execute.before": async (input, output) => {
905
+ if (ctx.envFlags.disableLoop) return;
906
+ const sessionID = input.sessionID;
907
+ const tool = input.tool;
908
+ if (!sessionID || !tool) return;
909
+
910
+ // Compute fingerprint of (tool, args). args is mutable in the
911
+ // hook output; we read it as-is for fingerprinting.
912
+ const args = output.args;
913
+ const fp = fingerprint(tool, args, ctx.worktree);
914
+
915
+ // All state mutations go through the per-session mutex (§4.3).
916
+ await ctx.stateStore.withLock(sessionID, async () => {
917
+ const state = await ctx.stateStore.load(sessionID);
918
+ // Lazy fallback for subagent-only sessions: if the state file
919
+ // doesn't exist yet (no chat.message has fired), create the
920
+ // empty state now (spec §4.5.1).
921
+ if (state.startedAt === 0) {
922
+ const now = Date.now();
923
+ state.parentAgent = null;
924
+ state.startedAt = now;
925
+ state.lastActivityAt = now;
926
+ }
927
+
928
+ // Append the current call to the state before deciding (§5.1:
929
+ // the count includes the current call).
930
+ const now = Date.now();
931
+ state.toolCalls.push({ tool, fingerprint: fp, at: now });
932
+ // Cap toolCalls at last 50 (§4.1).
933
+ if (state.toolCalls.length > 50) {
934
+ state.toolCalls.splice(0, state.toolCalls.length - 50);
935
+ }
936
+ state.lastActivityAt = now;
937
+ state.turnCount += 1;
938
+
939
+ const decision = decide(state, fp, now, ctx.options);
940
+
941
+ if (decision.action === "allow") {
942
+ await ctx.stateStore.save(state);
943
+ return;
944
+ }
945
+
946
+ if (decision.action === "block") {
947
+ state.blocksTriggered += 1;
948
+ await ctx.stateStore.save(state);
949
+ // Throw from the hook — surfaces as a tool error in the TUI
950
+ // and runs BEFORE opencode's doom_loop recovery (§3.3).
951
+ throw new Error(decision.reason);
952
+ }
953
+
954
+ // warn or escalate — log first, then queue the injection.
955
+ if (decision.action === "warn") {
956
+ state.warningsIssued += 1;
957
+ if (isLogOnlyWarn(decision, ctx.options)) {
958
+ // Threshold-3 band: log only, no injection (§5.4 row 1).
959
+ ctx.logger.warn(`bizar: ${decision.reason}`);
960
+ } else {
961
+ // Threshold-5/8 band: queue system-transform injection.
962
+ ctx.pendingInjections.set(sessionID, decision.reason);
963
+ ctx.logger.warn(`bizar: ${decision.reason}`);
964
+ }
965
+ } else {
966
+ // escalate — always inject.
967
+ ctx.pendingInjections.set(sessionID, decision.reason);
968
+ ctx.logger.warn(`bizar: ${decision.reason}`);
969
+ }
970
+
971
+ await ctx.stateStore.save(state);
972
+ });
973
+ },
974
+
975
+ // §3.1 — record the call result and update the outcome.
976
+ "tool.execute.after": async (input, output) => {
977
+ if (ctx.envFlags.disableLoop && ctx.envFlags.disableLog) return;
978
+ const sessionID = input.sessionID;
979
+ const tool = input.tool;
980
+ if (!sessionID || !tool) return;
981
+
982
+ const startMs = Date.now();
983
+
984
+ await ctx.stateStore.withLock(sessionID, async () => {
985
+ const state = await ctx.stateStore.load(sessionID);
986
+ if (state.startedAt === 0) return; // nothing to update
987
+ // Find the matching call by fingerprint. The hook appends the
988
+ // call in `before`; we update its outcome here.
989
+ const fp = fingerprint(tool, input.args, ctx.worktree);
990
+ const idx = findLastIndex(state.toolCalls, (c) => c.fingerprint === fp);
991
+ if (idx >= 0) {
992
+ const call = state.toolCalls[idx];
993
+ if (call) {
994
+ call.outcome = output && typeof output.output === "string" ? "ok" : "error";
995
+ }
996
+ }
997
+ state.lastActivityAt = Date.now();
998
+ await ctx.stateStore.save(state);
999
+ });
1000
+
1001
+ // Per-call log line (§10.1). Metadata only — no args.
1002
+ if (!ctx.envFlags.disableLog) {
1003
+ const durationMs = Date.now() - startMs;
1004
+ const outcome: "ok" | "error" = output && typeof output.output === "string" ? "ok" : "error";
1005
+ const fp = fingerprint(tool, input.args, ctx.worktree);
1006
+ try {
1007
+ await ctx.logWriter.write({
1008
+ sessionId: sessionID,
1009
+ agent: null, // per-call agent attribution removed (§4.4)
1010
+ tool,
1011
+ fingerprint: fp,
1012
+ outcome,
1013
+ durationMs,
1014
+ });
1015
+ } catch (err) {
1016
+ ctx.logger.warn(
1017
+ `bizar: log write failed: ${err instanceof Error ? err.message : String(err)}`,
1018
+ );
1019
+ }
1020
+ }
1021
+ },
1022
+
1023
+ // §3.1, §5.4 — handoff injection point. We push a single string onto
1024
+ // `output.system` if a pending injection is queued for this session.
1025
+ "experimental.chat.system.transform": async (input, output) => {
1026
+ const sessionID = input.sessionID;
1027
+ if (!sessionID) return;
1028
+ const pending = ctx.pendingInjections.get(sessionID);
1029
+ if (pending) {
1030
+ output.system.push(pending);
1031
+ ctx.pendingInjections.delete(sessionID);
1032
+ }
1033
+ },
1034
+
1035
+ // v0.4.2 — register the 4 background tools.
1036
+ tool: tools,
1037
+
1038
+ // v0.4.2 — dispose hook. Opencode calls this when the plugin is
1039
+ // being torn down. We do a best-effort cleanup similar to the
1040
+ // signal trap, but we do NOT call `process.exit` — that's the
1041
+ // signal handler's job.
1042
+ dispose: async () => {
1043
+ ctx.logger.debug("bizar: dispose hook fired");
1044
+ if (instanceManagerHandle !== null) {
1045
+ try {
1046
+ await instanceManagerHandle.shutdownAll();
1047
+ } catch (err: unknown) {
1048
+ ctx.logger.warn(
1049
+ `bizar: dispose: shutdownAll failed: ${
1050
+ err instanceof Error ? err.message : String(err)
1051
+ }`,
1052
+ );
1053
+ }
1054
+ }
1055
+ if (streamHandle !== null) {
1056
+ try {
1057
+ await streamHandle.disconnect();
1058
+ } catch {
1059
+ // ignore
1060
+ }
1061
+ }
1062
+ if (serveHandle !== null) {
1063
+ try {
1064
+ await serveHandle.stop();
1065
+ } catch (err: unknown) {
1066
+ ctx.logger.warn(
1067
+ `bizar: dispose: serve.stop failed: ${
1068
+ err instanceof Error ? err.message : String(err)
1069
+ }`,
1070
+ );
1071
+ }
1072
+ }
1073
+ },
1074
+ };
1075
+ }
1076
+
1077
+ function findLastIndex<T>(
1078
+ arr: readonly T[],
1079
+ predicate: (item: T) => boolean,
1080
+ ): number {
1081
+ for (let i = arr.length - 1; i >= 0; i--) {
1082
+ const item = arr[i];
1083
+ if (item !== undefined && predicate(item)) return i;
1084
+ }
1085
+ return -1;
1086
+ }
1087
+
1088
+ /**
1089
+ * When the serve child failed to start, register the 4 background tools
1090
+ * as stubs that return a clear error. This keeps the agent experience
1091
+ * consistent: calling `bizar_spawn_background` always returns JSON, not
1092
+ * a thrown exception from the tool framework. The plan tools
1093
+ * (`bizar_get_plan_comments`, `bizar_plan_action`, `bizar_wait_for_feedback`)
1094
+ * are NOT background tools — they read/write plan files directly — and
1095
+ * are always registered in `buildHooks`.
1096
+ */
1097
+ function bgDisabledTools(logger: Logger): Hooks["tool"] {
1098
+ const disabled = (name: string) =>
1099
+ async () => {
1100
+ logger.debug(`bizar: ${name} called but background agents are disabled`);
1101
+ return {
1102
+ output: JSON.stringify({
1103
+ error: "background agents are disabled (opencode serve unavailable). See plugin logs.",
1104
+ }),
1105
+ };
1106
+ };
1107
+ return {
1108
+ bizar_spawn_background: { execute: disabled("bizar_spawn_background") } as never,
1109
+ bizar_status: { execute: disabled("bizar_status") } as never,
1110
+ bizar_collect: { execute: disabled("bizar_collect") } as never,
1111
+ bizar_kill: { execute: disabled("bizar_kill") } as never,
1112
+ };
1113
+ }