@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,445 @@
1
+ import { createLogger } from "../core/logger.js";
2
+ import { runClaude, } from "../claude/claude-runner.js";
3
+ import { sessionManager } from "../claude/session-manager.js";
4
+ import { buildQuestionBlocks } from "../slack/question-blocks.js";
5
+ import { initState } from "../stores/multi-select-state.js";
6
+ import { postThreadMessage } from "../utils/slack-message.js";
7
+ import { t } from "../i18n/index.js";
8
+ import { ProgressTracker } from "../slack/progress-tracker.js";
9
+ import { safeAsync } from "../utils/safe-async.js";
10
+ import { registerRunner, unregisterRunner, refreshActivity } from "../claude/active-runners.js";
11
+ import { removeWorkspace } from "../stores/workspace-store.js";
12
+ import { config } from "../core/config.js";
13
+ import { initPendingBatch } from "../stores/pending-questions.js";
14
+ import { setPayload } from "../stores/action-payload-store.js";
15
+ const log = createLogger("claude-runner-setup");
16
+ /**
17
+ * 텍스트 메시지를 버퍼에 모아서 일정 시간 후 한 번에 전송.
18
+ * Slack API rate limit을 방지하기 위해 사용.
19
+ */
20
+ class TextBuffer {
21
+ sendFn;
22
+ flushDelayMs;
23
+ buffer = [];
24
+ timer = null;
25
+ constructor(sendFn, flushDelayMs = 2000) {
26
+ this.sendFn = sendFn;
27
+ this.flushDelayMs = flushDelayMs;
28
+ }
29
+ append(text) {
30
+ this.buffer.push(text);
31
+ this.resetTimer();
32
+ }
33
+ async flush() {
34
+ if (this.timer) {
35
+ clearTimeout(this.timer);
36
+ this.timer = null;
37
+ }
38
+ if (this.buffer.length === 0)
39
+ return;
40
+ const combined = this.buffer.join("\n");
41
+ this.buffer = [];
42
+ await this.sendFn(combined);
43
+ }
44
+ resetTimer() {
45
+ if (this.timer)
46
+ clearTimeout(this.timer);
47
+ this.timer = setTimeout(() => {
48
+ this.flush().catch((err) => log.error("TextBuffer flush error", err));
49
+ }, this.flushDelayMs);
50
+ }
51
+ }
52
+ /**
53
+ * Claude 실행 및 이벤트 핸들러 설정
54
+ */
55
+ export function setupClaudeRunner(options) {
56
+ const { client, channelId, threadTs, directory, projectName, prompt, sessionId, userMessageTs, autopilot } = options;
57
+ // threadTs 유효성 검사
58
+ if (!threadTs) {
59
+ log.error("setupClaudeRunner called without threadTs", {
60
+ channelId,
61
+ projectName,
62
+ sessionId,
63
+ });
64
+ throw new Error("threadTs is required");
65
+ }
66
+ log.info("setupClaudeRunner", { channelId, threadTs, projectName, sessionId });
67
+ // 진행 상태 추적기 생성
68
+ const tracker = new ProgressTracker({ client, channelId, threadTs, userMessageTs });
69
+ const receivedPromise = tracker.markReceived();
70
+ // result/exit 이벤트 간 레이스 컨디션 방지용 상태
71
+ let resultReceived = false;
72
+ let resultPromise = null;
73
+ let completionHandled = false;
74
+ // askUser/exitPlanMode ↔ exit 이벤트 간 레이스 컨디션 방지용 상태
75
+ // handlerTakeover: askUser/exitPlanMode 핸들러가 비동기 작업 중임을 표시
76
+ // processExitedEarly: handlerTakeover 중 프로세스가 예상치 못하게 종료됨
77
+ let handlerTakeover = false;
78
+ let processExitedEarly = false;
79
+ // 텍스트 버퍼 (2초간 모아서 한 번에 전송)
80
+ const textBuffer = new TextBuffer(async (text) => {
81
+ await postThreadMessage(client, channelId, text, threadTs);
82
+ }, 2000);
83
+ const runner = runClaude({
84
+ directory,
85
+ prompt,
86
+ sessionId,
87
+ });
88
+ // 활성 러너 등록 (동시 실행 방지 + 비활성 타임아웃)
89
+ registerRunner(threadTs, runner, {
90
+ onTimeout: () => {
91
+ log.warn("Runner killed by inactivity timeout", { projectName, threadTs });
92
+ const timeoutMsg = t("runner.inactivityTimeout", { minutes: config.inactivityTimeoutMinutes });
93
+ postThreadMessage(client, channelId, timeoutMsg, threadTs)
94
+ .catch((err) => log.error("Failed to send timeout message", err));
95
+ tracker.markError(timeoutMsg)
96
+ .catch((err) => log.error("Failed to mark timeout error", err));
97
+ },
98
+ });
99
+ // 이벤트 핸들러 설정
100
+ runner.on("init", safeAsync(async (event) => {
101
+ refreshActivity(threadTs, runner);
102
+ // 새 세션인 경우에만 sessionManager에 추가
103
+ if (!sessionId) {
104
+ sessionManager.add({
105
+ sessionId: event.sessionId,
106
+ projectName,
107
+ directory,
108
+ slackChannelId: channelId,
109
+ slackThreadTs: threadTs,
110
+ startedAt: new Date(),
111
+ lastActivityAt: new Date(),
112
+ autopilot: autopilot ?? false,
113
+ });
114
+ // 세션이 생성되었으므로 workspace 매핑 제거 (메모리 누수 방지)
115
+ removeWorkspace(threadTs);
116
+ log.info("Session started", { sessionId: event.sessionId, projectName, threadTs });
117
+ }
118
+ else {
119
+ // resume인 경우 활동 시간 업데이트
120
+ sessionManager.updateActivity(event.sessionId);
121
+ log.info("Session resumed", { sessionId: event.sessionId, projectName, threadTs });
122
+ }
123
+ // markReceived()의 👀 리액션이 완료된 후 ⚙️로 교체해야
124
+ // 두 리액션이 동시에 남는 레이스 컨디션을 방지
125
+ await receivedPromise;
126
+ await tracker.markWorking();
127
+ }, "init"));
128
+ // Claude 텍스트 출력 처리 (버퍼링하여 rate limit 방지)
129
+ runner.on("text", safeAsync(async (event) => {
130
+ refreshActivity(threadTs, runner);
131
+ if (!event.text.trim())
132
+ return;
133
+ textBuffer.append(event.text);
134
+ }, "text"));
135
+ // 도구 사용 처리 (AskUserQuestion, ExitPlanMode 제외 — 별도 이벤트로 처리)
136
+ runner.on("toolUse", safeAsync(async (event) => {
137
+ refreshActivity(threadTs, runner);
138
+ if (event.toolName === "AskUserQuestion")
139
+ return;
140
+ if (event.toolName === "ExitPlanMode")
141
+ return;
142
+ await tracker.updateToolUse(event.toolName);
143
+ }, "toolUse"));
144
+ // ExitPlanMode 처리 — stdin이 "ignore"라 CLI가 승인을 받을 수 없으므로
145
+ // 프로세스를 kill하고 "계획 승인" 메시지로 resume하여 plan mode를 빠져나감
146
+ runner.on("exitPlanMode", safeAsync(async () => {
147
+ handlerTakeover = true;
148
+ await textBuffer.flush();
149
+ // flush 중 프로세스가 예상치 못하게 종료된 경우 → exit 핸들러가 에러 처리 완료함
150
+ if (processExitedEarly) {
151
+ log.warn("Process exited during exitPlanMode handling, aborting resume", { projectName });
152
+ return;
153
+ }
154
+ log.info("ExitPlanMode detected, auto-approving", { projectName, autopilot });
155
+ await tracker.markPlanApproved();
156
+ const currentSessionId = runner.currentSessionId;
157
+ unregisterRunner(threadTs, runner);
158
+ runner.kill();
159
+ // markPlanApproved 중 프로세스가 종료된 경우 resume 방지
160
+ if (processExitedEarly) {
161
+ log.warn("Process exited during exitPlanMode approval, aborting resume", { projectName });
162
+ return;
163
+ }
164
+ if (currentSessionId) {
165
+ try {
166
+ setupClaudeRunner({
167
+ client,
168
+ channelId,
169
+ threadTs,
170
+ directory,
171
+ projectName,
172
+ prompt: t("runner.planApproved"),
173
+ sessionId: currentSessionId,
174
+ autopilot: autopilot ?? false,
175
+ });
176
+ }
177
+ catch (error) {
178
+ log.error("Failed to resume after ExitPlanMode", { projectName, error });
179
+ await postThreadMessage(client, channelId, t("runner.autopilotResumeFailed"), threadTs);
180
+ await tracker.markError(t("runner.autopilotResumeFailed"));
181
+ }
182
+ }
183
+ else {
184
+ log.error("No sessionId for ExitPlanMode resume", { projectName });
185
+ await postThreadMessage(client, channelId, t("runner.autopilotNoSession"), threadTs);
186
+ await tracker.markError(t("runner.autopilotNoSession"));
187
+ }
188
+ }, "exitPlanMode"));
189
+ // AskUserQuestion 처리 - Slack 스레드로 질문 전송
190
+ // ─────────────────────────────────────────────────────────────────────────
191
+ // 흐름: Claude CLI → askUser 이벤트 → Slack 전송 → 프로세스 kill → 대기
192
+ // → 사용자 Slack 응답 → question-handlers.ts → --resume으로 재시작
193
+ //
194
+ // autopilot 모드: 첫 번째 옵션을 즉시 자동 선택하고 resume
195
+ //
196
+ // 중요: Slack 전송 완료 후 반드시 runner.kill()을 호출해야 함
197
+ // - Claude CLI의 stdin이 "ignore"라서 응답을 받을 수 없음
198
+ // - kill하지 않으면 Claude가 "Answer questions?" 에러를 받고
199
+ // 같은 질문을 다른 tool_use_id로 무한 반복함
200
+ // ─────────────────────────────────────────────────────────────────────────
201
+ runner.on("askUser", safeAsync(async (event) => {
202
+ handlerTakeover = true;
203
+ await textBuffer.flush();
204
+ // flush 중 프로세스가 예상치 못하게 종료된 경우 → exit 핸들러가 에러 처리 완료함
205
+ if (processExitedEarly) {
206
+ log.warn("Process exited during askUser handling, aborting", { projectName });
207
+ return;
208
+ }
209
+ const questions = event.input.questions;
210
+ log.info("AskUserQuestion received", {
211
+ projectName,
212
+ questionCount: questions.length,
213
+ autopilot,
214
+ });
215
+ // 빈 질문 배열 방어 — 사용자에게 알림 후 세션 종료
216
+ if (!questions || questions.length === 0) {
217
+ log.warn("AskUserQuestion received with no questions, terminating", { projectName });
218
+ await postThreadMessage(client, channelId, t("runner.emptyQuestions"), threadTs);
219
+ await tracker.markError(t("runner.emptyQuestions"));
220
+ unregisterRunner(threadTs, runner);
221
+ runner.kill();
222
+ return;
223
+ }
224
+ const messageId = `ask-${threadTs}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
225
+ const firstQuestion = questions[0];
226
+ if (autopilot) {
227
+ // ── Autopilot: recommended 옵션 우선, 없으면 첫 번째 옵션 자동 선택 ──
228
+ // multiSelect인 경우 recommended 옵션 모두 선택
229
+ const pickBestOptions = (options, multiSelect) => {
230
+ if (multiSelect) {
231
+ const recommended = options.filter(o => /recommended/i.test(o.label));
232
+ if (recommended.length > 0)
233
+ return recommended.map(o => o.label);
234
+ }
235
+ else {
236
+ const recommended = options.find(o => /recommended/i.test(o.label));
237
+ if (recommended)
238
+ return [recommended.label];
239
+ }
240
+ return [options[0]?.label || "N/A"];
241
+ };
242
+ const parts = questions.map(q => {
243
+ const answers = pickBestOptions(q.options, q.multiSelect);
244
+ const answer = answers.join(", ");
245
+ const header = q.header || q.question.slice(0, 50);
246
+ return { question: q, header, answer };
247
+ });
248
+ const combinedPrompt = questions.length === 1
249
+ ? parts[0].answer
250
+ : parts.map(p => `[${p.header}]: ${p.answer}`).join("\n");
251
+ const displayAnswer = questions.length === 1
252
+ ? parts[0].answer
253
+ : parts.map(p => `${p.header}: ${p.answer}`).join(", ");
254
+ log.info("Autopilot auto-selecting", { projectName, selectedLabel: displayAnswer });
255
+ // Slack에 질문 + 자동 선택 결과를 완료 상태로 표시
256
+ // 복수 질문: 각 질문을 개별 메시지로 전송하여 리뷰 시 어떤 질문에 어떤 답변이 선택되었는지 명확히 표시
257
+ for (let i = 0; i < parts.length; i++) {
258
+ const part = parts[i];
259
+ const answerText = t("runner.autopilotAnswer", { answer: part.answer });
260
+ const blocks = buildQuestionBlocks({
261
+ question: part.question,
262
+ projectName,
263
+ messageId,
264
+ selectedAnswer: answerText,
265
+ isSubmitted: true,
266
+ });
267
+ try {
268
+ await client.chat.postMessage({
269
+ channel: channelId,
270
+ blocks,
271
+ text: answerText,
272
+ thread_ts: threadTs,
273
+ });
274
+ }
275
+ catch (error) {
276
+ log.error("Failed to post autopilot answer to Slack", error);
277
+ }
278
+ // Slack API 속도 제한 방지: 메시지 사이에 200ms 딜레이
279
+ if (i < parts.length - 1) {
280
+ await new Promise(resolve => setTimeout(resolve, 200));
281
+ }
282
+ }
283
+ // 리액션은 유지(⚙️)하고 상태 메시지만 갱신 — markCompleted를 쓰면
284
+ // "✅ 완료" 후 즉시 새 작업이 시작되어 사용자에게 혼란을 줌
285
+ await tracker.markAutopilotContinue();
286
+ const currentSessionId = runner.currentSessionId;
287
+ unregisterRunner(threadTs, runner);
288
+ runner.kill();
289
+ // Slack 전송 중 프로세스가 예상치 못하게 종료된 경우 resume 방지
290
+ if (processExitedEarly) {
291
+ log.warn("Process exited during autopilot askUser handling, aborting resume", { projectName });
292
+ return;
293
+ }
294
+ if (currentSessionId) {
295
+ try {
296
+ setupClaudeRunner({
297
+ client,
298
+ channelId,
299
+ threadTs,
300
+ directory,
301
+ projectName,
302
+ prompt: combinedPrompt,
303
+ sessionId: currentSessionId,
304
+ autopilot: true,
305
+ });
306
+ }
307
+ catch (error) {
308
+ log.error("Autopilot: failed to resume", { projectName, error });
309
+ await postThreadMessage(client, channelId, t("runner.autopilotResumeFailed"), threadTs);
310
+ await tracker.markError(t("runner.autopilotResumeFailed"));
311
+ }
312
+ }
313
+ else {
314
+ log.error("Autopilot: no sessionId available for resume", { projectName });
315
+ await postThreadMessage(client, channelId, t("runner.autopilotNoSession"), threadTs);
316
+ await tracker.markError(t("runner.autopilotNoSession"));
317
+ }
318
+ }
319
+ else {
320
+ // ── 일반 모드: Slack에 질문 전송 후 사용자 응답 대기 ──
321
+ // 복수 질문인 경우 pending batch 초기화 (순차 표시용)
322
+ if (questions.length > 1) {
323
+ initPendingBatch(threadTs, questions, projectName, channelId);
324
+ }
325
+ // 질문 데이터를 payload store에 저장 (Slack action.value 크기 제한 대응)
326
+ setPayload(`q:${messageId}`, {
327
+ questionText: firstQuestion.question,
328
+ header: firstQuestion.header,
329
+ optionLabels: firstQuestion.options.map(o => o.label),
330
+ });
331
+ // 첫 번째 질문이 multiSelect인 경우 상태 초기화
332
+ if (firstQuestion?.multiSelect) {
333
+ initState({
334
+ projectName,
335
+ messageId,
336
+ options: firstQuestion.options,
337
+ questionText: firstQuestion.question,
338
+ header: firstQuestion.header,
339
+ });
340
+ }
341
+ const blocks = buildQuestionBlocks({
342
+ question: questions[0],
343
+ projectName,
344
+ messageId,
345
+ });
346
+ try {
347
+ await client.chat.postMessage({
348
+ channel: channelId,
349
+ blocks,
350
+ text: t("runner.questionArrived"),
351
+ thread_ts: threadTs,
352
+ });
353
+ await tracker.markAskUser();
354
+ log.info("Killing Claude process after AskUserQuestion sent to Slack");
355
+ unregisterRunner(threadTs, runner);
356
+ runner.kill();
357
+ }
358
+ catch (error) {
359
+ log.error("Failed to send question to Slack", error);
360
+ unregisterRunner(threadTs, runner);
361
+ runner.kill();
362
+ }
363
+ }
364
+ }, "askUser"));
365
+ // 작업 완료 처리
366
+ runner.on("result", safeAsync(async (event) => {
367
+ refreshActivity(threadTs, runner);
368
+ resultReceived = true;
369
+ resultPromise = (async () => {
370
+ await textBuffer.flush();
371
+ log.info("Task completed", { projectName, costUsd: event.costUsd });
372
+ if (!completionHandled) {
373
+ completionHandled = true;
374
+ await tracker.markCompleted();
375
+ }
376
+ })();
377
+ await resultPromise;
378
+ }, "result"));
379
+ runner.on("error", safeAsync(async (error) => {
380
+ await textBuffer.flush();
381
+ unregisterRunner(threadTs, runner);
382
+ // ENOENT = claude 명령이 PATH에 없음 → 친절한 안내 메시지
383
+ const isNotFound = error.code === "ENOENT";
384
+ const userMessage = isNotFound
385
+ ? t("runner.claudeNotFound")
386
+ : t("runner.errorOccurred", { error: error.message });
387
+ await postThreadMessage(client, channelId, userMessage, threadTs);
388
+ await tracker.markError(userMessage);
389
+ }, "error"));
390
+ runner.on("exit", safeAsync(async (code) => {
391
+ try {
392
+ // result 핸들러가 진행 중이면 완료될 때까지 대기 (레이스 컨디션 방지)
393
+ if (resultPromise) {
394
+ await resultPromise;
395
+ }
396
+ await textBuffer.flush();
397
+ unregisterRunner(threadTs, runner);
398
+ // null = kill()로 종료 (autopilot resume, askUser 대기 등) → 무시
399
+ if (code === null)
400
+ return;
401
+ // askUser/exitPlanMode 핸들러가 비동기 작업 중일 때 프로세스가 예상치 못하게 종료된 경우
402
+ // → processExitedEarly 플래그로 핸들러에 알리고, 에러 처리는 여기서 수행
403
+ if (handlerTakeover) {
404
+ processExitedEarly = true;
405
+ const stderr = runner.stderrOutput;
406
+ if (stderr) {
407
+ log.error("Claude process stderr (during handler takeover)", { code, stderr: stderr.slice(-1000), threadTs, projectName });
408
+ }
409
+ else {
410
+ log.warn("Process exited while askUser/exitPlanMode handler in progress", { code, projectName });
411
+ }
412
+ const errorMsg = t("runner.exitError", { code: String(code) });
413
+ await postThreadMessage(client, channelId, errorMsg, threadTs);
414
+ await tracker.markError(errorMsg);
415
+ return;
416
+ }
417
+ // 정상 종료 + result 이벤트 수신 완료 → 무시
418
+ if (code === 0 && resultReceived)
419
+ return;
420
+ // 정상 종료이나 result 이벤트 없음 → fallback 완료 처리
421
+ if (code === 0 && !resultReceived) {
422
+ log.warn("Claude exited normally but no result event", { projectName });
423
+ if (!completionHandled) {
424
+ completionHandled = true;
425
+ await tracker.markCompleted();
426
+ }
427
+ return;
428
+ }
429
+ // 비정상 종료 → Slack 알림 + tracker 에러 표시 (stderr는 로그에만 기록)
430
+ const stderr = runner.stderrOutput;
431
+ if (stderr) {
432
+ log.error("Claude process exited with error", { code, projectName, stderr: stderr.slice(-1000), threadTs });
433
+ }
434
+ else {
435
+ log.warn("Claude process exited with error", { code, projectName });
436
+ }
437
+ const errorMsg = t("runner.exitError", { code: String(code) });
438
+ await postThreadMessage(client, channelId, errorMsg, threadTs);
439
+ await tracker.markError(errorMsg);
440
+ }
441
+ finally {
442
+ tracker.dispose();
443
+ }
444
+ }, "exit"));
445
+ }
@@ -0,0 +1,3 @@
1
+ export { registerClaudeCommand } from "./claude-command.js";
2
+ export { registerQuestionHandlers } from "./question-handlers.js";
3
+ export { registerInitHandlers } from "./init-handlers.js";
@@ -0,0 +1,3 @@
1
+ export { registerClaudeCommand } from "./claude-command.js";
2
+ export { registerQuestionHandlers } from "./question-handlers.js";
3
+ export { registerInitHandlers } from "./init-handlers.js";
@@ -0,0 +1,2 @@
1
+ import type { App } from "@slack/bolt";
2
+ export declare function registerInitHandlers(app: App): void;
@@ -0,0 +1,189 @@
1
+ import { existsSync } from "fs";
2
+ import { basename, join } from "path";
3
+ import { createLogger } from "../core/logger.js";
4
+ import { setChannelDir } from "../stores/channel-store.js";
5
+ import { config } from "../core/config.js";
6
+ import { expandTilde } from "../core/platform.js";
7
+ import { t } from "../i18n/index.js";
8
+ const log = createLogger("init-handlers");
9
+ export function registerInitHandlers(app) {
10
+ log.info("Registering init handlers");
11
+ // 디렉토리 선택 버튼 핸들러
12
+ app.action(/^init_select_dir_\d+$/, async ({ ack, body, client, action }) => {
13
+ await ack();
14
+ try {
15
+ if (!action.value)
16
+ return;
17
+ let selected;
18
+ try {
19
+ selected = JSON.parse(action.value);
20
+ }
21
+ catch {
22
+ log.error("Failed to parse init_select_dir value", { value: action.value?.slice(0, 100) });
23
+ return;
24
+ }
25
+ const channelId = body.channel?.id;
26
+ const messageTs = body.message?.ts;
27
+ if (!channelId || !messageTs) {
28
+ log.error("Missing channel or message ts in init_select_dir action");
29
+ return;
30
+ }
31
+ const baseDir = config.baseDir;
32
+ const directory = join(baseDir, selected.dirName);
33
+ const projectName = basename(selected.dirName);
34
+ // 디렉토리 존재 확인
35
+ if (!existsSync(directory)) {
36
+ try {
37
+ await client.chat.postEphemeral({
38
+ channel: channelId,
39
+ user: body.user.id,
40
+ text: t("command.initInvalidDir", { directory }),
41
+ });
42
+ }
43
+ catch (e) {
44
+ log.error("Failed to send dir not found notice", e);
45
+ }
46
+ return;
47
+ }
48
+ // 채널 매핑 저장
49
+ setChannelDir(channelId, { directory, projectName });
50
+ // 원본 메시지 업데이트 (버튼 제거, 확인 메시지로)
51
+ try {
52
+ await client.chat.update({
53
+ channel: channelId,
54
+ ts: messageTs,
55
+ text: t("command.initSuccess", { dirName: projectName }),
56
+ blocks: [
57
+ {
58
+ type: "section",
59
+ text: { type: "mrkdwn", text: t("command.initSuccess", { dirName: projectName }) },
60
+ },
61
+ ],
62
+ });
63
+ }
64
+ catch (e) {
65
+ log.error("Failed to update init message", e);
66
+ }
67
+ log.info("Channel directory set via button", { channelId, projectName, directory });
68
+ }
69
+ catch (error) {
70
+ log.error("Error handling init_select_dir action", error);
71
+ }
72
+ });
73
+ // 직접 입력 버튼 핸들러
74
+ app.action("init_custom_input", async ({ ack, body, client }) => {
75
+ await ack();
76
+ try {
77
+ const channelId = body.channel?.id;
78
+ const messageTs = body.message?.ts;
79
+ if (!channelId || !messageTs) {
80
+ log.error("Missing channel or message ts in init_custom_input action");
81
+ return;
82
+ }
83
+ const metadata = {
84
+ channelId,
85
+ originalMessageTs: messageTs,
86
+ };
87
+ await client.views.open({
88
+ trigger_id: body.trigger_id,
89
+ view: {
90
+ type: "modal",
91
+ callback_id: "init_custom_dir_modal",
92
+ private_metadata: JSON.stringify(metadata),
93
+ title: { type: "plain_text", text: t("command.initModalTitle") },
94
+ submit: { type: "plain_text", text: t("modal.submit") },
95
+ close: { type: "plain_text", text: t("modal.cancel") },
96
+ blocks: [
97
+ {
98
+ type: "input",
99
+ block_id: "dir_block",
100
+ label: { type: "plain_text", text: t("command.initModalLabel") },
101
+ element: {
102
+ type: "plain_text_input",
103
+ action_id: "dir_input",
104
+ placeholder: { type: "plain_text", text: t("command.initModalPlaceholder") },
105
+ },
106
+ },
107
+ ],
108
+ },
109
+ });
110
+ log.debug("Init custom dir modal opened", { channelId });
111
+ }
112
+ catch (error) {
113
+ log.error("Error handling init_custom_input action", error);
114
+ }
115
+ });
116
+ // 직접 입력 모달 제출 핸들러
117
+ app.view("init_custom_dir_modal", async ({ ack, view, client }) => {
118
+ let metadata;
119
+ try {
120
+ try {
121
+ metadata = JSON.parse(view.private_metadata);
122
+ }
123
+ catch {
124
+ log.error("Failed to parse init modal metadata", { metadata: view.private_metadata?.slice(0, 100) });
125
+ await ack();
126
+ return;
127
+ }
128
+ if (!metadata) {
129
+ await ack();
130
+ return;
131
+ }
132
+ const rawPath = view.state.values.dir_block.dir_input.value?.trim() || "";
133
+ if (!rawPath) {
134
+ await ack({
135
+ response_action: "errors",
136
+ errors: { dir_block: t("command.initModalDirNotExist") },
137
+ });
138
+ return;
139
+ }
140
+ // 경로 결정: 절대 경로면 그대로, ~로 시작하면 확장, 상대 경로면 baseDir 기준
141
+ const baseDir = config.baseDir;
142
+ let directory;
143
+ if (rawPath.startsWith("/")) {
144
+ directory = rawPath;
145
+ }
146
+ else if (rawPath.startsWith("~")) {
147
+ directory = expandTilde(rawPath);
148
+ }
149
+ else {
150
+ directory = join(baseDir, rawPath);
151
+ }
152
+ if (!existsSync(directory)) {
153
+ await ack({
154
+ response_action: "errors",
155
+ errors: { dir_block: t("command.initModalDirNotExist") },
156
+ });
157
+ return;
158
+ }
159
+ await ack();
160
+ const projectName = basename(directory);
161
+ setChannelDir(metadata.channelId, { directory, projectName });
162
+ // 원본 메시지 업데이트
163
+ try {
164
+ await client.chat.update({
165
+ channel: metadata.channelId,
166
+ ts: metadata.originalMessageTs,
167
+ text: t("command.initSuccess", { dirName: projectName }),
168
+ blocks: [
169
+ {
170
+ type: "section",
171
+ text: { type: "mrkdwn", text: t("command.initSuccess", { dirName: projectName }) },
172
+ },
173
+ ],
174
+ });
175
+ }
176
+ catch (e) {
177
+ log.error("Failed to update init message after modal submit", e);
178
+ }
179
+ log.info("Channel directory set via custom input", {
180
+ channelId: metadata.channelId,
181
+ projectName,
182
+ directory,
183
+ });
184
+ }
185
+ catch (error) {
186
+ log.error("Error handling init_custom_dir_modal submission", error);
187
+ }
188
+ });
189
+ }
@@ -0,0 +1,2 @@
1
+ import type { App } from "@slack/bolt";
2
+ export declare function registerQuestionHandlers(app: App): void;