@polderlabs/bizar-plugin 0.6.1 → 0.8.0

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.
@@ -1,84 +1,86 @@
1
1
  /**
2
- * bg-spawn.ts
2
+ * plugins/bizar/src/tools/bg-spawn.ts
3
3
  *
4
- * `bizar_spawn_background` tool (v0.4.2 spec §1, §6.3, §7.1).
4
+ * v0.8.0 — `bizar_spawn_background` tool, refactored to spawn one
5
+ * `opencode run` subprocess per agent (instead of POSTing to a
6
+ * passive `opencode serve` HTTP API). See opencode-runner.ts for the
7
+ * spawning implementation; see ../opencode-runner.ts for the
8
+ * rationale and the wire format we parse.
5
9
  *
6
- * Odin-only any other agent calling this tool receives a clear error
7
- * (MEDIUM-26). The caller's `ctx.agent` is the source of truth.
8
- *
9
- * Args:
10
- * - `agent: string` — the agent to spawn (e.g. "mimir", "thor", "tyr").
11
- * - `prompt: string` — the user prompt; sent verbatim via
12
- * `parts: [{ type: "text", text: prompt }]`.
13
- * - `model?: string` — `"<providerID>/<modelID>"` override (LOW-34).
14
- * - `timeoutMs?: number` — collect-time timeout, clamped to [1000, 1800000].
15
- *
16
- * Returns on success:
17
- * `{ instanceId, sessionId, status: "pending" }`
18
- *
19
- * The "Track BEFORE HTTP" invariant (spec §2.2 / HIGH-21):
20
- * 1. Validate inputs.
21
- * 2. Generate `instanceId` and `messageID`.
22
- * 3. `InstanceManager.add()` — atomic cap check + insert; map entry
23
- * exists BEFORE any HTTP call.
24
- * 4. `POST /session` — returns the opencode `sessionId`.
25
- * 5. `POST /session/{id}/prompt_async` — fire the prompt.
26
- * 6. On either HTTP failure, mark the instance `failed` and return the
27
- * error. The map is never left in a half-state.
10
+ * Spec §1, §6.3, §7.1.
28
11
  */
29
-
30
12
  import { tool } from "@opencode-ai/plugin";
31
13
  import { z } from "zod";
32
14
 
33
- import type { InstanceManager } from "../background.js";
34
15
  import { generateInstanceId, generateMessageId } from "../background.js";
35
- import type { HttpClient, ModelOverride } from "../http-client.js";
16
+ import type { InstanceManager } from "../background.js";
36
17
  import type { Logger } from "../logger.js";
18
+ import { spawnAgent } from "../opencode-runner.js";
19
+ import { resolve as pathResolve } from "node:path";
20
+ import { homedir } from "node:os";
37
21
 
38
- // --- Spec §1.4 / LOW-34: model parameter parsing -------------------------
22
+ /** Spec §7.3: `timeoutMs` clamped to [1000, 1800000] (1s..30min). */
23
+ const TIMEOUT_MIN_MS = 1000;
24
+ const TIMEOUT_MAX_MS = 1_800_000;
25
+ const TIMEOUT_DEFAULT_MS = 300_000;
26
+
27
+ /** Mirrors plugins/bizar/src/http-client.ts ModelOverride. */
28
+ export interface ModelOverride {
29
+ providerID: string;
30
+ modelID: string;
31
+ }
39
32
 
40
33
  /**
41
- * Parse the `model?: string` argument.
42
- * - "provider/model" → { providerID, modelID }
43
- * - "model" → null (no slash)
44
- * - "a/b/c" → null (multiple slashes)
45
- * - "provider/" → null (empty half)
46
- * - "/model" → null (empty half)
34
+ * Parse "providerID/modelID" into a ModelOverride. Returns null when
35
+ * the input is empty (use the agent's default) or malformed.
47
36
  */
48
- function parseModel(raw: string): ModelOverride | null {
49
- if (raw === "") return null;
50
- const parts = raw.split("/");
51
- if (parts.length !== 2) return null;
52
- const providerID = parts[0];
53
- const modelID = parts[1];
37
+ function parseModel(raw: string | undefined): ModelOverride | null {
38
+ if (raw === undefined || raw === null) return null;
39
+ const trimmed = String(raw).trim();
40
+ if (trimmed === "") return null;
41
+ const idx = trimmed.indexOf("/");
42
+ if (idx <= 0 || idx === trimmed.length - 1) return null;
43
+ const providerID = trimmed.slice(0, idx).trim();
44
+ const modelID = trimmed.slice(idx + 1).trim();
54
45
  if (!providerID || !modelID) return null;
55
46
  return { providerID, modelID };
56
47
  }
57
48
 
58
- // --- Tool factory ---------------------------------------------------------
59
-
60
- /** Spec §7.3: `timeoutMs` clamped to [1000, 1800000] (1s..30min). */
61
- const TIMEOUT_MIN_MS = 1000;
62
- const TIMEOUT_MAX_MS = 1_800_000;
63
- const TIMEOUT_DEFAULT_MS = 300_000;
64
-
65
49
  export interface BgSpawnDeps {
66
50
  instanceManager: InstanceManager;
67
- http: HttpClient;
68
51
  worktree: string;
69
52
  logger: Logger;
70
53
  }
71
54
 
72
55
  /**
73
- * Build the `bizar_spawn_background` tool. The plugin wires the result
74
- * into `Hooks.tool`. The `deps` closure carries the per-process state
75
- * (InstanceManager, HttpClient, worktree, logger).
56
+ * Compute the LogWriter's actual log path for a given instanceId.
57
+ * The plugin's `LogWriter` (../report.ts:147) writes to
58
+ * `${logDir}/${sessionId}.log` where `logDir` defaults to
59
+ * `~/.cache/bizar/logs` (overridable via the `BIZAR_LOG_DIR` env
60
+ * var). The instanceId is what we know at spawn time — the opencode
61
+ * sessionId is generated later by the subprocess and we don't
62
+ * pre-allocate it. We therefore use the instanceId as the log file
63
+ * name and let the runner append to it.
64
+ */
65
+ function buildLogPath(instanceId: string): string {
66
+ const logDir = process.env.BIZAR_LOG_DIR || pathResolve(homedir(), ".cache", "bizar", "logs");
67
+ return pathResolve(logDir, `${instanceId}.log`);
68
+ }
69
+
70
+ /**
71
+ * Build the `bizar_spawn_background` tool. The plugin wires the
72
+ * result into `Hooks.tool`. The `deps` closure carries the
73
+ * per-process state (InstanceManager, worktree, logger).
76
74
  */
77
75
  export function createBgSpawnTool(deps: BgSpawnDeps) {
78
76
  return tool({
79
77
  description:
80
- "Spawn a background agent that runs asynchronously. Only Odin may call this tool. " +
81
- "Returns an instanceId; use bizar_status / bizar_collect / bizar_kill to manage the instance.",
78
+ "Spawn a background agent that runs asynchronously as a separate `opencode run` subprocess. " +
79
+ "Only Odin may call this tool. " +
80
+ "Returns an instanceId immediately (sub-second), then the agent runs to completion in the background. " +
81
+ "Use `bizar_status` / `bizar_collect` / `bizar_kill` to manage the instance. " +
82
+ "Use `bizar_bg_view` (CLI) to watch all running agents in a tmux split window. " +
83
+ "IMPORTANT: do NOT block waiting for the agent. Return control to the user right after spawning.",
82
84
  args: {
83
85
  agent: z.string().min(1).describe("Agent name to spawn (e.g. 'mimir', 'thor', 'tyr')."),
84
86
  prompt: z.string().min(1).describe("User prompt for the background session."),
@@ -133,7 +135,7 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
133
135
  if (m === null) {
134
136
  return {
135
137
  output: JSON.stringify({
136
- error: `model must be in "providerID/modelID" format (e.g. "openrouter/minimax-m3"). Omit to use the agent's default.`,
138
+ error: `model must be in "providerID/modelID" format (e.g. "opencode/deepseek-v4-flash-free"). Omit to use the agent's default.`,
137
139
  }),
138
140
  };
139
141
  }
@@ -151,11 +153,14 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
151
153
  }
152
154
  const timeoutMs = requested;
153
155
 
154
- // 4. Generate the instanceId and seed the manager (track BEFORE HTTP).
156
+ // 4. Generate the instanceId and seed the manager (track BEFORE
157
+ // the subprocess starts, so a fast-exiting agent is still
158
+ // queryable via bizar_status).
155
159
  const instanceId = generateInstanceId();
160
+ const logPath = buildLogPath(instanceId);
156
161
  const draft = {
157
162
  instanceId,
158
- sessionId: "", // filled in by POST /session response
163
+ sessionId: "", // filled in once the opencode run reports it
159
164
  agent: args.agent,
160
165
  model: modelOverride
161
166
  ? `${modelOverride.providerID}/${modelOverride.modelID}`
@@ -163,7 +168,7 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
163
168
  promptPreview: args.prompt.slice(0, 200),
164
169
  prompt: args.prompt, // store full prompt for restart support
165
170
  parentAgent: ctx.agent,
166
- logPath: buildLogPath(deps.worktree, instanceId),
171
+ logPath,
167
172
  timeoutMs,
168
173
  toolCallCount: 0,
169
174
  // v0.5.5 — persistent auto-restart
@@ -180,25 +185,30 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
180
185
  };
181
186
  }
182
187
 
183
- // 5. POST /session. The in-memory entry already exists.
184
- const sessionRes = await deps.http.createSession(
185
- {
186
- parentID: ctx.sessionID,
187
- title: `bgr:${args.agent}:${instanceId}`,
188
+ // 5. Spawn the opencode run subprocess. The runner returns
189
+ // when the opencode child has reported its session id in
190
+ // the structured log stream (typically <500ms).
191
+ const messageID = generateMessageId();
192
+ let spawnRes: Awaited<ReturnType<typeof spawnAgent>>;
193
+ try {
194
+ spawnRes = await spawnAgent({
195
+ prompt: args.prompt,
188
196
  agent: args.agent,
189
- ...(modelOverride ? { model: modelOverride } : {}),
190
- },
191
- deps.worktree,
192
- );
193
- if (!sessionRes.ok) {
197
+ model: modelOverride,
198
+ worktree: deps.worktree,
199
+ logPath,
200
+ title: `bgr:${args.agent}:${instanceId}:${messageID}`,
201
+ });
202
+ } catch (err: unknown) {
203
+ const msg = err instanceof Error ? err.message : String(err);
194
204
  await deps.instanceManager.update(instanceId, {
195
205
  status: "failed",
196
- error: `POST /session failed: ${sessionRes.error}`,
206
+ error: `spawnAgent threw: ${msg}`,
197
207
  completedAt: Date.now(),
198
208
  });
199
209
  return {
200
210
  output: JSON.stringify({
201
- error: `spawn failed: ${sessionRes.error}`,
211
+ error: `spawn crashed: ${msg}`,
202
212
  instanceId,
203
213
  sessionId: null,
204
214
  status: "failed",
@@ -206,78 +216,105 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
206
216
  };
207
217
  }
208
218
 
209
- // 6. Persist the sessionId in the in-memory state.
210
- await deps.instanceManager.update(instanceId, {
211
- sessionId: sessionRes.value.id,
212
- status: "running",
213
- });
214
-
215
- // 6b. BUGFIX (v0.5.1): Now that the real sessionId is known,
216
- // attach the SSE event handler for this instance. The track-BEFORE-
217
- // HTTP invariant is preserved (instance is in the map from step 4),
218
- // but the per-session event subscription is deferred to here so it
219
- // can be registered against the real sessionId rather than "".
220
- // Re-read the instance so the SSE handler sees the updated sessionId.
221
- const freshInstance = await deps.instanceManager.get(instanceId);
222
- if (freshInstance) {
223
- try {
224
- deps.instanceManager.attachEventHandler(freshInstance);
225
- } catch (err: unknown) {
226
- // Event handler attachment is best-effort. If it fails (e.g. the
227
- // SSE stream is disconnected) the instance is still tracked and
228
- // can be re-attached later via a reconnect.
229
- deps.logger.warn(
230
- `bizar: attachEventHandler failed for ${instanceId}: ${
231
- err instanceof Error ? err.message : String(err)
232
- }`,
233
- );
234
- }
235
- }
236
-
237
- // 7. POST /session/{id}/prompt_async.
238
- const messageID = generateMessageId();
239
- const sendRes = await deps.http.sendPrompt(
240
- {
241
- sessionId: sessionRes.value.id,
242
- messageID,
243
- agent: args.agent,
244
- ...(modelOverride ? { model: modelOverride } : {}),
245
- parts: [{ type: "text", text: args.prompt }],
246
- },
247
- deps.worktree,
248
- );
249
- if (!sendRes.ok) {
219
+ if (!spawnRes.ok || !spawnRes.sessionId) {
250
220
  await deps.instanceManager.update(instanceId, {
251
221
  status: "failed",
252
- error: `POST /session/{id}/prompt_async failed: ${sendRes.error}`,
222
+ error: spawnRes.error || "opencode run failed before reporting session id",
253
223
  completedAt: Date.now(),
254
224
  });
255
225
  return {
256
226
  output: JSON.stringify({
257
- error: `spawn failed: ${sendRes.error}`,
227
+ error: `spawn failed: ${spawnRes.error || "no session id"}`,
258
228
  instanceId,
259
- sessionId: sessionRes.value.id,
229
+ sessionId: null,
260
230
  status: "failed",
261
231
  }),
262
232
  };
263
233
  }
264
234
 
235
+ // 6. Persist the sessionId in the instance state. The high-level
236
+ // `status` stays "pending" until the runner reports a terminal
237
+ // state; we record the processId and the runner's
238
+ // intermediate states in the dedicated fields.
239
+ await deps.instanceManager.update(instanceId, {
240
+ sessionId: spawnRes.sessionId,
241
+ status: "running",
242
+ processId: spawnRes.processId,
243
+ runnerState: "running",
244
+ sessionIdAt: Date.now(),
245
+ });
246
+
247
+ // 7. Wire the runner's exit event to the instance state. When
248
+ // the opencode run subprocess exits, the runner updates
249
+ // the status (done / failed / killed) and triggers the
250
+ // persistent auto-restart flow if appropriate.
251
+ if (spawnRes.processId !== undefined) {
252
+ const { onExit } = await import("../opencode-runner.js");
253
+ onExit(spawnRes.processId, (status) => {
254
+ // Map runner states to BackgroundStatus. The runner reports
255
+ // "starting" | "running" | "done" | "failed" | "killed";
256
+ // BackgroundStatus has "pending" | "running" | "done" |
257
+ // "failed" | "killed" | "timed_out". "starting" maps to
258
+ // "running" (in-flight, no terminal state).
259
+ const mapped: "pending" | "running" | "done" | "failed" | "killed" =
260
+ status.state === "starting" || status.state === "running"
261
+ ? "running"
262
+ : status.state;
263
+
264
+ const update: Parameters<InstanceManager["update"]>[1] = {
265
+ status: mapped,
266
+ runnerState: status.state,
267
+ completedAt: status.endedAt ?? Date.now(),
268
+ };
269
+ if (status.exitCode !== undefined) update.exitCode = status.exitCode;
270
+ if (status.error) {
271
+ update.runnerError = status.error;
272
+ update.error = status.error;
273
+ }
274
+ if (status.endedAt !== undefined) update.runnerEndedAt = status.endedAt;
275
+ // Best-effort: if the InstanceManager has gone away (e.g.
276
+ // the plugin restarted), the update silently no-ops.
277
+ deps.instanceManager
278
+ .update(instanceId, update)
279
+ .then(() => {
280
+ // Persistent auto-restart: only for natural failures,
281
+ // not for explicit kills or successes.
282
+ if (status.state === "failed") {
283
+ return deps.instanceManager.maybeAutoRestart(instanceId);
284
+ }
285
+ return undefined;
286
+ })
287
+ .catch((err: unknown) => {
288
+ deps.logger.warn(
289
+ `bizar: bg-spawn exit update failed for ${instanceId}: ${
290
+ err instanceof Error ? err.message : String(err)
291
+ }`,
292
+ );
293
+ });
294
+ });
295
+ }
296
+
297
+ // 8. Return the spawn result. v0.8.0 message: the agent is
298
+ // running in the background; Odin should return control to
299
+ // the user immediately and not block waiting.
265
300
  return {
266
301
  output: JSON.stringify({
267
302
  instanceId,
268
- sessionId: sessionRes.value.id,
269
- status: "pending",
303
+ sessionId: spawnRes.sessionId,
304
+ processId: spawnRes.processId,
305
+ status: "running",
306
+ message:
307
+ "Background agent started. It will run to completion in a separate `opencode run` subprocess. " +
308
+ "Use `bizar_status <instanceId>` to check progress, `bizar_collect <instanceId>` to wait for the result, " +
309
+ "or `bizar_kill <instanceId>` to stop it. Run `bizar bg view` in another terminal to watch all running agents live.",
310
+ nextSteps: [
311
+ "Tell the user the agent is running and approximately how long they should expect to wait",
312
+ "If the user wants the result now, call `bizar_collect <instanceId>` (with a reasonable timeout)",
313
+ "If the user wants to stop the agent, call `bizar_kill <instanceId>`",
314
+ "Do NOT block waiting for the result unless the user explicitly asked for it",
315
+ ],
270
316
  }),
271
317
  };
272
318
  },
273
319
  });
274
320
  }
275
-
276
- // --- Helpers --------------------------------------------------------------
277
-
278
- function buildLogPath(worktree: string, instanceId: string): string {
279
- // The log file is owned by the opencode serve child (not by us). The
280
- // plugin doesn't write to it. We still record the conventional path so
281
- // the user can `cat` the per-session log for diagnostics.
282
- return `${worktree}/.opencode/log/${instanceId}.log`;
283
- }
@@ -83,9 +83,9 @@ describe("config drift detection", () => {
83
83
  ).toEqual([]);
84
84
  });
85
85
 
86
- test("plugins/bizar/package.json version is 0.5.4", () => {
86
+ test("plugins/bizar/package.json version is 0.8.0", () => {
87
87
  const pkg = JSON.parse(readFileSync(PKG_JSON, "utf-8")) as { version?: string };
88
- expect(pkg.version).toBe("0.5.4");
88
+ expect(pkg.version).toBe("0.8.0");
89
89
  });
90
90
  });
91
91
 
@@ -0,0 +1,159 @@
1
+ /**
2
+ * dashboard-client.test.ts
3
+ *
4
+ * v0.7.0-alpha.1 — Tests for the SDK-backed dashboard publisher.
5
+ *
6
+ * Verifies:
7
+ * - publish() succeeds when the dashboard is reachable (uses a stub
8
+ * fetch via `createBizarClient`'s `fetch` injection — but our
9
+ * publisher doesn't currently expose fetch injection, so we test
10
+ * via the real `publishV2Event` round-trip from the smoke test
11
+ * pattern instead).
12
+ * - publish() drops events when the publisher is not started.
13
+ * - Graceful degradation: env var override is respected.
14
+ *
15
+ * Network tests (publish to a real dashboard) live in
16
+ * `tests/integration/dashboard-bridge.test.ts` and require a running
17
+ * dashboard server.
18
+ */
19
+
20
+ import { describe, it, expect } from "bun:test";
21
+ import { createDashboardPublisher, type Logger } from "../src/dashboard-client.js";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Test logger
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const makeLogger = (): Logger & { lines: string[] } => {
28
+ const lines: string[] = [];
29
+ return {
30
+ lines,
31
+ debug(m) { lines.push(`debug: ${m}`); },
32
+ info(m) { lines.push(`info: ${m}`); },
33
+ warn(m) { lines.push(`warn: ${m}`); },
34
+ error(m) { lines.push(`error: ${m}`); },
35
+ };
36
+ };
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Tests
40
+ // ---------------------------------------------------------------------------
41
+
42
+ describe("createDashboardPublisher", () => {
43
+ it("publish() drops events when not started", async () => {
44
+ const logger = makeLogger();
45
+ const pub = createDashboardPublisher({ logger });
46
+ // Do NOT call start()
47
+ expect(pub.isReady()).toBe(false);
48
+
49
+ await pub.publish({
50
+ type: "session.created",
51
+ properties: { sessionId: "ses_1", agent: "mimir" },
52
+ });
53
+
54
+ // Logger should have recorded a debug drop message.
55
+ expect(logger.lines.some((l) => l.includes("not ready"))).toBe(true);
56
+ });
57
+
58
+ it("start() is a no-op when disabled by config", async () => {
59
+ const logger = makeLogger();
60
+ const pub = createDashboardPublisher({ logger, disabled: true });
61
+ await pub.start();
62
+ expect(pub.isReady()).toBe(false);
63
+ expect(logger.lines.some((l) => l.includes("disabled"))).toBe(true);
64
+ });
65
+
66
+ it("start() is a no-op when no password is available", async () => {
67
+ const logger = makeLogger();
68
+ // Make sure no env vars or auth files are present in this test.
69
+ const prevUrl = process.env.BIZAR_DASHBOARD_URL;
70
+ const prevPort = process.env.BIZAR_DASHBOARD_PORT;
71
+ const prevPw = process.env.BIZAR_DASHBOARD_PASSWORD;
72
+ const prevAuthFile = process.env.BIZAR_DASHBOARD_AUTH_FILE;
73
+ const prevAuthFiles = process.env.BIZAR_DASHBOARD_AUTH_FILES;
74
+ delete process.env.BIZAR_DASHBOARD_URL;
75
+ delete process.env.BIZAR_DASHBOARD_PORT;
76
+ delete process.env.BIZAR_DASHBOARD_PASSWORD;
77
+ // Point auth-file discovery at a non-existent path.
78
+ process.env.BIZAR_DASHBOARD_AUTH_FILE = "/tmp/__no_such_auth_file__.json";
79
+
80
+ try {
81
+ const pub = createDashboardPublisher({ logger });
82
+ await pub.start();
83
+ expect(pub.isReady()).toBe(false);
84
+ expect(logger.lines.some((l) => l.includes("not started"))).toBe(true);
85
+ } finally {
86
+ if (prevUrl !== undefined) process.env.BIZAR_DASHBOARD_URL = prevUrl;
87
+ if (prevPort !== undefined) process.env.BIZAR_DASHBOARD_PORT = prevPort;
88
+ if (prevPw !== undefined) process.env.BIZAR_DASHBOARD_PASSWORD = prevPw;
89
+ if (prevAuthFile !== undefined) {
90
+ process.env.BIZAR_DASHBOARD_AUTH_FILE = prevAuthFile;
91
+ } else {
92
+ delete process.env.BIZAR_DASHBOARD_AUTH_FILE;
93
+ }
94
+ if (prevAuthFiles !== undefined) {
95
+ process.env.BIZAR_DASHBOARD_AUTH_FILES = prevAuthFiles;
96
+ } else {
97
+ delete process.env.BIZAR_DASHBOARD_AUTH_FILES;
98
+ }
99
+ }
100
+ });
101
+
102
+ it("start() succeeds when password is provided via env", async () => {
103
+ const logger = makeLogger();
104
+ process.env.BIZAR_DASHBOARD_PASSWORD = "test-password-1234567890abcdef";
105
+
106
+ try {
107
+ const pub = createDashboardPublisher({
108
+ logger,
109
+ baseUrl: "http://127.0.0.1:1", // port 1 = unreachable, but SDK client constructs fine
110
+ });
111
+ await pub.start();
112
+ expect(pub.isReady()).toBe(true);
113
+ expect(logger.lines.some((l) => l.includes("publisher started"))).toBe(true);
114
+ pub.stop();
115
+ } finally {
116
+ delete process.env.BIZAR_DASHBOARD_PASSWORD;
117
+ }
118
+ });
119
+
120
+ it("start() respects BIZAR_DASHBOARD_URL env var", async () => {
121
+ const logger = makeLogger();
122
+ process.env.BIZAR_DASHBOARD_URL = "http://127.0.0.1:4099";
123
+ process.env.BIZAR_DASHBOARD_PASSWORD = "test-password-1234567890abcdef";
124
+
125
+ try {
126
+ const pub = createDashboardPublisher({ logger });
127
+ await pub.start();
128
+ expect(pub.isReady()).toBe(true);
129
+ expect(
130
+ logger.lines.some((l) => l.includes("url=http://127.0.0.1:4099")),
131
+ ).toBe(true);
132
+ pub.stop();
133
+ } finally {
134
+ delete process.env.BIZAR_DASHBOARD_URL;
135
+ delete process.env.BIZAR_DASHBOARD_PASSWORD;
136
+ }
137
+ });
138
+
139
+ it("stop() clears ready state and drops queue", async () => {
140
+ const logger = makeLogger();
141
+ process.env.BIZAR_DASHBOARD_PASSWORD = "test-password-1234567890abcdef";
142
+
143
+ try {
144
+ const pub = createDashboardPublisher({ logger });
145
+ await pub.start();
146
+ expect(pub.isReady()).toBe(true);
147
+ pub.stop();
148
+ expect(pub.isReady()).toBe(false);
149
+ // Subsequent publish should be a no-op (drops because not ready).
150
+ await pub.publish({
151
+ type: "session.created",
152
+ properties: { sessionId: "ses_x", agent: "thor" },
153
+ });
154
+ // Just verifies it doesn't throw.
155
+ } finally {
156
+ delete process.env.BIZAR_DASHBOARD_PASSWORD;
157
+ }
158
+ });
159
+ });