From f7bce3f25098be543d6742dd07647175ef041244 Mon Sep 17 00:00:00 2001 From: icethief Date: Fri, 6 Mar 2026 15:58:33 +0900 Subject: [PATCH 01/10] feat: add noreply flag parsing to parseMessage Co-Authored-By: Claude Sonnet 4.6 --- src/slack/parser.ts | 8 ++++++-- tests/slack/parser.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/slack/parser.ts b/src/slack/parser.ts index 2bc5e1b..a7dd818 100644 --- a/src/slack/parser.ts +++ b/src/slack/parser.ts @@ -2,6 +2,7 @@ export interface ParsedMessage { repo: string | null; model: string | null; session: string | null; + noreply: boolean; prompt: string; } @@ -20,7 +21,10 @@ export function parseMessage(text: string): ParsedMessage { const { value: model, remaining: r2 } = extractPrefix(r1, "model"); const { value: session, remaining: r3 } = extractPrefix(r2, "session"); - const prompt = r3.replace(/\s+/g, " ").trim(); + const noreply = /\bnoreply\b/.test(r3); + const r4 = r3.replace(/\bnoreply\b/, "").trim(); - return { repo, model, session, prompt }; + const prompt = r4.replace(/\s+/g, " ").trim(); + + return { repo, model, session, noreply, prompt }; } diff --git a/tests/slack/parser.test.ts b/tests/slack/parser.test.ts index 9031b8c..e8163a9 100644 --- a/tests/slack/parser.test.ts +++ b/tests/slack/parser.test.ts @@ -82,3 +82,30 @@ describe("parseMessage", () => { expect(result.prompt).toBe("add tests"); }); }); + +describe("noreply flag", () => { + it("parses noreply keyword", () => { + const result = parseMessage("noreply repo:my-project run tests"); + expect(result.noreply).toBe(true); + expect(result.repo).toBe("my-project"); + expect(result.prompt).toBe("run tests"); + }); + + it("noreply is false when not present", () => { + const result = parseMessage("repo:my-project run tests"); + expect(result.noreply).toBe(false); + }); + + it("noreply works after bot mention", () => { + const result = parseMessage("<@U12345> noreply fix the bug"); + expect(result.noreply).toBe(true); + expect(result.prompt).toBe("fix the bug"); + }); + + it("noreply does not appear in prompt", () => { + const result = parseMessage("noreply do something"); + expect(result.noreply).toBe(true); + expect(result.prompt).toBe("do something"); + expect(result.prompt).not.toContain("noreply"); + }); +}); From 4b44ce571506f7f0aa61f6b1b8a921a6fe8a924b Mon Sep 17 00:00:00 2001 From: icethief Date: Fri, 6 Mar 2026 16:01:18 +0900 Subject: [PATCH 02/10] test: add noreply edge case tests (middle, end, combined with prefix) Co-Authored-By: Claude Sonnet 4.6 --- tests/slack/parser.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/slack/parser.test.ts b/tests/slack/parser.test.ts index e8163a9..06db139 100644 --- a/tests/slack/parser.test.ts +++ b/tests/slack/parser.test.ts @@ -108,4 +108,23 @@ describe("noreply flag", () => { expect(result.prompt).toBe("do something"); expect(result.prompt).not.toContain("noreply"); }); + + it("noreply in the middle of message", () => { + const result = parseMessage("run noreply tests"); + expect(result.noreply).toBe(true); + expect(result.prompt).toBe("run tests"); + }); + + it("noreply at the end of message", () => { + const result = parseMessage("run tests noreply"); + expect(result.noreply).toBe(true); + expect(result.prompt).toBe("run tests"); + }); + + it("noreply combined with model prefix", () => { + const result = parseMessage("noreply model:opus run tests"); + expect(result.noreply).toBe(true); + expect(result.model).toBe("opus"); + expect(result.prompt).toBe("run tests"); + }); }); From 6d8eed753c75e2633e0c422fdb5e56cbe2b52a1b Mon Sep 17 00:00:00 2001 From: icethief Date: Fri, 6 Mar 2026 16:02:33 +0900 Subject: [PATCH 03/10] feat: skip thread reply and send ephemeral session info when noreply flag is set Co-Authored-By: Claude Sonnet 4.6 --- src/slack/handler.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/slack/handler.ts b/src/slack/handler.ts index 2c5aee1..997be10 100644 --- a/src/slack/handler.ts +++ b/src/slack/handler.ts @@ -103,10 +103,10 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { return; } - const { repo, model, session: parsedSession, prompt } = parseMessage(event.text || ""); + const { repo, model, session: parsedSession, noreply, prompt } = parseMessage(event.text || ""); const resolvedModel = model ?? config.defaultModel; console.log( - `[mention] New request from ${event.user} | repo: ${repo ?? "(default)"} | model: ${resolvedModel ?? "(default)"} | prompt: "${prompt.slice(0, 80)}${prompt.length > 80 ? "..." : ""}"`, + `[mention] New request from ${event.user} | repo: ${repo ?? "(default)"} | model: ${resolvedModel ?? "(default)"} | noreply: ${noreply} | prompt: "${prompt.slice(0, 80)}${prompt.length > 80 ? "..." : ""}"`, ); let resolved: ResolvedRepo; @@ -168,15 +168,27 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { .add({ channel: event.channel, timestamp: event.ts, name: result.success ? "white_check_mark" : "x" }) .catch(() => {}), ]); - const messages = formatMentionReply(result); - for (const msg of messages) { - await client.chat.postMessage({ channel: event.channel, thread_ts: event.ts, ...msg }); + // 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 for local resume + // Post session info if (sessionId) { const sessionMsg = formatSessionInfo(sessionId, resolved.repoPath, config.claudePath); - await client.chat.postMessage({ channel: event.channel, thread_ts: event.ts, ...sessionMsg }); + if (noreply) { + await client.chat.postEphemeral({ + channel: event.channel, + user: event.user, + thread_ts: event.ts, + ...sessionMsg, + }); + } else { + await client.chat.postMessage({ channel: event.channel, thread_ts: event.ts, ...sessionMsg }); + } } const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); From 9454d0528270e41e4579b52b65d466d26528b482 Mon Sep 17 00:00:00 2001 From: icethief Date: Fri, 6 Mar 2026 16:14:36 +0900 Subject: [PATCH 04/10] fix: send ephemeral for session info and errors when noreply is set Co-Authored-By: Claude Sonnet 4.6 --- src/slack/handler.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/slack/handler.ts b/src/slack/handler.ts index 997be10..833852d 100644 --- a/src/slack/handler.ts +++ b/src/slack/handler.ts @@ -183,7 +183,6 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { await client.chat.postEphemeral({ channel: event.channel, user: event.user, - thread_ts: event.ts, ...sessionMsg, }); } else { @@ -200,11 +199,17 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { .remove({ channel: event.channel, timestamp: event.ts, name: "hourglass_flowing_sand" }) .catch(() => {}), client.reactions.add({ channel: event.channel, timestamp: event.ts, name: "x" }).catch(() => {}), - client.chat.postMessage({ - channel: event.channel, - thread_ts: event.ts, - text: "오류가 발생했습니다. 서버 로그를 확인해주세요.", - }), + noreply + ? client.chat.postEphemeral({ + channel: event.channel, + user: event.user, + text: "오류가 발생했습니다. 서버 로그를 확인해주세요.", + }) + : client.chat.postMessage({ + channel: event.channel, + thread_ts: event.ts, + text: "오류가 발생했습니다. 서버 로그를 확인해주세요.", + }), ]); } }; From 5b78fe3a5db9f17ff57136b1404e2e4fc29c9c4f Mon Sep 17 00:00:00 2001 From: icethief Date: Fri, 6 Mar 2026 16:20:34 +0900 Subject: [PATCH 05/10] refactor: remove duplicate noreply regex and extract postReplyOrEphemeral helper --- src/slack/handler.ts | 34 ++++++++++++++-------------------- src/slack/parser.ts | 2 +- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/slack/handler.ts b/src/slack/handler.ts index 833852d..252973f 100644 --- a/src/slack/handler.ts +++ b/src/slack/handler.ts @@ -6,6 +6,18 @@ import type { TaskQueue } from "../queue/taskQueue"; import { parseMessage } from "./parser"; import { formatMentionReply, formatSessionInfo } from "./responder"; +async function postReplyOrEphemeral( + client: any, + { channel, threadTs, user, noreply }: { channel: string; threadTs: string; user: string; noreply: boolean }, + payload: Record, +): Promise { + if (noreply) { + await client.chat.postEphemeral({ channel, user, ...payload }); + } else { + await client.chat.postMessage({ channel, thread_ts: threadTs, ...payload }); + } +} + export interface ResolvedRepo { repoName: string; repoPath: string; @@ -179,15 +191,7 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { // Post session info if (sessionId) { const sessionMsg = formatSessionInfo(sessionId, resolved.repoPath, config.claudePath); - if (noreply) { - await client.chat.postEphemeral({ - channel: event.channel, - user: event.user, - ...sessionMsg, - }); - } else { - await client.chat.postMessage({ channel: event.channel, thread_ts: event.ts, ...sessionMsg }); - } + await postReplyOrEphemeral(client, { channel: event.channel, threadTs: event.ts, user: event.user, noreply }, sessionMsg); } const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); @@ -199,17 +203,7 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { .remove({ channel: event.channel, timestamp: event.ts, name: "hourglass_flowing_sand" }) .catch(() => {}), client.reactions.add({ channel: event.channel, timestamp: event.ts, name: "x" }).catch(() => {}), - noreply - ? client.chat.postEphemeral({ - channel: event.channel, - user: event.user, - text: "오류가 발생했습니다. 서버 로그를 확인해주세요.", - }) - : client.chat.postMessage({ - channel: event.channel, - thread_ts: event.ts, - text: "오류가 발생했습니다. 서버 로그를 확인해주세요.", - }), + postReplyOrEphemeral(client, { channel: event.channel, threadTs: event.ts, user: event.user, noreply }, { text: "오류가 발생했습니다. 서버 로그를 확인해주세요." }), ]); } }; diff --git a/src/slack/parser.ts b/src/slack/parser.ts index a7dd818..baa1405 100644 --- a/src/slack/parser.ts +++ b/src/slack/parser.ts @@ -21,8 +21,8 @@ export function parseMessage(text: string): ParsedMessage { const { value: model, remaining: r2 } = extractPrefix(r1, "model"); const { value: session, remaining: r3 } = extractPrefix(r2, "session"); - const noreply = /\bnoreply\b/.test(r3); const r4 = r3.replace(/\bnoreply\b/, "").trim(); + const noreply = r4 !== r3; const prompt = r4.replace(/\s+/g, " ").trim(); From 577552e97c8c54491d628bd809f19dfb7c87fec0 Mon Sep 17 00:00:00 2001 From: icethief Date: Fri, 6 Mar 2026 16:27:18 +0900 Subject: [PATCH 06/10] fix: pass thread_ts to ephemeral only when mention is inside a thread --- src/slack/handler.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/slack/handler.ts b/src/slack/handler.ts index 252973f..4a3f669 100644 --- a/src/slack/handler.ts +++ b/src/slack/handler.ts @@ -8,11 +8,11 @@ import { formatMentionReply, formatSessionInfo } from "./responder"; async function postReplyOrEphemeral( client: any, - { channel, threadTs, user, noreply }: { channel: string; threadTs: string; user: string; noreply: boolean }, + { channel, threadTs, user, noreply }: { channel: string; threadTs: string | undefined; user: string; noreply: boolean }, payload: Record, ): Promise { if (noreply) { - await client.chat.postEphemeral({ channel, user, ...payload }); + await client.chat.postEphemeral({ channel, user, ...(threadTs && { thread_ts: threadTs }), ...payload }); } else { await client.chat.postMessage({ channel, thread_ts: threadTs, ...payload }); } @@ -191,7 +191,7 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { // Post session info if (sessionId) { const sessionMsg = formatSessionInfo(sessionId, resolved.repoPath, config.claudePath); - await postReplyOrEphemeral(client, { channel: event.channel, threadTs: event.ts, user: event.user, noreply }, sessionMsg); + await postReplyOrEphemeral(client, { channel: event.channel, threadTs: event.thread_ts, user: event.user, noreply }, sessionMsg); } const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); @@ -203,7 +203,7 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { .remove({ channel: event.channel, timestamp: event.ts, name: "hourglass_flowing_sand" }) .catch(() => {}), client.reactions.add({ channel: event.channel, timestamp: event.ts, name: "x" }).catch(() => {}), - postReplyOrEphemeral(client, { channel: event.channel, threadTs: event.ts, user: event.user, noreply }, { text: "오류가 발생했습니다. 서버 로그를 확인해주세요." }), + postReplyOrEphemeral(client, { channel: event.channel, threadTs: event.thread_ts, user: event.user, noreply }, { text: "오류가 발생했습니다. 서버 로그를 확인해주세요." }), ]); } }; From ad572cfb613e6ba1a1562e9cca0088f5e44ae92f Mon Sep 17 00:00:00 2001 From: icethief Date: Fri, 6 Mar 2026 16:37:25 +0900 Subject: [PATCH 07/10] fix: use event.ts for noreply=false thread reply to prevent regression --- src/slack/handler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/slack/handler.ts b/src/slack/handler.ts index 4a3f669..66aeaf7 100644 --- a/src/slack/handler.ts +++ b/src/slack/handler.ts @@ -191,7 +191,7 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { // Post session info if (sessionId) { const sessionMsg = formatSessionInfo(sessionId, resolved.repoPath, config.claudePath); - await postReplyOrEphemeral(client, { channel: event.channel, threadTs: event.thread_ts, user: event.user, noreply }, sessionMsg); + 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); @@ -203,7 +203,7 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { .remove({ channel: event.channel, timestamp: event.ts, name: "hourglass_flowing_sand" }) .catch(() => {}), client.reactions.add({ channel: event.channel, timestamp: event.ts, name: "x" }).catch(() => {}), - postReplyOrEphemeral(client, { channel: event.channel, threadTs: event.thread_ts, user: event.user, noreply }, { text: "오류가 발생했습니다. 서버 로그를 확인해주세요." }), + postReplyOrEphemeral(client, { channel: event.channel, threadTs: noreply ? event.thread_ts : event.ts, user: event.user, noreply }, { text: "오류가 발생했습니다. 서버 로그를 확인해주세요." }), ]); } }; From 8f606fa25c01a72164c513106dfea0c01375c614 Mon Sep 17 00:00:00 2001 From: icethief Date: Fri, 6 Mar 2026 16:39:44 +0900 Subject: [PATCH 08/10] style: apply biome formatting to handler.ts --- src/slack/handler.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/slack/handler.ts b/src/slack/handler.ts index 66aeaf7..166e70d 100644 --- a/src/slack/handler.ts +++ b/src/slack/handler.ts @@ -8,7 +8,12 @@ import { formatMentionReply, formatSessionInfo } from "./responder"; async function postReplyOrEphemeral( client: any, - { channel, threadTs, user, noreply }: { channel: string; threadTs: string | undefined; user: string; noreply: boolean }, + { + channel, + threadTs, + user, + noreply, + }: { channel: string; threadTs: string | undefined; user: string; noreply: boolean }, payload: Record, ): Promise { if (noreply) { @@ -191,7 +196,11 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { // 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); + 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); @@ -203,7 +212,11 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { .remove({ channel: event.channel, timestamp: event.ts, name: "hourglass_flowing_sand" }) .catch(() => {}), client.reactions.add({ channel: event.channel, timestamp: event.ts, name: "x" }).catch(() => {}), - postReplyOrEphemeral(client, { channel: event.channel, threadTs: noreply ? event.thread_ts : event.ts, user: event.user, noreply }, { text: "오류가 발생했습니다. 서버 로그를 확인해주세요." }), + postReplyOrEphemeral( + client, + { channel: event.channel, threadTs: noreply ? event.thread_ts : event.ts, user: event.user, noreply }, + { text: "오류가 발생했습니다. 서버 로그를 확인해주세요." }, + ), ]); } }; From 96e27128a1542adba7425de8987b5e368e8fa1bf Mon Sep 17 00:00:00 2001 From: icethief Date: Fri, 6 Mar 2026 16:42:38 +0900 Subject: [PATCH 09/10] chore: add husky pre-push hook to run lint and tests before push --- .husky/pre-push | 2 ++ bun.lock | 3 +++ package.json | 7 +++++-- 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 .husky/pre-push diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..eede5ed --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,2 @@ +bun run lint +bun test diff --git a/bun.lock b/bun.lock index d0c6b48..48bf595 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "devDependencies": { "@biomejs/biome": "^2.4.6", "@types/bun": "latest", + "husky": "^9.1.7", }, "peerDependencies": { "typescript": "^5", @@ -162,6 +163,8 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], diff --git a/package.json b/package.json index af6d673..96d8075 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,15 @@ "test": "bun test", "lint": "bunx biome check src/ tests/", "lint:fix": "bunx biome check --write src/ tests/", - "format": "bunx biome format --write src/ tests/" + "format": "bunx biome format --write src/ tests/", + "prepare": "husky" }, "devDependencies": { "@biomejs/biome": "^2.4.6", - "@types/bun": "latest" + "@types/bun": "latest", + "husky": "^9.1.7" }, + "trustedDependencies": ["husky"], "peerDependencies": { "typescript": "^5" }, From 609469122d69fcf0e5993b994ce69ea6ba679966 Mon Sep 17 00:00:00 2001 From: icethief Date: Fri, 6 Mar 2026 16:44:33 +0900 Subject: [PATCH 10/10] chore: add typecheck to pre-push hook and fix payload type in postReplyOrEphemeral --- .husky/pre-push | 1 + package.json | 1 + src/slack/handler.ts | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.husky/pre-push b/.husky/pre-push index eede5ed..f6b11f8 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,2 +1,3 @@ bun run lint +bun run typecheck bun test diff --git a/package.json b/package.json index 96d8075..0feb408 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "lint": "bunx biome check src/ tests/", "lint:fix": "bunx biome check --write src/ tests/", "format": "bunx biome format --write src/ tests/", + "typecheck": "bunx tsc --noEmit", "prepare": "husky" }, "devDependencies": { diff --git a/src/slack/handler.ts b/src/slack/handler.ts index 166e70d..a42443d 100644 --- a/src/slack/handler.ts +++ b/src/slack/handler.ts @@ -4,6 +4,7 @@ import type { CCSlackConfig } from "../config"; import { buildPrompt } from "../prompt/template"; import type { TaskQueue } from "../queue/taskQueue"; import { parseMessage } from "./parser"; +import type { SlackMessage } from "./responder"; import { formatMentionReply, formatSessionInfo } from "./responder"; async function postReplyOrEphemeral( @@ -14,7 +15,7 @@ async function postReplyOrEphemeral( user, noreply, }: { channel: string; threadTs: string | undefined; user: string; noreply: boolean }, - payload: Record, + payload: SlackMessage | { text: string }, ): Promise { if (noreply) { await client.chat.postEphemeral({ channel, user, ...(threadTs && { thread_ts: threadTs }), ...payload });