@polderlabs/bizar-plugin 0.6.0 → 0.6.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: "minimax/MiniMax-M3", // optional: override model
158
+ model: "openrouter/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
@@ -125,9 +125,12 @@ import { SettingsStore } from "./src/settings.js";
125
125
  import { parseSlashCommand } from "./src/commands.js";
126
126
  import { createPlanActionTool } from "./src/tools/plan-action.js";
127
127
  import { createWaitForFeedbackTool } from "./src/tools/wait-for-feedback.js";
128
+ import { wrapFetchForReasoningCleanup } from "./src/reasoning-clean.js";
128
129
 
129
130
  // v0.5.0 — visual plan wiring: side-effect executor + plan-fs
130
131
  import { executeSideEffect, type ExecuteOptions } from "./src/commands-impl.js";
132
+ import { join as pathJoin } from "node:path";
133
+ import { homedir } from "node:os";
131
134
 
132
135
  // --- Env-var constants (per spec §8) -------------------------------------
133
136
 
@@ -753,9 +756,40 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
753
756
  };
754
757
 
755
758
  return {
756
- // §3.1 — config: no mutation. We already resolved options in init().
757
- config: async () => {
758
- // intentionally empty options are resolved at init time
759
+ // §3.1 — config: wrap provider fetches to strip duplicated inline
760
+ // think blocks from responses of reasoning models that emit BOTH a
761
+ // structured reasoning field (rendered as a thought) AND an inline
762
+ // `` block (which would otherwise leak into the visible message).
763
+ // See plugins/bizar/src/reasoning-clean.ts for the full rationale.
764
+ config: async (cfg) => {
765
+ try {
766
+ const providers = (cfg as { provider?: Record<string, unknown> } | undefined)?.provider;
767
+ if (!providers || typeof providers !== "object") return;
768
+ const debug = (msg: string) => ctx.logger.debug(`bizar: ${msg}`);
769
+ for (const [name, provider] of Object.entries(providers)) {
770
+ if (!provider || typeof provider !== "object") continue;
771
+ const prov = provider as { options?: Record<string, unknown> };
772
+ if (!prov.options || typeof prov.options !== "object") continue;
773
+ const original = prov.options.fetch;
774
+ if (typeof original !== "function") continue;
775
+ // Only wrap once — detect by stamping a sentinel.
776
+ const wrapped = (original as { __bizarReasoningClean?: boolean })
777
+ .__bizarReasoningClean;
778
+ if (wrapped) continue;
779
+ prov.options.fetch = wrapFetchForReasoningCleanup(
780
+ original as Parameters<typeof wrapFetchForReasoningCleanup>[0],
781
+ { debug, providers: [name] },
782
+ );
783
+ (prov.options.fetch as { __bizarReasoningClean?: boolean }).__bizarReasoningClean = true;
784
+ debug(`wrapped provider.fetch for ${name}`);
785
+ }
786
+ } catch (err) {
787
+ ctx.logger.warn(
788
+ `bizar: config hook failed (passing through): ${
789
+ err instanceof Error ? err.message : String(err)
790
+ }`,
791
+ );
792
+ }
759
793
  },
760
794
 
761
795
  // §3.1, §4.5.1 — event: track session boundaries. We do NOT create
@@ -862,6 +896,29 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
862
896
  finalResponse = `Command failed: ${msg}`;
863
897
  }
864
898
  }
899
+ // --- v0.5.1: dialog support.
900
+ // If the command emitted a dialog descriptor, persist it to disk
901
+ // so Tyr's dialog-poller can broadcast it to the dashboard.
902
+ // We return without throwing so no chat bubble is shown.
903
+ if (result.dialog) {
904
+ // S4 — defense-in-depth: validate ID shape before touching disk.
905
+ if (!/^dlg_[a-zA-Z0-9_-]{1,64}$/.test(result.dialog.id)) {
906
+ return; // silently drop malformed dialog IDs
907
+ }
908
+ try {
909
+ const { mkdir, writeFile } = await import("node:fs/promises");
910
+ const dialogDir = pathJoin(homedir(), ".cache", "bizar", "dialogs");
911
+ await mkdir(dialogDir, { recursive: true });
912
+ await writeFile(
913
+ pathJoin(dialogDir, `${result.dialog.id}.json`),
914
+ JSON.stringify({ ...result.dialog, createdAt: new Date().toISOString() }, null, 2),
915
+ );
916
+ } catch (dialogErr: unknown) {
917
+ const msg = dialogErr instanceof Error ? dialogErr.message : String(dialogErr);
918
+ ctx.logger.warn(`bizar: failed to write dialog file: ${msg}`);
919
+ }
920
+ return;
921
+ }
865
922
  // Surface the response to the user/host. We throw so the
866
923
  // message is treated as handled; the LLM does not process
867
924
  // it further. The host renders the throw message.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar-plugin",
3
- "version": "0.6.0",
3
+ "version": "0.6.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",
@@ -99,6 +99,15 @@ export type BackgroundStatus =
99
99
  * - `interventionAt` — epoch ms of the most recent intervention.
100
100
  * - `interventionReason` — short human-readable description of the
101
101
  * intervention, e.g. `"thinking loop (5m 12s without tool/text)"`.
102
+ *
103
+ * v0.5.5 — persistent auto-restart. These are typed as optional so
104
+ * existing state files on disk remain valid after upgrade.
105
+ * - `persistent` — when true, the manager auto-restarts on terminal
106
+ * failure (up to maxRestarts). Default false.
107
+ * - `restartCount` — number of times this instance has been
108
+ * auto-restarted (not including the original spawn). Default 0.
109
+ * - `maxRestarts` — cap; default 3.
110
+ * - `lastRestartAt` — epoch ms of the most recent auto-restart.
102
111
  */
103
112
  export interface BackgroundState {
104
113
  instanceId: string;
@@ -127,6 +136,23 @@ export interface BackgroundState {
127
136
  interventionCount?: number;
128
137
  interventionAt?: number;
129
138
  interventionReason?: string;
139
+ // v0.5.5 — persistent auto-restart
140
+ persistent?: boolean;
141
+ restartCount?: number;
142
+ maxRestarts?: number;
143
+ lastRestartAt?: string;
144
+ /**
145
+ * Full original prompt text, stored so the instance can be restarted
146
+ * with the same input. Optional for backward compat; always set on
147
+ * new spawns from v0.5.5+.
148
+ */
149
+ prompt?: string;
150
+ /**
151
+ * Human-readable error from the last failed restart attempt.
152
+ * Set only when `_maybeAutoRestart` calls `restart()` and it returns
153
+ * `{ ok: false }`. Cleared on a successful restart.
154
+ */
155
+ restartError?: string;
130
156
  }
131
157
 
132
158
  /**
@@ -159,6 +185,10 @@ export const EMPTY_BACKGROUND_STATE: Omit<
159
185
  lastEventAt: 0,
160
186
  lastToolOrTextAt: 0,
161
187
  interventionCount: 0,
188
+ // v0.5.5 — persistent auto-restart defaults
189
+ persistent: false,
190
+ restartCount: 0,
191
+ maxRestarts: 3,
162
192
  };
163
193
 
164
194
  /**
@@ -314,6 +344,17 @@ function readState(
314
344
  if (typeof parsed.interventionCount !== "number") {
315
345
  parsed.interventionCount = 0;
316
346
  }
347
+ // v0.5.5 — backfill persistent/restart fields for files written by
348
+ // older versions. These are optional but the restarter needs them.
349
+ if (typeof parsed.persistent !== "boolean") {
350
+ parsed.persistent = false;
351
+ }
352
+ if (typeof parsed.restartCount !== "number") {
353
+ parsed.restartCount = 0;
354
+ }
355
+ if (typeof parsed.maxRestarts !== "number") {
356
+ parsed.maxRestarts = 3;
357
+ }
317
358
  return parsed;
318
359
  } catch (err: unknown) {
319
360
  logger.log({
package/src/background.ts CHANGED
@@ -278,6 +278,30 @@ export class InstanceManager {
278
278
  }
279
279
  }
280
280
 
281
+ // --- Internal dispatch (shared by add() and restart()) ------------------
282
+
283
+ /**
284
+ * Insert a fully-constructed BackgroundState into the in-memory map
285
+ * and persist to disk asynchronously. Does NOT check the concurrency
286
+ * cap — the caller handles that.
287
+ *
288
+ * This is extracted from `add()` so `restart()` can reuse the same
289
+ * insert-and-persist path without duplicating the logic.
290
+ */
291
+ private dispatchInternal(full: BackgroundState): BackgroundState {
292
+ this.instances.set(full.instanceId, full);
293
+ // Persist asynchronously; failure is logged but does not roll back
294
+ // the in-memory insert (the instance is "tracked" either way).
295
+ this.stateStore.save(full).catch((err: unknown) => {
296
+ this.logger.warn(
297
+ `bizar: failed to persist new instance ${full.instanceId}: ${
298
+ err instanceof Error ? err.message : String(err)
299
+ }`,
300
+ );
301
+ });
302
+ return full;
303
+ }
304
+
281
305
  // --- Atomic add (spec §2.2) ---------------------------------------------
282
306
 
283
307
  /**
@@ -315,16 +339,7 @@ export class InstanceManager {
315
339
  lastToolOrTextAt: now,
316
340
  interventionCount: 0,
317
341
  };
318
- this.instances.set(draft.instanceId, full);
319
- // Persist asynchronously; failure is logged but does not roll back
320
- // the in-memory insert (the instance is "tracked" either way).
321
- this.stateStore.save(full).catch((err: unknown) => {
322
- this.logger.warn(
323
- `bizar: failed to persist new instance ${draft.instanceId}: ${
324
- err instanceof Error ? err.message : String(err)
325
- }`,
326
- );
327
- });
342
+ this.dispatchInternal(full);
328
343
  // BUGFIX (v0.5.1): Do NOT call attachEventHandler() here. The
329
344
  // instance was just added with sessionId="" (filled in later by
330
345
  // POST /session). EventStream.onSessionEvent rejects empty strings,
@@ -426,6 +441,86 @@ export class InstanceManager {
426
441
  });
427
442
  this.logger.info(`bizar: killed background instance ${instanceId}`);
428
443
  }
444
+
445
+ // --- Restart (v0.5.5 — persistent auto-restart) ------------------------
446
+
447
+ /**
448
+ * Re-spawn a failed persistent instance with the same prompt, agent,
449
+ * and model config. The new instance gets a fresh `instanceId` and is
450
+ * linked to the original via `parentInstanceId`.
451
+ *
452
+ * Returns `{ ok: true, newInstanceId }` on success, or
453
+ * `{ ok: false, error: "..." }` on failure. Does NOT check the
454
+ * concurrency cap — the original instance is terminal, so its slot
455
+ * is already freed.
456
+ */
457
+ async restart(instanceId: string): Promise<{
458
+ ok: boolean;
459
+ newInstanceId?: string;
460
+ error?: string;
461
+ }> {
462
+ const existing = this.instances.get(instanceId);
463
+ if (!existing) return { ok: false, error: "instance_not_found" };
464
+ const totalRestarts = this.getTotalRestartCount(instanceId);
465
+ if (totalRestarts >= (existing.maxRestarts ?? 3)) {
466
+ return { ok: false, error: "max_restarts_reached" };
467
+ }
468
+
469
+ const now = Date.now();
470
+ const newInstanceId = generateInstanceId();
471
+ const full: BackgroundState = {
472
+ instanceId: newInstanceId,
473
+ sessionId: "",
474
+ agent: existing.agent,
475
+ model: existing.model,
476
+ promptPreview: (existing.prompt ?? existing.promptPreview ?? "").slice(0, PROMPT_PREVIEW_MAX),
477
+ prompt: existing.prompt,
478
+ parentAgent: existing.parentAgent,
479
+ parentInstanceId: instanceId,
480
+ logPath: `${this.worktree}/.opencode/log/${newInstanceId}.log`,
481
+ timeoutMs: existing.timeoutMs,
482
+ toolCallCount: 0,
483
+ // v0.5.5 — persist the auto-restart fields
484
+ persistent: existing.persistent,
485
+ maxRestarts: existing.maxRestarts,
486
+ restartCount: (existing.restartCount ?? 0) + 1,
487
+ status: "pending",
488
+ startedAt: now,
489
+ lastEventAt: now,
490
+ lastToolOrTextAt: now,
491
+ interventionCount: 0,
492
+ };
493
+
494
+ this.dispatchInternal(full);
495
+ this.logger.info(
496
+ `bizar: restarted instance ${instanceId} as ${newInstanceId} (restart #${full.restartCount})`,
497
+ );
498
+ return { ok: true, newInstanceId };
499
+ }
500
+
501
+ /**
502
+ * Walk the parent chain to compute the true restart count for this
503
+ * instance (Forseti C4). `restartCount` alone only reflects the child's
504
+ * own counter, so a chain of restarts can exceed `maxRestarts`. This
505
+ * helper sums the counters across the entire chain (parent + all
506
+ * descendants) and returns the total.
507
+ */
508
+ private getTotalRestartCount(instanceId: string): number {
509
+ let current = this.instances.get(instanceId);
510
+ if (!current) return 0;
511
+ let count = current.restartCount ?? 0;
512
+ let parentId = current.parentInstanceId;
513
+ const visited = new Set<string>([instanceId]);
514
+ while (parentId && !visited.has(parentId)) {
515
+ visited.add(parentId);
516
+ const parent = this.instances.get(parentId);
517
+ if (!parent) break;
518
+ count += parent.restartCount ?? 0;
519
+ parentId = parent.parentInstanceId;
520
+ }
521
+ return count;
522
+ }
523
+
429
524
  // --- Collect ------------------------------------------------------------
430
525
 
431
526
  /**
@@ -634,6 +729,8 @@ export class InstanceManager {
634
729
  error: `No activity for ${this.stallTimeoutMs}ms — LLM appears stalled`,
635
730
  completedAt: Date.now(),
636
731
  });
732
+ // v0.5.5 — persistent auto-restart on stall
733
+ await this._maybeAutoRestart(inst.instanceId);
637
734
  }
638
735
 
639
736
  /**
@@ -704,6 +801,8 @@ export class InstanceManager {
704
801
  error: `Thinking loop detected: ${formatDuration(sinceMs)} of thinking without tool calls or output. Spawn a Mimir agent for research.`,
705
802
  completedAt: Date.now(),
706
803
  });
804
+ // v0.5.5 — persistent auto-restart on thinking-loop exhaustion
805
+ await this._maybeAutoRestart(inst.instanceId);
707
806
  }
708
807
 
709
808
  // --- Internal: per-session event handler -------------------------------
@@ -759,6 +858,37 @@ export class InstanceManager {
759
858
  error: errMsg,
760
859
  completedAt: Date.now(),
761
860
  });
861
+ // v0.5.5 — persistent auto-restart. If the instance is persistent
862
+ // and was not explicitly killed, try to restart.
863
+ if (inst.persistent && inst.status !== "killed") {
864
+ await this._maybeAutoRestart(instanceId);
865
+ }
866
+ }
867
+ }
868
+
869
+ /** v0.5.5 — Attempt an auto-restart for a persistent failed instance. */
870
+ private async _maybeAutoRestart(instanceId: string): Promise<void> {
871
+ const inst = this.instances.get(instanceId);
872
+ if (!inst || !inst.persistent) return;
873
+ if (inst.status === "killed") return; // user killed it — do not restart
874
+ this.logger.info(
875
+ `bizar: persistent instance ${instanceId} failed; auto-restarting`,
876
+ );
877
+ const result = await this.restart(instanceId);
878
+ if (result.ok) {
879
+ // Clear any previous restartError on the parent so the operator
880
+ // sees a clean state.
881
+ await this.update(instanceId, { restartError: undefined });
882
+ this.logger.info(
883
+ `bizar: auto-restart complete: ${instanceId} -> ${result.newInstanceId}`,
884
+ );
885
+ } else {
886
+ // Persist a human-readable error so the operator can see why the
887
+ // restart was rejected (e.g. max_restarts_reached).
888
+ await this.update(instanceId, { restartError: result.error || "unknown" });
889
+ this.logger.warn(
890
+ `bizar: auto-restart failed for ${instanceId}: ${result.error}`,
891
+ );
762
892
  }
763
893
  }
764
894
 
@@ -800,7 +930,11 @@ export class InstanceManager {
800
930
  patch.completedAt = Date.now();
801
931
  }
802
932
  await this.update(instanceId, patch);
803
- if (patch.status === "failed") return;
933
+ // v0.5.5 persistent auto-restart on tool-call cap
934
+ if (patch.status === "failed") {
935
+ await this._maybeAutoRestart(instanceId);
936
+ return;
937
+ }
804
938
  }
805
939
 
806
940
  // --- Loop-guard threshold-12 detection (spec §4.1) ---
@@ -816,6 +950,8 @@ export class InstanceManager {
816
950
  loopGuardTool: tool,
817
951
  completedAt: Date.now(),
818
952
  });
953
+ // v0.5.5 — persistent auto-restart on loop-guard
954
+ await this._maybeAutoRestart(instanceId);
819
955
  return;
820
956
  }
821
957
  }
@@ -112,7 +112,7 @@ export interface ExecuteResult {
112
112
  * list so we can include status + lastEdited).
113
113
  * `open_plan_url` — just returns the parser's response (the parser
114
114
  * already built the URL). No I/O.
115
- * `launch_dashboard` — spawns `bizar dashboard start` as a detached
115
+ * `launch_dashboard` — spawns `bizar dash start` as a detached
116
116
  * child process. Reads the port file back and
117
117
  * appends the URL to the parser's response.
118
118
  * `tool_invocation` — delegates to `executeToolInvocation`.
@@ -196,7 +196,7 @@ async function executeListPlans(
196
196
  /**
197
197
  * Launch the Bizar dashboard as a detached child process.
198
198
  *
199
- * We spawn `bizar dashboard start` with `detached: true` and `unref()`
199
+ * We spawn `bizar dash start` with `detached: true` and `unref()`
200
200
  * so the child's lifetime is independent of the plugin host. We then
201
201
  * poll the port file (written by the child) for up to ~3s and append
202
202
  * the URL to the parser's response. If anything goes wrong we surface
@@ -234,7 +234,7 @@ async function executeLaunchDashboard(
234
234
  // `bizar` is on $PATH for global installs; for npx / local installs
235
235
  // we'd want to resolve to the package's bin. Spawn `bizar` directly
236
236
  // for now — the user's $PATH is the source of truth.
237
- const child = spawn("bizar", ["dashboard", "start"], {
237
+ const child = spawn("bizar", ["dash", "start"], {
238
238
  detached: true,
239
239
  stdio: "ignore",
240
240
  cwd: ctx.worktree,
@@ -248,7 +248,7 @@ async function executeLaunchDashboard(
248
248
  return {
249
249
  responseOverride:
250
250
  `Could not launch the Bizar dashboard: ${msg}\n` +
251
- `Try running \`bizar dashboard start\` in your terminal.`,
251
+ `Try running \`bizar dash start\` in your terminal.`,
252
252
  };
253
253
  }
254
254