@polderlabs/bizar 4.3.0 → 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.
- package/bizar-dash/src/server/opencode-sdk.mjs +72 -0
- package/bizar-dash/src/server/routes/background.mjs +92 -0
- package/bizar-dash/src/server/routes/chat.mjs +300 -123
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +26 -0
- package/bizar-dash/src/server/routes/tasks.mjs +59 -1
- package/bizar-dash/src/server/task-delegator.mjs +154 -8
- package/bizar-dash/src/server/tasks-store.mjs +50 -2
- package/bizar-dash/src/web/components/background/AttachButton.tsx +96 -0
- package/bizar-dash/src/web/components/background/TmuxAttachCard.tsx +122 -0
- package/bizar-dash/src/web/components/chat/AgentNode.tsx +127 -0
- package/bizar-dash/src/web/components/chat/AgentTree.tsx +80 -0
- package/bizar-dash/src/web/components/chat/ChatComposer.tsx +76 -0
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +144 -0
- package/bizar-dash/src/web/components/chat/ChatRail.tsx +387 -0
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +28 -7
- package/bizar-dash/src/web/components/chat/JumpToLatest.tsx +58 -0
- package/bizar-dash/src/web/components/chat/MessageBlock.tsx +260 -0
- package/bizar-dash/src/web/components/chat/SessionRowMenu.tsx +206 -0
- package/bizar-dash/src/web/components/chat/StreamingIndicator.tsx +33 -5
- package/bizar-dash/src/web/components/chat/_legacy.ts +30 -0
- package/bizar-dash/src/web/components/chat/index.ts +11 -0
- package/bizar-dash/src/web/components/chat/useChat.ts +345 -167
- package/bizar-dash/src/web/components/tasks/BacklogPanel.css +109 -0
- package/bizar-dash/src/web/components/tasks/BacklogPanel.tsx +209 -0
- package/bizar-dash/src/web/styles/chat.css +1536 -133
- package/bizar-dash/src/web/views/BackgroundAgents.tsx +3 -0
- package/bizar-dash/src/web/views/Chat.tsx +147 -57
- package/bizar-dash/src/web/views/Tasks.tsx +23 -1
- package/cli/bg.mjs +94 -71
- package/cli/bin.mjs +19 -7
- package/cli/service-controller.mjs +587 -0
- package/cli/service-controller.test.mjs +92 -0
- package/cli/service.mjs +162 -14
- package/config/agents/baldr.md +2 -0
- package/config/agents/browser-harness.md +2 -0
- package/config/agents/forseti.md +2 -0
- package/config/agents/frigg.md +2 -0
- package/config/agents/heimdall.md +2 -0
- package/config/agents/hermod.md +2 -0
- package/config/agents/mimir.md +2 -0
- package/config/agents/odin.md +2 -0
- package/config/agents/quick.md +2 -0
- package/config/agents/semble-search.md +2 -0
- package/config/agents/thor.md +2 -0
- package/config/agents/tyr.md +2 -0
- package/config/agents/vidarr.md +2 -0
- package/config/agents/vor.md +2 -0
- package/config/opencode.json.template +1 -0
- package/install.sh +448 -787
- package/package.json +1 -1
- package/packages/sdk/package.json +20 -0
- package/packages/sdk/src/client.ts +5 -0
- package/packages/sdk/src/errors.ts +11 -2
- package/packages/sdk/src/index.ts +19 -0
- package/packages/sdk/src/opencode-events.ts +134 -0
- package/packages/sdk/src/opencode-types.ts +66 -0
- 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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
//
|
|
72
|
+
// ── POST /api/chat ─────────────────────────────────────────────────────
|
|
56
73
|
//
|
|
57
|
-
// Flow:
|
|
58
|
-
// 1
|
|
59
|
-
//
|
|
60
|
-
//
|
|
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.
|
|
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 →
|
|
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.
|
|
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.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
//
|
|
304
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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 (!
|
|
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
|
}
|