@messenger-agent/claude-agent 0.24.0-alpha.2

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.
@@ -0,0 +1,1229 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
+ import { Hono } from "hono";
4
+ import { HTTPException } from "hono/http-exception";
5
+ import { zValidator } from "@hono/zod-validator";
6
+ import { streamSSE } from "hono/streaming";
7
+ import { claudePlatformInstructions } from "../platform-instructions.js";
8
+ import { logger } from "@messenger-agent/shared/logger";
9
+ import { appConfig } from "../config.js";
10
+ import { resolveAgentWorkdir, resolveAgentWorkspaceRoot } from "@messenger-agent/shared/agent-workspaces";
11
+ import { isManagedTaskToolName } from "@messenger-agent/shared/managed-task-tools";
12
+ import { saveUploadedFilePart } from "@messenger-agent/shared/uploaded-files";
13
+ import { answerPendingApproval, answerPendingAsk, deletePendingClientTool, findPendingClientToolByConversation, listPendingClientTools, loadPendingClientTool, savePendingAsk, savePendingApproval, updatePendingAskConversation, waitPendingApproval, waitPendingAsk, } from "../pending-asks.js";
14
+ import { AgentAnswerToolPartSchema, ChatBodySchema, } from "../schemas.js";
15
+ import { createTaskAggregator } from "../task-aggregator.js";
16
+ import { createManagedTaskMcpServer, MANAGED_TASK_MCP_SERVER_NAME } from "../managed-task-mcp.js";
17
+ import { claudeContextSnapshot, claudeTokenUsage, } from "../token-usage.js";
18
+ import { SessionManager } from "../session-manager.js";
19
+ import { getLlmProxyBinding, moveLlmProxyBinding, setLlmProxyBinding } from "../llm-proxy-bindings.js";
20
+ const chat = new Hono();
21
+ const sessionManager = new SessionManager(() => appConfig.claude.sessionIdleTimeoutMs ?? 3_600_000);
22
+ const activeStreams = new Set();
23
+ function isManagedTaskTool(toolName) {
24
+ if (isManagedTaskToolName(toolName))
25
+ return true;
26
+ const prefix = `mcp__${MANAGED_TASK_MCP_SERVER_NAME}__`;
27
+ return toolName.startsWith(prefix) && isManagedTaskToolName(toolName.slice(prefix.length));
28
+ }
29
+ function managedTaskStructuredOutput(toolUseResult) {
30
+ if (!toolUseResult || typeof toolUseResult !== "object" || Array.isArray(toolUseResult))
31
+ return undefined;
32
+ const structuredContent = toolUseResult.structuredContent;
33
+ return structuredContent && typeof structuredContent === "object" && !Array.isArray(structuredContent)
34
+ ? structuredContent
35
+ : undefined;
36
+ }
37
+ export function claudeActivitySnapshot() {
38
+ const waitingConversations = new Set(listPendingClientTools().map((record) => record.conversationId));
39
+ return { active: activeStreams.size, waiting: waitingConversations.size };
40
+ }
41
+ export class ChatRequestError extends Error {
42
+ status;
43
+ constructor(status, message) {
44
+ super(message);
45
+ this.status = status;
46
+ this.name = "ChatRequestError";
47
+ }
48
+ }
49
+ const liveTurns = new Map();
50
+ function resolveAgentMode(value) {
51
+ return value?.trim().toLowerCase() === "plan" ? "plan" : "default";
52
+ }
53
+ function claudePermissionOptions(agentMode) {
54
+ if (agentMode === "plan") {
55
+ return { permissionMode: "plan" };
56
+ }
57
+ return { permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true };
58
+ }
59
+ function conversationNotFound(conversationId) {
60
+ return {
61
+ type: "not_found_error",
62
+ code: "conversation_not_found",
63
+ message: `Conversation not found or already completed: ${conversationId}`,
64
+ param: "conversation_id",
65
+ };
66
+ }
67
+ function isRecord(value) {
68
+ return Boolean(value && typeof value === "object");
69
+ }
70
+ function isUploadedFilePart(part) {
71
+ return (part.type === "file" &&
72
+ "mediaType" in part &&
73
+ typeof part.mediaType === "string" &&
74
+ "url" in part &&
75
+ typeof part.url === "string" &&
76
+ (!("filename" in part) || part.filename === undefined || typeof part.filename === "string"));
77
+ }
78
+ async function partSummary(part) {
79
+ if (part.type === "text" && typeof part.text === "string")
80
+ return part.text;
81
+ if (isUploadedFilePart(part)) {
82
+ return saveUploadedFilePart(part, appConfig.fileUploads.tempDir);
83
+ }
84
+ if (part.type === "step-start")
85
+ return undefined;
86
+ throw new HTTPException(400, { message: `Unsupported message part type: ${part.type}` });
87
+ }
88
+ async function messageToPrompt(parts) {
89
+ const summaries = await Promise.all(parts.map(partSummary));
90
+ const prompt = summaries
91
+ .filter((text) => Boolean(text && text.trim().length > 0))
92
+ .join("\n")
93
+ .trim();
94
+ if (!prompt) {
95
+ throw new HTTPException(400, { message: "No supported message content found" });
96
+ }
97
+ return prompt;
98
+ }
99
+ function clientToolAnswer(parts) {
100
+ for (const part of parts) {
101
+ const parsed = AgentAnswerToolPartSchema.safeParse(part);
102
+ if (parsed.success)
103
+ return parsed.data;
104
+ }
105
+ return undefined;
106
+ }
107
+ function answerText(output) {
108
+ if (typeof output === "string")
109
+ return output;
110
+ if (isRecord(output)) {
111
+ if (typeof output.answer === "string")
112
+ return output.answer;
113
+ if (typeof output.text === "string")
114
+ return output.text;
115
+ if (isRecord(output.answers)) {
116
+ return Object.values(output.answers)
117
+ .map((answer) => String(answer))
118
+ .join("\n");
119
+ }
120
+ if (Array.isArray(output.answers)) {
121
+ return output.answers
122
+ .map((answer) => {
123
+ if (typeof answer === "string")
124
+ return answer;
125
+ if (isRecord(answer)) {
126
+ if (typeof answer.answer === "string")
127
+ return answer.answer;
128
+ if (typeof answer.text === "string")
129
+ return answer.text;
130
+ }
131
+ return JSON.stringify(answer);
132
+ })
133
+ .join("\n");
134
+ }
135
+ }
136
+ return JSON.stringify(output);
137
+ }
138
+ function answerPrompt(output) {
139
+ if (isRecord(output) && isRecord(output.answers)) {
140
+ const lines = Object.entries(output.answers).map(([question, answer]) => `- ${question}:${String(answer)}`);
141
+ if (lines.length > 0) {
142
+ return ["User answered previous question:", ...lines].join("\n");
143
+ }
144
+ }
145
+ return answerText(output);
146
+ }
147
+ function toolResultText(content) {
148
+ if (typeof content === "string")
149
+ return content;
150
+ return JSON.stringify(content);
151
+ }
152
+ function questionText(question, index) {
153
+ if (isRecord(question) && typeof question.question === "string")
154
+ return question.question;
155
+ return `Question ${index + 1}`;
156
+ }
157
+ function questionsFromInput(input) {
158
+ if (isRecord(input) && Array.isArray(input.questions))
159
+ return input.questions;
160
+ return [];
161
+ }
162
+ function answerMapFromOutput(input, output) {
163
+ const questions = questionsFromInput(input);
164
+ if (isRecord(output)) {
165
+ if (isRecord(output.answers)) {
166
+ return Object.fromEntries(Object.entries(output.answers).map(([key, value]) => [key, String(value)]));
167
+ }
168
+ }
169
+ if (questions.length > 0) {
170
+ return Object.fromEntries(questions.map((question, index) => [questionText(question, index), answerText(output)]));
171
+ }
172
+ return { answer: answerText(output) };
173
+ }
174
+ function pendingAskToolOutput(pendingAsk, answerPart, prompt) {
175
+ if (answerPart && answerPart.state === "output-available")
176
+ return answerPart.output;
177
+ return { answers: prompt };
178
+ }
179
+ function pendingAskLiveAnswer(input, answerPart, prompt) {
180
+ if (answerPart && answerPart.state === "output-available") {
181
+ return {
182
+ answers: answerMapFromOutput(input, answerPart.output),
183
+ output: answerPart.output,
184
+ };
185
+ }
186
+ return { answers: prompt };
187
+ }
188
+ function pendingAskResumePrompt(answerPart, prompt) {
189
+ if (!answerPart || answerPart.state !== "output-available")
190
+ return `User answered previous question: ${prompt}`;
191
+ return answerPrompt(answerPart.output);
192
+ }
193
+ function planText(input) {
194
+ if (isRecord(input) && typeof input.plan === "string")
195
+ return input.plan;
196
+ return JSON.stringify(input);
197
+ }
198
+ function approvalDeniedFromText(text) {
199
+ const trimmed = text.trim();
200
+ return {
201
+ approved: false,
202
+ reason: trimmed || undefined,
203
+ };
204
+ }
205
+ function pendingApprovalToolOutput(pendingApproval, answerPart, prompt) {
206
+ if (answerPart && answerPart.state === "approval-responded")
207
+ return answerPart.approval;
208
+ return {
209
+ id: pendingApproval.approveId,
210
+ ...approvalDeniedFromText(prompt),
211
+ };
212
+ }
213
+ function pendingApprovalLiveAnswer(pendingApproval, answerPart, prompt) {
214
+ const output = pendingApprovalToolOutput(pendingApproval, answerPart, prompt);
215
+ if (output.id !== pendingApproval.approveId) {
216
+ throw new HTTPException(400, { message: "Tool approval id does not match pending approval" });
217
+ }
218
+ return {
219
+ approved: output.approved,
220
+ reason: output.reason,
221
+ };
222
+ }
223
+ function pendingApprovalResumePrompt(pendingApproval, answerPart, prompt) {
224
+ const approval = pendingApprovalLiveAnswer(pendingApproval, answerPart, prompt);
225
+ const lines = [
226
+ approval.approved
227
+ ? `User approved tool ${pendingApproval.toolName} plan:`
228
+ : `User rejected tool ${pendingApproval.toolName} plan:`,
229
+ planText(pendingApproval.input),
230
+ ];
231
+ if (approval.reason)
232
+ lines.push(`Reason: ${approval.reason}`);
233
+ return lines.join("\n");
234
+ }
235
+ function isAskUserAnswerPart(answerPart) {
236
+ return Boolean(answerPart &&
237
+ answerPart.state === "output-available" &&
238
+ (answerPart.toolName === "AskUserQuestion" || answerPart.toolName === "ask_user_question"));
239
+ }
240
+ function isApprovalAnswerPart(answerPart) {
241
+ return Boolean(answerPart && answerPart.state === "approval-responded");
242
+ }
243
+ async function promptFromClientToolAnswer(answerPart, parts) {
244
+ if (!answerPart)
245
+ return messageToPrompt(parts);
246
+ if (answerPart.state === "approval-responded")
247
+ return answerPart.approval.reason ?? "";
248
+ return answerPrompt(answerPart.output);
249
+ }
250
+ function resolveWorkdir(workspaceId, subpath) {
251
+ return resolveAgentWorkdir(appConfig, workspaceId, subpath);
252
+ }
253
+ function isAskUserQuestionTool(name) {
254
+ return name === "AskUserQuestion";
255
+ }
256
+ function isExitPlanModeTool(name) {
257
+ return name === "ExitPlanMode";
258
+ }
259
+ /**
260
+ * Subagent messages are produced inside a parent tool call and must not be
261
+ * forwarded downstream. They are marked by a non-empty `subagent_type` or
262
+ * `parent_tool_use_id` on the SDK message.
263
+ */
264
+ function isSubagentMessage(message) {
265
+ const subagentType = "subagent_type" in message ? message.subagent_type : undefined;
266
+ const parentToolUseId = "parent_tool_use_id" in message ? message.parent_tool_use_id : undefined;
267
+ return Boolean(subagentType) || Boolean(parentToolUseId);
268
+ }
269
+ /** Read the session id from any SDK message that carries one. */
270
+ function messageSessionId(message) {
271
+ return "session_id" in message ? message.session_id : undefined;
272
+ }
273
+ function isNoopTaskNotificationResult(message) {
274
+ return (message.subtype === "success" &&
275
+ message.origin?.kind === "task-notification" &&
276
+ message.num_turns === 0 &&
277
+ message.result === "" &&
278
+ message.usage.input_tokens === 0 &&
279
+ message.usage.output_tokens === 0 &&
280
+ message.usage.cache_creation_input_tokens === 0 &&
281
+ message.usage.cache_read_input_tokens === 0 &&
282
+ Object.keys(message.modelUsage).length === 0);
283
+ }
284
+ function chatHeader(headers, name) {
285
+ return headers?.get(name) ?? undefined;
286
+ }
287
+ function matrixThreadRef(headers) {
288
+ const roomId = chatHeader(headers, "X-Matrix-Room-Id")?.trim();
289
+ const threadId = chatHeader(headers, "X-Matrix-Thread-ID")?.trim();
290
+ return roomId && threadId ? `${roomId}/${threadId}` : undefined;
291
+ }
292
+ function matrixEventRef(headers) {
293
+ const roomId = chatHeader(headers, "X-Matrix-Room-Id")?.trim();
294
+ const eventId = chatHeader(headers, "X-Matrix-Event-ID")?.trim();
295
+ const threadRootEventId = chatHeader(headers, "X-Matrix-Thread-ID")?.trim();
296
+ if (!roomId || !eventId)
297
+ return undefined;
298
+ return { roomId, eventId, ...(threadRootEventId ? { threadRootEventId } : null) };
299
+ }
300
+ chat.post("/v1/chat/:conversation_id/cancel", (c) => {
301
+ const conversationId = c.req.param("conversation_id");
302
+ if (!cancelClaudeConversation(conversationId)) {
303
+ return c.json({ error: conversationNotFound(conversationId) }, 404);
304
+ }
305
+ return c.json({ success: true });
306
+ });
307
+ export function cancelClaudeConversation(conversationId) {
308
+ return sessionManager.cancel(conversationId);
309
+ }
310
+ chat.get("/admin/status", (c) => {
311
+ const waits = listPendingClientTools().map((record) => ({
312
+ conversationId: record.conversationId,
313
+ toolCallId: record.toolCallId,
314
+ toolName: record.toolName,
315
+ kind: record.kind,
316
+ approveId: record.kind === "approval" ? record.approveId : undefined,
317
+ since: record.createdAt,
318
+ agentMode: record.agentMode,
319
+ model: record.model,
320
+ }));
321
+ const activity = claudeActivitySnapshot();
322
+ return c.json({
323
+ activeTaskCount: activity.active,
324
+ waitingClientToolCount: waits.length,
325
+ clientToolWaits: waits,
326
+ });
327
+ });
328
+ export async function prepareClaudeChatStream({ body, headers }) {
329
+ const requestedWorkdir = chatHeader(headers, "X-Matrix-Workdir");
330
+ const requestedWorkspaceId = chatHeader(headers, "X-Matrix-Workspace-ID");
331
+ const threadRef = matrixThreadRef(headers);
332
+ const matrixEvent = matrixEventRef(headers);
333
+ let workdir;
334
+ let workspaceRoot;
335
+ try {
336
+ workdir = resolveWorkdir(requestedWorkspaceId, requestedWorkdir);
337
+ workspaceRoot = resolveAgentWorkspaceRoot(appConfig, requestedWorkspaceId);
338
+ }
339
+ catch (err) {
340
+ throw new ChatRequestError(400, err instanceof Error ? err.message : "Invalid workspace");
341
+ }
342
+ if (requestedWorkdir && !existsSync(workdir)) {
343
+ throw new ChatRequestError(400, `Workdir not found: ${workdir}`);
344
+ }
345
+ mkdirSync(workdir, { recursive: true });
346
+ if (body.fork && !("sessionId" in body.fork)) {
347
+ throw new ChatRequestError(400, "Claude fork requires sessionId and messageId");
348
+ }
349
+ const claudeFork = body.fork && "sessionId" in body.fork ? body.fork : undefined;
350
+ const answerPart = clientToolAnswer(body.message.parts);
351
+ const pendingClientTool = answerPart && (isAskUserAnswerPart(answerPart) || isApprovalAnswerPart(answerPart))
352
+ ? loadPendingClientTool(answerPart.toolCallId)
353
+ : body.conversationId
354
+ ? findPendingClientToolByConversation(body.conversationId)
355
+ : undefined;
356
+ const liveTurn = pendingClientTool ? liveTurns.get(pendingClientTool.conversationId) : undefined;
357
+ const prompt = await promptFromClientToolAnswer(answerPart, body.message.parts);
358
+ const interruptedTextTool = answerPart ? undefined : pendingClientTool;
359
+ const requestedConversationId = pendingClientTool && !liveTurn ? pendingClientTool.conversationId : body.conversationId;
360
+ const requestedLlmProxyBinding = chatHeader(headers, "X-Elevo-LLM-Binding")?.trim() || undefined;
361
+ let existingSession = !claudeFork ? sessionManager.get(requestedConversationId) : undefined;
362
+ const storedLlmProxyBinding = existingSession?.llmProxyBinding ?? getLlmProxyBinding(requestedConversationId);
363
+ if (requestedLlmProxyBinding && storedLlmProxyBinding && requestedLlmProxyBinding !== storedLlmProxyBinding) {
364
+ throw new ChatRequestError(409, "LLM proxy binding does not match the existing conversation");
365
+ }
366
+ const llmProxyBinding = storedLlmProxyBinding ?? requestedLlmProxyBinding;
367
+ if (existingSession && llmProxyBinding && !existingSession.llmProxyBinding) {
368
+ if (existingSession.hasActiveOwner()) {
369
+ throw new ChatRequestError(409, "Cannot attach an LLM proxy binding while the conversation is active");
370
+ }
371
+ existingSession.abort();
372
+ existingSession = undefined;
373
+ }
374
+ const requestedModel = body.model || appConfig.claude.defaultModel;
375
+ const activeWorkdir = pendingClientTool && !liveTurn ? pendingClientTool.workdir : workdir;
376
+ const agentMode = pendingClientTool?.kind === "approval" && pendingApprovalLiveAnswer(pendingClientTool, answerPart, prompt).approved
377
+ ? "default"
378
+ : pendingClientTool?.agentMode
379
+ ? resolveAgentMode(pendingClientTool.agentMode)
380
+ : resolveAgentMode(chatHeader(headers, "X-Matrix-Agent-Mode"));
381
+ const activeModel = pendingClientTool && !liveTurn ? (pendingClientTool.model ?? requestedModel) : requestedModel;
382
+ return {
383
+ run: async (writer, signal) => {
384
+ if (pendingClientTool && liveTurn && !interruptedTextTool) {
385
+ const streamToken = {};
386
+ activeStreams.add(streamToken);
387
+ let downstreamClosed = false;
388
+ let heartbeatTimer;
389
+ const writeDone = async () => {
390
+ if (downstreamClosed)
391
+ return;
392
+ downstreamClosed = true;
393
+ if (heartbeatTimer) {
394
+ clearTimeout(heartbeatTimer);
395
+ heartbeatTimer = undefined;
396
+ }
397
+ await writer.writeDone();
398
+ };
399
+ const writeEnvelope = async (data) => {
400
+ if (heartbeatTimer)
401
+ clearTimeout(heartbeatTimer);
402
+ await writer.writeData(data);
403
+ if (!downstreamClosed) {
404
+ heartbeatTimer = setTimeout(() => {
405
+ writer.writeData({ type: "heartbeat" }).catch(() => { });
406
+ }, 60_000);
407
+ }
408
+ };
409
+ const writeData = async (data) => {
410
+ if (downstreamClosed)
411
+ return;
412
+ await writeEnvelope(data);
413
+ };
414
+ try {
415
+ await liveTurn.attach({ writeData, writeDone });
416
+ if (pendingClientTool.kind === "ask_user") {
417
+ answerPendingAsk(pendingClientTool.toolCallId, pendingAskLiveAnswer(pendingClientTool.input, answerPart, prompt));
418
+ }
419
+ else {
420
+ const approval = pendingApprovalLiveAnswer(pendingClientTool, answerPart, prompt);
421
+ if (approval.approved) {
422
+ await sessionManager.get(pendingClientTool.conversationId)?.setPermissionMode("bypassPermissions");
423
+ }
424
+ answerPendingApproval(pendingClientTool.toolCallId, approval);
425
+ }
426
+ await liveTurn.done;
427
+ }
428
+ catch (err) {
429
+ logger.error("Claude live ask continuation failed:", err);
430
+ await writeData({
431
+ type: "error",
432
+ errorText: err instanceof Error ? err.message : String(err),
433
+ });
434
+ await writeDone();
435
+ }
436
+ finally {
437
+ if (heartbeatTimer)
438
+ clearTimeout(heartbeatTimer);
439
+ activeStreams.delete(streamToken);
440
+ }
441
+ return;
442
+ }
443
+ const streamToken = {};
444
+ activeStreams.add(streamToken);
445
+ const localConversationId = requestedConversationId ?? randomUUID();
446
+ let activeConversationId = localConversationId;
447
+ let responseStarted = false;
448
+ let stepStarted = false;
449
+ let completed = false;
450
+ let textOpen = new Set();
451
+ let reasoningOpen = new Set();
452
+ const textBlockIds = new Map();
453
+ const reasoningBlockIds = new Map();
454
+ const toolNamesByCallId = new Map();
455
+ let partialTextSeen = false;
456
+ let partialReasoningSeen = false;
457
+ let currentMessageId;
458
+ let finishReason = "stop";
459
+ let pendingUsage;
460
+ let latestContext;
461
+ let activeCompact;
462
+ let backgroundTaskIds = new Set();
463
+ let awaitingBackgroundCompletionResult = false;
464
+ let heartbeatTimer;
465
+ const taskAggregator = createTaskAggregator();
466
+ // Task* tool calls are buffered: a run of consecutive Task* calls collapses
467
+ // into a single TodoWrite-shaped input+output pair, flushed lazily when any
468
+ // non-Task* message arrives or the stream ends. Each flush uses a fresh
469
+ // toolCallId so every emitted pair is a self-contained, completed tool call.
470
+ let pendingTodoFlush = false;
471
+ let todoFlushSeq = 0;
472
+ // When the model calls AskUserQuestion / ExitPlanMode, canUseTool returns
473
+ // deny+interrupt and we pause: emit the downstream tool event, then keep
474
+ // draining the SDK stream until the turn ends naturally. We must NOT close
475
+ // the async iterator early — tearing it down before the SDK's can_use_tool
476
+ // control request is answered rejects the pending permission request
477
+ // ("Tool permission stream closed before response received") and canUseTool
478
+ // never runs.
479
+ let paused = false;
480
+ const pendingAskToolCallIds = new Set();
481
+ // Once the downstream SSE is closed with [DONE], all further writes are
482
+ // suppressed. We close it immediately when pausing for a user-answered tool
483
+ // (AskUserQuestion / ExitPlanMode) while still draining the SDK stream, so
484
+ // any error the SDK throws afterwards (e.g. the interrupted-result
485
+ // diagnostic) is not forwarded downstream.
486
+ let downstreamClosed = false;
487
+ let activeSink;
488
+ let liveTurnRegistered = false;
489
+ let liveTurnTimeout;
490
+ let doneResolve;
491
+ const liveDone = new Promise((resolve) => {
492
+ doneResolve = resolve;
493
+ });
494
+ let pauseResolve;
495
+ const firstTurnPaused = new Promise((resolve) => {
496
+ pauseResolve = resolve;
497
+ });
498
+ const scheduleHeartbeat = () => {
499
+ heartbeatTimer = setTimeout(() => {
500
+ writeData({ type: "heartbeat" }).catch(() => { });
501
+ }, 60_000);
502
+ };
503
+ const writeEnvelope = async (data) => {
504
+ if (heartbeatTimer)
505
+ clearTimeout(heartbeatTimer);
506
+ await writer.writeData(data);
507
+ if (!downstreamClosed)
508
+ scheduleHeartbeat();
509
+ };
510
+ const initialSink = {
511
+ writeData: async (data) => {
512
+ if (downstreamClosed)
513
+ return;
514
+ await writeEnvelope(data);
515
+ },
516
+ writeDone: async () => {
517
+ if (downstreamClosed)
518
+ return;
519
+ downstreamClosed = true;
520
+ if (heartbeatTimer) {
521
+ clearTimeout(heartbeatTimer);
522
+ heartbeatTimer = undefined;
523
+ }
524
+ await writer.writeDone();
525
+ },
526
+ };
527
+ activeSink = initialSink;
528
+ const writeData = async (data) => {
529
+ if (downstreamClosed)
530
+ return;
531
+ await activeSink.writeData(data);
532
+ };
533
+ const writeDone = async () => {
534
+ if (downstreamClosed)
535
+ return;
536
+ await activeSink.writeDone();
537
+ downstreamClosed = true;
538
+ };
539
+ const writeCompactStart = async () => {
540
+ if (activeCompact)
541
+ return;
542
+ activeCompact = { activityId: randomUUID() };
543
+ await writeData({
544
+ type: "data",
545
+ kind: "compact",
546
+ data: { phase: "start", activityId: activeCompact.activityId },
547
+ });
548
+ };
549
+ const writeCompactEnd = async (status, metadata = {}) => {
550
+ if (!activeCompact)
551
+ return;
552
+ const compact = activeCompact;
553
+ activeCompact = undefined;
554
+ await writeData({
555
+ type: "data",
556
+ kind: "compact",
557
+ data: {
558
+ phase: "end",
559
+ activityId: compact.activityId,
560
+ status,
561
+ ...metadata,
562
+ },
563
+ });
564
+ };
565
+ const writeFinish = async (finishReason) => {
566
+ if (completed || downstreamClosed)
567
+ return;
568
+ completed = true;
569
+ for (const id of textOpen) {
570
+ await writeData({ type: "text-end", id });
571
+ }
572
+ textOpen = new Set();
573
+ for (const id of reasoningOpen) {
574
+ await writeData({ type: "reasoning-end", id });
575
+ }
576
+ reasoningOpen = new Set();
577
+ if (stepStarted) {
578
+ if (finishReason === "stop" && pendingUsage) {
579
+ await writeData({ type: "data", kind: "token_usage", data: pendingUsage });
580
+ }
581
+ await writeData({ type: "finish-step" });
582
+ }
583
+ await writeData({ type: "finish", finishReason });
584
+ await writeDone();
585
+ };
586
+ const closeOpenText = async () => {
587
+ for (const id of textOpen)
588
+ await writeData({ type: "text-end", id });
589
+ textOpen = new Set();
590
+ };
591
+ const closeOpenReasoning = async () => {
592
+ for (const id of reasoningOpen)
593
+ await writeData({ type: "reasoning-end", id });
594
+ reasoningOpen = new Set();
595
+ };
596
+ const closeOpenBlocks = async () => {
597
+ await closeOpenText();
598
+ await closeOpenReasoning();
599
+ };
600
+ const flushTodo = async () => {
601
+ if (!pendingTodoFlush)
602
+ return;
603
+ pendingTodoFlush = false;
604
+ taskAggregator.clearDirty();
605
+ await closeOpenBlocks();
606
+ const toolCallId = `todo-${localConversationId}-${todoFlushSeq++}`;
607
+ await writeData({
608
+ type: "tool-input-available",
609
+ toolCallId,
610
+ toolName: "TodoWrite",
611
+ input: taskAggregator.snapshot(),
612
+ });
613
+ await writeData({
614
+ type: "tool-output-available",
615
+ toolCallId,
616
+ output: "Task updated",
617
+ });
618
+ };
619
+ const pauseForClientTool = async (toolCallId, toolName, input, approval) => {
620
+ if (pendingAskToolCallIds.has(toolCallId))
621
+ return;
622
+ pendingAskToolCallIds.add(toolCallId);
623
+ paused = true;
624
+ signal?.removeEventListener("abort", abortFromParent);
625
+ if (approval) {
626
+ await writeData({
627
+ type: "tool-approval-request",
628
+ toolCallId,
629
+ approveId: approval.approveId,
630
+ });
631
+ }
632
+ await writeDone();
633
+ liveTurnTimeout = setTimeout(() => {
634
+ logger.warn(`Claude ${toolName} timed out after ${appConfig.claude.askTimeoutMs}ms`, {
635
+ conversationId: activeConversationId,
636
+ toolCallId,
637
+ });
638
+ liveTurns.delete(activeConversationId);
639
+ session.abort();
640
+ doneResolve();
641
+ }, appConfig.claude.askTimeoutMs);
642
+ liveTurnTimeout.unref?.();
643
+ pauseResolve();
644
+ };
645
+ const ensureTextStarted = async (id) => {
646
+ if (textOpen.has(id))
647
+ return;
648
+ textOpen.add(id);
649
+ await writeData({ type: "text-start", id });
650
+ };
651
+ const ensureReasoningStarted = async (id) => {
652
+ if (reasoningOpen.has(id))
653
+ return;
654
+ reasoningOpen.add(id);
655
+ await writeData({ type: "reasoning-start", id });
656
+ };
657
+ const registerSession = (id) => {
658
+ if (id === activeConversationId)
659
+ return;
660
+ const previousConversationId = activeConversationId;
661
+ activeConversationId = id;
662
+ sessionManager.bind(session, id);
663
+ moveLlmProxyBinding(previousConversationId, id, llmProxyBinding);
664
+ updatePendingAskConversation(previousConversationId, id);
665
+ const liveTurn = liveTurns.get(previousConversationId);
666
+ if (liveTurn) {
667
+ liveTurns.delete(previousConversationId);
668
+ liveTurns.set(id, liveTurn);
669
+ }
670
+ };
671
+ const writeStart = async (id) => {
672
+ if (responseStarted)
673
+ return;
674
+ responseStarted = true;
675
+ registerSession(id);
676
+ if (!liveTurnRegistered) {
677
+ liveTurnRegistered = true;
678
+ liveTurns.set(id, {
679
+ attach: async (sink) => {
680
+ if (liveTurnTimeout) {
681
+ clearTimeout(liveTurnTimeout);
682
+ liveTurnTimeout = undefined;
683
+ }
684
+ activeSink = sink;
685
+ downstreamClosed = false;
686
+ paused = false;
687
+ },
688
+ done: liveDone,
689
+ dispose: () => {
690
+ if (liveTurnTimeout) {
691
+ clearTimeout(liveTurnTimeout);
692
+ liveTurnTimeout = undefined;
693
+ }
694
+ },
695
+ });
696
+ }
697
+ await writeData({
698
+ type: "start",
699
+ messageMetadata: {
700
+ conversationId: id,
701
+ threadId: id,
702
+ },
703
+ });
704
+ await writeData({ type: "start-step" });
705
+ stepStarted = true;
706
+ };
707
+ const cleanupCompletedTurn = () => {
708
+ if (liveTurnRegistered) {
709
+ liveTurns.get(activeConversationId)?.dispose();
710
+ liveTurns.delete(activeConversationId);
711
+ }
712
+ for (const toolCallId of pendingAskToolCallIds) {
713
+ deletePendingClientTool(toolCallId, { rejectWait: true });
714
+ }
715
+ };
716
+ const finishCompletedTurn = async () => {
717
+ await writeFinish(finishReason);
718
+ cleanupCompletedTurn();
719
+ doneResolve();
720
+ return true;
721
+ };
722
+ scheduleHeartbeat();
723
+ const permissionHandler = async (toolName, input, options) => {
724
+ const { toolUseID } = options;
725
+ if (isAskUserQuestionTool(toolName)) {
726
+ savePendingAsk({
727
+ conversationId: activeConversationId,
728
+ toolCallId: toolUseID,
729
+ workdir: activeWorkdir,
730
+ model: activeModel,
731
+ agentMode,
732
+ toolName: "AskUserQuestion",
733
+ input,
734
+ });
735
+ await pauseForClientTool(toolUseID, "AskUserQuestion", input);
736
+ const answer = await waitPendingAsk(toolUseID);
737
+ if (!answer) {
738
+ return {
739
+ behavior: "deny",
740
+ message: `${toolName} requires user input; session will resume after user responds.`,
741
+ toolUseID,
742
+ };
743
+ }
744
+ if ("interrupted" in answer) {
745
+ return {
746
+ behavior: "deny",
747
+ message: "Question interrupted by text response",
748
+ interrupt: true,
749
+ toolUseID,
750
+ };
751
+ }
752
+ return {
753
+ behavior: "allow",
754
+ updatedInput: {
755
+ ...input,
756
+ questions: questionsFromInput(input),
757
+ answers: answer.answers,
758
+ },
759
+ toolUseID,
760
+ };
761
+ }
762
+ if (isExitPlanModeTool(toolName)) {
763
+ const pendingApproval = savePendingApproval({
764
+ conversationId: activeConversationId,
765
+ toolCallId: toolUseID,
766
+ workdir: activeWorkdir,
767
+ model: activeModel,
768
+ agentMode,
769
+ toolName: "ExitPlanMode",
770
+ input,
771
+ });
772
+ await pauseForClientTool(toolUseID, "ExitPlanMode", input, pendingApproval);
773
+ const approval = await waitPendingApproval(toolUseID);
774
+ if (approval && "interrupted" in approval) {
775
+ return {
776
+ behavior: "deny",
777
+ message: "Approval interrupted by text response",
778
+ interrupt: true,
779
+ toolUseID,
780
+ decisionClassification: "user_reject",
781
+ };
782
+ }
783
+ if (approval?.approved) {
784
+ return {
785
+ behavior: "allow",
786
+ updatedInput: { ...input, approval },
787
+ toolUseID,
788
+ };
789
+ }
790
+ return {
791
+ behavior: "deny",
792
+ message: approval?.reason || "Denied by user",
793
+ toolUseID,
794
+ decisionClassification: "user_reject",
795
+ };
796
+ }
797
+ return { behavior: "allow", toolUseID };
798
+ };
799
+ const handleMessage = async (message) => {
800
+ if (isSubagentMessage(message))
801
+ return false;
802
+ const id = messageSessionId(message);
803
+ if (id) {
804
+ await writeStart(id);
805
+ taskAggregator.setSessionId(id);
806
+ }
807
+ if (paused)
808
+ return false;
809
+ switch (message.type) {
810
+ case "system": {
811
+ if (message.subtype === "background_tasks_changed") {
812
+ backgroundTaskIds = new Set(message.tasks.map((task) => task.task_id));
813
+ }
814
+ else if (message.subtype === "status") {
815
+ if (message.status === "compacting") {
816
+ await writeCompactStart();
817
+ }
818
+ if (message.compact_result === "success") {
819
+ await writeCompactEnd("completed");
820
+ }
821
+ else if (message.compact_result === "failed") {
822
+ await writeCompactEnd("failed", { error: message.compact_error });
823
+ }
824
+ }
825
+ else if (message.subtype === "compact_boundary") {
826
+ await writeCompactEnd("completed", {
827
+ trigger: message.compact_metadata.trigger,
828
+ preTokens: message.compact_metadata.pre_tokens,
829
+ postTokens: message.compact_metadata.post_tokens,
830
+ durationMs: message.compact_metadata.duration_ms,
831
+ });
832
+ }
833
+ return false;
834
+ }
835
+ case "stream_event": {
836
+ const event = message.event;
837
+ switch (event.type) {
838
+ case "message_start": {
839
+ currentMessageId = event.message.id;
840
+ partialTextSeen = false;
841
+ partialReasoningSeen = false;
842
+ break;
843
+ }
844
+ case "content_block_start": {
845
+ const key = `${currentMessageId ?? message.session_id}:${event.index}`;
846
+ if (event.content_block.type === "text") {
847
+ await flushTodo();
848
+ textBlockIds.set(key, message.uuid);
849
+ await ensureTextStarted(message.uuid);
850
+ }
851
+ else if (event.content_block.type === "thinking") {
852
+ await flushTodo();
853
+ reasoningBlockIds.set(key, message.uuid);
854
+ await ensureReasoningStarted(message.uuid);
855
+ }
856
+ break;
857
+ }
858
+ case "content_block_delta": {
859
+ const delta = event.delta;
860
+ const key = `${currentMessageId ?? message.session_id}:${event.index}`;
861
+ if (delta.type === "text_delta") {
862
+ await flushTodo();
863
+ partialTextSeen = true;
864
+ const blockId = textBlockIds.get(key);
865
+ if (!blockId) {
866
+ logger.warn("Ignoring Claude text delta without content_block_start", {
867
+ conversationId: activeConversationId,
868
+ blockKey: key,
869
+ });
870
+ break;
871
+ }
872
+ await writeData({ type: "text-delta", id: blockId, delta: delta.text });
873
+ }
874
+ else if (delta.type === "thinking_delta") {
875
+ await flushTodo();
876
+ partialReasoningSeen = true;
877
+ const blockId = reasoningBlockIds.get(key);
878
+ if (!blockId) {
879
+ logger.warn("Ignoring Claude reasoning delta without content_block_start", {
880
+ conversationId: activeConversationId,
881
+ blockKey: key,
882
+ });
883
+ break;
884
+ }
885
+ await writeData({ type: "reasoning-delta", id: blockId, delta: delta.thinking });
886
+ }
887
+ break;
888
+ }
889
+ case "content_block_stop": {
890
+ // The matching SDKAssistantMessage carries the transcript UUID
891
+ // required for fork. Keep downstream blocks open until that
892
+ // checkpoint has been published.
893
+ break;
894
+ }
895
+ }
896
+ return false;
897
+ }
898
+ case "assistant": {
899
+ latestContext = claudeContextSnapshot(message.message) ?? latestContext;
900
+ if (message.session_id && message.uuid) {
901
+ await writeData({
902
+ type: "data",
903
+ kind: "agent_session",
904
+ data: {
905
+ agentType: "claude",
906
+ sessionId: message.session_id,
907
+ messageId: message.uuid,
908
+ },
909
+ });
910
+ }
911
+ const content = message.message.content;
912
+ if (typeof content === "string")
913
+ break;
914
+ for (const block of content) {
915
+ switch (block.type) {
916
+ case "thinking": {
917
+ // Fallback for non-streamed thinking blocks.
918
+ if (partialReasoningSeen || !block.thinking)
919
+ break;
920
+ await flushTodo();
921
+ await ensureReasoningStarted(message.uuid);
922
+ await writeData({ type: "reasoning-delta", id: message.uuid, delta: block.thinking });
923
+ break;
924
+ }
925
+ case "text": {
926
+ // Fallback for non-streamed text blocks.
927
+ if (partialTextSeen || !block.text)
928
+ break;
929
+ await flushTodo();
930
+ await ensureTextStarted(message.uuid);
931
+ await writeData({ type: "text-delta", id: message.uuid, delta: block.text });
932
+ break;
933
+ }
934
+ case "tool_use": {
935
+ await closeOpenBlocks();
936
+ toolNamesByCallId.set(block.id, block.name);
937
+ if (taskAggregator.isTaskTool(block.name)) {
938
+ // Buffer Task* tools: record the change and defer emission so a
939
+ // run of consecutive Task* calls collapses into a single
940
+ // TodoWrite input+output pair, flushed on the next non-Task*
941
+ // message or at stream end.
942
+ if (taskAggregator.applyToolUse(block.id, block.name, block.input)) {
943
+ pendingTodoFlush = true;
944
+ }
945
+ break;
946
+ }
947
+ await flushTodo();
948
+ await closeOpenBlocks();
949
+ await writeData({
950
+ type: "tool-input-available",
951
+ toolCallId: block.id,
952
+ toolName: block.name,
953
+ input: block.input,
954
+ });
955
+ break;
956
+ }
957
+ }
958
+ }
959
+ await closeOpenBlocks();
960
+ textBlockIds.clear();
961
+ reasoningBlockIds.clear();
962
+ return false;
963
+ }
964
+ case "user": {
965
+ const content = message.message.content;
966
+ if (typeof content === "string")
967
+ break;
968
+ const toolResults = content.filter((block) => block.type === "tool_result");
969
+ const structuredOutput = toolResults.length === 1 ? managedTaskStructuredOutput(message.tool_use_result) : undefined;
970
+ for (const block of toolResults) {
971
+ const toolCallId = block.tool_use_id;
972
+ const output = block.content;
973
+ // Re-read the task files after a Task* tool completes and buffer the
974
+ // change; the raw Task* tool output is suppressed (it is represented
975
+ // by the aggregated TodoWrite pair flushed later).
976
+ if (taskAggregator.isTaskToolCall(toolCallId)) {
977
+ if (taskAggregator.applyToolResult(toolCallId, output)) {
978
+ pendingTodoFlush = true;
979
+ }
980
+ continue;
981
+ }
982
+ await flushTodo();
983
+ if (block.is_error === true) {
984
+ await writeData({
985
+ type: "tool-output-error",
986
+ toolCallId,
987
+ errorText: toolResultText(output),
988
+ });
989
+ }
990
+ else {
991
+ const toolName = toolNamesByCallId.get(toolCallId);
992
+ await writeData({
993
+ type: "tool-output-available",
994
+ toolCallId,
995
+ output: toolName && isManagedTaskTool(toolName) && structuredOutput !== undefined
996
+ ? structuredOutput
997
+ : output,
998
+ });
999
+ }
1000
+ toolNamesByCallId.delete(toolCallId);
1001
+ }
1002
+ return false;
1003
+ }
1004
+ case "result": {
1005
+ if (backgroundTaskIds.size > 0) {
1006
+ if (message.origin?.kind !== "task-notification") {
1007
+ awaitingBackgroundCompletionResult = true;
1008
+ }
1009
+ return false;
1010
+ }
1011
+ // A resumed session can replay a zero-turn task notification result
1012
+ // before it processes the newly queued human message.
1013
+ if (isNoopTaskNotificationResult(message) && !awaitingBackgroundCompletionResult) {
1014
+ return false;
1015
+ }
1016
+ finishReason = message.subtype === "success" || session.wasCancelled ? "stop" : "error";
1017
+ const usage = claudeTokenUsage(message, latestContext);
1018
+ pendingUsage = finishReason === "stop" ? usage : undefined;
1019
+ await flushTodo();
1020
+ if (message.subtype !== "success" && !session.wasCancelled) {
1021
+ await writeData({
1022
+ type: "error",
1023
+ errorText: message.errors?.filter((error) => error.trim()).join("\n") ||
1024
+ `Claude Code failed: ${message.subtype}`,
1025
+ });
1026
+ }
1027
+ return finishCompletedTurn();
1028
+ }
1029
+ }
1030
+ return false;
1031
+ };
1032
+ const managedTaskContext = { threadRef, matrixEvent };
1033
+ const session = existingSession ??
1034
+ sessionManager.create({
1035
+ conversationId: localConversationId,
1036
+ workdir: activeWorkdir,
1037
+ model: activeModel,
1038
+ agentMode,
1039
+ managedTaskContext,
1040
+ llmProxyBinding,
1041
+ buildOptions: (abortController, canUseTool, context) => ({
1042
+ abortController,
1043
+ cwd: activeWorkdir,
1044
+ includePartialMessages: true,
1045
+ model: activeModel,
1046
+ maxTurns: appConfig.claude.maxTurns,
1047
+ systemPrompt: {
1048
+ type: "preset",
1049
+ preset: "claude_code",
1050
+ append: claudePlatformInstructions,
1051
+ },
1052
+ ...claudePermissionOptions(agentMode),
1053
+ tools: { type: "preset", preset: "claude_code" },
1054
+ mcpServers: {
1055
+ [MANAGED_TASK_MCP_SERVER_NAME]: createManagedTaskMcpServer({ workspaceRoot, context }),
1056
+ },
1057
+ disallowedTools: ["EnterWorktree", "ExitWorktree"],
1058
+ ...(claudeFork
1059
+ ? {
1060
+ resume: claudeFork.sessionId,
1061
+ resumeSessionAt: claudeFork.messageId,
1062
+ forkSession: true,
1063
+ }
1064
+ : requestedConversationId
1065
+ ? { resume: requestedConversationId }
1066
+ : null),
1067
+ canUseTool,
1068
+ env: {
1069
+ ...process.env,
1070
+ ...(appConfig.claude.apiKey ? { ANTHROPIC_API_KEY: appConfig.claude.apiKey } : null),
1071
+ ...(appConfig.claude.baseUrl ? { ANTHROPIC_BASE_URL: appConfig.claude.baseUrl } : null),
1072
+ ...(llmProxyBinding
1073
+ ? {
1074
+ ANTHROPIC_CUSTOM_HEADERS: [
1075
+ process.env.ANTHROPIC_CUSTOM_HEADERS,
1076
+ `X-Elevo-LLM-Binding: ${llmProxyBinding}`,
1077
+ ]
1078
+ .filter(Boolean)
1079
+ .join("\n"),
1080
+ }
1081
+ : null),
1082
+ CLAUDE_AGENT_SDK_CLIENT_APP: "coding-agent/claude-agent",
1083
+ },
1084
+ }),
1085
+ });
1086
+ setLlmProxyBinding(localConversationId, llmProxyBinding);
1087
+ session.updateManagedTaskContext(managedTaskContext);
1088
+ const abortFromParent = () => session.abort();
1089
+ let ownerSignalAttached = false;
1090
+ try {
1091
+ if (interruptedTextTool && liveTurn) {
1092
+ await liveTurn.attach({
1093
+ writeData: async () => undefined,
1094
+ writeDone: async () => undefined,
1095
+ });
1096
+ if (interruptedTextTool.kind === "ask_user") {
1097
+ answerPendingAsk(interruptedTextTool.toolCallId, { interrupted: true });
1098
+ }
1099
+ else {
1100
+ answerPendingApproval(interruptedTextTool.toolCallId, { interrupted: true });
1101
+ }
1102
+ await liveTurn.done;
1103
+ }
1104
+ if (pendingClientTool && (!liveTurn || interruptedTextTool)) {
1105
+ if (pendingClientTool.kind === "ask_user") {
1106
+ await writeData(interruptedTextTool
1107
+ ? {
1108
+ type: "tool-output-error",
1109
+ toolCallId: pendingClientTool.toolCallId,
1110
+ toolName: pendingClientTool.toolName,
1111
+ errorText: "Question interrupted by text response",
1112
+ }
1113
+ : {
1114
+ type: "tool-output-available",
1115
+ toolCallId: pendingClientTool.toolCallId,
1116
+ output: pendingAskToolOutput(pendingClientTool, answerPart, prompt),
1117
+ });
1118
+ }
1119
+ else {
1120
+ await writeData(interruptedTextTool
1121
+ ? {
1122
+ type: "tool-output-error",
1123
+ toolCallId: pendingClientTool.toolCallId,
1124
+ toolName: pendingClientTool.toolName,
1125
+ errorText: "Approval interrupted by text response",
1126
+ }
1127
+ : {
1128
+ type: "tool-output-available",
1129
+ toolCallId: pendingClientTool.toolCallId,
1130
+ output: pendingApprovalToolOutput(pendingClientTool, answerPart, prompt),
1131
+ });
1132
+ }
1133
+ if (!interruptedTextTool || !liveTurn) {
1134
+ deletePendingClientTool(pendingClientTool.toolCallId);
1135
+ }
1136
+ }
1137
+ const turnPrompt = pendingClientTool && !liveTurn && !interruptedTextTool
1138
+ ? pendingClientTool.kind === "ask_user"
1139
+ ? pendingAskResumePrompt(answerPart, prompt)
1140
+ : pendingApprovalResumePrompt(pendingClientTool, answerPart, prompt)
1141
+ : prompt;
1142
+ const reservation = await session.reserveTurn(turnPrompt, handleMessage, permissionHandler, {
1143
+ model: activeModel,
1144
+ agentMode,
1145
+ managedTaskContext,
1146
+ });
1147
+ if (reservation.kind === "interjection") {
1148
+ if (heartbeatTimer)
1149
+ clearTimeout(heartbeatTimer);
1150
+ await writer.writeDone();
1151
+ return;
1152
+ }
1153
+ ownerSignalAttached = true;
1154
+ if (signal?.aborted)
1155
+ abortFromParent();
1156
+ signal?.addEventListener("abort", abortFromParent, { once: true });
1157
+ await Promise.race([reservation.done, firstTurnPaused]);
1158
+ if (!paused && !completed && !downstreamClosed) {
1159
+ await writeFinish(session.wasCancelled || !session.isDead ? "stop" : "error");
1160
+ }
1161
+ }
1162
+ catch (err) {
1163
+ if (downstreamClosed) {
1164
+ logger.error("Claude stream failed after downstream closed:", err);
1165
+ doneResolve();
1166
+ }
1167
+ else {
1168
+ await writeStart(activeConversationId);
1169
+ if (signal?.aborted || session.wasCancelled) {
1170
+ await writeFinish("stop");
1171
+ }
1172
+ else if (session.isDead) {
1173
+ await writeFinish("error");
1174
+ }
1175
+ else {
1176
+ logger.error("Claude stream failed:", err);
1177
+ await writeData({
1178
+ type: "error",
1179
+ errorText: err instanceof Error ? err.message : String(err),
1180
+ });
1181
+ await writeFinish("error");
1182
+ }
1183
+ }
1184
+ }
1185
+ finally {
1186
+ if (ownerSignalAttached)
1187
+ signal?.removeEventListener("abort", abortFromParent);
1188
+ if (heartbeatTimer)
1189
+ clearTimeout(heartbeatTimer);
1190
+ activeStreams.delete(streamToken);
1191
+ if (!paused) {
1192
+ cleanupCompletedTurn();
1193
+ }
1194
+ if (!paused)
1195
+ doneResolve();
1196
+ }
1197
+ },
1198
+ };
1199
+ }
1200
+ export async function handleClaudeChatStream(request, writer) {
1201
+ const prepared = await prepareClaudeChatStream(request);
1202
+ await prepared.run(writer, request.signal);
1203
+ }
1204
+ chat.post("/v1/chat/stream", zValidator("json", ChatBodySchema), async (c) => {
1205
+ let prepared;
1206
+ try {
1207
+ prepared = await prepareClaudeChatStream({
1208
+ body: c.req.valid("json"),
1209
+ headers: c.req.raw.headers,
1210
+ });
1211
+ }
1212
+ catch (err) {
1213
+ if (err instanceof ChatRequestError) {
1214
+ return c.json({ error: err.message }, err.status);
1215
+ }
1216
+ throw err;
1217
+ }
1218
+ return streamSSE(c, async (sse) => {
1219
+ await prepared.run({
1220
+ writeData: async (data) => {
1221
+ await sse.writeSSE({ data: JSON.stringify(data) });
1222
+ },
1223
+ writeDone: async () => {
1224
+ await sse.writeSSE({ data: "[DONE]" });
1225
+ },
1226
+ }, c.req.raw.signal);
1227
+ });
1228
+ });
1229
+ export default chat;