eniac-slack 0.1.43 → 0.1.44

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.44] - 2026-04-17
4
+
5
+ - fix: claude.test.ts TypeScript 빌드 오류 수정 (#55)
6
+ - Replace Claude SDK with CLI-based implementation for Slack bot (#49)
7
+
8
+
3
9
  ## [0.1.43] - 2026-03-16
4
10
 
5
11
  - fix(slack): 응답 완료 시 삭제+재발송 대신 편집으로 변경 (#48)
@@ -15,35 +15,20 @@ export type ChatEvent = {
15
15
  type: "error";
16
16
  message: string;
17
17
  };
18
- /**
19
- * Abort any in-flight chat request for the given thread.
20
- * Returns the AbortSignal if there was an active request that was aborted.
21
- */
22
18
  export declare function abortActiveChat(threadTs: string): AbortSignal | undefined;
23
- /**
24
- * Get the AbortSignal for the active request on a thread.
25
- */
26
19
  export declare function getAbortSignal(threadTs: string): AbortSignal | undefined;
27
20
  export declare function createSession(threadTs: string, workDir: string, authorUserId: string): Session;
28
21
  export declare function getSession(threadTs: string): Session | undefined;
29
- /**
30
- * Delete a session and clean up all associated files:
31
- * - SDK session JSONL file
32
- * - Working directory
33
- * - sessions.json entry
34
- */
35
22
  export declare function deleteSession(threadTs: string): boolean;
36
- /**
37
- * Delete ALL sessions and clean up all associated files (workDir, JSONL).
38
- * Returns the number of sessions deleted.
39
- */
40
23
  export declare function deleteAllSessions(): number;
41
24
  export declare function getAllSessions(): Map<string, Session>;
42
25
  /**
43
- * Send a message to a Claude session via the SDK.
26
+ * Send a message to a Claude session via the CLI.
44
27
  *
45
- * Uses `canUseTool` callback to handle permission requests
46
- * through Slack interactive buttons.
28
+ * Spawns `claude -p` and parses stream-json output. When a dangerous tool
29
+ * (Bash, Edit, Write, …) is detected, the process is killed before the tool
30
+ * runs, a Slack approval button is shown, and the session is resumed with the
31
+ * user's decision. This loop repeats until the turn completes normally.
47
32
  */
48
33
  export declare function chat(threadTs: string, userMessage: string, slackClient: WebClient, channel: string, images?: SlackImageFile[]): AsyncGenerator<ChatEvent, void, unknown>;
49
34
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"claude.d.ts","sourceRoot":"","sources":["../../src/services/claude.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAK9D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAIhD,UAAU,OAAO;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,OAAO,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,SAAS,GACjB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AA0KvC;;;GAGG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,CASzE;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,CAExE;AAED,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,GACnB,OAAO,CAaT;AAED,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAEhE;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAuCvD;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAyC1C;AAED,wBAAgB,cAAc,IAAI,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAErD;AAsCD;;;;;GAKG;AACH,wBAAuB,IAAI,CACzB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,SAAS,EACtB,OAAO,EAAE,MAAM,EACf,MAAM,CAAC,EAAE,cAAc,EAAE,GACxB,cAAc,CAAC,SAAS,EAAE,IAAI,EAAE,OAAO,CAAC,CAqP1C"}
1
+ {"version":3,"file":"claude.d.ts","sourceRoot":"","sources":["../../src/services/claude.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAK9D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAIhD,UAAU,OAAO;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,OAAO,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,SAAS,GACjB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AA+JvC,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,CASzE;AAED,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,CAExE;AAED,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,GACnB,OAAO,CAaT;AAED,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAEhE;AAED,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAmCvD;AAED,wBAAgB,iBAAiB,IAAI,MAAM,CAoC1C;AAED,wBAAgB,cAAc,IAAI,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAErD;AA+SD;;;;;;;GAOG;AACH,wBAAuB,IAAI,CACzB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,SAAS,EACtB,OAAO,EAAE,MAAM,EACf,MAAM,CAAC,EAAE,cAAc,EAAE,GACxB,cAAc,CAAC,SAAS,EAAE,IAAI,EAAE,OAAO,CAAC,CAkF1C"}
@@ -1,4 +1,5 @@
1
- import { query } from "@anthropic-ai/claude-agent-sdk";
1
+ import { execa } from "execa";
2
+ import { createInterface } from "node:readline";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import fs from "node:fs";
4
5
  import path from "node:path";
@@ -28,24 +29,8 @@ function saveSessions() {
28
29
  console.warn("[sessions] failed to save:", err);
29
30
  }
30
31
  }
31
- // --- MCP server config loader ---
32
+ // --- MCP config path ---
32
33
  const MCP_CONFIG_PATH = path.join(os.homedir(), ".claude", ".mcp.json");
33
- function loadMcpServers() {
34
- try {
35
- const data = fs.readFileSync(MCP_CONFIG_PATH, "utf-8");
36
- const config = JSON.parse(data);
37
- if (config.mcpServers && Object.keys(config.mcpServers).length > 0) {
38
- console.log(`[mcp] loaded ${Object.keys(config.mcpServers).length} MCP server(s): ${Object.keys(config.mcpServers).join(", ")}`);
39
- return config.mcpServers;
40
- }
41
- }
42
- catch {
43
- console.log("[mcp] no MCP config found or failed to parse");
44
- }
45
- return undefined;
46
- }
47
- const mcpServers = loadMcpServers();
48
- // --- MCP server selection (all servers always included) ---
49
34
  // --- System prompt for Slack bot ---
50
35
  const SYSTEM_PROMPT = `
51
36
  You are an AI assistant in a Slack workspace, helping with software engineering tasks.
@@ -108,6 +93,15 @@ Before modifying any infrastructure resource:
108
93
  If ports, volumes, or networks are shared with other services, you MUST notify the user.
109
94
  `.trim();
110
95
  const SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 2 weeks
96
+ // Tools that require Slack approval before running
97
+ const DANGEROUS_TOOLS = new Set([
98
+ "Bash",
99
+ "Edit",
100
+ "Write",
101
+ "MultiEdit",
102
+ "NotebookEdit",
103
+ ]);
104
+ const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
111
105
  function cleanupExpiredSessions() {
112
106
  const now = Date.now();
113
107
  let cleaned = 0;
@@ -146,10 +140,6 @@ const sessions = loadSessions();
146
140
  cleanupExpiredSessions();
147
141
  // --- Active request tracking for abort-on-new-message ---
148
142
  const activeRequests = new Map();
149
- /**
150
- * Abort any in-flight chat request for the given thread.
151
- * Returns the AbortSignal if there was an active request that was aborted.
152
- */
153
143
  export function abortActiveChat(threadTs) {
154
144
  const controller = activeRequests.get(threadTs);
155
145
  if (controller) {
@@ -160,9 +150,6 @@ export function abortActiveChat(threadTs) {
160
150
  }
161
151
  return undefined;
162
152
  }
163
- /**
164
- * Get the AbortSignal for the active request on a thread.
165
- */
166
153
  export function getAbortSignal(threadTs) {
167
154
  return activeRequests.get(threadTs)?.signal;
168
155
  }
@@ -183,20 +170,12 @@ export function createSession(threadTs, workDir, authorUserId) {
183
170
  export function getSession(threadTs) {
184
171
  return sessions.get(threadTs);
185
172
  }
186
- /**
187
- * Delete a session and clean up all associated files:
188
- * - SDK session JSONL file
189
- * - Working directory
190
- * - sessions.json entry
191
- */
192
173
  export function deleteSession(threadTs) {
193
174
  const session = sessions.get(threadTs);
194
175
  if (!session)
195
176
  return false;
196
177
  console.log(`[sessions] deleting session: threadTs=${threadTs}, sessionId=${session.sessionId}`);
197
- // Abort any in-flight request
198
178
  abortActiveChat(threadTs);
199
- // Delete SDK session file (~/.claude/projects/{cwd-encoded}/{sessionId}.jsonl)
200
179
  const cwdEncoded = session.workDir.replace(/\//g, "-");
201
180
  const sessionFile = path.join(os.homedir(), ".claude", "projects", cwdEncoded, `${session.sessionId}.jsonl`);
202
181
  try {
@@ -206,7 +185,6 @@ export function deleteSession(threadTs) {
206
185
  catch {
207
186
  // File may not exist
208
187
  }
209
- // Delete worktree directory
210
188
  try {
211
189
  fs.rmSync(session.workDir, { recursive: true, force: true });
212
190
  console.log(`[sessions] deleted workDir: ${session.workDir}`);
@@ -214,16 +192,11 @@ export function deleteSession(threadTs) {
214
192
  catch {
215
193
  // Directory may not exist
216
194
  }
217
- // Remove from in-memory map and persist
218
195
  sessions.delete(threadTs);
219
196
  saveSessions();
220
197
  console.log(`[sessions] session deleted: threadTs=${threadTs}`);
221
198
  return true;
222
199
  }
223
- /**
224
- * Delete ALL sessions and clean up all associated files (workDir, JSONL).
225
- * Returns the number of sessions deleted.
226
- */
227
200
  export function deleteAllSessions() {
228
201
  const count = sessions.size;
229
202
  if (count === 0)
@@ -231,22 +204,17 @@ export function deleteAllSessions() {
231
204
  console.log(`[sessions] deleting all ${count} sessions`);
232
205
  for (const [threadTs, session] of sessions) {
233
206
  console.log(`[sessions] deleting session: threadTs=${threadTs}, sessionId=${session.sessionId}`);
234
- // Abort any in-flight request
235
207
  abortActiveChat(threadTs);
236
- // Delete SDK session file
237
208
  const cwdEncoded = session.workDir.replace(/\//g, "-");
238
209
  const sessionFile = path.join(os.homedir(), ".claude", "projects", cwdEncoded, `${session.sessionId}.jsonl`);
239
210
  try {
240
211
  fs.unlinkSync(sessionFile);
241
- console.log(`[sessions] deleted session file: ${sessionFile}`);
242
212
  }
243
213
  catch {
244
214
  // File may not exist
245
215
  }
246
- // Delete worktree directory
247
216
  try {
248
217
  fs.rmSync(session.workDir, { recursive: true, force: true });
249
- console.log(`[sessions] deleted workDir: ${session.workDir}`);
250
218
  }
251
219
  catch {
252
220
  // Directory may not exist
@@ -272,9 +240,16 @@ function describeToolInput(toolName, input) {
272
240
  return `\`\`\`\n${JSON.stringify(input, null, 2).slice(0, 500)}\n\`\`\``;
273
241
  }
274
242
  }
275
- /**
276
- * Convert raw SDK / process error messages into user-friendly Korean messages.
277
- */
243
+ function killWithEscalation(proc) {
244
+ proc.kill("SIGTERM");
245
+ const timer = setTimeout(() => {
246
+ try {
247
+ proc.kill("SIGKILL");
248
+ }
249
+ catch { /* already exited */ }
250
+ }, 5_000);
251
+ void proc.then(() => clearTimeout(timer)).catch(() => clearTimeout(timer));
252
+ }
278
253
  function toFriendlyError(rawMessage) {
279
254
  if (/process exited with code/i.test(rawMessage)) {
280
255
  return "Claude 작업 중 예기치 않은 문제가 발생했어요. 잠시 후 다시 시도해 주세요. 문제가 계속되면 관리자에게 문의해 주세요.";
@@ -288,145 +263,130 @@ function toFriendlyError(rawMessage) {
288
263
  if (/timeout|timed out/i.test(rawMessage)) {
289
264
  return "Claude 응답 시간이 초과되었어요. 질문을 좀 더 간결하게 줄여서 다시 시도해 주세요.";
290
265
  }
291
- // Fallback — show original message with friendly wrapper
292
266
  return `Claude 작업 중 문제가 발생했어요: ${rawMessage}`;
293
267
  }
268
+ // --- CLI argument builder ---
269
+ function buildArgs(session, prompt, isResume) {
270
+ const args = [
271
+ "-p", prompt,
272
+ "--output-format", "stream-json",
273
+ "--verbose",
274
+ "--include-partial-messages",
275
+ "--dangerously-skip-permissions",
276
+ "--append-system-prompt", SYSTEM_PROMPT,
277
+ ...(isResume
278
+ ? ["--resume", session.sessionId]
279
+ : ["--session-id", session.sessionId]),
280
+ ];
281
+ if (fs.existsSync(MCP_CONFIG_PATH)) {
282
+ args.push("--mcp-config", MCP_CONFIG_PATH);
283
+ }
284
+ return args;
285
+ }
294
286
  /**
295
- * Send a message to a Claude session via the SDK.
287
+ * Runs one claude CLI process. Yields ChatEvents (text/error).
288
+ * Returns a ToolInterception if a dangerous tool was intercepted mid-stream,
289
+ * or null on normal completion.
296
290
  *
297
- * Uses `canUseTool` callback to handle permission requests
298
- * through Slack interactive buttons.
291
+ * Kill strategy: when we see content_block_stop for a dangerous tool_use,
292
+ * the process is killed before the tool executes (session file not yet written
293
+ * for this turn), then we ask Slack for approval.
299
294
  */
300
- export async function* chat(threadTs, userMessage, slackClient, channel, images) {
301
- const session = sessions.get(threadTs);
302
- if (!session) {
303
- throw new Error(`No session found for thread ${threadTs}`);
304
- }
305
- // Set up abort controller for this request
306
- const abortController = new AbortController();
307
- activeRequests.set(threadTs, abortController);
308
- const { signal } = abortController;
309
- // Tools that are safe to auto-allow without Slack approval
310
- const AUTO_ALLOW_TOOLS = new Set([
311
- "Read",
312
- "Glob",
313
- "Grep",
314
- "WebSearch",
315
- "WebFetch",
316
- "Agent",
317
- "TodoRead",
318
- "ListMcpResources",
319
- "ReadMcpResource",
320
- ]);
321
- const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
322
- // Permission handler — auto-allows safe tools, asks Slack for dangerous ones
323
- const canUseTool = async (toolName, input, { signal }) => {
324
- // Auto-allow safe read-only tools
325
- if (AUTO_ALLOW_TOOLS.has(toolName)) {
326
- console.log(`[claude] auto-allow: ${toolName}`);
327
- return { behavior: "allow" };
328
- }
329
- const permId = randomUUID();
330
- const description = describeToolInput(toolName, input);
331
- console.log(`[claude] permission request: tool=${toolName}, id=${permId}`);
332
- // Race between Slack button response and timeout
333
- const granted = await Promise.race([
334
- requestPermission(slackClient, channel, threadTs, permId, toolName, description, session.authorUserId),
335
- new Promise((resolve) => setTimeout(() => {
336
- console.log(`[claude] permission timeout: ${permId}`);
337
- resolve(false);
338
- }, PERMISSION_TIMEOUT_MS)),
339
- ]);
340
- console.log(`[claude] permission ${granted ? "approved" : "denied"}: tool=${toolName}`);
341
- if (granted) {
342
- return { behavior: "allow" };
343
- }
344
- else {
345
- return { behavior: "deny", message: "User denied or timed out via Slack" };
346
- }
347
- };
348
- session.lastActivityAt = Date.now();
295
+ async function* runOnce(session, prompt, threadTs, signal, slackClient, channel) {
296
+ // Capture before marking started — first call needs --session-id, not --resume
297
+ const isResume = session.hasStarted;
298
+ session.hasStarted = true;
349
299
  saveSessions();
350
- console.log(`[claude] starting query, cwd=${session.workDir}, hasStarted=${session.hasStarted}`);
351
- // Save images to disk for Claude to read via Read tool
352
- let prompt = userMessage;
353
- if (images && images.length > 0) {
354
- const imgDir = path.join(session.workDir, ".eniac-images");
355
- fs.mkdirSync(imgDir, { recursive: true });
356
- const imagePaths = [];
357
- for (let i = 0; i < images.length; i++) {
358
- const imgPath = path.join(imgDir, `img-${Date.now()}-${i}.png`);
359
- fs.writeFileSync(imgPath, Buffer.from(images[i].base64, "base64"));
360
- imagePaths.push(imgPath);
361
- console.log(`[claude] saved image ${i}: ${imgPath} (${images[i].base64.length} base64 chars)`);
362
- }
363
- const fileList = imagePaths.map((p) => `- ${p}`).join("\n");
364
- prompt = `사용자가 이미지를 첨부했습니다. Read 도구로 아래 이미지 파일을 확인하세요:\n${fileList}\n\n사용자 메시지: ${userMessage}`;
365
- console.log(`[claude] ${images.length} image(s) saved to disk, using Read tool approach`);
366
- }
367
- let receivedSuccessResult = false;
368
- // Capture stderr for debugging process crashes
300
+ const args = buildArgs(session, prompt, isResume);
301
+ console.log(`[claude] launching: cwd=${session.workDir}, resume=${args.includes("--resume")}`);
302
+ const proc = execa("claude", args, {
303
+ cwd: session.workDir,
304
+ reject: false,
305
+ });
369
306
  const stderrChunks = [];
307
+ proc.stderr?.on("data", (chunk) => {
308
+ stderrChunks.push(chunk.toString());
309
+ if (stderrChunks.length > 50)
310
+ stderrChunks.shift();
311
+ });
312
+ const onAbort = () => killWithEscalation(proc);
313
+ signal.addEventListener("abort", onAbort, { once: true });
314
+ // Buffer text deltas — only yield after the process exits cleanly (no interception).
315
+ // This prevents pre-kill text from leaking to Slack when a dangerous tool is intercepted.
316
+ const textBuffer = [];
317
+ let resultFallback = null;
318
+ let receivedSuccessResult = false;
319
+ // Dangerous tool being assembled from stream deltas
320
+ let pendingTool = null;
321
+ // Set when we decide to intercept
322
+ let intercepted = null;
370
323
  try {
371
- const q = query({
372
- prompt,
373
- options: {
374
- cwd: session.workDir,
375
- canUseTool,
376
- permissionMode: "bypassPermissions",
377
- allowDangerouslySkipPermissions: true,
378
- includePartialMessages: true,
379
- systemPrompt: SYSTEM_PROMPT,
380
- stderr: (data) => {
381
- stderrChunks.push(data);
382
- // Keep only last 50 chunks to avoid memory bloat
383
- if (stderrChunks.length > 50)
384
- stderrChunks.shift();
385
- },
386
- ...(mcpServers ? { mcpServers } : {}),
387
- ...(session.hasStarted
388
- ? { resume: session.sessionId }
389
- : { sessionId: session.sessionId }),
390
- },
391
- });
392
- session.hasStarted = true;
393
- saveSessions();
394
- let hasYieldedText = false;
395
- for await (const message of q) {
396
- // Check if this request was aborted by a new incoming message
324
+ const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
325
+ for await (const line of rl) {
397
326
  if (signal.aborted) {
398
- console.log(`[claude] request aborted for thread_ts=${threadTs}`);
399
- return;
327
+ // onAbort already called killWithEscalation; break to reach await proc
328
+ break;
400
329
  }
401
- const msgType = message.type;
402
- // Real-time streaming text deltas
330
+ if (!line.trim())
331
+ continue;
332
+ let msg;
333
+ try {
334
+ msg = JSON.parse(line);
335
+ }
336
+ catch {
337
+ continue;
338
+ }
339
+ const msgType = msg.type;
403
340
  if (msgType === "stream_event") {
404
- const partial = message;
405
- if (partial.event?.type === "content_block_delta") {
406
- if (partial.event.delta?.type === "text_delta" &&
407
- partial.event.delta.text) {
408
- hasYieldedText = true;
409
- yield { type: "text", content: partial.event.delta.text };
341
+ const event = msg.event;
342
+ if (!event)
343
+ continue;
344
+ const evType = event.type;
345
+ if (evType === "content_block_start") {
346
+ const block = event.content_block;
347
+ if (block?.type === "tool_use") {
348
+ const name = block.name;
349
+ if (DANGEROUS_TOOLS.has(name)) {
350
+ pendingTool = { name, inputChunks: [] };
351
+ }
352
+ else {
353
+ recordToolUse(threadTs, name);
354
+ }
410
355
  }
411
356
  }
412
- continue;
413
- }
414
- // Track tool usage from tool_progress messages
415
- if (msgType === "tool_progress") {
416
- const tp = message;
417
- if (tp.tool_name) {
418
- recordToolUse(threadTs, tp.tool_name);
357
+ else if (pendingTool && evType === "content_block_delta") {
358
+ const delta = event.delta;
359
+ if (delta?.type === "input_json_delta") {
360
+ pendingTool.inputChunks.push(delta.partial_json ?? "");
361
+ }
362
+ }
363
+ else if (pendingTool && evType === "content_block_stop") {
364
+ // Full tool input assembled — kill before the tool runs
365
+ const toolName = pendingTool.name;
366
+ const inputStr = pendingTool.inputChunks.join("");
367
+ let toolInput = {};
368
+ try {
369
+ toolInput = JSON.parse(inputStr);
370
+ }
371
+ catch {
372
+ toolInput = { raw: inputStr };
373
+ }
374
+ pendingTool = null;
375
+ killWithEscalation(proc);
376
+ intercepted = { toolName, toolInput };
377
+ break; // exit readline loop; await proc happens below
378
+ }
379
+ else if (!pendingTool && evType === "content_block_delta") {
380
+ const delta = event.delta;
381
+ if (delta?.type === "text_delta" && delta.text) {
382
+ textBuffer.push(delta.text);
383
+ }
419
384
  }
420
385
  continue;
421
386
  }
422
- // Final result — if streaming didn't deliver the text, use result.result as fallback
423
387
  if (msgType === "result") {
424
- const result = message;
425
- console.log(`[claude] result: subtype=${result.subtype}, is_error=${result.is_error ?? false}, stop_reason=${result.stop_reason ?? "null"}, duration_ms=${result.duration_ms ?? 0}, cost=$${result.total_cost_usd ?? 0}, turns=${result.num_turns ?? 0}, len=${result.result?.length ?? 0}, preview=${result.result?.substring(0, 200) ?? "null"}`);
426
- if (result.errors && result.errors.length > 0) {
427
- console.error(`[claude] result errors: ${JSON.stringify(result.errors)}`);
428
- }
429
- // Record stats
388
+ const result = msg;
389
+ console.log(`[claude] result: subtype=${result.subtype}, cost=$${result.total_cost_usd ?? 0}, turns=${result.num_turns ?? 0}`);
430
390
  recordResult(threadTs, {
431
391
  costUsd: result.total_cost_usd,
432
392
  tokens: result.usage
@@ -440,47 +400,148 @@ export async function* chat(threadTs, userMessage, slackClient, channel, images)
440
400
  turns: result.num_turns,
441
401
  durationMs: result.duration_ms,
442
402
  });
443
- if (result.subtype === "success") {
403
+ if (result.subtype === "success")
444
404
  receivedSuccessResult = true;
405
+ if (result.result && textBuffer.length === 0) {
406
+ resultFallback = result.result;
445
407
  }
446
- if (result.result && !hasYieldedText) {
447
- console.log(`[claude] no streaming text received, using result.result as fallback (${result.result.length} chars)`);
448
- yield { type: "text", content: result.result };
449
- }
450
- continue;
451
408
  }
452
409
  }
453
- }
454
- catch (error) {
455
- if (signal.aborted) {
456
- console.log(`[claude] request aborted (in catch) for thread_ts=${threadTs}`);
457
- return;
410
+ // Wait for process to fully exit
411
+ const procResult = await proc;
412
+ // Flush buffered text — only if the process completed without interception.
413
+ // Discarding on interception prevents pre-kill text from appearing in Slack.
414
+ if (!intercepted && !signal.aborted) {
415
+ if (textBuffer.length > 0) {
416
+ for (const t of textBuffer)
417
+ yield { type: "text", content: t };
418
+ }
419
+ else if (resultFallback) {
420
+ console.log(`[claude] no streaming text, using result.result fallback (${resultFallback.length} chars)`);
421
+ yield { type: "text", content: resultFallback };
422
+ }
458
423
  }
459
- const errMsg = error instanceof Error ? error.message : String(error);
460
- const stderr = stderrChunks.join("").trim();
461
- if (stderr) {
462
- console.error(`[claude] stderr output:\n${stderr}`);
424
+ if (!intercepted &&
425
+ !signal.aborted &&
426
+ !receivedSuccessResult &&
427
+ procResult.exitCode !== 0) {
428
+ const stderr = stderrChunks.join("").trim();
429
+ if (stderr)
430
+ console.error(`[claude] stderr:\n${stderr}`);
431
+ yield {
432
+ type: "error",
433
+ message: toFriendlyError(`process exited with code ${procResult.exitCode}`),
434
+ };
463
435
  }
464
- if (error instanceof Error && error.stack) {
465
- console.error(`[claude] stack: ${error.stack}`);
436
+ }
437
+ catch (error) {
438
+ if (!signal.aborted && !receivedSuccessResult) {
439
+ const errMsg = error instanceof Error ? error.message : String(error);
440
+ const stderr = stderrChunks.join("").trim();
441
+ if (stderr)
442
+ console.error(`[claude] stderr:\n${stderr}`);
443
+ yield { type: "error", message: toFriendlyError(errMsg) };
466
444
  }
467
- if (error instanceof Error && error.cause) {
468
- console.error(`[claude] cause: ${JSON.stringify(error.cause)}`);
445
+ }
446
+ finally {
447
+ signal.removeEventListener("abort", onAbort);
448
+ }
449
+ if (!intercepted || signal.aborted)
450
+ return null;
451
+ // Intercepted a dangerous tool — ask Slack for approval
452
+ const { toolName, toolInput } = intercepted;
453
+ recordToolUse(threadTs, toolName);
454
+ const permId = randomUUID();
455
+ const description = describeToolInput(toolName, toolInput);
456
+ console.log(`[claude] intercepted dangerous tool: ${toolName}, asking Slack (permId=${permId})`);
457
+ const granted = await Promise.race([
458
+ requestPermission(slackClient, channel, threadTs, permId, toolName, description, session.authorUserId),
459
+ new Promise((resolve) => setTimeout(() => {
460
+ console.log(`[claude] permission timeout: ${permId}`);
461
+ resolve(false);
462
+ }, PERMISSION_TIMEOUT_MS)),
463
+ new Promise((resolve) => {
464
+ if (signal.aborted) {
465
+ resolve(false);
466
+ return;
467
+ }
468
+ signal.addEventListener("abort", () => resolve(false), { once: true });
469
+ }),
470
+ ]);
471
+ if (signal.aborted)
472
+ return null;
473
+ console.log(`[claude] permission ${granted ? "approved" : "denied"}: ${toolName}`);
474
+ return { toolName, toolInput, granted };
475
+ }
476
+ /**
477
+ * Send a message to a Claude session via the CLI.
478
+ *
479
+ * Spawns `claude -p` and parses stream-json output. When a dangerous tool
480
+ * (Bash, Edit, Write, …) is detected, the process is killed before the tool
481
+ * runs, a Slack approval button is shown, and the session is resumed with the
482
+ * user's decision. This loop repeats until the turn completes normally.
483
+ */
484
+ export async function* chat(threadTs, userMessage, slackClient, channel, images) {
485
+ const session = sessions.get(threadTs);
486
+ if (!session) {
487
+ throw new Error(`No session found for thread ${threadTs}`);
488
+ }
489
+ // Abort any in-flight request on this thread before starting a new one.
490
+ // Without this, the old process keeps running with a stale controller that
491
+ // can never be aborted via abortActiveChat().
492
+ activeRequests.get(threadTs)?.abort();
493
+ const abortController = new AbortController();
494
+ activeRequests.set(threadTs, abortController);
495
+ const { signal } = abortController;
496
+ session.lastActivityAt = Date.now();
497
+ saveSessions();
498
+ // Save images to disk for Claude to read via Read tool
499
+ let prompt = userMessage;
500
+ if (images && images.length > 0) {
501
+ const imgDir = path.join(session.workDir, ".eniac-images");
502
+ fs.mkdirSync(imgDir, { recursive: true });
503
+ const imagePaths = [];
504
+ for (let i = 0; i < images.length; i++) {
505
+ const imgPath = path.join(imgDir, `img-${Date.now()}-${i}.png`);
506
+ fs.writeFileSync(imgPath, Buffer.from(images[i].base64, "base64"));
507
+ imagePaths.push(imgPath);
508
+ console.log(`[claude] saved image ${i}: ${imgPath} (${images[i].base64.length} base64 chars)`);
469
509
  }
470
- // If we already received a successful result, the process exit is just
471
- // cleanup noise (e.g. exit code 1 after the SDK has already returned).
472
- if (receivedSuccessResult) {
473
- console.warn(`[claude] ignoring post-result error: ${errMsg}`);
474
- return;
510
+ const fileList = imagePaths.map((p) => `- ${p}`).join("\n");
511
+ prompt = `사용자가 이미지를 첨부했습니다. Read 도구로 아래 이미지 파일을 확인하세요:\n${fileList}\n\n사용자 메시지: ${userMessage}`;
512
+ console.log(`[claude] ${images.length} image(s) saved to disk`);
513
+ }
514
+ console.log(`[claude] starting chat, cwd=${session.workDir}, hasStarted=${session.hasStarted}`);
515
+ let currentPrompt = prompt;
516
+ try {
517
+ while (!signal.aborted) {
518
+ const result = yield* runOnce(session, currentPrompt, threadTs, signal, slackClient, channel);
519
+ if (!result)
520
+ break; // normal completion or aborted
521
+ // Build resume prompt that tells Claude exactly what was decided
522
+ const actionDesc = describeToolInput(result.toolName, result.toolInput);
523
+ if (result.granted) {
524
+ currentPrompt =
525
+ `사용자가 다음 작업을 승인했습니다:\n` +
526
+ `*${result.toolName}*: ${actionDesc}\n` +
527
+ `해당 작업을 실행하고 계속 진행해주세요.`;
528
+ }
529
+ else {
530
+ currentPrompt =
531
+ `사용자가 다음 작업을 거부했습니다:\n` +
532
+ `*${result.toolName}*: ${actionDesc}\n` +
533
+ `해당 도구를 사용하지 않고 다른 방법으로 진행해주세요.`;
534
+ }
475
535
  }
476
- console.error(`[claude] error: ${errMsg}`);
477
- yield {
478
- type: "error",
479
- message: toFriendlyError(errMsg),
480
- };
481
536
  }
482
537
  finally {
483
- // Clean up active request tracking
538
+ // Clean up images saved for this turn — they're only needed by the first runOnce call.
539
+ if (images && images.length > 0) {
540
+ try {
541
+ fs.rmSync(path.join(session.workDir, ".eniac-images"), { recursive: true, force: true });
542
+ }
543
+ catch { /* best effort */ }
544
+ }
484
545
  if (activeRequests.get(threadTs) === abortController) {
485
546
  activeRequests.delete(threadTs);
486
547
  }