diff --git a/README.ko.md b/README.ko.md index d4b436b..eec01f2 100644 --- a/README.ko.md +++ b/README.ko.md @@ -15,6 +15,7 @@ Slack과 로컬 Claude Code CLI를 연결하는 브리지입니다. AI Assistant - **세션 연속성** — Slack에서 시작한 세션을 로컬에서 `claude --resume `로 이어받기 - `--allowedTools`로 MCP 도구 권한 자동 승인 - 인메모리 작업 큐로 동시성 제어 +- **리액션 취소** — 진행 중인 메시지에 `x` 리액션을 달면 작업을 즉시 중지 - 허가된 사용자만 사용 가능 (Slack User ID 기반) ## 설정 @@ -31,10 +32,12 @@ Slack과 로컬 Claude Code CLI를 연결하는 브리지입니다. AI Assistant - `assistant:write` (Agents & AI Apps 활성화 시 자동 추가) - `chat:write` - `im:history` + - `reactions:read` 6. **Features > Event Subscriptions**에서 이벤트를 켜고, **Subscribe to bot events**에 다음을 추가합니다: - `assistant_thread_started` - `assistant_thread_context_changed` - `message.im` + - `reaction_added` 7. **Features > App Home**에서 **Messages Tab**을 활성화하고, **"Allow users to send Slash commands and messages from the messages tab"**을 체크합니다. 8. **Install App**에서 워크스페이스에 설치합니다. `xoxb-...` Bot Token을 복사합니다. diff --git a/README.md b/README.md index 218d401..49360ba 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Bridge your Slack workspace to a local [Claude Code](https://docs.anthropic.com/ - **Session continuity** — Resume any Slack-initiated session locally with `claude --resume ` - **Tool allowlist** — Auto-approve MCP tools via `--allowedTools` - **Concurrency control** — In-memory task queue with configurable limits +- **Reaction cancel** — Add an `x` reaction to any bot-triggered message to cancel the running task - **Auth** — Slack User ID allowlist ## Setup @@ -31,10 +32,12 @@ Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** - `assistant:write` (auto-added from step 4) - `chat:write` - `im:history` + - `reactions:read` 6. **Features → Event Subscriptions** — Enable events, then under **Subscribe to bot events** add: - `assistant_thread_started` - `assistant_thread_context_changed` - `message.im` + - `reaction_added` 7. **Features → App Home** — Enable **Messages Tab** and check **"Allow users to send Slash commands and messages from the messages tab"**. 8. **Install App** — Install to your workspace. Copy the `xoxb-...` Bot Token. diff --git a/src/claude/runner.ts b/src/claude/runner.ts index 59aae84..e1a287e 100644 --- a/src/claude/runner.ts +++ b/src/claude/runner.ts @@ -8,6 +8,7 @@ export interface ClaudeOptions { model?: string; sessionId?: string; isResuming?: boolean; + signal?: AbortSignal; } export type StreamEvent = @@ -50,6 +51,19 @@ function buildClaudeEnv(maxOutputTokens?: number): Record { return { ...BASE_ENV, CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(maxOutputTokens) }; } +function makeAbortPromise(signal: AbortSignal | undefined, proc: { kill(): void }): Promise { + return new Promise((_, reject) => { + signal?.addEventListener( + "abort", + () => { + proc.kill(); + reject(new Error("cancelled")); + }, + { once: true }, + ); + }); +} + function buildClaudeCmd(options: ClaudeOptions, extraFlags: string[] = []): string[] { const { claudePath, prompt, model, allowedTools, sessionId, isResuming } = options; const cmd = [claudePath, "-p", prompt, "--output-format", "stream-json", "--verbose", ...extraFlags]; @@ -109,6 +123,15 @@ export async function* runClaudeStream(options: ClaudeOptions): AsyncGenerator((resolve) => { timer = setTimeout(() => { timedOut = true; @@ -164,6 +187,7 @@ export async function* runClaudeStream(options: ClaudeOptions): AsyncGenerator ({ done: true as const, value: undefined })), + abortPromise.catch(() => ({ done: true as const, value: undefined })), ]); if (timedOut) { @@ -171,6 +195,11 @@ export async function* runClaudeStream(options: ClaudeOptions): AsyncGenerator { env: buildClaudeEnv(maxOutputTokens), }); + const abortPromise = makeAbortPromise(options.signal, proc); + + // Handle pre-aborted signal (checked after listener registration to avoid race) + if (options.signal?.aborted) { + proc.kill(); + return { success: false, output: "", error: "cancelled" }; + } + let timer: ReturnType; const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => { @@ -259,7 +296,7 @@ export async function runClaude(options: ClaudeOptions): Promise { })(); try { - return await Promise.race([resultPromise, timeoutPromise]); + return await Promise.race([resultPromise, timeoutPromise, abortPromise]); } finally { clearTimeout(timer!); } diff --git a/src/index.ts b/src/index.ts index b161b43..2dfa79a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,8 @@ import { App } from "@slack/bolt"; import { DEFAULT_CONFIG_PATH, loadConfig } from "./config"; import { TaskQueue } from "./queue/taskQueue"; -import { createAssistant, createMentionHandler } from "./slack/handler"; +import { CancelMap } from "./slack/cancelMap"; +import { createAssistant, createMentionHandler, createReactionCancelHandler } from "./slack/handler"; const configPath = process.env.CCSLACK_CONFIG || DEFAULT_CONFIG_PATH; const config = loadConfig(configPath); @@ -18,10 +19,13 @@ const app = new App({ }); const queue = new TaskQueue(config.maxConcurrency); -const assistant = createAssistant(config, queue); +const cancelMap = new CancelMap(); + +const assistant = createAssistant(config, queue, cancelMap); app.assistant(assistant); -app.event("app_mention", createMentionHandler(config, queue)); +app.event("app_mention", createMentionHandler(config, queue, cancelMap)); +app.event("reaction_added", createReactionCancelHandler(config, cancelMap)); (async () => { await app.start(); diff --git a/src/slack/cancelMap.ts b/src/slack/cancelMap.ts new file mode 100644 index 0000000..7d8af4e --- /dev/null +++ b/src/slack/cancelMap.ts @@ -0,0 +1,22 @@ +export class CancelMap { + private readonly map = new Map(); + + register(ts: string): AbortSignal { + this.map.get(ts)?.abort(); + const controller = new AbortController(); + this.map.set(ts, controller); + return controller.signal; + } + + cancel(ts: string): boolean { + const controller = this.map.get(ts); + if (!controller) return false; + this.map.delete(ts); + controller.abort(); + return true; + } + + unregister(ts: string): void { + this.map.delete(ts); + } +} diff --git a/src/slack/handler.ts b/src/slack/handler.ts index a42443d..f0397bd 100644 --- a/src/slack/handler.ts +++ b/src/slack/handler.ts @@ -3,6 +3,7 @@ import { runClaude, runClaudeStream } from "../claude/runner"; import type { CCSlackConfig } from "../config"; import { buildPrompt } from "../prompt/template"; import type { TaskQueue } from "../queue/taskQueue"; +import type { CancelMap } from "./cancelMap"; import { parseMessage } from "./parser"; import type { SlackMessage } from "./responder"; import { formatMentionReply, formatSessionInfo } from "./responder"; @@ -113,7 +114,7 @@ async function fetchThreadContext( } } -export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { +export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue, cancelMap: CancelMap) { return async ({ event, client }: { event: any; client: any }) => { // Auth check if (!config.allowedUsers.includes(event.user)) { @@ -157,6 +158,8 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { threadContext: isResuming ? "" : threadContext, }); + const signal = cancelMap.register(event.ts); + const startTime = Date.now(); console.log( `[claude] Starting batch: claude -p "${prompt.slice(0, 50)}..." in ${resolved.repoPath}${sessionId ? ` [session: ${sessionId}]` : ""}`, @@ -174,8 +177,12 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { model: resolvedModel, sessionId, isResuming, + signal, }), ); + cancelMap.unregister(event.ts); + + const isCancelled = !result.success && result.error === "cancelled"; // Swap reaction, then send reply chunks sequentially for ordering await Promise.all([ @@ -186,27 +193,53 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { .add({ channel: event.channel, timestamp: event.ts, name: result.success ? "white_check_mark" : "x" }) .catch(() => {}), ]); - // noreply: skip thread reply, send session info only to requester via ephemeral - if (!noreply) { - const messages = formatMentionReply(result); - for (const msg of messages) { - await client.chat.postMessage({ channel: event.channel, thread_ts: event.ts, ...msg }); + + // Cancelled: send error + session info as ephemeral only + if (isCancelled) { + const ephemeralParts: Promise[] = [ + client.chat.postEphemeral({ + channel: event.channel, + user: event.user, + thread_ts: event.ts, + text: "작업이 취소되었습니다.", + }), + ]; + if (sessionId) { + const sessionMsg = formatSessionInfo(sessionId, resolved.repoPath, config.claudePath); + ephemeralParts.push( + client.chat.postEphemeral({ + channel: event.channel, + user: event.user, + thread_ts: event.ts, + ...sessionMsg, + }), + ); + } + await Promise.all(ephemeralParts); + } else { + // noreply: skip thread reply, send session info only to requester via ephemeral + if (!noreply) { + const messages = formatMentionReply(result); + for (const msg of messages) { + await client.chat.postMessage({ channel: event.channel, thread_ts: event.ts, ...msg }); + } } - } - // Post session info - if (sessionId) { - const sessionMsg = formatSessionInfo(sessionId, resolved.repoPath, config.claudePath); - await postReplyOrEphemeral( - client, - { channel: event.channel, threadTs: noreply ? event.thread_ts : event.ts, user: event.user, noreply }, - sessionMsg, - ); + // Post session info + if (sessionId) { + const sessionMsg = formatSessionInfo(sessionId, resolved.repoPath, config.claudePath); + await postReplyOrEphemeral( + client, + { channel: event.channel, threadTs: noreply ? event.thread_ts : event.ts, user: event.user, noreply }, + sessionMsg, + ); + } } const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(`[claude] Mention response finished in ${elapsed}s (${result.output.length} chars)`); } catch (err: any) { + cancelMap.unregister(event.ts); console.log(`[claude] Mention handler error: ${err.message}`); await Promise.all([ client.reactions @@ -223,7 +256,29 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { }; } -export function createAssistant(config: CCSlackConfig, queue: TaskQueue): Assistant { +export function createReactionCancelHandler(config: CCSlackConfig, cancelMap: Pick) { + return async ({ event, client }: { event: any; client: any }) => { + if (event.reaction !== "x") return; + if (!config.allowedUsers.includes(event.user)) return; + if (event.item?.type !== "message") return; + + const { channel, ts } = event.item; + const cancelled = cancelMap.cancel(ts); + if (!cancelled) return; + + await Promise.all([ + client.reactions.remove({ channel, timestamp: ts, name: "hourglass_flowing_sand" }).catch(() => {}), + client.chat.postEphemeral({ + channel, + user: event.user, + thread_ts: ts, + text: "작업이 취소되었습니다.", + }), + ]); + }; +} + +export function createAssistant(config: CCSlackConfig, queue: TaskQueue, cancelMap: CancelMap): Assistant { return new Assistant({ threadStarted: async ({ say, setSuggestedPrompts, setTitle, saveThreadContext }) => { await setTitle("CCSlack Assistant"); @@ -284,9 +339,11 @@ export function createAssistant(config: CCSlackConfig, queue: TaskQueue): Assist }); // Queue + Stream + const signal = cancelMap.register(ev.ts); const startTime = Date.now(); console.log(`[claude] Starting stream: claude -p "${prompt.slice(0, 50)}..." in ${resolved.repoPath}`); + let cancelled = false; try { await queue.enqueue(async () => { const streamer = client.chatStream({ @@ -309,6 +366,7 @@ export function createAssistant(config: CCSlackConfig, queue: TaskQueue): Assist model: resolvedModel, sessionId, isResuming, + signal, })) { if (evt.type === "text_delta") { if (hasThinking) { @@ -329,22 +387,36 @@ export function createAssistant(config: CCSlackConfig, queue: TaskQueue): Assist console.log(`[thinking] ${preview}`); } else if (evt.type === "error") { console.log(`[claude] Error: ${evt.error}`); - await streamer.append({ markdown_text: `\n\nError: ${evt.error}` }); + if (evt.error === "cancelled") { + cancelled = true; + } else { + await streamer.append({ markdown_text: `\n\nError: ${evt.error}` }); + } } else if (evt.type === "result") { console.log(`[claude] Result received: ${evt.text.length} chars`); } } } finally { + cancelMap.unregister(ev.ts); await streamer.stop(); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(`[claude] Stream finished in ${elapsed}s`); } }); - // Post session info for local resume + // Post session info: ephemeral if cancelled, public otherwise if (sessionId) { const sessionMsg = formatSessionInfo(sessionId, resolved.repoPath, config.claudePath); - await say(sessionMsg); + if (cancelled) { + await client.chat.postEphemeral({ + channel: ev.channel, + user: ev.user, + thread_ts: ev.thread_ts || ev.ts, + ...sessionMsg, + }); + } else { + await say(sessionMsg); + } } } catch (err: any) { console.log(`[claude] Unhandled error: ${err.message}`); diff --git a/tests/claude/runner.test.ts b/tests/claude/runner.test.ts index 0883b95..f3f1585 100644 --- a/tests/claude/runner.test.ts +++ b/tests/claude/runner.test.ts @@ -150,6 +150,42 @@ echo '{"type":"result","result":"final answer"}' }); }); +describe("AbortSignal support", () => { + it("runClaude returns cancelled error when signal is pre-aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + const result = await runClaude({ + prompt: "hello", + cwd: "/tmp", + claudePath: "echo", + timeout: 5000, + signal: controller.signal, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("cancelled"); + }); + + it("runClaudeStream yields cancelled error when signal is pre-aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + const events: StreamEvent[] = []; + for await (const evt of runClaudeStream({ + prompt: "hello", + cwd: "/tmp", + claudePath: "echo", + timeout: 5000, + signal: controller.signal, + })) { + events.push(evt); + } + + expect(events.some((e) => e.type === "error" && e.error === "cancelled")).toBe(true); + }); +}); + describe("parseStreamJson", () => { it("extracts text from result message", () => { const input = [ diff --git a/tests/slack/cancelMap.test.ts b/tests/slack/cancelMap.test.ts new file mode 100644 index 0000000..a97dfe7 --- /dev/null +++ b/tests/slack/cancelMap.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "bun:test"; +import { CancelMap } from "../../src/slack/cancelMap"; + +describe("CancelMap", () => { + it("register returns an AbortSignal", () => { + const map = new CancelMap(); + const signal = map.register("ts-001"); + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal.aborted).toBe(false); + }); + + it("cancel aborts the registered signal", () => { + const map = new CancelMap(); + const signal = map.register("ts-001"); + const result = map.cancel("ts-001"); + expect(result).toBe(true); + expect(signal.aborted).toBe(true); + }); + + it("cancel returns false for unknown ts", () => { + const map = new CancelMap(); + const result = map.cancel("unknown"); + expect(result).toBe(false); + }); + + it("unregister removes the entry", () => { + const map = new CancelMap(); + map.register("ts-001"); + map.unregister("ts-001"); + const result = map.cancel("ts-001"); + expect(result).toBe(false); + }); + + it("unregister does not abort the signal", () => { + const map = new CancelMap(); + const signal = map.register("ts-001"); + map.unregister("ts-001"); + expect(signal.aborted).toBe(false); + expect(map.cancel("ts-001")).toBe(false); + }); +}); diff --git a/tests/slack/handler.test.ts b/tests/slack/handler.test.ts index 82cf4b8..46b8daf 100644 --- a/tests/slack/handler.test.ts +++ b/tests/slack/handler.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, mock } from "bun:test"; import type { CCSlackConfig } from "../../src/config"; -import { resolveRepoPath, resolveSessionId, SESSION_ID_RE } from "../../src/slack/handler"; +import { createReactionCancelHandler, resolveRepoPath, resolveSessionId, SESSION_ID_RE } from "../../src/slack/handler"; import { formatSessionInfo } from "../../src/slack/responder"; const mockConfig: CCSlackConfig = { @@ -132,3 +132,93 @@ describe("SESSION_ID_RE", () => { expect("random text".match(SESSION_ID_RE)).toBeNull(); }); }); + +describe("createReactionCancelHandler", () => { + const config = mockConfig; + + it("cancels task and sends ephemeral message when x reaction by allowed user", async () => { + const cancelMap = { cancel: mock(() => true) }; + const mockClient = { + chat: { postEphemeral: mock(async () => {}) }, + reactions: { remove: mock(async () => {}) }, + }; + + const handler = createReactionCancelHandler(config, cancelMap as any); + await handler({ + event: { + reaction: "x", + user: config.allowedUsers[0], + item: { type: "message", channel: "C123", ts: "1234567890.000001" }, + }, + client: mockClient, + }); + + expect(cancelMap.cancel).toHaveBeenCalledWith("1234567890.000001"); + expect(mockClient.chat.postEphemeral).toHaveBeenCalled(); + expect(mockClient.reactions.remove).toHaveBeenCalledWith({ + channel: "C123", + timestamp: "1234567890.000001", + name: "hourglass_flowing_sand", + }); + }); + + it("ignores reaction from non-allowed user", async () => { + const cancelMap = { cancel: mock(() => true) }; + const mockClient = { + chat: { postEphemeral: mock(async () => {}) }, + reactions: { remove: mock(async () => {}) }, + }; + + const handler = createReactionCancelHandler(config, cancelMap as any); + await handler({ + event: { + reaction: "x", + user: "U_UNKNOWN", + item: { type: "message", channel: "C123", ts: "1234567890.000001" }, + }, + client: mockClient, + }); + + expect(cancelMap.cancel).not.toHaveBeenCalled(); + }); + + it("ignores non-x reactions", async () => { + const cancelMap = { cancel: mock(() => true) }; + const mockClient = { + chat: { postEphemeral: mock(async () => {}) }, + reactions: { remove: mock(async () => {}) }, + }; + + const handler = createReactionCancelHandler(config, cancelMap as any); + await handler({ + event: { + reaction: "thumbsup", + user: config.allowedUsers[0], + item: { type: "message", channel: "C123", ts: "1234567890.000001" }, + }, + client: mockClient, + }); + + expect(cancelMap.cancel).not.toHaveBeenCalled(); + }); + + it("does nothing when no active task for that ts", async () => { + const cancelMap = { cancel: mock(() => false) }; + const mockClient = { + chat: { postEphemeral: mock(async () => {}) }, + reactions: { remove: mock(async () => {}) }, + }; + + const handler = createReactionCancelHandler(config, cancelMap as any); + await handler({ + event: { + reaction: "x", + user: config.allowedUsers[0], + item: { type: "message", channel: "C123", ts: "no-such-ts" }, + }, + client: mockClient, + }); + + expect(mockClient.chat.postEphemeral).not.toHaveBeenCalled(); + }); +});