eniac-slack 0.1.42 → 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 +11 -0
- package/dist/services/claude.d.ts +5 -20
- package/dist/services/claude.d.ts.map +1 -1
- package/dist/services/claude.js +260 -199
- package/dist/services/claude.js.map +1 -1
- package/dist/services/claude.test.d.ts +2 -0
- package/dist/services/claude.test.d.ts.map +1 -0
- package/dist/services/claude.test.js +530 -0
- package/dist/services/claude.test.js.map +1 -0
- package/dist/services/slack-messenger.d.ts.map +1 -1
- package/dist/services/slack-messenger.js +4 -16
- package/dist/services/slack-messenger.js.map +1 -1
- package/package.json +6 -4
- package/src/services/claude.test.ts +671 -0
- package/src/services/claude.ts +311 -249
- package/src/services/slack-messenger.ts +4 -21
- package/vitest.config.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
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
|
+
|
|
9
|
+
## [0.1.43] - 2026-03-16
|
|
10
|
+
|
|
11
|
+
- fix(slack): 응답 완료 시 삭제+재발송 대신 편집으로 변경 (#48)
|
|
12
|
+
|
|
13
|
+
|
|
3
14
|
## [0.1.42] - 2026-03-14
|
|
4
15
|
|
|
5
16
|
- feat(slack): MCP 서버 조건부 필터링 제거, 전체 상시 로딩 (#47)
|
|
@@ -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
|
|
26
|
+
* Send a message to a Claude session via the CLI.
|
|
44
27
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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":"
|
|
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"}
|
package/dist/services/claude.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
298
|
-
*
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
372
|
-
|
|
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
|
-
|
|
399
|
-
|
|
327
|
+
// onAbort already called killWithEscalation; break to reach await proc
|
|
328
|
+
break;
|
|
400
329
|
}
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
405
|
-
if (
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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 =
|
|
425
|
-
console.log(`[claude] result: subtype=${result.subtype},
|
|
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
|
-
|
|
455
|
-
if
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
|
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
|
}
|