@polderlabs/bizar 4.4.13 → 4.5.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.
Files changed (90) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/main-CDFKHzBg.css +1 -0
  3. package/bizar-dash/dist/assets/main-NYFpS2wY.js +312 -0
  4. package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +1 -0
  5. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile--0FBIKX3.js} +2 -2
  6. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile--0FBIKX3.js.map} +1 -1
  7. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js +352 -0
  8. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +1 -0
  9. package/bizar-dash/dist/index.html +3 -3
  10. package/bizar-dash/dist/mobile.html +2 -2
  11. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  12. package/bizar-dash/skills/bizar/SKILL.md +96 -0
  13. package/bizar-dash/skills/chat/SKILL.md +74 -0
  14. package/bizar-dash/skills/lightrag/SKILL.md +75 -0
  15. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  16. package/bizar-dash/skills/obsidian/SKILL.md +55 -0
  17. package/bizar-dash/skills/providers/SKILL.md +75 -0
  18. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  19. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  20. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  21. package/bizar-dash/skills/usage/SKILL.md +62 -0
  22. package/bizar-dash/src/server/api.mjs +12 -0
  23. package/bizar-dash/src/server/memory-lightrag.mjs +5 -2
  24. package/bizar-dash/src/server/memory-store.mjs +38 -0
  25. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  26. package/bizar-dash/src/server/minimax.mjs +196 -5
  27. package/bizar-dash/src/server/providers-store.mjs +956 -0
  28. package/bizar-dash/src/server/routes/config.mjs +52 -1
  29. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  30. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  31. package/bizar-dash/src/server/routes/memory.mjs +241 -1
  32. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  33. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  34. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  35. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  36. package/bizar-dash/src/server/routes/update.mjs +340 -0
  37. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  38. package/bizar-dash/src/server/serve-info.mjs +135 -4
  39. package/bizar-dash/src/server/server.mjs +4 -0
  40. package/bizar-dash/src/server/skills-store.mjs +152 -262
  41. package/bizar-dash/src/web/App.tsx +118 -29
  42. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  43. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  44. package/bizar-dash/src/web/components/Topbar.tsx +0 -1
  45. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  46. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  47. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  48. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  49. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  50. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  51. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  52. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  53. package/bizar-dash/src/web/lib/api.ts +43 -0
  54. package/bizar-dash/src/web/main.tsx +1 -0
  55. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  56. package/bizar-dash/src/web/styles/chat.css +135 -1
  57. package/bizar-dash/src/web/styles/main.css +46 -0
  58. package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
  59. package/bizar-dash/src/web/styles/settings.css +418 -0
  60. package/bizar-dash/src/web/styles/skills.css +302 -0
  61. package/bizar-dash/src/web/styles/tasks.css +288 -0
  62. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  63. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  64. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
  65. package/bizar-dash/src/web/views/Settings.tsx +6 -0
  66. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  67. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  68. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  69. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  70. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  71. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  72. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  73. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  74. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  75. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  76. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  77. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  78. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  79. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  80. package/bizar-dash/tests/update-check.test.mjs +127 -0
  81. package/bizar-dash/tests/update-run.test.mjs +266 -0
  82. package/cli/bin.mjs +82 -1
  83. package/cli/provision.mjs +118 -4
  84. package/config/agents/_shared/SKILLS.md +109 -0
  85. package/package.json +1 -1
  86. package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
  87. package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
  88. package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
  89. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  90. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -0,0 +1,293 @@
1
+ /**
2
+ * minimax-usage-store.node.test.mjs
3
+ *
4
+ * Tests for the JSONL usage store.
5
+ *
6
+ * Run with: node --test bizar-dash/tests/minimax-usage-store.node.test.mjs
7
+ *
8
+ * Strategy: we avoid ESM dynamic-import cache issues by writing JSONL lines
9
+ * directly to the store file path (derived from BIZAR_STORE_HOME env var)
10
+ * and importing the store module only to call its pure/stateless helper
11
+ * functions (computeTotals, computePerModel, etc.). recordUsage and queryUsage
12
+ * are tested via direct file I/O so the ESM module loader never interferes.
13
+ */
14
+ import { describe, it, before, after, beforeEach } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import { mkdtempSync, writeFileSync, rmSync, unlinkSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { tmpdir } from 'node:os';
19
+
20
+ // Allow tests to run without polluting the real store.
21
+ // NOTE: STORE_FILE is computed lazily inside functions, NOT at module level,
22
+ // because process.env.BIZAR_STORE_HOME is set in the `before` hook which
23
+ // runs AFTER module-level code.
24
+ let SANDBOX_HOME;
25
+ let ORIGINAL_BIZAR_STORE_HOME;
26
+
27
+ /** The store file path, resolved after BIZAR_STORE_HOME is set by `before`. */
28
+ function storeFile() {
29
+ return join(process.env.BIZAR_STORE_HOME, 'usage.jsonl');
30
+ }
31
+
32
+ const RECORD_BASE = {
33
+ ts: Date.now(), // required for queryUsage range filters
34
+ providerId: 'minimax',
35
+ modelId: 'MiniMax-M3',
36
+ endpoint: 'chat',
37
+ requestId: 'msg_test1',
38
+ promptTokens: 100,
39
+ completionTokens: 200,
40
+ totalTokens: 300,
41
+ cachedTokens: 0,
42
+ reasoningTokens: 50,
43
+ latencyMs: 1234,
44
+ finishReason: 'stop',
45
+ error: null,
46
+ keyEnvVar: 'env:MINIMAX_API_KEY',
47
+ isBackup: false,
48
+ cached: false,
49
+ };
50
+
51
+ before(() => {
52
+ SANDBOX_HOME = mkdtempSync(join(tmpdir(), `bizar-usage-test-${Date.now()}-`));
53
+ ORIGINAL_BIZAR_STORE_HOME = process.env.BIZAR_STORE_HOME;
54
+ // Set BIZAR_STORE_HOME before the store module is ever imported so its
55
+ // top-level STORE_FILE computation picks up the sandbox path.
56
+ process.env.BIZAR_STORE_HOME = join(SANDBOX_HOME, '.local', 'share', 'bizar');
57
+ });
58
+
59
+ after(() => {
60
+ process.env.BIZAR_STORE_HOME = ORIGINAL_BIZAR_STORE_HOME ?? '';
61
+ try { rmSync(SANDBOX_HOME, { recursive: true, force: true }); } catch { /* ignore */ }
62
+ });
63
+
64
+ beforeEach(() => {
65
+ // Wipe the JSONL store before each test.
66
+ try { unlinkSync(storeFile()); } catch { /* ignore */ }
67
+ });
68
+
69
+ function makeRecord(overrides = {}) {
70
+ return { ...RECORD_BASE, ...overrides };
71
+ }
72
+
73
+ /**
74
+ * Write a JSONL line directly to the store file (bypassing the store module
75
+ * to avoid any ESM import ordering issues).
76
+ */
77
+ function appendJsonl(record) {
78
+ writeFileSync(storeFile(), JSON.stringify(record) + '\n', { flag: 'a', encoding: 'utf8' });
79
+ }
80
+
81
+ /**
82
+ * Load the store module. Uses a fresh import each time via unique cache-busting query param.
83
+ * The BIZAR_STORE_HOME env var is set in the `before` hook before any imports happen.
84
+ */
85
+ async function getStore() {
86
+ const stamp = `${Date.now()}-${Math.random()}`;
87
+ return import(`../src/server/minimax-usage-store.mjs?nocache=${stamp}`);
88
+ }
89
+
90
+ // ─── recordUsage + queryUsage ─────────────────────────────────────────
91
+
92
+ describe('recordUsage + queryUsage', () => {
93
+ it('records a single chat usage record', async () => {
94
+ const store = await getStore();
95
+ store.recordUsage(makeRecord());
96
+ const result = store.queryUsage({ range: '24h' });
97
+ assert.equal(result.totals.requests, 1);
98
+ assert.equal(result.totals.totalTokens, 300);
99
+ assert.equal(result.totals.promptTokens, 100);
100
+ assert.equal(result.totals.completionTokens, 200);
101
+ assert.equal(result.totals.reasoningTokens, 50);
102
+ assert.equal(result.totals.avgLatencyMs, 1234);
103
+ });
104
+
105
+ it('aggregates multiple records', async () => {
106
+ const store = await getStore();
107
+ store.recordUsage(makeRecord({ requestId: 'r1', totalTokens: 100 }));
108
+ store.recordUsage(makeRecord({ requestId: 'r2', totalTokens: 200 }));
109
+ store.recordUsage(makeRecord({ requestId: 'r3', totalTokens: 300 }));
110
+ const result = store.queryUsage({ range: '24h' });
111
+ assert.equal(result.totals.requests, 3);
112
+ assert.equal(result.totals.totalTokens, 600);
113
+ });
114
+
115
+ it('filters by range — 24h excludes old records', async () => {
116
+ const store = await getStore();
117
+ // Write a recent record via the store.
118
+ store.recordUsage(makeRecord({ requestId: 'recent', ts: Date.now() - 60_000 }));
119
+ // Manually write an 8-day-old record to the file.
120
+ appendJsonl(makeRecord({ requestId: 'old', ts: Date.now() - 8 * 86_400_000 }));
121
+ const result = store.queryUsage({ range: '24h' });
122
+ assert.equal(result.totals.requests, 1);
123
+ assert.equal(result.daily.length, 1);
124
+ });
125
+
126
+ it('supports range=7d', async () => {
127
+ const store = await getStore();
128
+ store.recordUsage(makeRecord({ requestId: 'r1', ts: Date.now() - 2 * 86_400_000 }));
129
+ store.recordUsage(makeRecord({ requestId: 'r2', ts: Date.now() - 6 * 86_400_000 }));
130
+ const result = store.queryUsage({ range: '7d' });
131
+ assert.equal(result.totals.requests, 2);
132
+ });
133
+
134
+ it('computes avgLatencyMs and p95LatencyMs', async () => {
135
+ const store = await getStore();
136
+ for (let i = 0; i < 20; i++) {
137
+ store.recordUsage(makeRecord({ requestId: `r${i}`, latencyMs: (i + 1) * 100 }));
138
+ }
139
+ const result = store.queryUsage({ range: '24h' });
140
+ assert.equal(result.totals.avgLatencyMs, 1050);
141
+ assert.equal(result.totals.p95LatencyMs, 2000);
142
+ });
143
+
144
+ it('records errors correctly', async () => {
145
+ const store = await getStore();
146
+ store.recordUsage(makeRecord({ requestId: 'e1', error: { code: 'http_429', message: 'rate limited' } }));
147
+ store.recordUsage(makeRecord({ requestId: 'e2', error: { code: 'http_500', message: 'server error' } }));
148
+ const result = store.queryUsage({ range: '24h' });
149
+ assert.equal(result.totals.errors, 2);
150
+ assert.equal(result.totals.requests, 2);
151
+ assert.equal(result.errors.length, 2);
152
+ });
153
+
154
+ it('records cached=true correctly', async () => {
155
+ const store = await getStore();
156
+ // Both records are cached so cost is 0 (uncached record would cost > 0).
157
+ store.recordUsage(makeRecord({ requestId: 'c1', cached: true }));
158
+ store.recordUsage(makeRecord({ requestId: 'c2', cached: true }));
159
+ const result = store.queryUsage({ range: '24h' });
160
+ assert.equal(result.totals.requests, 2);
161
+ // Cost should be 0 for cached records.
162
+ assert.equal(result.totals.costEstimate, 0);
163
+ });
164
+
165
+ it('computes per-model buckets', async () => {
166
+ const store = await getStore();
167
+ store.recordUsage(makeRecord({ modelId: 'MiniMax-M3', totalTokens: 100 }));
168
+ store.recordUsage(makeRecord({ modelId: 'MiniMax-M3', totalTokens: 200 }));
169
+ store.recordUsage(makeRecord({ modelId: 'MiniMax-M2.7', totalTokens: 50 }));
170
+ const result = store.queryUsage({ range: '24h' });
171
+ assert.equal(result.perModel.length, 2);
172
+ const m3 = result.perModel.find(m => m.modelId === 'MiniMax-M3');
173
+ assert.ok(m3);
174
+ assert.equal(m3.totalTokens, 300);
175
+ assert.equal(m3.requests, 2);
176
+ });
177
+
178
+ it('computes per-key buckets', async () => {
179
+ const store = await getStore();
180
+ store.recordUsage(makeRecord({ keyEnvVar: 'MINIMAX_API_KEY', isBackup: false, totalTokens: 100 }));
181
+ store.recordUsage(makeRecord({ keyEnvVar: 'MINIMAX_API_KEY_BACKUP', isBackup: true, totalTokens: 50 }));
182
+ const result = store.queryUsage({ range: '24h' });
183
+ assert.equal(result.perKey.length, 2);
184
+ const primary = result.perKey.find(k => k.keyEnvVar === 'MINIMAX_API_KEY' && !k.isBackup);
185
+ assert.ok(primary);
186
+ assert.equal(primary.requests, 1);
187
+ });
188
+ });
189
+
190
+ // ─── getUsageSummary ──────────────────────────────────────────────────
191
+
192
+ describe('getUsageSummary', () => {
193
+ it('returns zeros when no records', async () => {
194
+ const store = await getStore();
195
+ const s = store.getUsageSummary('minimax');
196
+ assert.equal(s.requests, 0);
197
+ assert.equal(s.tokens, 0);
198
+ });
199
+
200
+ it('returns last-5-min rolling totals', async () => {
201
+ const store = await getStore();
202
+ // 10 minutes ago — should NOT be in 5-min window.
203
+ store.recordUsage(makeRecord({ requestId: 'old', ts: Date.now() - 10 * 60_000, totalTokens: 9999 }));
204
+ // 2 minutes ago — should be in 5-min window.
205
+ store.recordUsage(makeRecord({ requestId: 'recent', ts: Date.now() - 2 * 60_000, totalTokens: 500 }));
206
+ const s = store.getUsageSummary('minimax');
207
+ assert.equal(s.requests, 1);
208
+ assert.equal(s.tokens, 500);
209
+ });
210
+ });
211
+
212
+ // ─── getUsageLimitsForAgent ───────────────────────────────────────────
213
+
214
+ describe('getUsageLimitsForAgent', () => {
215
+ it('returns a valid summary', async () => {
216
+ const store = await getStore();
217
+ store.recordUsage(makeRecord({ totalTokens: 10_000 }));
218
+ const s = await store.getUsageLimitsForAgent('minimax');
219
+ assert.equal(s.provider, 'minimax');
220
+ assert.equal(s.tokensLast24h, 10_000);
221
+ assert.equal(s.requestsLast24h, 1);
222
+ assert.ok(s.limits);
223
+ assert.equal(typeof s.percentUsed24h, 'number');
224
+ assert.ok(s.estimatedTimeUntilReset === null || typeof s.estimatedTimeUntilReset === 'string');
225
+ assert.ok(s.warning === null || typeof s.warning === 'string');
226
+ });
227
+
228
+ it('warns when 24h usage exceeds 80pct of heuristic limit', async () => {
229
+ const store = await getStore();
230
+ // Exceed 80% of 1_000_000 tokens.
231
+ store.recordUsage(makeRecord({ totalTokens: 850_000 }));
232
+ const s = await store.getUsageLimitsForAgent('minimax');
233
+ assert.equal(s.warning, 'approaching_daily_limit');
234
+ });
235
+ });
236
+
237
+ // ─── pruneUsage ──────────────────────────────────────────────────────
238
+
239
+ describe('pruneUsage', () => {
240
+ it('removes records older than the cutoff', async () => {
241
+ const store = await getStore();
242
+ store.recordUsage(makeRecord({ requestId: 'old', ts: Date.now() - 48 * 86_400_000 }));
243
+ store.recordUsage(makeRecord({ requestId: 'recent', ts: Date.now() - 1 * 86_400_000 }));
244
+ const removed = store.pruneUsage({ olderThanMs: 24 * 86_400_000 });
245
+ assert.equal(removed, 1);
246
+ const result = store.queryUsage({ range: '30d' });
247
+ assert.equal(result.totals.requests, 1);
248
+ });
249
+
250
+ it('returns 0 when nothing to prune', async () => {
251
+ const store = await getStore();
252
+ store.recordUsage(makeRecord());
253
+ const removed = store.pruneUsage({ olderThanMs: 24 * 86_400_000 });
254
+ assert.equal(removed, 0);
255
+ });
256
+ });
257
+
258
+ // ─── __resetStoreForTests ────────────────────────────────────────────
259
+
260
+ describe('__resetStoreForTests', () => {
261
+ it('wipes all records', async () => {
262
+ const store = await getStore();
263
+ store.recordUsage(makeRecord());
264
+ store.recordUsage(makeRecord({ requestId: 'another' }));
265
+ assert.equal(store.queryUsage({ range: '24h' }).totals.requests, 2);
266
+ store.__resetStoreForTests();
267
+ assert.equal(store.queryUsage({ range: '24h' }).totals.requests, 0);
268
+ });
269
+ });
270
+
271
+ // ─── cost estimation ─────────────────────────────────────────────────
272
+
273
+ describe('costEstimate', () => {
274
+ it('estimates cost for MiniMax-M3', async () => {
275
+ const store = await getStore();
276
+ store.recordUsage(makeRecord({
277
+ modelId: 'MiniMax-M3',
278
+ promptTokens: 1_000_000,
279
+ completionTokens: 1_000_000,
280
+ totalTokens: 2_000_000,
281
+ }));
282
+ const result = store.queryUsage({ range: '24h' });
283
+ // 1M prompt @ $1/M = $1, 1M completion @ $3/M = $3 → $4 total
284
+ assert.equal(result.totals.costEstimate, 4);
285
+ });
286
+
287
+ it('returns 0 for unknown models', async () => {
288
+ const store = await getStore();
289
+ store.recordUsage(makeRecord({ modelId: 'unknown-model', totalTokens: 1_000_000 }));
290
+ const result = store.queryUsage({ range: '24h' });
291
+ assert.equal(result.totals.costEstimate, 0);
292
+ });
293
+ });
@@ -18,7 +18,8 @@ import {
18
18
  writeFileSync,
19
19
  rmSync,
20
20
  existsSync,
21
- renameSync,
21
+ copyFileSync,
22
+ unlinkSync,
22
23
  readFileSync,
23
24
  mkdirSync,
24
25
  } from 'node:fs';
@@ -91,23 +92,21 @@ async function startDashboard() {
91
92
  }
92
93
 
93
94
  let dashboardServer, dashboardBaseUrl, tmpDir;
94
- const SERVE_JSON_PATH = join(homedir(), '.cache', 'bizar', 'serve.json');
95
+ let SERVE_JSON_PATH;
95
96
  let originalServeJson;
96
97
 
97
98
  before(async () => {
99
+ SERVE_JSON_PATH = `${tmpdir()}/bizar-test-serve-detail-${process.pid}-${Date.now()}.json`;
100
+ process.env.BIZAR_SERVE_JSON_PATH = SERVE_JSON_PATH;
98
101
  await startUpstream();
99
102
  await startDashboard();
100
103
  tmpDir = mkdtempSync(join(tmpdir(), 'opencode-sessions-detail-'));
101
- // Snapshot any existing serve.json so we can restore it.
102
104
  if (existsSync(SERVE_JSON_PATH)) {
103
105
  originalServeJson = readFileSync(SERVE_JSON_PATH, 'utf8');
104
106
  }
105
- mkdirSync(join(homedir(), '.cache', 'bizar'), { recursive: true });
106
107
  });
107
108
 
108
109
  after(async () => {
109
- // Restore the real serve.json (or remove ours) before exit so we
110
- // never leave a fake file behind in the user's home.
111
110
  if (originalServeJson !== undefined) {
112
111
  writeFileSync(SERVE_JSON_PATH, originalServeJson, 'utf8');
113
112
  } else if (existsSync(SERVE_JSON_PATH)) {
@@ -116,6 +115,7 @@ after(async () => {
116
115
  if (dashboardServer) await new Promise((r) => dashboardServer.close(r));
117
116
  if (upstreamServer) await new Promise((r) => upstreamServer.close(r));
118
117
  if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
118
+ delete process.env.BIZAR_SERVE_JSON_PATH;
119
119
  });
120
120
 
121
121
  beforeEach(() => {
@@ -128,11 +128,14 @@ function writeServeJson(worktree) {
128
128
  writeFileSync(SERVE_JSON_PATH, JSON.stringify({ port: upstreamPort, password: 'test-pw', worktree, pid: 99999, startedAt: Date.now() }), 'utf8');
129
129
  }
130
130
 
131
- /** Move serve.json aside so readServeInfo() returns null. */
131
+ /** Move serve.json aside so readServeInfo() returns null.
132
+ * Uses copy + unlink because rename across filesystems (e.g.
133
+ * /home/* → /tmp/*) throws EXDEV on Linux. */
132
134
  function moveServeJsonAside() {
133
135
  if (!existsSync(SERVE_JSON_PATH)) return;
134
- const tmp = join(tmpDir, `serve-aside-${Date.now()}.json`);
135
- renameSync(SERVE_JSON_PATH, tmp);
136
+ const tmp = join(tmpDir, `serve-aside-${Date.now()}-${process.pid}.json`);
137
+ copyFileSync(SERVE_JSON_PATH, tmp);
138
+ unlinkSync(SERVE_JSON_PATH);
136
139
  }
137
140
 
138
141
  function parseSseStream(text) {