@pollit/twin-dev-bot 0.0.1

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.
Files changed (110) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +415 -0
  3. package/bin/twindevbot.js +22 -0
  4. package/dist/action-payload-store.d.ts +22 -0
  5. package/dist/action-payload-store.js +54 -0
  6. package/dist/active-runners.d.ts +44 -0
  7. package/dist/active-runners.js +114 -0
  8. package/dist/channel-store.d.ts +16 -0
  9. package/dist/channel-store.js +91 -0
  10. package/dist/claude/active-runners.d.ts +44 -0
  11. package/dist/claude/active-runners.js +114 -0
  12. package/dist/claude/claude-runner.d.ts +57 -0
  13. package/dist/claude/claude-runner.js +210 -0
  14. package/dist/claude/session-manager.d.ts +62 -0
  15. package/dist/claude/session-manager.js +247 -0
  16. package/dist/claude-runner.d.ts +57 -0
  17. package/dist/claude-runner.js +210 -0
  18. package/dist/cli.d.ts +2 -0
  19. package/dist/cli.js +271 -0
  20. package/dist/config.d.ts +9 -0
  21. package/dist/config.js +49 -0
  22. package/dist/conversation-store.d.ts +53 -0
  23. package/dist/conversation-store.js +173 -0
  24. package/dist/core/config.d.ts +9 -0
  25. package/dist/core/config.js +49 -0
  26. package/dist/core/logger.d.ts +34 -0
  27. package/dist/core/logger.js +110 -0
  28. package/dist/core/paths.d.ts +11 -0
  29. package/dist/core/paths.js +18 -0
  30. package/dist/core/platform.d.ts +18 -0
  31. package/dist/core/platform.js +33 -0
  32. package/dist/daemon/index.d.ts +3 -0
  33. package/dist/daemon/index.js +14 -0
  34. package/dist/daemon/macos.d.ts +8 -0
  35. package/dist/daemon/macos.js +150 -0
  36. package/dist/daemon/types.d.ts +9 -0
  37. package/dist/daemon/types.js +1 -0
  38. package/dist/daemon/windows.d.ts +8 -0
  39. package/dist/daemon/windows.js +137 -0
  40. package/dist/handlers/claude-command.d.ts +2 -0
  41. package/dist/handlers/claude-command.js +634 -0
  42. package/dist/handlers/claude-runner-setup.d.ts +16 -0
  43. package/dist/handlers/claude-runner-setup.js +445 -0
  44. package/dist/handlers/index.d.ts +3 -0
  45. package/dist/handlers/index.js +3 -0
  46. package/dist/handlers/init-handlers.d.ts +2 -0
  47. package/dist/handlers/init-handlers.js +189 -0
  48. package/dist/handlers/question-handlers.d.ts +2 -0
  49. package/dist/handlers/question-handlers.js +835 -0
  50. package/dist/i18n/en.d.ts +150 -0
  51. package/dist/i18n/en.js +163 -0
  52. package/dist/i18n/index.d.ts +20 -0
  53. package/dist/i18n/index.js +31 -0
  54. package/dist/i18n/ko.d.ts +1 -0
  55. package/dist/i18n/ko.js +141 -0
  56. package/dist/logger.d.ts +34 -0
  57. package/dist/logger.js +110 -0
  58. package/dist/multi-select-state.d.ts +58 -0
  59. package/dist/multi-select-state.js +151 -0
  60. package/dist/paths.d.ts +11 -0
  61. package/dist/paths.js +18 -0
  62. package/dist/pending-questions.d.ts +53 -0
  63. package/dist/pending-questions.js +139 -0
  64. package/dist/platform.d.ts +18 -0
  65. package/dist/platform.js +33 -0
  66. package/dist/progress-tracker.d.ts +47 -0
  67. package/dist/progress-tracker.js +218 -0
  68. package/dist/question-blocks.d.ts +27 -0
  69. package/dist/question-blocks.js +235 -0
  70. package/dist/server.d.ts +1 -0
  71. package/dist/server.js +83 -0
  72. package/dist/session-manager.d.ts +62 -0
  73. package/dist/session-manager.js +247 -0
  74. package/dist/setup.d.ts +5 -0
  75. package/dist/setup.js +132 -0
  76. package/dist/slack/progress-tracker.d.ts +47 -0
  77. package/dist/slack/progress-tracker.js +218 -0
  78. package/dist/slack/question-blocks.d.ts +27 -0
  79. package/dist/slack/question-blocks.js +235 -0
  80. package/dist/stores/action-payload-store.d.ts +22 -0
  81. package/dist/stores/action-payload-store.js +54 -0
  82. package/dist/stores/channel-store.d.ts +16 -0
  83. package/dist/stores/channel-store.js +91 -0
  84. package/dist/stores/multi-select-state.d.ts +58 -0
  85. package/dist/stores/multi-select-state.js +151 -0
  86. package/dist/stores/pending-questions.d.ts +53 -0
  87. package/dist/stores/pending-questions.js +139 -0
  88. package/dist/stores/workspace-store.d.ts +27 -0
  89. package/dist/stores/workspace-store.js +160 -0
  90. package/dist/templates.d.ts +23 -0
  91. package/dist/templates.js +292 -0
  92. package/dist/types/claude-stream.d.ts +116 -0
  93. package/dist/types/claude-stream.js +3 -0
  94. package/dist/types/conversation.d.ts +16 -0
  95. package/dist/types/conversation.js +4 -0
  96. package/dist/types/index.d.ts +2 -0
  97. package/dist/types/index.js +2 -0
  98. package/dist/types/slack.d.ts +51 -0
  99. package/dist/types/slack.js +1 -0
  100. package/dist/utils/display-width.d.ts +8 -0
  101. package/dist/utils/display-width.js +33 -0
  102. package/dist/utils/safe-async.d.ts +6 -0
  103. package/dist/utils/safe-async.js +14 -0
  104. package/dist/utils/slack-message.d.ts +73 -0
  105. package/dist/utils/slack-message.js +220 -0
  106. package/dist/utils/slack-rate-limit.d.ts +5 -0
  107. package/dist/utils/slack-rate-limit.js +49 -0
  108. package/dist/workspace-store.d.ts +27 -0
  109. package/dist/workspace-store.js +160 -0
  110. package/package.json +51 -0
@@ -0,0 +1,634 @@
1
+ import { existsSync, readdirSync, statSync } from "fs";
2
+ import { exec } from "child_process";
3
+ import { basename, join } from "path";
4
+ import { createLogger } from "../core/logger.js";
5
+ import { sessionManager } from "../claude/session-manager.js";
6
+ import { postChannelMessage, postThreadMessage } from "../utils/slack-message.js";
7
+ import { setupClaudeRunner } from "./claude-runner-setup.js";
8
+ import { getTemplate, getTemplateListText, DEFAULT_SCAFFOLD_TIMEOUT } from "../templates.js";
9
+ import { t } from "../i18n/index.js";
10
+ import { addWorkspace, getWorkspace } from "../stores/workspace-store.js";
11
+ import { getChannelDir, setChannelDir } from "../stores/channel-store.js";
12
+ import { isRunnerActive, killActiveRunner } from "../claude/active-runners.js";
13
+ import { config } from "../core/config.js";
14
+ import { setPayload } from "../stores/action-payload-store.js";
15
+ const log = createLogger("claude-command");
16
+ // ─────────────────────────────────────────────────────────────────────────
17
+ // Race condition 방지: 동일 스레드에서 동시 setupClaudeRunner 호출 방지
18
+ // ─────────────────────────────────────────────────────────────────────────
19
+ // Note: setupClaudeRunner는 내부적으로 registerRunner를 호출하며,
20
+ // registerRunner는 기존 러너를 kill하므로 최종적으로는 안전하지만,
21
+ // 두 프로세스가 동시에 spawn되는 것을 방지하기 위한 가드입니다.
22
+ const pendingSetups = new Set();
23
+ // ─────────────────────────────────────────────────────────────────────────
24
+ // Levenshtein distance (간단한 문자열 유사도 측정)
25
+ // ─────────────────────────────────────────────────────────────────────────
26
+ function levenshteinDistance(a, b) {
27
+ const matrix = [];
28
+ for (let i = 0; i <= b.length; i++) {
29
+ matrix[i] = [i];
30
+ }
31
+ for (let j = 0; j <= a.length; j++) {
32
+ matrix[0][j] = j;
33
+ }
34
+ for (let i = 1; i <= b.length; i++) {
35
+ for (let j = 1; j <= a.length; j++) {
36
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
37
+ matrix[i][j] = matrix[i - 1][j - 1];
38
+ }
39
+ else {
40
+ matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
41
+ }
42
+ }
43
+ }
44
+ return matrix[b.length][a.length];
45
+ }
46
+ function parseSubcommand(text) {
47
+ const trimmed = text.trim();
48
+ if (!trimmed)
49
+ return null;
50
+ const parts = trimmed.split(/\s+/);
51
+ return {
52
+ subcommand: parts[0].toLowerCase(),
53
+ args: parts.slice(1),
54
+ };
55
+ }
56
+ // ─────────────────────────────────────────────────────────────────────────
57
+ // 디렉토리명 검증 (커맨드 인젝션 방지)
58
+ // ─────────────────────────────────────────────────────────────────────────
59
+ const SAFE_DIRNAME_RE = /^[a-zA-Z0-9._-]+$/;
60
+ function isValidDirName(name) {
61
+ if (name === "." || name === "..")
62
+ return false;
63
+ if (name.startsWith("-"))
64
+ return false;
65
+ return SAFE_DIRNAME_RE.test(name);
66
+ }
67
+ function isThreadMessage(event) {
68
+ if (typeof event !== "object" || event === null)
69
+ return false;
70
+ const e = event;
71
+ return (typeof e.text === "string" &&
72
+ typeof e.channel === "string" &&
73
+ typeof e.thread_ts === "string" &&
74
+ typeof e.ts === "string" &&
75
+ !e.subtype &&
76
+ !e.bot_id);
77
+ }
78
+ // ─────────────────────────────────────────────────────────────────────────
79
+ // init 핸들러 — 채널의 작업 디렉토리 설정
80
+ // ─────────────────────────────────────────────────────────────────────────
81
+ async function handleChannelInit(client, channelId) {
82
+ const baseDir = config.baseDir;
83
+ if (!existsSync(baseDir)) {
84
+ await postChannelMessage(client, channelId, t("command.baseDirNotFound", { baseDir }));
85
+ return;
86
+ }
87
+ const dirs = readdirSync(baseDir).filter((name) => {
88
+ try {
89
+ return statSync(join(baseDir, name)).isDirectory();
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ });
95
+ // Slack Block Kit 블록 생성
96
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
97
+ const blocks = [];
98
+ if (dirs.length === 0) {
99
+ // 디렉토리 없음 — 안내 + 직접 입력 버튼만
100
+ blocks.push({
101
+ type: "section",
102
+ text: { type: "mrkdwn", text: t("command.initEmpty", { baseDir }) },
103
+ });
104
+ }
105
+ else {
106
+ blocks.push({
107
+ type: "section",
108
+ text: { type: "mrkdwn", text: t("command.initSelectDir") },
109
+ });
110
+ // 디렉토리 버튼 (actions block당 최대 5개)
111
+ for (let i = 0; i < dirs.length; i += 5) {
112
+ const chunk = dirs.slice(i, i + 5);
113
+ blocks.push({
114
+ type: "actions",
115
+ elements: chunk.map((dirName, j) => ({
116
+ type: "button",
117
+ text: { type: "plain_text", text: dirName },
118
+ action_id: `init_select_dir_${i + j}`,
119
+ value: JSON.stringify({ dirName }),
120
+ })),
121
+ });
122
+ }
123
+ }
124
+ // 직접 입력 버튼 (별도 actions block)
125
+ blocks.push({
126
+ type: "actions",
127
+ elements: [
128
+ {
129
+ type: "button",
130
+ text: { type: "plain_text", text: t("command.initCustomInput") },
131
+ action_id: "init_custom_input",
132
+ },
133
+ ],
134
+ });
135
+ await client.chat.postMessage({
136
+ channel: channelId,
137
+ text: t("command.initSelectDir"),
138
+ blocks,
139
+ });
140
+ log.info("Init directory selection posted", { channelId, dirCount: dirs.length });
141
+ }
142
+ // ─────────────────────────────────────────────────────────────────────────
143
+ // task 핸들러 — 채널 작업 디렉토리에서 Claude 세션 시작
144
+ // ─────────────────────────────────────────────────────────────────────────
145
+ async function handleTask(client, channelId, args, userId) {
146
+ const autopilot = args.includes("--autopilot");
147
+ const channelDir = getChannelDir(channelId);
148
+ if (!channelDir) {
149
+ await postChannelMessage(client, channelId, t("command.taskNoDir"));
150
+ return;
151
+ }
152
+ const { directory, projectName } = channelDir;
153
+ // 디렉토리 존재 확인
154
+ if (!existsSync(directory)) {
155
+ await postChannelMessage(client, channelId, t("command.initInvalidDir", { directory }));
156
+ return;
157
+ }
158
+ // 채널에 세션 시작 메시지
159
+ const autopilotSuffix = autopilot ? `\n${t("command.autopilotNotice")}` : "";
160
+ const parentText = t("command.taskStarted", { dirName: projectName }) + autopilotSuffix;
161
+ const startResult = await postChannelMessage(client, channelId, parentText);
162
+ if (!startResult.success || !startResult.ts) {
163
+ log.error("Failed to send task message");
164
+ if (userId) {
165
+ try {
166
+ await client.chat.postEphemeral({
167
+ channel: channelId,
168
+ user: userId,
169
+ text: t("command.postFailed"),
170
+ });
171
+ }
172
+ catch (e) {
173
+ log.error("Failed to send ephemeral error", e);
174
+ }
175
+ }
176
+ return;
177
+ }
178
+ // 스레드에 안내 메시지
179
+ const threadText = t("command.taskSuccess", { dirName: projectName }) + autopilotSuffix;
180
+ await postThreadMessage(client, channelId, threadText, startResult.ts);
181
+ // workspace 등록 (스레드에 메시지 오면 Claude 세션 시작용)
182
+ addWorkspace(startResult.ts, { directory, projectName, channelId, autopilot });
183
+ log.info("Task started", { projectName, directory, threadTs: startResult.ts, autopilot });
184
+ }
185
+ function parseNewArgs(args) {
186
+ let dirName;
187
+ let mode;
188
+ let templateKey;
189
+ for (let i = 0; i < args.length; i++) {
190
+ const arg = args[i];
191
+ if (arg === "--empty") {
192
+ mode = "empty";
193
+ }
194
+ else if (arg === "--template") {
195
+ mode = "template";
196
+ if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
197
+ templateKey = args[i + 1].toLowerCase();
198
+ i++;
199
+ }
200
+ }
201
+ else if (!arg.startsWith("--")) {
202
+ if (!dirName) {
203
+ dirName = arg;
204
+ }
205
+ }
206
+ }
207
+ if (!dirName || !mode)
208
+ return null;
209
+ if (mode === "template" && !templateKey)
210
+ return null;
211
+ return { dirName, mode, templateKey };
212
+ }
213
+ async function handleNew(client, channelId, args) {
214
+ const dirName = args.find((a) => !a.startsWith("--"));
215
+ // 디렉토리 이름이 없으면 사용법 안내
216
+ if (!dirName) {
217
+ await postChannelMessage(client, channelId, t("command.newUsage", { templates: getTemplateListText() }));
218
+ return;
219
+ }
220
+ if (!isValidDirName(dirName)) {
221
+ await postChannelMessage(client, channelId, t("command.invalidDirName", { dirName }));
222
+ return;
223
+ }
224
+ // 옵션 파싱
225
+ const newOpts = parseNewArgs(args);
226
+ if (!newOpts) {
227
+ await postChannelMessage(client, channelId, t("command.newOptionsRequired", { dirName, templates: getTemplateListText() }));
228
+ return;
229
+ }
230
+ const baseDir = config.baseDir;
231
+ if (!existsSync(baseDir)) {
232
+ await postChannelMessage(client, channelId, t("command.baseDirNotFound", { baseDir }));
233
+ return;
234
+ }
235
+ const directory = join(baseDir, newOpts.dirName);
236
+ const projectName = basename(newOpts.dirName);
237
+ // 이미 존재하는 디렉토리 체크
238
+ if (existsSync(directory)) {
239
+ await postChannelMessage(client, channelId, t("command.dirAlreadyExists", { dirName: newOpts.dirName }));
240
+ return;
241
+ }
242
+ if (newOpts.mode === "empty") {
243
+ // 빈 디렉토리 생성
244
+ try {
245
+ const { mkdirSync } = await import("fs");
246
+ mkdirSync(directory, { recursive: true });
247
+ }
248
+ catch (error) {
249
+ const errMsg = error instanceof Error ? error.message : String(error);
250
+ await postChannelMessage(client, channelId, t("command.errorOccurred", { error: errMsg }));
251
+ return;
252
+ }
253
+ await postChannelMessage(client, channelId, t("command.emptyDirCreated", { dirName: newOpts.dirName }));
254
+ }
255
+ else {
256
+ // 템플릿으로 프로젝트 생성
257
+ const template = getTemplate(newOpts.templateKey);
258
+ if (!template) {
259
+ await postChannelMessage(client, channelId, t("command.templateNotFound", { templateKey: newOpts.templateKey, templates: getTemplateListText() }));
260
+ return;
261
+ }
262
+ let scaffoldResult;
263
+ try {
264
+ scaffoldResult = template.scaffold(projectName);
265
+ }
266
+ catch (err) {
267
+ await postChannelMessage(client, channelId, `Failed to prepare scaffold for template "${template.name}": ${String(err)}`);
268
+ return;
269
+ }
270
+ if (typeof scaffoldResult === "string") {
271
+ // 셸 명령어 모드
272
+ const messageResult = await postChannelMessage(client, channelId, t("command.creatingProject", { templateName: template.name, command: scaffoldResult }));
273
+ // 진행 상태 업데이트 (5초마다 점 추가)
274
+ let dots = 0;
275
+ const progressInterval = setInterval(async () => {
276
+ if (!messageResult.success || !messageResult.ts)
277
+ return;
278
+ dots = (dots + 1) % 4;
279
+ const dotStr = ".".repeat(dots + 1);
280
+ try {
281
+ await client.chat.update({
282
+ channel: channelId,
283
+ ts: messageResult.ts,
284
+ text: t("command.creatingProject", { templateName: template.name, command: scaffoldResult }) + dotStr,
285
+ });
286
+ }
287
+ catch { }
288
+ }, 5000);
289
+ try {
290
+ await execAsync(scaffoldResult, baseDir, template.timeout ?? DEFAULT_SCAFFOLD_TIMEOUT);
291
+ }
292
+ catch (error) {
293
+ const errMsg = error instanceof Error ? error.message : String(error);
294
+ await postChannelMessage(client, channelId, t("command.errorOccurred", { error: errMsg }));
295
+ return;
296
+ }
297
+ finally {
298
+ clearInterval(progressInterval);
299
+ }
300
+ }
301
+ else {
302
+ // Node.js API 모드 (크로스 플랫폼)
303
+ const messageResult = await postChannelMessage(client, channelId, t("command.creatingProject", { templateName: template.name, command: template.name }));
304
+ // 진행 상태 업데이트 (5초마다 점 추가)
305
+ let dots = 0;
306
+ const progressInterval = setInterval(async () => {
307
+ if (!messageResult.success || !messageResult.ts)
308
+ return;
309
+ dots = (dots + 1) % 4;
310
+ const dotStr = ".".repeat(dots + 1);
311
+ try {
312
+ await client.chat.update({
313
+ channel: channelId,
314
+ ts: messageResult.ts,
315
+ text: t("command.creatingProject", { templateName: template.name, command: template.name }) + dotStr,
316
+ });
317
+ }
318
+ catch { }
319
+ }, 5000);
320
+ try {
321
+ await scaffoldResult(baseDir);
322
+ }
323
+ catch (error) {
324
+ const errMsg = error instanceof Error ? error.message : String(error);
325
+ await postChannelMessage(client, channelId, t("command.errorOccurred", { error: errMsg }));
326
+ return;
327
+ }
328
+ finally {
329
+ clearInterval(progressInterval);
330
+ }
331
+ }
332
+ await postChannelMessage(client, channelId, t("command.projectCreated", { templateName: template.name, dirName: newOpts.dirName }));
333
+ }
334
+ // new 성공 후 채널 디렉토리 자동 설정 + task 시작
335
+ setChannelDir(channelId, { directory, projectName });
336
+ const autopilotArgs = args.includes("--autopilot") ? ["--autopilot"] : [];
337
+ await handleTask(client, channelId, autopilotArgs);
338
+ }
339
+ function execAsync(command, cwd, timeout = DEFAULT_SCAFFOLD_TIMEOUT) {
340
+ return new Promise((resolve, reject) => {
341
+ exec(command, { timeout, cwd, maxBuffer: 50 * 1024 * 1024 }, (error, stdout, stderr) => {
342
+ if (error) {
343
+ reject(new Error(`${error.message}\n${stderr}`));
344
+ return;
345
+ }
346
+ resolve(stdout);
347
+ });
348
+ });
349
+ }
350
+ // ─────────────────────────────────────────────────────────────────────────
351
+ // stop 핸들러
352
+ // ─────────────────────────────────────────────────────────────────────────
353
+ async function handleStop(client, channelId) {
354
+ // 채널의 최근 메시지 조회하여 활성 스레드 찾기
355
+ try {
356
+ const result = await client.conversations.history({
357
+ channel: channelId,
358
+ limit: 100,
359
+ });
360
+ if (!result.messages) {
361
+ await postChannelMessage(client, channelId, t("command.noActiveTask"));
362
+ return;
363
+ }
364
+ // 스레드가 있는 메시지 중 활성 러너가 있는지 확인
365
+ for (const msg of result.messages) {
366
+ const threadTs = msg.thread_ts || msg.ts;
367
+ if (threadTs && isRunnerActive(threadTs)) {
368
+ killActiveRunner(threadTs);
369
+ await postChannelMessage(client, channelId, t("command.stopped"));
370
+ log.info("Task stopped by user command", { channelId, threadTs });
371
+ return;
372
+ }
373
+ }
374
+ await postChannelMessage(client, channelId, t("command.noActiveTask"));
375
+ }
376
+ catch (error) {
377
+ log.error("Failed to stop task", error);
378
+ await postChannelMessage(client, channelId, t("command.noActiveTask"));
379
+ }
380
+ }
381
+ // ─────────────────────────────────────────────────────────────────────────
382
+ // 사용법 안내
383
+ // ─────────────────────────────────────────────────────────────────────────
384
+ async function showUsage(client, channelId) {
385
+ await postChannelMessage(client, channelId, t("command.help", { templates: getTemplateListText(), baseDir: config.baseDir }));
386
+ }
387
+ // ─────────────────────────────────────────────────────────────────────────
388
+ // 메인 커맨드 등록
389
+ // ─────────────────────────────────────────────────────────────────────────
390
+ export function registerClaudeCommand(app) {
391
+ // /twindevbot 명령어 처리 — 서브커맨드 라우터
392
+ app.command("/twindevbot", async ({ ack, body, client }) => {
393
+ await ack();
394
+ try {
395
+ const channelId = body.channel_id;
396
+ const parsed = parseSubcommand(body.text);
397
+ if (!parsed) {
398
+ await showUsage(client, channelId);
399
+ return;
400
+ }
401
+ log.info("Command received", {
402
+ subcommand: parsed.subcommand,
403
+ args: parsed.args,
404
+ channel: channelId,
405
+ });
406
+ switch (parsed.subcommand) {
407
+ case "init":
408
+ await handleChannelInit(client, channelId);
409
+ break;
410
+ case "task":
411
+ await handleTask(client, channelId, parsed.args, body.user_id);
412
+ break;
413
+ case "new":
414
+ await handleNew(client, channelId, parsed.args);
415
+ break;
416
+ case "stop":
417
+ await handleStop(client, channelId);
418
+ break;
419
+ default: {
420
+ const knownCommands = ["init", "task", "new", "stop"];
421
+ const suggestion = knownCommands.find(cmd => levenshteinDistance(parsed.subcommand, cmd) <= 2);
422
+ if (suggestion) {
423
+ await postChannelMessage(client, channelId, t("command.didYouMean", { suggestion }));
424
+ }
425
+ else {
426
+ await showUsage(client, channelId);
427
+ }
428
+ break;
429
+ }
430
+ }
431
+ }
432
+ catch (error) {
433
+ log.error("Error handling /twindevbot command", error);
434
+ try {
435
+ await postChannelMessage(client, body.channel_id, `:warning: An error occurred while processing your command.`);
436
+ }
437
+ catch {
438
+ // Best effort - don't let error reporting fail the handler
439
+ }
440
+ }
441
+ });
442
+ // 스레드 메시지 처리 (사용자 응답)
443
+ // sessionManager에 세션이 있으면 --resume, workspace만 있으면 새 세션 시작
444
+ app.event("message", async ({ event, client }) => {
445
+ try {
446
+ const raw = event;
447
+ log.info("Message event received", {
448
+ type: raw.type,
449
+ subtype: raw.subtype,
450
+ channel: raw.channel,
451
+ thread_ts: raw.thread_ts,
452
+ ts: raw.ts,
453
+ bot_id: raw.bot_id,
454
+ text: typeof raw.text === "string" ? raw.text.slice(0, 50) : undefined,
455
+ });
456
+ // subtype(message_changed 등), 봇, 텍스트 없음, 채널 없음, 스레드 아닌 메시지 필터링
457
+ if (!isThreadMessage(event))
458
+ return;
459
+ const threadTs = event.thread_ts;
460
+ const text = event.text;
461
+ const userMessageTs = event.ts;
462
+ // 러너가 실행 중이면 개입(interrupt) 확인 메시지를 전송
463
+ // autopilot 모드와 일반 모드를 구분하여 각각 다른 안내 메시지를 표시
464
+ if (isRunnerActive(threadTs)) {
465
+ const existingSession = sessionManager.getByThread(threadTs);
466
+ const workspace = getWorkspace(threadTs);
467
+ const isAutopilot = existingSession?.autopilot || workspace?.autopilot;
468
+ // userMessage를 서버 사이드에 저장 (Slack action.value ~2,000바이트 제한 대응)
469
+ const interruptKey = `interrupt:${threadTs}:${userMessageTs}`;
470
+ setPayload(interruptKey, text);
471
+ const interruptValue = JSON.stringify({
472
+ threadTs,
473
+ channelId: event.channel,
474
+ projectName: existingSession?.projectName || workspace?.projectName || "unknown",
475
+ userMessageTs,
476
+ });
477
+ if (isAutopilot) {
478
+ log.info("Autopilot runner active, sending interrupt confirmation", { threadTs });
479
+ await client.chat.postMessage({
480
+ channel: event.channel,
481
+ thread_ts: threadTs,
482
+ text: t("command.autopilotInterruptConfirm"),
483
+ blocks: [
484
+ {
485
+ type: "section",
486
+ text: { type: "mrkdwn", text: t("command.autopilotInterruptConfirm") },
487
+ },
488
+ {
489
+ type: "actions",
490
+ elements: [
491
+ {
492
+ type: "button",
493
+ text: { type: "plain_text", text: t("command.autopilotInterruptYes") },
494
+ action_id: "autopilot_interrupt_yes",
495
+ value: interruptValue,
496
+ style: "danger",
497
+ },
498
+ {
499
+ type: "button",
500
+ text: { type: "plain_text", text: t("command.autopilotInterruptNo") },
501
+ action_id: "autopilot_interrupt_no",
502
+ value: interruptValue,
503
+ },
504
+ ],
505
+ },
506
+ ],
507
+ });
508
+ return;
509
+ }
510
+ // 일반 모드: 러너가 실행 중이면 개입 확인 메시지 전송
511
+ log.info("Normal runner active, sending interrupt confirmation", { threadTs });
512
+ await client.chat.postMessage({
513
+ channel: event.channel,
514
+ thread_ts: threadTs,
515
+ text: t("command.normalInterruptConfirm"),
516
+ blocks: [
517
+ {
518
+ type: "section",
519
+ text: { type: "mrkdwn", text: t("command.normalInterruptConfirm") },
520
+ },
521
+ {
522
+ type: "actions",
523
+ elements: [
524
+ {
525
+ type: "button",
526
+ text: { type: "plain_text", text: t("command.normalInterruptYes") },
527
+ action_id: "normal_interrupt_yes",
528
+ value: interruptValue,
529
+ style: "danger",
530
+ },
531
+ {
532
+ type: "button",
533
+ text: { type: "plain_text", text: t("command.normalInterruptNo") },
534
+ action_id: "normal_interrupt_no",
535
+ value: interruptValue,
536
+ },
537
+ ],
538
+ },
539
+ ],
540
+ });
541
+ return;
542
+ }
543
+ // 1. 기존 세션이 있으면 resume
544
+ const session = sessionManager.getByThread(threadTs);
545
+ if (session) {
546
+ log.info("Resuming session from thread message", {
547
+ projectName: session.projectName,
548
+ threadTs,
549
+ text,
550
+ });
551
+ // Race condition 방지: 이미 설정 중이면 무시
552
+ if (pendingSetups.has(threadTs)) {
553
+ log.debug("Setup already in progress for thread", { threadTs });
554
+ return;
555
+ }
556
+ pendingSetups.add(threadTs);
557
+ try {
558
+ setupClaudeRunner({
559
+ client,
560
+ channelId: session.slackChannelId,
561
+ threadTs: session.slackThreadTs,
562
+ directory: session.directory,
563
+ projectName: session.projectName,
564
+ prompt: text,
565
+ sessionId: session.sessionId,
566
+ userMessageTs,
567
+ autopilot: session.autopilot,
568
+ });
569
+ // setupClaudeRunner는 동기 함수이며 runner 등록까지 완료됨
570
+ pendingSetups.delete(threadTs);
571
+ }
572
+ catch (error) {
573
+ pendingSetups.delete(threadTs);
574
+ log.error("Failed to setup Claude runner for session resume", error);
575
+ await postThreadMessage(client, event.channel, t("runner.errorOccurred", { error: error instanceof Error ? error.message : String(error) }), threadTs);
576
+ }
577
+ return;
578
+ }
579
+ // 2. workspace가 있으면 새 Claude 세션 시작
580
+ const workspace = getWorkspace(threadTs);
581
+ if (workspace) {
582
+ log.info("Starting new Claude session from workspace", {
583
+ projectName: workspace.projectName,
584
+ threadTs,
585
+ text,
586
+ });
587
+ // Race condition 방지: 이미 설정 중이면 무시
588
+ if (pendingSetups.has(threadTs)) {
589
+ log.debug("Setup already in progress for thread", { threadTs });
590
+ return;
591
+ }
592
+ pendingSetups.add(threadTs);
593
+ try {
594
+ setupClaudeRunner({
595
+ client,
596
+ channelId: workspace.channelId,
597
+ threadTs,
598
+ directory: workspace.directory,
599
+ projectName: workspace.projectName,
600
+ prompt: text,
601
+ userMessageTs,
602
+ autopilot: workspace.autopilot,
603
+ });
604
+ // setupClaudeRunner는 동기 함수이며 runner 등록까지 완료됨
605
+ pendingSetups.delete(threadTs);
606
+ }
607
+ catch (error) {
608
+ pendingSetups.delete(threadTs);
609
+ log.error("Failed to setup Claude runner for new workspace session", error);
610
+ await postThreadMessage(client, event.channel, t("runner.errorOccurred", { error: error instanceof Error ? error.message : String(error) }), threadTs);
611
+ }
612
+ return;
613
+ }
614
+ // 세션도 워크스페이스도 없는 스레드 — 사용자에게 안내
615
+ log.debug("No session or workspace found for thread", { threadTs });
616
+ if (event.user) {
617
+ try {
618
+ await client.chat.postEphemeral({
619
+ channel: event.channel,
620
+ user: event.user,
621
+ text: t("session.expired"),
622
+ thread_ts: threadTs,
623
+ });
624
+ }
625
+ catch (ephemeralError) {
626
+ log.error("Failed to send stale thread notice", ephemeralError);
627
+ }
628
+ }
629
+ }
630
+ catch (error) {
631
+ log.error("Error handling message event", error);
632
+ }
633
+ });
634
+ }
@@ -0,0 +1,16 @@
1
+ import type { WebClient } from "@slack/web-api";
2
+ export interface SetupClaudeRunnerOptions {
3
+ client: WebClient;
4
+ channelId: string;
5
+ threadTs: string;
6
+ directory: string;
7
+ projectName: string;
8
+ prompt: string;
9
+ sessionId?: string;
10
+ userMessageTs?: string;
11
+ autopilot?: boolean;
12
+ }
13
+ /**
14
+ * Claude 실행 및 이벤트 핸들러 설정
15
+ */
16
+ export declare function setupClaudeRunner(options: SetupClaudeRunnerOptions): void;