@rokrokss/claude-slack-channel 0.1.0 → 0.3.0

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/CLAUDE.md CHANGED
@@ -12,6 +12,7 @@
12
12
  - `event.ts` — DM 판별, 스레드 해석, stale/empty 필터링
13
13
  - `resilience.ts` — 이벤트 중복 제거 (EventDeduplicator)
14
14
  - `permalink.ts` — Slack 퍼마링크 빌더
15
+ - `process.ts` — 부모 프로세스 channels 플래그 감지 (hasChannelsFlag)
15
16
  - `index.ts` — barrel re-export
16
17
  - `server.test.ts` — lib/ 테스트 (bun:test)
17
18
 
package/README.md CHANGED
@@ -1,6 +1,48 @@
1
1
  # Claude Slack Channel
2
2
 
3
- Claude Code 세션과 Slack을 양방향으로 연결합니다. Slack DM이나 채널 멘션으로 Claude Code와 대화할 수 있습니다.
3
+ Claude Code 세션과 Slack을 양방향으로 연결하는 MCP 채널 서버입니다. Slack DM이나 채널 멘션으로 Claude Code와 대화할 수 있습니다.
4
+
5
+ ## 설치
6
+
7
+ 프로젝트 `.mcp.json` 또는 `~/.claude.json`에 추가합니다:
8
+
9
+ ```json
10
+ {
11
+ "mcpServers": {
12
+ "slack-channel": {
13
+ "command": "bunx",
14
+ "args": ["@rokrokss/claude-slack-channel"],
15
+ "env": {
16
+ "SLACK_BOT_TOKEN": "xoxb-...",
17
+ "SLACK_APP_TOKEN": "xapp-...",
18
+ "SLACK_ALLOW_FROM": "U123,U456",
19
+ "SLACK_WORKSPACE": "your-workspace",
20
+ "SLACK_ACK_REACTION": "eyes"
21
+ }
22
+ }
23
+ }
24
+ }
25
+ ```
26
+
27
+ | 환경변수 | 필수 | 설명 |
28
+ |---|---|---|
29
+ | `SLACK_BOT_TOKEN` | O | Bot User OAuth Token (`xoxb-...`) |
30
+ | `SLACK_APP_TOKEN` | O | App-Level Token (`xapp-...`) |
31
+ | `SLACK_ALLOW_FROM` | O | 허용할 Slack 사용자 ID (쉼표 구분) |
32
+ | `SLACK_WORKSPACE` | O | Slack 워크스페이스 서브도메인 (예: `msuniverse`). permalink 생성에 사용 |
33
+ | `SLACK_BOT_OWNER` | - | 봇 소유자 사용자 ID. allowlist 없이도 항상 허용 |
34
+ | `SLACK_ACK_REACTION` | - | 수신 확인 이모지 (예: `eyes`). 답장 후 자동 제거 |
35
+ | `SLACK_DEFAULT_COLOR` | - | 메시지 사이드바 색상 hex (기본: `#e5da9a`) |
36
+ | `SLACK_SHOW_FOOTER` | - | 메시지 하단 footer 표시 여부 (기본: `true`). `false`로 설정 시 숨김 |
37
+ | `SLACK_FORCE_SOCKET_MODE` | - | `1`로 설정 시 플래그 감지를 건너뛰고 Socket Mode 강제 연결 |
38
+
39
+ ## 실행
40
+
41
+ ```bash
42
+ claude --dangerously-load-development-channels server:slack-channel
43
+ ```
44
+
45
+ 허용된 사용자는 DM이든 채널이든 어디서든 봇과 대화할 수 있습니다.
4
46
 
5
47
  ## 아키텍처
6
48
 
@@ -8,22 +50,22 @@ Claude Code 세션과 Slack을 양방향으로 연결합니다. Slack DM이나
8
50
  ┌─────────────────────────────────────────────────────────────────┐
9
51
  │ Claude Code (Host) │
10
52
  │ │
11
- │ Claude LLM ◄──── <channel> notification ◄──── stdout │
12
- │ │
53
+ │ Claude LLM <──── <channel> notification <──── stdout │
54
+ │ │ ^
13
55
  │ │ tool call (reply/react/...) │ │
14
- │ │
15
- │ stdin ────► server.ts (MCP Server) ────► mcp.notification() │
16
- │ │
56
+ v │ │
57
+ │ stdin ────> server.ts (MCP Server) ────> mcp.notification() │
58
+ │ │ ^
17
59
  │ │ │ │
18
- │ │
60
+ v │ │
19
61
  │ Slack Web API Socket Mode (WebSocket) │
20
62
  │ │ │ │
21
63
  └────────────────────┼───────────┼────────────────────────────────┘
22
64
  │ │
23
-
65
+ v
24
66
  ┌──────────────────┴──────┐
25
- │ Slack Platform
26
- (채널, DM, 스레드)
67
+ │ Slack Platform
68
+ (채널, DM, 스레드)
27
69
  └─────────────────────────┘
28
70
  ```
29
71
 
@@ -37,49 +79,141 @@ Claude Code 세션과 Slack을 양방향으로 연결합니다. Slack DM이나
37
79
  ```
38
80
  main()
39
81
 
40
- ├── startSocketMode()
82
+ ├── hasChannelsFlag()
83
+ │ └── 부모 프로세스(Claude Code)의 command line에서
84
+ │ --dangerously-load-development-channels 또는 --channels 확인
85
+
86
+ ├── [플래그 있음] startSocketMode()
87
+ │ ├── killPreviousInstance() → 기존 인스턴스 종료 (PID 파일)
88
+ │ ├── writePidFile() → 자신의 PID 기록
41
89
  │ ├── web.auth.test() → botUserId 확인
42
- │ │ └── 실패 ──► process.exit(1)
90
+ │ │ └── 실패 process.exit(1)
43
91
  │ └── socket.start() → Slack 이벤트 수신 시작
44
92
 
93
+ ├── [플래그 없음] Socket Mode 스킵 (Tools-only mode)
94
+
45
95
  └── mcp.connect(transport) → MCP stdio 연결
46
96
  ```
47
97
 
48
- Socket Mode는 항상 연결됩니다. Claude Code `--dangerously-load-development-channels` 플래그 없이 실행되면 channel notification을 무시하므로, 서버 측에서 별도 게이팅 없이도 안전합니다.
98
+ Socket Mode는 부모 프로세스(Claude Code)에 channels 플래그가 있을 때만 연결됩니다. 플래그 없이 실행된 세션은 도구만 제공하며 Socket Mode를 건드리지 않으므로, 기존 channels 세션의 연결이 보호됩니다.
99
+
100
+ ### Channels 플래그 감지 (`hasChannelsFlag`)
101
+
102
+ Claude Code는 `--dangerously-load-development-channels` 또는 `--channels` 플래그와 함께 실행될 때만 채널 기능을 사용합니다. 이 MCP 서버는 부모 프로세스의 커맨드라인을 검사하여 플래그 존재 여부를 판단합니다.
103
+
104
+ ```
105
+ hasChannelsFlag()
106
+
107
+ ├── SLACK_FORCE_SOCKET_MODE=1 ? ──── true (환경변수 오버라이드)
108
+
109
+ └── findAncestorClaudeCommand()
110
+
111
+ │ process.ppid부터 시작, 최대 5단계 탐색
112
+
113
+ v
114
+ ┌──────────────────────────────────────────────────────┐
115
+ │ PID 1234 (bun server.ts) │
116
+ │ └── ppid -> PID 1230 │
117
+ │ │
118
+ │ PID 1230 (node ...) │
119
+ │ └── ppid -> PID 1225 │
120
+ │ │
121
+ │ PID 1225 (claude --channels server:slack) <── 발견! │
122
+ │ └── 커맨드라인에 "channels" 포함 -> true │
123
+ └──────────────────────────────────────────────────────┘
124
+ ```
125
+
126
+ **프로세스 정보 조회 방법:**
127
+
128
+ | 플랫폼 | 명령 |
129
+ |---|---|
130
+ | macOS / Linux | `ps -o ppid=,command= -p <pid>` |
131
+ | Windows | `wmic process where ProcessId=<pid> get ParentProcessId,CommandLine /format:csv` |
132
+
133
+ 각 단계에서 부모 PID와 커맨드라인을 함께 가져와, `claude`라는 이름의 프로세스를 찾으면 그 커맨드라인에서 플래그를 확인합니다. 5단계 안에 찾지 못하면 `false`를 반환하며, Socket Mode를 시작하지 않습니다.
134
+
135
+ **왜 이것이 중요한가:**
136
+
137
+ Claude Code는 하나의 앱에 대해 여러 세션을 열 수 있습니다. 플래그 없이 실행된 세션이 Socket Mode에 연결하면, 이미 연결된 channels 세션의 WebSocket을 끊어버립니다. 플래그 검사를 통해 일반 세션은 tools-only 모드로 동작하여 기존 channels 세션을 보호합니다.
138
+
139
+ ```
140
+ 세션 A: claude --channels server:slack <- Socket Mode 연결 O
141
+ 세션 B: claude <- Socket Mode 스킵 (tools-only)
142
+ 세션 C: claude <- Socket Mode 스킵 (tools-only)
143
+ 세션 A의 연결은 안전하게 유지됨
144
+ ```
145
+
146
+ ### Socket Preemption (PID 파일 기반 단일 인스턴스 보장)
147
+
148
+ Slack Socket Mode는 **앱당 하나의 WebSocket 연결만** 허용합니다. 두 번째 연결이 생기면 첫 번째가 끊기며 이벤트가 유실될 수 있습니다. PID 파일(`~/.claude/channels/slack/socket.pid`)을 사용하여 항상 최신 인스턴스만 Socket Mode를 유지합니다.
149
+
150
+ ```
151
+ 인스턴스 A 시작 인스턴스 B 시작
152
+ │ │
153
+ v │
154
+ PID 파일 없음 │
155
+ │ │
156
+ v │
157
+ socket.pid <- A.pid 기록 │
158
+ │ │
159
+ v │
160
+ Socket Mode 연결 O │
161
+ 이벤트 수신 중... │
162
+ │ v
163
+ │ socket.pid 읽기 -> A.pid
164
+ │ │
165
+ │ v
166
+ │ kill(A.pid, 0) -> 생존 확인
167
+ │ │
168
+ │<──── SIGTERM ─────────────── kill(A.pid, SIGTERM)
169
+ │ │
170
+ v v
171
+ cleanupPidFile() socket.pid <- B.pid 기록
172
+ PID 파일 정리 (자기 PID일 때만) │
173
+ │ v
174
+ v Socket Mode 연결 O
175
+ 프로세스 종료 이벤트 수신 중...
176
+ ```
177
+
178
+ **PID 파일 안전 장치:**
179
+
180
+ - **생존 확인**: `process.kill(oldPid, 0)` — signal 0은 프로세스를 죽이지 않고 존재 여부만 확인합니다. 이미 종료된 프로세스에 SIGTERM을 보내는 것을 방지합니다.
181
+ - **소유권 확인**: `cleanupPidFile()`은 PID 파일의 내용이 자신의 PID일 때만 삭제합니다. 새 인스턴스가 이미 PID를 덮어썼다면 건드리지 않습니다.
182
+ - **종료 핸들링**: `exit`, `SIGTERM`, `SIGINT` 세 가지 시그널 모두에서 PID 파일을 정리합니다.
49
183
 
50
184
  ## 인바운드 메시지 파이프라인
51
185
 
52
- Slack에서 메시지가 들어오면 6단계 파이프라인을 거칩니다. 각 단계에서 조건을 만족하지 않으면 메시지를 drop합니다.
186
+ Slack에서 메시지가 들어오면 5단계 파이프라인을 거칩니다. 각 단계에서 조건을 만족하지 않으면 메시지를 drop합니다.
53
187
 
54
188
  ```
55
189
  Slack Event (message / app_mention)
56
190
 
57
-
191
+ v
58
192
  ┌─────────────┐
59
193
  │ 1. Dedup │──── 이미 처리한 이벤트? ──── drop
60
194
  └──────┬──────┘
61
195
  │ 새 이벤트
62
-
196
+ v
63
197
  ┌─────────────┐
64
198
  │ 2. Stale │──── 10분 이상 된 이벤트? ──── drop
65
199
  └──────┬──────┘
66
200
  │ 최근 이벤트
67
-
201
+ v
68
202
  ┌─────────────┐
69
203
  │ 3. Empty │──── 텍스트/파일/블록 없음? ──── drop
70
204
  └──────┬──────┘
71
205
  │ 내용 있음
72
-
206
+ v
73
207
  ┌─────────────┐
74
208
  │ 4. Gate │──── 봇 자신? subtype 불허? ──── drop
75
- │ (접근제어) │──── allowlist에 없음? ──────── drop
209
+ │ (접근제어) │──── allowlist에 없음? ──────── drop
76
210
  └──────┬──────┘
77
211
  │ 허용된 사용자
78
-
212
+ v
79
213
  ┌─────────────┐
80
214
  │ 5. Deliver │──── permalink 생성
81
215
  │ │──── ack reaction 추가
82
- │ │──── mcp.notification() Claude
216
+ │ │──── mcp.notification() -> Claude
83
217
  └─────────────┘
84
218
  ```
85
219
 
@@ -87,106 +221,78 @@ Slack Event (message / app_mention)
87
221
 
88
222
  Claude가 사용할 수 있는 MCP 도구:
89
223
 
90
- ```
91
- Claude (tool call)
92
-
93
- ├── reply ──────────── 메시지 전송
94
- │ chat.postMessage mrkdwn 포맷 변환
95
- │ ack reaction 자동 제거
96
-
97
- ├── react ─────────── 이모지 리액션 추가
98
- │ reactions.add
99
-
100
- ├── delete_bot_message ── 봇 메시지 삭제
101
- │ chat.delete (자기 메시지만)
102
-
103
- └── fetch_dm_thread ── DM 스레드 읽기
104
- conversations.replies
105
- (is_dm=true일 때만 사용)
106
- ```
224
+ | 도구 | 기능 | Slack API |
225
+ |---|---|---|
226
+ | `reply` | 메시지 전송 (mrkdwn 포맷 변환, ack reaction 자동 제거) | `chat.postMessage` |
227
+ | `react` | 이모지 리액션 추가 | `reactions.add` |
228
+ | `delete_bot_message` | 자신의 메시지 삭제 | `chat.delete` |
229
+ | `fetch_dm_thread` | DM 스레드 내용 읽기 (`is_dm=true`일 때만 사용) | `conversations.replies` |
107
230
 
108
231
  모든 아웃바운드 호출은 **audit log**에 기록되며, **outbound gate**를 통과해야 합니다 (인바운드를 받은 채널에만 응답 가능).
109
232
 
110
- ## 보안 레이어
233
+ ## 보안
111
234
 
112
235
  ```
113
- ┌─────────────────────────────────────────────────────┐
114
- │ Inbound Security
115
-
116
- │ Allowlist Gate ── SLACK_ALLOW_FROM에 등록된
117
- │ 사용자만 Claude에 메시지 전달
118
-
119
- │ Bot Owner ────── SLACK_BOT_OWNER는 항상 허용
120
-
121
- │ Self-loop ────── 봇 자신의 메시지 자동 drop
122
-
123
- │ Dedup ────────── 중복 이벤트 필터링 (TTL 10분)
124
-
125
- ├─────────────────────────────────────────────────────┤
126
- Outbound Security
127
-
128
- │ Outbound Gate ── 인바운드 수신 이력이 있는
129
- │ 채널에만 응답 허용
130
-
131
- │ Prompt Hardening ── 시스템 프롬프트에서
132
- │ 설정 변경 요청 거부
133
- └─────────────────────────────────────────────────────┘
134
- ```
135
-
136
- ## 프로젝트 구조
137
-
138
- ```
139
- server.ts MCP 서버, Slack 클라이언트, 이벤트 핸들링
140
- tools.ts MCP 도구 등록 (reply, react, delete_bot_message, fetch_dm_thread)
141
- lib/
142
- gate.ts 접근 제어 (Access, GateOptions, gate, clientSupportsChannels)
143
- security.ts 아웃바운드 게이트 (assertOutboundAllowed)
144
- formatting.ts Slack mrkdwn 변환, 메시지 텍스트 추출
145
- audit.ts 감사 로그 (AuditEntry, auditLog)
146
- event.ts DM 판별, 스레드 해석, stale/empty 필터링
147
- resilience.ts 이벤트 중복 제거 (EventDeduplicator)
148
- permalink.ts Slack 퍼마링크 빌더
149
- index.ts barrel re-export
150
- server.test.ts lib/ 테스트 (bun:test, 85개)
236
+ ┌───────────────────────────────────────────────────────┐
237
+ │ Inbound Security
238
+
239
+ │ Allowlist Gate ── SLACK_ALLOW_FROM에 등록된
240
+ │ 사용자만 Claude에 메시지 전달
241
+
242
+ │ Bot Owner ────── SLACK_BOT_OWNER는 항상 허용
243
+
244
+ │ Self-loop ────── 봇 자신의 메시지 자동 drop
245
+
246
+ │ Dedup ────────── 중복 이벤트 필터링 (TTL 10분)
247
+
248
+ ├───────────────────────────────────────────────────────┤
249
+ Outbound Security
250
+
251
+ │ Outbound Gate ── 인바운드 수신 이력이 있는
252
+ │ 채널에만 응답 허용
253
+
254
+ │ Prompt Hardening ── 시스템 프롬프트에서
255
+ │ 설정 변경 요청 거부
256
+ └───────────────────────────────────────────────────────┘
151
257
  ```
152
258
 
153
259
  ## 데이터 흐름 상세
154
260
 
155
- ### Permission Request 흐름
261
+ ### Permission Request
156
262
 
157
263
  도구 실행에 권한 확인이 필요할 때 Slack으로 알림을 보냅니다:
158
264
 
159
265
  ```
160
- Claude Code ──► permission_request notification
266
+ Claude Code ──> permission_request notification
161
267
 
162
-
268
+ v
163
269
  server.ts 수신
164
270
 
165
-
271
+ v
166
272
  마지막 inbound 채널/스레드로
167
273
  Slack 알림 전송 (경고색 #f0ad4e)
168
274
 
169
-
275
+ v
170
276
  사용자가 Slack에서 확인
171
277
  (승인/거부는 터미널에서)
172
278
  ```
173
279
 
174
- ### Ack Reaction 흐름
280
+ ### Ack Reaction
175
281
 
176
282
  메시지 수신 확인과 답장 완료를 이모지로 표시합니다:
177
283
 
178
284
  ```
179
- 메시지 수신 ──► ack reaction 추가 (예: 👀)
285
+ 메시지 수신 ──> ack reaction 추가 (예: eyes)
180
286
 
181
-
287
+ v
182
288
  Claude가 처리 중...
183
289
 
184
-
290
+ v
185
291
  reply 도구 호출 시
186
292
  ack reaction 자동 제거
187
293
  ```
188
294
 
189
- ## 기존 Slack App 추가 설정
295
+ ## Slack App 설정
190
296
 
191
297
  이미 Slack App이 있다면 (예: slack MCP 서버용), 같은 앱에 아래 설정을 추가합니다.
192
298
 
@@ -218,50 +324,28 @@ Claude Code ──► permission_request notification
218
324
 
219
325
  **OAuth & Permissions** → **Reinstall to Workspace** → Bot Token (`xoxb-...`) 복사
220
326
 
221
- ## 설치 및 설정
222
-
223
- 프로젝트 `.mcp.json` 또는 `~/.claude.json`에 추가합니다:
327
+ ## 프로젝트 구조
224
328
 
225
- ```json
226
- {
227
- "mcpServers": {
228
- "slack-channel": {
229
- "command": "bunx",
230
- "args": ["@rokrokss/claude-slack-channel"],
231
- "env": {
232
- "SLACK_BOT_TOKEN": "xoxb-...",
233
- "SLACK_APP_TOKEN": "xapp-...",
234
- "SLACK_ALLOW_FROM": "U123,U456",
235
- "SLACK_WORKSPACE": "your-workspace",
236
- "SLACK_ACK_REACTION": "eyes"
237
- }
238
- }
239
- }
240
- }
241
329
  ```
242
-
243
- | 환경변수 | 필수 | 설명 |
244
- |---|---|---|
245
- | `SLACK_BOT_TOKEN` | O | Bot User OAuth Token (`xoxb-...`) |
246
- | `SLACK_APP_TOKEN` | O | App-Level Token (`xapp-...`) |
247
- | `SLACK_ALLOW_FROM` | O | 허용할 Slack 사용자 ID (쉼표 구분) |
248
- | `SLACK_WORKSPACE` | O | Slack 워크스페이스 서브도메인 (예: `msuniverse`). permalink 생성에 사용 |
249
- | `SLACK_BOT_OWNER` | X | 봇 소유자 사용자 ID. allowlist 없이도 항상 허용 |
250
- | `SLACK_ACK_REACTION` | X | 수신 확인 이모지 (예: `eyes`). 답장 자동 제거 |
251
- | `SLACK_DEFAULT_COLOR` | X | 메시지 사이드바 색상 hex (기본: `#e5da9a`) |
252
-
253
- 허용된 사용자는 DM이든 채널이든 어디서든 봇과 대화할 수 있습니다.
254
-
255
- ## 실행
256
-
257
- ```bash
258
- claude --dangerously-load-development-channels server:slack-channel
330
+ server.ts MCP 서버, Slack 클라이언트, 이벤트 핸들링
331
+ tools.ts MCP 도구 등록 (reply, react, delete_bot_message, fetch_dm_thread)
332
+ lib/
333
+ gate.ts 접근 제어 (Access, GateOptions, gate)
334
+ process.ts 부모 프로세스 channels 플래그 감지 (hasChannelsFlag)
335
+ security.ts 아웃바운드 게이트 (assertOutboundAllowed)
336
+ formatting.ts Slack mrkdwn 변환, 메시지 텍스트 추출
337
+ audit.ts 감사 로그 (AuditEntry, auditLog)
338
+ event.ts DM 판별, 스레드 해석, stale/empty 필터링
339
+ resilience.ts 이벤트 중복 제거 (EventDeduplicator)
340
+ permalink.ts Slack 퍼마링크 빌더
341
+ index.ts barrel re-export
342
+ server.test.ts lib/ 테스트 (bun:test)
259
343
  ```
260
344
 
261
345
  ## 개발
262
346
 
263
347
  ```bash
264
- bun test # 테스트 실행 (85개)
348
+ bun test # 테스트 실행
265
349
  bun run typecheck # 타입 체크
266
350
  ```
267
351
 
package/bun.lock CHANGED
@@ -45,7 +45,7 @@
45
45
 
46
46
  "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
47
47
 
48
- "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
48
+ "axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
49
49
 
50
50
  "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
51
51
 
@@ -199,7 +199,7 @@
199
199
 
200
200
  "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
201
201
 
202
- "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
202
+ "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
203
203
 
204
204
  "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
205
205
 
@@ -255,7 +255,7 @@
255
255
 
256
256
  "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
257
257
 
258
- "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
258
+ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
259
259
 
260
260
  "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
261
261
 
package/lib/gate.ts CHANGED
@@ -18,16 +18,6 @@ export function defaultAccess(): Access {
18
18
  return { allowFrom: [] }
19
19
  }
20
20
 
21
- /**
22
- * Check if the MCP client supports the channel capability.
23
- */
24
- export function clientSupportsChannels(capabilities: Record<string, unknown> | undefined): boolean {
25
- if (!capabilities) return false
26
- const experimental = capabilities['experimental']
27
- if (!experimental || typeof experimental !== 'object') return false
28
- return 'claude/channel' in experimental
29
- }
30
-
31
21
  export function gate(event: unknown, opts: GateOptions): GateResult {
32
22
  const ev = event as Record<string, unknown>
33
23
 
package/lib/index.ts CHANGED
@@ -5,3 +5,4 @@ export * from './audit.ts'
5
5
  export * from './event.ts'
6
6
  export * from './resilience.ts'
7
7
  export * from './permalink.ts'
8
+ export * from './process.ts'
package/lib/process.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { execSync } from 'child_process'
2
+
3
+ /**
4
+ * Check if a command line string contains channels-related flags.
5
+ */
6
+ export function hasChannelsFlag(commandLine?: string): boolean {
7
+ // Environment variable override — always wins
8
+ if (process.env['SLACK_FORCE_SOCKET_MODE'] === '1') return true
9
+
10
+ const cmd = commandLine ?? findAncestorClaudeCommand()
11
+ return (
12
+ cmd.includes('dangerously-load-development-channels') ||
13
+ /--channels\b/.test(cmd)
14
+ )
15
+ }
16
+
17
+ /**
18
+ * Walk up the process tree from ppid to find a `claude` ancestor.
19
+ * Returns its command line, or '' if not found.
20
+ * Stops after 5 levels to avoid runaway traversal.
21
+ * Works on macOS/Linux (ps) and Windows (wmic).
22
+ */
23
+ function findAncestorClaudeCommand(): string {
24
+ const isWindows = process.platform === 'win32'
25
+ try {
26
+ let pid = process.ppid
27
+ for (let i = 0; i < 5 && pid > 1; i++) {
28
+ const { ppid, cmd } = isWindows
29
+ ? getProcessInfoWindows(pid)
30
+ : getProcessInfoUnix(pid)
31
+ if (!cmd) break
32
+ if (/\bclaude\b/.test(cmd)) return cmd
33
+ pid = ppid
34
+ }
35
+ } catch { /* process query failure → return empty */ }
36
+ return ''
37
+ }
38
+
39
+ function getProcessInfoUnix(pid: number): { ppid: number; cmd: string } {
40
+ const line = execSync(`ps -o ppid=,command= -p ${pid}`, {
41
+ encoding: 'utf-8',
42
+ timeout: 3000,
43
+ }).trim()
44
+ const match = line.match(/^\s*(\d+)\s+(.+)$/)
45
+ if (!match) return { ppid: 0, cmd: '' }
46
+ return { ppid: Number(match[1]), cmd: match[2] }
47
+ }
48
+
49
+ function getProcessInfoWindows(pid: number): { ppid: number; cmd: string } {
50
+ const line = execSync(
51
+ `wmic process where ProcessId=${pid} get ParentProcessId,CommandLine /format:csv`,
52
+ { encoding: 'utf-8', timeout: 3000 },
53
+ ).trim()
54
+ // CSV format: Node,CommandLine,ParentProcessId
55
+ // First line is header, second line is data
56
+ const rows = line.split(/\r?\n/).filter(Boolean)
57
+ if (rows.length < 2) return { ppid: 0, cmd: '' }
58
+ const parts = rows[1].split(',')
59
+ if (parts.length < 3) return { ppid: 0, cmd: '' }
60
+ const cmd = parts.slice(1, -1).join(',') // CommandLine may contain commas
61
+ const ppid = Number(parts[parts.length - 1])
62
+ return { ppid, cmd }
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokrokss/claude-slack-channel",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Two-way Slack channel for Claude Code — Slack DM과 채널 멘션으로 Claude Code 세션과 대화",
5
5
  "type": "module",
6
6
  "bin": "./server.ts",
package/server.test.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  isStaleEvent,
15
15
  isEmptyMessage,
16
16
  EventDeduplicator,
17
- clientSupportsChannels,
17
+ hasChannelsFlag,
18
18
  type Access,
19
19
  type AuditEntry,
20
20
  type GateOptions,
@@ -668,55 +668,51 @@ describe('EventDeduplicator', () => {
668
668
  })
669
669
 
670
670
  // ---------------------------------------------------------------------------
671
- // clientSupportsChannels()
671
+ // hasChannelsFlag()
672
672
  // ---------------------------------------------------------------------------
673
673
 
674
- describe('clientSupportsChannels', () => {
675
- test('returns false for undefined capabilities', () => {
676
- expect(clientSupportsChannels(undefined)).toBe(false)
674
+ describe('hasChannelsFlag', () => {
675
+ test('returns true for --dangerously-load-development-channels', () => {
676
+ expect(hasChannelsFlag('claude --dangerously-load-development-channels server:slack-channel')).toBe(true)
677
677
  })
678
678
 
679
- test('returns false for empty capabilities', () => {
680
- expect(clientSupportsChannels({})).toBe(false)
679
+ test('returns true for --channels flag', () => {
680
+ expect(hasChannelsFlag('claude --channels plugin:telegram@anthropic')).toBe(true)
681
681
  })
682
682
 
683
- test('returns false when no experimental field', () => {
684
- expect(clientSupportsChannels({ tools: {} })).toBe(false)
683
+ test('returns false for plain claude', () => {
684
+ expect(hasChannelsFlag('claude')).toBe(false)
685
685
  })
686
686
 
687
- test('returns false when experimental is empty', () => {
688
- expect(clientSupportsChannels({ experimental: {} })).toBe(false)
687
+ test('returns false for other flags', () => {
688
+ expect(hasChannelsFlag('claude --allow-dangerously-skip-permissions')).toBe(false)
689
689
  })
690
690
 
691
- test('returns false when experimental has other keys but not claude/channel', () => {
692
- expect(clientSupportsChannels({
693
- experimental: { 'some/other': {} },
694
- })).toBe(false)
691
+ test('returns false for empty string', () => {
692
+ expect(hasChannelsFlag('')).toBe(false)
695
693
  })
696
694
 
697
- test('returns true when claude/channel is present', () => {
698
- expect(clientSupportsChannels({
699
- experimental: { 'claude/channel': {} },
700
- })).toBe(true)
695
+ test('returns true when flag is among multiple args', () => {
696
+ expect(hasChannelsFlag('claude --verbose --dangerously-load-development-channels server:slack-channel --model opus')).toBe(true)
701
697
  })
702
698
 
703
- test('returns true when claude/channel is present alongside other keys', () => {
704
- expect(clientSupportsChannels({
705
- experimental: {
706
- 'claude/channel': {},
707
- 'claude/channel/permission': {},
708
- },
709
- })).toBe(true)
699
+ test('does not match --channels as substring of another flag', () => {
700
+ expect(hasChannelsFlag('claude --channelsFoo bar')).toBe(false)
710
701
  })
711
702
 
712
- test('returns true even if claude/channel value is null', () => {
713
- expect(clientSupportsChannels({
714
- experimental: { 'claude/channel': null },
715
- })).toBe(true)
703
+ test('matches --channels=value syntax', () => {
704
+ expect(hasChannelsFlag('claude --channels=plugin:foo@bar')).toBe(true)
716
705
  })
717
706
 
718
- test('returns false when experimental is not an object', () => {
719
- expect(clientSupportsChannels({ experimental: 'string' })).toBe(false)
707
+ test('SLACK_FORCE_SOCKET_MODE=1 overrides detection', () => {
708
+ const prev = process.env['SLACK_FORCE_SOCKET_MODE']
709
+ try {
710
+ process.env['SLACK_FORCE_SOCKET_MODE'] = '1'
711
+ expect(hasChannelsFlag('')).toBe(true)
712
+ } finally {
713
+ if (prev === undefined) delete process.env['SLACK_FORCE_SOCKET_MODE']
714
+ else process.env['SLACK_FORCE_SOCKET_MODE'] = prev
715
+ }
720
716
  })
721
717
  })
722
718
 
package/server.ts CHANGED
@@ -17,6 +17,10 @@ import { join } from 'path'
17
17
  import {
18
18
  mkdirSync,
19
19
  appendFileSync,
20
+ readFileSync,
21
+ writeFileSync,
22
+ unlinkSync,
23
+ existsSync,
20
24
  } from 'fs'
21
25
  import { z } from 'zod'
22
26
  import {
@@ -29,6 +33,7 @@ import {
29
33
  isStaleEvent,
30
34
  isEmptyMessage,
31
35
  EventDeduplicator,
36
+ hasChannelsFlag,
32
37
  type Access,
33
38
  type GateResult,
34
39
  } from './lib/index.ts'
@@ -73,7 +78,9 @@ const allowFromList = (process.env['SLACK_ALLOW_FROM'] || '')
73
78
  const ackReaction = (process.env['SLACK_ACK_REACTION'] || '').trim().replace(/^:|:$/g, '') || undefined
74
79
  console.error(`[slack] ackReaction: ${ackReaction ?? '(disabled)'}`)
75
80
  const botOwner = (process.env['SLACK_BOT_OWNER'] || '').trim() || undefined
81
+ const showFooter = (process.env['SLACK_SHOW_FOOTER'] ?? 'true').trim().toLowerCase() !== 'false'
76
82
  console.error(`[slack] botOwner: ${botOwner ?? '(not set)'}`)
83
+ console.error(`[slack] showFooter: ${showFooter}`)
77
84
  const workspace = process.env['SLACK_WORKSPACE'] || ''
78
85
  if (!workspace) {
79
86
  console.error('[slack] SLACK_WORKSPACE is required for permalink generation. Set it in .mcp.json env field.')
@@ -153,7 +160,7 @@ async function resolveUserName(userId: string): Promise<string> {
153
160
  // ---------------------------------------------------------------------------
154
161
 
155
162
  const mcp = new McpServer(
156
- { name: 'slack-channel', version: '0.1.0' },
163
+ { name: 'slack-channel', version: '0.3.0' },
157
164
  {
158
165
  capabilities: {
159
166
  experimental: {
@@ -190,10 +197,16 @@ registerTools({
190
197
  web,
191
198
  stateDir: STATE_DIR,
192
199
  defaultColor: DEFAULT_COLOR,
200
+ botOwner,
201
+ showFooter,
193
202
  assertOutboundAllowed,
194
203
  lastInboundMessageId,
195
204
  pendingAckReactions,
196
205
  resolveUserName,
206
+ clearInboundContext: () => {
207
+ lastInboundContext = null
208
+ console.error('[slack] inbound context cleared (reply sent)')
209
+ },
197
210
  })
198
211
 
199
212
  // ---------------------------------------------------------------------------
@@ -201,6 +214,7 @@ registerTools({
201
214
  // ---------------------------------------------------------------------------
202
215
 
203
216
  // 마지막 inbound 메시지의 채널+스레드를 추적하여 permission 알림 전송 대상으로 사용
217
+ // reply 도구 호출 시 초기화 — Slack 대화 처리 완료로 간주
204
218
  let lastInboundContext: { channelId: string; threadTs: string } | null = null
205
219
 
206
220
  mcp.server.setNotificationHandler(
@@ -224,13 +238,13 @@ mcp.server.setNotificationHandler(
224
238
 
225
239
  const { channelId, threadTs } = lastInboundContext
226
240
  try {
227
- const ownerTag = botOwner ? ` <@${botOwner}>` : ''
228
241
  await web.chat.postMessage({
229
242
  channel: channelId,
230
243
  thread_ts: threadTs,
231
244
  attachments: [{
232
245
  color: '#f0ad4e',
233
- text: `터미널에서 도구 실행 권한 확인이 필요합니다. ${ownerTag}\n\`${tool_name}\`: ${description}`,
246
+ text: `터미널에서 도구 실행 권한 확인이 필요합니다.\n\`${tool_name}\`: ${description}`,
247
+ ...(showFooter && botOwner ? { footer: `다음 사용자가 만듬 <@${botOwner}>` } : {}),
234
248
  mrkdwn_in: ['text'],
235
249
  }],
236
250
  unfurl_links: false,
@@ -374,7 +388,43 @@ socket.on('app_mention', async ({ event, ack }) => {
374
388
  // Startup
375
389
  // ---------------------------------------------------------------------------
376
390
 
391
+ const PID_FILE = join(STATE_DIR, 'socket.pid')
392
+
393
+ function killPreviousInstance(): void {
394
+ if (!existsSync(PID_FILE)) return
395
+ try {
396
+ const oldPid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10)
397
+ if (isNaN(oldPid) || oldPid === process.pid) return
398
+ // Check if process is alive (signal 0 doesn't kill, just checks)
399
+ process.kill(oldPid, 0)
400
+ debugLog(`[slack] Killing previous Socket Mode instance (pid ${oldPid})`)
401
+ process.kill(oldPid, 'SIGTERM')
402
+ } catch {
403
+ // Process doesn't exist or no permission — safe to proceed
404
+ }
405
+ }
406
+
407
+ function writePidFile(): void {
408
+ writeFileSync(PID_FILE, String(process.pid), 'utf-8')
409
+ }
410
+
411
+ function cleanupPidFile(): void {
412
+ try {
413
+ if (existsSync(PID_FILE) && readFileSync(PID_FILE, 'utf-8').trim() === String(process.pid)) {
414
+ unlinkSync(PID_FILE)
415
+ }
416
+ } catch {}
417
+ }
418
+
419
+ process.on('exit', cleanupPidFile)
420
+ process.on('SIGTERM', () => { cleanupPidFile(); process.exit(0) })
421
+ process.on('SIGINT', () => { cleanupPidFile(); process.exit(0) })
422
+
377
423
  async function startSocketMode(): Promise<void> {
424
+ // Kill previous instance if still running
425
+ killPreviousInstance()
426
+ writePidFile()
427
+
378
428
  // Resolve bot's own user ID (for mention detection + self-filtering)
379
429
  try {
380
430
  const auth = await web.auth.test()
@@ -390,17 +440,15 @@ async function startSocketMode(): Promise<void> {
390
440
  }
391
441
 
392
442
  async function main(): Promise<void> {
393
- // Start Socket Mode (Slack WebSocket) — always connect regardless of client
394
- // channel capability. Claude Code determines channel support on its side via
395
- // --channels flag; the server cannot detect this from the MCP handshake.
396
- // If the client registered channel handlers, notifications are delivered;
397
- // otherwise they are silently ignored.
398
- await startSocketMode().catch((err) => {
399
- console.error('[slack] Socket Mode init failed:', err)
400
- process.exit(1)
401
- })
443
+ if (hasChannelsFlag()) {
444
+ await startSocketMode().catch((err) => {
445
+ console.error('[slack] Socket Mode init failed:', err)
446
+ process.exit(1)
447
+ })
448
+ } else {
449
+ debugLog('[slack] Socket Mode skipped — parent process has no channels flag. Tools-only mode.')
450
+ }
402
451
 
403
- // Connect MCP stdio (server ↔ Claude Code)
404
452
  const transport = new StdioServerTransport()
405
453
  await mcp.connect(transport)
406
454
  debugLog('[slack] MCP server running on stdio')
package/tools.ts CHANGED
@@ -8,14 +8,17 @@ export interface ToolDependencies {
8
8
  web: WebClient
9
9
  stateDir: string
10
10
  defaultColor: string
11
+ botOwner?: string
12
+ showFooter: boolean
11
13
  assertOutboundAllowed: (chatId: string) => void
12
14
  lastInboundMessageId: Map<string, string>
13
15
  pendingAckReactions: Map<string, { channel: string; ts: string; emoji: string }>
14
16
  resolveUserName: (userId: string) => Promise<string>
17
+ clearInboundContext: () => void
15
18
  }
16
19
 
17
20
  export function registerTools(deps: ToolDependencies): void {
18
- const { mcp, web, stateDir, defaultColor, assertOutboundAllowed, lastInboundMessageId, pendingAckReactions, resolveUserName } = deps
21
+ const { mcp, web, stateDir, defaultColor, botOwner, showFooter, assertOutboundAllowed, lastInboundMessageId, pendingAckReactions, resolveUserName, clearInboundContext } = deps
19
22
 
20
23
  mcp.registerTool('reply', {
21
24
  description: 'Send a message to a Slack channel or DM.',
@@ -48,6 +51,7 @@ export function registerTools(deps: ToolDependencies): void {
48
51
  color,
49
52
  text: fixSlackMrkdwn(args.text),
50
53
  mrkdwn_in: ['text'],
54
+ ...(showFooter && botOwner ? { footer: `다음 사용자가 만듬 <@${botOwner}>` } : {}),
51
55
  }],
52
56
  unfurl_links: false,
53
57
  unfurl_media: false,
@@ -70,6 +74,9 @@ export function registerTools(deps: ToolDependencies): void {
70
74
  } catch (err) { console.error('[slack] ack reaction auto-remove failed:', err) }
71
75
  }
72
76
 
77
+ // Slack 대화 처리 완료로 간주 — permission_request 알림 대상 초기화
78
+ clearInboundContext()
79
+
73
80
  return {
74
81
  content: [{
75
82
  type: 'text' as const,