compass-agent 2.0.4

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,908 @@
1
+ /**
2
+ * Claude CLI streaming handler with agentic task visualization.
3
+ *
4
+ * Spawns the Claude CLI, parses NDJSON events, streams text to Slack via
5
+ * chatStream (with chat.update fallback), emits TaskUpdateChunks for tool
6
+ * calls, and logs usage on completion.
7
+ *
8
+ * Supports two display modes:
9
+ * - "plan" – used when Claude enters plan mode (EnterPlanMode tool).
10
+ * Shows a plan_update title + grouped task_update steps.
11
+ * - "timeline" – default mode for normal tool-use / implementation.
12
+ * Shows individual task cards interleaved with streamed text.
13
+ *
14
+ * The display mode is detected from the first content_block_start event and
15
+ * the streamer is created lazily so we can pick the right mode.
16
+ */
17
+
18
+ import { spawn } from "child_process";
19
+ import { appendFileSync, writeFileSync } from "fs";
20
+ import { join } from "path";
21
+ import { getTeachings, upsertSession, addUsageLog } from "../db.ts";
22
+ import {
23
+ buildBlocks, buildStopOnlyBlocks, buildFeedbackBlock, buildDisclaimerBlock,
24
+ } from "../ui/blocks.ts";
25
+ import { log, logErr } from "../lib/log.ts";
26
+ import type { HandleClaudeStreamOpts } from "../types.ts";
27
+
28
+ // ── Temporary debug: dump raw NDJSON to file ──────────────────
29
+ const STREAM_DEBUG = process.env.STREAM_DEBUG === "1";
30
+ const STREAM_DEBUG_FILE = join(import.meta.dir, "..", "stream-debug.jsonl");
31
+
32
+ const CLAUDE_PATH = process.env.CLAUDE_PATH || "claude";
33
+ const UPDATE_INTERVAL_MS = 750;
34
+
35
+ /** Tools that are internal / meta and should be hidden or shown differently */
36
+ const HIDDEN_TOOLS = new Set(["EnterPlanMode", "ExitPlanMode"]);
37
+
38
+ const TOOL_STATUS_MAP: Record<string, string> = {
39
+ Read: "is reading files...",
40
+ Write: "is writing code...",
41
+ Edit: "is editing code...",
42
+ Bash: "is running commands...",
43
+ Glob: "is searching files...",
44
+ Grep: "is searching code...",
45
+ WebFetch: "is fetching web content...",
46
+ WebSearch: "is searching the web...",
47
+ Task: "is running a sub-agent...",
48
+ EnterPlanMode: "is planning...",
49
+ ExitPlanMode: "is finalizing the plan...",
50
+ TaskCreate: "is creating tasks...",
51
+ TaskUpdate: "is updating tasks...",
52
+ TodoWrite: "is updating tasks...",
53
+ NotebookEdit: "is editing a notebook...",
54
+ };
55
+
56
+ export function toolTitle(toolName: string, toolInput: any): string {
57
+ try {
58
+ switch (toolName) {
59
+ case "Read":
60
+ return `Read ${toolInput.file_path || "file"}`;
61
+ case "Write":
62
+ return `Write ${toolInput.file_path || "file"}`;
63
+ case "Edit":
64
+ return `Edit ${toolInput.file_path || "file"}`;
65
+ case "Bash": {
66
+ const cmd = toolInput.command || "";
67
+ return `Run: ${cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd}`;
68
+ }
69
+ case "Glob":
70
+ return `Search: ${toolInput.pattern || "files"}`;
71
+ case "Grep":
72
+ return `Search: ${toolInput.pattern || "code"}`;
73
+ case "Task": {
74
+ const desc = toolInput.description || toolInput.subagent_type || "task";
75
+ return `Sub-agent: ${desc}`;
76
+ }
77
+ case "AskUserQuestion": {
78
+ const q = toolInput.questions?.[0]?.question;
79
+ return q ? `Question: ${q}` : "Asking a question...";
80
+ }
81
+ case "EnterPlanMode":
82
+ return "Entering plan mode";
83
+ case "ExitPlanMode":
84
+ return "Plan ready";
85
+ case "TaskCreate":
86
+ case "TodoWrite":
87
+ return `Create task: ${toolInput.subject || toolInput.description || "task"}`;
88
+ case "TaskUpdate":
89
+ return `Update task: ${toolInput.subject || toolInput.status || "task"}`;
90
+ default:
91
+ return `${toolName}`;
92
+ }
93
+ } catch {
94
+ return toolName;
95
+ }
96
+ }
97
+
98
+ const MAX_OUTPUT_LEN = 120;
99
+
100
+ /**
101
+ * Extract a brief output summary from a tool result for display on task cards.
102
+ * Returns null if there's nothing meaningful to show.
103
+ */
104
+ function extractToolOutput(resultSummary: any, contentBlock: any): string | null {
105
+ // Try the top-level tool_use_result summary first (concise)
106
+ let text: string | null = null;
107
+ if (typeof resultSummary === "string" && resultSummary.length > 0) {
108
+ text = resultSummary;
109
+ } else if (resultSummary?.message) {
110
+ text = resultSummary.message;
111
+ }
112
+
113
+ // Fall back to the content block's content field
114
+ if (!text && contentBlock?.content) {
115
+ const raw = typeof contentBlock.content === "string"
116
+ ? contentBlock.content
117
+ : JSON.stringify(contentBlock.content);
118
+ // Count lines for file/search results
119
+ const lines = raw.split("\n");
120
+ if (lines.length > 3) {
121
+ text = `${lines.length} lines`;
122
+ } else if (raw.length > 0 && raw.length <= MAX_OUTPUT_LEN) {
123
+ text = raw;
124
+ }
125
+ }
126
+
127
+ if (!text) return null;
128
+ // Strip "Error: " prefix noise from non-interactive tool stubs
129
+ if (text.startsWith("Error: ")) text = text.slice(7);
130
+ // Truncate
131
+ return text.length > MAX_OUTPUT_LEN ? text.slice(0, MAX_OUTPUT_LEN - 3) + "..." : text;
132
+ }
133
+
134
+ export async function handleClaudeStream(opts: HandleClaudeStreamOpts): Promise<void> {
135
+ const {
136
+ channelId, threadTs, userText, userId, client,
137
+ spawnCwd, isResume, setStatus,
138
+ activeProcesses, cachedTeamId, botUserId,
139
+ } = opts;
140
+ let { sessionId } = opts;
141
+
142
+ // ── Thinking indicator (instant feedback) ──
143
+ await setStatus({
144
+ status: "is thinking...",
145
+ loading_messages: [
146
+ "Thinking...",
147
+ "Processing your request...",
148
+ "Working on it...",
149
+ ],
150
+ });
151
+
152
+ // Stop button is posted lazily: after first stream content (so it appears
153
+ // below the streaming text) or before first fallback chat.update.
154
+ let stopMsgTs: string | undefined;
155
+ let stopBtnPromise: Promise<void>;
156
+ function ensureStopButton(): Promise<void> {
157
+ if (!stopBtnPromise) {
158
+ stopBtnPromise = client.chat.postMessage({
159
+ channel: channelId,
160
+ thread_ts: threadTs,
161
+ text: " ",
162
+ blocks: buildStopOnlyBlocks(threadTs),
163
+ }).then((res: any) => {
164
+ stopMsgTs = res.ts;
165
+ log(channelId, `Stop-button carrier posted: ts=${stopMsgTs}`);
166
+ }).catch((err: any) => {
167
+ logErr(channelId, `Failed to post stop button: ${err.message}`);
168
+ });
169
+ }
170
+ return stopBtnPromise;
171
+ }
172
+
173
+ // ── Streamer ──────────────────────────────────────────────
174
+ // Assistant threads: created lazily (native setStatus provides instant feedback
175
+ // while we detect plan vs timeline mode from the first content event).
176
+ // Channel @mentions: created eagerly with a "Thinking" in_progress task
177
+ // (no native setStatus available, so the stream IS the thinking indicator).
178
+ let streamer: any = null;
179
+ let displayMode: "plan" | "timeline" | null = null;
180
+ let streamerActive = false;
181
+ let streamFailed = !cachedTeamId;
182
+
183
+ if (!cachedTeamId) {
184
+ log(channelId, `No cached team_id, using chat.update fallback`);
185
+ }
186
+
187
+ function initStreamer(mode: "plan" | "timeline") {
188
+ if (streamer) return;
189
+ displayMode = mode;
190
+ try {
191
+ streamer = client.chatStream({
192
+ channel: channelId,
193
+ thread_ts: threadTs,
194
+ recipient_team_id: cachedTeamId,
195
+ recipient_user_id: userId,
196
+ task_display_mode: mode,
197
+ });
198
+ log(channelId, `Streamer created: task_display_mode=${mode}`);
199
+ } catch (err: any) {
200
+ logErr(channelId, `Streamer creation failed (${mode}): ${err.message}`);
201
+ streamFailed = true;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Ensure the streamer exists (defaults to timeline if not yet created),
207
+ * then append chunks/text. Handles the appendChain serialization.
208
+ */
209
+ function safeAppend(payload: any) {
210
+ if (streamFailed) return;
211
+ if (!streamer) initStreamer("timeline");
212
+ appendChain = appendChain.then(async () => {
213
+ if (!streamerActive) {
214
+ streamerActive = true;
215
+ log(channelId, `Streamer activated`);
216
+ }
217
+ await streamer.append(payload);
218
+ }).catch((err: any) => {
219
+ if (!streamFailed) {
220
+ logErr(channelId, `Stream append failed: ${err.message}`);
221
+ streamFailed = true;
222
+ }
223
+ });
224
+ }
225
+
226
+ // ── Build Claude args ─────────────────────────────────────
227
+ const args = [
228
+ "-p", userText,
229
+ "--output-format", "stream-json",
230
+ "--verbose",
231
+ "--include-partial-messages",
232
+ "--dangerously-skip-permissions",
233
+ ];
234
+
235
+ // Append user-defined additional args from .env
236
+ const additionalArgs = (process.env.CLAUDE_ADDITIONAL_ARGS || "").trim();
237
+ if (additionalArgs) {
238
+ args.push(...additionalArgs.split(/\s+/));
239
+ log(channelId, `Additional claude args: ${additionalArgs}`);
240
+ }
241
+
242
+ if (isResume) {
243
+ args.push("--resume", sessionId);
244
+ } else {
245
+ args.push("--session-id", sessionId);
246
+ }
247
+
248
+ // Inject team teachings
249
+ const teachings = getTeachings("default");
250
+ if (teachings.length > 0) {
251
+ const teachingText = teachings.map((t) => `- ${t.instruction}`).join("\n");
252
+ args.push("--append-system-prompt", `\nTeam conventions:\n${teachingText}`);
253
+ log(channelId, `Injecting ${teachings.length} teaching(s) via --append-system-prompt`);
254
+ } else {
255
+ log(channelId, `No teachings to inject`);
256
+ }
257
+
258
+ log(channelId, `Spawning claude: cwd=${spawnCwd} resume=${isResume}`);
259
+
260
+ // ── Spawn Claude process ──────────────────────────────────
261
+ const env: Record<string, string | undefined> = { ...process.env };
262
+ delete env.CLAUDECODE;
263
+
264
+ // Inject ENV_* variables: ENV_ANTHROPIC_KEY=xxx → ANTHROPIC_KEY=xxx
265
+ for (const [key, val] of Object.entries(process.env)) {
266
+ if (key.startsWith("ENV_") && key.length > 4) {
267
+ env[key.slice(4)] = val;
268
+ }
269
+ }
270
+
271
+ // Inject Slack context for MCP server tools
272
+ env.SLACK_CHANNEL_ID = channelId;
273
+ env.SLACK_USER_ID = userId;
274
+ if (botUserId) env.SLACK_BOT_USER_ID = botUserId;
275
+
276
+ const proc = spawn(CLAUDE_PATH, args, { env: env as NodeJS.ProcessEnv, cwd: spawnCwd, stdio: ["pipe", "pipe", "pipe"] });
277
+ proc.stdin!.end();
278
+ activeProcesses.set(threadTs, proc);
279
+
280
+ let accumulatedText = "";
281
+ let appendChain = Promise.resolve();
282
+ let lastUpdateTime = 0;
283
+ let lastUpdatePromise = Promise.resolve();
284
+ let updateCount = 0;
285
+ let deltaCount = 0;
286
+ let jsonBuffer = "";
287
+ let stopped = false;
288
+ let done = false;
289
+ let resultData: any = null;
290
+ const startTime = Date.now();
291
+
292
+ // Tool tracking for agentic visualization
293
+ // Maps content_block index → tool info (including the tool_use_id for sub-agent correlation)
294
+ const activeTools = new Map<number, {
295
+ name: string;
296
+ inputJson: string;
297
+ taskId: string;
298
+ toolUseId: string;
299
+ }>();
300
+ let taskIdCounter = 0;
301
+ let thinkingTaskDone = false;
302
+ let planModeActive = false;
303
+
304
+ // Sub-agent tracking: maps a Task tool_use_id → its visual task info
305
+ const subAgentTasks = new Map<string, { description: string; taskId: string }>();
306
+
307
+ // Completed tool tracking: maps tool_use_id → task info so we can update
308
+ // the task card with output/sources/error when the tool result arrives later.
309
+ const completedTools = new Map<string, { taskId: string; name: string; title: string }>();
310
+
311
+ log(channelId, `Claude process started: pid=${proc.pid}, session=${sessionId}, resume=${isResume}, streaming=${!streamFailed}`);
312
+
313
+ // Clear debug file for this run
314
+ if (STREAM_DEBUG) {
315
+ writeFileSync(STREAM_DEBUG_FILE, "");
316
+ log(channelId, `[STREAM_DEBUG] Dumping raw NDJSON to ${STREAM_DEBUG_FILE}`);
317
+ }
318
+
319
+ proc.stdout!.on("data", (chunk: Buffer) => {
320
+ const raw = chunk.toString();
321
+ jsonBuffer += raw;
322
+ const lines = jsonBuffer.split("\n");
323
+ jsonBuffer = lines.pop()!;
324
+
325
+ for (const line of lines) {
326
+ if (!line.trim()) continue;
327
+
328
+ // Dump every raw line to debug file
329
+ if (STREAM_DEBUG) {
330
+ try { appendFileSync(STREAM_DEBUG_FILE, line + "\n"); } catch {}
331
+ }
332
+
333
+ try {
334
+ const data = JSON.parse(line);
335
+
336
+ // ── system messages ────────────────────────────────
337
+ if (data.type === "system") {
338
+ log(channelId, `stream: type=system subtype=${data.subtype} session_id=${data.session_id} model=${data.model || "n/a"}`);
339
+ if (data.subtype === "init" && data.session_id) {
340
+ const oldId = sessionId;
341
+ sessionId = data.session_id;
342
+ upsertSession(threadTs, data.session_id);
343
+ log(channelId, `Session ID updated: ${oldId} -> ${data.session_id}`);
344
+ } else if (data.subtype === "status") {
345
+ // Track permission mode changes (plan mode entry/exit)
346
+ if (data.permissionMode === "plan") {
347
+ planModeActive = true;
348
+ log(channelId, `stream: plan mode activated (permissionMode=plan)`);
349
+ } else if (planModeActive && data.permissionMode !== "plan") {
350
+ planModeActive = false;
351
+ log(channelId, `stream: plan mode deactivated (permissionMode=${data.permissionMode})`);
352
+ }
353
+ }
354
+
355
+ // ── stream_event (raw Claude API events) ──────────
356
+ } else if (data.type === "stream_event") {
357
+ const evt = data.event;
358
+ const parentId = data.parent_tool_use_id;
359
+
360
+ // ── Sub-agent stream events: update parent task details ──
361
+ if (parentId && subAgentTasks.has(parentId)) {
362
+ // Sub-agent events are typically complete messages, but if any
363
+ // stream_events leak through with a parent_tool_use_id, log them.
364
+ if (evt?.type === "content_block_start" && evt.content_block?.type === "tool_use") {
365
+ const subTool = evt.content_block.name;
366
+ const parentTask = subAgentTasks.get(parentId)!;
367
+ const detail = TOOL_STATUS_MAP[subTool] || `Using ${subTool}...`;
368
+ safeAppend({
369
+ chunks: [{
370
+ type: "task_update",
371
+ id: parentTask.taskId,
372
+ title: parentTask.description,
373
+ status: "in_progress" as const,
374
+ details: detail,
375
+ }],
376
+ });
377
+ }
378
+ continue; // don't process sub-agent stream_events as top-level
379
+ }
380
+
381
+ if (evt?.type === "message_start") {
382
+ log(channelId, `stream: message_start model=${evt.message?.model} id=${evt.message?.id}`);
383
+
384
+ } else if (evt?.type === "content_block_start") {
385
+ const blockType = evt.content_block?.type;
386
+ log(channelId, `stream: content_block_start index=${evt.index} type=${blockType}`);
387
+
388
+ // ── Plan mode detection: create plan streamer before any other appends ──
389
+ if (blockType === "tool_use" && evt.content_block.name === "EnterPlanMode" && !streamer) {
390
+ initStreamer("plan");
391
+ safeAppend({
392
+ chunks: [{ type: "plan_update", title: "Planning..." }],
393
+ });
394
+ log(channelId, `Plan mode: created plan streamer with plan_update title`);
395
+ }
396
+
397
+ // ── Ensure streamer exists (defaults to timeline) ──
398
+ // Skip for thinking blocks — no visual output, and creating the
399
+ // streamer (chat.startStream) clears the native setStatus indicator.
400
+ if (!streamer && !streamFailed && blockType !== "thinking") {
401
+ initStreamer("timeline");
402
+ }
403
+
404
+ // ── Complete the "Thinking..." task (timeline only) ──
405
+ // In timeline mode, we show a brief "Thinking" completed card.
406
+ // In plan mode, the plan_update title is the indicator.
407
+ // Skip for thinking blocks — the native setStatus indicator is still active.
408
+ if (!thinkingTaskDone && !streamFailed && blockType !== "thinking") {
409
+ thinkingTaskDone = true;
410
+ if (displayMode === "timeline") {
411
+ safeAppend({
412
+ chunks: [{ type: "task_update", id: "thinking", title: "Thinking", status: "complete" as const }],
413
+ });
414
+ }
415
+ }
416
+
417
+ // ── Track tool_use blocks for agentic visualization ──
418
+ if (blockType === "tool_use") {
419
+ const toolName = evt.content_block.name;
420
+ const toolUseId = evt.content_block.id || "";
421
+ const taskId = `task_${++taskIdCounter}`;
422
+ activeTools.set(evt.index, { name: toolName, inputJson: "", taskId, toolUseId });
423
+
424
+ // Update Slack status bar
425
+ const statusMsg = TOOL_STATUS_MAP[toolName] || `is using ${toolName}...`;
426
+ setStatus(statusMsg).catch(() => {});
427
+ log(channelId, `Tool start: ${toolName} (index=${evt.index}, taskId=${taskId}, toolUseId=${toolUseId})`);
428
+
429
+ // Emit in-progress task chunk (skip hidden/meta tools)
430
+ if (!HIDDEN_TOOLS.has(toolName)) {
431
+ safeAppend({
432
+ chunks: [{
433
+ type: "task_update",
434
+ id: taskId,
435
+ title: `Using ${toolName}...`,
436
+ status: "in_progress" as const,
437
+ }],
438
+ });
439
+ }
440
+ }
441
+
442
+ // ── Thinking block: show indicator ──
443
+ if (blockType === "thinking") {
444
+ log(channelId, `stream: thinking block started (index=${evt.index})`);
445
+ if (displayMode === "plan") {
446
+ setStatus("is planning...").catch(() => {});
447
+ } else {
448
+ setStatus("is thinking...").catch(() => {});
449
+ }
450
+ }
451
+
452
+ } else if (evt?.type === "content_block_delta") {
453
+ if (evt?.delta?.type === "text_delta") {
454
+ deltaCount++;
455
+ const deltaText = evt.delta.text;
456
+ accumulatedText += deltaText;
457
+
458
+ if (deltaCount === 1 || deltaCount % 10 === 0) {
459
+ log(channelId, `stream: text_delta #${deltaCount}, accumulated=${accumulatedText.length} chars`);
460
+ }
461
+
462
+ // Ensure streamer + mark thinking done on first text
463
+ if (!streamer && !streamFailed) initStreamer("timeline");
464
+ if (!thinkingTaskDone && !streamFailed) {
465
+ thinkingTaskDone = true;
466
+ if (displayMode === "timeline") {
467
+ safeAppend({
468
+ chunks: [{ type: "task_update", id: "thinking", title: "Thinking", status: "complete" as const }],
469
+ });
470
+ }
471
+ }
472
+
473
+ // Streaming path: use chatStream
474
+ if (!streamFailed) {
475
+ appendChain = appendChain.then(async () => {
476
+ if (!streamerActive) {
477
+ streamerActive = true;
478
+ log(channelId, `Streamer activated: first text append`);
479
+ }
480
+ await streamer.append({ markdown_text: deltaText });
481
+ ensureStopButton();
482
+ }).catch((err: any) => {
483
+ if (!streamFailed) {
484
+ logErr(channelId, `Streaming failed, falling back to chat.update: ${err.message}`);
485
+ streamFailed = true;
486
+ }
487
+ });
488
+ }
489
+
490
+ // Fallback path: throttled chat.update
491
+ if (streamFailed) {
492
+ const now = Date.now();
493
+ if (!done && now - lastUpdateTime >= UPDATE_INTERVAL_MS) {
494
+ lastUpdateTime = now;
495
+ updateCount++;
496
+ log(channelId, `chat.update #${updateCount}: ${accumulatedText.length} chars`);
497
+ lastUpdatePromise = ensureStopButton().then(() => {
498
+ if (!stopMsgTs) return;
499
+ return client.chat.update({
500
+ channel: channelId,
501
+ ts: stopMsgTs,
502
+ text: accumulatedText,
503
+ blocks: buildBlocks(accumulatedText, threadTs, true),
504
+ });
505
+ }).catch((err: any) => logErr(channelId, `chat.update failed: ${err.message}`));
506
+ }
507
+ }
508
+ } else if (evt?.delta?.type === "input_json_delta") {
509
+ // Accumulate tool input JSON for title extraction
510
+ const tool = activeTools.get(evt.index);
511
+ if (tool) {
512
+ tool.inputJson += evt.delta.partial_json || "";
513
+ }
514
+ } else if (evt?.delta?.type === "thinking_delta") {
515
+ // Extended thinking — no visual output, just log occasionally
516
+ }
517
+
518
+ } else if (evt?.type === "content_block_stop") {
519
+ log(channelId, `stream: content_block_stop index=${evt.index}`);
520
+
521
+ // Complete tool task chunk
522
+ const tool = activeTools.get(evt.index);
523
+ if (tool) {
524
+ activeTools.delete(evt.index);
525
+
526
+ let parsedInput: any = {};
527
+ try { parsedInput = JSON.parse(tool.inputJson); } catch {}
528
+ const title = toolTitle(tool.name, parsedInput);
529
+ log(channelId, `Tool complete: ${tool.name} -> "${title}"`);
530
+
531
+ // ── Special handling: AskUserQuestion ──
532
+ // Render the question + options as visible content so the user
533
+ // sees what Claude wanted to ask (the tool itself errors in
534
+ // non-interactive mode, but the question is still valuable).
535
+ if (tool.name === "AskUserQuestion") {
536
+ const questions = parsedInput.questions || [];
537
+ const parts: string[] = [];
538
+ for (const q of questions) {
539
+ if (q.question) parts.push(`> *${q.question}*`);
540
+ for (const opt of q.options || []) {
541
+ const desc = opt.description ? ` — ${opt.description}` : "";
542
+ parts.push(`> • ${opt.label}${desc}`);
543
+ }
544
+ }
545
+ if (parts.length > 0) {
546
+ safeAppend({ markdown_text: "\n" + parts.join("\n") + "\n\n" });
547
+ }
548
+ // Mark the task complete with the question as title
549
+ safeAppend({
550
+ chunks: [{
551
+ type: "task_update",
552
+ id: tool.taskId,
553
+ title,
554
+ status: "complete" as const,
555
+ }],
556
+ });
557
+
558
+ // ── Special handling: Task (sub-agent) ──
559
+ } else if (tool.name === "Task") {
560
+ const desc = parsedInput.description || parsedInput.subagent_type || "Sub-agent";
561
+ subAgentTasks.set(tool.toolUseId, { description: `Sub-agent: ${desc}`, taskId: tool.taskId });
562
+ log(channelId, `Sub-agent registered: toolUseId=${tool.toolUseId} desc="${desc}"`);
563
+ // Don't mark complete yet — it completes when the sub-agent finishes
564
+ // (we'll get a type=user tool_result for this toolUseId)
565
+ } else if (HIDDEN_TOOLS.has(tool.name)) {
566
+ // Hidden tools (EnterPlanMode, ExitPlanMode):
567
+ // no task_update emitted on start, so nothing to complete.
568
+ // But update plan title on ExitPlanMode
569
+ if (tool.name === "ExitPlanMode" && displayMode === "plan") {
570
+ safeAppend({
571
+ chunks: [{ type: "plan_update", title: "Plan ready" }],
572
+ });
573
+ }
574
+ } else {
575
+ // Normal tool: emit complete task chunk
576
+ // Attach sources for web tools (WebFetch, WebSearch)
577
+ const sources: { type: "url"; url: string; text: string }[] = [];
578
+ if (tool.name === "WebFetch" && parsedInput.url) {
579
+ try {
580
+ const hostname = new URL(parsedInput.url).hostname;
581
+ sources.push({ type: "url", url: parsedInput.url, text: hostname });
582
+ } catch {
583
+ sources.push({ type: "url", url: parsedInput.url, text: parsedInput.url });
584
+ }
585
+ }
586
+
587
+ safeAppend({
588
+ chunks: [{
589
+ type: "task_update",
590
+ id: tool.taskId,
591
+ title,
592
+ status: "complete" as const,
593
+ ...(sources.length > 0 ? { sources } : {}),
594
+ }],
595
+ });
596
+
597
+ // Register for later output/error update when tool result arrives
598
+ completedTools.set(tool.toolUseId, { taskId: tool.taskId, name: tool.name, title });
599
+ }
600
+
601
+ // Reset status to thinking/planning
602
+ setStatus(planModeActive ? "is planning..." : "is thinking...").catch(() => {});
603
+ }
604
+
605
+ } else if (evt?.type === "message_delta") {
606
+ log(channelId, `stream: message_delta stop_reason=${evt.delta?.stop_reason}`);
607
+ } else if (evt?.type === "message_stop") {
608
+ log(channelId, `stream: message_stop`);
609
+ } else {
610
+ log(channelId, `stream: stream_event type=${evt?.type}`);
611
+ }
612
+
613
+ // ── assistant messages (complete turn) ────────────
614
+ } else if (data.type === "assistant") {
615
+ const parentId = data.parent_tool_use_id;
616
+ const content = data.message?.content;
617
+ const model = data.message?.model || "unknown";
618
+
619
+ if (parentId && subAgentTasks.has(parentId)) {
620
+ // Sub-agent assistant message: extract tool calls to update details
621
+ const parentTask = subAgentTasks.get(parentId)!;
622
+ const toolCalls = (content || []).filter((c: any) => c.type === "tool_use");
623
+ if (toolCalls.length > 0) {
624
+ const toolNames = toolCalls.map((t: any) => t.name).join(", ");
625
+ log(channelId, `stream: sub-agent (${model}) tools: ${toolNames} [parent=${parentId}]`);
626
+ // Update the sub-agent task's details with what it's doing
627
+ const lastTool = toolCalls[toolCalls.length - 1];
628
+ let detail = TOOL_STATUS_MAP[lastTool.name] || `Using ${lastTool.name}...`;
629
+ // Try to get a more specific detail from the tool input
630
+ try {
631
+ if (lastTool.name === "Read" && lastTool.input?.file_path) {
632
+ detail = `Reading ${lastTool.input.file_path.split("/").pop()}`;
633
+ } else if (lastTool.name === "Grep" && lastTool.input?.pattern) {
634
+ detail = `Searching: ${lastTool.input.pattern}`;
635
+ } else if (lastTool.name === "Glob" && lastTool.input?.pattern) {
636
+ detail = `Finding: ${lastTool.input.pattern}`;
637
+ } else if (lastTool.name === "Bash" && lastTool.input?.command) {
638
+ const cmd = lastTool.input.command;
639
+ detail = `Running: ${cmd.length > 40 ? cmd.slice(0, 37) + "..." : cmd}`;
640
+ }
641
+ } catch {}
642
+ safeAppend({
643
+ chunks: [{
644
+ type: "task_update",
645
+ id: parentTask.taskId,
646
+ title: parentTask.description,
647
+ status: "in_progress" as const,
648
+ details: detail,
649
+ }],
650
+ });
651
+ } else {
652
+ const textLen = content?.[0]?.text?.length || 0;
653
+ log(channelId, `stream: sub-agent (${model}) text response, len=${textLen} [parent=${parentId}]`);
654
+ }
655
+ } else {
656
+ const textLen = content?.[0]?.text?.length || 0;
657
+ log(channelId, `stream: assistant message (${model}), content_length=${textLen} chars`);
658
+ }
659
+
660
+ // ── user messages (tool results) ──────────────────
661
+ } else if (data.type === "user") {
662
+ const parentId = data.parent_tool_use_id;
663
+ const resultSummary = data.tool_use_result;
664
+ const content = data.message?.content;
665
+
666
+ if (parentId && subAgentTasks.has(parentId)) {
667
+ // Sub-agent tool result: update details
668
+ const parentTask = subAgentTasks.get(parentId)!;
669
+ // Check if this is the sub-agent's prompt (first user message) or a tool result
670
+ const firstBlock = content?.[0];
671
+ if (firstBlock?.type === "tool_result") {
672
+ log(channelId, `stream: sub-agent tool result [parent=${parentId}]`);
673
+ } else {
674
+ log(channelId, `stream: sub-agent prompt delivered [parent=${parentId}]`);
675
+ }
676
+ } else if (parentId === null || parentId === undefined) {
677
+ // Top-level tool result
678
+ const firstBlock = content?.[0];
679
+ const toolUseId = firstBlock?.tool_use_id;
680
+
681
+ // Check if this completes a sub-agent Task
682
+ if (toolUseId && subAgentTasks.has(toolUseId)) {
683
+ const subTask = subAgentTasks.get(toolUseId)!;
684
+ // Extract a brief output summary from the result
685
+ const subOutput = extractToolOutput(resultSummary, firstBlock);
686
+ log(channelId, `stream: sub-agent completed: ${subTask.description} [toolUseId=${toolUseId}]`);
687
+ safeAppend({
688
+ chunks: [{
689
+ type: "task_update",
690
+ id: subTask.taskId,
691
+ title: subTask.description,
692
+ status: "complete" as const,
693
+ details: undefined,
694
+ ...(subOutput ? { output: subOutput } : {}),
695
+ }],
696
+ });
697
+ subAgentTasks.delete(toolUseId);
698
+
699
+ // Update a completed tool's task card with output or error status
700
+ } else if (toolUseId && completedTools.has(toolUseId)) {
701
+ const completed = completedTools.get(toolUseId)!;
702
+ completedTools.delete(toolUseId);
703
+
704
+ const isError = firstBlock?.is_error === true;
705
+ const output = extractToolOutput(resultSummary, firstBlock);
706
+
707
+ // Extract sources from WebSearch results (URLs in the content)
708
+ const sources: { type: "url"; url: string; text: string }[] = [];
709
+ if (completed.name === "WebSearch" && firstBlock?.content) {
710
+ const raw = typeof firstBlock.content === "string" ? firstBlock.content : "";
711
+ const urlRegex = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
712
+ let m: RegExpExecArray | null;
713
+ while ((m = urlRegex.exec(raw)) !== null && sources.length < 4) {
714
+ sources.push({ type: "url", url: m[2], text: m[1] });
715
+ }
716
+ }
717
+
718
+ if (isError || output || sources.length > 0) {
719
+ log(channelId, `stream: tool result update taskId=${completed.taskId} error=${isError} sources=${sources.length}${output ? ` output="${output.substring(0, 60)}"` : ""}`);
720
+ safeAppend({
721
+ chunks: [{
722
+ type: "task_update",
723
+ id: completed.taskId,
724
+ title: completed.title,
725
+ status: isError ? "error" as const : "complete" as const,
726
+ ...(output ? { output } : {}),
727
+ ...(sources.length > 0 ? { sources } : {}),
728
+ }],
729
+ });
730
+ } else {
731
+ log(channelId, `stream: tool result (no output) [toolUseId=${toolUseId}]`);
732
+ }
733
+ } else {
734
+ // Untracked tool result — just log
735
+ const summary = typeof resultSummary === "string"
736
+ ? resultSummary
737
+ : resultSummary?.message || "";
738
+ log(channelId, `stream: tool result${summary ? `: ${summary.substring(0, 80)}` : ""}`);
739
+ }
740
+ } else {
741
+ log(channelId, `stream: user message (parent=${parentId})`);
742
+ }
743
+
744
+ // ── result (final) ────────────────────────────────
745
+ } else if (data.type === "result") {
746
+ resultData = data;
747
+ const elapsed = Date.now() - startTime;
748
+ log(channelId, `stream: result subtype=${data.subtype} is_error=${data.is_error} duration_ms=${data.duration_ms} turns=${data.num_turns} cost=$${data.total_cost_usd?.toFixed(4)} session=${data.session_id}`);
749
+ if (data.is_error && data.result) {
750
+ logErr(channelId, `stream: error detail: ${typeof data.result === "string" ? data.result : JSON.stringify(data.result)}`);
751
+ }
752
+ log(channelId, `stream: total deltas=${deltaCount}, slack updates=${updateCount}, wall_time=${elapsed}ms`);
753
+ } else {
754
+ log(channelId, `stream: unknown type=${data.type}`);
755
+ }
756
+ } catch (err: any) {
757
+ logErr(channelId, `Failed to parse stream line: ${err.message} — raw: ${line.substring(0, 200)}`);
758
+ }
759
+ }
760
+ });
761
+
762
+ proc.stderr!.on("data", (chunk: Buffer) => {
763
+ logErr(channelId, `claude stderr: ${chunk.toString().trim()}`);
764
+ });
765
+
766
+ proc.on("error", (err) => {
767
+ logErr(channelId, `claude process error: ${err.message}`);
768
+ activeProcesses.delete(threadTs);
769
+ });
770
+
771
+ return new Promise<void>((resolve) => {
772
+ proc.on("close", async (code, signal) => {
773
+ done = true;
774
+ activeProcesses.delete(threadTs);
775
+ stopped = signal === "SIGTERM";
776
+ const elapsed = Date.now() - startTime;
777
+
778
+ log(channelId, `Claude process exited: code=${code} signal=${signal} pid=${proc.pid} elapsed=${elapsed}ms stopped=${stopped}`);
779
+ log(channelId, `Final stats: deltas=${deltaCount}, slack_updates=${updateCount}, text_length=${accumulatedText.length}, streaming=${streamerActive}, mode=${displayMode}`);
780
+
781
+ // ── Usage logging ──────────────────────────────────
782
+ if (resultData) {
783
+ try {
784
+ addUsageLog(
785
+ threadTs, userId,
786
+ resultData.model || null,
787
+ resultData.input_tokens || 0,
788
+ resultData.output_tokens || 0,
789
+ resultData.total_cost_usd || 0,
790
+ resultData.duration_ms || elapsed,
791
+ resultData.num_turns || 0,
792
+ );
793
+ log(channelId, `Usage logged: cost=$${(resultData.total_cost_usd || 0).toFixed(4)} turns=${resultData.num_turns}`);
794
+ } catch (err: any) {
795
+ logErr(channelId, `Failed to log usage: ${err.message}`);
796
+ }
797
+ }
798
+
799
+ // ── Mark any remaining sub-agent tasks as complete ──
800
+ for (const [id, sub] of subAgentTasks) {
801
+ safeAppend({
802
+ chunks: [{
803
+ type: "task_update",
804
+ id: sub.taskId,
805
+ title: sub.description,
806
+ status: "complete" as const,
807
+ }],
808
+ });
809
+ }
810
+ subAgentTasks.clear();
811
+
812
+ // ── Finalize streaming or fallback ─────────────────
813
+ const finalizationBlocks = [buildFeedbackBlock(threadTs), buildDisclaimerBlock()];
814
+
815
+ log(channelId, `Finalize path: streamFailed=${streamFailed} streamerActive=${streamerActive}`);
816
+ if (!streamFailed && streamerActive) {
817
+ await appendChain;
818
+
819
+ // Complete the thinking indicator if it never got resolved (timeline only)
820
+ if (!thinkingTaskDone && displayMode === "timeline") {
821
+ thinkingTaskDone = true;
822
+ await streamer.append({
823
+ chunks: [{ type: "task_update", id: "thinking", title: "Thinking", status: "complete" }],
824
+ }).catch(() => {});
825
+ }
826
+
827
+ if (stopped) {
828
+ await streamer.append({ markdown_text: "\n\n_Stopped by user._" }).catch(() => {});
829
+ }
830
+
831
+ // Finalize stream as a durable message with feedback/disclaimer blocks
832
+ try {
833
+ await streamer.stop({ blocks: finalizationBlocks });
834
+ log(channelId, `Stream finalized with blocks (${accumulatedText.length} chars, mode=${displayMode})`);
835
+ } catch (err: any) {
836
+ logErr(channelId, `streamer.stop failed: ${err.message}`);
837
+ }
838
+
839
+ // Delete only the stop-button carrier
840
+ if (stopMsgTs) {
841
+ await client.chat.delete({ channel: channelId, ts: stopMsgTs }).catch((err: any) => {
842
+ logErr(channelId, `Failed to delete stop button carrier: ${err.message}`);
843
+ });
844
+ }
845
+ } else if (streamer && !streamerActive) {
846
+ // Streamer was created but never activated (no content appended)
847
+ const text = stopped
848
+ ? "_Stopped by user._"
849
+ : code !== 0 ? "Something went wrong." : "No response.";
850
+ log(channelId, `No text produced, final message: "${text}"`);
851
+
852
+ // Try to use the streamer for a clean message
853
+ try {
854
+ await streamer.append({ markdown_text: text });
855
+ await streamer.stop({ blocks: finalizationBlocks });
856
+ } catch {
857
+ // Streamer failed, fall back to postMessage
858
+ await client.chat.postMessage({
859
+ channel: channelId,
860
+ thread_ts: threadTs,
861
+ text,
862
+ blocks: [...buildBlocks(text, threadTs, false), ...finalizationBlocks],
863
+ }).catch((err: any) => logErr(channelId, `Final postMessage failed: ${err.message}`));
864
+ }
865
+ } else {
866
+ // Fallback: chat.update mode (no streamer or stream failed)
867
+ let finalText: string;
868
+ if (stopped) {
869
+ finalText = accumulatedText
870
+ ? accumulatedText + "\n\n_Stopped by user._"
871
+ : "_Stopped by user._";
872
+ } else {
873
+ finalText = accumulatedText || (code !== 0 ? "Something went wrong." : "No response.");
874
+ }
875
+
876
+ await lastUpdatePromise;
877
+ await ensureStopButton();
878
+
879
+ log(channelId, `Sending final chat.update (${finalText.length} chars)`);
880
+ if (stopMsgTs) {
881
+ await client.chat
882
+ .update({
883
+ channel: channelId,
884
+ ts: stopMsgTs,
885
+ text: finalText,
886
+ blocks: [...buildBlocks(finalText, threadTs, false), ...finalizationBlocks],
887
+ })
888
+ .catch((err: any) => logErr(channelId, `Final chat.update failed: ${err.message}`));
889
+ } else {
890
+ await client.chat
891
+ .postMessage({
892
+ channel: channelId,
893
+ thread_ts: threadTs,
894
+ text: finalText,
895
+ blocks: [...buildBlocks(finalText, threadTs, false), ...finalizationBlocks],
896
+ })
897
+ .catch((err: any) => logErr(channelId, `Final postMessage failed: ${err.message}`));
898
+ }
899
+ }
900
+
901
+ // Clear Assistant status indicator
902
+ await setStatus("").catch(() => {});
903
+
904
+ log(channelId, `Done processing message from user=${userId}`);
905
+ resolve();
906
+ });
907
+ });
908
+ }