@polderlabs/bizar-plugin 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,360 @@
1
+ /**
2
+ * plugins/bizar/src/reasoning-clean.ts
3
+ *
4
+ * Wraps a provider's `fetch` to strip inline ``...</think>`` blocks from
5
+ * `message.content` / `delta.content` when the response also includes
6
+ * structured reasoning (`reasoning`, `reasoning_details`, or
7
+ * `reasoning_content`).
8
+ *
9
+ * Why this exists
10
+ * ───────────────
11
+ * Some reasoning models (e.g. MiniMax M3 via OpenRouter) emit their chain
12
+ * of thought BOTH:
13
+ * 1. In the structured `reasoning` / `reasoning_details` field, which
14
+ * opencode already extracts and renders as a separate "thought"
15
+ * chunk, AND
16
+ * 2. Inlined in `content` as `` blocks, which opencode would also
17
+ * render as plain text — producing the duplicate "Thought: … + the
18
+ * same text in the assistant message" the user sees.
19
+ *
20
+ * opencode's openrouter-specific SDK does not strip the inline think
21
+ * blocks from `content`. The opencode-level `interleaved` config that
22
+ * could solve this only applies to the `@ai-sdk/openai-compatible` SDK.
23
+ * Wrapping `provider.options.fetch` in the `config` hook is the only
24
+ * hook surface where the response body can be post-processed.
25
+ *
26
+ * Behaviour
27
+ * ─────────
28
+ * • Only `POST` requests whose URL ends with `/chat/completions` are
29
+ * intercepted. Other requests pass through untouched.
30
+ * • Non-streaming responses (`Content-Type: application/json`) are parsed,
31
+ * mutated, and re-serialised.
32
+ * • Streaming responses (`Content-Type: text/event-stream`) are piped
33
+ * through a `TransformStream` that buffers content across chunks and
34
+ * drops anything between a complete ` pair, using a tiny state
35
+ * machine so chunks that split a marker mid-stream are handled.
36
+ * • If parsing or rewriting fails for any reason, the original response
37
+ * is forwarded unchanged — this wrapper must never break a chat.
38
+ */
39
+
40
+ const THINK_OPEN = "<think>" as const;
41
+ const THINK_CLOSE = "</think>" as const;
42
+
43
+ type FetchLike = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>;
44
+
45
+ export interface ReasoningCleanOptions {
46
+ /** Extra logger for debug lines; defaults to no-op. */
47
+ debug?: (msg: string) => void;
48
+ /**
49
+ * Provider ids whose responses should be cleaned. Defaults to the set
50
+ * known to exhibit the duplicated-think pattern: openrouter and minimax.
51
+ */
52
+ providers?: string[];
53
+ }
54
+
55
+ const DEFAULT_PROVIDERS = new Set(["openrouter", "minimax"]);
56
+
57
+ /**
58
+ * Strip ``...</think>`` blocks from a plain string. Used for
59
+ * non-streaming responses (or for accumulated streamed content).
60
+ *
61
+ * The trailing whitespace after `</think>` is also consumed so the
62
+ * cleaned content does not start with an extra blank line.
63
+ */
64
+ export function stripInlineThinkBlocks(content: string): string {
65
+ return content.replace(/<think>[\s\S]*?<\/think>\s*/g, "");
66
+ }
67
+
68
+ /**
69
+ * Stream-level state machine: feed it the content deltas in order; it
70
+ * yields the content that should be forwarded to the caller.
71
+ */
72
+ class ThinkStripper {
73
+ private state: "NORMAL" | "IN_THINK" = "NORMAL";
74
+ // Buffer of characters that may be the start of a marker but are not
75
+ // yet complete. Holds at most max(THINK_OPEN.length, THINK_CLOSE.length)
76
+ // characters from a chunk boundary.
77
+ private pending = "";
78
+
79
+ push(chunk: string): string {
80
+ if (chunk.length === 0) return "";
81
+ let input = this.pending + chunk;
82
+ this.pending = "";
83
+ let out = "";
84
+
85
+ while (input.length > 0) {
86
+ if (this.state === "NORMAL") {
87
+ const idx = input.indexOf(THINK_OPEN);
88
+ if (idx === -1) {
89
+ // No open marker; might have a partial at the tail.
90
+ const tail = keepPartialTail(input, [THINK_OPEN]);
91
+ out += input.slice(0, input.length - tail.length);
92
+ this.pending = tail;
93
+ input = "";
94
+ break;
95
+ }
96
+ out += input.slice(0, idx);
97
+ input = input.slice(idx + THINK_OPEN.length);
98
+ this.state = "IN_THINK";
99
+ } else {
100
+ // IN_THINK
101
+ const idx = input.indexOf(THINK_CLOSE);
102
+ if (idx === -1) {
103
+ // Still inside a think block; might have a partial close at tail.
104
+ const tail = keepPartialTail(input, [THINK_CLOSE]);
105
+ // Discard everything except the possible partial tail.
106
+ this.pending = tail;
107
+ input = "";
108
+ break;
109
+ }
110
+ input = input.slice(idx + THINK_CLOSE.length);
111
+ this.state = "NORMAL";
112
+ // Drop any whitespace that immediately follows the close tag so
113
+ // the next emitted content does not start with extra blank lines.
114
+ const wsMatch = input.match(/^\s*/);
115
+ if (wsMatch) input = input.slice(wsMatch[0].length);
116
+ }
117
+ }
118
+
119
+ return out;
120
+ }
121
+
122
+ flush(): string {
123
+ // If the stream ended while still inside a think block (malformed
124
+ // response), emit any pending tail rather than swallowing it.
125
+ const tail = this.pending;
126
+ this.pending = "";
127
+ if (this.state === "IN_THINK") {
128
+ this.state = "NORMAL";
129
+ return tail;
130
+ }
131
+ return tail;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Of a string, return the longest suffix that is a prefix of one of the
137
+ * given markers. Used to defer deciding whether a chunk ends in a real
138
+ * marker until the next chunk arrives.
139
+ */
140
+ function keepPartialTail(input: string, markers: readonly string[]): string {
141
+ const max = Math.max(...markers.map((m) => m.length));
142
+ const start = Math.max(0, input.length - max);
143
+ const window = input.slice(start);
144
+ for (let len = Math.min(max, window.length); len > 0; len--) {
145
+ const candidate = window.slice(0, len);
146
+ if (markers.some((m) => m.startsWith(candidate))) return candidate;
147
+ }
148
+ return "";
149
+ }
150
+
151
+ /**
152
+ * Decide whether the URL targets one of the providers we should clean.
153
+ * The provider id may appear in the hostname (e.g. `openrouter.ai`)
154
+ * rather than as a path segment, so we match against the full URL.
155
+ */
156
+ function targetsProvider(url: string, providers: Set<string>): string | null {
157
+ const lower = url.toLowerCase();
158
+ for (const p of providers) {
159
+ const lp = p.toLowerCase();
160
+ if (lower.includes(`/${lp}/`) || lower.includes(`/${lp}?`) || lower.includes(`${lp}.`) || lower.includes(`-${lp}.`) || lower.includes(`.${lp}/`)) {
161
+ return p;
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+
167
+ /**
168
+ * Return true if a request body looks like an OpenAI-compatible chat
169
+ * completions request (so we know whether to inspect the response).
170
+ */
171
+ function isChatCompletionsRequest(url: string, init?: RequestInit): boolean {
172
+ if (!/\/chat\/completions(?:\?|$)/.test(url)) return false;
173
+ const method = (init?.method ?? "POST").toUpperCase();
174
+ return method === "POST";
175
+ }
176
+
177
+ /**
178
+ * Process one non-streaming JSON response: strip inline think blocks
179
+ * from `choices[*].message.content`. Returns the original text on any
180
+ * parse error.
181
+ */
182
+ function cleanNonStreamingJson(text: string): string {
183
+ const data = JSON.parse(text);
184
+ const choices = Array.isArray(data?.choices) ? data.choices : [];
185
+ let touched = false;
186
+ for (const choice of choices) {
187
+ const msg = choice?.message;
188
+ if (msg && typeof msg.content === "string" && msg.content.includes(THINK_OPEN)) {
189
+ const cleaned = stripInlineThinkBlocks(msg.content);
190
+ if (cleaned !== msg.content) {
191
+ msg.content = cleaned;
192
+ touched = true;
193
+ }
194
+ }
195
+ }
196
+ return touched ? JSON.stringify(data) : text;
197
+ }
198
+
199
+ /**
200
+ * Process one SSE event line of the form `data: <payload>`. Mutates the
201
+ * decoded payload in place to strip inline think blocks from
202
+ * `choices[*].delta.content`, using a per-message `ThinkStripper` so
203
+ * content split across chunks is still handled correctly.
204
+ *
205
+ * `strippers` is an array keyed by choice index — each choice maintains
206
+ * its own stripper across multiple events.
207
+ */
208
+ function cleanSseLine(
209
+ line: string,
210
+ strippers: ThinkStripper[],
211
+ debug?: (msg: string) => void,
212
+ ): string {
213
+ if (!line.startsWith("data:")) return line;
214
+ const payload = line.slice(5).trimStart();
215
+ if (payload === "[DONE]") {
216
+ // Flush any in-flight strippers so we don't lose content that was
217
+ // waiting on a chunk boundary.
218
+ return line;
219
+ }
220
+ let obj: any;
221
+ try {
222
+ obj = JSON.parse(payload);
223
+ } catch {
224
+ return line;
225
+ }
226
+ const choices = Array.isArray(obj?.choices) ? obj.choices : [];
227
+ for (let i = 0; i < choices.length; i++) {
228
+ const delta = choices[i]?.delta;
229
+ if (!delta || typeof delta.content !== "string" || delta.content.length === 0) continue;
230
+ let stripper = strippers[i];
231
+ if (!stripper) {
232
+ stripper = new ThinkStripper();
233
+ strippers[i] = stripper;
234
+ }
235
+ const cleaned = stripper.push(delta.content);
236
+ if (cleaned.length > 0) {
237
+ delta.content = cleaned;
238
+ } else {
239
+ // Avoid sending an empty content delta — some relays reject them.
240
+ delete delta.content;
241
+ }
242
+ if (choices[i]?.finish_reason) {
243
+ // End of this choice: flush the stripper so any pending partial
244
+ // marker becomes part of the final delta.
245
+ const flushed = stripper.flush();
246
+ if (flushed.length > 0) {
247
+ delta.content = (delta.content ?? "") + flushed;
248
+ } else if (delta.content === undefined) {
249
+ // Keep the choice delta well-formed even if nothing is left.
250
+ delta.content = "";
251
+ }
252
+ }
253
+ }
254
+ debug?.(`reasoning-clean: rewrote SSE line ${payload.length}B`);
255
+ return "data: " + JSON.stringify(obj);
256
+ }
257
+
258
+ /**
259
+ * Transform an SSE response stream: parse each `data:` line, strip inline
260
+ * think blocks, and re-emit the bytes.
261
+ */
262
+ function streamTransformer(debug?: (msg: string) => void): TransformStream<Uint8Array, Uint8Array> {
263
+ const decoder = new TextDecoder("utf-8");
264
+ const encoder = new TextEncoder();
265
+ const strippers: ThinkStripper[] = [];
266
+ let buffer = "";
267
+ return new TransformStream<Uint8Array, Uint8Array>({
268
+ transform(chunk, controller) {
269
+ buffer += decoder.decode(chunk, { stream: true });
270
+ // SSE events are separated by a blank line ("\n\n").
271
+ let boundary = buffer.indexOf("\n\n");
272
+ while (boundary !== -1) {
273
+ const event = buffer.slice(0, boundary);
274
+ buffer = buffer.slice(boundary + 2);
275
+ const rewritten = event
276
+ .split("\n")
277
+ .map((line) => cleanSseLine(line, strippers, debug))
278
+ .join("\n");
279
+ controller.enqueue(encoder.encode(rewritten + "\n\n"));
280
+ boundary = buffer.indexOf("\n\n");
281
+ }
282
+ },
283
+ flush(controller) {
284
+ // Flush any leftover SSE event at end of stream.
285
+ const tail = buffer + decoder.decode();
286
+ if (tail.length > 0) {
287
+ const rewritten = tail
288
+ .split("\n")
289
+ .map((line) => cleanSseLine(line, strippers, debug))
290
+ .join("\n");
291
+ controller.enqueue(encoder.encode(rewritten));
292
+ }
293
+ },
294
+ });
295
+ }
296
+
297
+ /**
298
+ * Wrap a fetch implementation so that responses from the listed
299
+ * providers have inline ``...</think>`` blocks stripped from the
300
+ * content while preserving the structured reasoning fields. Returns a
301
+ * function with the same signature as the original fetch.
302
+ */
303
+ export function wrapFetchForReasoningCleanup(
304
+ originalFetch: FetchLike,
305
+ options: ReasoningCleanOptions = {},
306
+ ): FetchLike {
307
+ const providers = options.providers
308
+ ? new Set(options.providers)
309
+ : DEFAULT_PROVIDERS;
310
+ const debug = options.debug;
311
+
312
+ return async (input, init) => {
313
+ const url =
314
+ typeof input === "string"
315
+ ? input
316
+ : input instanceof URL
317
+ ? input.toString()
318
+ : (input as Request).url;
319
+ // Resolve which provider this call is going to. If we can't tell, pass through.
320
+ const providerHit = targetsProvider(url, providers);
321
+ if (!isChatCompletionsRequest(url, init)) {
322
+ return originalFetch(input, init);
323
+ }
324
+ if (!providerHit) {
325
+ return originalFetch(input, init);
326
+ }
327
+ let response: Response;
328
+ try {
329
+ response = await originalFetch(input, init);
330
+ } catch (err) {
331
+ debug?.(`reasoning-clean: fetch threw, passing through: ${(err as Error).message}`);
332
+ throw err;
333
+ }
334
+ const ct = response.headers.get("content-type") ?? "";
335
+ if (ct.includes("text/event-stream")) {
336
+ const body = response.body;
337
+ if (!body) return response;
338
+ const transformed = body.pipeThrough(streamTransformer(debug));
339
+ return new Response(transformed, {
340
+ status: response.status,
341
+ statusText: response.statusText,
342
+ headers: response.headers,
343
+ });
344
+ }
345
+ // Non-streaming JSON.
346
+ try {
347
+ const text = await response.text();
348
+ const cleaned = cleanNonStreamingJson(text);
349
+ if (cleaned === text) return response;
350
+ return new Response(cleaned, {
351
+ status: response.status,
352
+ statusText: response.statusText,
353
+ headers: response.headers,
354
+ });
355
+ } catch (err) {
356
+ debug?.(`reasoning-clean: clean failed, passing through: ${(err as Error).message}`);
357
+ return response;
358
+ }
359
+ };
360
+ }
package/src/serve.ts CHANGED
@@ -229,13 +229,19 @@ export class ServeLifecycle {
229
229
 
230
230
  /**
231
231
  * Graceful stop: SIGTERM, wait up to 5s, then SIGKILL. Idempotent.
232
+ *
233
+ * Cross-platform note: Bun's `Subprocess.kill()` without an explicit
234
+ * signal maps to the platform-appropriate default (`SIGTERM` on
235
+ * POSIX, `TerminateProcess` on Windows). The forced-kill phase drops
236
+ * the signal argument for the same reason, so the same code works
237
+ * on both Windows and Linux/macOS without a platform branch.
232
238
  */
233
239
  async stop(): Promise<void> {
234
240
  const proc = this._proc;
235
241
  if (proc === null) return;
236
242
  this._intentionalShutdown = true;
237
243
  try {
238
- proc.kill("SIGTERM");
244
+ proc.kill();
239
245
  } catch {
240
246
  // already dead
241
247
  }
@@ -243,7 +249,7 @@ export class ServeLifecycle {
243
249
  await withTimeout(proc.exited, 5_000);
244
250
  } catch {
245
251
  try {
246
- proc.kill("SIGKILL");
252
+ proc.kill();
247
253
  } catch {
248
254
  // ignore
249
255
  }
@@ -344,7 +350,10 @@ export class ServeLifecycle {
344
350
  const proc = this._proc;
345
351
  if (proc !== null) {
346
352
  try {
347
- proc.kill("SIGKILL");
353
+ // No signal — Bun maps the default to the platform-appropriate
354
+ // forced termination (SIGKILL on POSIX, TerminateProcess on
355
+ // Windows).
356
+ proc.kill();
348
357
  } catch {
349
358
  // ignore
350
359
  }
@@ -92,6 +92,19 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
92
92
  .positive()
93
93
  .optional()
94
94
  .describe("Collect-time timeout in ms (1s..30min, default 5min)."),
95
+ persistent: z
96
+ .boolean()
97
+ .optional()
98
+ .default(false)
99
+ .describe("When true, auto-restart on terminal failure (up to maxRestarts)."),
100
+ maxRestarts: z
101
+ .number()
102
+ .int()
103
+ .min(1)
104
+ .max(10)
105
+ .optional()
106
+ .default(3)
107
+ .describe("Number of auto-restart attempts before giving up."),
95
108
  },
96
109
  execute: async (rawArgs, ctx) => {
97
110
  // 1. Odin-only (MEDIUM-26).
@@ -109,6 +122,8 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
109
122
  prompt: string;
110
123
  model?: string;
111
124
  timeoutMs?: number;
125
+ persistent?: boolean;
126
+ maxRestarts?: number;
112
127
  };
113
128
 
114
129
  // 2. Validate the model parameter (LOW-34 / §1.4).
@@ -118,7 +133,7 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
118
133
  if (m === null) {
119
134
  return {
120
135
  output: JSON.stringify({
121
- error: `model must be in "providerID/modelID" format (e.g. "minimax/MiniMax-M3"). Omit to use the agent's default.`,
136
+ error: `model must be in "providerID/modelID" format (e.g. "openrouter/minimax-m3"). Omit to use the agent's default.`,
122
137
  }),
123
138
  };
124
139
  }
@@ -146,10 +161,15 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
146
161
  ? `${modelOverride.providerID}/${modelOverride.modelID}`
147
162
  : "agent-default",
148
163
  promptPreview: args.prompt.slice(0, 200),
164
+ prompt: args.prompt, // store full prompt for restart support
149
165
  parentAgent: ctx.agent,
150
166
  logPath: buildLogPath(deps.worktree, instanceId),
151
167
  timeoutMs,
152
168
  toolCallCount: 0,
169
+ // v0.5.5 — persistent auto-restart
170
+ persistent: args.persistent ?? false,
171
+ maxRestarts: args.maxRestarts ?? 3,
172
+ restartCount: 0,
153
173
  };
154
174
  const addRes = await deps.instanceManager.add(draft);
155
175
  if (addRes === "cap_reached") {
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  import { describe, it, expect, beforeEach } from "bun:test";
17
+ import os from "node:os";
18
+ import path from "node:path";
17
19
 
18
20
  // --- Real InstanceManager (the one under test) ----------------------------
19
21
 
@@ -86,14 +88,14 @@ function makeDraft(overrides: Partial<BackgroundState> = {}): BackgroundState {
86
88
  agent: "mimir",
87
89
  status: "pending",
88
90
  startedAt: Date.now(),
89
- model: "minimax/MiniMax-M3",
91
+ model: "openrouter/minimax-m3",
90
92
  promptPreview: "test",
91
93
  resultPreview: undefined,
92
94
  resultMessageIds: [],
93
95
  error: undefined,
94
96
  parentAgent: "odin",
95
97
  parentInstanceId: undefined,
96
- logPath: "/tmp/test.log",
98
+ logPath: path.join(os.tmpdir(), "test.log"),
97
99
  timeoutMs: 300_000,
98
100
  toolCallCount: 0,
99
101
  loopGuardTool: undefined,
@@ -122,7 +124,7 @@ describe("InstanceManager.add — empty sessionId (BUGFIX v0.5.1)", () => {
122
124
  warn: () => {},
123
125
  error: () => {},
124
126
  } as never,
125
- serve: { worktree: "/tmp" } as never,
127
+ serve: { worktree: os.tmpdir() } as never,
126
128
  http: {} as never,
127
129
  stream: stream as never,
128
130
  stallTimeoutMs: 180_000,
@@ -40,7 +40,7 @@ function makeState(overrides: Partial<BackgroundState> = {}): BackgroundState {
40
40
  agent: "mimir",
41
41
  status: "running",
42
42
  startedAt: Date.now(),
43
- model: "minimax/MiniMax-M3",
43
+ model: "openrouter/minimax-m3",
44
44
  promptPreview: "Do the thing",
45
45
  resultPreview: undefined,
46
46
  resultMessageIds: [],
@@ -22,7 +22,7 @@ function makeBgState(overrides: Partial<BackgroundState> = {}): BackgroundState
22
22
  agent: "mimir",
23
23
  status: "pending",
24
24
  startedAt: Date.now(),
25
- model: "minimax/MiniMax-M3",
25
+ model: "openrouter/minimax-m3",
26
26
  promptPreview: "Do the thing",
27
27
  resultPreview: undefined,
28
28
  resultMessageIds: [],
@@ -17,6 +17,8 @@
17
17
  */
18
18
 
19
19
  import { describe, test, expect } from "bun:test";
20
+ import os from "node:os";
21
+ import path from "node:path";
20
22
 
21
23
  import { decide } from "../src/loop.js";
22
24
  import {
@@ -58,7 +60,7 @@ function emptyState(): SessionState {
58
60
 
59
61
  const FP = "fp:read:loop";
60
62
  const TOOL = "read";
61
- const ARGS = { path: "/tmp/example.txt" };
63
+ const ARGS = { path: path.join(os.tmpdir(), "example.txt") };
62
64
  const NOW = 1_700_000_500_000;
63
65
 
64
66
  // For the block tests we need a window size that is at least as large as
@@ -8,25 +8,29 @@
8
8
 
9
9
  import { describe, test, expect } from "bun:test";
10
10
  import { fingerprint } from "../src/fingerprint";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+
14
+ const TMP = path.join(os.tmpdir(), "canonical-key-order-test");
11
15
 
12
16
  describe("fingerprint — canonical key order", () => {
13
17
  test("flat object: same keys/values in different insertion order produce the same fingerprint", () => {
14
18
  const a = {
15
19
  tool: "read",
16
- args: { path: "/tmp/foo.ts", recursive: false, limit: 10 },
20
+ args: { path: path.join(os.tmpdir(), "foo.ts"), recursive: false, limit: 10 },
17
21
  };
18
22
  const b = {
19
23
  tool: "read",
20
- args: { limit: 10, recursive: false, path: "/tmp/foo.ts" },
24
+ args: { limit: 10, recursive: false, path: path.join(os.tmpdir(), "foo.ts") },
21
25
  };
22
26
  // Same keys, same values, different insertion order — must match.
23
- expect(fingerprint(a.tool, a.args, "/tmp")).toBe(fingerprint(b.tool, b.args, "/tmp"));
27
+ expect(fingerprint(a.tool, a.args, TMP)).toBe(fingerprint(b.tool, b.args, TMP));
24
28
  });
25
29
 
26
30
  test("nested objects: different insertion order at both levels also match", () => {
27
31
  const a = { tool: "edit", args: { meta: { z: 1, a: 2 }, path: "/x" } };
28
32
  const b = { tool: "edit", args: { path: "/x", meta: { a: 2, z: 1 } } };
29
- expect(fingerprint(a.tool, a.args, "/tmp")).toBe(fingerprint(b.tool, b.args, "/tmp"));
33
+ expect(fingerprint(a.tool, a.args, TMP)).toBe(fingerprint(b.tool, b.args, TMP));
30
34
  });
31
35
 
32
36
  test("deeply nested: three levels of differing key order all resolve to same fingerprint", () => {
@@ -54,18 +58,18 @@ describe("fingerprint — canonical key order", () => {
54
58
  },
55
59
  },
56
60
  };
57
- expect(fingerprint(a.tool, a.args, "/tmp")).toBe(fingerprint(b.tool, b.args, "/tmp"));
61
+ expect(fingerprint(a.tool, a.args, TMP)).toBe(fingerprint(b.tool, b.args, TMP));
58
62
  });
59
63
 
60
64
  test("array order is preserved (arrays of same values in same order match)", () => {
61
65
  const a = { tool: "bash", args: { commands: ["echo a", "echo b"] } };
62
66
  const b = { tool: "bash", args: { commands: ["echo a", "echo b"] } };
63
- expect(fingerprint(a.tool, a.args, "/tmp")).toBe(fingerprint(b.tool, b.args, "/tmp"));
67
+ expect(fingerprint(a.tool, a.args, TMP)).toBe(fingerprint(b.tool, b.args, TMP));
64
68
  });
65
69
 
66
70
  test("array with different order produces different fingerprint", () => {
67
71
  const a = { tool: "bash", args: { commands: ["echo a", "echo b"] } };
68
72
  const b = { tool: "bash", args: { commands: ["echo b", "echo a"] } };
69
- expect(fingerprint(a.tool, a.args, "/tmp")).not.toBe(fingerprint(b.tool, b.args, "/tmp"));
73
+ expect(fingerprint(a.tool, a.args, TMP)).not.toBe(fingerprint(b.tool, b.args, TMP));
70
74
  });
71
75
  });
@@ -125,7 +125,7 @@ class MockPlugin {
125
125
 
126
126
  // ── Test setup ───────────────────────────────────────────────────────────────
127
127
 
128
- const TEST_DIR = "/tmp/bizar-event-test";
128
+ const TEST_DIR = path.join(os.tmpdir(), "bizar-event-test");
129
129
  const TEST_SESSION = "session-evt-001";
130
130
  const TEST_SESSION_2 = "session-evt-002";
131
131