@polderlabs/bizar-plugin 0.6.2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -98,6 +98,7 @@ import type { Plugin, Hooks, PluginInput, PluginOptions } from "@opencode-ai/plu
98
98
  import { createLogger, type Logger } from "./src/logger.js";
99
99
  import { decide, isLogOnlyWarn } from "./src/loop.js";
100
100
  import { fingerprint } from "./src/fingerprint.js";
101
+ import { createDashboardPublisher, type DashboardPublisher } from "./src/dashboard-client.js";
101
102
  import { StateStore, type SessionState } from "./src/state.js";
102
103
  import { LogWriter } from "./src/report.js";
103
104
  import {
@@ -244,6 +245,10 @@ interface RuntimeContext {
244
245
  seenMessageIds: Map<string, Set<string>>;
245
246
  /** sessionID → pending system-transform message, set at warn/escalate. */
246
247
  pendingInjections: Map<string, string>;
248
+ /** v0.7.0-alpha.1 — Dashboard publisher (or null if disabled/not started).
249
+ * Used by the `event` hook to forward opencode session lifecycle
250
+ * events to the v2 dashboard via the @polderlabs/bizar-sdk. */
251
+ dashboardPublisher: DashboardPublisher | null;
247
252
  }
248
253
 
249
254
  /**
@@ -333,10 +338,11 @@ async function init(
333
338
 
334
339
  // --- Background agents (v0.4.2) -----------------------------------------
335
340
 
336
- let instanceManager: InstanceManager | null = null;
337
- let serve: ServeLifecycle | null = null;
338
- let stream: EventStream | null = null;
339
- let bgAvailable = false;
341
+ let instanceManager: InstanceManager | null = null;
342
+ let serve: ServeLifecycle | null = null;
343
+ let stream: EventStream | null = null;
344
+ let dashboardPublisher: DashboardPublisher | null = null;
345
+ let bgAvailable = false;
340
346
 
341
347
  if (readServeDisabled()) {
342
348
  logger.info("bizar: background agents disabled via BIZAR_SERVE_DISABLE=1");
@@ -394,11 +400,50 @@ async function init(
394
400
  });
395
401
  streamHandle = stream;
396
402
 
403
+ // v0.7.0-alpha.1 — Wire dashboard publisher to the EventStream so
404
+ // every opencode SSE event is also published to the v2 dashboard.
405
+ // The publisher gracefully degrades if the dashboard is unreachable
406
+ // (queues, retries, warns; never throws into the plugin).
407
+ try {
408
+ const pub = createDashboardPublisher({
409
+ logger,
410
+ });
411
+ await pub.start();
412
+ dashboardPublisher = pub;
413
+ stream.onEvent((event) => {
414
+ // Translate opencode StreamEvent → DashboardEvent shape.
415
+ // The dashboard only cares about the wire-level (type, properties)
416
+ // so we forward { type, properties } directly. Cast through
417
+ // `unknown` because the opencode event shape doesn't exactly
418
+ // match the SDK's discriminated DashboardEvent — it's a
419
+ // forward-compatible passthrough.
420
+ const dashEvent = {
421
+ type: event.type,
422
+ properties: { ...event },
423
+ } as unknown as Parameters<DashboardPublisher["publish"]>[0];
424
+ void pub.publish(dashEvent);
425
+ });
426
+ // Stop the publisher when the stream is disposed.
427
+ const origDisconnect = stream.disconnect.bind(stream);
428
+ stream.disconnect = async () => {
429
+ await origDisconnect();
430
+ pub.stop();
431
+ };
432
+ logger.info("bizar: dashboard publisher wired to EventStream (v2 protocol)");
433
+ } catch (err) {
434
+ logger.warn(
435
+ `bizar: dashboard publisher failed to start: ${
436
+ err instanceof Error ? err.message : String(err)
437
+ }`,
438
+ );
439
+ }
440
+
397
441
  instanceManager = new InstanceManager({
398
442
  stateStore: bgStateStore,
399
443
  maxConcurrent,
400
444
  toolCallCap,
401
445
  logger,
446
+ worktree: input.worktree,
402
447
  serve,
403
448
  http,
404
449
  stream,
@@ -469,6 +514,7 @@ async function init(
469
514
  directory: input.directory,
470
515
  seenMessageIds: new Map(),
471
516
  pendingInjections: new Map(),
517
+ dashboardPublisher,
472
518
  };
473
519
 
474
520
  return buildHooks(ctx, { instanceManager, bgAvailable });
@@ -772,9 +818,13 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
772
818
  const tools = bg.instanceManager
773
819
  ? {
774
820
  ...basePlanTools,
821
+ // v0.8.0 — bg-spawn no longer needs the HTTP client. It
822
+ // spawns an `opencode run` subprocess per agent (see
823
+ // src/opencode-runner.ts). The serve child is still
824
+ // available for the dashboard's v2 protocol and for any
825
+ // TUI/web client that wants to attach to it.
775
826
  bizar_spawn_background: createBgSpawnTool({
776
827
  instanceManager: bg.instanceManager,
777
- http: (bg.instanceManager as unknown as { http: HttpClient }).http,
778
828
  worktree: ctx.worktree,
779
829
  logger: ctx.logger,
780
830
  }),
@@ -859,10 +909,37 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
859
909
  // `chat.message` seed, per spec §4.5.1).
860
910
  event: async ({ event }) => {
861
911
  try {
862
- const ev = event as { type?: string; sessionID?: string };
912
+ // v0.7.0-alpha.1 opencode's event object has { type: string,
913
+ // properties: { sessionID: string, ... } }. The legacy plugin
914
+ // assumed `event.sessionID` was top-level (which is wrong), so
915
+ // the hook returned early for every event. We extract from
916
+ // BOTH locations to be robust across opencode versions, and
917
+ // publish to the dashboard regardless.
918
+ const ev = event as {
919
+ type?: string;
920
+ sessionID?: string;
921
+ properties?: { sessionID?: string; [k: string]: unknown };
922
+ };
863
923
  const type = ev.type;
864
- const sessionID = ev.sessionID;
865
- if (!type || !sessionID) return;
924
+ const sessionID = ev.sessionID ?? ev.properties?.sessionID;
925
+ if (!type) return;
926
+
927
+ // v0.7.0-alpha.1 — Forward every opencode event to the dashboard.
928
+ // The plugin SDK does NOT translate opencode events to the SDK's
929
+ // discriminated DashboardEvent shape (it's a forward-compatible
930
+ // passthrough — the dashboard is happy with any {type, properties}
931
+ // event object). The publish is fire-and-forget; failures are
932
+ // logged and swallowed inside the publisher.
933
+ if (ctx.dashboardPublisher !== null) {
934
+ const dashEvent = {
935
+ type,
936
+ properties: { ...ev },
937
+ } as unknown as Parameters<DashboardPublisher["publish"]>[0];
938
+ void ctx.dashboardPublisher.publish(dashEvent);
939
+ }
940
+
941
+ // Legacy logic below: only runs when we have a sessionID.
942
+ if (!sessionID) return;
866
943
 
867
944
  if (type === "session.deleted") {
868
945
  await ctx.stateStore.withLock(sessionID, async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar-plugin",
3
- "version": "0.6.2",
3
+ "version": "0.8.1",
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"
15
15
  },
16
16
  "keywords": [
17
17
  "opencode",
@@ -26,6 +26,7 @@
26
26
  "node": ">=20"
27
27
  },
28
28
  "dependencies": {
29
+ "@polderlabs/bizar-sdk": "*",
29
30
  "zod": "4.1.8"
30
31
  },
31
32
  "devDependencies": {
@@ -107,8 +107,32 @@ export type BackgroundStatus =
107
107
  * - `restartCount` — number of times this instance has been
108
108
  * auto-restarted (not including the original spawn). Default 0.
109
109
  * - `maxRestarts` — cap; default 3.
110
- * - `lastRestartAt` — epoch ms of the most recent auto-restart.
111
- */
110
+ * - `lastRestartAt` — epoch ms of the most recent auto-restart.
111
+ * - `processId` (v0.8.0) — PID of the opencode run subprocess. Set
112
+ * by the opencode-runner after `Bun.spawn` returns. Optional
113
+ * for backward compat.
114
+ * - `exitCode` (v0.8.0) — exit code of the opencode run
115
+ * subprocess. Populated when the process exits.
116
+ * - `runnerState` (v0.8.0) — free-form status from the runner
117
+ * ("starting" | "running" | "done" | "failed" | "killed").
118
+ * Allows the dashboard to show runner-level state separately
119
+ * from the higher-level `status`.
120
+ * - `runnerError` (v0.8.0) — opencode run subprocess error (e.g.
121
+ * "opencode run exited with code 1").
122
+ * - `spawnMessage` (v0.8.0) — "you can continue" message returned
123
+ * to the LLM by the spawn tool. Surfaces in the dashboard.
124
+ * - `spawnNextSteps` (v0.8.0) — list of next-step hints returned
125
+ * to the LLM by the spawn tool.
126
+ * - `sessionIdAt` (v0.8.0) — when the opencode sessionId was
127
+ * first observed in the subprocess stderr.
128
+ * - `runnerStartedAt` / `runnerEndedAt` — when the subprocess
129
+ * started / ended (subset of `startedAt`/`completedAt`).
130
+ * - `spawnedAt` — when the bg-spawn tool recorded the instance.
131
+ * Distinct from `startedAt` because the runner starts a
132
+ * tick or two later.
133
+ * - `exitSignal` — exit signal name (e.g. "SIGTERM",
134
+ * "SIGKILL") if the subprocess was killed.
135
+ */
112
136
  export interface BackgroundState {
113
137
  instanceId: string;
114
138
  sessionId: string;
@@ -153,6 +177,18 @@ export interface BackgroundState {
153
177
  * `{ ok: false }`. Cleared on a successful restart.
154
178
  */
155
179
  restartError?: string;
180
+ // v0.8.0 — process tracking (see opencode-runner.ts).
181
+ processId?: number;
182
+ exitCode?: number;
183
+ runnerState?: string;
184
+ runnerError?: string;
185
+ spawnMessage?: string;
186
+ spawnNextSteps?: string[];
187
+ sessionIdAt?: number;
188
+ runnerStartedAt?: number;
189
+ runnerEndedAt?: number;
190
+ spawnedAt?: number;
191
+ exitSignal?: string;
156
192
  }
157
193
 
158
194
  /**
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
  };
@@ -866,6 +978,19 @@ export class InstanceManager {
866
978
  }
867
979
  }
868
980
 
981
+ /**
982
+ * v0.8.0 — Public version of `_maybeAutoRestart`. The opencode-runner
983
+ * (see src/opencode-runner.ts) calls this from its onExit callback
984
+ * when a `bizar_spawn_background` subprocess exits. Without this,
985
+ * persistent instances would never auto-restart under the new
986
+ * subprocess-based path (the SSE event handler that previously
987
+ * triggered auto-restart is no longer wired up because we don't
988
+ * subscribe to per-session events anymore).
989
+ */
990
+ async maybeAutoRestart(instanceId: string): Promise<void> {
991
+ await this._maybeAutoRestart(instanceId);
992
+ }
993
+
869
994
  /** v0.5.5 — Attempt an auto-restart for a persistent failed instance. */
870
995
  private async _maybeAutoRestart(instanceId: string): Promise<void> {
871
996
  const inst = this.instances.get(instanceId);
@@ -922,9 +1047,13 @@ export class InstanceManager {
922
1047
  if (nextCount >= this.toolCallCap) {
923
1048
  // Abort and mark failed. Use a fire-and-forget abort because we
924
1049
  // do not want to block the handler on a network call.
925
- this.http
926
- .abortSession(inst.sessionId, this.worktree)
927
- .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
+ }
928
1057
  patch.status = "failed";
929
1058
  patch.error = `Tool-call cap reached (${nextCount}). Aborted to prevent cost runaway.`;
930
1059
  patch.completedAt = Date.now();
@@ -982,6 +1111,9 @@ export class InstanceManager {
982
1111
  * - If `loopGuardTool` is set, prepend the marker.
983
1112
  */
984
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 ?? "";
985
1117
  const res = await this.http.listMessages(inst.sessionId, this.worktree);
986
1118
  if (!res.ok) {
987
1119
  this.logger.warn(`bizar: collect: listMessages failed: ${res.error}`);