@polderlabs/bizar-plugin 0.5.4 → 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/index.ts CHANGED
@@ -109,6 +109,7 @@ import {
109
109
  } from "./src/options.js";
110
110
 
111
111
  import { ServeLifecycle } from "./src/serve.js";
112
+ import { writeServeInfo, clearServeInfo } from "./src/serve-info.js";
112
113
  import { HttpClient } from "./src/http-client.js";
113
114
  import { EventStream } from "./src/event-stream.js";
114
115
  import { BackgroundStateStore, type BackgroundState } from "./src/background-state.js";
@@ -124,9 +125,12 @@ import { SettingsStore } from "./src/settings.js";
124
125
  import { parseSlashCommand } from "./src/commands.js";
125
126
  import { createPlanActionTool } from "./src/tools/plan-action.js";
126
127
  import { createWaitForFeedbackTool } from "./src/tools/wait-for-feedback.js";
128
+ import { wrapFetchForReasoningCleanup } from "./src/reasoning-clean.js";
127
129
 
128
130
  // v0.5.0 — visual plan wiring: side-effect executor + plan-fs
129
131
  import { executeSideEffect, type ExecuteOptions } from "./src/commands-impl.js";
132
+ import { join as pathJoin } from "node:path";
133
+ import { homedir } from "node:os";
130
134
 
131
135
  // --- Env-var constants (per spec §8) -------------------------------------
132
136
 
@@ -216,6 +220,7 @@ let instanceManagerHandle: InstanceManager | null = null;
216
220
  let serveHandle: ServeLifecycle | null = null;
217
221
  let streamHandle: EventStream | null = null;
218
222
  let loggerHandle: Logger | null = null;
223
+ const signalHandlerRefs = new Map<"SIGTERM" | "SIGINT", () => void>();
219
224
 
220
225
  // --- Plugin entry point ---------------------------------------------------
221
226
 
@@ -359,6 +364,20 @@ async function init(
359
364
  });
360
365
  serveHandle = serve;
361
366
  const serveInfo = await serve.start();
367
+ // v3.5.7 — Persist serve-info so the dashboard can talk to us
368
+ try {
369
+ writeServeInfo(options.stateDir, {
370
+ baseUrl: serveInfo.baseUrl,
371
+ port: serveInfo.port,
372
+ password: serveInfo.password,
373
+ worktree: serveInfo.worktree,
374
+ pid: serveInfo.pid,
375
+ startedAt: serveInfo.startedAt,
376
+ }, logger);
377
+ logger.info(`[bizar] wrote serve-info to ${options.stateDir}/serve.json`);
378
+ } catch (err) {
379
+ logger.warn(`[bizar] failed to write serve-info: ${err instanceof Error ? err.message : String(err)}`);
380
+ }
362
381
  const http = new HttpClient({
363
382
  baseUrl: `http://127.0.0.1:${serveInfo.port}`,
364
383
  password: serveInfo.password,
@@ -437,7 +456,7 @@ async function init(
437
456
 
438
457
  // --- Signal traps (spec §5.3) ------------------------------------------
439
458
 
440
- installSignalHandlers(logger, instanceManager, serve, stream);
459
+ installSignalHandlers(logger, instanceManager, serve, stream, options.stateDir);
441
460
 
442
461
  const ctx: RuntimeContext = {
443
462
  logger,
@@ -462,6 +481,7 @@ function installSignalHandlers(
462
481
  instanceManager: InstanceManager | null,
463
482
  serve: ServeLifecycle | null,
464
483
  stream: EventStream | null,
484
+ stateDir: string,
465
485
  ): void {
466
486
  const onSignal = async (sig: "SIGTERM" | "SIGINT") => {
467
487
  if (shuttingDown) return;
@@ -503,7 +523,10 @@ function installSignalHandlers(
503
523
  }
504
524
  }
505
525
 
506
- // 4. Exit. (Note: the host may keep the process alive if other work
526
+ // 4. Clear serve-info so the dashboard doesn't try to talk to a dead serve.
527
+ clearServeInfo(stateDir, logger);
528
+
529
+ // 5. Exit. (Note: the host may keep the process alive if other work
507
530
  // is pending, but for the plugin process this is the end.)
508
531
  try {
509
532
  process.exit(0);
@@ -516,14 +539,19 @@ function installSignalHandlers(
516
539
  // duplicate handlers. Use `process.once` so each handler runs at most
517
540
  // once per signal; the `shuttingDown` guard catches reentry.
518
541
  for (const sig of ["SIGTERM", "SIGINT"] as const) {
519
- try {
520
- process.removeAllListeners(sig);
521
- } catch {
522
- // ignore
542
+ const previous = signalHandlerRefs.get(sig);
543
+ if (previous) {
544
+ try {
545
+ process.removeListener(sig, previous);
546
+ } catch {
547
+ // ignore
548
+ }
523
549
  }
524
- process.on(sig, () => {
550
+ const handler = () => {
525
551
  void onSignal(sig);
526
- });
552
+ };
553
+ signalHandlerRefs.set(sig, handler);
554
+ process.once(sig, handler);
527
555
  }
528
556
  }
529
557
 
@@ -728,9 +756,40 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
728
756
  };
729
757
 
730
758
  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
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
+ }
734
793
  },
735
794
 
736
795
  // §3.1, §4.5.1 — event: track session boundaries. We do NOT create
@@ -837,6 +896,29 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
837
896
  finalResponse = `Command failed: ${msg}`;
838
897
  }
839
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
+ }
840
922
  // Surface the response to the user/host. We throw so the
841
923
  // message is treated as handled; the LLM does not process
842
924
  // it further. The host renders the throw message.
@@ -1070,6 +1152,7 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
1070
1152
  );
1071
1153
  }
1072
1154
  }
1155
+ clearServeInfo(ctx.options.stateDir, ctx.logger);
1073
1156
  },
1074
1157
  };
1075
1158
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar-plugin",
3
- "version": "0.5.4",
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({
@@ -389,11 +430,22 @@ export class BackgroundStateStore {
389
430
  */
390
431
  async save(state: BackgroundState): Promise<void> {
391
432
  if (!this.ensureDir()) return;
433
+ return withLock(this.locks, state.instanceId, () => this.saveUnlocked(state));
434
+ }
435
+
436
+ /**
437
+ * Persist a `BackgroundState` without acquiring the per-instance mutex.
438
+ *
439
+ * Callers must already hold the lock for `state.instanceId`. This exists
440
+ * for internal code paths such as `InstanceManager.update()` that need to
441
+ * mutate in-memory state while holding the same lock; calling `save()`
442
+ * there would re-enter the mutex and deadlock the promise chain.
443
+ */
444
+ saveUnlocked(state: BackgroundState): Promise<void> {
445
+ if (!this.ensureDir()) return Promise.resolve();
392
446
  const filePath = backgroundStateFilePath(this.stateDir, state.instanceId);
393
- return withLock(this.locks, state.instanceId, () => {
394
- writeStateAtomic(filePath, state, this.logger);
395
- return Promise.resolve();
396
- });
447
+ writeStateAtomic(filePath, state, this.logger);
448
+ return Promise.resolve();
397
449
  }
398
450
 
399
451
  /**
package/src/background.ts CHANGED
@@ -139,6 +139,7 @@ const STALL_CHECK_INTERVAL_MS = 15_000;
139
139
  */
140
140
  export class InstanceManager {
141
141
  private instances = new Map<string, BackgroundState>();
142
+ private eventUnsubscribers = new Map<string, () => void>();
142
143
  private addLock: Promise<unknown> = Promise.resolve();
143
144
  private stateStore: BackgroundStateStore;
144
145
  private maxConcurrent: number;
@@ -193,6 +194,7 @@ export class InstanceManager {
193
194
  () => void this.runStallAndLoopChecks(),
194
195
  STALL_CHECK_INTERVAL_MS,
195
196
  );
197
+ this.stallCheckerTimer.unref?.();
196
198
  }
197
199
 
198
200
  // --- Getters ------------------------------------------------------------
@@ -276,6 +278,30 @@ export class InstanceManager {
276
278
  }
277
279
  }
278
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
+
279
305
  // --- Atomic add (spec §2.2) ---------------------------------------------
280
306
 
281
307
  /**
@@ -313,16 +339,7 @@ export class InstanceManager {
313
339
  lastToolOrTextAt: now,
314
340
  interventionCount: 0,
315
341
  };
316
- this.instances.set(draft.instanceId, full);
317
- // Persist asynchronously; failure is logged but does not roll back
318
- // the in-memory insert (the instance is "tracked" either way).
319
- this.stateStore.save(full).catch((err: unknown) => {
320
- this.logger.warn(
321
- `bizar: failed to persist new instance ${draft.instanceId}: ${
322
- err instanceof Error ? err.message : String(err)
323
- }`,
324
- );
325
- });
342
+ this.dispatchInternal(full);
326
343
  // BUGFIX (v0.5.1): Do NOT call attachEventHandler() here. The
327
344
  // instance was just added with sessionId="" (filled in later by
328
345
  // POST /session). EventStream.onSessionEvent rejects empty strings,
@@ -376,8 +393,11 @@ export class InstanceManager {
376
393
  if (TERMINAL_STATUSES.has(patch.status ?? current.status) && !current.completedAt) {
377
394
  current.completedAt = Date.now();
378
395
  }
396
+ if (TERMINAL_STATUSES.has(current.status)) {
397
+ this.detachEventHandler(instanceId);
398
+ }
379
399
  try {
380
- await this.stateStore.save(current);
400
+ await this.stateStore.saveUnlocked(current);
381
401
  } catch (err: unknown) {
382
402
  this.logger.warn(
383
403
  `bizar: failed to persist update for ${instanceId}: ${
@@ -421,6 +441,86 @@ export class InstanceManager {
421
441
  });
422
442
  this.logger.info(`bizar: killed background instance ${instanceId}`);
423
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
+
424
524
  // --- Collect ------------------------------------------------------------
425
525
 
426
526
  /**
@@ -629,6 +729,8 @@ export class InstanceManager {
629
729
  error: `No activity for ${this.stallTimeoutMs}ms — LLM appears stalled`,
630
730
  completedAt: Date.now(),
631
731
  });
732
+ // v0.5.5 — persistent auto-restart on stall
733
+ await this._maybeAutoRestart(inst.instanceId);
632
734
  }
633
735
 
634
736
  /**
@@ -699,18 +801,33 @@ export class InstanceManager {
699
801
  error: `Thinking loop detected: ${formatDuration(sinceMs)} of thinking without tool calls or output. Spawn a Mimir agent for research.`,
700
802
  completedAt: Date.now(),
701
803
  });
804
+ // v0.5.5 — persistent auto-restart on thinking-loop exhaustion
805
+ await this._maybeAutoRestart(inst.instanceId);
702
806
  }
703
807
 
704
808
  // --- Internal: per-session event handler -------------------------------
705
809
 
706
810
  public attachEventHandler(inst: BackgroundState): () => void {
811
+ this.detachEventHandler(inst.instanceId);
707
812
  const handler: SessionEventHandler = (ev: StreamEvent) => {
708
813
  void this.handleInstanceEvent(inst.instanceId, ev);
709
814
  };
710
815
  const unsubscribe = this.stream.onSessionEvent(inst.sessionId, handler);
816
+ this.eventUnsubscribers.set(inst.instanceId, unsubscribe);
711
817
  return unsubscribe;
712
818
  }
713
819
 
820
+ private detachEventHandler(instanceId: string): void {
821
+ const unsubscribe = this.eventUnsubscribers.get(instanceId);
822
+ if (!unsubscribe) return;
823
+ try {
824
+ unsubscribe();
825
+ } catch {
826
+ // ignore
827
+ }
828
+ this.eventUnsubscribers.delete(instanceId);
829
+ }
830
+
714
831
  private async handleInstanceEvent(
715
832
  instanceId: string,
716
833
  ev: StreamEvent,
@@ -741,6 +858,37 @@ export class InstanceManager {
741
858
  error: errMsg,
742
859
  completedAt: Date.now(),
743
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
+ );
744
892
  }
745
893
  }
746
894
 
@@ -782,7 +930,11 @@ export class InstanceManager {
782
930
  patch.completedAt = Date.now();
783
931
  }
784
932
  await this.update(instanceId, patch);
785
- 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
+ }
786
938
  }
787
939
 
788
940
  // --- Loop-guard threshold-12 detection (spec §4.1) ---
@@ -798,6 +950,8 @@ export class InstanceManager {
798
950
  loopGuardTool: tool,
799
951
  completedAt: Date.now(),
800
952
  });
953
+ // v0.5.5 — persistent auto-restart on loop-guard
954
+ await this._maybeAutoRestart(instanceId);
801
955
  return;
802
956
  }
803
957
  }
@@ -112,6 +112,9 @@ 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 dash start` as a detached
116
+ * child process. Reads the port file back and
117
+ * appends the URL to the parser's response.
115
118
  * `tool_invocation` — delegates to `executeToolInvocation`.
116
119
  *
117
120
  * Never throws. All failures become `responseOverride` strings.
@@ -131,6 +134,8 @@ export async function executeSideEffect(
131
134
  // No I/O — the parser already built the URL. The chat hook
132
135
  // uses the parser's response unchanged.
133
136
  return {};
137
+ case "launch_dashboard":
138
+ return await executeLaunchDashboard(sideEffect.defaultPort, ctx);
134
139
  case "tool_invocation":
135
140
  return await executeToolInvocation(sideEffect, ctx, opts);
136
141
  default: {
@@ -188,6 +193,96 @@ async function executeListPlans(
188
193
  };
189
194
  }
190
195
 
196
+ /**
197
+ * Launch the Bizar dashboard as a detached child process.
198
+ *
199
+ * We spawn `bizar dash start` with `detached: true` and `unref()`
200
+ * so the child's lifetime is independent of the plugin host. We then
201
+ * poll the port file (written by the child) for up to ~3s and append
202
+ * the URL to the parser's response. If anything goes wrong we surface
203
+ * a clear error so the user knows where to look.
204
+ *
205
+ * Never throws — all failures become responseSuffix/Override.
206
+ */
207
+ async function executeLaunchDashboard(
208
+ defaultPort: number,
209
+ ctx: ExecutorContext,
210
+ ): Promise<ExecuteResult> {
211
+ const { spawn } = await import("node:child_process");
212
+ const { existsSync, readFileSync } = await import("node:fs");
213
+ const { join } = await import("node:path");
214
+ const { homedir } = await import("node:os");
215
+
216
+ const portFile = join(homedir(), ".config", "bizar", "dashboard.port");
217
+
218
+ // If a dashboard is already running, just report its URL.
219
+ if (existsSync(portFile)) {
220
+ try {
221
+ const port = readFileSync(portFile, "utf8").trim();
222
+ if (port && Number.isFinite(Number(port))) {
223
+ return {
224
+ responseSuffix:
225
+ `\n✓ Dashboard already running at http://localhost:${port}/`,
226
+ };
227
+ }
228
+ } catch {
229
+ /* fall through to spawn */
230
+ }
231
+ }
232
+
233
+ try {
234
+ // `bizar` is on $PATH for global installs; for npx / local installs
235
+ // we'd want to resolve to the package's bin. Spawn `bizar` directly
236
+ // for now — the user's $PATH is the source of truth.
237
+ const child = spawn("bizar", ["dash", "start"], {
238
+ detached: true,
239
+ stdio: "ignore",
240
+ cwd: ctx.worktree,
241
+ });
242
+ child.on("error", (err) => {
243
+ ctx.logger.warn(`bizar: dashboard spawn error: ${err.message}`);
244
+ });
245
+ child.unref();
246
+ } catch (err: unknown) {
247
+ const msg = err instanceof Error ? err.message : String(err);
248
+ return {
249
+ responseOverride:
250
+ `Could not launch the Bizar dashboard: ${msg}\n` +
251
+ `Try running \`bizar dash start\` in your terminal.`,
252
+ };
253
+ }
254
+
255
+ // Poll the port file briefly so the response carries the live URL.
256
+ const deadline = Date.now() + 3000;
257
+ let resolvedPort: number | null = null;
258
+ while (Date.now() < deadline) {
259
+ if (existsSync(portFile)) {
260
+ try {
261
+ const port = Number(readFileSync(portFile, "utf8").trim());
262
+ if (Number.isFinite(port) && port > 0) {
263
+ resolvedPort = port;
264
+ break;
265
+ }
266
+ } catch {
267
+ /* ignore — keep polling */
268
+ }
269
+ }
270
+ await new Promise((r) => setTimeout(r, 100));
271
+ }
272
+
273
+ if (resolvedPort === null) {
274
+ return {
275
+ responseSuffix:
276
+ `\n✓ Dashboard launching… (preferred port ${defaultPort}, ` +
277
+ `fallback to a free port). The browser will open shortly.`,
278
+ };
279
+ }
280
+
281
+ return {
282
+ responseSuffix: `\n✓ Dashboard running at http://localhost:${resolvedPort}/`,
283
+ };
284
+ }
285
+
191
286
  // --- executeToolInvocation -----------------------------------------------
192
287
 
193
288
  /**