@polderlabs/bizar 4.2.4 → 4.4.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 (63) hide show
  1. package/bizar-dash/src/server/api.mjs +2 -0
  2. package/bizar-dash/src/server/opencode-sdk.mjs +72 -0
  3. package/bizar-dash/src/server/routes/background.mjs +92 -0
  4. package/bizar-dash/src/server/routes/chat.mjs +300 -123
  5. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +452 -0
  6. package/bizar-dash/src/server/routes/tasks.mjs +59 -1
  7. package/bizar-dash/src/server/serve-info.mjs +124 -1
  8. package/bizar-dash/src/server/task-delegator.mjs +154 -8
  9. package/bizar-dash/src/server/tasks-store.mjs +50 -2
  10. package/bizar-dash/src/web/components/background/AttachButton.tsx +96 -0
  11. package/bizar-dash/src/web/components/background/TmuxAttachCard.tsx +122 -0
  12. package/bizar-dash/src/web/components/chat/AgentNode.tsx +127 -0
  13. package/bizar-dash/src/web/components/chat/AgentTree.tsx +80 -0
  14. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +76 -0
  15. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +144 -0
  16. package/bizar-dash/src/web/components/chat/ChatRail.tsx +387 -0
  17. package/bizar-dash/src/web/components/chat/ChatThread.tsx +28 -7
  18. package/bizar-dash/src/web/components/chat/JumpToLatest.tsx +58 -0
  19. package/bizar-dash/src/web/components/chat/MessageBlock.tsx +260 -0
  20. package/bizar-dash/src/web/components/chat/SessionList.tsx +15 -5
  21. package/bizar-dash/src/web/components/chat/SessionRowMenu.tsx +206 -0
  22. package/bizar-dash/src/web/components/chat/StreamingIndicator.tsx +33 -5
  23. package/bizar-dash/src/web/components/chat/_legacy.ts +30 -0
  24. package/bizar-dash/src/web/components/chat/index.ts +11 -0
  25. package/bizar-dash/src/web/components/chat/useChat.ts +426 -62
  26. package/bizar-dash/src/web/components/tasks/BacklogPanel.css +109 -0
  27. package/bizar-dash/src/web/components/tasks/BacklogPanel.tsx +209 -0
  28. package/bizar-dash/src/web/lib/types.ts +1 -0
  29. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +7 -5
  30. package/bizar-dash/src/web/styles/chat.css +1536 -133
  31. package/bizar-dash/src/web/views/BackgroundAgents.tsx +3 -0
  32. package/bizar-dash/src/web/views/Chat.tsx +147 -55
  33. package/bizar-dash/src/web/views/Tasks.tsx +23 -1
  34. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +273 -0
  35. package/cli/bg.mjs +94 -71
  36. package/cli/bin.mjs +19 -7
  37. package/cli/service-controller.mjs +587 -0
  38. package/cli/service-controller.test.mjs +92 -0
  39. package/cli/service.mjs +162 -14
  40. package/config/agents/baldr.md +2 -0
  41. package/config/agents/browser-harness.md +2 -0
  42. package/config/agents/forseti.md +2 -0
  43. package/config/agents/frigg.md +2 -0
  44. package/config/agents/heimdall.md +2 -0
  45. package/config/agents/hermod.md +2 -0
  46. package/config/agents/mimir.md +2 -0
  47. package/config/agents/odin.md +2 -0
  48. package/config/agents/quick.md +2 -0
  49. package/config/agents/semble-search.md +2 -0
  50. package/config/agents/thor.md +2 -0
  51. package/config/agents/tyr.md +2 -0
  52. package/config/agents/vidarr.md +2 -0
  53. package/config/agents/vor.md +2 -0
  54. package/config/opencode.json.template +1 -0
  55. package/install.sh +448 -787
  56. package/package.json +2 -2
  57. package/packages/sdk/package.json +20 -0
  58. package/packages/sdk/src/client.ts +5 -0
  59. package/packages/sdk/src/errors.ts +11 -2
  60. package/packages/sdk/src/index.ts +19 -0
  61. package/packages/sdk/src/opencode-events.ts +134 -0
  62. package/packages/sdk/src/opencode-types.ts +66 -0
  63. package/packages/sdk/src/opencode.ts +335 -0
@@ -21,6 +21,7 @@ import { useModal } from '../components/Modal';
21
21
  import { useToast } from '../components/Toast';
22
22
  import { BgStatusBadge } from '../components/BgStatusBadge';
23
23
  import { openKillConfirmDialog } from '../components/KillConfirmDialog';
24
+ import { TmuxAttachCard } from '../components/background/TmuxAttachCard';
24
25
  import { api } from '../lib/api';
25
26
  import { Ws } from '../lib/ws';
26
27
  import { cn, formatTime, truncate } from '../lib/utils';
@@ -283,6 +284,8 @@ function BackgroundAgentCard({
283
284
  <Trash2 size={14} /> Kill
284
285
  </Button>
285
286
  </div>
287
+
288
+ <TmuxAttachCard instance={inst} />
286
289
  </Card>
287
290
  );
288
291
  }
@@ -1,13 +1,22 @@
1
- // src/views/Chat.tsx — Gemini-inspired dark chat orchestrator.
2
- // Composer state (agent, model, text, attachments) is local.
3
- // useChat handles messages, sessions, send, polling.
1
+ // src/views/Chat.tsx — Gemini-inspired dark chat orchestrator (v3.22).
2
+ //
3
+ // v3.22 redesigned layout: 3-column grid (rail / thread / info).
4
+ // The legacy `.chat-shell / .chat-body / .chat-sessions / .chat-main /
5
+ // .chat-info` wrapper classes are still available for MobileChat (which
6
+ // uses its own view); this file uses the new `.chat-page` grid.
7
+ //
8
+ // The `chat-thread` section is a vertical grid (head / scroll / composer).
9
+ // The scrollable message list lives inside the legacy `ChatThread`
10
+ // component (still wrapped in its own `.chat-thread` div with className
11
+ // `legacy` to opt into the original styles — see chat.css).
4
12
 
5
- import { useEffect, useRef, useState } from 'react';
13
+ import { useEffect, useMemo, useRef, useState } from 'react';
6
14
  import { ChatTopBar } from '../components/chat/ChatTopBar';
7
- import { SessionList } from '../components/chat/SessionList';
15
+ import { ChatRail } from '../components/chat/ChatRail';
8
16
  import { ChatThread } from '../components/chat/ChatThread';
9
- import { FloatingComposer } from '../components/chat/FloatingComposer';
10
- import { InfoPanel } from '../components/chat/InfoPanel';
17
+ import { ChatComposer } from '../components/chat/ChatComposer';
18
+ import { ChatInfoPanel } from '../components/chat/ChatInfoPanel';
19
+ import { JumpToLatest } from '../components/chat/JumpToLatest';
11
20
  import { useChat } from '../components/chat/useChat';
12
21
  import { useSlashCommands } from '../components/chat/useSlashCommands';
13
22
  import { useToast } from '../components/Toast';
@@ -28,7 +37,6 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
28
37
  const toast = useToast();
29
38
  const modal = useModal();
30
39
 
31
- // ── Delegate to useChat: messages, sessions, send, polling ──────────────
32
40
  const chat = useChat(snapshot, settings, initialTaskId ?? '');
33
41
 
34
42
  // ── Composer state (local) ───────────────────────────────────────────────
@@ -39,12 +47,13 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
39
47
  const [creating, setCreating] = useState(false);
40
48
 
41
49
  const fileInputRef = useRef<HTMLInputElement>(null);
42
- const inputRef = useRef<HTMLTextAreaElement>(null);
43
50
 
44
51
  // ── Slash commands ───────────────────────────────────────────────────────
45
52
  const { allCommands, suggestions, setQuery } = useSlashCommands(snapshot);
46
53
 
47
- useEffect(() => { setQuery(text); }, [text, setQuery]);
54
+ useEffect(() => {
55
+ setQuery(text);
56
+ }, [text, setQuery]);
48
57
 
49
58
  // ── File attach ──────────────────────────────────────────────────────────
50
59
  const onAttach = () => fileInputRef.current?.click();
@@ -68,6 +77,7 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
68
77
  setText('');
69
78
  setQuery('');
70
79
  chat.onSend(msg, agent, model, attachments);
80
+ chat.jumpToLatest();
71
81
  };
72
82
 
73
83
  // ── Create session ───────────────────────────────────────────────────────
@@ -96,7 +106,9 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
96
106
  children: <p style={{ margin: 0 }}>This action cannot be undone.</p>,
97
107
  footer: (
98
108
  <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
99
- <Button variant="secondary" size="sm" onClick={() => modal.close()}>Cancel</Button>
109
+ <Button variant="secondary" size="sm" onClick={() => modal.close()}>
110
+ Cancel
111
+ </Button>
100
112
  <Button
101
113
  variant="danger"
102
114
  size="sm"
@@ -112,7 +124,42 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
112
124
  });
113
125
  };
114
126
 
115
- // ── Pin / copy are handled directly via chat.* ──────────────────────────
127
+ // ── Derived: sessions with display state overlaid ────────────────────────
128
+ const displaySessions = useMemo(
129
+ () => chat.sessions.map((s) => chat.getSessionDisplay(s)),
130
+ [chat.sessions, chat.getSessionDisplay],
131
+ );
132
+ const displayOpencodeSessions = useMemo(
133
+ () => chat.opencodeSessions.map((s) => chat.getSessionDisplay(s)),
134
+ [chat.opencodeSessions, chat.getSessionDisplay],
135
+ );
136
+
137
+ // ── Current session for the thread head subtitle ────────────────────────
138
+ const activeSessionDisplay = useMemo(() => {
139
+ const id = chat.activeSource === 'opencode'
140
+ ? chat.activeOpencodeSessionId ?? ''
141
+ : chat.sessionId;
142
+ if (!id) return null;
143
+ return (
144
+ displaySessions.find((s) => s.id === id) ??
145
+ displayOpencodeSessions.find((s) => s.id === id) ??
146
+ null
147
+ );
148
+ }, [
149
+ chat.activeSource,
150
+ chat.activeOpencodeSessionId,
151
+ chat.sessionId,
152
+ displaySessions,
153
+ displayOpencodeSessions,
154
+ ]);
155
+
156
+ const threadSubtitle = (() => {
157
+ const base = `${agent || 'Odin'} · ${snapshot.activeProject?.name ?? 'no project'}`;
158
+ const state = activeSessionDisplay?.state ?? 'idle';
159
+ if (state === 'streaming') return `Replying · ${base}`;
160
+ if (state === 'awaiting') return `Your turn · ${base}`;
161
+ return `${base} · idle`;
162
+ })();
116
163
 
117
164
  return (
118
165
  <div className="chat-shell">
@@ -125,33 +172,71 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
125
172
  onToggleInfo={() => {}}
126
173
  onOpenOverview={() => setActiveTab?.('overview')}
127
174
  />
128
- <div className="chat-body">
129
- <aside className="chat-sessions">
130
- <SessionList
131
- sessions={chat.sessions}
132
- opencodeSessions={chat.opencodeSessions}
133
- activeSessionId={chat.sessionId}
134
- activeProject={snapshot.activeProject}
135
- onCreateSession={handleCreateSession}
136
- onSelectSession={chat.loadChat}
137
- creating={creating}
138
- />
139
- </aside>
140
-
141
- <main className="chat-main">
142
- <ChatThread
143
- messages={chat.messages}
144
- loading={chat.loading}
145
- activeProject={snapshot.activeProject}
146
- sessionId={chat.sessionId}
147
- pinned={chat.pinned}
148
- onPickSuggestion={(t) => setText(t)}
149
- onCopy={(m) => chat.copyMessage(m as Parameters<typeof chat.copyMessage>[0])}
150
- onDelete={handleDelete}
151
- onTogglePin={chat.togglePin}
152
- onRegenerate={chat.onRegenerate}
153
- />
154
- <FloatingComposer
175
+ <div className="chat-page">
176
+ {/* ── rail ──────────────────────────────────────────────────────── */}
177
+ <ChatRail
178
+ sessions={displaySessions}
179
+ opencodeSessions={displayOpencodeSessions}
180
+ activeSessionId={chat.sessionId}
181
+ activeOpencodeSessionId={chat.activeOpencodeSessionId}
182
+ activeProject={snapshot.activeProject}
183
+ creating={creating}
184
+ onCreateSession={handleCreateSession}
185
+ onSelectSession={chat.selectBizarSession}
186
+ onSelectOpencodeSession={(s) => chat.loadOpencodeSession(s.id)}
187
+ onRenameSession={(id, title) => chat.renameSession(id, title)}
188
+ onDeleteSession={(id) => chat.deleteSession(id)}
189
+ />
190
+
191
+ {/* ── thread ───────────────────────────────────────────────────── */}
192
+ <section className="chat-thread-section">
193
+ <div className="chat-thread-head">
194
+ <div>
195
+ <div className="chat-thread-title">
196
+ {activeSessionDisplay?.title ?? chat.sessionId ?? 'New chat'}
197
+ </div>
198
+ <div
199
+ className={`chat-thread-sub chat-muted state-${activeSessionDisplay?.state ?? 'idle'}`}
200
+ >
201
+ <span className="chat-thread-dot" />
202
+ {threadSubtitle}
203
+ </div>
204
+ </div>
205
+ <div className="chat-thread-actions">
206
+ <button className="btn btn-ghost" title="Rename" type="button">
207
+ <span className="mono">rename</span>
208
+ </button>
209
+ </div>
210
+ </div>
211
+
212
+ <div className="chat-thread-scroll" ref={chat.listRef} onScroll={chat.handleScroll}>
213
+ <ChatThread
214
+ messages={chat.activeSource === 'opencode' ? chat.opencodeMessages : chat.bizarMessages}
215
+ loading={chat.loading}
216
+ activeProject={snapshot.activeProject}
217
+ sessionId={
218
+ chat.activeSource === 'opencode'
219
+ ? (chat.activeOpencodeSessionId ?? chat.sessionId)
220
+ : chat.sessionId
221
+ }
222
+ pinned={chat.pinned}
223
+ onPickSuggestion={(t) => setText(t)}
224
+ onCopy={(m) => chat.copyMessage(m as Parameters<typeof chat.copyMessage>[0])}
225
+ onDelete={handleDelete}
226
+ onTogglePin={chat.togglePin}
227
+ onRegenerate={chat.onRegenerate}
228
+ />
229
+ </div>
230
+
231
+ {!chat.stickToBottom && (
232
+ <JumpToLatest
233
+ streaming={activeSessionDisplay?.state === 'streaming'}
234
+ newMessageCount={chat.newMessageCount}
235
+ onClick={chat.jumpToLatest}
236
+ />
237
+ )}
238
+
239
+ <ChatComposer
155
240
  agent={agent}
156
241
  setAgent={setAgent}
157
242
  model={model}
@@ -166,24 +251,31 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
166
251
  onPickSuggestion={(cmd) => setText(`${cmd.split(' ')[0]} `)}
167
252
  agents={snapshot.agents || []}
168
253
  onAttach={onAttach}
169
- sessionsOpen={true}
170
- infoOpen={true}
171
254
  />
172
- <input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={onFiles} />
173
- </main>
174
-
175
- <aside className="chat-info">
176
- <InfoPanel
177
- sessionId={chat.sessionId}
178
- messages={chat.messages}
179
- pinned={chat.pinned}
180
- agent={agent}
181
- model={model}
182
- agents={snapshot.agents || []}
183
- mcps={snapshot.mcps || []}
184
- allCommands={allCommands}
255
+ <input
256
+ ref={fileInputRef}
257
+ type="file"
258
+ multiple
259
+ style={{ display: 'none' }}
260
+ onChange={onFiles}
185
261
  />
186
- </aside>
262
+ </section>
263
+
264
+ {/* ── info panel ──────────────────────────────────────────────── */}
265
+ <ChatInfoPanel
266
+ sessionId={
267
+ chat.activeSource === 'opencode'
268
+ ? (chat.activeOpencodeSessionId ?? chat.sessionId)
269
+ : chat.sessionId
270
+ }
271
+ messages={chat.activeSource === 'opencode' ? chat.opencodeMessages : chat.bizarMessages}
272
+ pinned={chat.pinned}
273
+ agent={agent}
274
+ model={model}
275
+ agents={snapshot.agents || []}
276
+ mcps={snapshot.mcps || []}
277
+ allCommands={allCommands}
278
+ />
187
279
  </div>
188
280
  </div>
189
281
  );
@@ -30,6 +30,7 @@ import {
30
30
  RefreshCw,
31
31
  Send,
32
32
  FileText,
33
+ Inbox,
33
34
  } from 'lucide-react';
34
35
  import { Button } from '../components/Button';
35
36
  import { Card, CardTitle } from '../components/Card';
@@ -45,6 +46,7 @@ import { api, enhancePrompt } from '../lib/api';
45
46
  import { cn, formatRelative, priorityColors, autoTitleFromContent } from '../lib/utils';
46
47
  import type { Agent, Settings, Snapshot, Task } from '../lib/types';
47
48
  import { openArtifactViewer } from '../components/ArtifactViewer';
49
+ import { BacklogPanel } from '../components/tasks/BacklogPanel';
48
50
 
49
51
  type Props = {
50
52
  snapshot: Snapshot;
@@ -319,6 +321,10 @@ export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
319
321
  openArtifactViewer(modal, artifactId);
320
322
  };
321
323
 
324
+ // v3.22 — Backlog panel visibility.
325
+ const [showBacklog, setShowBacklog] = useState(false);
326
+ const backlogCount = (snapshot.tasks || []).filter((t) => t.status === 'backlog').length;
327
+
322
328
  return (
323
329
  <div className="view view-tasks">
324
330
  <header className="view-header">
@@ -447,9 +453,25 @@ export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
447
453
  >
448
454
  <Plus size={14} /> Add
449
455
  </Button>
456
+ {backlogCount > 0 && (
457
+ <Button
458
+ variant={showBacklog ? 'accent' : 'ghost'}
459
+ size="sm"
460
+ onClick={() => setShowBacklog((v) => !v)}
461
+ title={showBacklog ? 'Hide backlog' : 'Show backlog'}
462
+ >
463
+ <Inbox size={14} />
464
+ Backlog
465
+ <span className="badge">{backlogCount}</span>
466
+ </Button>
467
+ )}
450
468
  </div>
451
469
  </div>
452
470
 
471
+ {showBacklog && backlogCount > 0 && (
472
+ <BacklogPanel agents={snapshot.agents || []} onRefresh={refreshSnapshot} />
473
+ )}
474
+
453
475
  {selected.size > 0 && (
454
476
  <div className="task-bulk-bar">
455
477
  <span className="text-sm">
@@ -900,7 +922,7 @@ function formatDuration(seconds: number): string {
900
922
  return `${m}m`;
901
923
  }
902
924
 
903
- function openTaskModal(
925
+ export function openTaskModal(
904
926
  modal: ReturnType<typeof useModal>,
905
927
  toast: ReturnType<typeof useToast>,
906
928
  task: Task | null,
@@ -0,0 +1,273 @@
1
+ /**
2
+ * tests/opencode-sessions-detail.test.mjs — v4.2.4
3
+ *
4
+ * Tests the opencode session-detail router (GET messages, POST send,
5
+ * GET stream SSE proxy). Strategy: stand up a fake opencode serve child
6
+ * on a random port, point readServeInfo() at it by writing a tmp
7
+ * serve.json to the first candidate path (~/.cache/bizar/serve.json).
8
+ * For the "plugin offline" case, move our tmp serve.json aside so
9
+ * readServeInfo() finds nothing.
10
+ *
11
+ * We restore the original serve.json (if any) in `after()`.
12
+ */
13
+
14
+ import { test, before, after, beforeEach } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import {
17
+ mkdtempSync,
18
+ writeFileSync,
19
+ rmSync,
20
+ existsSync,
21
+ renameSync,
22
+ readFileSync,
23
+ mkdirSync,
24
+ } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { tmpdir, homedir } from 'node:os';
27
+ import { createServer } from 'node:http';
28
+ import express from 'express';
29
+
30
+ import { createOpencodeSessionDetailRouter } from '../src/server/routes/opencode-session-detail.mjs';
31
+
32
+ // ── fake upstream (opencode serve child) ──────────────────────────────────
33
+
34
+ let upstreamServer, upstreamPort;
35
+ const upstream = { promptHits: [], lastPromptBody: null, streamChunks: [] };
36
+
37
+ function startUpstream() {
38
+ return new Promise((resolve) => {
39
+ upstreamServer = createServer((req, res) => {
40
+ const url = new URL(req.url, 'http://127.0.0.1');
41
+ if (url.pathname === '/event') {
42
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
43
+ for (const c of upstream.streamChunks) res.write(c);
44
+ setTimeout(() => res.end(), 50);
45
+ return;
46
+ }
47
+ const promptM = url.pathname.match(/^\/api\/session\/([^/]+)\/prompt$/);
48
+ if (promptM && req.method === 'POST') {
49
+ let body = '';
50
+ req.on('data', (c) => { body += c; });
51
+ req.on('end', () => {
52
+ upstream.promptHits.push({ sessionId: promptM[1], body });
53
+ try { upstream.lastPromptBody = JSON.parse(body); } catch { /* ignore */ }
54
+ if (promptM[1] === 'sess-fail') {
55
+ res.writeHead(404, { 'Content-Type': 'application/json' }); res.end('not found'); return;
56
+ }
57
+ res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{}');
58
+ });
59
+ return;
60
+ }
61
+ if (url.pathname === '/api/session' && req.method === 'GET') {
62
+ res.writeHead(200, { 'Content-Type': 'application/json' });
63
+ res.end(JSON.stringify({ data: [{ id: 'sess-1', title: 'Test', time: { created: 1, updated: 2, archived: null }, location: { directory: '/tmp/session-worktree' } }] }));
64
+ return;
65
+ }
66
+ const msgM = url.pathname.match(/^\/api\/session\/([^/]+)\/message$/);
67
+ if (msgM && req.method === 'GET') {
68
+ res.writeHead(200, { 'Content-Type': 'application/json' });
69
+ res.end(JSON.stringify({ data: [
70
+ { info: { id: 'm1', role: 'user', time: { created: 1000 } }, parts: [{ type: 'text', text: 'hello' }] },
71
+ { info: { id: 'm2', role: 'assistant', time: { created: 2000 } }, parts: [{ type: 'text', text: 'hi' }] },
72
+ ] }));
73
+ return;
74
+ }
75
+ res.writeHead(404); res.end('not found');
76
+ });
77
+ upstreamServer.listen(0, '127.0.0.1', () => {
78
+ upstreamPort = upstreamServer.address().port;
79
+ resolve();
80
+ });
81
+ });
82
+ }
83
+
84
+ async function startDashboard() {
85
+ const app = express();
86
+ app.use(express.json({ limit: '2mb' }));
87
+ app.use('/api', createOpencodeSessionDetailRouter());
88
+ dashboardServer = createServer(app);
89
+ await new Promise((r) => dashboardServer.listen(0, '127.0.0.1', r));
90
+ dashboardBaseUrl = `http://127.0.0.1:${dashboardServer.address().port}`;
91
+ }
92
+
93
+ let dashboardServer, dashboardBaseUrl, tmpDir;
94
+ const SERVE_JSON_PATH = join(homedir(), '.cache', 'bizar', 'serve.json');
95
+ let originalServeJson;
96
+
97
+ before(async () => {
98
+ await startUpstream();
99
+ await startDashboard();
100
+ tmpDir = mkdtempSync(join(tmpdir(), 'opencode-sessions-detail-'));
101
+ // Snapshot any existing serve.json so we can restore it.
102
+ if (existsSync(SERVE_JSON_PATH)) {
103
+ originalServeJson = readFileSync(SERVE_JSON_PATH, 'utf8');
104
+ }
105
+ mkdirSync(join(homedir(), '.cache', 'bizar'), { recursive: true });
106
+ });
107
+
108
+ 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
+ if (originalServeJson !== undefined) {
112
+ writeFileSync(SERVE_JSON_PATH, originalServeJson, 'utf8');
113
+ } else if (existsSync(SERVE_JSON_PATH)) {
114
+ rmSync(SERVE_JSON_PATH);
115
+ }
116
+ if (dashboardServer) await new Promise((r) => dashboardServer.close(r));
117
+ if (upstreamServer) await new Promise((r) => upstreamServer.close(r));
118
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
119
+ });
120
+
121
+ beforeEach(() => {
122
+ upstream.promptHits = [];
123
+ upstream.lastPromptBody = null;
124
+ upstream.streamChunks = [];
125
+ });
126
+
127
+ function writeServeJson(worktree) {
128
+ writeFileSync(SERVE_JSON_PATH, JSON.stringify({ port: upstreamPort, password: 'test-pw', worktree, pid: 99999, startedAt: Date.now() }), 'utf8');
129
+ }
130
+
131
+ /** Move serve.json aside so readServeInfo() returns null. */
132
+ function moveServeJsonAside() {
133
+ if (!existsSync(SERVE_JSON_PATH)) return;
134
+ const tmp = join(tmpDir, `serve-aside-${Date.now()}.json`);
135
+ renameSync(SERVE_JSON_PATH, tmp);
136
+ }
137
+
138
+ function parseSseStream(text) {
139
+ const out = [];
140
+ let event = null, dataLines = [];
141
+ for (const line of text.split(/\r?\n/)) {
142
+ if (line === '') {
143
+ if (event !== null || dataLines.length > 0) out.push({ event, data: dataLines.join('\n') });
144
+ event = null; dataLines = [];
145
+ continue;
146
+ }
147
+ if (line.startsWith(':')) continue;
148
+ const colon = line.indexOf(':');
149
+ if (colon < 0) continue;
150
+ const field = line.slice(0, colon);
151
+ let value = line.slice(colon + 1);
152
+ if (value.startsWith(' ')) value = value.slice(1);
153
+ if (field === 'event') event = value;
154
+ else if (field === 'data') dataLines.push(value);
155
+ }
156
+ return out;
157
+ }
158
+
159
+ // ── tests ─────────────────────────────────────────────────────────────────
160
+
161
+ test('GET messages returns 503 plugin_offline when no serve.json', async () => {
162
+ moveServeJsonAside();
163
+ try {
164
+ const res = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/messages`);
165
+ assert.equal(res.status, 503);
166
+ assert.equal((await res.json()).error, 'plugin_offline');
167
+ } finally {
168
+ // Restore — the next test wants serve.json present again.
169
+ if (originalServeJson !== undefined) writeServeJson(JSON.parse(originalServeJson).worktree);
170
+ }
171
+ });
172
+
173
+ test('GET messages returns 200 with normalized message list', async () => {
174
+ writeServeJson('/tmp/session-worktree');
175
+ const res = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/messages`);
176
+ assert.equal(res.status, 200);
177
+ const body = await res.json();
178
+ assert.equal(body.messages.length, 2);
179
+ assert.equal(body.messages[0].id, 'm1');
180
+ assert.equal(body.messages[0].role, 'user');
181
+ assert.equal(body.messages[0].content, 'hello');
182
+ assert.equal(body.messages[0].ts, 1000);
183
+ assert.equal(body.messages[1].role, 'assistant');
184
+ assert.equal(body.messages[1].content, 'hi');
185
+ assert.equal(body.messages[1].ts, 2000);
186
+ });
187
+
188
+ test('POST send returns 400 when body is empty', async () => {
189
+ writeServeJson('/tmp/session-worktree');
190
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/send`, {
191
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
192
+ });
193
+ assert.equal(r.status, 400);
194
+ });
195
+
196
+ test('POST send returns 400 when agent is missing', async () => {
197
+ writeServeJson('/tmp/session-worktree');
198
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/send`, {
199
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'hi' }),
200
+ });
201
+ assert.equal(r.status, 400);
202
+ });
203
+
204
+ test('POST send returns 400 when message is empty string', async () => {
205
+ writeServeJson('/tmp/session-worktree');
206
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/send`, {
207
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: '', agent: 'odin' }),
208
+ });
209
+ assert.equal(r.status, 400);
210
+ });
211
+
212
+ test('POST send returns ok=true with synthesized messageId', async () => {
213
+ writeServeJson('/tmp/session-worktree');
214
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/send`, {
215
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'hello there', agent: 'odin' }),
216
+ });
217
+ assert.equal(r.status, 200);
218
+ const body = await r.json();
219
+ assert.equal(body.ok, true);
220
+ assert.ok(body.messageId.startsWith('msg_'));
221
+ assert.equal(upstream.promptHits.length, 1);
222
+ assert.equal(upstream.lastPromptBody.prompt.text, 'hello there');
223
+ assert.equal(upstream.lastPromptBody.agent, 'odin');
224
+ assert.equal(upstream.lastPromptBody.id, body.messageId);
225
+ });
226
+
227
+ test('POST send returns 502 opencode_error when upstream 404s', async () => {
228
+ writeServeJson('/tmp/session-worktree');
229
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-fail/send`, {
230
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'hi', agent: 'odin' }),
231
+ });
232
+ assert.equal(r.status, 502);
233
+ const body = await r.json();
234
+ assert.equal(body.error, 'opencode_error');
235
+ assert.ok(body.message.length > 0);
236
+ });
237
+
238
+ test('SSE stream forwards only events for the requested session', async () => {
239
+ writeServeJson('/tmp/session-worktree');
240
+ upstream.streamChunks = [
241
+ 'event: message.updated\ndata: {"type":"message.updated","properties":{"sessionID":"our-id","messageID":"m1"}}\n\n',
242
+ 'event: message.updated\ndata: {"type":"message.updated","properties":{"sessionID":"OTHER-id","messageID":"m9"}}\n\n',
243
+ 'event: sync\ndata: {"type":"sync","syncEvent":{"type":"session.idle.1","data":{"sessionID":"our-id"}}}\n\n',
244
+ ];
245
+ const res = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`);
246
+ assert.equal(res.status, 200);
247
+ assert.match(res.headers.get('content-type'), /text\/event-stream/);
248
+ assert.match(res.headers.get('cache-control'), /no-cache/);
249
+ assert.equal(res.headers.get('x-accel-buffering'), 'no');
250
+ const events = parseSseStream(await res.text());
251
+ assert.equal(events.length, 2);
252
+ assert.equal(events[0].event, 'message.updated');
253
+ const d0 = JSON.parse(events[0].data);
254
+ assert.equal(d0.sessionID, 'our-id');
255
+ assert.equal(d0.messageID, 'm1');
256
+ assert.equal(events[1].event, 'session.idle');
257
+ assert.equal(JSON.parse(events[1].data).sessionID, 'our-id');
258
+ });
259
+
260
+ test('SSE stream unwraps sync envelopes and strips version suffix', async () => {
261
+ writeServeJson('/tmp/session-worktree');
262
+ upstream.streamChunks = [
263
+ 'event: sync\ndata: {"type":"sync","syncEvent":{"type":"message.updated.1","data":{"sessionID":"our-id","messageID":"m3"}}}\n\n',
264
+ ];
265
+ const res = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`);
266
+ assert.equal(res.status, 200);
267
+ const events = parseSseStream(await res.text());
268
+ assert.equal(events.length, 1);
269
+ assert.equal(events[0].event, 'message.updated');
270
+ const d = JSON.parse(events[0].data);
271
+ assert.equal(d.sessionID, 'our-id');
272
+ assert.equal(d.messageID, 'm3');
273
+ });