@polderlabs/bizar 4.4.13 → 4.5.1

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 (118) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +2 -0
  3. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
  4. package/bizar-dash/dist/assets/main-eWZ4NlCL.css +1 -0
  5. package/bizar-dash/dist/assets/main-usWhlPWa.js +362 -0
  6. package/bizar-dash/dist/assets/main-usWhlPWa.js.map +1 -0
  7. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile-DQLFCjwJ.js} +2 -2
  8. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile-DQLFCjwJ.js.map} +1 -1
  9. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js +352 -0
  10. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +1 -0
  11. package/bizar-dash/dist/index.html +3 -3
  12. package/bizar-dash/dist/mobile.html +2 -2
  13. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  14. package/bizar-dash/skills/bizar/SKILL.md +116 -0
  15. package/bizar-dash/skills/chat/SKILL.md +74 -0
  16. package/bizar-dash/skills/headroom/SKILL.md +94 -0
  17. package/bizar-dash/skills/lightrag/SKILL.md +86 -0
  18. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  19. package/bizar-dash/skills/obsidian/SKILL.md +68 -0
  20. package/bizar-dash/skills/providers/SKILL.md +75 -0
  21. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  22. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  23. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  24. package/bizar-dash/skills/usage/SKILL.md +62 -0
  25. package/bizar-dash/src/server/api.mjs +18 -0
  26. package/bizar-dash/src/server/headroom.mjs +645 -0
  27. package/bizar-dash/src/server/memory-lightrag.mjs +272 -2
  28. package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
  29. package/bizar-dash/src/server/memory-store.mjs +189 -0
  30. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  31. package/bizar-dash/src/server/minimax.mjs +196 -5
  32. package/bizar-dash/src/server/providers-store.mjs +956 -0
  33. package/bizar-dash/src/server/routes/_shared.mjs +17 -0
  34. package/bizar-dash/src/server/routes/config.mjs +52 -1
  35. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  36. package/bizar-dash/src/server/routes/headroom.mjs +126 -0
  37. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  38. package/bizar-dash/src/server/routes/memory.mjs +668 -1
  39. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  40. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  41. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  42. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  43. package/bizar-dash/src/server/routes/update.mjs +340 -0
  44. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  45. package/bizar-dash/src/server/serve-info.mjs +135 -4
  46. package/bizar-dash/src/server/server.mjs +20 -0
  47. package/bizar-dash/src/server/skills-store.mjs +152 -262
  48. package/bizar-dash/src/web/App.tsx +120 -29
  49. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  50. package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
  51. package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
  52. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  53. package/bizar-dash/src/web/components/Topbar.tsx +2 -1
  54. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  55. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  56. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  57. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  58. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  59. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  60. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  61. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  62. package/bizar-dash/src/web/lib/api.ts +43 -0
  63. package/bizar-dash/src/web/lib/types.ts +16 -0
  64. package/bizar-dash/src/web/main.tsx +2 -0
  65. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  66. package/bizar-dash/src/web/styles/chat.css +135 -1
  67. package/bizar-dash/src/web/styles/main.css +46 -0
  68. package/bizar-dash/src/web/styles/memory.css +955 -0
  69. package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
  70. package/bizar-dash/src/web/styles/settings.css +418 -0
  71. package/bizar-dash/src/web/styles/skills.css +302 -0
  72. package/bizar-dash/src/web/styles/tasks.css +288 -0
  73. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  74. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  75. package/bizar-dash/src/web/views/Memory.tsx +140 -0
  76. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
  77. package/bizar-dash/src/web/views/Overview.tsx +3 -0
  78. package/bizar-dash/src/web/views/Settings.tsx +36 -0
  79. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  80. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  81. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
  82. package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
  83. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
  84. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +354 -0
  85. package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
  86. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
  87. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
  88. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  89. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  90. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  91. package/bizar-dash/tests/headroom-install.test.mjs +173 -0
  92. package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
  93. package/bizar-dash/tests/headroom-status.test.mjs +117 -0
  94. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  95. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
  96. package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
  97. package/bizar-dash/tests/memory-tab.test.mjs +322 -0
  98. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  99. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  100. package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
  101. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  102. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  103. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  104. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  105. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  106. package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
  107. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  108. package/bizar-dash/tests/update-check.test.mjs +127 -0
  109. package/bizar-dash/tests/update-run.test.mjs +266 -0
  110. package/cli/bin.mjs +247 -1
  111. package/cli/provision.mjs +118 -4
  112. package/config/agents/_shared/SKILLS.md +109 -0
  113. package/package.json +1 -1
  114. package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
  115. package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
  116. package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
  117. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  118. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -0,0 +1,194 @@
1
+ // src/web/views/memory/SemanticSearchPanel.tsx — cross-source search (LightRAG + Obsidian).
2
+ import { useEffect, useMemo, useState } from 'react';
3
+ import { Brain, FileText, Loader2, Search as SearchIcon, X } from 'lucide-react';
4
+ import { Button } from '../../components/Button';
5
+ import { Card, CardMeta, CardTitle } from '../../components/Card';
6
+ import { EmptyState } from '../../components/EmptyState';
7
+ import { Spinner } from '../../components/Spinner';
8
+ import { useToast } from '../../components/Toast';
9
+ import { api } from '../../lib/api';
10
+ import { cn } from '../../lib/utils';
11
+
12
+ type Result = {
13
+ source: 'lightrag' | 'obsidian';
14
+ relPath: string | null;
15
+ snippet: string;
16
+ score: number;
17
+ mtime: number | null;
18
+ raw?: unknown;
19
+ error?: string;
20
+ };
21
+
22
+ type Props = { refreshKey: number };
23
+
24
+ export function SemanticSearchPanel({ refreshKey }: Props) {
25
+ const toast = useToast();
26
+ const [q, setQ] = useState('');
27
+ const [sources, setSources] = useState<{ lightrag: boolean; obsidian: boolean }>({ lightrag: true, obsidian: true });
28
+ const [results, setResults] = useState<Result[] | null>(null);
29
+ const [loading, setLoading] = useState(false);
30
+ const [lastQuery, setLastQuery] = useState('');
31
+
32
+ const onSearch = async (query: string) => {
33
+ if (!query.trim()) return;
34
+ setLoading(true);
35
+ setLastQuery(query);
36
+ try {
37
+ const activeSources = [
38
+ ...(sources.lightrag ? ['lightrag' as const] : []),
39
+ ...(sources.obsidian ? ['obsidian' as const] : []),
40
+ ];
41
+ const r = await api.post<{ query: string; count: number; results: Result[] }>(
42
+ '/memory/semantic-search',
43
+ { query, limit: 20, sources: activeSources },
44
+ );
45
+ setResults(r.results || []);
46
+ } catch (err) {
47
+ toast.error(`Search failed: ${(err as Error).message}`);
48
+ setResults([]);
49
+ } finally {
50
+ setLoading(false);
51
+ }
52
+ };
53
+
54
+ // Re-run when sources toggle (if there's a last query).
55
+ useEffect(() => {
56
+ if (lastQuery) onSearch(lastQuery);
57
+ // eslint-disable-next-line react-hooks/exhaustive-deps
58
+ }, [sources]);
59
+
60
+ // Re-run when the panel is re-mounted.
61
+ useEffect(() => {
62
+ if (lastQuery) onSearch(lastQuery);
63
+ // eslint-disable-next-line react-hooks/exhaustive-deps
64
+ }, [refreshKey]);
65
+
66
+ const grouped = useMemo(() => {
67
+ const obs = (results || []).filter((r) => r.source === 'obsidian');
68
+ const lr = (results || []).filter((r) => r.source === 'lightrag');
69
+ return { obs, lr };
70
+ }, [results]);
71
+
72
+ return (
73
+ <div className="memory-panel-content">
74
+ <Card variant="elevated">
75
+ <CardTitle>
76
+ <SearchIcon size={14} /> Semantic search
77
+ </CardTitle>
78
+ <CardMeta>Query both LightRAG (graph-based) and the Obsidian vault in one shot.</CardMeta>
79
+ <div className="memory-search-row">
80
+ <input
81
+ type="text"
82
+ className="input"
83
+ placeholder="e.g. how are notes indexed?"
84
+ value={q}
85
+ onChange={(e) => setQ(e.target.value)}
86
+ onKeyDown={(e) => {
87
+ if (e.key === 'Enter') onSearch(q);
88
+ }}
89
+ disabled={loading}
90
+ />
91
+ <Button variant="primary" onClick={() => onSearch(q)} disabled={loading || !q.trim()}>
92
+ {loading ? <Loader2 size={12} className="memory-spin" /> : <SearchIcon size={12} />}
93
+ Search
94
+ </Button>
95
+ </div>
96
+ <div className="memory-source-toggle-row">
97
+ <ToggleChip
98
+ active={sources.obsidian}
99
+ onToggle={() => setSources((s) => ({ ...s, obsidian: !s.obsidian }))}
100
+ label="Obsidian"
101
+ icon={<FileText size={11} />}
102
+ />
103
+ <ToggleChip
104
+ active={sources.lightrag}
105
+ onToggle={() => setSources((s) => ({ ...s, lightrag: !s.lightrag }))}
106
+ label="LightRAG"
107
+ icon={<Brain size={11} />}
108
+ />
109
+ {q && (
110
+ <button type="button" className="icon-btn" onClick={() => { setQ(''); setResults(null); setLastQuery(''); }} aria-label="Clear">
111
+ <X size={12} />
112
+ </button>
113
+ )}
114
+ </div>
115
+ </Card>
116
+
117
+ {loading && (
118
+ <div className="view-loading">
119
+ <Spinner size="md" />
120
+ </div>
121
+ )}
122
+
123
+ {!loading && results && results.length === 0 && (
124
+ <EmptyState
125
+ icon={<SearchIcon size={28} />}
126
+ title="No matches"
127
+ message={`Nothing in scope matched "${lastQuery}".`}
128
+ />
129
+ )}
130
+
131
+ {grouped.obs.length > 0 && (
132
+ <Card>
133
+ <CardTitle>
134
+ <FileText size={14} /> Obsidian ({grouped.obs.length})
135
+ </CardTitle>
136
+ <ul className="memory-search-result-list">
137
+ {grouped.obs.map((r, i) => (
138
+ <li key={`obs-${i}`} className="memory-search-result">
139
+ <div className="memory-search-result-head">
140
+ <code>{r.relPath}</code>
141
+ <span className="muted text-xs">score {r.score.toFixed(1)}</span>
142
+ </div>
143
+ <p className="muted text-sm">{r.snippet}</p>
144
+ </li>
145
+ ))}
146
+ </ul>
147
+ </Card>
148
+ )}
149
+
150
+ {grouped.lr.length > 0 && (
151
+ <Card>
152
+ <CardTitle>
153
+ <Brain size={14} /> LightRAG ({grouped.lr.length})
154
+ </CardTitle>
155
+ <div className="memory-search-result-list">
156
+ {grouped.lr.map((r, i) => (
157
+ <div key={`lr-${i}`} className="memory-search-result">
158
+ <div className="memory-search-result-head">
159
+ <span className="memory-source-pill"><Brain size={11} /> LightRAG</span>
160
+ {r.error && <span className="muted text-xs">error: {r.error}</span>}
161
+ </div>
162
+ <p className="text-sm">{r.snippet}</p>
163
+ </div>
164
+ ))}
165
+ </div>
166
+ </Card>
167
+ )}
168
+ </div>
169
+ );
170
+ }
171
+
172
+ function ToggleChip({
173
+ active,
174
+ onToggle,
175
+ label,
176
+ icon,
177
+ }: {
178
+ active: boolean;
179
+ onToggle: () => void;
180
+ label: string;
181
+ icon: React.ReactNode;
182
+ }) {
183
+ return (
184
+ <button
185
+ type="button"
186
+ className={cn('memory-toggle-chip', active && 'memory-toggle-chip-active')}
187
+ onClick={onToggle}
188
+ aria-pressed={active}
189
+ >
190
+ {icon}
191
+ <span>{label}</span>
192
+ </button>
193
+ );
194
+ }
@@ -0,0 +1,391 @@
1
+ /**
2
+ * tests/chat-session-create.test.mjs — v4.2.5
3
+ *
4
+ * Tests the session-mutation endpoints on the opencode-sessions router:
5
+ *
6
+ * POST /api/opencode-sessions/new
7
+ * PATCH /api/opencode-sessions/:id
8
+ * DELETE /api/opencode-sessions/:id
9
+ *
10
+ * Strategy: stand up a fake opencode serve child on a random port,
11
+ * point readServeInfo() at it by writing a tmp serve.json to
12
+ * ~/.cache/bizar/serve.json. The fake serves:
13
+ *
14
+ * POST /api/session?directory=... → returns { data: { id, ... } }
15
+ * PATCH /api/session/:id?directory=. → echoes the body as { id }
16
+ * DELETE /api/session/:id?directory=. → returns 200 or 404
17
+ *
18
+ * For "plugin offline" tests we move serve.json aside, then restore.
19
+ */
20
+
21
+ import { test, before, after, beforeEach } from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+ import {
24
+ mkdtempSync,
25
+ writeFileSync,
26
+ rmSync,
27
+ existsSync,
28
+ copyFileSync,
29
+ unlinkSync,
30
+ readFileSync,
31
+ } from 'node:fs';
32
+ import { join } from 'node:path';
33
+ import { tmpdir } from 'node:os';
34
+ import { createServer } from 'node:http';
35
+ import express from 'express';
36
+
37
+ import { createOpencodeSessionsRouter } from '../src/server/routes/opencode-sessions.mjs';
38
+
39
+ // ── fake upstream (opencode serve child) ──────────────────────────────────
40
+
41
+ let upstreamServer, upstreamPort;
42
+ const upstream = {
43
+ createHits: [],
44
+ patchHits: [],
45
+ deleteHits: [],
46
+ nextCreateStatus: 200,
47
+ nextCreateId: null,
48
+ // Behavior knobs tests can flip:
49
+ failNextCreate: false,
50
+ };
51
+
52
+ function startUpstream() {
53
+ return new Promise((resolve) => {
54
+ upstreamServer = createServer((req, res) => {
55
+ const url = new URL(req.url, 'http://127.0.0.1');
56
+
57
+ // CREATE: POST /api/session?directory=...
58
+ if (
59
+ url.pathname === '/api/session' &&
60
+ req.method === 'POST'
61
+ ) {
62
+ let body = '';
63
+ req.on('data', (c) => {
64
+ body += c;
65
+ });
66
+ req.on('end', () => {
67
+ let parsed;
68
+ try { parsed = JSON.parse(body); } catch { parsed = {}; }
69
+ upstream.createHits.push({ url: req.url, body: parsed });
70
+ if (upstream.failNextCreate) {
71
+ upstream.failNextCreate = false;
72
+ res.writeHead(500, { 'Content-Type': 'application/json' });
73
+ res.end(JSON.stringify({ error: 'fake-upstream-failure' }));
74
+ return;
75
+ }
76
+ res.writeHead(upstream.nextCreateStatus, {
77
+ 'Content-Type': 'application/json',
78
+ });
79
+ const id =
80
+ upstream.nextCreateId ||
81
+ `sess-new-${upstream.createHits.length}`;
82
+ upstream.nextCreateId = null;
83
+ res.end(JSON.stringify({ data: { id, title: parsed.title, agent: parsed.agent } }));
84
+ });
85
+ return;
86
+ }
87
+
88
+ // RENAME: PATCH /api/session/:id?directory=...
89
+ const patchM = url.pathname.match(/^\/api\/session\/([^/]+)$/);
90
+ if (patchM && req.method === 'PATCH') {
91
+ let body = '';
92
+ req.on('data', (c) => {
93
+ body += c;
94
+ });
95
+ req.on('end', () => {
96
+ let parsed;
97
+ try { parsed = JSON.parse(body); } catch { parsed = {}; }
98
+ upstream.patchHits.push({ id: patchM[1], url: req.url, body: parsed });
99
+ if (patchM[1] === 'sess-missing') {
100
+ res.writeHead(404, { 'Content-Type': 'application/json' });
101
+ res.end(JSON.stringify({ error: 'not found' }));
102
+ return;
103
+ }
104
+ res.writeHead(200, { 'Content-Type': 'application/json' });
105
+ res.end(JSON.stringify({ id: patchM[1], title: parsed.title }));
106
+ });
107
+ return;
108
+ }
109
+
110
+ // DELETE: DELETE /api/session/:id?directory=...
111
+ const deleteM = url.pathname.match(/^\/api\/session\/([^/]+)$/);
112
+ if (deleteM && req.method === 'DELETE') {
113
+ upstream.deleteHits.push({ id: deleteM[1], url: req.url });
114
+ if (deleteM[1] === 'sess-gone') {
115
+ res.writeHead(404, { 'Content-Type': 'application/json' });
116
+ res.end(JSON.stringify({ error: 'gone' }));
117
+ return;
118
+ }
119
+ res.writeHead(200, { 'Content-Type': 'application/json' });
120
+ res.end(JSON.stringify({ id: deleteM[1] }));
121
+ return;
122
+ }
123
+
124
+ res.writeHead(404);
125
+ res.end('not found');
126
+ });
127
+ upstreamServer.listen(0, '127.0.0.1', () => {
128
+ upstreamPort = upstreamServer.address().port;
129
+ resolve();
130
+ });
131
+ });
132
+ }
133
+
134
+ async function startDashboard() {
135
+ const app = express();
136
+ app.use(express.json({ limit: '2mb' }));
137
+ app.use('/api', createOpencodeSessionsRouter());
138
+ dashboardServer = createServer(app);
139
+ await new Promise((r) => dashboardServer.listen(0, '127.0.0.1', r));
140
+ dashboardBaseUrl = `http://127.0.0.1:${dashboardServer.address().port}`;
141
+ }
142
+
143
+ let dashboardServer, dashboardBaseUrl, tmpDir;
144
+ let SERVE_JSON_PATH;
145
+ let originalServeJson;
146
+
147
+ before(async () => {
148
+ SERVE_JSON_PATH = `${tmpdir()}/bizar-test-serve-create-${process.pid}-${Date.now()}.json`;
149
+ process.env.BIZAR_SERVE_JSON_PATH = SERVE_JSON_PATH;
150
+ await startUpstream();
151
+ await startDashboard();
152
+ tmpDir = mkdtempSync(join(tmpdir(), 'opencode-sessions-create-'));
153
+ if (existsSync(SERVE_JSON_PATH)) {
154
+ originalServeJson = readFileSync(SERVE_JSON_PATH, 'utf8');
155
+ }
156
+ });
157
+
158
+ after(async () => {
159
+ if (originalServeJson !== undefined) {
160
+ writeFileSync(SERVE_JSON_PATH, originalServeJson, 'utf8');
161
+ } else if (existsSync(SERVE_JSON_PATH)) {
162
+ rmSync(SERVE_JSON_PATH);
163
+ }
164
+ if (dashboardServer) await new Promise((r) => dashboardServer.close(r));
165
+ if (upstreamServer) await new Promise((r) => upstreamServer.close(r));
166
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
167
+ delete process.env.BIZAR_SERVE_JSON_PATH;
168
+ });
169
+
170
+ beforeEach(() => {
171
+ upstream.createHits = [];
172
+ upstream.patchHits = [];
173
+ upstream.deleteHits = [];
174
+ upstream.nextCreateStatus = 200;
175
+ upstream.nextCreateId = null;
176
+ upstream.failNextCreate = false;
177
+ });
178
+
179
+ function writeServeJson(worktree = '/tmp/new-session-worktree') {
180
+ writeFileSync(
181
+ SERVE_JSON_PATH,
182
+ JSON.stringify({
183
+ port: upstreamPort,
184
+ password: 'test-pw',
185
+ worktree,
186
+ pid: 99999,
187
+ startedAt: Date.now(),
188
+ }),
189
+ 'utf8',
190
+ );
191
+ }
192
+
193
+ /** Move serve.json aside so readServeInfo() returns null.
194
+ * Uses copy + unlink because rename across filesystems (e.g.
195
+ * /home/* → /tmp/*) throws EXDEV on Linux. */
196
+ function moveServeJsonAside() {
197
+ if (!existsSync(SERVE_JSON_PATH)) return;
198
+ const tmp = join(tmpDir, `serve-aside-${Date.now()}-${process.pid}.json`);
199
+ copyFileSync(SERVE_JSON_PATH, tmp);
200
+ unlinkSync(SERVE_JSON_PATH);
201
+ }
202
+
203
+ // ── tests ─────────────────────────────────────────────────────────────────
204
+
205
+ test('POST /api/opencode-sessions/new returns 201 with id+title+agent on happy path', async () => {
206
+ writeServeJson();
207
+ upstream.nextCreateId = 'sess-fresh-1';
208
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/new`, {
209
+ method: 'POST',
210
+ headers: { 'Content-Type': 'application/json' },
211
+ body: JSON.stringify({ title: 'Hello world', agent: 'odin' }),
212
+ });
213
+ assert.equal(r.status, 201);
214
+ const body = await r.json();
215
+ assert.equal(body.id, 'sess-fresh-1');
216
+ assert.equal(body.title, 'Hello world');
217
+ assert.equal(body.agent, 'odin');
218
+ assert.ok(typeof body.directory === 'string' && body.directory.length > 0);
219
+ assert.ok(typeof body.createdAt === 'number' && body.createdAt > 0);
220
+ assert.equal(upstream.createHits.length, 1);
221
+ assert.equal(upstream.createHits[0].body.title, 'Hello world');
222
+ assert.equal(upstream.createHits[0].body.agent, 'odin');
223
+ });
224
+
225
+ test('POST /api/opencode-sessions/new returns 400 when body is missing', async () => {
226
+ writeServeJson();
227
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/new`, {
228
+ method: 'POST',
229
+ headers: { 'Content-Type': 'application/json' },
230
+ });
231
+ assert.equal(r.status, 400);
232
+ const body = await r.json();
233
+ assert.match(body.message, /agent.*required/i);
234
+ });
235
+
236
+ test('POST /api/opencode-sessions/new returns 400 when agent missing', async () => {
237
+ writeServeJson();
238
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/new`, {
239
+ method: 'POST',
240
+ headers: { 'Content-Type': 'application/json' },
241
+ body: JSON.stringify({ title: 'no-agent' }),
242
+ });
243
+ assert.equal(r.status, 400);
244
+ });
245
+
246
+ test('POST /api/opencode-sessions/new returns 400 when agent is invalid', async () => {
247
+ writeServeJson();
248
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/new`, {
249
+ method: 'POST',
250
+ headers: { 'Content-Type': 'application/json' },
251
+ body: JSON.stringify({ agent: 'has space and !' }),
252
+ });
253
+ assert.equal(r.status, 400);
254
+ });
255
+
256
+ test('POST /api/opencode-sessions/new returns 502 when upstream 500s', async () => {
257
+ writeServeJson();
258
+ upstream.failNextCreate = true;
259
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/new`, {
260
+ method: 'POST',
261
+ headers: { 'Content-Type': 'application/json' },
262
+ body: JSON.stringify({ agent: 'odin' }),
263
+ });
264
+ assert.equal(r.status, 502);
265
+ const body = await r.json();
266
+ assert.equal(body.error, 'opencode_error');
267
+ assert.ok(body.message.length > 0);
268
+ });
269
+
270
+ test('POST /api/opencode-sessions/new returns 503 plugin_offline when no serve.json', async () => {
271
+ moveServeJsonAside();
272
+ try {
273
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/new`, {
274
+ method: 'POST',
275
+ headers: { 'Content-Type': 'application/json' },
276
+ body: JSON.stringify({ agent: 'odin' }),
277
+ });
278
+ assert.equal(r.status, 503);
279
+ assert.equal((await r.json()).error, 'plugin_offline');
280
+ } finally {
281
+ if (originalServeJson !== undefined)
282
+ writeFileSync(SERVE_JSON_PATH, originalServeJson, 'utf8');
283
+ else writeServeJson('/tmp/session-worktree');
284
+ }
285
+ });
286
+
287
+ test('POST /api/opencode-sessions/new defaults title to "Chat: <agent>"', async () => {
288
+ writeServeJson();
289
+ upstream.nextCreateId = 'sess-defaulted-1';
290
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/new`, {
291
+ method: 'POST',
292
+ headers: { 'Content-Type': 'application/json' },
293
+ body: JSON.stringify({ agent: 'tyr' }),
294
+ });
295
+ assert.equal(r.status, 201);
296
+ const body = await r.json();
297
+ assert.equal(body.title, 'Chat: tyr');
298
+ assert.equal(upstream.createHits[0].body.title, 'Chat: tyr');
299
+ });
300
+
301
+ test('PATCH /api/opencode-sessions/:id renames the session (200)', async () => {
302
+ writeServeJson();
303
+ const r = await fetch(
304
+ `${dashboardBaseUrl}/api/opencode-sessions/sess-1`,
305
+ {
306
+ method: 'PATCH',
307
+ headers: { 'Content-Type': 'application/json' },
308
+ body: JSON.stringify({ title: 'New title' }),
309
+ },
310
+ );
311
+ assert.equal(r.status, 200);
312
+ const body = await r.json();
313
+ assert.equal(body.id, 'sess-1');
314
+ assert.equal(body.title, 'New title');
315
+ assert.equal(upstream.patchHits.length, 1);
316
+ assert.equal(upstream.patchHits[0].body.title, 'New title');
317
+ });
318
+
319
+ test('PATCH /api/opencode-sessions/:id returns 400 when title missing', async () => {
320
+ writeServeJson();
321
+ const r = await fetch(
322
+ `${dashboardBaseUrl}/api/opencode-sessions/sess-1`,
323
+ {
324
+ method: 'PATCH',
325
+ headers: { 'Content-Type': 'application/json' },
326
+ body: JSON.stringify({}),
327
+ },
328
+ );
329
+ assert.equal(r.status, 400);
330
+ });
331
+
332
+ test('PATCH /api/opencode-sessions/:id returns 400 when title is empty', async () => {
333
+ writeServeJson();
334
+ const r = await fetch(
335
+ `${dashboardBaseUrl}/api/opencode-sessions/sess-1`,
336
+ {
337
+ method: 'PATCH',
338
+ headers: { 'Content-Type': 'application/json' },
339
+ body: JSON.stringify({ title: ' ' }),
340
+ },
341
+ );
342
+ assert.equal(r.status, 400);
343
+ });
344
+
345
+ test('PATCH /api/opencode-sessions/:id returns 404 when upstream 404s', async () => {
346
+ writeServeJson();
347
+ const r = await fetch(
348
+ `${dashboardBaseUrl}/api/opencode-sessions/sess-missing`,
349
+ {
350
+ method: 'PATCH',
351
+ headers: { 'Content-Type': 'application/json' },
352
+ body: JSON.stringify({ title: 'whatever' }),
353
+ },
354
+ );
355
+ assert.equal(r.status, 404);
356
+ });
357
+
358
+ test('DELETE /api/opencode-sessions/:id returns 200 with { deleted: true }', async () => {
359
+ writeServeJson();
360
+ const r = await fetch(
361
+ `${dashboardBaseUrl}/api/opencode-sessions/sess-1`,
362
+ { method: 'DELETE' },
363
+ );
364
+ assert.equal(r.status, 200);
365
+ const body = await r.json();
366
+ assert.deepEqual(body, { id: 'sess-1', deleted: true });
367
+ assert.equal(upstream.deleteHits.length, 1);
368
+ });
369
+
370
+ test('DELETE /api/opencode-sessions/:id is idempotent (returns 200 even when upstream 404s)', async () => {
371
+ // Idempotent semantics: a "session already gone" response is the
372
+ // desired terminal state — we don't surface that to the client as
373
+ // an error.
374
+ writeServeJson();
375
+ const r = await fetch(
376
+ `${dashboardBaseUrl}/api/opencode-sessions/sess-gone`,
377
+ { method: 'DELETE' },
378
+ );
379
+ assert.equal(r.status, 200);
380
+ const body = await r.json();
381
+ assert.deepEqual(body, { id: 'sess-gone', deleted: true });
382
+ });
383
+
384
+ test('DELETE /api/opencode-sessions/:id returns 400 when id is invalid', async () => {
385
+ writeServeJson();
386
+ const r = await fetch(
387
+ `${dashboardBaseUrl}/api/opencode-sessions/has space`,
388
+ { method: 'DELETE' },
389
+ );
390
+ assert.equal(r.status, 400);
391
+ });