@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,218 @@
1
+ import { createLogger } from "./logger.js";
2
+ import { addReaction, removeReaction, postThreadMessage, } from "./utils/slack-message.js";
3
+ import { withRetry } from "./utils/slack-rate-limit.js";
4
+ import { t } from "./i18n/index.js";
5
+ const log = createLogger("progress-tracker");
6
+ export class ProgressTracker {
7
+ client;
8
+ channelId;
9
+ threadTs;
10
+ userMessageTs;
11
+ statusMessageTs;
12
+ startTime;
13
+ currentReaction;
14
+ // updateToolUse 쓰로틀링
15
+ static THROTTLE_MS = 5000;
16
+ lastUpdateTime = 0;
17
+ pendingUpdate = null;
18
+ pendingUpdateTimer = null;
19
+ disposed = false;
20
+ constructor(options) {
21
+ this.client = options.client;
22
+ this.channelId = options.channelId;
23
+ this.threadTs = options.threadTs;
24
+ this.userMessageTs = options.userMessageTs;
25
+ this.startTime = Date.now();
26
+ }
27
+ /** 사용자 메시지에 👀 리액션 추가 (접수 확인) */
28
+ async markReceived() {
29
+ try {
30
+ await this.setReaction("eyes");
31
+ }
32
+ catch (error) {
33
+ log.error("Failed to mark received", error);
34
+ }
35
+ }
36
+ /** 작업 시작 — 👀→⚙️ 교체 + 상태 메시지 게시 */
37
+ async markWorking() {
38
+ try {
39
+ await this.swapReaction("eyes", "gear");
40
+ await this.postStatusMessage(t("progress.working"));
41
+ }
42
+ catch (error) {
43
+ log.error("Failed to mark working", error);
44
+ }
45
+ }
46
+ /** 도구 사용 — 상태 메시지 갱신 (리액션 변경 없음, 5초 쓰로틀) */
47
+ async updateToolUse(toolName) {
48
+ try {
49
+ const key = `progress.tool.${toolName}`;
50
+ const description = t(key) !== key ? t(key) : t("progress.tool.default");
51
+ const elapsed = this.getElapsedText();
52
+ const text = `:gear: ${description} (${elapsed})`;
53
+ const now = Date.now();
54
+ const timeSinceLastUpdate = now - this.lastUpdateTime;
55
+ if (timeSinceLastUpdate >= ProgressTracker.THROTTLE_MS) {
56
+ this.lastUpdateTime = now;
57
+ this.clearPendingUpdate();
58
+ await this.updateStatusMessage(text);
59
+ }
60
+ else {
61
+ // 쓰로틀: 마지막 텍스트로 덮어쓰고 타이머 예약
62
+ this.pendingUpdate = text;
63
+ if (!this.pendingUpdateTimer) {
64
+ const delay = ProgressTracker.THROTTLE_MS - timeSinceLastUpdate;
65
+ this.pendingUpdateTimer = setTimeout(() => {
66
+ if (this.disposed)
67
+ return;
68
+ this.flushPendingUpdate().catch((err) => log.error("Failed to flush pending update", err));
69
+ }, delay);
70
+ }
71
+ }
72
+ }
73
+ catch (error) {
74
+ log.error("Failed to update tool use", error);
75
+ }
76
+ }
77
+ /** 작업 완료 — ⚙️→✅ 교체 + 상태 메시지 갱신 */
78
+ async markCompleted() {
79
+ try {
80
+ await this.flushPendingUpdate();
81
+ await this.swapReaction("gear", "white_check_mark");
82
+ const elapsed = this.getElapsedText();
83
+ await this.updateStatusMessage(t("progress.completed", { elapsed }));
84
+ }
85
+ catch (error) {
86
+ log.error("Failed to mark completed", error);
87
+ }
88
+ }
89
+ /** 오류 — ⚙️→❌ 교체 + 상태 메시지 갱신 */
90
+ async markError(errorMessage) {
91
+ try {
92
+ await this.flushPendingUpdate();
93
+ await this.swapReaction(this.currentReaction ?? "gear", "x");
94
+ await this.updateStatusMessage(t("progress.error", { error: errorMessage }));
95
+ }
96
+ catch (error) {
97
+ log.error("Failed to mark error", error);
98
+ }
99
+ }
100
+ /** 계획 승인 — ⚙️→👍 교체 + 상태 메시지 갱신 (구현 시작 전 전환 상태) */
101
+ async markPlanApproved() {
102
+ try {
103
+ await this.flushPendingUpdate();
104
+ await this.swapReaction("gear", "thumbsup");
105
+ await this.updateStatusMessage(t("progress.planApproved"));
106
+ }
107
+ catch (error) {
108
+ log.error("Failed to mark plan approved", error);
109
+ }
110
+ }
111
+ /** Autopilot 자동 응답 후 계속 진행 — 리액션 변경 없이 상태 메시지만 갱신 */
112
+ async markAutopilotContinue() {
113
+ try {
114
+ await this.flushPendingUpdate();
115
+ const elapsed = this.getElapsedText();
116
+ await this.updateStatusMessage(t("progress.autopilotContinue", { elapsed }));
117
+ }
118
+ catch (error) {
119
+ log.error("Failed to mark autopilot continue", error);
120
+ }
121
+ }
122
+ /** 질문 전송 — ⚙️→✋ 교체 + 상태 메시지 갱신 */
123
+ async markAskUser() {
124
+ try {
125
+ await this.flushPendingUpdate();
126
+ await this.swapReaction("gear", "raised_hand");
127
+ await this.updateStatusMessage(t("progress.askUser"));
128
+ }
129
+ catch (error) {
130
+ log.error("Failed to mark ask user", error);
131
+ }
132
+ }
133
+ /** 타이머 정리 — 인스턴스를 더 이상 사용하지 않을 때 호출 */
134
+ dispose() {
135
+ this.clearPendingUpdate();
136
+ this.disposed = true;
137
+ }
138
+ // ─────────────────────────────────────────────────────────────────────
139
+ // private helpers
140
+ // ─────────────────────────────────────────────────────────────────────
141
+ async setReaction(emoji) {
142
+ if (!this.userMessageTs)
143
+ return;
144
+ await addReaction(this.client, this.channelId, this.userMessageTs, emoji);
145
+ this.currentReaction = emoji;
146
+ }
147
+ async swapReaction(from, to) {
148
+ if (!this.userMessageTs)
149
+ return;
150
+ // 현재 리액션이 from과 일치하면 제거
151
+ if (this.currentReaction === from) {
152
+ await removeReaction(this.client, this.channelId, this.userMessageTs, from);
153
+ }
154
+ try {
155
+ await addReaction(this.client, this.channelId, this.userMessageTs, to);
156
+ this.currentReaction = to;
157
+ }
158
+ catch (error) {
159
+ log.error("Failed to add reaction during swap", { from, to, error });
160
+ this.currentReaction = undefined;
161
+ }
162
+ }
163
+ async postStatusMessage(text) {
164
+ const result = await postThreadMessage(this.client, this.channelId, text, this.threadTs);
165
+ if (result.success && result.ts) {
166
+ this.statusMessageTs = result.ts;
167
+ }
168
+ }
169
+ async updateStatusMessage(text) {
170
+ if (!this.statusMessageTs) {
171
+ // 상태 메시지가 아직 없으면 새로 게시
172
+ await this.postStatusMessage(text);
173
+ return;
174
+ }
175
+ try {
176
+ await withRetry(() => this.client.chat.update({
177
+ channel: this.channelId,
178
+ ts: this.statusMessageTs,
179
+ text,
180
+ }));
181
+ }
182
+ catch (error) {
183
+ log.error("Failed to update status message", { error });
184
+ }
185
+ }
186
+ async flushPendingUpdate() {
187
+ if (this.pendingUpdateTimer) {
188
+ clearTimeout(this.pendingUpdateTimer);
189
+ this.pendingUpdateTimer = null;
190
+ }
191
+ if (this.pendingUpdate) {
192
+ const text = this.pendingUpdate;
193
+ this.pendingUpdate = null;
194
+ this.lastUpdateTime = Date.now();
195
+ await this.updateStatusMessage(text);
196
+ }
197
+ }
198
+ clearPendingUpdate() {
199
+ if (this.pendingUpdateTimer) {
200
+ clearTimeout(this.pendingUpdateTimer);
201
+ this.pendingUpdateTimer = null;
202
+ }
203
+ this.pendingUpdate = null;
204
+ }
205
+ getElapsedText() {
206
+ const elapsedMs = Date.now() - this.startTime;
207
+ const totalSeconds = Math.floor(elapsedMs / 1000);
208
+ if (totalSeconds < 1) {
209
+ return t("progress.lessThanOneSecond");
210
+ }
211
+ if (totalSeconds < 60) {
212
+ return t("progress.seconds", { n: totalSeconds });
213
+ }
214
+ const minutes = Math.floor(totalSeconds / 60);
215
+ const seconds = totalSeconds % 60;
216
+ return t("progress.minutesSeconds", { m: minutes, s: seconds });
217
+ }
218
+ }
@@ -0,0 +1,27 @@
1
+ import type { KnownBlock, ActionsBlock, SectionBlock, ContextBlock, DividerBlock, HeaderBlock } from "@slack/types";
2
+ import type { Question } from "./types/index.js";
3
+ type SlackBlock = KnownBlock | ActionsBlock | SectionBlock | ContextBlock | DividerBlock | HeaderBlock;
4
+ interface BuildQuestionBlocksOptions {
5
+ /** 렌더링할 질문 */
6
+ question: Question;
7
+ /** 프로젝트명 */
8
+ projectName: string;
9
+ /** 질문 메시지 ID */
10
+ messageId: string;
11
+ /** 선택된 답변 (완료 시) */
12
+ selectedAnswer?: string;
13
+ /** 완료 여부 */
14
+ isSubmitted?: boolean;
15
+ /** multiSelect 시 현재 선택된 옵션 인덱스 배열 */
16
+ selectedOptionIndexes?: number[];
17
+ }
18
+ /**
19
+ * AskUserQuestion을 Slack Block으로 변환
20
+ * 단일 질문을 렌더링하고, 버튼 클릭 시 바로 Claude에 전달
21
+ *
22
+ * 주의: 버튼 value에는 짧은 필드만 포함.
23
+ * questionText, header, optionLabels 등은 action-payload-store에 별도 저장되어
24
+ * 핸들러에서 messageId로 조회함 (Slack action.value ~2,000바이트 제한 대응).
25
+ */
26
+ export declare function buildQuestionBlocks(options: BuildQuestionBlocksOptions): SlackBlock[];
27
+ export {};
@@ -0,0 +1,235 @@
1
+ import { t } from "./i18n/index.js";
2
+ const SLACK_BLOCK_LIMIT = 50;
3
+ const SLACK_BUTTON_TEXT_LIMIT = 75;
4
+ /** 버튼 텍스트가 Slack 제한(75자)을 초과하면 말줄임표(…)를 붙여 잘린 것을 표시 */
5
+ function truncateButtonText(text) {
6
+ if (text.length <= SLACK_BUTTON_TEXT_LIMIT)
7
+ return text;
8
+ return text.slice(0, SLACK_BUTTON_TEXT_LIMIT - 1) + "…";
9
+ }
10
+ /**
11
+ * AskUserQuestion을 Slack Block으로 변환
12
+ * 단일 질문을 렌더링하고, 버튼 클릭 시 바로 Claude에 전달
13
+ *
14
+ * 주의: 버튼 value에는 짧은 필드만 포함.
15
+ * questionText, header, optionLabels 등은 action-payload-store에 별도 저장되어
16
+ * 핸들러에서 messageId로 조회함 (Slack action.value ~2,000바이트 제한 대응).
17
+ */
18
+ export function buildQuestionBlocks(options) {
19
+ const { question, projectName, messageId, selectedAnswer, isSubmitted = false, selectedOptionIndexes = [], } = options;
20
+ const blocks = [];
21
+ // 헤더
22
+ blocks.push({
23
+ type: "header",
24
+ text: {
25
+ type: "plain_text",
26
+ text: isSubmitted ? t("question.headerCompleted") : t("question.header"),
27
+ emoji: true,
28
+ },
29
+ });
30
+ // 질문 제목
31
+ if (question.header) {
32
+ blocks.push({
33
+ type: "section",
34
+ text: { type: "mrkdwn", text: `*${question.header}*` },
35
+ });
36
+ }
37
+ // 질문 내용
38
+ blocks.push({
39
+ type: "section",
40
+ text: { type: "mrkdwn", text: question.question },
41
+ });
42
+ blocks.push({ type: "divider" });
43
+ if (isSubmitted && selectedAnswer) {
44
+ // 완료 상태 - 체크 이모지와 선택된 답변 표시
45
+ blocks.push({
46
+ type: "section",
47
+ text: { type: "mrkdwn", text: `✅ *${selectedAnswer}*` },
48
+ });
49
+ }
50
+ else if (question.multiSelect) {
51
+ // 복수 선택 모드 - 토글 버튼 + 선택 완료 버튼
52
+ const selectedSet = new Set(selectedOptionIndexes);
53
+ if (question.options && question.options.length > 0) {
54
+ // Slack 블록 50개 제한 대응: 옵션 렌더링 전략 결정
55
+ const trailingBlocks = 3; // submit + textInput + hint
56
+ const availableForOptions = SLACK_BLOCK_LIMIT - blocks.length - trailingBlocks;
57
+ const descriptionCount = question.options.filter(o => o.description).length;
58
+ const totalWithDesc = question.options.length + descriptionCount;
59
+ let skipDescriptions = false;
60
+ let optionsToRender = question.options;
61
+ if (totalWithDesc > availableForOptions) {
62
+ skipDescriptions = true;
63
+ if (question.options.length > availableForOptions) {
64
+ // 잘림 안내 context 블록 1개를 위해 -1
65
+ const maxOptions = Math.max(1, availableForOptions - 1);
66
+ optionsToRender = question.options.slice(0, maxOptions);
67
+ }
68
+ }
69
+ const isTruncated = optionsToRender.length < question.options.length;
70
+ optionsToRender.forEach((opt, i) => {
71
+ const isSelected = selectedSet.has(i);
72
+ const buttonText = isSelected ? `✅ ${opt.label}` : opt.label;
73
+ const toggleValue = {
74
+ questionIndex: 0,
75
+ optionIndex: i,
76
+ label: opt.label,
77
+ projectName,
78
+ messageId,
79
+ };
80
+ const button = {
81
+ type: "button",
82
+ text: { type: "plain_text", text: truncateButtonText(buttonText), emoji: true },
83
+ value: JSON.stringify(toggleValue),
84
+ action_id: `toggle_option_0_${i}`,
85
+ style: isSelected ? "primary" : undefined,
86
+ };
87
+ blocks.push({
88
+ type: "actions",
89
+ elements: [button],
90
+ });
91
+ // 설명이 있으면 버튼 아래에 표시 (블록 제한 시 생략)
92
+ if (!skipDescriptions && opt.description) {
93
+ blocks.push({
94
+ type: "context",
95
+ elements: [{ type: "plain_text", text: opt.description, emoji: false }],
96
+ });
97
+ }
98
+ });
99
+ if (isTruncated) {
100
+ blocks.push({
101
+ type: "context",
102
+ elements: [{ type: "mrkdwn", text: t("question.truncatedOptions", { shown: String(optionsToRender.length), total: String(question.options.length) }) }],
103
+ });
104
+ }
105
+ }
106
+ // 선택 완료 버튼
107
+ const submitValue = {
108
+ questionIndex: 0,
109
+ projectName,
110
+ messageId,
111
+ };
112
+ blocks.push({
113
+ type: "actions",
114
+ block_id: "submit_multi_select_0",
115
+ elements: [
116
+ {
117
+ type: "button",
118
+ text: { type: "plain_text", text: t("question.submitSelection"), emoji: true },
119
+ value: JSON.stringify(submitValue),
120
+ action_id: "submit_multi_select_0",
121
+ style: "primary",
122
+ },
123
+ ],
124
+ });
125
+ // 직접 입력 버튼 (멀티 선택에서도 직접 입력 가능)
126
+ const multiSelectTextInputValue = {
127
+ questionIndex: 0,
128
+ type: "text_input",
129
+ projectName,
130
+ messageId,
131
+ };
132
+ blocks.push({
133
+ type: "actions",
134
+ block_id: "text_input_0",
135
+ elements: [
136
+ {
137
+ type: "button",
138
+ text: { type: "plain_text", text: t("question.textInput"), emoji: true },
139
+ value: JSON.stringify(multiSelectTextInputValue),
140
+ action_id: "text_input_0",
141
+ },
142
+ ],
143
+ });
144
+ // 현재 선택된 항목 안내
145
+ if (selectedOptionIndexes.length > 0) {
146
+ const selectedLabels = selectedOptionIndexes
147
+ .map(i => question.options[i]?.label)
148
+ .filter(Boolean)
149
+ .join(", ");
150
+ blocks.push({
151
+ type: "context",
152
+ elements: [{ type: "mrkdwn", text: t("question.currentSelection", { labels: selectedLabels }) }],
153
+ });
154
+ }
155
+ else {
156
+ blocks.push({
157
+ type: "context",
158
+ elements: [{ type: "mrkdwn", text: t("question.selectHint") }],
159
+ });
160
+ }
161
+ }
162
+ else {
163
+ // 단일 선택 모드 - 기존 로직
164
+ if (question.options && question.options.length > 0) {
165
+ // Slack 블록 50개 제한 대응: 옵션 렌더링 전략 결정
166
+ const trailingBlocks = 1; // textInput
167
+ const availableForOptions = SLACK_BLOCK_LIMIT - blocks.length - trailingBlocks;
168
+ const descriptionCount = question.options.filter(o => o.description).length;
169
+ const totalWithDesc = question.options.length + descriptionCount;
170
+ let skipDescriptions = false;
171
+ let optionsToRender = question.options;
172
+ if (totalWithDesc > availableForOptions) {
173
+ skipDescriptions = true;
174
+ if (question.options.length > availableForOptions) {
175
+ const maxOptions = Math.max(1, availableForOptions - 1);
176
+ optionsToRender = question.options.slice(0, maxOptions);
177
+ }
178
+ }
179
+ const isTruncated = optionsToRender.length < question.options.length;
180
+ optionsToRender.forEach((opt, i) => {
181
+ const buttonValue = {
182
+ questionIndex: 0,
183
+ optionIndex: i,
184
+ label: opt.label,
185
+ isMultiSelect: false,
186
+ projectName,
187
+ messageId,
188
+ };
189
+ const button = {
190
+ type: "button",
191
+ text: { type: "plain_text", text: truncateButtonText(opt.label), emoji: true },
192
+ value: JSON.stringify(buttonValue),
193
+ action_id: `select_option_0_${i}`,
194
+ };
195
+ blocks.push({
196
+ type: "actions",
197
+ elements: [button],
198
+ });
199
+ // 설명이 있으면 버튼 아래에 표시 (블록 제한 시 생략)
200
+ if (!skipDescriptions && opt.description) {
201
+ blocks.push({
202
+ type: "context",
203
+ elements: [{ type: "plain_text", text: opt.description, emoji: false }],
204
+ });
205
+ }
206
+ });
207
+ if (isTruncated) {
208
+ blocks.push({
209
+ type: "context",
210
+ elements: [{ type: "mrkdwn", text: t("question.truncatedOptions", { shown: String(optionsToRender.length), total: String(question.options.length) }) }],
211
+ });
212
+ }
213
+ }
214
+ // 직접 입력 버튼
215
+ const textInputValue = {
216
+ questionIndex: 0,
217
+ type: "text_input",
218
+ projectName,
219
+ messageId,
220
+ };
221
+ blocks.push({
222
+ type: "actions",
223
+ block_id: "text_input_0",
224
+ elements: [
225
+ {
226
+ type: "button",
227
+ text: { type: "plain_text", text: t("question.textInput"), emoji: true },
228
+ value: JSON.stringify(textInputValue),
229
+ action_id: "text_input_0",
230
+ },
231
+ ],
232
+ });
233
+ }
234
+ return blocks;
235
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,83 @@
1
+ import bolt from "@slack/bolt";
2
+ const { App, LogLevel } = bolt;
3
+ import { writeFileSync, unlinkSync } from "fs";
4
+ import { config } from "./core/config.js";
5
+ import { initLocale } from "./i18n/index.js";
6
+ import { createLogger, createBoltLogger } from "./core/logger.js";
7
+ import { registerClaudeCommand, registerQuestionHandlers, registerInitHandlers } from "./handlers/index.js";
8
+ import { sessionManager } from "./claude/session-manager.js";
9
+ import { killAllRunners } from "./claude/active-runners.js";
10
+ import { PID_FILE } from "./core/paths.js";
11
+ // locale은 앞으로 env 기반 선택이 추가될 수 있어, config 접근 전에 미리 초기화한다.
12
+ // (현재는 en만 지원하므로 initLocale은 no-op)
13
+ initLocale();
14
+ const log = createLogger("server");
15
+ // 글로벌 에러 핸들러 (마지막 방어선)
16
+ process.on("uncaughtException", (error) => {
17
+ log.error("Uncaught exception", error);
18
+ killAllRunners();
19
+ try {
20
+ unlinkSync(PID_FILE);
21
+ }
22
+ catch {
23
+ // 파일이 없을 수 있음
24
+ }
25
+ process.exit(1);
26
+ });
27
+ process.on("unhandledRejection", (reason) => {
28
+ log.error("Unhandled rejection", reason instanceof Error ? reason : { reason });
29
+ });
30
+ const app = new App({
31
+ token: config.slack.botToken,
32
+ appToken: config.slack.appToken,
33
+ socketMode: true,
34
+ logLevel: LogLevel.INFO,
35
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
+ logger: createBoltLogger(),
37
+ });
38
+ // 핸들러 등록
39
+ registerClaudeCommand(app);
40
+ registerQuestionHandlers(app);
41
+ registerInitHandlers(app);
42
+ // Graceful shutdown: 자식 프로세스 정리 후 종료
43
+ let shuttingDown = false;
44
+ async function gracefulShutdown(signal) {
45
+ if (shuttingDown)
46
+ return;
47
+ shuttingDown = true;
48
+ log.info("Shutdown signal received, cleaning up", { signal });
49
+ const killed = killAllRunners();
50
+ log.info("Active runners terminated", { count: killed });
51
+ try {
52
+ await app.stop();
53
+ log.info("Slack app stopped");
54
+ }
55
+ catch (error) {
56
+ log.error("Error stopping Slack app", error);
57
+ }
58
+ // PID 파일 정리
59
+ try {
60
+ unlinkSync(PID_FILE);
61
+ }
62
+ catch {
63
+ // 파일이 없을 수 있음
64
+ }
65
+ // in-flight 비동기 작업(Slack 메시지 전송 등)이 완료될 시간 확보
66
+ await new Promise((resolve) => setTimeout(resolve, 2000));
67
+ process.exit(0);
68
+ }
69
+ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
70
+ process.on("SIGINT", () => gracefulShutdown("SIGINT"));
71
+ // 서버 시작
72
+ (async () => {
73
+ await app.start();
74
+ // PID 파일 작성 (daemon stop 시 프로세스 트리 종료에 사용)
75
+ // 서버 시작 성공 후 작성해야 실패 시 stale PID 파일이 남지 않음
76
+ writeFileSync(PID_FILE, String(process.pid));
77
+ log.info("Server started (Socket Mode)", {
78
+ activeSessions: sessionManager.getActiveCount(),
79
+ });
80
+ })().catch((error) => {
81
+ log.error("Failed to start server", error);
82
+ process.exit(1);
83
+ });
@@ -0,0 +1,62 @@
1
+ export interface ClaudeSession {
2
+ sessionId: string;
3
+ projectName: string;
4
+ directory: string;
5
+ slackChannelId: string;
6
+ slackThreadTs: string;
7
+ startedAt: Date;
8
+ lastActivityAt: Date;
9
+ autopilot: boolean;
10
+ }
11
+ declare class SessionManager {
12
+ private sessions;
13
+ private sessionKeyToId;
14
+ private threadToSession;
15
+ private loaded;
16
+ private cleanupInterval;
17
+ private static readonly MAX_INACTIVE_MS;
18
+ /**
19
+ * 파일 로드를 지연 실행. 첫 접근 시 한 번만 호출됨.
20
+ */
21
+ private ensureLoaded;
22
+ /**
23
+ * 파일에서 세션 로드
24
+ */
25
+ private loadFromFile;
26
+ /**
27
+ * 파일에 세션 저장
28
+ */
29
+ private saveToFile;
30
+ add(session: ClaudeSession): void;
31
+ get(sessionId: string): ClaudeSession | undefined;
32
+ /**
33
+ * projectName과 threadTs로 세션 조회
34
+ */
35
+ getBySessionKey(projectName: string, threadTs: string): ClaudeSession | undefined;
36
+ getByThread(threadTs: string): ClaudeSession | undefined;
37
+ updateActivity(sessionId: string): void;
38
+ /**
39
+ * threadTs로 세션의 autopilot 플래그 변경
40
+ */
41
+ setAutopilot(threadTs: string, value: boolean): void;
42
+ remove(sessionId: string): void;
43
+ getActiveCount(): number;
44
+ /**
45
+ * 비활성 세션 정리 (lastActivityAt 기준)
46
+ */
47
+ cleanup(): number;
48
+ /**
49
+ * 주기적 정리 타이머 시작 (매 시간마다)
50
+ */
51
+ private startCleanupTimer;
52
+ /**
53
+ * 정리 타이머 중지 (주로 테스트용)
54
+ */
55
+ stopCleanupTimer(): void;
56
+ /**
57
+ * 테스트용: cleanup 수동 실행
58
+ */
59
+ runCleanup(): number;
60
+ }
61
+ export declare const sessionManager: SessionManager;
62
+ export {};