@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,357 @@
1
+ /**
2
+ * src/server/state.mjs
3
+ *
4
+ * v3.0.0 — Server-side state aggregation.
5
+ *
6
+ * Holds the read-only legacy endpoints (overview, plans, chat-via-legacy,
7
+ * etc.) used by both the dashboard and the TUI. New endpoints (per-project
8
+ * tasks, schedules, mods, projects) live directly in api.mjs and use the
9
+ * dedicated stores.
10
+ *
11
+ * Data sources:
12
+ * - getOverview: counts + .bizar/activity.log tail
13
+ * - getChat: per-project sessions/<id>.jsonl (preferred) — falls
14
+ * back to legacy .bizar/sessions if no project is active
15
+ * - getAgents: ~/.config/opencode/agents/*.md (frontmatter parse)
16
+ * - getPlans: scans plans/ (worktree) and ~/.config/opencode/plans/
17
+ */
18
+ import {
19
+ existsSync,
20
+ readFileSync,
21
+ writeFileSync,
22
+ readdirSync,
23
+ statSync,
24
+ mkdirSync,
25
+ } from 'node:fs';
26
+ import { join, basename, dirname } from 'node:path';
27
+ import { homedir } from 'node:os';
28
+ import { projectsStore } from './projects-store.mjs';
29
+
30
+ const HOME = homedir();
31
+
32
+ /**
33
+ * @param {object} opts
34
+ * @param {string} opts.projectRoot
35
+ * @param {string} opts.opencodeConfigDir
36
+ * @param {string} opts.bizarRoot
37
+ */
38
+ export function createState({ projectRoot, opencodeConfigDir, bizarRoot }) {
39
+ const paths = {
40
+ projectRoot,
41
+ opencodeConfigDir,
42
+ bizarRoot,
43
+ opencodeJson: join(opencodeConfigDir, 'opencode.json'),
44
+ agentsDir: join(opencodeConfigDir, 'agents'),
45
+ commandsDir: join(opencodeConfigDir, 'commands-bizar'),
46
+ bizarDir: join(projectRoot, '.bizar'),
47
+ sessionsDir: join(projectRoot, '.bizar', 'sessions'),
48
+ activityLog: join(projectRoot, '.bizar', 'activity.log'),
49
+ plansDir: join(projectRoot, 'plans'),
50
+ globalPlansDir: join(opencodeConfigDir, 'plans'),
51
+ settingsFile: join(HOME, '.config', 'bizar', 'settings.json'),
52
+ };
53
+
54
+ function safeReadJSON(file, fallback = null) {
55
+ try {
56
+ if (!existsSync(file)) return fallback;
57
+ const text = readFileSync(file, 'utf8');
58
+ if (!text.trim()) return fallback;
59
+ return JSON.parse(text);
60
+ } catch {
61
+ return fallback;
62
+ }
63
+ }
64
+
65
+ function safeReadText(file, fallback = '') {
66
+ try {
67
+ if (!existsSync(file)) return fallback;
68
+ return readFileSync(file, 'utf8');
69
+ } catch {
70
+ return fallback;
71
+ }
72
+ }
73
+
74
+ function safeStat(p) {
75
+ try {
76
+ return statSync(p);
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ function parseFrontmatter(raw) {
83
+ if (!raw.startsWith('---')) return { frontmatter: {}, body: raw };
84
+ const end = raw.indexOf('\n---', 3);
85
+ if (end === -1) return { frontmatter: {}, body: raw };
86
+ const fmBlock = raw.slice(3, end).trim();
87
+ const body = raw.slice(end + 4).replace(/^\s+/, '');
88
+ const frontmatter = {};
89
+ for (const line of fmBlock.split(/\r?\n/)) {
90
+ const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
91
+ if (!m) continue;
92
+ const key = m[1];
93
+ let val = m[2].trim();
94
+ if (
95
+ (val.startsWith('"') && val.endsWith('"')) ||
96
+ (val.startsWith("'") && val.endsWith("'"))
97
+ ) {
98
+ val = val.slice(1, -1);
99
+ }
100
+ frontmatter[key] = val;
101
+ }
102
+ return { frontmatter, body };
103
+ }
104
+
105
+ function getOverview() {
106
+ // Use the new store for project count to keep the v3 view consistent.
107
+ const projectsList = projectsStore.list();
108
+ const agents = readAgents();
109
+ const plans = readPlans();
110
+ const active = projectsStore.active();
111
+
112
+ let sessionCount = 0;
113
+ if (active) {
114
+ const dir = join(projectsStore.projectDir(active.id), 'sessions');
115
+ if (existsSync(dir)) {
116
+ try {
117
+ sessionCount = readdirSync(dir).filter((f) => f.endsWith('.jsonl')).length;
118
+ } catch {
119
+ sessionCount = 0;
120
+ }
121
+ }
122
+ } else if (existsSync(paths.sessionsDir)) {
123
+ try {
124
+ sessionCount = readdirSync(paths.sessionsDir).filter((f) => f.endsWith('.jsonl')).length;
125
+ } catch {
126
+ sessionCount = 0;
127
+ }
128
+ }
129
+
130
+ const recentActivity = [];
131
+ try {
132
+ const logFile = active
133
+ ? join(projectsStore.projectDir(active.id), 'activity.log')
134
+ : paths.activityLog;
135
+ if (existsSync(logFile)) {
136
+ const lines = readFileSync(logFile, 'utf8').split(/\r?\n/);
137
+ for (const line of lines) {
138
+ if (!line.trim()) continue;
139
+ try {
140
+ recentActivity.push(JSON.parse(line));
141
+ } catch {
142
+ // skip
143
+ }
144
+ }
145
+ }
146
+ } catch {
147
+ /* ignore */
148
+ }
149
+ recentActivity.reverse();
150
+ const trimmedActivity = recentActivity.slice(0, 30);
151
+
152
+ return {
153
+ counts: {
154
+ agents: agents.length,
155
+ plans: plans.length,
156
+ projects: projectsList.projects.length,
157
+ sessions: sessionCount,
158
+ activeProject: active?.id || null,
159
+ },
160
+ recentActivity: trimmedActivity,
161
+ versions: {
162
+ node: process.version,
163
+ platform: process.platform,
164
+ bizarRoot,
165
+ projectRoot,
166
+ },
167
+ generatedAt: new Date().toISOString(),
168
+ };
169
+ }
170
+
171
+ function getChat({ sessionId = null, limit = 200 } = {}) {
172
+ const active = projectsStore.active();
173
+ const sessionsDir = active
174
+ ? join(projectsStore.projectDir(active.id), 'sessions')
175
+ : paths.sessionsDir;
176
+ if (!existsSync(sessionsDir)) return { messages: [], sessions: [] };
177
+ const allFiles = readdirSync(sessionsDir).filter((f) => f.endsWith('.jsonl'));
178
+ const sessions = allFiles.map((f) => {
179
+ const st = safeStat(join(sessionsDir, f));
180
+ return {
181
+ id: f.replace(/\.jsonl$/, ''),
182
+ file: f,
183
+ mtime: st ? st.mtimeMs : 0,
184
+ size: st ? st.size : 0,
185
+ };
186
+ });
187
+ sessions.sort((a, b) => b.mtime - a.mtime);
188
+
189
+ const target = sessionId
190
+ ? allFiles.filter((f) => f === `${sessionId}.jsonl`)
191
+ : allFiles;
192
+
193
+ const messages = [];
194
+ for (const file of target) {
195
+ const full = join(sessionsDir, file);
196
+ try {
197
+ const lines = readFileSync(full, 'utf8').split(/\r?\n/);
198
+ for (const line of lines) {
199
+ if (!line.trim()) continue;
200
+ try {
201
+ messages.push(JSON.parse(line));
202
+ } catch {
203
+ /* skip */
204
+ }
205
+ }
206
+ } catch {
207
+ /* skip */
208
+ }
209
+ }
210
+
211
+ messages.reverse();
212
+ return { messages: messages.slice(0, limit), sessions };
213
+ }
214
+
215
+ function readAgents() {
216
+ const dir = paths.agentsDir;
217
+ if (!existsSync(dir)) return [];
218
+ const out = [];
219
+ for (const file of readdirSync(dir)) {
220
+ if (!file.endsWith('.md')) continue;
221
+ const full = join(dir, file);
222
+ const raw = safeReadText(full);
223
+ const { frontmatter } = parseFrontmatter(raw);
224
+ const st = safeStat(full);
225
+ out.push({
226
+ name:
227
+ frontmatter.name ||
228
+ basename(file, '.md') ||
229
+ file.replace(/\.md$/, ''),
230
+ description: frontmatter.description || '',
231
+ model: frontmatter.model || '',
232
+ mode: frontmatter.mode || '',
233
+ file,
234
+ path: full,
235
+ mtime: st ? st.mtimeMs : 0,
236
+ });
237
+ }
238
+ out.sort((a, b) => a.name.localeCompare(b.name));
239
+ return out;
240
+ }
241
+
242
+ function getAgents() {
243
+ return readAgents();
244
+ }
245
+
246
+ function readPlansFromDir(dir) {
247
+ if (!existsSync(dir)) return [];
248
+ const out = [];
249
+ for (const entry of readdirSync(dir)) {
250
+ const full = join(dir, entry);
251
+ const st = safeStat(full);
252
+ if (!st || !st.isDirectory()) continue;
253
+ const meta = safeReadJSON(join(full, 'meta.json'), null);
254
+ const planPath = join(full, 'plan.mdx');
255
+ const planExists = existsSync(planPath);
256
+ out.push({
257
+ slug: entry,
258
+ title: meta?.title || entry,
259
+ status: meta?.status || 'draft',
260
+ source: dir === paths.plansDir ? 'worktree' : 'global',
261
+ elementCount: meta?.elementCount ?? null,
262
+ commentCount: meta?.commentCount ?? null,
263
+ mtime: st.mtimeMs,
264
+ planUrl: planExists ? `/${entry}/` : null,
265
+ });
266
+ }
267
+ return out;
268
+ }
269
+
270
+ function readPlans() {
271
+ const a = readPlansFromDir(paths.plansDir);
272
+ const b = readPlansFromDir(paths.globalPlansDir);
273
+ const seen = new Set();
274
+ const out = [];
275
+ for (const p of [...a, ...b]) {
276
+ if (seen.has(p.slug)) continue;
277
+ seen.add(p.slug);
278
+ out.push(p);
279
+ }
280
+ out.sort((a, b) => b.mtime - a.mtime);
281
+ return out;
282
+ }
283
+
284
+ function getPlans() {
285
+ return readPlans();
286
+ }
287
+
288
+ function getProjects() {
289
+ // Legacy v2.x shape — list of {name, path, ...} for the Dashboard
290
+ // UI. The new v3 API lives in api.mjs and returns the richer registry.
291
+ const out = [];
292
+ let dir = projectRoot;
293
+ const seen = new Set();
294
+ while (dir && dir !== dirname(dir) && !seen.has(dir)) {
295
+ seen.add(dir);
296
+ const marker = join(dir, '.bizar', 'PROJECT.md');
297
+ if (existsSync(marker)) {
298
+ const st = safeStat(marker);
299
+ out.push({
300
+ name: basename(dir),
301
+ path: dir,
302
+ projectMdSize: st ? st.size : 0,
303
+ mtime: st ? st.mtimeMs : 0,
304
+ active: dir === projectRoot,
305
+ });
306
+ }
307
+ dir = dirname(dir);
308
+ }
309
+ const projectsRoot = join(HOME, 'Projects');
310
+ if (existsSync(projectsRoot)) {
311
+ try {
312
+ for (const entry of readdirSync(projectsRoot)) {
313
+ const full = join(projectsRoot, entry);
314
+ const st = safeStat(full);
315
+ if (!st || !st.isDirectory()) continue;
316
+ if (out.some((p) => p.path === full)) continue;
317
+ out.push({
318
+ name: entry,
319
+ path: full,
320
+ projectMdSize: 0,
321
+ mtime: st.mtimeMs,
322
+ active: false,
323
+ });
324
+ }
325
+ } catch {
326
+ /* ignore */
327
+ }
328
+ }
329
+ out.sort((a, b) => (b.active ? 1 : 0) - (a.active ? 1 : 0));
330
+ return out;
331
+ }
332
+
333
+ function appendActivity(event) {
334
+ try {
335
+ const active = projectsStore.active();
336
+ const targetDir = active
337
+ ? projectsStore.ensureProjectDir(active.id)
338
+ : paths.bizarDir;
339
+ const logFile = join(targetDir, 'activity.log');
340
+ mkdirSync(targetDir, { recursive: true });
341
+ const record = { ts: new Date().toISOString(), ...event };
342
+ writeFileSync(logFile, JSON.stringify(record) + '\n', { flag: 'a', encoding: 'utf8' });
343
+ } catch (err) {
344
+ console.error('[dashboard state] appendActivity failed:', err);
345
+ }
346
+ }
347
+
348
+ return {
349
+ paths,
350
+ getOverview,
351
+ getChat,
352
+ getAgents,
353
+ getPlans,
354
+ getProjects,
355
+ appendActivity,
356
+ };
357
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * src/server/tailscale-store.mjs
3
+ *
4
+ * v3.0.0 — Tailscale serve control.
5
+ *
6
+ * v3 only does the surface plumbing. The actual `tailscale serve` CLI
7
+ * invocation is wrapped in try/catch with helpful errors.
8
+ */
9
+ import { execFile } from 'node:child_process';
10
+ import { promisify } from 'node:util';
11
+ import { existsSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { homedir, hostname as getOsHostname } from 'node:os';
14
+
15
+ const execFileP = promisify(execFile);
16
+ const HOME = homedir();
17
+ const SETTINGS_FILE = join(HOME, '.config', 'bizar', 'tailscale.json');
18
+
19
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
20
+ import { dirname } from 'node:path';
21
+
22
+ function loadSettings() {
23
+ try {
24
+ if (!existsSync(SETTINGS_FILE)) return { enabled: false, port: 4321, https: true, hostname: '' };
25
+ return JSON.parse(readFileSync(SETTINGS_FILE, 'utf8'));
26
+ } catch {
27
+ return { enabled: false, port: 4321, https: true, hostname: '' };
28
+ }
29
+ }
30
+
31
+ function saveSettings(s) {
32
+ try {
33
+ mkdirSync(dirname(SETTINGS_FILE), { recursive: true });
34
+ writeFileSync(SETTINGS_FILE, JSON.stringify(s, null, 2) + '\n', 'utf8');
35
+ } catch {
36
+ /* ignore */
37
+ }
38
+ }
39
+
40
+ async function tailscaleVersion() {
41
+ try {
42
+ const { stdout } = await execFileP('tailscale', ['version'], { timeout: 5000 });
43
+ return stdout.trim().split('\n')[0] || '';
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ async function tailscaleStatus() {
50
+ try {
51
+ const { stdout } = await execFileP('tailscale', ['status', '--json'], { timeout: 5000 });
52
+ const data = JSON.parse(stdout);
53
+ return {
54
+ authenticated: !!data.AuthURL || !!data.Self?.Online,
55
+ backend: data.BackendState || 'unknown',
56
+ hostname: data.Self?.HostName || '',
57
+ };
58
+ } catch {
59
+ return { authenticated: false, backend: 'unknown' };
60
+ }
61
+ }
62
+
63
+ export const tailscaleStore = {
64
+ SETTINGS_FILE,
65
+
66
+ async status() {
67
+ const version = await tailscaleVersion();
68
+ const installed = !!version;
69
+ let ts = null;
70
+ if (installed) ts = await tailscaleStatus();
71
+ const cfg = loadSettings();
72
+ return {
73
+ installed,
74
+ version,
75
+ ...ts,
76
+ settings: cfg,
77
+ };
78
+ },
79
+
80
+ async enable({ port = 4321, https = true, hostname = '' } = {}) {
81
+ if (!existsSync('/usr/bin/tailscale') && !existsSync('/usr/local/bin/tailscale')) {
82
+ // best-effort detection — `tailscale` may be in $PATH elsewhere
83
+ }
84
+ try {
85
+ // Resolve hostname: use provided value, or fall back to current machine hostname
86
+ const resolvedHostname = hostname || getOsHostname();
87
+ const args = ['serve', '--bg'];
88
+ // Pass hostname via --host flag (Tailscale serve uses this for HTTPS certificate)
89
+ if (resolvedHostname) {
90
+ args.push('--host', resolvedHostname);
91
+ }
92
+ args.push(https ? 'https' : 'http', `localhost:${port}`);
93
+ await execFileP('tailscale', args, { timeout: 10000 });
94
+ const cfg = { enabled: true, port, https, hostname: resolvedHostname };
95
+ saveSettings(cfg);
96
+ return { ok: true, settings: cfg };
97
+ } catch (err) {
98
+ return { ok: false, error: err.message };
99
+ }
100
+ },
101
+
102
+ async disable() {
103
+ try {
104
+ await execFileP('tailscale', ['serve', 'reset'], { timeout: 10000 });
105
+ const cfg = loadSettings();
106
+ cfg.enabled = false;
107
+ saveSettings(cfg);
108
+ return { ok: true, settings: cfg };
109
+ } catch (err) {
110
+ return { ok: false, error: err.message };
111
+ }
112
+ },
113
+ };