@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,835 @@
1
+ import { sessionManager } from "../claude/session-manager.js";
2
+ import { createLogger } from "../core/logger.js";
3
+ import { updateSlackMessage, updateSlackMessageWithMultiSelect, postThreadMessage } from "../utils/slack-message.js";
4
+ import { setupClaudeRunner } from "./claude-runner-setup.js";
5
+ import { toggleOption, getSelectedOptions, clearState, initState, getState } from "../stores/multi-select-state.js";
6
+ import { isRunnerActive, killActiveRunner } from "../claude/active-runners.js";
7
+ import { t } from "../i18n/index.js";
8
+ import { hasPendingBatch, recordAnswerAndAdvance, buildCombinedAnswer, clearPendingBatch, } from "../stores/pending-questions.js";
9
+ import { buildQuestionBlocks } from "../slack/question-blocks.js";
10
+ import { getWorkspace } from "../stores/workspace-store.js";
11
+ import { getPayload, removePayload, setPayload } from "../stores/action-payload-store.js";
12
+ const log = createLogger("question-handlers");
13
+ // ─────────────────────────────────────────────────────────────────────────
14
+ // Race condition 방지: 동일 스레드에서 동시 resumeClaudeWithAnswer 호출 방지
15
+ // ─────────────────────────────────────────────────────────────────────────
16
+ const pendingResumes = new Set();
17
+ /**
18
+ * 액션의 메시지에서 threadTs를 추출하고 세션을 조회하는 공통 헬퍼
19
+ */
20
+ function resolveSessionFromAction(message, projectName) {
21
+ const threadTs = message?.thread_ts;
22
+ if (!threadTs) {
23
+ log.error("Thread ts not found in message", { projectName });
24
+ return null;
25
+ }
26
+ const session = sessionManager.getByThread(threadTs);
27
+ if (!session) {
28
+ log.error("Session not found", { projectName, threadTs });
29
+ return null;
30
+ }
31
+ return { session, threadTs };
32
+ }
33
+ /**
34
+ * 세션을 찾지 못했을 때 사용자에게 ephemeral 메시지로 안내
35
+ */
36
+ async function postSessionExpiredNotice(client, channelId, userId, threadTs, projectName) {
37
+ try {
38
+ await client.chat.postEphemeral({
39
+ channel: channelId,
40
+ user: userId,
41
+ text: projectName ? t("session.expired", { projectName }) : t("session.expiredUnknown"),
42
+ ...(threadTs ? { thread_ts: threadTs } : {}),
43
+ });
44
+ }
45
+ catch (error) {
46
+ log.error("Failed to send session expired notice", error);
47
+ }
48
+ }
49
+ async function postActionErrorNotice(client, channelId, userId, threadTs) {
50
+ try {
51
+ await client.chat.postEphemeral({
52
+ channel: channelId,
53
+ user: userId,
54
+ text: t("error.actionFailed"),
55
+ ...(threadTs ? { thread_ts: threadTs } : {}),
56
+ });
57
+ }
58
+ catch (ephemeralError) {
59
+ log.error("Failed to send action error notice", ephemeralError);
60
+ }
61
+ }
62
+ function buildInterruptPayloadKey(threadTs, userMessageTs) {
63
+ return userMessageTs ? `interrupt:${threadTs}:${userMessageTs}` : `interrupt:${threadTs}`;
64
+ }
65
+ async function postInterruptPayloadMissingNotice(client, channelId, userId, threadTs) {
66
+ try {
67
+ await client.chat.postEphemeral({
68
+ channel: channelId,
69
+ user: userId,
70
+ text: t("interrupt.payloadExpired"),
71
+ ...(threadTs ? { thread_ts: threadTs } : {}),
72
+ });
73
+ }
74
+ catch (error) {
75
+ log.error("Failed to send interrupt payload missing notice", error);
76
+ }
77
+ }
78
+ /**
79
+ * 사용자 응답 후 Claude를 resume하는 공통 흐름:
80
+ * 1. Slack 메시지 업데이트 (완료 표시)
81
+ * 2. 대기 중인 복수 질문이 있으면 다음 질문 전송 (Claude resume 안 함)
82
+ * 3. 모든 질문에 답변 완료되면 조합된 답변으로 Claude resume
83
+ * 4. 대기 질문이 없으면 단일 답변으로 Claude resume
84
+ */
85
+ async function resumeClaudeWithAnswer(options) {
86
+ const { client, session, channelId, userId, messageTs, projectName, messageId, answerText, questionText, header, multiSelect } = options;
87
+ const threadTs = session.slackThreadTs;
88
+ // 이미 러너가 실행 중이면 ephemeral 메시지로 안내 (버튼 더블 클릭 방지)
89
+ if (isRunnerActive(threadTs)) {
90
+ log.warn("Runner already active, ignoring duplicate answer", { projectName, threadTs });
91
+ try {
92
+ await client.chat.postEphemeral({
93
+ channel: channelId,
94
+ user: userId,
95
+ text: t("error.alreadyProcessing"),
96
+ thread_ts: threadTs,
97
+ });
98
+ }
99
+ catch (error) {
100
+ log.error("Failed to send already-processing notice", error);
101
+ }
102
+ return;
103
+ }
104
+ // Race condition 방지: 이미 resume 진행 중이면 무시 (더블 클릭 방지)
105
+ if (pendingResumes.has(threadTs)) {
106
+ log.warn("Resume already in progress, ignoring duplicate answer", { projectName, threadTs });
107
+ return;
108
+ }
109
+ pendingResumes.add(threadTs);
110
+ try {
111
+ // 1. 현재 Slack 메시지를 답변 완료로 업데이트
112
+ const displayQuestionText = questionText || "(Original question expired)";
113
+ await updateSlackMessage({
114
+ client,
115
+ channelId,
116
+ ts: messageTs,
117
+ projectName,
118
+ messageId,
119
+ question: { question: displayQuestionText, header, options: [], multiSelect },
120
+ selectedAnswer: answerText,
121
+ isSubmitted: true,
122
+ });
123
+ // 2. 대기 중인 복수 질문 배치 확인
124
+ if (hasPendingBatch(threadTs)) {
125
+ const result = recordAnswerAndAdvance(threadTs, answerText);
126
+ if (result && !result.done) {
127
+ // 아직 남은 질문이 있음 — 다음 질문을 새 Slack 메시지로 전송
128
+ const nextQuestion = result.nextQuestion;
129
+ const newMessageId = `ask-${threadTs}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
130
+ // 질문 데이터를 payload store에 저장
131
+ setPayload(`q:${newMessageId}`, {
132
+ questionText: nextQuestion.question,
133
+ header: nextQuestion.header,
134
+ optionLabels: nextQuestion.options.map((o) => o.label),
135
+ });
136
+ // 다음 질문이 multiSelect이면 상태 초기화
137
+ if (nextQuestion.multiSelect) {
138
+ initState({
139
+ projectName,
140
+ messageId: newMessageId,
141
+ options: nextQuestion.options,
142
+ questionText: nextQuestion.question,
143
+ header: nextQuestion.header,
144
+ });
145
+ }
146
+ const blocks = buildQuestionBlocks({
147
+ question: nextQuestion,
148
+ projectName,
149
+ messageId: newMessageId,
150
+ });
151
+ await client.chat.postMessage({
152
+ channel: channelId,
153
+ blocks,
154
+ text: t("runner.questionArrived"),
155
+ thread_ts: threadTs,
156
+ });
157
+ log.info("Posted next pending question", {
158
+ projectName,
159
+ threadTs,
160
+ questionIndex: result.batch.currentIndex,
161
+ totalQuestions: result.batch.questions.length,
162
+ });
163
+ // Claude resume 하지 않음 — 다음 답변 대기
164
+ return;
165
+ }
166
+ if (result && result.done) {
167
+ // 모든 질문에 답변 완료 — 조합된 답변으로 Claude resume
168
+ const combinedAnswer = buildCombinedAnswer(threadTs);
169
+ clearPendingBatch(threadTs);
170
+ if (combinedAnswer) {
171
+ log.info("All pending questions answered, resuming Claude", {
172
+ projectName,
173
+ threadTs,
174
+ answerPreview: combinedAnswer.slice(0, 100),
175
+ });
176
+ setupClaudeRunner({
177
+ client,
178
+ channelId: session.slackChannelId,
179
+ threadTs,
180
+ directory: session.directory,
181
+ projectName: session.projectName,
182
+ prompt: combinedAnswer,
183
+ sessionId: session.sessionId,
184
+ autopilot: session.autopilot,
185
+ });
186
+ return;
187
+ }
188
+ }
189
+ // fallback: result가 null이면 배치 정리 후 일반 흐름
190
+ clearPendingBatch(threadTs);
191
+ }
192
+ // 3. 대기 질문 없음 — 단일 답변으로 Claude resume (기존 동작)
193
+ setupClaudeRunner({
194
+ client,
195
+ channelId: session.slackChannelId,
196
+ threadTs,
197
+ directory: session.directory,
198
+ projectName: session.projectName,
199
+ prompt: answerText,
200
+ sessionId: session.sessionId,
201
+ autopilot: session.autopilot,
202
+ });
203
+ }
204
+ catch (error) {
205
+ log.error("Failed to resume Claude with answer", error);
206
+ try {
207
+ await client.chat.postMessage({
208
+ channel: channelId,
209
+ text: t("error.resumeFailed"),
210
+ thread_ts: threadTs,
211
+ });
212
+ }
213
+ catch (notifyError) {
214
+ log.error("Failed to send resume error notice", notifyError);
215
+ }
216
+ }
217
+ finally {
218
+ pendingResumes.delete(threadTs);
219
+ }
220
+ }
221
+ export function registerQuestionHandlers(app) {
222
+ log.info("Registering question handlers");
223
+ // 단일 선택 옵션 핸들러 - 버튼 클릭 시 바로 Claude에 전달
224
+ // ─────────────────────────────────────────────────────────────────────────
225
+ // AskUserQuestion 응답 흐름의 마지막 단계:
226
+ // 1. 사용자가 Slack에서 버튼 클릭
227
+ // 2. 선택된 옵션으로 Slack 메시지 업데이트 (완료 표시)
228
+ // 3. setupClaudeRunner()를 session.sessionId와 함께 호출
229
+ // → --resume 옵션으로 기존 세션 이어서 실행
230
+ // → 선택한 답변이 prompt로 전달됨
231
+ // ─────────────────────────────────────────────────────────────────────────
232
+ app.action(/^select_option_\d+_\d+$/, async ({ ack, body, client, action }) => {
233
+ await ack();
234
+ try {
235
+ log.info("Button clicked", { action_id: action.action_id });
236
+ const message = body.message;
237
+ if (!action.value)
238
+ return;
239
+ let selected;
240
+ try {
241
+ selected = JSON.parse(action.value);
242
+ }
243
+ catch {
244
+ log.error("Failed to parse select_option value", { value: action.value?.slice(0, 100) });
245
+ return;
246
+ }
247
+ const { projectName, messageId } = selected;
248
+ const channelId = body.channel?.id;
249
+ const messageTs = message?.ts;
250
+ if (!channelId || !messageTs) {
251
+ log.error("Missing channel or message ts in select_option action", { projectName, hasChannel: !!body.channel, hasMessage: !!message });
252
+ return;
253
+ }
254
+ const resolved = resolveSessionFromAction(message, projectName);
255
+ if (!resolved) {
256
+ const threadTs = message?.thread_ts;
257
+ await postSessionExpiredNotice(client, channelId, body.user.id, threadTs, projectName);
258
+ return;
259
+ }
260
+ const { session } = resolved;
261
+ // 질문 데이터를 payload store에서 조회
262
+ const questionPayload = getPayload(`q:${messageId}`);
263
+ await resumeClaudeWithAnswer({
264
+ client,
265
+ session,
266
+ channelId,
267
+ userId: body.user.id,
268
+ messageTs,
269
+ projectName,
270
+ messageId,
271
+ answerText: selected.label,
272
+ questionText: questionPayload?.questionText ?? "",
273
+ header: questionPayload?.header,
274
+ });
275
+ log.info("Answer sent to Claude", {
276
+ projectName,
277
+ threadTs: session.slackThreadTs,
278
+ answer: selected.label,
279
+ });
280
+ }
281
+ catch (error) {
282
+ log.error("Error handling select_option action", error);
283
+ const threadTs = body.message?.thread_ts;
284
+ await postActionErrorNotice(client, body.channel?.id ?? "", body.user.id, threadTs);
285
+ }
286
+ });
287
+ // 직접 입력 버튼 핸들러
288
+ app.action(/^text_input_\d+$/, async ({ ack, body, client, action }) => {
289
+ await ack();
290
+ try {
291
+ const message = body.message;
292
+ if (!action.value)
293
+ return;
294
+ let textInputValue;
295
+ try {
296
+ textInputValue = JSON.parse(action.value);
297
+ }
298
+ catch {
299
+ log.error("Failed to parse text_input value", { value: action.value?.slice(0, 100) });
300
+ return;
301
+ }
302
+ const { projectName, messageId } = textInputValue;
303
+ const channelId = body.channel?.id;
304
+ const messageTs = message?.ts;
305
+ if (!channelId || !messageTs) {
306
+ log.error("Missing channel or message ts in text_input action", { projectName, hasChannel: !!body.channel, hasMessage: !!message });
307
+ return;
308
+ }
309
+ const resolved = resolveSessionFromAction(message, projectName);
310
+ if (!resolved) {
311
+ const threadTs = message?.thread_ts;
312
+ await postSessionExpiredNotice(client, channelId, body.user.id, threadTs);
313
+ return;
314
+ }
315
+ const { session, threadTs } = resolved;
316
+ const questionIndex = textInputValue.questionIndex;
317
+ // questionText, header는 action-payload-store에 저장되어 있으므로
318
+ // modal metadata에 포함하지 않음 (private_metadata ~3,000바이트 제한 대응)
319
+ const metadata = {
320
+ requestId: `${projectName}:${messageId}`,
321
+ questionIndex,
322
+ channelId,
323
+ messageTs,
324
+ threadTs,
325
+ };
326
+ await client.views.open({
327
+ trigger_id: body.trigger_id,
328
+ view: {
329
+ type: "modal",
330
+ callback_id: "text_input_modal",
331
+ private_metadata: JSON.stringify(metadata),
332
+ title: { type: "plain_text", text: t("modal.title") },
333
+ submit: { type: "plain_text", text: t("modal.submit") },
334
+ close: { type: "plain_text", text: t("modal.cancel") },
335
+ blocks: [
336
+ {
337
+ type: "section",
338
+ text: {
339
+ type: "mrkdwn",
340
+ text: t("modal.prompt"),
341
+ },
342
+ },
343
+ {
344
+ type: "input",
345
+ block_id: "answer_block",
346
+ label: { type: "plain_text", text: t("modal.label") },
347
+ element: {
348
+ type: "plain_text_input",
349
+ action_id: "answer_input",
350
+ multiline: true,
351
+ placeholder: { type: "plain_text", text: t("modal.placeholder") },
352
+ },
353
+ },
354
+ ],
355
+ },
356
+ });
357
+ log.debug("Modal opened", { question: questionIndex + 1 });
358
+ }
359
+ catch (error) {
360
+ log.error("Error handling text_input action", error);
361
+ const threadTs = body.message?.thread_ts;
362
+ await postActionErrorNotice(client, body.channel?.id ?? "", body.user.id, threadTs);
363
+ }
364
+ });
365
+ // 모달 제출 핸들러 - 직접 입력 시 Claude에 바로 전달
366
+ app.view("text_input_modal", async ({ ack, view, client, body }) => {
367
+ await ack();
368
+ let metadata;
369
+ try {
370
+ try {
371
+ metadata = JSON.parse(view.private_metadata);
372
+ }
373
+ catch {
374
+ log.error("Failed to parse modal metadata", { metadata: view.private_metadata?.slice(0, 100) });
375
+ return;
376
+ }
377
+ if (!metadata)
378
+ return;
379
+ const { requestId, channelId, messageTs, threadTs } = metadata;
380
+ // requestId에서 projectName:messageId 파싱 (messageId에 ':'가 포함될 수 있으므로 첫 번째 ':'만 기준으로 분리)
381
+ const colonIndex = requestId.indexOf(":");
382
+ if (colonIndex === -1) {
383
+ log.error("Invalid requestId format: missing ':'", { requestId });
384
+ return;
385
+ }
386
+ const projectName = requestId.substring(0, colonIndex);
387
+ const messageId = requestId.substring(colonIndex + 1);
388
+ const answerText = view.state.values.answer_block.answer_input.value || "";
389
+ if (!answerText.trim()) {
390
+ log.debug("Empty answer - ignoring");
391
+ return;
392
+ }
393
+ // threadTs로 세션 조회
394
+ const session = sessionManager.getByThread(threadTs);
395
+ if (!session) {
396
+ log.error("Session not found", { projectName, threadTs });
397
+ await postSessionExpiredNotice(client, channelId, body.user.id, threadTs, projectName);
398
+ return;
399
+ }
400
+ const trimmedAnswer = answerText.trim();
401
+ // multiSelect 질문에서 "직접 입력"으로 답변한 경우 상태 정리
402
+ clearState(projectName, messageId);
403
+ // 질문 데이터를 payload store에서 조회
404
+ const questionPayload = getPayload(`q:${messageId}`);
405
+ await resumeClaudeWithAnswer({
406
+ client,
407
+ session,
408
+ channelId,
409
+ userId: body.user.id,
410
+ messageTs,
411
+ projectName,
412
+ messageId,
413
+ answerText: trimmedAnswer,
414
+ questionText: questionPayload?.questionText ?? "",
415
+ header: questionPayload?.header,
416
+ });
417
+ log.info("Custom answer sent to Claude", {
418
+ projectName,
419
+ threadTs: session.slackThreadTs,
420
+ preview: trimmedAnswer.slice(0, 50),
421
+ });
422
+ }
423
+ catch (error) {
424
+ log.error("Error handling text_input_modal submission", error);
425
+ if (metadata) {
426
+ await postActionErrorNotice(client, metadata.channelId, body.user.id, metadata.threadTs);
427
+ }
428
+ }
429
+ });
430
+ // 복수 선택 토글 핸들러 - 옵션 선택/해제
431
+ // Note: ack() is called immediately to meet Slack's 3-second requirement.
432
+ // Visual feedback delay is due to Slack's message update latency and cannot be further optimized at the application level.
433
+ app.action(/^toggle_option_\d+_\d+$/, async ({ ack, body, client, action }) => {
434
+ await ack();
435
+ try {
436
+ log.info("Toggle button clicked", { action_id: action.action_id });
437
+ const message = body.message;
438
+ if (!action.value)
439
+ return;
440
+ let toggleValue;
441
+ try {
442
+ toggleValue = JSON.parse(action.value);
443
+ }
444
+ catch {
445
+ log.error("Failed to parse toggle_option value", { value: action.value?.slice(0, 100) });
446
+ return;
447
+ }
448
+ const { projectName, messageId } = toggleValue;
449
+ const channelId = body.channel?.id;
450
+ const messageTs = message?.ts;
451
+ if (!channelId || !messageTs) {
452
+ log.error("Missing channel or message ts in toggle_option action", { projectName, hasChannel: !!body.channel, hasMessage: !!message });
453
+ return;
454
+ }
455
+ const resolved = resolveSessionFromAction(message, projectName);
456
+ if (!resolved) {
457
+ const threadTs = message?.thread_ts;
458
+ await postSessionExpiredNotice(client, channelId, body.user.id, threadTs, projectName);
459
+ return;
460
+ }
461
+ // 상태 토글
462
+ toggleOption(projectName, messageId, toggleValue.optionIndex);
463
+ const selectedIndexes = getSelectedOptions(projectName, messageId);
464
+ // Slack 메시지 업데이트 (선택 상태 반영)
465
+ await updateSlackMessageWithMultiSelect({
466
+ client,
467
+ channelId,
468
+ ts: messageTs,
469
+ projectName,
470
+ messageId,
471
+ selectedOptionIndexes: selectedIndexes,
472
+ });
473
+ log.debug("Toggle state updated", {
474
+ projectName,
475
+ messageId,
476
+ optionIndex: toggleValue.optionIndex,
477
+ selectedIndexes,
478
+ });
479
+ }
480
+ catch (error) {
481
+ log.error("Error handling toggle_option action", error);
482
+ const threadTs = body.message?.thread_ts;
483
+ await postActionErrorNotice(client, body.channel?.id ?? "", body.user.id, threadTs);
484
+ }
485
+ });
486
+ // 복수 선택 완료 핸들러 - "선택 완료" 버튼 클릭 시 Claude에 전달
487
+ // ─────────────────────────────────────────────────────────────────────────
488
+ // multiSelect=true인 경우의 응답 흐름:
489
+ // 1. 사용자가 여러 옵션을 토글로 선택/해제 (toggle_option 핸들러)
490
+ // 2. "선택 완료" 버튼 클릭 시 이 핸들러 실행
491
+ // 3. 선택된 옵션들을 쉼표로 연결하여 답변 생성
492
+ // 4. setupClaudeRunner()를 session.sessionId와 함께 호출
493
+ // → --resume 옵션으로 기존 세션 이어서 실행
494
+ // ─────────────────────────────────────────────────────────────────────────
495
+ app.action(/^submit_multi_select_\d+$/, async ({ ack, body, client, action }) => {
496
+ await ack();
497
+ try {
498
+ log.info("Submit multi-select clicked", { action_id: action.action_id });
499
+ const message = body.message;
500
+ if (!action.value)
501
+ return;
502
+ let submitValue;
503
+ try {
504
+ submitValue = JSON.parse(action.value);
505
+ }
506
+ catch {
507
+ log.error("Failed to parse submit_multi_select value", { value: action.value?.slice(0, 100) });
508
+ return;
509
+ }
510
+ const { projectName, messageId } = submitValue;
511
+ const channelId = body.channel?.id;
512
+ const messageTs = message?.ts;
513
+ if (!channelId || !messageTs) {
514
+ log.error("Missing channel or message ts in submit_multi_select action", { projectName, hasChannel: !!body.channel, hasMessage: !!message });
515
+ return;
516
+ }
517
+ const resolved = resolveSessionFromAction(message, projectName);
518
+ if (!resolved) {
519
+ const threadTs = message?.thread_ts;
520
+ await postSessionExpiredNotice(client, channelId, body.user.id, threadTs, projectName);
521
+ return;
522
+ }
523
+ const { session } = resolved;
524
+ // 선택된 옵션들 가져오기
525
+ const selectedIndexes = getSelectedOptions(projectName, messageId);
526
+ if (selectedIndexes.length === 0) {
527
+ log.warn("No options selected", { projectName, messageId });
528
+ const threadTs = message?.thread_ts;
529
+ try {
530
+ await client.chat.postEphemeral({
531
+ channel: channelId,
532
+ user: body.user.id,
533
+ text: t("multiSelect.noneSelected"),
534
+ ...(threadTs ? { thread_ts: threadTs } : {}),
535
+ });
536
+ }
537
+ catch (error) {
538
+ log.error("Failed to send no-selection notice", error);
539
+ }
540
+ return;
541
+ }
542
+ // 질문 데이터를 payload store에서 조회
543
+ const questionPayload = getPayload(`q:${messageId}`);
544
+ // payload가 만료된 경우 multi-select-state에서 옵션 라벨을 fallback으로 사용
545
+ const msState = !questionPayload ? getState(projectName, messageId) : null;
546
+ let optionLabels = questionPayload?.optionLabels ?? [];
547
+ if (optionLabels.length === 0 && msState) {
548
+ optionLabels = msState.options.map(o => o.label);
549
+ log.warn("Payload expired, using multi-select-state as fallback for option labels", { projectName, messageId });
550
+ }
551
+ // 선택된 라벨들 조합
552
+ const selectedLabels = selectedIndexes
553
+ .map(i => optionLabels[i])
554
+ .filter(Boolean);
555
+ const answerText = selectedLabels.join(", ");
556
+ // 상태 정리
557
+ clearState(projectName, messageId);
558
+ await resumeClaudeWithAnswer({
559
+ client,
560
+ session,
561
+ channelId,
562
+ userId: body.user.id,
563
+ messageTs,
564
+ projectName,
565
+ messageId,
566
+ answerText,
567
+ questionText: questionPayload?.questionText ?? msState?.questionText ?? "",
568
+ header: questionPayload?.header ?? msState?.header,
569
+ multiSelect: true,
570
+ });
571
+ log.info("Multi-select answer sent to Claude", {
572
+ projectName,
573
+ threadTs: session.slackThreadTs,
574
+ selectedLabels,
575
+ });
576
+ }
577
+ catch (error) {
578
+ log.error("Error handling submit_multi_select action", error);
579
+ const threadTs = body.message?.thread_ts;
580
+ await postActionErrorNotice(client, body.channel?.id ?? "", body.user.id, threadTs);
581
+ }
582
+ });
583
+ // 일반 모드 개입 확인 - "예" 핸들러
584
+ // 이전 작업을 중단하고 사용자 메시지로 새 작업 시작
585
+ app.action("normal_interrupt_yes", async ({ ack, body, client, action }) => {
586
+ await ack();
587
+ let interruptValue;
588
+ try {
589
+ if (!action.value)
590
+ return;
591
+ try {
592
+ interruptValue = JSON.parse(action.value);
593
+ }
594
+ catch {
595
+ log.error("Failed to parse normal_interrupt_yes value", { value: action.value?.slice(0, 100) });
596
+ return;
597
+ }
598
+ if (!interruptValue)
599
+ return;
600
+ const { threadTs, channelId, projectName, userMessageTs } = interruptValue;
601
+ log.info("Normal interrupt confirmed (yes)", { projectName, threadTs });
602
+ // 실행 중인 러너 종료
603
+ killActiveRunner(threadTs);
604
+ // 확인 메시지 업데이트
605
+ const messageTs = body.message?.ts;
606
+ if (messageTs) {
607
+ await client.chat.update({
608
+ channel: channelId,
609
+ ts: messageTs,
610
+ text: t("command.normalInterruptedYes"),
611
+ blocks: [
612
+ {
613
+ type: "section",
614
+ text: { type: "mrkdwn", text: t("command.normalInterruptedYes") },
615
+ },
616
+ ],
617
+ });
618
+ }
619
+ // 사용자 메시지로 새 작업 시작
620
+ const session = sessionManager.getByThread(threadTs);
621
+ const workspace = session ? undefined : getWorkspace(threadTs);
622
+ if (!session && !workspace) {
623
+ await postThreadMessage(client, channelId, t("session.notFound"), threadTs);
624
+ return;
625
+ }
626
+ // userMessage를 payload store에서 조회
627
+ const payloadKey = buildInterruptPayloadKey(threadTs, userMessageTs);
628
+ const userMessage = getPayload(payloadKey, true) ?? "";
629
+ if (!userMessage.trim()) {
630
+ await postInterruptPayloadMissingNotice(client, channelId, body.user.id, threadTs);
631
+ return;
632
+ }
633
+ if (session) {
634
+ setupClaudeRunner({
635
+ client,
636
+ channelId: session.slackChannelId,
637
+ threadTs: session.slackThreadTs,
638
+ directory: session.directory,
639
+ projectName: session.projectName,
640
+ prompt: userMessage,
641
+ sessionId: session.sessionId,
642
+ autopilot: session.autopilot,
643
+ });
644
+ }
645
+ else if (workspace) {
646
+ setupClaudeRunner({
647
+ client,
648
+ channelId: workspace.channelId,
649
+ threadTs,
650
+ directory: workspace.directory,
651
+ projectName: workspace.projectName,
652
+ prompt: userMessage,
653
+ autopilot: workspace.autopilot,
654
+ });
655
+ }
656
+ }
657
+ catch (error) {
658
+ log.error("Error handling normal_interrupt_yes", error);
659
+ await postActionErrorNotice(client, interruptValue?.channelId ?? body.channel?.id ?? "", body.user?.id ?? "", interruptValue?.threadTs);
660
+ }
661
+ });
662
+ // 일반 모드 개입 확인 - "아니오" 핸들러
663
+ // 이전 작업을 계속 진행
664
+ app.action("normal_interrupt_no", async ({ ack, body, client, action }) => {
665
+ await ack();
666
+ let interruptValue;
667
+ try {
668
+ if (!action.value)
669
+ return;
670
+ try {
671
+ interruptValue = JSON.parse(action.value);
672
+ }
673
+ catch {
674
+ log.error("Failed to parse normal_interrupt_no value", { value: action.value?.slice(0, 100) });
675
+ return;
676
+ }
677
+ if (!interruptValue)
678
+ return;
679
+ const { channelId, projectName, threadTs, userMessageTs } = interruptValue;
680
+ // 사용하지 않는 userMessage 정리
681
+ removePayload(buildInterruptPayloadKey(threadTs, userMessageTs));
682
+ log.info("Normal interrupt declined (no)", { projectName, threadTs });
683
+ // 확인 메시지를 계속 진행 안내로 업데이트
684
+ const messageTs = body.message?.ts;
685
+ if (messageTs) {
686
+ // 러너가 아직 활성 상태인지 확인
687
+ const message = isRunnerActive(threadTs)
688
+ ? t("command.normalInterruptContinue")
689
+ : t("interrupt.taskAlreadyCompleted");
690
+ await client.chat.update({
691
+ channel: channelId,
692
+ ts: messageTs,
693
+ text: message,
694
+ blocks: [
695
+ {
696
+ type: "section",
697
+ text: { type: "mrkdwn", text: message },
698
+ },
699
+ ],
700
+ });
701
+ }
702
+ }
703
+ catch (error) {
704
+ log.error("Error handling normal_interrupt_no", error);
705
+ await postActionErrorNotice(client, interruptValue?.channelId ?? body.channel?.id ?? "", body.user?.id ?? "", interruptValue?.threadTs);
706
+ }
707
+ });
708
+ // Autopilot 개입 확인 - "네" 핸들러
709
+ // autopilot 모드 중단 후 사용자 메시지를 일반 모드로 실행
710
+ app.action("autopilot_interrupt_yes", async ({ ack, body, client, action }) => {
711
+ await ack();
712
+ let interruptValue;
713
+ try {
714
+ if (!action.value)
715
+ return;
716
+ try {
717
+ interruptValue = JSON.parse(action.value);
718
+ }
719
+ catch {
720
+ log.error("Failed to parse autopilot_interrupt_yes value", { value: action.value?.slice(0, 100) });
721
+ return;
722
+ }
723
+ if (!interruptValue)
724
+ return;
725
+ const { threadTs, channelId, projectName, userMessageTs } = interruptValue;
726
+ log.info("Autopilot interrupt confirmed (yes)", { projectName, threadTs });
727
+ // 실행 중인 러너 종료
728
+ killActiveRunner(threadTs);
729
+ // 세션의 autopilot 플래그 해제
730
+ sessionManager.setAutopilot(threadTs, false);
731
+ // 확인 메시지 업데이트
732
+ const messageTs = body.message?.ts;
733
+ if (messageTs) {
734
+ await client.chat.update({
735
+ channel: channelId,
736
+ ts: messageTs,
737
+ text: t("command.autopilotInterruptedYes"),
738
+ blocks: [
739
+ {
740
+ type: "section",
741
+ text: { type: "mrkdwn", text: t("command.autopilotInterruptedYes") },
742
+ },
743
+ ],
744
+ });
745
+ }
746
+ // 사용자 메시지를 일반 모드로 실행
747
+ const session = sessionManager.getByThread(threadTs);
748
+ const workspace = session ? undefined : getWorkspace(threadTs);
749
+ if (!session && !workspace) {
750
+ await postThreadMessage(client, channelId, t("session.notFound"), threadTs);
751
+ return;
752
+ }
753
+ // userMessage를 payload store에서 조회
754
+ const payloadKey = buildInterruptPayloadKey(threadTs, userMessageTs);
755
+ const userMessage = getPayload(payloadKey, true) ?? "";
756
+ if (!userMessage.trim()) {
757
+ await postInterruptPayloadMissingNotice(client, channelId, body.user.id, threadTs);
758
+ return;
759
+ }
760
+ if (session) {
761
+ setupClaudeRunner({
762
+ client,
763
+ channelId: session.slackChannelId,
764
+ threadTs: session.slackThreadTs,
765
+ directory: session.directory,
766
+ projectName: session.projectName,
767
+ prompt: userMessage,
768
+ sessionId: session.sessionId,
769
+ autopilot: false,
770
+ });
771
+ }
772
+ else if (workspace) {
773
+ setupClaudeRunner({
774
+ client,
775
+ channelId: workspace.channelId,
776
+ threadTs,
777
+ directory: workspace.directory,
778
+ projectName: workspace.projectName,
779
+ prompt: userMessage,
780
+ autopilot: false,
781
+ });
782
+ }
783
+ }
784
+ catch (error) {
785
+ log.error("Error handling autopilot_interrupt_yes", error);
786
+ await postActionErrorNotice(client, interruptValue?.channelId ?? body.channel?.id ?? "", body.user?.id ?? "", interruptValue?.threadTs);
787
+ }
788
+ });
789
+ // Autopilot 개입 확인 - "아니요" 핸들러
790
+ // autopilot 모드를 유지하고 확인 메시지만 업데이트
791
+ app.action("autopilot_interrupt_no", async ({ ack, body, client, action }) => {
792
+ await ack();
793
+ let interruptValue;
794
+ try {
795
+ if (!action.value)
796
+ return;
797
+ try {
798
+ interruptValue = JSON.parse(action.value);
799
+ }
800
+ catch {
801
+ log.error("Failed to parse autopilot_interrupt_no value", { value: action.value?.slice(0, 100) });
802
+ return;
803
+ }
804
+ if (!interruptValue)
805
+ return;
806
+ const { channelId, projectName, threadTs, userMessageTs } = interruptValue;
807
+ // 사용하지 않는 userMessage 정리
808
+ removePayload(buildInterruptPayloadKey(threadTs, userMessageTs));
809
+ log.info("Autopilot interrupt declined (no)", { projectName, threadTs });
810
+ // 확인 메시지를 계속 진행 안내로 업데이트
811
+ const messageTs = body.message?.ts;
812
+ if (messageTs) {
813
+ // 러너가 아직 활성 상태인지 확인
814
+ const message = isRunnerActive(threadTs)
815
+ ? t("command.autopilotContinue")
816
+ : t("interrupt.taskAlreadyCompleted");
817
+ await client.chat.update({
818
+ channel: channelId,
819
+ ts: messageTs,
820
+ text: message,
821
+ blocks: [
822
+ {
823
+ type: "section",
824
+ text: { type: "mrkdwn", text: message },
825
+ },
826
+ ],
827
+ });
828
+ }
829
+ }
830
+ catch (error) {
831
+ log.error("Error handling autopilot_interrupt_no", error);
832
+ await postActionErrorNotice(client, interruptValue?.channelId ?? body.channel?.id ?? "", body.user?.id ?? "", interruptValue?.threadTs);
833
+ }
834
+ });
835
+ }