@polderlabs/bizar 4.7.2 → 4.9.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 (44) hide show
  1. package/bizar-dash/dist/assets/index-DU61awG3.js +9 -0
  2. package/bizar-dash/dist/assets/index-DU61awG3.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-DaC1Lc6q.js +366 -0
  4. package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +1 -0
  5. package/bizar-dash/dist/assets/{main-DX_Jh8Wc.css → main-DfmIfOUS.css} +1 -1
  6. package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js → mobile-CL5uUQEC.js} +1 -1
  7. package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js.map → mobile-CL5uUQEC.js.map} +1 -1
  8. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +338 -0
  9. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +1 -0
  10. package/bizar-dash/dist/index.html +3 -3
  11. package/bizar-dash/dist/mobile.html +2 -2
  12. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  13. package/bizar-dash/src/server/memory-lightrag.mjs +109 -0
  14. package/bizar-dash/src/server/memory-store.mjs +121 -0
  15. package/bizar-dash/src/server/otel.mjs +133 -0
  16. package/bizar-dash/src/server/routes/chat.mjs +246 -170
  17. package/bizar-dash/src/server/routes/memory.mjs +46 -0
  18. package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
  19. package/bizar-dash/src/server/server.mjs +40 -0
  20. package/bizar-dash/src/web/components/SettingsSearch.tsx +204 -89
  21. package/bizar-dash/src/web/lib/search.ts +115 -0
  22. package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
  23. package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
  24. package/bizar-dash/src/web/styles/memory.css +84 -1
  25. package/bizar-dash/src/web/styles/settings.css +80 -0
  26. package/bizar-dash/src/web/views/Memory.tsx +6 -1
  27. package/bizar-dash/src/web/views/Settings.tsx +96 -0
  28. package/bizar-dash/src/web/views/memory/MemoryGraphLegend.tsx +29 -0
  29. package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
  30. package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -0
  31. package/bizar-dash/tests/backup-restore.test.tsx +35 -17
  32. package/bizar-dash/tests/bundle-analysis.test.mjs +70 -0
  33. package/bizar-dash/tests/components/settings-search.test.tsx +180 -0
  34. package/bizar-dash/tests/docker-build.test.mjs +96 -0
  35. package/bizar-dash/tests/lib/search-fuzzy.test.ts +149 -0
  36. package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
  37. package/bizar-dash/tests/memory-graph.test.mjs +95 -0
  38. package/bizar-dash/tests/otel.test.mjs +188 -0
  39. package/cli/commands/dash.mjs +6 -0
  40. package/package.json +7 -1
  41. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +0 -361
  42. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
  43. package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
  44. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
@@ -22,7 +22,9 @@
22
22
  * - Upstream SSE error: clean up and return without crashing.
23
23
  */
24
24
  import { Router } from 'express';
25
+ import { SpanStatusCode } from '@opentelemetry/api';
25
26
  import { warn as logWarn } from '../logger.mjs';
27
+ import { tracer } from '../otel.mjs';
26
28
  import {
27
29
  existsSync,
28
30
  mkdirSync,
@@ -109,11 +111,31 @@ export function createChatRouter({ state, broadcast }) {
109
111
  });
110
112
  router.use(chatLimiter);
111
113
 
114
+ // v4.9.0 — Wrap chat reads in a span. Status flips to ERROR only on
115
+ // thrown errors; a 200 with empty history is still OK. We bind the
116
+ // session id up front so a trace collector can filter by chat
117
+ // session across the SSE pump span opened further downstream.
112
118
  router.get('/chat', wrap(async (req, res) => {
113
- const sessionId = req.query.session ? String(req.query.session) : null;
114
- const requestedLimit = req.query.limit ? Number(req.query.limit) : 200;
115
- const limit = Math.min(500, Math.max(1, Number.isFinite(requestedLimit) ? requestedLimit : 200));
116
- res.json(state.getChat({ sessionId, limit }));
119
+ return tracer.startActiveSpan('chat.history', async (span) => {
120
+ try {
121
+ const sessionId = req.query.session ? String(req.query.session) : null;
122
+ const requestedLimit = req.query.limit ? Number(req.query.limit) : 200;
123
+ const limit = Math.min(
124
+ 500,
125
+ Math.max(1, Number.isFinite(requestedLimit) ? requestedLimit : 200),
126
+ );
127
+ span.setAttribute('chat.session_id', sessionId || '');
128
+ span.setAttribute('chat.history_limit', limit);
129
+ res.json(state.getChat({ sessionId, limit }));
130
+ span.setStatus({ code: SpanStatusCode.OK });
131
+ } catch (err) {
132
+ span.recordException(err);
133
+ span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
134
+ throw err;
135
+ } finally {
136
+ span.end();
137
+ }
138
+ });
117
139
  }));
118
140
 
119
141
  // ── POST /api/chat ─────────────────────────────────────────────────────
@@ -123,186 +145,240 @@ export function createChatRouter({ state, broadcast }) {
123
145
  // 5: Instead of polling, subscribe to SSE and stream deltas via WS.
124
146
  // 6: On session.idle, persist + broadcast final message, return 200.
125
147
  //
148
+ // v4.9.0 — POST /api/chat now runs entirely inside a 'chat.send'
149
+ // span. The span's status is driven by the final HTTP status code
150
+ // (captured via `res.on('finish')`), so every early-return branch —
151
+ // 202 queued fallback, 502 upstream error, 503 subscription cap,
152
+ // 400 missing message — automatically lands on the right span
153
+ // status. The async SSE pump launched at the tail of the handler is
154
+ // intentionally NOT parented to this span: that work continues for
155
+ // seconds after the response flushes, and riding one span for the
156
+ // whole pump would defeat the point of distributed tracing.
126
157
  router.post('/chat', wrap(async (req, res) => {
127
- const body = req.body || {};
128
- const message = typeof body.message === 'string' ? body.message.trim() : '';
129
- if (!message) {
130
- res.status(400).json({ error: 'bad_request', message: 'message is required' });
131
- return;
132
- }
133
- const active = projectsStore.active();
134
-
135
- // 1. Persist the user message to the per-project .jsonl log.
136
- let chatSessionId = null;
137
- let file = null;
138
- let record = null;
139
- if (active) {
140
- const dir = projectsStore.ensureProjectDir(active.id);
141
- const sessionsDir = join(dir, 'sessions');
142
- mkdirSync(sessionsDir, { recursive: true });
143
- const requestedSessionId = typeof body.session === 'string' ? body.session.trim() : '';
144
- chatSessionId = SESSION_ID_RE.test(requestedSessionId)
145
- ? requestedSessionId
146
- : `sess_${Date.now().toString(36)}`;
147
- file = join(sessionsDir, `${chatSessionId}.jsonl`);
148
- record = {
149
- id: `msg_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
150
- ts: new Date().toISOString(),
151
- role: 'user',
152
- agent: body.agent || null,
153
- model: body.model || null,
154
- content: message,
155
- attachments: body.attachments || [],
158
+ return tracer.startActiveSpan('chat.send', async (span) => {
159
+ let spanEnded = false;
160
+ const finishSpan = () => {
161
+ if (spanEnded) return;
162
+ spanEnded = true;
163
+ try {
164
+ const code = res.statusCode || 0;
165
+ if (code >= 400) {
166
+ span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${code}` });
167
+ } else {
168
+ span.setStatus({ code: SpanStatusCode.OK });
169
+ }
170
+ } finally {
171
+ span.end();
172
+ }
156
173
  };
174
+ res.once('finish', finishSpan);
157
175
  try {
158
- appendFileSync(file, JSON.stringify(record) + '\n', 'utf8');
159
- } catch {
160
- // best effort
161
- }
162
- } else {
163
- const requestedSessionId = typeof body.session === 'string' ? body.session.trim() : '';
164
- chatSessionId = SESSION_ID_RE.test(requestedSessionId)
165
- ? requestedSessionId
166
- : `sess_${Date.now().toString(36)}`;
167
- record = {
168
- id: `msg_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
169
- ts: new Date().toISOString(),
170
- role: 'user',
171
- agent: body.agent || null,
172
- model: body.model || null,
173
- content: message,
174
- attachments: body.attachments || [],
175
- };
176
- }
176
+ const body = req.body || {};
177
+ const message = typeof body.message === 'string' ? body.message.trim() : '';
178
+ span.setAttribute('chat.message_length', message.length);
179
+ span.setAttribute('chat.requested_session', typeof body.session === 'string' ? body.session.trim() : '');
180
+ span.setAttribute('chat.requested_agent', typeof body.agent === 'string' ? body.agent : '');
181
+ if (!message) {
182
+ res.status(400).json({ error: 'bad_request', message: 'message is required' });
183
+ return;
184
+ }
185
+ const active = projectsStore.active();
186
+ span.setAttribute('chat.has_active_project', active ? true : false);
187
+
188
+ // 1. Persist the user message to the per-project .jsonl log.
189
+ let chatSessionId = null;
190
+ let file = null;
191
+ let record = null;
192
+ const requestedSessionRaw = typeof body.session === 'string' ? body.session.trim() : '';
193
+ if (active) {
194
+ const dir = projectsStore.ensureProjectDir(active.id);
195
+ const sessionsDir = join(dir, 'sessions');
196
+ mkdirSync(sessionsDir, { recursive: true });
197
+ chatSessionId = SESSION_ID_RE.test(requestedSessionRaw)
198
+ ? requestedSessionRaw
199
+ : `sess_${Date.now().toString(36)}`;
200
+ span.setAttribute('chat.session_id', chatSessionId);
201
+ file = join(sessionsDir, `${chatSessionId}.jsonl`);
202
+ record = {
203
+ id: `msg_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
204
+ ts: new Date().toISOString(),
205
+ role: 'user',
206
+ agent: body.agent || null,
207
+ model: body.model || null,
208
+ content: message,
209
+ attachments: body.attachments || [],
210
+ };
211
+ try {
212
+ appendFileSync(file, JSON.stringify(record) + '\n', 'utf8');
213
+ } catch {
214
+ // best effort
215
+ }
216
+ } else {
217
+ chatSessionId = SESSION_ID_RE.test(requestedSessionRaw)
218
+ ? requestedSessionRaw
219
+ : `sess_${Date.now().toString(36)}`;
220
+ span.setAttribute('chat.session_id', chatSessionId);
221
+ record = {
222
+ id: `msg_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
223
+ ts: new Date().toISOString(),
224
+ role: 'user',
225
+ agent: body.agent || null,
226
+ model: body.model || null,
227
+ content: message,
228
+ attachments: body.attachments || [],
229
+ };
230
+ }
177
231
 
178
- state.appendActivity({
179
- kind: 'chat.message',
180
- agent: body.agent || null,
181
- message: message.slice(0, 500),
182
- });
183
- broadcast({ type: 'chat:message', sessionId: chatSessionId, message: record });
232
+ state.appendActivity({
233
+ kind: 'chat.message',
234
+ agent: body.agent || null,
235
+ message: message.slice(0, 500),
236
+ });
237
+ broadcast({ type: 'chat:message', sessionId: chatSessionId, message: record });
238
+
239
+ // 2. No active project → legacy 202.
240
+ if (!active) {
241
+ res.status(202).json({
242
+ accepted: true,
243
+ agent: body.agent || null,
244
+ queued: true,
245
+ reason: 'no_active_project',
246
+ });
247
+ return;
248
+ }
184
249
 
185
- // 2. No active projectlegacy 202.
186
- if (!active) {
187
- return res.status(202).json({
188
- accepted: true,
189
- agent: body.agent || null,
190
- queued: true,
191
- reason: 'no_active_project',
192
- });
193
- }
250
+ // 3. No plugin runningqueued fallback.
251
+ const serveInfo = readServeInfo();
252
+ if (!serveInfo) {
253
+ res.status(202).json({
254
+ accepted: true,
255
+ agent: body.agent || null,
256
+ queued: true,
257
+ session: chatSessionId,
258
+ reason: 'plugin_offline',
259
+ });
260
+ return;
261
+ }
194
262
 
195
- // 3. No plugin running → queued fallback.
196
- const serveInfo = readServeInfo();
197
- if (!serveInfo) {
198
- return res.status(202).json({
199
- accepted: true,
200
- agent: body.agent || null,
201
- queued: true,
202
- session: chatSessionId,
203
- reason: 'plugin_offline',
204
- });
205
- }
263
+ const sessionsDir = join(projectsStore.ensureProjectDir(active.id), 'sessions');
264
+ const sidecarPath = join(sessionsDir, `${chatSessionId}.opencode.json`);
265
+
266
+ // 4. Resolve or create the opencode session that backs this chat.
267
+ let opencodeSessionId = null;
268
+ try {
269
+ if (existsSync(sidecarPath)) {
270
+ const sidecar = JSON.parse(readFileSync(sidecarPath, 'utf8'));
271
+ opencodeSessionId = sidecar?.opencodeSessionId || null;
272
+ }
273
+ } catch {
274
+ opencodeSessionId = null;
275
+ }
276
+ span.setAttribute('chat.opencode_session_id', opencodeSessionId || '');
277
+
278
+ if (!opencodeSessionId) {
279
+ const agentName = body.agent || active.defaultAgent || 'odin';
280
+ const create = await createOpencodeSession(
281
+ serveInfo,
282
+ { title: `Chat: ${agentName}`, agent: agentName },
283
+ active.path || serveInfo.worktree,
284
+ );
285
+ if (!create.ok || !create.sessionId) {
286
+ res.status(502).json({
287
+ error: 'create_session_failed',
288
+ message: create.error || 'failed to create opencode session',
289
+ session: chatSessionId,
290
+ });
291
+ return;
292
+ }
293
+ opencodeSessionId = create.sessionId;
294
+ try {
295
+ const sidecar = {
296
+ opencodeSessionId,
297
+ agent: agentName,
298
+ createdAt: Date.now(),
299
+ chatSessionId,
300
+ };
301
+ writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2) + '\n', 'utf8');
302
+ } catch {
303
+ // best effort
304
+ }
305
+ }
206
306
 
207
- const sessionsDir = join(projectsStore.ensureProjectDir(active.id), 'sessions');
208
- const sidecarPath = join(sessionsDir, `${chatSessionId}.opencode.json`);
307
+ // 5. POST the prompt.
308
+ const agentName = body.agent || active.defaultAgent || 'odin';
309
+ const send = await sendOpencodePrompt(
310
+ serveInfo,
311
+ {
312
+ sessionId: opencodeSessionId,
313
+ agent: agentName,
314
+ text: message,
315
+ messageID: record.id,
316
+ },
317
+ active.path || serveInfo.worktree,
318
+ );
319
+ if (!send.ok) {
320
+ res.status(502).json({
321
+ error: 'send_prompt_failed',
322
+ message: send.error || 'failed to send prompt to opencode',
323
+ session: chatSessionId,
324
+ opencodeSessionId,
325
+ });
326
+ return;
327
+ }
209
328
 
210
- // 4. Resolve or create the opencode session that backs this chat.
211
- let opencodeSessionId = null;
212
- try {
213
- if (existsSync(sidecarPath)) {
214
- const sidecar = JSON.parse(readFileSync(sidecarPath, 'utf8'));
215
- opencodeSessionId = sidecar?.opencodeSessionId || null;
216
- }
217
- } catch {
218
- opencodeSessionId = null;
219
- }
329
+ // 6. Enforce the concurrent subscription cap.
330
+ if (activeChatSubscriptions >= MAX_CHAT_SUBSCRIPTIONS) {
331
+ res.status(503).json({
332
+ error: 'too_many_subscriptions',
333
+ message: `Chat subscription cap (${MAX_CHAT_SUBSCRIPTIONS}) reached; try again later.`,
334
+ accepted: true,
335
+ session: chatSessionId,
336
+ opencodeSessionId,
337
+ });
338
+ return;
339
+ }
340
+ activeChatSubscriptions++;
220
341
 
221
- if (!opencodeSessionId) {
222
- const agentName = body.agent || active.defaultAgent || 'odin';
223
- const create = await createOpencodeSession(
224
- serveInfo,
225
- { title: `Chat: ${agentName}`, agent: agentName },
226
- active.path || serveInfo.worktree,
227
- );
228
- if (!create.ok || !create.sessionId) {
229
- return res.status(502).json({
230
- error: 'create_session_failed',
231
- message: create.error || 'failed to create opencode session',
342
+ // 7. Return 200 immediately. The SSE subscription streams deltas via WS.
343
+ res.json({
344
+ accepted: true,
232
345
  session: chatSessionId,
346
+ opencodeSessionId,
347
+ userMessage: record,
233
348
  });
234
- }
235
- opencodeSessionId = create.sessionId;
236
- try {
237
- const sidecar = {
349
+
350
+ // 8. Subscribe to opencode SSE and forward deltas via WS broadcast.
351
+ // On session.idle: persist the final message, broadcast chat:message,
352
+ // then decrement the counter.
353
+ void streamOpencodeSession({
354
+ serveInfo,
238
355
  opencodeSessionId,
239
- agent: agentName,
240
- createdAt: Date.now(),
356
+ directory: active.path || serveInfo.worktree,
241
357
  chatSessionId,
242
- };
243
- writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2) + '\n', 'utf8');
244
- } catch {
245
- // best effort
358
+ agentName,
359
+ file,
360
+ record,
361
+ broadcast,
362
+ state,
363
+ onDone: () => {
364
+ activeChatSubscriptions = Math.max(0, activeChatSubscriptions - 1);
365
+ },
366
+ });
367
+ } catch (err) {
368
+ if (!spanEnded) {
369
+ // Errors caught here propagate to `wrap()`, which writes
370
+ // an error JSON response and triggers `res.on('finish')`
371
+ // later. We must end the span NOW (before rethrowing) so
372
+ // finishSpan sees `spanEnded === true` and skips its own
373
+ // end — calling span.end() twice is invalid in the OTel
374
+ // API.
375
+ spanEnded = true;
376
+ span.recordException(err);
377
+ span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
378
+ span.end();
379
+ }
380
+ throw err;
246
381
  }
247
- }
248
-
249
- // 5. POST the prompt.
250
- const agentName = body.agent || active.defaultAgent || 'odin';
251
- const send = await sendOpencodePrompt(
252
- serveInfo,
253
- {
254
- sessionId: opencodeSessionId,
255
- agent: agentName,
256
- text: message,
257
- messageID: record.id,
258
- },
259
- active.path || serveInfo.worktree,
260
- );
261
- if (!send.ok) {
262
- return res.status(502).json({
263
- error: 'send_prompt_failed',
264
- message: send.error || 'failed to send prompt to opencode',
265
- session: chatSessionId,
266
- opencodeSessionId,
267
- });
268
- }
269
-
270
- // 6. Enforce the concurrent subscription cap.
271
- if (activeChatSubscriptions >= MAX_CHAT_SUBSCRIPTIONS) {
272
- return res.status(503).json({
273
- error: 'too_many_subscriptions',
274
- message: `Chat subscription cap (${MAX_CHAT_SUBSCRIPTIONS}) reached; try again later.`,
275
- accepted: true,
276
- session: chatSessionId,
277
- opencodeSessionId,
278
- });
279
- }
280
- activeChatSubscriptions++;
281
-
282
- // 7. Return 200 immediately. The SSE subscription streams deltas via WS.
283
- res.json({
284
- accepted: true,
285
- session: chatSessionId,
286
- opencodeSessionId,
287
- userMessage: record,
288
- });
289
-
290
- // 8. Subscribe to opencode SSE and forward deltas via WS broadcast.
291
- // On session.idle: persist the final message, broadcast chat:message,
292
- // then decrement the counter.
293
- void streamOpencodeSession({
294
- serveInfo,
295
- opencodeSessionId,
296
- directory: active.path || serveInfo.worktree,
297
- chatSessionId,
298
- agentName,
299
- file,
300
- record,
301
- broadcast,
302
- state,
303
- onDone: () => {
304
- activeChatSubscriptions = Math.max(0, activeChatSubscriptions - 1);
305
- },
306
382
  });
307
383
  }));
308
384
 
@@ -1149,6 +1149,52 @@ export function createMemoryRouter({ projectRoot }) {
1149
1149
  }
1150
1150
  }));
1151
1151
 
1152
+ // GET /memory/graph — combined knowledge graph (LightRAG entities + Obsidian wikilinks).
1153
+ // Query params: ?root=<noteId>&depth=2&limit=200
1154
+ router.get('/memory/graph', wrap(async (req, res) => {
1155
+ const root = req.query.root ? String(req.query.root) : null;
1156
+ const depth = Math.min(parseInt(req.query.depth, 10) || 2, 3);
1157
+ const limit = Math.min(parseInt(req.query.limit, 10) || 200, 500);
1158
+
1159
+ try {
1160
+ const lightrag = await getMemoryLightrag();
1161
+ const [lrGraph, obsidianGraph] = await Promise.all([
1162
+ lightrag.getLightRAGGraph({ projectRoot, root, depth, limit }),
1163
+ (async () => {
1164
+ try {
1165
+ const { getObsidianLinkGraph } = memoryStore;
1166
+ return getObsidianLinkGraph({ projectRoot, limit });
1167
+ } catch {
1168
+ return { nodes: [], edges: [] };
1169
+ }
1170
+ })(),
1171
+ ]);
1172
+
1173
+ // Dedupe nodes by id; prefer the lightrag node (has richer type/size).
1174
+ const nodeMap = new Map();
1175
+ for (const n of obsidianGraph.nodes) nodeMap.set(n.id, n);
1176
+ for (const n of lrGraph.nodes) {
1177
+ if (!nodeMap.has(n.id)) nodeMap.set(n.id, n);
1178
+ }
1179
+ const nodes = [...nodeMap.values()].slice(0, limit);
1180
+
1181
+ // Merge edges from both sources; deduplicate by source+target.
1182
+ const edgeSet = new Set();
1183
+ const edges = [];
1184
+ for (const e of [...obsidianGraph.edges, ...lrGraph.edges]) {
1185
+ const key = `${e.source}|${e.target}`;
1186
+ if (!edgeSet.has(key)) {
1187
+ edgeSet.add(key);
1188
+ edges.push(e);
1189
+ }
1190
+ }
1191
+
1192
+ res.json({ nodes, edges, totalNodes: nodes.length, totalEdges: edges.length });
1193
+ } catch (err) {
1194
+ res.status(500).json({ error: 'graph_failed', message: err.message });
1195
+ }
1196
+ }));
1197
+
1152
1198
  // POST /memory/lightrag/reindex — alias of /memory/reindex (the canonical
1153
1199
  // path lives there for backwards compat). Both return the same shape.
1154
1200
  router.post('/memory/lightrag/reindex', wrap(async (_req, res) => {
@@ -26,10 +26,12 @@
26
26
  */
27
27
 
28
28
  import { Router } from 'express';
29
+ import { SpanStatusCode } from '@opentelemetry/api';
29
30
  import Database from 'better-sqlite3';
30
31
  import { join } from 'node:path';
31
32
  import { homedir } from 'node:os';
32
33
  import { wrap } from './_shared.mjs';
34
+ import { tracer } from '../otel.mjs';
33
35
  import {
34
36
  readServeInfo,
35
37
  listOpencodeSessions,
@@ -118,58 +120,90 @@ export function createOpencodeSessionsRouter() {
118
120
  // Returns 503 when the opencode plugin is offline.
119
121
  // ────────────────────────────────────────────────────────────────────
120
122
  router.post('/opencode-sessions/new', wrap(async (req, res) => {
121
- const body = req.body && typeof req.body === 'object' ? req.body : {};
122
- const title = typeof body.title === 'string' && body.title.trim().length > 0
123
- ? body.title.trim().slice(0, TITLE_MAX)
124
- : null;
125
- const agent = typeof body.agent === 'string' ? body.agent.trim() : '';
126
- if (!agent) {
127
- res.status(400).json({ error: 'bad_request', message: '`agent` is required' });
128
- return;
129
- }
130
- if (!AGENT_NAME_RE.test(agent)) {
131
- res.status(400).json({ error: 'bad_request', message: '`agent` is invalid (allowed: [A-Za-z0-9_-]{1,64})' });
132
- return;
133
- }
134
- if (title !== null && title.length > TITLE_MAX) {
135
- res.status(400).json({ error: 'bad_request', message: `title too long (> ${TITLE_MAX} chars)` });
136
- return;
137
- }
123
+ return tracer.startActiveSpan('opencode.session.create', async (span) => {
124
+ let spanEnded = false;
125
+ const finishSpan = () => {
126
+ if (spanEnded) return;
127
+ spanEnded = true;
128
+ try {
129
+ const code = res.statusCode || 0;
130
+ if (code >= 400) {
131
+ span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${code}` });
132
+ } else {
133
+ span.setStatus({ code: SpanStatusCode.OK });
134
+ }
135
+ } finally {
136
+ span.end();
137
+ }
138
+ };
139
+ res.once('finish', finishSpan);
140
+ try {
141
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
142
+ const title = typeof body.title === 'string' && body.title.trim().length > 0
143
+ ? body.title.trim().slice(0, TITLE_MAX)
144
+ : null;
145
+ const agent = typeof body.agent === 'string' ? body.agent.trim() : '';
146
+ span.setAttribute('opencode.session.agent', agent);
147
+ span.setAttribute('opencode.session.title_length', title === null ? 0 : title.length);
148
+ if (!agent) {
149
+ res.status(400).json({ error: 'bad_request', message: '`agent` is required' });
150
+ return;
151
+ }
152
+ if (!AGENT_NAME_RE.test(agent)) {
153
+ res.status(400).json({ error: 'bad_request', message: '`agent` is invalid (allowed: [A-Za-z0-9_-]{1,64})' });
154
+ return;
155
+ }
156
+ if (title !== null && title.length > TITLE_MAX) {
157
+ res.status(400).json({ error: 'bad_request', message: `title too long (> ${TITLE_MAX} chars)` });
158
+ return;
159
+ }
138
160
 
139
- const info = readServeInfo();
140
- if (!info) {
141
- res.status(503).json({
142
- error: 'plugin_offline',
143
- message: 'opencode plugin is not running',
144
- });
145
- return;
146
- }
161
+ const info = readServeInfo();
162
+ if (!info) {
163
+ res.status(503).json({
164
+ error: 'plugin_offline',
165
+ message: 'opencode plugin is not running',
166
+ });
167
+ return;
168
+ }
147
169
 
148
- const directory = typeof body.directory === 'string' && body.directory.length > 0
149
- ? body.directory
150
- : (info.worktree || '');
170
+ const directory = typeof body.directory === 'string' && body.directory.length > 0
171
+ ? body.directory
172
+ : (info.worktree || '');
173
+ span.setAttribute('opencode.session.directory', directory);
151
174
 
152
- const finalTitle = title || `Chat: ${agent}`;
153
- const result = await createOpencodeSession(
154
- info,
155
- { title: finalTitle, agent },
156
- directory,
157
- );
158
- if (!result.ok || !result.sessionId) {
159
- const status = result.status === 404 ? 404 : 502;
160
- res.status(status).json({
161
- error: 'opencode_error',
162
- message: result.error || 'failed to create opencode session',
163
- });
164
- return;
165
- }
175
+ const finalTitle = title || `Chat: ${agent}`;
176
+ const result = await createOpencodeSession(
177
+ info,
178
+ { title: finalTitle, agent },
179
+ directory,
180
+ );
181
+ if (!result.ok || !result.sessionId) {
182
+ const status = result.status === 404 ? 404 : 502;
183
+ res.status(status).json({
184
+ error: 'opencode_error',
185
+ message: result.error || 'failed to create opencode session',
186
+ });
187
+ return;
188
+ }
166
189
 
167
- res.status(201).json({
168
- id: result.sessionId,
169
- title: finalTitle,
170
- agent,
171
- directory,
172
- createdAt: Date.now(),
190
+ span.setAttribute('opencode.session.id', result.sessionId);
191
+ res.status(201).json({
192
+ id: result.sessionId,
193
+ title: finalTitle,
194
+ agent,
195
+ directory,
196
+ createdAt: Date.now(),
197
+ });
198
+ } catch (err) {
199
+ if (!spanEnded) {
200
+ spanEnded = true;
201
+ span.recordException(err);
202
+ span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
203
+ span.end();
204
+ }
205
+ throw err;
206
+ }
173
207
  });
174
208
  }));
175
209