@polderlabs/bizar 4.3.0 → 4.4.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 (57) hide show
  1. package/bizar-dash/src/server/opencode-sdk.mjs +72 -0
  2. package/bizar-dash/src/server/routes/background.mjs +92 -0
  3. package/bizar-dash/src/server/routes/chat.mjs +300 -123
  4. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +26 -0
  5. package/bizar-dash/src/server/routes/tasks.mjs +59 -1
  6. package/bizar-dash/src/server/task-delegator.mjs +154 -8
  7. package/bizar-dash/src/server/tasks-store.mjs +50 -2
  8. package/bizar-dash/src/web/components/background/AttachButton.tsx +96 -0
  9. package/bizar-dash/src/web/components/background/TmuxAttachCard.tsx +122 -0
  10. package/bizar-dash/src/web/components/chat/AgentNode.tsx +127 -0
  11. package/bizar-dash/src/web/components/chat/AgentTree.tsx +80 -0
  12. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +76 -0
  13. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +144 -0
  14. package/bizar-dash/src/web/components/chat/ChatRail.tsx +387 -0
  15. package/bizar-dash/src/web/components/chat/ChatThread.tsx +28 -7
  16. package/bizar-dash/src/web/components/chat/JumpToLatest.tsx +58 -0
  17. package/bizar-dash/src/web/components/chat/MessageBlock.tsx +260 -0
  18. package/bizar-dash/src/web/components/chat/SessionRowMenu.tsx +206 -0
  19. package/bizar-dash/src/web/components/chat/StreamingIndicator.tsx +33 -5
  20. package/bizar-dash/src/web/components/chat/_legacy.ts +30 -0
  21. package/bizar-dash/src/web/components/chat/index.ts +11 -0
  22. package/bizar-dash/src/web/components/chat/useChat.ts +345 -167
  23. package/bizar-dash/src/web/components/tasks/BacklogPanel.css +109 -0
  24. package/bizar-dash/src/web/components/tasks/BacklogPanel.tsx +209 -0
  25. package/bizar-dash/src/web/styles/chat.css +1536 -133
  26. package/bizar-dash/src/web/views/BackgroundAgents.tsx +3 -0
  27. package/bizar-dash/src/web/views/Chat.tsx +147 -57
  28. package/bizar-dash/src/web/views/Tasks.tsx +23 -1
  29. package/cli/bg.mjs +94 -71
  30. package/cli/bin.mjs +36 -8
  31. package/cli/service-controller.mjs +587 -0
  32. package/cli/service-controller.test.mjs +92 -0
  33. package/cli/service.mjs +162 -14
  34. package/config/agents/baldr.md +2 -0
  35. package/config/agents/browser-harness.md +2 -0
  36. package/config/agents/forseti.md +2 -0
  37. package/config/agents/frigg.md +2 -0
  38. package/config/agents/heimdall.md +2 -0
  39. package/config/agents/hermod.md +2 -0
  40. package/config/agents/mimir.md +2 -0
  41. package/config/agents/odin.md +2 -0
  42. package/config/agents/quick.md +2 -0
  43. package/config/agents/semble-search.md +2 -0
  44. package/config/agents/thor.md +2 -0
  45. package/config/agents/tyr.md +2 -0
  46. package/config/agents/vidarr.md +2 -0
  47. package/config/agents/vor.md +2 -0
  48. package/config/opencode.json.template +1 -0
  49. package/install.sh +448 -787
  50. package/package.json +2 -2
  51. package/packages/sdk/package.json +20 -0
  52. package/packages/sdk/src/client.ts +5 -0
  53. package/packages/sdk/src/errors.ts +11 -2
  54. package/packages/sdk/src/index.ts +19 -0
  55. package/packages/sdk/src/opencode-events.ts +134 -0
  56. package/packages/sdk/src/opencode-types.ts +66 -0
  57. package/packages/sdk/src/opencode.ts +335 -0
@@ -7,11 +7,19 @@
7
7
  * /api/chat/sessions (POST) — create a new session
8
8
  * /api/chat/regenerate (POST) — re-dispatch the last user message
9
9
  *
10
- * The POST /api/chat handler is the most complex endpoint in the
11
- * codebase: it persists the user message, mints an opencode session
12
- * if needed, posts the prompt, polls up to 90s for an assistant
13
- * reply, and persists + broadcasts the result. On plugin offline,
14
- * it falls back to a "queued" 202 response.
10
+ * v0.1.0 POST /api/chat now streams tokens in real time via SSE.
11
+ * Instead of polling for up to 90s, the handler:
12
+ * 1. Persists the user message + resolves/creates the opencode session (unchanged).
13
+ * 2. Returns 200 immediately with `{ accepted, session, opencodeSessionId }`.
14
+ * 3. Subscribes to opencode's SSE `/event?directory=…` filtered by sessionID.
15
+ * 4. Streams `message.part.updated` deltas over the WebSocket as `chat:delta` envelopes.
16
+ * 5. On `session.idle`, persists the final assistant message and broadcasts
17
+ * `chat:message`, then closes the subscription.
18
+ *
19
+ * Failure modes:
20
+ * - No active project: broadcast the message, return 202 "queued" (unchanged).
21
+ * - No serve-info: same queued fallback (unchanged).
22
+ * - Upstream SSE error: clean up and return without crashing.
15
23
  */
16
24
  import { Router } from 'express';
17
25
  import {
@@ -31,11 +39,20 @@ import {
31
39
  sendOpencodePrompt,
32
40
  listOpencodeMessages,
33
41
  extractContentFromOpencodeMessage,
42
+ unwrapOpencodeSseEvent,
43
+ buildAuthHeader,
34
44
  } from '../serve-info.mjs';
35
45
  import { wrap } from './_shared.mjs';
36
46
 
37
47
  const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,120}$/;
38
48
 
49
+ /**
50
+ * Maximum concurrent SSE subscriptions for chat streaming.
51
+ * Mirrors the cap in opencode-session-detail.mjs.
52
+ */
53
+ const MAX_CHAT_SUBSCRIPTIONS = 50;
54
+ let activeChatSubscriptions = 0;
55
+
39
56
  /**
40
57
  * @param {object} deps
41
58
  * @param {object} deps.state
@@ -52,33 +69,13 @@ export function createChatRouter({ state, broadcast }) {
52
69
  res.json(state.getChat({ sessionId, limit }));
53
70
  }));
54
71
 
55
- // v3.5.5 POST /api/chat now actually invokes the agent.
72
+ // ── POST /api/chat ─────────────────────────────────────────────────────
56
73
  //
57
- // Flow:
58
- // 1. Persist the user message to the per-project .jsonl log (same
59
- // as before keeps the chat history intact even when the plugin
60
- // is offline).
61
- // 2. Resolve a chat session id (use the body-provided one, or mint
62
- // a new `sess_*`).
63
- // 3. Look up (or create) the opencode session id that backs this
64
- // chat session — stored in a sidecar file at
65
- // `<sessions>/<chatSessionId>.opencode.json`.
66
- // 4. POST the prompt to the opencode session via the plugin's
67
- // opencode serve child.
68
- // 5. Poll for the assistant response (up to 90s) and persist it.
69
- // 6. Broadcast both messages on the WS `chat:message` channel.
74
+ // Flow (changes from v0.1.0):
75
+ // 1-4: unchanged (persist user message, resolve/opencode session, POST prompt).
76
+ // 5: Instead of polling, subscribe to SSE and stream deltas via WS.
77
+ // 6: On session.idle, persist + broadcast final message, return 200.
70
78
  //
71
- // Failure modes:
72
- // - No active project: we still broadcast the message but skip
73
- // persistence and return 202 (legacy behavior).
74
- // - No serve-info: we fall back to the legacy "queued" path —
75
- // the user message is persisted and broadcast but no agent
76
- // invocation happens. The client can poll GET /api/chat for an
77
- // eventual reply if the plugin comes up.
78
- // - createSession / sendPrompt failure: 502 with the underlying
79
- // error, the user message is still persisted.
80
- // - Polling timeout: 202 with `timeout: true` so the client knows
81
- // the agent is still working and can poll again.
82
79
  router.post('/chat', wrap(async (req, res) => {
83
80
  const body = req.body || {};
84
81
  const message = typeof body.message === 'string' ? body.message.trim() : '';
@@ -89,8 +86,6 @@ export function createChatRouter({ state, broadcast }) {
89
86
  const active = projectsStore.active();
90
87
 
91
88
  // 1. Persist the user message to the per-project .jsonl log.
92
- // (No-op when no project is active — the legacy fallback path
93
- // only broadcast.)
94
89
  let chatSessionId = null;
95
90
  let file = null;
96
91
  let record = null;
@@ -118,7 +113,6 @@ export function createChatRouter({ state, broadcast }) {
118
113
  // best effort
119
114
  }
120
115
  } else {
121
- // No project — synthesize an id so the response shape is consistent.
122
116
  const requestedSessionId = typeof body.session === 'string' ? body.session.trim() : '';
123
117
  chatSessionId = SESSION_ID_RE.test(requestedSessionId)
124
118
  ? requestedSessionId
@@ -141,7 +135,7 @@ export function createChatRouter({ state, broadcast }) {
141
135
  });
142
136
  broadcast({ type: 'chat:message', sessionId: chatSessionId, message: record });
143
137
 
144
- // 2. No active project → legacy 202. Don't try to dispatch.
138
+ // 2. No active project → legacy 202.
145
139
  if (!active) {
146
140
  return res.status(202).json({
147
141
  accepted: true,
@@ -151,9 +145,7 @@ export function createChatRouter({ state, broadcast }) {
151
145
  });
152
146
  }
153
147
 
154
- // 3. No plugin running → fall back to queued. The user message is
155
- // already persisted + broadcast; the next time the plugin comes
156
- // up the user can re-send or POST /api/chat/regenerate.
148
+ // 3. No plugin running → queued fallback.
157
149
  const serveInfo = readServeInfo();
158
150
  if (!serveInfo) {
159
151
  return res.status(202).json({
@@ -207,9 +199,7 @@ export function createChatRouter({ state, broadcast }) {
207
199
  }
208
200
  }
209
201
 
210
- // 5. POST the prompt. We use the opencode session id as the
211
- // messageID — the polling loop below uses it to detect the
212
- // "before vs after" boundary.
202
+ // 5. POST the prompt.
213
203
  const agentName = body.agent || active.defaultAgent || 'odin';
214
204
  const send = await sendOpencodePrompt(
215
205
  serveInfo,
@@ -230,86 +220,42 @@ export function createChatRouter({ state, broadcast }) {
230
220
  });
231
221
  }
232
222
 
233
- // 6. Poll for the assistant response. We look for the LAST
234
- // assistant message; if its creation time is greater than the
235
- // user's messageID timestamp, we consider it our reply. This
236
- // is the same heuristic the plugin's own event stream uses.
237
- const promptSentAt = Date.now();
238
- const deadline = promptSentAt + 90_000;
239
- let assistantRecord = null;
240
- let lastSeenAssistantId = null;
241
- while (Date.now() < deadline) {
242
- await new Promise((r) => setTimeout(r, 2_000));
243
- const list = await listOpencodeMessages(
244
- serveInfo,
245
- opencodeSessionId,
246
- active.path || serveInfo.worktree,
247
- );
248
- if (!list.ok || !Array.isArray(list.messages) || list.messages.length === 0) {
249
- continue;
250
- }
251
- // Walk newest → oldest; pick the first assistant message that
252
- // was created at or after promptSentAt.
253
- const assistants = list.messages
254
- .filter((m) => (m?.info?.role || m?.role) === 'assistant')
255
- .sort((a, b) => {
256
- const ta = a?.info?.time?.created || 0;
257
- const tb = b?.info?.time?.created || 0;
258
- return tb - ta;
259
- });
260
- for (const m of assistants) {
261
- const created = m?.info?.time?.created || 0;
262
- if (created >= promptSentAt - 1_000) {
263
- const id = m?.info?.id || '';
264
- if (id && id === lastSeenAssistantId) continue;
265
- lastSeenAssistantId = id;
266
- assistantRecord = {
267
- id: id || `asst_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
268
- ts: new Date(created || Date.now()).toISOString(),
269
- role: 'assistant',
270
- agent: agentName,
271
- content: extractContentFromOpencodeMessage(m),
272
- opencodeSessionId,
273
- inReplyTo: record.id,
274
- };
275
- break;
276
- }
277
- }
278
- if (assistantRecord) break;
279
- }
280
-
281
- if (assistantRecord) {
282
- // 7. Persist + broadcast the assistant message.
283
- try {
284
- appendFileSync(file, JSON.stringify(assistantRecord) + '\n', 'utf8');
285
- } catch {
286
- // best effort
287
- }
288
- broadcast({ type: 'chat:message', sessionId: chatSessionId, message: assistantRecord });
289
- state.appendActivity({
290
- kind: 'chat.response',
291
- agent: agentName,
292
- message: (assistantRecord.content || '').slice(0, 500),
293
- });
294
- return res.json({
223
+ // 6. Enforce the concurrent subscription cap.
224
+ if (activeChatSubscriptions >= MAX_CHAT_SUBSCRIPTIONS) {
225
+ return res.status(503).json({
226
+ error: 'too_many_subscriptions',
227
+ message: `Chat subscription cap (${MAX_CHAT_SUBSCRIPTIONS}) reached; try again later.`,
295
228
  accepted: true,
296
229
  session: chatSessionId,
297
230
  opencodeSessionId,
298
- userMessage: record,
299
- assistantMessage: assistantRecord,
300
231
  });
301
232
  }
233
+ activeChatSubscriptions++;
302
234
 
303
- // 8. Timeout the agent is still working. The next poll of GET
304
- // /api/chat?session=… will pick up whatever has arrived by
305
- // then. Return 202 so the client knows nothing failed.
306
- return res.status(202).json({
235
+ // 7. Return 200 immediately. The SSE subscription streams deltas via WS.
236
+ res.json({
307
237
  accepted: true,
308
238
  session: chatSessionId,
309
239
  opencodeSessionId,
310
240
  userMessage: record,
311
- queued: true,
312
- timeout: true,
241
+ });
242
+
243
+ // 8. Subscribe to opencode SSE and forward deltas via WS broadcast.
244
+ // On session.idle: persist the final message, broadcast chat:message,
245
+ // then decrement the counter.
246
+ void streamOpencodeSession({
247
+ serveInfo,
248
+ opencodeSessionId,
249
+ directory: active.path || serveInfo.worktree,
250
+ chatSessionId,
251
+ agentName,
252
+ file,
253
+ record,
254
+ broadcast,
255
+ state,
256
+ onDone: () => {
257
+ activeChatSubscriptions = Math.max(0, activeChatSubscriptions - 1);
258
+ },
313
259
  });
314
260
  }));
315
261
 
@@ -334,9 +280,6 @@ export function createChatRouter({ state, broadcast }) {
334
280
  res.json({ sessions });
335
281
  }));
336
282
 
337
- // v3.0.4 — Create a new chat session. Generates an id, ensures the
338
- // sessions dir + empty .jsonl file exist, and returns the session
339
- // metadata. Idempotent: if the session already exists, returns it.
340
283
  router.post('/chat/sessions', wrap(async (req, res) => {
341
284
  const active = projectsStore.active();
342
285
  if (!active) {
@@ -363,9 +306,6 @@ export function createChatRouter({ state, broadcast }) {
363
306
  });
364
307
  }));
365
308
 
366
- // ── /api/chat/regenerate ─────────────────────────────────────────────
367
- // v3.0.0: re-dispatches the last user message before messageId via POST /chat.
368
- // Full opencode re-dispatch lands in v3.1 when the plugin exposes a stable HTTP API.
369
309
  router.post('/chat/regenerate', wrap(async (req, res) => {
370
310
  const { sessionId, messageId } = req.body || {};
371
311
  if (!messageId) {
@@ -389,7 +329,6 @@ export function createChatRouter({ state, broadcast }) {
389
329
  res.status(404).json({ error: 'not_found', message: 'session not found' });
390
330
  return;
391
331
  }
392
- // Read the session file and find the last user message before messageId
393
332
  const full = join(sessionsDir, targetFiles[0]);
394
333
  let lastUserMessage = null;
395
334
  let foundTarget = false;
@@ -421,7 +360,6 @@ export function createChatRouter({ state, broadcast }) {
421
360
  res.status(500).json({ error: 'read_failed', message: err.message });
422
361
  return;
423
362
  }
424
- // Fallback: find last user message
425
363
  if (!lastUserMessage) {
426
364
  try {
427
365
  const lines = readFileSync(full, 'utf8').split(/\r?\n/).filter(Boolean).reverse();
@@ -444,7 +382,6 @@ export function createChatRouter({ state, broadcast }) {
444
382
  res.status(404).json({ error: 'not_found', message: 'no user message found to regenerate' });
445
383
  return;
446
384
  }
447
- // Re-post via POST /chat (queued for agent processing)
448
385
  const record = {
449
386
  ts: new Date().toISOString(),
450
387
  role: 'user',
@@ -469,10 +406,6 @@ export function createChatRouter({ state, broadcast }) {
469
406
  res.status(202).json({ accepted: true, regeneratedMessage: record });
470
407
  }));
471
408
 
472
- // S3 — /chat/audit: thin endpoint that AuditDialog.tsx calls.
473
- // The heavy lifting is the existing `/audit` slash command routed to
474
- // forseti via opencode.json.template. This keeps the dialog functional
475
- // without re-implementing audit logic.
476
409
  router.post('/chat/audit', wrap(async (req, res) => {
477
410
  res.json({
478
411
  ok: true,
@@ -483,3 +416,247 @@ export function createChatRouter({ state, broadcast }) {
483
416
 
484
417
  return router;
485
418
  }
419
+
420
+ // ── SSE streaming helper ──────────────────────────────────────────────────────
421
+
422
+ /**
423
+ * Subscribe to opencode's SSE stream for a session, forward deltas via WS
424
+ * broadcast, and persist the final assistant message on idle.
425
+ *
426
+ * @param {object} opts
427
+ * @param {import('../serve-info.mjs').ServeInfo} opts.serveInfo
428
+ * @param {string} opts.opencodeSessionId
429
+ * @param {string} opts.directory
430
+ * @param {string} opts.chatSessionId
431
+ * @param {string} opts.agentName
432
+ * @param {string|null} opts.file .jsonl path for persistence
433
+ * @param {object} opts.record the user message record
434
+ * @param {Function} opts.broadcast
435
+ * @param {object} opts.state
436
+ * @param {Function} opts.onDone called when the stream ends (for counter cleanup)
437
+ */
438
+ async function streamOpencodeSession({
439
+ serveInfo,
440
+ opencodeSessionId,
441
+ directory,
442
+ chatSessionId,
443
+ agentName,
444
+ file,
445
+ record,
446
+ broadcast,
447
+ state,
448
+ onDone,
449
+ }) {
450
+ const upstreamUrl = `${serveInfo.baseUrl}/event?directory=${encodeURIComponent(directory || '')}`;
451
+ const auth = buildAuthHeader(serveInfo);
452
+ const controller = new AbortController();
453
+
454
+ let upstream;
455
+ try {
456
+ upstream = fetch(upstreamUrl, {
457
+ method: 'GET',
458
+ headers: {
459
+ Accept: 'text/event-stream',
460
+ Authorization: auth,
461
+ },
462
+ signal: controller.signal,
463
+ });
464
+ } catch (err) {
465
+ const msg = err instanceof Error ? err.message : String(err);
466
+ broadcast({
467
+ type: 'chat:error',
468
+ sessionId: chatSessionId,
469
+ error: `upstream_open_failed: ${msg}`,
470
+ });
471
+ onDone();
472
+ return;
473
+ }
474
+
475
+ let assistantRecord = null;
476
+ let done = false;
477
+
478
+ void (async () => {
479
+ try {
480
+ const r = await upstream;
481
+ if (!r.ok || !r.body) {
482
+ const msg = `upstream_status: ${r.status}`;
483
+ broadcast({ type: 'chat:error', sessionId: chatSessionId, error: msg });
484
+ onDone();
485
+ return;
486
+ }
487
+ await pumpSseForChat(r.body, controller, opencodeSessionId, {
488
+ onDelta(envelope) {
489
+ // Forward text part deltas as chat:delta
490
+ const textDelta = extractTextDelta(envelope);
491
+ if (textDelta) {
492
+ broadcast({
493
+ type: 'chat:delta',
494
+ sessionId: chatSessionId,
495
+ delta: textDelta.delta,
496
+ type: 'text',
497
+ messageId: envelope.messageID || null,
498
+ });
499
+ }
500
+ },
501
+ onIdle(envelope) {
502
+ if (done) return;
503
+ done = true;
504
+ // Fetch the final message list and extract the assistant reply.
505
+ void (async () => {
506
+ try {
507
+ const list = await listOpencodeMessages(
508
+ serveInfo,
509
+ opencodeSessionId,
510
+ directory,
511
+ );
512
+ if (list?.ok && Array.isArray(list.messages)) {
513
+ const promptSentAt = Date.now() - 5_000; // buffer for clock skew
514
+ const assistants = list.messages
515
+ .filter((m) => (m?.info?.role || m?.role) === 'assistant')
516
+ .sort((a, b) => {
517
+ const ta = a?.info?.time?.created || 0;
518
+ const tb = b?.info?.time?.created || 0;
519
+ return tb - ta;
520
+ });
521
+ for (const m of assistants) {
522
+ const created = m?.info?.time?.created || 0;
523
+ if (created >= promptSentAt) {
524
+ const id = m?.info?.id || '';
525
+ assistantRecord = {
526
+ id: id || `asst_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
527
+ ts: new Date(created || Date.now()).toISOString(),
528
+ role: 'assistant',
529
+ agent: agentName,
530
+ content: extractContentFromOpencodeMessage(m),
531
+ opencodeSessionId,
532
+ inReplyTo: record.id,
533
+ };
534
+ break;
535
+ }
536
+ }
537
+ }
538
+ } catch {
539
+ // best effort
540
+ }
541
+
542
+ if (assistantRecord) {
543
+ try {
544
+ if (file) appendFileSync(file, JSON.stringify(assistantRecord) + '\n', 'utf8');
545
+ } catch {
546
+ // best effort
547
+ }
548
+ broadcast({ type: 'chat:message', sessionId: chatSessionId, message: assistantRecord });
549
+ state.appendActivity({
550
+ kind: 'chat.response',
551
+ agent: agentName,
552
+ message: (assistantRecord.content || '').slice(0, 500),
553
+ });
554
+ }
555
+ onDone();
556
+ })();
557
+ },
558
+ });
559
+ } catch (err) {
560
+ if (controller.signal.aborted) return;
561
+ const msg = err instanceof Error ? err.message : String(err);
562
+ broadcast({ type: 'chat:error', sessionId: chatSessionId, error: `stream_error: ${msg}` });
563
+ onDone();
564
+ } finally {
565
+ if (!done) {
566
+ done = true;
567
+ onDone();
568
+ }
569
+ }
570
+ })();
571
+ }
572
+
573
+ /**
574
+ * Pump an opencode SSE stream, filtering by sessionID and dispatching to
575
+ * the appropriate callback.
576
+ *
577
+ * @param {ReadableStream<Uint8Array>} body
578
+ * @param {AbortController} controller
579
+ * @param {string} sessionId
580
+ * @param {{ onDelta: Function, onIdle: Function }} handlers
581
+ */
582
+ async function pumpSseForChat(body, controller, sessionId, handlers) {
583
+ const reader = body.getReader();
584
+ const decoder = new TextDecoder('utf-8');
585
+ let buffer = '';
586
+ try {
587
+ while (true) {
588
+ const { value, done } = await reader.read();
589
+ if (done) break;
590
+ if (value && value.byteLength > 0) {
591
+ buffer += decoder.decode(value, { stream: true });
592
+ }
593
+ let sep;
594
+ while ((sep = buffer.indexOf('\n\n')) >= 0 || (sep = buffer.indexOf('\r\n\r\n')) >= 0) {
595
+ const isCRLF = buffer[sep] === '\r';
596
+ const block = buffer.slice(0, sep);
597
+ buffer = buffer.slice(sep + (isCRLF ? 4 : 2));
598
+ handleChatSseBlock(block, sessionId, handlers);
599
+ }
600
+ }
601
+ } finally {
602
+ try { reader.releaseLock(); } catch { /* ignore */ }
603
+ }
604
+ }
605
+
606
+ /**
607
+ * Parse one SSE block and dispatch to onDelta or onIdle.
608
+ *
609
+ * @param {string} block
610
+ * @param {string} sessionId
611
+ * @param {{ onDelta: Function, onIdle: Function }} handlers
612
+ */
613
+ function handleChatSseBlock(block, sessionId, handlers) {
614
+ if (!block || block.trim() === '') return;
615
+ let eventName = null;
616
+ const dataLines = [];
617
+ for (const line of block.split(/\r?\n/)) {
618
+ if (line === '' || line.startsWith(':')) continue;
619
+ const colon = line.indexOf(':');
620
+ if (colon < 0) continue;
621
+ const field = line.slice(0, colon);
622
+ let value = line.slice(colon + 1);
623
+ if (value.startsWith(' ')) value = value.slice(1);
624
+ if (field === 'event') eventName = value;
625
+ else if (field === 'data') dataLines.push(value);
626
+ }
627
+ if (dataLines.length === 0) return;
628
+ const raw = dataLines.join('\n');
629
+ let parsed;
630
+ try {
631
+ parsed = JSON.parse(raw);
632
+ } catch {
633
+ return;
634
+ }
635
+ const evt = unwrapOpencodeSseEvent(eventName, parsed);
636
+ if (!evt || !evt.type) return;
637
+ if (evt.sessionID && evt.sessionID !== sessionId) return;
638
+
639
+ if (evt.type === 'message.part.updated') {
640
+ handlers.onDelta(evt);
641
+ } else if (evt.type === 'session.idle') {
642
+ handlers.onIdle(evt);
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Extract a text delta from an opencode SSE event envelope.
648
+ * Returns null if no text delta is present.
649
+ *
650
+ * @param {object} envelope from unwrapOpencodeSseEvent
651
+ * @returns {{ delta: string } | null}
652
+ */
653
+ function extractTextDelta(envelope) {
654
+ const part = envelope.part;
655
+ if (!part || typeof part !== 'object') return null;
656
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
657
+ const p = /** @type {any} */ (part);
658
+ if (p.type === 'text' && typeof p.text === 'string') {
659
+ return { delta: p.text };
660
+ }
661
+ return null;
662
+ }
@@ -420,6 +420,32 @@ function handleSseBlock(block, res, sessionId) {
420
420
  try {
421
421
  res.write(`event: ${evt.type}\n`);
422
422
  res.write(`data: ${payload}\n\n`);
423
+
424
+ // v0.1.0 — also emit `chat:delta` / `chat:status` envelopes when
425
+ // a text part delta or session.idle arrives. These are consumed by
426
+ // dashboard components that listen to the same SSE stream.
427
+ if (evt.type === 'message.part.updated') {
428
+ const part = evt.part;
429
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
430
+ const p = /** @type {any} */ (part);
431
+ if (p && p.type === 'text' && typeof p.text === 'string') {
432
+ const deltaPayload = JSON.stringify({
433
+ sessionId: evt.sessionID,
434
+ messageId: evt.messageID || undefined,
435
+ delta: p.text,
436
+ type: 'text',
437
+ });
438
+ res.write(`event: chat:delta\n`);
439
+ res.write(`data: ${deltaPayload}\n\n`);
440
+ }
441
+ } else if (evt.type === 'session.idle') {
442
+ const statusPayload = JSON.stringify({
443
+ sessionId: evt.sessionID,
444
+ status: 'idle',
445
+ });
446
+ res.write(`event: chat:status\n`);
447
+ res.write(`data: ${statusPayload}\n\n`);
448
+ }
423
449
  } catch {
424
450
  /* socket closed mid-write */
425
451
  }
@@ -39,6 +39,7 @@ import {
39
39
  normalizeOpencodeMessage,
40
40
  } from '../serve-info.mjs';
41
41
  import { readActiveProjectId, wrap } from './_shared.mjs';
42
+ import { ALLOWED_TASK_STATUSES } from '../tasks-store.mjs';
42
43
 
43
44
  /**
44
45
  * @param {object} deps
@@ -153,7 +154,7 @@ export function createTasksRouter({ state, broadcast, projectRoot }) {
153
154
  router.patch('/tasks/:id/status', wrap(async (req, res) => {
154
155
  const projectId = req.body?.projectId || readActiveProjectId();
155
156
  const { status } = req.body || {};
156
- if (!['queued', 'doing', 'done', 'blocked', 'archived'].includes(status)) {
157
+ if (!ALLOWED_TASK_STATUSES.includes(status)) {
157
158
  res.status(400).json({ error: 'bad_request', message: 'invalid status' });
158
159
  return;
159
160
  }
@@ -464,5 +465,62 @@ export function createTasksRouter({ state, broadcast, projectRoot }) {
464
465
  res.json(task);
465
466
  }));
466
467
 
468
+ // v3.22 — Backlog routes. GET /tasks/backlog is declared before
469
+ // /tasks/:id so the literal "backlog" segment is not captured as :id.
470
+ router.get('/tasks/backlog', wrap(async (req, res) => {
471
+ const projectId = req.query.projectId || readActiveProjectId();
472
+ const tasks = tasksStore.listBacklog(projectId);
473
+ res.json({ tasks });
474
+ }));
475
+
476
+ router.post('/tasks/:id/promote', wrap(async (req, res) => {
477
+ const projectId = req.body?.projectId || readActiveProjectId();
478
+ const task = await tasksStore.getById(projectId, req.params.id);
479
+ if (!task) {
480
+ res.status(404).json({ error: 'not_found' });
481
+ return;
482
+ }
483
+ if (task.status !== 'backlog') {
484
+ res.status(409).json({ error: 'not_backlog', message: `task is '${task.status}', must be 'backlog' to promote` });
485
+ return;
486
+ }
487
+ const updated = await tasksStore.promote(projectId, req.params.id);
488
+ broadcast({ type: 'tasks:change', task: updated });
489
+ res.json({ ok: true, task: updated });
490
+ }));
491
+
492
+ router.post('/tasks/:id/demote', wrap(async (req, res) => {
493
+ const projectId = req.body?.projectId || readActiveProjectId();
494
+ const task = await tasksStore.getById(projectId, req.params.id);
495
+ if (!task) {
496
+ res.status(404).json({ error: 'not_found' });
497
+ return;
498
+ }
499
+ if (task.status !== 'queued') {
500
+ res.status(409).json({ error: 'not_queued', message: `task is '${task.status}', must be 'queued' to demote` });
501
+ return;
502
+ }
503
+ const updated = await tasksStore.demote(projectId, req.params.id);
504
+ broadcast({ type: 'tasks:change', task: updated });
505
+ res.json({ ok: true, task: updated });
506
+ }));
507
+
508
+ router.post('/tasks/promote-batch', wrap(async (req, res) => {
509
+ const projectId = req.body?.projectId || readActiveProjectId();
510
+ const { ids } = req.body || {};
511
+ if (!Array.isArray(ids) || ids.length === 0) {
512
+ res.status(400).json({ error: 'bad_request', message: 'ids[] required' });
513
+ return;
514
+ }
515
+ const result = await tasksStore.promoteBatch(projectId, ids);
516
+ for (const r of result.affected) {
517
+ if (!r.ok) continue;
518
+ const all = await tasksStore.loadTasks(projectId, { includeArchived: false });
519
+ const t = all.find((x) => x.id === r.id);
520
+ if (t) broadcast({ type: 'tasks:change', task: t });
521
+ }
522
+ res.json(result);
523
+ }));
524
+
467
525
  return router;
468
526
  }