@polderlabs/bizar-plugin 0.8.0 → 0.8.2

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/README.md CHANGED
@@ -155,7 +155,7 @@ The plugin starts one `opencode serve` process on init (single-serve, multi-sess
155
155
  const result = await bizarre_spawn_background({
156
156
  agent: "mimir", // which agent to run
157
157
  prompt: "Research X and return findings", // what to do
158
- model: "openrouter/minimax-m3", // optional: override model
158
+ model: "minimax/minimax-m3", // optional: override model
159
159
  timeoutMs: 300_000, // optional: default 5 min, max 30 min
160
160
  }, ctx);
161
161
  console.log(result.instanceId); // "bgr_01ARSH3J5V..."
package/index.ts CHANGED
@@ -126,7 +126,11 @@ import { SettingsStore } from "./src/settings.js";
126
126
  import { parseSlashCommand } from "./src/commands.js";
127
127
  import { createPlanActionTool } from "./src/tools/plan-action.js";
128
128
  import { createWaitForFeedbackTool } from "./src/tools/wait-for-feedback.js";
129
- import { stripInlineThinkBlocks } from "./src/reasoning-clean.js";
129
+ import {
130
+ stripInlineThinkBlocks,
131
+ wrapFetchForReasoningCleanup,
132
+ type FetchLike,
133
+ } from "./src/reasoning-clean.js";
130
134
 
131
135
  // v0.5.0 — visual plan wiring: side-effect executor + plan-fs
132
136
  import { executeSideEffect, type ExecuteOptions } from "./src/commands-impl.js";
@@ -223,6 +227,45 @@ let streamHandle: EventStream | null = null;
223
227
  let loggerHandle: Logger | null = null;
224
228
  const signalHandlerRefs = new Map<"SIGTERM" | "SIGINT", () => void>();
225
229
 
230
+ /** v0.6.2 — Set to `true` after the first time we wrap `globalThis.fetch`
231
+ * with the reasoning-clean wrapper. Subsequent calls in the same process
232
+ * are no-ops, so a plugin reload cannot double-wrap. */
233
+ let fetchWrapInstalled = false;
234
+
235
+ /**
236
+ * v0.6.2 — Reasoning directive. Install the reasoning-clean fetch wrap
237
+ * on `globalThis.fetch`. The wrap strips inline ``...</think>` (and the
238
+ * other recognised variants — see `src/reasoning-clean.ts`) from
239
+ * chat-completions responses targeting `openrouter`/`minimax`, while
240
+ * leaving the structured `reasoning` / `reasoning_details` fields
241
+ * intact.
242
+ *
243
+ * This is the workaround for the fact that opencode 1.17.9 does not
244
+ * fire the `config` hook in this runtime (the SDK type declares it, but
245
+ * the host never calls it). By the time the host would call `config`,
246
+ * the plugin would already be past init — and the AI SDK is already
247
+ * using the unwrapped fetch. So we wrap fetch once, globally, as the
248
+ * plugin initialises. Subsequent reloads in the same process are a
249
+ * no-op thanks to the `fetchWrapInstalled` flag.
250
+ */
251
+ function installFetchReasoningCleanup(logger: Logger): void {
252
+ if (fetchWrapInstalled) return;
253
+ const original = globalThis.fetch;
254
+ if (typeof original !== "function") {
255
+ logger.warn("bizar: globalThis.fetch is not a function; reasoning-clean wrap skipped");
256
+ return;
257
+ }
258
+ const wrapped = wrapFetchForReasoningCleanup(
259
+ original.bind(globalThis) as FetchLike,
260
+ {
261
+ debug: (msg) => logger.debug(msg),
262
+ },
263
+ );
264
+ globalThis.fetch = wrapped as typeof globalThis.fetch;
265
+ fetchWrapInstalled = true;
266
+ logger.info("bizar: reasoning-clean fetch wrap installed (openrouter/minimax)");
267
+ }
268
+
226
269
  // --- Plugin entry point ---------------------------------------------------
227
270
 
228
271
  /**
@@ -319,6 +362,16 @@ async function init(
319
362
  logger.warn(`bizar: ${note}`);
320
363
  }
321
364
 
365
+ // v0.6.2 — Reasoning directive. Wrap globalThis.fetch so that inline
366
+ // ``...</think>` blocks in chat completions responses
367
+ // from openrouter/minimax providers are stripped from `content` even
368
+ // when the model also emits structured reasoning. The `config` hook
369
+ // in the opencode plugin API is declared in the SDK type but does NOT
370
+ // fire in 1.17.9 (confirmed via debug probe 2026-06-24), so we wrap
371
+ // fetch globally as a fallback. Idempotent — only the first call in
372
+ // this process actually wraps.
373
+ installFetchReasoningCleanup(logger);
374
+
322
375
  const stateStore = new StateStore(options.stateDir, logger);
323
376
  const settingsStore = new SettingsStore(options.stateDir, logger);
324
377
  const logWriter = new LogWriter(options.logDir, options.logRotationBytes, logger);
@@ -443,6 +496,7 @@ let bgAvailable = false;
443
496
  maxConcurrent,
444
497
  toolCallCap,
445
498
  logger,
499
+ worktree: input.worktree,
446
500
  serve,
447
501
  http,
448
502
  stream,
@@ -757,22 +811,30 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
757
811
  // sees the same thinking text twice — once in the proper panel and
758
812
  // again as visible message text below it.
759
813
  //
760
- // The opencode plugin API in this version does NOT trigger a
761
- // `config` hook (the `wrap-fetch` workaround from v0.6.1 is dead
762
- // code in current builds), so we cannot post-process the response
763
- // stream. The only working hooks that can help are:
814
+ // Defence in depth (three layers, in order of impact):
815
+ //
816
+ // 1. `installFetchReasoningCleanup` (init-time) wraps
817
+ // `globalThis.fetch` with `wrapFetchForReasoningCleanup` from
818
+ // `src/reasoning-clean.ts`. The wrap strips the inline ``
819
+ // blocks from chat-completions responses to `openrouter` /
820
+ // `minimax` while leaving the structured reasoning fields
821
+ // alone. This is the only layer that fixes the CURRENT
822
+ // response in-flight. The opencode plugin API in 1.17.9 declares
823
+ // a `config` hook in the SDK type but does not actually fire it
824
+ // (confirmed via debug probe 2026-06-24), so we wrap fetch
825
+ // globally instead.
764
826
  //
765
- // 1. `experimental.chat.system.transform` — runs every turn; we
827
+ // 2. `experimental.chat.system.transform` — runs every turn; we
766
828
  // push a directive telling the model to put thinking in the
767
829
  // structured field only.
768
- // 2. `experimental.chat.messages.transform` — runs before each
830
+ //
831
+ // 3. `experimental.chat.messages.transform` — runs before each
769
832
  // request; we strip `` blocks from previous assistant
770
833
  // messages so the model sees clean history and is less likely
771
834
  // to keep emitting inline ``.
772
835
  //
773
- // Neither fixes the CURRENT response (the model has already
774
- // returned), but together they strongly reduce — and in many cases
775
- // eliminate — the duplication on subsequent turns.
836
+ // Layers 2 and 3 reduce the frequency of the leak; layer 1 strips
837
+ // any leak that still slips through.
776
838
  const REASONING_DIRECTIVE_MARKER = "BIZAR_REASONING_DIRECTIVE_v0.6.2";
777
839
  const REASONING_DIRECTIVE = [
778
840
  REASONING_DIRECTIVE_MARKER,
@@ -903,6 +965,40 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
903
965
  }
904
966
  },
905
967
 
968
+ // v0.6.2 — Reasoning directive. Strip inline `` blocks
969
+ // from the FINAL text of each completed assistant text part. This is
970
+ // the post-processing layer that fixes the CURRENT response in cases
971
+ // where the model emits its chain-of-thought in BOTH the structured
972
+ // `reasoning` field AND inline in `content` (the M3-via-OpenRouter
973
+ // leak). opencode's openrouter SDK does not strip the inline blocks,
974
+ // so we do it here at the boundary between the SDK output and the
975
+ // UI rendering. The `config` hook that the SDK type declares for
976
+ // fetch-level wrapping does NOT fire in 1.17.9, and the AI SDK
977
+ // uses `Bun.fetch` (read-only) rather than `globalThis.fetch`, so a
978
+ // fetch wrap is a no-op in this runtime. `experimental.text.complete`
979
+ // is the working alternative — it runs on every completed text
980
+ // part, with mutable `output.text`. Idempotent: stripping already-
981
+ // cleaned text is a no-op.
982
+ "experimental.text.complete": async (input, output) => {
983
+ try {
984
+ const original = output.text;
985
+ if (typeof original !== "string" || !original.includes("<think>")) return;
986
+ const cleaned = stripInlineThinkBlocks(original);
987
+ if (cleaned !== original) {
988
+ output.text = cleaned;
989
+ ctx.logger.debug(
990
+ `bizar: text.complete stripped think blocks (session=${input.sessionID} message=${input.messageID} part=${input.partID} ${original.length}→${cleaned.length}B)`,
991
+ );
992
+ }
993
+ } catch (err) {
994
+ ctx.logger.warn(
995
+ `bizar: text.complete failed (passing through): ${
996
+ err instanceof Error ? err.message : String(err)
997
+ }`,
998
+ );
999
+ }
1000
+ },
1001
+
906
1002
  // §3.1, §4.5.1 — event: track session boundaries. We do NOT create
907
1003
  // the state file here (canonical lifecycle: file is created at the
908
1004
  // `chat.message` seed, per spec §4.5.1).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar-plugin",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Bizar opencode plugin — loop detection, status reporting, handoff signal, background agents, and slash commands + visual plan flow for subagent activity",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -11,7 +11,7 @@
11
11
  "scripts": {
12
12
  "check:imports": "bash scripts/check-forbidden-imports.sh",
13
13
  "typecheck": "tsc --noEmit",
14
- "test": "npm run check:imports && bun test tests/loop.test.ts tests/block.test.ts tests/stall-think.test.ts tests/tools/bg-get-comments.test.ts tests/settings.test.ts tests/commands.test.ts tests/commands-impl.test.ts tests/tools/plan-action.test.ts tests/tools/wait-for-feedback.test.ts"
14
+ "test": "npm run check:imports && bun test tests/loop.test.ts tests/block.test.ts tests/stall-think.test.ts tests/tools/bg-get-comments.test.ts tests/tools/opencode-runner.test.ts tests/settings.test.ts tests/commands.test.ts tests/commands-impl.test.ts tests/tools/plan-action.test.ts tests/tools/wait-for-feedback.test.ts tests/reasoning-clean.test.ts"
15
15
  },
16
16
  "keywords": [
17
17
  "opencode",
package/src/background.ts CHANGED
@@ -145,9 +145,16 @@ export class InstanceManager {
145
145
  private maxConcurrent: number;
146
146
  private toolCallCap: number;
147
147
  private logger: Logger;
148
- private serve: ServeLifecycle;
149
- private http: HttpClient;
150
- private stream: EventStream;
148
+ // v0.8.0 — `serve`, `http`, `stream` are nullable to support the
149
+ // bg-only mode used when the opencode serve child is unavailable
150
+ // (BIZAR_SERVE_DISABLE=1, startup failure) or when this process IS a
151
+ // bg-spawned `opencode run` subprocess. In bg-only mode, every method
152
+ // that would otherwise call `this.http.X` or `this.stream.X` is a
153
+ // no-op; state transitions still happen via the runner's `onExit`
154
+ // callback (see src/tools/bg-spawn.ts).
155
+ private serve: ServeLifecycle | null;
156
+ private http: HttpClient | null;
157
+ private stream: EventStream | null;
151
158
  private worktree: string;
152
159
  // v0.3.0 — stall and thinking-loop protection
153
160
  private stallTimeoutMs: number;
@@ -163,9 +170,13 @@ export class InstanceManager {
163
170
  maxConcurrent: number;
164
171
  toolCallCap: number;
165
172
  logger: Logger;
166
- serve: ServeLifecycle;
167
- http: HttpClient;
168
- stream: EventStream;
173
+ // v0.8.0 — `worktree` is now an explicit param (was previously
174
+ // derived from `opts.serve.worktree`, which is impossible when
175
+ // `serve` is null in bg-only mode).
176
+ worktree: string;
177
+ serve: ServeLifecycle | null;
178
+ http: HttpClient | null;
179
+ stream: EventStream | null;
169
180
  // v0.3.0
170
181
  stallTimeoutMs?: number;
171
182
  thinkingLoopTimeoutMs?: number;
@@ -178,7 +189,7 @@ export class InstanceManager {
178
189
  this.serve = opts.serve;
179
190
  this.http = opts.http;
180
191
  this.stream = opts.stream;
181
- this.worktree = opts.serve.worktree;
192
+ this.worktree = opts.worktree;
182
193
  this.stallTimeoutMs = Math.max(
183
194
  1_000,
184
195
  Math.floor(opts.stallTimeoutMs ?? 180_000),
@@ -188,13 +199,23 @@ export class InstanceManager {
188
199
  Math.floor(opts.thinkingLoopTimeoutMs ?? 300_000),
189
200
  );
190
201
  this.maxInterventions = Math.max(1, Math.floor(opts.maxInterventions ?? 1));
191
- // Schedule the periodic stall + thinking-loop checker. The interval
192
- // reference is stored so `shutdownAll` / `dispose` can clear it.
193
- this.stallCheckerTimer = setInterval(
194
- () => void this.runStallAndLoopChecks(),
195
- STALL_CHECK_INTERVAL_MS,
196
- );
197
- this.stallCheckerTimer.unref?.();
202
+ // Schedule the periodic stall + thinking-loop checker ONLY when we
203
+ // have a working HTTP client. In bg-only mode the runner owns the
204
+ // subprocess lifecycle, so the checker has nothing to do.
205
+ if (this.http !== null) {
206
+ this.stallCheckerTimer = setInterval(
207
+ () => void this.runStallAndLoopChecks(),
208
+ STALL_CHECK_INTERVAL_MS,
209
+ );
210
+ this.stallCheckerTimer.unref?.();
211
+ }
212
+ }
213
+
214
+ /** True iff the manager was constructed without an HTTP client (no
215
+ * opencode serve child reachable). HTTP-dependent operations are
216
+ * no-ops in this mode. */
217
+ get isBgOnly(): boolean {
218
+ return this.http === null;
198
219
  }
199
220
 
200
221
  // --- Getters ------------------------------------------------------------
@@ -238,6 +259,11 @@ export class InstanceManager {
238
259
  */
239
260
  async runStallAndLoopChecks(): Promise<void> {
240
261
  if (this.stallCheckerDisabled) return;
262
+ // v0.8.0 — bg-only mode has no HTTP client, so stall and
263
+ // intervention are no-ops. The constructor also skips registering
264
+ // the interval in this mode, but a stray call from a test or future
265
+ // caller must still be safe.
266
+ if (this.http === null) return;
241
267
  // Snapshot the instance ids so we do not iterate while the map mutates.
242
268
  const ids: string[] = [];
243
269
  for (const inst of this.instances.values()) {
@@ -424,6 +450,23 @@ export class InstanceManager {
424
450
  );
425
451
  return;
426
452
  }
453
+ // v0.8.0 — bg-only mode has no HTTP client. The subprocess is
454
+ // already owned by opencode-runner.ts (see src/tools/bg-spawn.ts);
455
+ // mark the instance killed in-memory and let the runner notice the
456
+ // status change when the process eventually exits. We do NOT try
457
+ // to kill the OS process from here — that's the runner's job and
458
+ // we don't have a clean processId reference in bg-only mode
459
+ // (the runner does, but it lives in a separate module).
460
+ if (this.http === null) {
461
+ this.logger.warn(
462
+ `bizar: kill(${instanceId}) in bg-only mode: marking killed; subprocess will be reaped by opencode-runner.ts on exit`,
463
+ );
464
+ await this.update(instanceId, {
465
+ status: "killed",
466
+ completedAt: Date.now(),
467
+ });
468
+ return;
469
+ }
427
470
  // Abort the opencode session. The next SSE event for this session
428
471
  // (EventSessionIdle or EventSessionError) will finalize the status.
429
472
  const abort = await this.http.abortSession(inst.sessionId, this.worktree);
@@ -523,6 +566,80 @@ export class InstanceManager {
523
566
 
524
567
  // --- Collect ------------------------------------------------------------
525
568
 
569
+ /**
570
+ * Wait for an instance to reach a terminal status, or until `deadline`
571
+ * (ms epoch) is reached. Returns `true` on terminal, `false` on
572
+ * timeout.
573
+ *
574
+ * Two implementations:
575
+ * - **With HTTP+stream** (full mode): subscribe to the SSE session
576
+ * event stream AND poll the in-memory map. SSE gives sub-second
577
+ * resolution; the in-memory check covers terminal states we set
578
+ * ourselves (tool-call cap, loop guard, intervention abort).
579
+ * - **Bg-only mode** (no HTTP, no SSE): poll the in-memory map
580
+ * every 500 ms. Terminal transitions come from the runner's
581
+ * `onExit` callback (see src/tools/bg-spawn.ts) which updates
582
+ * the instance state directly.
583
+ */
584
+ private async waitForTerminal(
585
+ instanceId: string,
586
+ deadline: number,
587
+ ): Promise<boolean> {
588
+ // Already terminal?
589
+ const initial = this.instances.get(instanceId);
590
+ if (initial && TERMINAL_STATUSES.has(initial.status)) return true;
591
+
592
+ if (this.stream === null || initial === undefined) {
593
+ // Bg-only path: poll the in-memory map.
594
+ const POLL_MS = 500;
595
+ while (Date.now() < deadline) {
596
+ await new Promise<void>((resolve) => setTimeout(resolve, POLL_MS));
597
+ const cur = this.instances.get(instanceId);
598
+ if (!cur) return false;
599
+ if (TERMINAL_STATUSES.has(cur.status)) return true;
600
+ }
601
+ return false;
602
+ }
603
+
604
+ // Full path: subscribe to the session event stream AND observe our
605
+ // own in-memory state changes for terminal transitions we set
606
+ // ourselves (tool-cap, loop guard, intervention abort).
607
+ return await new Promise<boolean>((resolve) => {
608
+ const remaining = Math.max(0, deadline - Date.now());
609
+ if (remaining === 0) {
610
+ resolve(false);
611
+ return;
612
+ }
613
+ const timer = setTimeout(() => {
614
+ unsubscribe();
615
+ resolve(false);
616
+ }, remaining);
617
+ const unsubscribe = this.stream!.onSessionEvent(
618
+ initial.sessionId,
619
+ (ev) => {
620
+ if (ev.type === "session.idle" || ev.type === "session.error") {
621
+ clearTimeout(timer);
622
+ unsubscribe();
623
+ resolve(true);
624
+ return;
625
+ }
626
+ const cur = this.instances.get(instanceId);
627
+ if (cur && TERMINAL_STATUSES.has(cur.status)) {
628
+ clearTimeout(timer);
629
+ unsubscribe();
630
+ resolve(true);
631
+ }
632
+ },
633
+ );
634
+ const cur = this.instances.get(instanceId);
635
+ if (cur && TERMINAL_STATUSES.has(cur.status)) {
636
+ clearTimeout(timer);
637
+ unsubscribe();
638
+ resolve(true);
639
+ }
640
+ });
641
+ }
642
+
526
643
  /**
527
644
  * Wait for the instance to reach a terminal state (or until
528
645
  * `timeoutMs` elapses), then build the result string per spec §4.4.
@@ -540,42 +657,7 @@ export class InstanceManager {
540
657
 
541
658
  // 1. Wait for terminal state.
542
659
  if (!TERMINAL_STATUSES.has(inst.status)) {
543
- const reachedTerminal = await new Promise<boolean>((resolve) => {
544
- const remaining = Math.max(0, deadline - Date.now());
545
- if (remaining === 0) {
546
- resolve(false);
547
- return;
548
- }
549
- const timer = setTimeout(() => {
550
- unsubscribe();
551
- resolve(false);
552
- }, remaining);
553
- const unsubscribe = this.stream.onSessionEvent(inst.sessionId, (ev) => {
554
- if (
555
- ev.type === "session.idle" ||
556
- ev.type === "session.error"
557
- ) {
558
- clearTimeout(timer);
559
- unsubscribe();
560
- resolve(true);
561
- return;
562
- }
563
- // Also resolve on tool-cap / loop-guard (which we set ourselves).
564
- const cur = this.instances.get(instanceId);
565
- if (cur && TERMINAL_STATUSES.has(cur.status)) {
566
- clearTimeout(timer);
567
- unsubscribe();
568
- resolve(true);
569
- }
570
- });
571
- // Re-check after subscribing in case the state already changed.
572
- const cur = this.instances.get(instanceId);
573
- if (cur && TERMINAL_STATUSES.has(cur.status)) {
574
- clearTimeout(timer);
575
- unsubscribe();
576
- resolve(true);
577
- }
578
- });
660
+ const reachedTerminal = await this.waitForTerminal(instanceId, deadline);
579
661
  if (!reachedTerminal) {
580
662
  // Timed out. Return what we have.
581
663
  const final = this.instances.get(instanceId);
@@ -599,7 +681,11 @@ export class InstanceManager {
599
681
  }
600
682
 
601
683
  // 2. Build the result. Fetch messages from the opencode server and
602
- // concatenate the assistant text parts.
684
+ // concatenate the assistant text parts. In bg-only mode there is
685
+ // no HTTP client to ask, so we fall back to whatever
686
+ // `resultPreview` was captured during the run (often empty
687
+ // because there is no SSE stream in bg-only mode — the runner
688
+ // writes the raw output to the log file instead).
603
689
  const final = this.instances.get(instanceId);
604
690
  if (!final) {
605
691
  throw new Error(`collect: instance ${instanceId} disappeared`);
@@ -695,13 +781,19 @@ export class InstanceManager {
695
781
  completedAt: Date.now(),
696
782
  });
697
783
  }
698
- // Phase 2: best-effort aborts in parallel, 5s per call.
699
- const abortPromises = live.map((inst) =>
700
- withTimeout(this.http.abortSession(inst.sessionId, this.worktree), 5_000).catch(
701
- () => undefined,
702
- ),
703
- );
704
- await Promise.allSettled(abortPromises);
784
+ // Phase 2: best-effort aborts in parallel, 5s per call. In bg-only
785
+ // mode there is no HTTP client to ask — the runner owns the
786
+ // subprocess lifecycle, so the in-memory status flip is the only
787
+ // signal we can emit. Skipped cleanly when http is null.
788
+ if (this.http !== null) {
789
+ const abortPromises = live.map((inst) =>
790
+ withTimeout(
791
+ this.http!.abortSession(inst.sessionId, this.worktree),
792
+ 5_000,
793
+ ).catch(() => undefined),
794
+ );
795
+ await Promise.allSettled(abortPromises);
796
+ }
705
797
  this.logger.info(`bizar: shutdownAll complete (${live.length} instances aborted)`);
706
798
  }
707
799
 
@@ -721,9 +813,13 @@ export class InstanceManager {
721
813
  );
722
814
  // Fire-and-forget. If the serve child is dead, this returns a
723
815
  // failure result but we still mark the instance failed in-memory.
724
- this.http
725
- .abortSession(inst.sessionId, this.worktree)
726
- .catch(() => undefined);
816
+ // v0.8.0 — bg-only mode has no HTTP client; the abort is a no-op
817
+ // there (the runner owns the subprocess).
818
+ if (this.http !== null) {
819
+ this.http
820
+ .abortSession(inst.sessionId, this.worktree)
821
+ .catch(() => undefined);
822
+ }
727
823
  await this.update(inst.instanceId, {
728
824
  status: "failed",
729
825
  error: `No activity for ${this.stallTimeoutMs}ms — LLM appears stalled`,
@@ -751,15 +847,25 @@ export class InstanceManager {
751
847
  `bizar: instance ${inst.instanceId} thinking loop (${sinceMs}ms without tool/text); sending intervention #${currentCount + 1}/${this.maxInterventions}`,
752
848
  );
753
849
  try {
754
- await this.http.sendPrompt(
755
- {
756
- sessionId: inst.sessionId,
757
- messageID,
758
- agent: inst.agent,
759
- parts: [{ type: "text", text: prompt }],
760
- },
761
- this.worktree,
762
- );
850
+ // v0.8.0 — bg-only mode has no HTTP client; interventions are a
851
+ // no-op there (the runner doesn't expose a "send a user message
852
+ // mid-run" hook). Logged as a debug so the operator can see why
853
+ // no intervention went out.
854
+ if (this.http === null) {
855
+ this.logger.debug(
856
+ `bizar: skipping intervention for ${inst.sessionId} (bg-only mode; no HTTP client)`,
857
+ );
858
+ } else {
859
+ await this.http.sendPrompt(
860
+ {
861
+ sessionId: inst.sessionId,
862
+ messageID,
863
+ agent: inst.agent,
864
+ parts: [{ type: "text", text: prompt }],
865
+ },
866
+ this.worktree,
867
+ );
868
+ }
763
869
  } catch (err: unknown) {
764
870
  // We swallow the error: the periodic checker will try again next
765
871
  // tick. The intervention counter is still incremented below so
@@ -793,9 +899,13 @@ export class InstanceManager {
793
899
  this.logger.warn(
794
900
  `bizar: instance ${inst.instanceId} thinking loop exhausted ${this.maxInterventions} intervention(s) over ${sinceMs}ms; aborting`,
795
901
  );
796
- this.http
797
- .abortSession(inst.sessionId, this.worktree)
798
- .catch(() => undefined);
902
+ // v0.8.0 — bg-only mode has no HTTP client; the abort is a no-op
903
+ // there (the runner owns the subprocess).
904
+ if (this.http !== null) {
905
+ this.http
906
+ .abortSession(inst.sessionId, this.worktree)
907
+ .catch(() => undefined);
908
+ }
799
909
  await this.update(inst.instanceId, {
800
910
  status: "failed",
801
911
  error: `Thinking loop detected: ${formatDuration(sinceMs)} of thinking without tool calls or output. Spawn a Mimir agent for research.`,
@@ -809,6 +919,8 @@ export class InstanceManager {
809
919
 
810
920
  public attachEventHandler(inst: BackgroundState): () => void {
811
921
  this.detachEventHandler(inst.instanceId);
922
+ // v0.8.0 — bg-only mode has no EventStream; return a no-op unsubscriber.
923
+ if (!this.stream) return () => {};
812
924
  const handler: SessionEventHandler = (ev: StreamEvent) => {
813
925
  void this.handleInstanceEvent(inst.instanceId, ev);
814
926
  };
@@ -935,9 +1047,13 @@ export class InstanceManager {
935
1047
  if (nextCount >= this.toolCallCap) {
936
1048
  // Abort and mark failed. Use a fire-and-forget abort because we
937
1049
  // do not want to block the handler on a network call.
938
- this.http
939
- .abortSession(inst.sessionId, this.worktree)
940
- .catch(() => undefined);
1050
+ // v0.8.0 — bg-only mode has no HTTP client; the abort is a
1051
+ // no-op there (the runner owns the subprocess).
1052
+ if (this.http !== null) {
1053
+ this.http
1054
+ .abortSession(inst.sessionId, this.worktree)
1055
+ .catch(() => undefined);
1056
+ }
941
1057
  patch.status = "failed";
942
1058
  patch.error = `Tool-call cap reached (${nextCount}). Aborted to prevent cost runaway.`;
943
1059
  patch.completedAt = Date.now();
@@ -995,6 +1111,9 @@ export class InstanceManager {
995
1111
  * - If `loopGuardTool` is set, prepend the marker.
996
1112
  */
997
1113
  private async buildResultText(inst: BackgroundState): Promise<string> {
1114
+ // v0.8.0 — bg-only mode has no HTTP client; fall back to whatever
1115
+ // text-part preview we've already accumulated.
1116
+ if (!this.http) return inst.resultPreview ?? "";
998
1117
  const res = await this.http.listMessages(inst.sessionId, this.worktree);
999
1118
  if (!res.ok) {
1000
1119
  this.logger.warn(`bizar: collect: listMessages failed: ${res.error}`);
@@ -105,6 +105,47 @@ const agents = new Map<number, AgentRecord>();
105
105
 
106
106
  // --- Public API -----------------------------------------------------------
107
107
 
108
+ /**
109
+ * Pure: build the argv array for `opencode run` from SpawnAgentOptions.
110
+ *
111
+ * Extracted from spawnAgent so it can be unit-tested without spawning
112
+ * a real process. Throws when opts.agent is empty — `opencode run`
113
+ * requires a known agent name; passing an empty string would silently
114
+ * fall back to opencode's default agent and break session attribution
115
+ * (the title prefix `bgr:<agent>:...` would also degrade to `bgr::...`).
116
+ *
117
+ * Arg layout (matches `opencode run --help`):
118
+ * opencode run
119
+ * --dir <worktree>
120
+ * --print-logs
121
+ * --log-level INFO
122
+ * --title <title>
123
+ * --agent <agent> ← REQUIRED; was missing pre-v0.8.1
124
+ * [--model <providerID>/<modelID>] ← optional override
125
+ * -- <prompt>
126
+ */
127
+ export function buildOpencodeRunArgs(opts: SpawnAgentOptions): string[] {
128
+ if (!opts.agent) {
129
+ throw new Error("bizar_spawn_background: agent is required");
130
+ }
131
+ const args: string[] = [
132
+ "opencode",
133
+ "run",
134
+ "--dir", opts.worktree,
135
+ "--print-logs",
136
+ "--log-level", "INFO",
137
+ "--title", opts.title || `bgr:${opts.agent}:${Date.now()}`,
138
+ "--agent", opts.agent,
139
+ ];
140
+ if (opts.model) {
141
+ args.push("--model", `${opts.model.providerID}/${opts.model.modelID}`);
142
+ }
143
+ // `--` separates flags from positional so a prompt starting with
144
+ // `-` is treated as a message.
145
+ args.push("--", opts.prompt);
146
+ return args;
147
+ }
148
+
108
149
  /**
109
150
  * Spawn a single `opencode run` process. The promise resolves once
110
151
  * the opencode child has reported its session id in the structured
@@ -128,23 +169,10 @@ export async function spawnAgent(opts: SpawnAgentOptions): Promise<SpawnAgentRes
128
169
  }
129
170
  }
130
171
 
131
- // 2. Build argv. Note: opencode run takes the prompt as a positional
132
- // arg. `--dir` sets the worktree. `--print-logs` ensures the
133
- // structured log stream goes to stderr.
134
- const args: string[] = [
135
- "opencode",
136
- "run",
137
- "--dir", opts.worktree,
138
- "--print-logs",
139
- "--log-level", "INFO",
140
- "--title", opts.title || `bgr:${opts.agent}:${Date.now()}`,
141
- ];
142
- if (opts.model) {
143
- args.push("--model", `${opts.model.providerID}/${opts.model.modelID}`);
144
- }
145
- // `--` separates flags from positional so a prompt starting with
146
- // `-` is treated as a message.
147
- args.push("--", opts.prompt);
172
+ // 2. Build argv. Pulled into a pure function so tests can assert the
173
+ // flag layout (notably the `--agent` flag and the migrated model
174
+ // ID format) without spawning a real `opencode run` process.
175
+ const args = buildOpencodeRunArgs(opts);
148
176
 
149
177
  // 3. Spawn the process.
150
178
  let proc: Subprocess;
@@ -227,8 +255,8 @@ export async function spawnAgent(opts: SpawnAgentOptions): Promise<SpawnAgentRes
227
255
  // 6. Stream readers + exit handler — install BEFORE returning the
228
256
  // promise so a fast-exiting process still produces a clean
229
257
  // resolution.
230
- const stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
231
- const stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
258
+ const stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader() as unknown as ReadableStreamDefaultReader<Uint8Array<ArrayBufferLike>>;
259
+ const stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader() as unknown as ReadableStreamDefaultReader<Uint8Array<ArrayBufferLike>>;
232
260
  void readStream(stderrReader, "stderr");
233
261
  void readStream(stdoutReader, "stdout");
234
262