@polderlabs/bizar-dash 3.0.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 (59) hide show
  1. package/dist/assets/index-B5X9g8B4.css +1 -0
  2. package/dist/assets/index-LqQuSp9d.js +388 -0
  3. package/dist/assets/index-LqQuSp9d.js.map +1 -0
  4. package/dist/index.html +18 -0
  5. package/package.json +67 -0
  6. package/src/cli.mjs +228 -0
  7. package/src/server/agents-store.mjs +190 -0
  8. package/src/server/api.mjs +913 -0
  9. package/src/server/browser.mjs +40 -0
  10. package/src/server/diagnostics-store.mjs +138 -0
  11. package/src/server/mods-loader.mjs +361 -0
  12. package/src/server/projects-store.mjs +198 -0
  13. package/src/server/providers-store.mjs +183 -0
  14. package/src/server/schedules-runner.mjs +150 -0
  15. package/src/server/schedules-store.mjs +233 -0
  16. package/src/server/search-store.mjs +120 -0
  17. package/src/server/server.mjs +388 -0
  18. package/src/server/state.mjs +357 -0
  19. package/src/server/tailscale-store.mjs +113 -0
  20. package/src/server/tasks-store.mjs +275 -0
  21. package/src/server/tui.mjs +844 -0
  22. package/src/server/watcher.mjs +81 -0
  23. package/src/web/App.tsx +316 -0
  24. package/src/web/components/Button.tsx +55 -0
  25. package/src/web/components/Card.tsx +40 -0
  26. package/src/web/components/EmptyState.tsx +30 -0
  27. package/src/web/components/Modal.tsx +137 -0
  28. package/src/web/components/SearchModal.tsx +185 -0
  29. package/src/web/components/Spinner.tsx +19 -0
  30. package/src/web/components/StatusBadge.tsx +25 -0
  31. package/src/web/components/Tag.tsx +28 -0
  32. package/src/web/components/Toast.tsx +142 -0
  33. package/src/web/components/Topbar.tsx +203 -0
  34. package/src/web/index.html +17 -0
  35. package/src/web/lib/api.ts +71 -0
  36. package/src/web/lib/markdown.tsx +59 -0
  37. package/src/web/lib/types.ts +388 -0
  38. package/src/web/lib/utils.ts +79 -0
  39. package/src/web/lib/ws.ts +132 -0
  40. package/src/web/main.tsx +12 -0
  41. package/src/web/styles/main.css +3148 -0
  42. package/src/web/views/Agents.tsx +406 -0
  43. package/src/web/views/Chat.tsx +527 -0
  44. package/src/web/views/Config.tsx +683 -0
  45. package/src/web/views/Mods.tsx +350 -0
  46. package/src/web/views/Overview.tsx +350 -0
  47. package/src/web/views/Plans.tsx +667 -0
  48. package/src/web/views/Schedules.tsx +299 -0
  49. package/src/web/views/Settings.tsx +571 -0
  50. package/src/web/views/Tasks.tsx +761 -0
  51. package/templates/mod/FORMAT.md +76 -0
  52. package/templates/mod/hello-mod/README.md +19 -0
  53. package/templates/mod/hello-mod/agents/greeter.md +8 -0
  54. package/templates/mod/hello-mod/commands/hello.md +6 -0
  55. package/templates/mod/hello-mod/mod.json +20 -0
  56. package/templates/mod/hello-mod/routes/ping.mjs +9 -0
  57. package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
  58. package/tsconfig.json +23 -0
  59. package/vite.config.ts +24 -0
@@ -0,0 +1,913 @@
1
+ /**
2
+ * src/server/api.mjs
3
+ *
4
+ * v3.0.0 — Comprehensive REST surface for the Bizar dashboard.
5
+ *
6
+ * All routes return JSON. Errors are JSON { error, message } with status.
7
+ * Handlers are wrapped in a `wrap` helper so a thrown error never crashes
8
+ * Express.
9
+ */
10
+ import express from 'express';
11
+ import {
12
+ existsSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ writeFileSync,
16
+ readdirSync,
17
+ statSync,
18
+ } from 'node:fs';
19
+ import { join, dirname, basename } from 'node:path';
20
+ import { homedir } from 'node:os';
21
+ import { projectsStore } from './projects-store.mjs';
22
+ import { tasksStore } from './tasks-store.mjs';
23
+ import { agentsStore } from './agents-store.mjs';
24
+ import { providersStore, mcpsStore } from './providers-store.mjs';
25
+ import { modsLoader } from './mods-loader.mjs';
26
+ import { schedulesStore } from './schedules-store.mjs';
27
+ import { schedulesRunner } from './schedules-runner.mjs';
28
+ import { searchStore } from './search-store.mjs';
29
+ import { diagnosticsStore } from './diagnostics-store.mjs';
30
+ import { tailscaleStore } from './tailscale-store.mjs';
31
+
32
+ const HOME = homedir();
33
+ const OPENCODE_DIR = join(HOME, '.config', 'opencode');
34
+ const OPENCODE_JSON = join(OPENCODE_DIR, 'opencode.json');
35
+ const BIZAR_HOME = join(HOME, '.config', 'bizar');
36
+ const SETTINGS_FILE = join(BIZAR_HOME, 'settings.json');
37
+
38
+ function safeReadJSON(file, fallback = null) {
39
+ try {
40
+ if (!existsSync(file)) return fallback;
41
+ const text = readFileSync(file, 'utf8');
42
+ if (!text.trim()) return fallback;
43
+ return JSON.parse(text);
44
+ } catch {
45
+ return fallback;
46
+ }
47
+ }
48
+
49
+ function safeReadText(file, fallback = '') {
50
+ try {
51
+ if (!existsSync(file)) return fallback;
52
+ return readFileSync(file, 'utf8');
53
+ } catch {
54
+ return fallback;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * The v3 settings shape.
60
+ */
61
+ const DEFAULT_SETTINGS = {
62
+ theme: {
63
+ mode: 'dark',
64
+ accent: '#8b5cf6',
65
+ success: '#3fb950',
66
+ warning: '#d29922',
67
+ error: '#f85149',
68
+ info: '#58a6ff',
69
+ fontFamily: 'Inter',
70
+ fontSize: 14,
71
+ compactMode: false,
72
+ animations: true,
73
+ },
74
+ ui: {
75
+ layout: 'topnav',
76
+ showHeader: true,
77
+ showStatusBar: true,
78
+ defaultTab: 'overview',
79
+ },
80
+ defaultAgent: 'odin',
81
+ defaultModel: '',
82
+ notifications: { onAgentComplete: true, onPlanApproval: true },
83
+ dashboard: { autoLaunchWeb: true },
84
+ service: { enabled: true, autostart: false },
85
+ about: {
86
+ version: '3.0.0',
87
+ homepage: 'https://github.com/DrB0rk/BizarHarness',
88
+ license: 'MIT',
89
+ },
90
+ };
91
+
92
+ function mergeSettings(existing) {
93
+ if (!existing || typeof existing !== 'object') return DEFAULT_SETTINGS;
94
+ const merged = { ...DEFAULT_SETTINGS, ...existing };
95
+ merged.theme = { ...DEFAULT_SETTINGS.theme, ...(existing.theme || {}) };
96
+ merged.ui = { ...DEFAULT_SETTINGS.ui, ...(existing.ui || {}) };
97
+ merged.notifications = {
98
+ ...DEFAULT_SETTINGS.notifications,
99
+ ...(existing.notifications || {}),
100
+ };
101
+ merged.dashboard = { ...DEFAULT_SETTINGS.dashboard, ...(existing.dashboard || {}) };
102
+ merged.service = { ...DEFAULT_SETTINGS.service, ...(existing.service || {}) };
103
+ merged.about = { ...DEFAULT_SETTINGS.about, ...(existing.about || {}) };
104
+ return merged;
105
+ }
106
+
107
+ function readSettings() {
108
+ const raw = safeReadJSON(SETTINGS_FILE, null);
109
+ return {
110
+ path: SETTINGS_FILE,
111
+ data: mergeSettings(raw),
112
+ exists: existsSync(SETTINGS_FILE),
113
+ };
114
+ }
115
+
116
+ function writeSettings(data) {
117
+ mkdirSync(dirname(SETTINGS_FILE), { recursive: true });
118
+ const merged = mergeSettings(data);
119
+ writeFileSync(SETTINGS_FILE, JSON.stringify(merged, null, 2) + '\n', 'utf8');
120
+ return readSettings();
121
+ }
122
+
123
+ function parseFrontmatter(raw) {
124
+ if (!raw.startsWith('---')) return { frontmatter: {}, body: raw };
125
+ const end = raw.indexOf('\n---', 3);
126
+ if (end === -1) return { frontmatter: {}, body: raw };
127
+ const fmBlock = raw.slice(3, end).trim();
128
+ const body = raw.slice(end + 4).replace(/^\s+/, '');
129
+ const frontmatter = {};
130
+ for (const line of fmBlock.split(/\r?\n/)) {
131
+ const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
132
+ if (!m) continue;
133
+ const key = m[1];
134
+ let val = m[2].trim();
135
+ if (
136
+ (val.startsWith('"') && val.endsWith('"')) ||
137
+ (val.startsWith("'") && val.endsWith("'"))
138
+ ) {
139
+ val = val.slice(1, -1);
140
+ }
141
+ frontmatter[key] = val;
142
+ }
143
+ return { frontmatter, body };
144
+ }
145
+
146
+ function readActiveProjectId() {
147
+ return projectsStore.active()?.id || null;
148
+ }
149
+
150
+ export function createApiRouter({
151
+ state,
152
+ watcher,
153
+ projectRoot,
154
+ opencodeConfigDir,
155
+ bizarRoot,
156
+ broadcast = () => {},
157
+ }) {
158
+ const router = express.Router();
159
+
160
+ const wrap = (fn) => async (req, res, ...rest) => {
161
+ try {
162
+ await fn(req, res, ...rest);
163
+ } catch (err) {
164
+ const status = err?.status || 500;
165
+ res.status(status).json({
166
+ error: err?.code || 'internal_error',
167
+ message: err?.message || String(err),
168
+ });
169
+ }
170
+ };
171
+
172
+ // ── /api/snapshot ──────────────────────────────────────────────────────
173
+ router.get('/snapshot', wrap(async (_req, res) => {
174
+ res.json(buildSnapshot());
175
+ }));
176
+
177
+ router.get('/overview', wrap(async (_req, res) => {
178
+ res.json(state.getOverview());
179
+ }));
180
+
181
+ // ── /api/projects ──────────────────────────────────────────────────────
182
+ router.get('/projects', wrap(async (_req, res) => {
183
+ res.json(projectsStore.list());
184
+ }));
185
+
186
+ router.post('/projects', wrap(async (req, res) => {
187
+ const path = (req.body?.path || '').trim();
188
+ const name = req.body?.name || null;
189
+ if (!path) {
190
+ res.status(400).json({ error: 'bad_request', message: 'path is required' });
191
+ return;
192
+ }
193
+ const project = projectsStore.add(path, name);
194
+ state.appendActivity({ kind: 'project.add', id: project.id, path: project.path });
195
+ res.status(201).json(project);
196
+ }));
197
+
198
+ router.post('/projects/:id/activate', wrap(async (req, res) => {
199
+ const activated = projectsStore.activate(req.params.id);
200
+ if (!activated) {
201
+ res.status(404).json({ error: 'not_found' });
202
+ return;
203
+ }
204
+ state.appendActivity({ kind: 'project.activate', id: activated.id });
205
+ broadcast({ type: 'project:change', project: activated });
206
+ res.json(activated);
207
+ }));
208
+
209
+ router.delete('/projects/:id', wrap(async (req, res) => {
210
+ projectsStore.remove(req.params.id);
211
+ broadcast({ type: 'project:change' });
212
+ res.status(204).end();
213
+ }));
214
+
215
+ router.post('/projects/refresh', wrap(async (_req, res) => {
216
+ res.json(projectsStore.list());
217
+ }));
218
+
219
+ // ── /api/projects/active/<entity> ──────────────────────────────────────
220
+ router.get('/projects/active/tasks', wrap(async (_req, res) => {
221
+ const active = projectsStore.active();
222
+ if (!active) {
223
+ res.json({ tasks: [], projectId: null });
224
+ return;
225
+ }
226
+ res.json({ tasks: tasksStore.loadTasks(active.id), projectId: active.id });
227
+ }));
228
+
229
+ router.get('/projects/active/schedules', wrap(async (_req, res) => {
230
+ const active = projectsStore.active();
231
+ if (!active) {
232
+ res.json({ schedules: [], projectId: null });
233
+ return;
234
+ }
235
+ res.json({ schedules: schedulesStore.list(active.id), projectId: active.id });
236
+ }));
237
+
238
+ router.get('/projects/active/mods', wrap(async (_req, res) => {
239
+ const active = projectsStore.active();
240
+ if (!active) {
241
+ res.json({ mods: [], projectId: null });
242
+ return;
243
+ }
244
+ res.json({ mods: [], projectId: active.id });
245
+ }));
246
+
247
+ router.get('/projects/active/state', wrap(async (_req, res) => {
248
+ const active = projectsStore.active();
249
+ if (!active) {
250
+ res.json({ state: {}, projectId: null });
251
+ return;
252
+ }
253
+ const stateFile = join(projectsStore.projectDir(active.id), 'state.json');
254
+ res.json({ state: safeReadJSON(stateFile, {}) || {}, projectId: active.id });
255
+ }));
256
+
257
+ // ── /api/tasks ─────────────────────────────────────────────────────────
258
+ router.get('/tasks', wrap(async (_req, res) => {
259
+ const projectId = req.query.projectId || readActiveProjectId();
260
+ res.json(tasksStore.loadTasks(projectId));
261
+ }));
262
+
263
+ router.post('/tasks', wrap(async (req, res) => {
264
+ const projectId = req.body?.projectId || readActiveProjectId();
265
+ const task = await tasksStore.create(projectId, req.body || {});
266
+ broadcast({ type: 'tasks:change', task });
267
+ res.status(201).json(task);
268
+ }));
269
+
270
+ router.put('/tasks/:id', wrap(async (req, res) => {
271
+ const projectId = req.body?.projectId || readActiveProjectId();
272
+ const task = await tasksStore.update(projectId, req.params.id, req.body || {});
273
+ if (!task) {
274
+ res.status(404).json({ error: 'not_found' });
275
+ return;
276
+ }
277
+ broadcast({ type: 'tasks:change', task });
278
+ res.json(task);
279
+ }));
280
+
281
+ router.patch('/tasks/:id/status', wrap(async (req, res) => {
282
+ const projectId = req.body?.projectId || readActiveProjectId();
283
+ const { status } = req.body || {};
284
+ if (!['queued', 'doing', 'done'].includes(status)) {
285
+ res.status(400).json({ error: 'bad_request', message: 'invalid status' });
286
+ return;
287
+ }
288
+ const task = await tasksStore.move(projectId, req.params.id, status);
289
+ if (!task) {
290
+ res.status(404).json({ error: 'not_found' });
291
+ return;
292
+ }
293
+ broadcast({ type: 'tasks:change', task });
294
+ res.json(task);
295
+ }));
296
+
297
+ router.post('/tasks/:id/comments', wrap(async (req, res) => {
298
+ const projectId = req.body?.projectId || readActiveProjectId();
299
+ const task = await tasksStore.addComment(projectId, req.params.id, req.body?.text || '');
300
+ if (!task) {
301
+ res.status(404).json({ error: 'not_found' });
302
+ return;
303
+ }
304
+ broadcast({ type: 'tasks:change', task });
305
+ res.json(task);
306
+ }));
307
+
308
+ router.post('/tasks/:id/timer', wrap(async (req, res) => {
309
+ const projectId = req.body?.projectId || readActiveProjectId();
310
+ const task = await tasksStore.toggleTimer(projectId, req.params.id);
311
+ if (!task) {
312
+ res.status(404).json({ error: 'not_found' });
313
+ return;
314
+ }
315
+ broadcast({ type: 'tasks:change', task });
316
+ res.json(task);
317
+ }));
318
+
319
+ router.delete('/tasks/:id', wrap(async (req, res) => {
320
+ const projectId = req.query.projectId || readActiveProjectId();
321
+ const ok = await tasksStore.delete(projectId, req.params.id);
322
+ if (!ok) {
323
+ res.status(404).json({ error: 'not_found' });
324
+ return;
325
+ }
326
+ broadcast({ type: 'tasks:delete', id: req.params.id });
327
+ res.status(204).end();
328
+ }));
329
+
330
+ // ── /api/schedules ─────────────────────────────────────────────────────
331
+ router.get('/schedules', wrap(async (_req, res) => {
332
+ const projectId = req.query.projectId || readActiveProjectId();
333
+ res.json(schedulesStore.list(projectId || 'default'));
334
+ }));
335
+
336
+ router.post('/schedules', wrap(async (req, res) => {
337
+ const projectId = req.body?.projectId || readActiveProjectId() || 'default';
338
+ const sched = schedulesStore.add(projectId, req.body || {});
339
+ broadcast({ type: 'schedules:change' });
340
+ res.status(201).json(sched);
341
+ }));
342
+
343
+ router.put('/schedules/:id', wrap(async (req, res) => {
344
+ const projectId = req.body?.projectId || readActiveProjectId() || 'default';
345
+ const sched = schedulesStore.update(projectId, req.params.id, req.body || {});
346
+ if (!sched) {
347
+ res.status(404).json({ error: 'not_found' });
348
+ return;
349
+ }
350
+ broadcast({ type: 'schedules:change' });
351
+ res.json(sched);
352
+ }));
353
+
354
+ router.post('/schedules/:id/run', wrap(async (req, res) => {
355
+ const projectId = req.body?.projectId || readActiveProjectId() || 'default';
356
+ const sched = schedulesStore.get(projectId, req.params.id);
357
+ if (!sched) {
358
+ res.status(404).json({ error: 'not_found' });
359
+ return;
360
+ }
361
+ const result = await schedulesRunner.runOne(projectId, sched);
362
+ broadcast({ type: 'schedules:change' });
363
+ res.json(result);
364
+ }));
365
+
366
+ router.delete('/schedules/:id', wrap(async (req, res) => {
367
+ const projectId = req.query.projectId || readActiveProjectId() || 'default';
368
+ const ok = schedulesStore.remove(projectId, req.params.id);
369
+ if (!ok) {
370
+ res.status(404).json({ error: 'not_found' });
371
+ return;
372
+ }
373
+ broadcast({ type: 'schedules:change' });
374
+ res.status(204).end();
375
+ }));
376
+
377
+ // ── /api/mods ──────────────────────────────────────────────────────────
378
+ router.get('/mods', wrap(async (_req, res) => {
379
+ res.json({ mods: modsLoader.list() });
380
+ }));
381
+
382
+ router.get('/mods/:id', wrap(async (req, res) => {
383
+ const mod = modsLoader.get(req.params.id);
384
+ if (!mod) {
385
+ res.status(404).json({ error: 'not_found' });
386
+ return;
387
+ }
388
+ res.json(mod);
389
+ }));
390
+
391
+ router.post('/mods', wrap(async (req, res) => {
392
+ const path = req.body?.path;
393
+ if (!path) {
394
+ res.status(400).json({ error: 'bad_request', message: 'path is required' });
395
+ return;
396
+ }
397
+ const mod = modsLoader.installFromPath(path);
398
+ res.status(201).json(mod);
399
+ }));
400
+
401
+ router.put('/mods/:id', wrap(async (req, res) => {
402
+ const mod = modsLoader.setEnabled(req.params.id, !!req.body?.enabled);
403
+ if (!mod) {
404
+ res.status(404).json({ error: 'not_found' });
405
+ return;
406
+ }
407
+ res.json(mod);
408
+ }));
409
+
410
+ router.delete('/mods/:id', wrap(async (req, res) => {
411
+ const ok = modsLoader.uninstall(req.params.id);
412
+ if (!ok) {
413
+ res.status(404).json({ error: 'not_found' });
414
+ return;
415
+ }
416
+ res.status(204).end();
417
+ }));
418
+
419
+ router.get('/mods/:id/files', wrap(async (req, res) => {
420
+ res.json({ files: modsLoader.listFiles(req.params.id) });
421
+ }));
422
+
423
+ // NOTE: we use /mods/:id/mod-file/* (named route) to avoid conflicting
424
+ // with mod route mounting at /api/mods/:id/*. The wildcard in /* was
425
+ // too greedy and captured paths like /mods/test-mod/hello.
426
+ router.get('/mods/:id/mod-file/*', wrap(async (req, res) => {
427
+ const rel = req.params[0] || '';
428
+ const content = modsLoader.readFile(req.params.id, rel);
429
+ if (content === null) {
430
+ res.status(404).json({ error: 'not_found' });
431
+ return;
432
+ }
433
+ res.type('text/plain').send(content);
434
+ }));
435
+
436
+ router.put('/mods/:id/mod-file/*', wrap(async (req, res) => {
437
+ const rel = req.params[0] || '';
438
+ const body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body, null, 2);
439
+ modsLoader.writeFile(req.params.id, rel, body);
440
+ res.json({ ok: true });
441
+ }));
442
+
443
+ // ── /api/mods/views ──────────────────────────────────────────────────
444
+ router.get('/mods/views', wrap(async (_req, res) => {
445
+ const views = modsLoader.listModViews();
446
+ res.json({ views });
447
+ }));
448
+
449
+ // ── /api/mods/:id/mod-web/* ──────────────────────────────────────────
450
+ // Serve files from each mod's web/ directory (for iframe embedding)
451
+ // Named 'mod-web' to avoid conflict with mod route mounting at /:id/*
452
+ router.get('/mods/:id/mod-web/*', wrap(async (req, res) => {
453
+ const mod = modsLoader.get(req.params.id);
454
+ if (!mod || !mod.enabled) {
455
+ res.status(404).json({ error: 'not_found' });
456
+ return;
457
+ }
458
+ const rel = req.params[0] || '';
459
+ const filePath = join(mod.path, 'web', rel);
460
+ if (!filePath.startsWith(mod.path)) {
461
+ res.status(403).json({ error: 'forbidden' });
462
+ return;
463
+ }
464
+ if (!existsSync(filePath)) {
465
+ res.status(404).json({ error: 'not_found' });
466
+ return;
467
+ }
468
+ res.sendFile(filePath);
469
+ }));
470
+
471
+
472
+
473
+
474
+ // ── /api/agents ────────────────────────────────────────────────────────
475
+ router.get('/agents', wrap(async (_req, res) => {
476
+ res.json({ agents: agentsStore.list() });
477
+ }));
478
+
479
+ router.get('/agents/:name', wrap(async (req, res) => {
480
+ const agent = agentsStore.get(req.params.name);
481
+ if (!agent) {
482
+ res.status(404).json({ error: 'not_found' });
483
+ return;
484
+ }
485
+ res.json(agent);
486
+ }));
487
+
488
+ router.post('/agents', wrap(async (req, res) => {
489
+ const agent = agentsStore.create(req.body || {});
490
+ broadcast({ type: 'agents:change' });
491
+ res.status(201).json(agent);
492
+ }));
493
+
494
+ router.put('/agents/:name', wrap(async (req, res) => {
495
+ const agent = agentsStore.update(req.params.name, req.body || {});
496
+ broadcast({ type: 'agents:change' });
497
+ res.json(agent);
498
+ }));
499
+
500
+ router.post('/agents/:name/invoke', wrap(async (req, res) => {
501
+ const name = req.params.name;
502
+ const prompt = (req.body && req.body.prompt) || '';
503
+ if (!prompt.trim()) {
504
+ res.status(400).json({ error: 'bad_request', message: 'prompt is required' });
505
+ return;
506
+ }
507
+ state.appendActivity({ kind: 'agent.invoke', agent: name, prompt: String(prompt).slice(0, 500) });
508
+ res.status(202).json({ accepted: true, agent: name });
509
+ }));
510
+
511
+ router.delete('/agents/:name', wrap(async (req, res) => {
512
+ const ok = agentsStore.delete(req.params.name);
513
+ if (!ok) {
514
+ res.status(404).json({ error: 'not_found' });
515
+ return;
516
+ }
517
+ broadcast({ type: 'agents:change' });
518
+ res.status(204).end();
519
+ }));
520
+
521
+ // ── /api/config ────────────────────────────────────────────────────────
522
+ router.get('/config', wrap(async (_req, res) => {
523
+ const data = safeReadJSON(OPENCODE_JSON, null);
524
+ res.json({
525
+ path: OPENCODE_JSON,
526
+ data,
527
+ raw: data === null ? '' : JSON.stringify(data, null, 2),
528
+ exists: existsSync(OPENCODE_JSON),
529
+ });
530
+ }));
531
+
532
+ router.put('/config', wrap(async (req, res) => {
533
+ const body = req.body;
534
+ let parsed;
535
+ if (typeof body === 'string') {
536
+ try {
537
+ parsed = JSON.parse(body);
538
+ } catch (err) {
539
+ res.status(400).json({ error: 'invalid_json', message: err.message });
540
+ return;
541
+ }
542
+ } else if (body && typeof body === 'object') {
543
+ parsed = body;
544
+ } else {
545
+ res.status(400).json({ error: 'bad_request', message: 'body must be JSON' });
546
+ return;
547
+ }
548
+ mkdirSync(dirname(OPENCODE_JSON), { recursive: true });
549
+ writeFileSync(OPENCODE_JSON, JSON.stringify(parsed, null, 2) + '\n', 'utf8');
550
+ state.appendActivity({ kind: 'config.update' });
551
+ watcher.poke('change', OPENCODE_JSON);
552
+ res.json({ path: OPENCODE_JSON, data: parsed, exists: true, raw: JSON.stringify(parsed, null, 2) });
553
+ }));
554
+
555
+ router.post('/config/reload', wrap(async (_req, res) => {
556
+ const data = safeReadJSON(OPENCODE_JSON, null);
557
+ res.json({
558
+ path: OPENCODE_JSON,
559
+ data,
560
+ raw: data === null ? '' : JSON.stringify(data, null, 2),
561
+ exists: existsSync(OPENCODE_JSON),
562
+ });
563
+ }));
564
+
565
+ // ── /api/config/providers ─────────────────────────────────────────────
566
+ router.get('/config/providers', wrap(async (_req, res) => {
567
+ res.json({ providers: providersStore.list() });
568
+ }));
569
+
570
+ router.post('/config/providers', wrap(async (req, res) => {
571
+ const provider = providersStore.add(req.body || {});
572
+ res.status(201).json(provider);
573
+ }));
574
+
575
+ router.put('/config/providers/:id', wrap(async (req, res) => {
576
+ const provider = providersStore.update(req.params.id, req.body || {});
577
+ res.json(provider);
578
+ }));
579
+
580
+ router.delete('/config/providers/:id', wrap(async (req, res) => {
581
+ const ok = providersStore.remove(req.params.id);
582
+ if (!ok) {
583
+ res.status(404).json({ error: 'not_found' });
584
+ return;
585
+ }
586
+ res.status(204).end();
587
+ }));
588
+
589
+ // ── /api/config/mcps ───────────────────────────────────────────────────
590
+ router.get('/config/mcps', wrap(async (_req, res) => {
591
+ res.json({ mcps: mcpsStore.list() });
592
+ }));
593
+
594
+ router.post('/config/mcps', wrap(async (req, res) => {
595
+ const mcp = mcpsStore.add(req.body || {});
596
+ res.status(201).json(mcp);
597
+ }));
598
+
599
+ router.put('/config/mcps/:id', wrap(async (req, res) => {
600
+ const mcp = mcpsStore.update(req.params.id, req.body || {});
601
+ res.json(mcp);
602
+ }));
603
+
604
+ router.delete('/config/mcps/:id', wrap(async (req, res) => {
605
+ const ok = mcpsStore.remove(req.params.id);
606
+ if (!ok) {
607
+ res.status(404).json({ error: 'not_found' });
608
+ return;
609
+ }
610
+ res.status(204).end();
611
+ }));
612
+
613
+ // ── /api/settings ─────────────────────────────────────────────────────
614
+ router.get('/settings', wrap(async (_req, res) => {
615
+ res.json(readSettings());
616
+ }));
617
+
618
+ router.put('/settings', wrap(async (req, res) => {
619
+ const updated = writeSettings(req.body || {});
620
+ state.appendActivity({ kind: 'settings.update' });
621
+ broadcast({ type: 'settings:change', settings: updated.data });
622
+ res.json(updated);
623
+ }));
624
+
625
+ router.post('/settings/reset', wrap(async (_req, res) => {
626
+ const updated = writeSettings(DEFAULT_SETTINGS);
627
+ broadcast({ type: 'settings:change', settings: updated.data });
628
+ res.json(updated);
629
+ }));
630
+
631
+ // ── /api/chat ──────────────────────────────────────────────────────────
632
+ router.get('/chat', wrap(async (req, res) => {
633
+ const sessionId = req.query.session ? String(req.query.session) : null;
634
+ const limit = req.query.limit ? Number(req.query.limit) : 200;
635
+ res.json(state.getChat({ sessionId, limit }));
636
+ }));
637
+
638
+ router.post('/chat', wrap(async (req, res) => {
639
+ const body = req.body || {};
640
+ const message = typeof body.message === 'string' ? body.message.trim() : '';
641
+ if (!message) {
642
+ res.status(400).json({ error: 'bad_request', message: 'message is required' });
643
+ return;
644
+ }
645
+ const active = projectsStore.active();
646
+ if (active) {
647
+ // Append to per-project session
648
+ const dir = projectsStore.ensureProjectDir(active.id);
649
+ const sessionsDir = join(dir, 'sessions');
650
+ mkdirSync(sessionsDir, { recursive: true });
651
+ const sessionId = body.session || `sess_${Date.now().toString(36)}`;
652
+ const file = join(sessionsDir, `${sessionId}.jsonl`);
653
+ const record = {
654
+ ts: new Date().toISOString(),
655
+ role: 'user',
656
+ agent: body.agent || null,
657
+ model: body.model || null,
658
+ content: message,
659
+ attachments: body.attachments || [],
660
+ };
661
+ try {
662
+ const lines = existsSync(file) ? readFileSync(file, 'utf8').split(/\r?\n/).filter(Boolean) : [];
663
+ lines.push(JSON.stringify(record));
664
+ writeFileSync(file, lines.map((l) => l).join('\n') + '\n', 'utf8');
665
+ } catch (err) {
666
+ // best effort
667
+ }
668
+ }
669
+ state.appendActivity({
670
+ kind: 'chat.message',
671
+ agent: body.agent || null,
672
+ message: message.slice(0, 500),
673
+ });
674
+ broadcast({ type: 'chat:message', message: record });
675
+ res.status(202).json({
676
+ accepted: true,
677
+ agent: body.agent || null,
678
+ queued: true,
679
+ });
680
+ }));
681
+
682
+ router.get('/chat/sessions', wrap(async (_req, res) => {
683
+ const active = projectsStore.active();
684
+ if (!active) {
685
+ res.json({ sessions: [] });
686
+ return;
687
+ }
688
+ const dir = join(projectsStore.ensureProjectDir(active.id), 'sessions');
689
+ if (!existsSync(dir)) {
690
+ res.json({ sessions: [] });
691
+ return;
692
+ }
693
+ const sessions = readdirSync(dir)
694
+ .filter((f) => f.endsWith('.jsonl'))
695
+ .map((f) => {
696
+ const st = statSync(join(dir, f));
697
+ return { id: f.replace(/\.jsonl$/, ''), file: f, mtime: st.mtimeMs, size: st.size };
698
+ });
699
+ sessions.sort((a, b) => b.mtime - a.mtime);
700
+ res.json({ sessions });
701
+ }));
702
+
703
+ // ── /api/search ────────────────────────────────────────────────────────
704
+ router.get('/search', wrap(async (req, res) => {
705
+ const q = (req.query.q || '').toString();
706
+ const scope = (req.query.scope || 'all').toString();
707
+ const active = projectsStore.active();
708
+ res.json(searchStore.search(q, { activeProjectId: active?.id, scope }));
709
+ }));
710
+
711
+ // ── /api/diagnostics ──────────────────────────────────────────────────
712
+ router.get('/diagnostics', wrap(async (_req, res) => {
713
+ res.json(diagnosticsStore.snapshot());
714
+ }));
715
+
716
+ router.get('/diagnostics/health', wrap(async (_req, res) => {
717
+ res.json(diagnosticsStore.health());
718
+ }));
719
+
720
+ router.get('/diagnostics/logs', wrap(async (req, res) => {
721
+ const tail = Math.min(Number(req.query.tail) || 100, 5000);
722
+ const serviceLog = join(BIZAR_HOME, 'service.log');
723
+ const dashboardLog = join(BIZAR_HOME, 'dashboard.log');
724
+ // Prefer service.log, fall back to dashboard.log
725
+ const logFile = existsSync(serviceLog) ? serviceLog : existsSync(dashboardLog) ? dashboardLog : null;
726
+ if (!logFile) {
727
+ res.json({ lines: [], file: null, total: 0 });
728
+ return;
729
+ }
730
+ try {
731
+ const text = readFileSync(logFile, 'utf8');
732
+ const allLines = text.split(/\r?\n/).filter(Boolean);
733
+ const lines = allLines.slice(-tail);
734
+ res.json({ lines, file: logFile, total: allLines.length });
735
+ } catch (err) {
736
+ res.status(500).json({ error: 'read_failed', message: err.message });
737
+ }
738
+ }));
739
+
740
+ // ── /api/tailscale ─────────────────────────────────────────────────────
741
+ router.get('/tailscale/status', wrap(async (_req, res) => {
742
+ res.json(await tailscaleStore.status());
743
+ }));
744
+
745
+ router.post('/tailscale/enable', wrap(async (req, res) => {
746
+ res.json(await tailscaleStore.enable(req.body || {}));
747
+ }));
748
+
749
+ router.post('/tailscale/disable', wrap(async (_req, res) => {
750
+ res.json(await tailscaleStore.disable());
751
+ }));
752
+
753
+ // ── /api/chat/regenerate ─────────────────────────────────────────────
754
+ // v3.0.0: re-dispatches the last user message before messageId via POST /chat.
755
+ // Full opencode re-dispatch lands in v3.1 when the plugin exposes a stable HTTP API.
756
+ router.post('/chat/regenerate', wrap(async (req, res) => {
757
+ const { sessionId, messageId } = req.body || {};
758
+ if (!messageId) {
759
+ res.status(400).json({ error: 'bad_request', message: 'messageId is required' });
760
+ return;
761
+ }
762
+ const active = projectsStore.active();
763
+ if (!active) {
764
+ res.status(400).json({ error: 'no_active_project', message: 'no active project' });
765
+ return;
766
+ }
767
+ const dir = projectsStore.ensureProjectDir(active.id);
768
+ const sessionsDir = join(dir, 'sessions');
769
+ if (!existsSync(sessionsDir)) {
770
+ res.status(404).json({ error: 'not_found', message: 'no sessions found' });
771
+ return;
772
+ }
773
+ const allFiles = readdirSync(sessionsDir).filter((f) => f.endsWith('.jsonl'));
774
+ const targetFiles = sessionId ? allFiles.filter((f) => f === `${sessionId}.jsonl`) : allFiles;
775
+ if (!targetFiles.length) {
776
+ res.status(404).json({ error: 'not_found', message: 'session not found' });
777
+ return;
778
+ }
779
+ // Read the session file and find the last user message before messageId
780
+ const full = join(sessionsDir, targetFiles[0]);
781
+ let lastUserMessage = null;
782
+ let foundTarget = false;
783
+ try {
784
+ const lines = readFileSync(full, 'utf8').split(/\r?\n/).filter(Boolean);
785
+ for (let i = 0; i < lines.length; i++) {
786
+ try {
787
+ const msg = JSON.parse(lines[i]);
788
+ if (msg.id === messageId || (messageId && String(msg.ts) === String(messageId))) {
789
+ foundTarget = true;
790
+ for (let j = i - 1; j >= 0; j--) {
791
+ try {
792
+ const prev = JSON.parse(lines[j]);
793
+ if (prev.role === 'user') {
794
+ lastUserMessage = prev;
795
+ break;
796
+ }
797
+ } catch {
798
+ /* skip */
799
+ }
800
+ }
801
+ break;
802
+ }
803
+ } catch {
804
+ /* skip */
805
+ }
806
+ }
807
+ } catch (err) {
808
+ res.status(500).json({ error: 'read_failed', message: err.message });
809
+ return;
810
+ }
811
+ // Fallback: find last user message
812
+ if (!lastUserMessage) {
813
+ try {
814
+ const lines = readFileSync(full, 'utf8').split(/\r?\n/).filter(Boolean).reverse();
815
+ for (const line of lines) {
816
+ try {
817
+ const msg = JSON.parse(line);
818
+ if (msg.role === 'user') {
819
+ lastUserMessage = msg;
820
+ break;
821
+ }
822
+ } catch {
823
+ /* skip */
824
+ }
825
+ }
826
+ } catch {
827
+ /* ignore */
828
+ }
829
+ }
830
+ if (!lastUserMessage) {
831
+ res.status(404).json({ error: 'not_found', message: 'no user message found to regenerate' });
832
+ return;
833
+ }
834
+ // Re-post via POST /chat (queued for agent processing)
835
+ const record = {
836
+ ts: new Date().toISOString(),
837
+ role: 'user',
838
+ agent: lastUserMessage.agent || null,
839
+ model: lastUserMessage.model || null,
840
+ content: lastUserMessage.content || lastUserMessage.message || '',
841
+ attachments: lastUserMessage.attachments || [],
842
+ };
843
+ try {
844
+ const lines = existsSync(full) ? readFileSync(full, 'utf8').split(/\r?\n/).filter(Boolean) : [];
845
+ lines.push(JSON.stringify(record));
846
+ writeFileSync(full, lines.join('\n') + '\n', 'utf8');
847
+ } catch {
848
+ /* best effort */
849
+ }
850
+ state.appendActivity({
851
+ kind: 'chat.regenerate',
852
+ agent: lastUserMessage.agent || null,
853
+ message: (lastUserMessage.content || '').slice(0, 500),
854
+ });
855
+ broadcast({ type: 'chat:regenerate', message: record });
856
+ res.status(202).json({ accepted: true, regeneratedMessage: record });
857
+ }));
858
+
859
+ // ── /api/plans (kept from v2.x) ───────────────────────────────────────
860
+ router.get('/plans', wrap(async (_req, res) => {
861
+ res.json({ plans: state.getPlans() });
862
+ }));
863
+
864
+ router.get('/plans/:slug', wrap(async (req, res) => {
865
+ const slug = req.params.slug;
866
+ const local = join(state.paths.plansDir, slug);
867
+ const global = join(state.paths.globalPlansDir, slug);
868
+ const dir = existsSync(local) ? local : existsSync(global) ? global : null;
869
+ if (!dir) {
870
+ res.status(404).json({ error: 'not_found' });
871
+ return;
872
+ }
873
+ const meta = safeReadJSON(join(dir, 'meta.json'), null);
874
+ const planMdx = safeReadText(join(dir, 'plan.mdx'));
875
+ res.json({ slug, dir, meta, planMdx });
876
+ }));
877
+
878
+ // ── /api/health ───────────────────────────────────────────────────────
879
+ router.get('/health', (_req, res) => res.json({ ok: true, ts: Date.now() }));
880
+
881
+ router.use((req, res) => {
882
+ res.status(404).json({
883
+ error: 'not_found',
884
+ message: `no route for ${req.method} ${req.originalUrl}`,
885
+ });
886
+ });
887
+
888
+ function buildSnapshot() {
889
+ const cfg = safeReadJSON(OPENCODE_JSON, null);
890
+ const active = projectsStore.active();
891
+ return {
892
+ overview: state.getOverview(),
893
+ agents: agentsStore.list(),
894
+ plans: state.getPlans(),
895
+ projects: projectsStore.list().projects,
896
+ activeProject: active,
897
+ config: {
898
+ path: OPENCODE_JSON,
899
+ data: cfg,
900
+ raw: cfg === null ? '' : JSON.stringify(cfg, null, 2),
901
+ exists: existsSync(OPENCODE_JSON),
902
+ },
903
+ settings: readSettings(),
904
+ tasks: active ? tasksStore.loadTasks(active.id) : [],
905
+ mods: modsLoader.list(),
906
+ schedules: active ? schedulesStore.list(active.id) : [],
907
+ providers: providersStore.list(),
908
+ mcps: mcpsStore.list(),
909
+ };
910
+ }
911
+
912
+ return router;
913
+ }