Skip to content
3 changes: 3 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Slack과 로컬 Claude Code CLI를 연결하는 브리지입니다. AI Assistant
- **세션 연속성** — Slack에서 시작한 세션을 로컬에서 `claude --resume <id>`로 이어받기
- `--allowedTools`로 MCP 도구 권한 자동 승인
- 인메모리 작업 큐로 동시성 제어
- **리액션 취소** — 진행 중인 메시지에 `x` 리액션을 달면 작업을 즉시 중지
- 허가된 사용자만 사용 가능 (Slack User ID 기반)

## 설정
Expand All @@ -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을 복사합니다.

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`
- **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
Expand All @@ -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.

Expand Down
39 changes: 38 additions & 1 deletion src/claude/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface ClaudeOptions {
model?: string;
sessionId?: string;
isResuming?: boolean;
signal?: AbortSignal;
}

export type StreamEvent =
Expand Down Expand Up @@ -50,6 +51,19 @@ function buildClaudeEnv(maxOutputTokens?: number): Record<string, string> {
return { ...BASE_ENV, CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(maxOutputTokens) };
}

function makeAbortPromise(signal: AbortSignal | undefined, proc: { kill(): void }): Promise<never> {
return new Promise<never>((_, 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];
Expand Down Expand Up @@ -109,6 +123,15 @@ export async function* runClaudeStream(options: ClaudeOptions): AsyncGenerator<S
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();
yield { type: "error", error: "cancelled" };
return;
}

const timeoutPromise = new Promise<void>((resolve) => {
timer = setTimeout(() => {
timedOut = true;
Expand Down Expand Up @@ -164,13 +187,19 @@ export async function* runClaudeStream(options: ClaudeOptions): AsyncGenerator<S
const { done, value } = await Promise.race([
readPromise,
timeoutPromise.then(() => ({ done: true as const, value: undefined })),
abortPromise.catch(() => ({ done: true as const, value: undefined })),
]);

if (timedOut) {
yield { type: "error", error: "timeout" };
return;
}

if (options.signal?.aborted) {
yield { type: "error", error: "cancelled" };
return;
}

if (done) break;

buffer += decoder.decode(value, { stream: true });
Expand Down Expand Up @@ -222,6 +251,14 @@ export async function runClaude(options: ClaudeOptions): Promise<ClaudeResult> {
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<typeof setTimeout>;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
Expand Down Expand Up @@ -259,7 +296,7 @@ export async function runClaude(options: ClaudeOptions): Promise<ClaudeResult> {
})();

try {
return await Promise.race([resultPromise, timeoutPromise]);
return await Promise.race([resultPromise, timeoutPromise, abortPromise]);
} finally {
clearTimeout(timer!);
}
Expand Down
10 changes: 7 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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();
Expand Down
22 changes: 22 additions & 0 deletions src/slack/cancelMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export class CancelMap {
private readonly map = new Map<string, AbortController>();

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);
}
}
110 changes: 91 additions & 19 deletions src/slack/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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}]` : ""}`,
Expand All @@ -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([
Expand 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<void>[] = [
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
Expand All @@ -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<CancelMap, "cancel">) {
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");
Expand Down Expand Up @@ -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({
Expand All @@ -309,6 +366,7 @@ export function createAssistant(config: CCSlackConfig, queue: TaskQueue): Assist
model: resolvedModel,
sessionId,
isResuming,
signal,
})) {
if (evt.type === "text_delta") {
if (hasThinking) {
Expand All @@ -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}`);
Expand Down
Loading