@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,247 @@
1
+ import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
2
+ import { createLogger } from "./logger.js";
3
+ import { SESSIONS_FILE } from "./paths.js";
4
+ const log = createLogger("session-manager");
5
+ /**
6
+ * 세션 키 생성: projectName:threadTs
7
+ */
8
+ function makeSessionKey(projectName, threadTs) {
9
+ return `${projectName}:${threadTs}`;
10
+ }
11
+ class SessionManager {
12
+ sessions = new Map();
13
+ // sessionKey (projectName:threadTs) -> sessionId 매핑
14
+ sessionKeyToId = new Map();
15
+ // threadTs -> sessionId 매핑 (스레드로 세션 찾기)
16
+ threadToSession = new Map();
17
+ loaded = false;
18
+ cleanupInterval = null;
19
+ // 24시간 동안 활동이 없는 세션은 자동 삭제
20
+ static MAX_INACTIVE_MS = 24 * 60 * 60 * 1000;
21
+ /**
22
+ * 파일 로드를 지연 실행. 첫 접근 시 한 번만 호출됨.
23
+ */
24
+ ensureLoaded() {
25
+ if (this.loaded)
26
+ return;
27
+ this.loaded = true;
28
+ this.loadFromFile();
29
+ this.startCleanupTimer();
30
+ }
31
+ /**
32
+ * 파일에서 세션 로드
33
+ */
34
+ loadFromFile() {
35
+ try {
36
+ if (!existsSync(SESSIONS_FILE)) {
37
+ log.info("No sessions file found, starting fresh");
38
+ return;
39
+ }
40
+ const content = readFileSync(SESSIONS_FILE, "utf-8");
41
+ let data;
42
+ try {
43
+ data = JSON.parse(content);
44
+ }
45
+ catch (parseError) {
46
+ log.error("Sessions file is corrupted, starting fresh", { parseError });
47
+ return;
48
+ }
49
+ if (!data.sessions || !Array.isArray(data.sessions)) {
50
+ log.error("Sessions file has invalid structure, starting fresh");
51
+ return;
52
+ }
53
+ let loadedCount = 0;
54
+ for (const s of data.sessions) {
55
+ try {
56
+ const session = {
57
+ sessionId: s.sessionId,
58
+ projectName: s.projectName,
59
+ directory: s.directory,
60
+ slackChannelId: s.slackChannelId,
61
+ slackThreadTs: s.slackThreadTs,
62
+ startedAt: new Date(s.startedAt),
63
+ lastActivityAt: new Date(s.lastActivityAt),
64
+ autopilot: s.autopilot ?? false,
65
+ };
66
+ this.sessions.set(session.sessionId, session);
67
+ const key = makeSessionKey(session.projectName, session.slackThreadTs);
68
+ this.sessionKeyToId.set(key, session.sessionId);
69
+ this.threadToSession.set(session.slackThreadTs, session.sessionId);
70
+ loadedCount++;
71
+ }
72
+ catch (sessionError) {
73
+ log.warn("Skipping invalid session entry", { entry: s, error: sessionError });
74
+ }
75
+ }
76
+ log.info("Sessions loaded from file", { count: loadedCount, total: data.sessions.length });
77
+ }
78
+ catch (error) {
79
+ log.error("Failed to load sessions from file", { error });
80
+ }
81
+ }
82
+ /**
83
+ * 파일에 세션 저장
84
+ */
85
+ saveToFile() {
86
+ try {
87
+ const sessions = Array.from(this.sessions.values()).map((s) => ({
88
+ sessionId: s.sessionId,
89
+ projectName: s.projectName,
90
+ directory: s.directory,
91
+ slackChannelId: s.slackChannelId,
92
+ slackThreadTs: s.slackThreadTs,
93
+ startedAt: s.startedAt.toISOString(),
94
+ lastActivityAt: s.lastActivityAt.toISOString(),
95
+ autopilot: s.autopilot ?? undefined,
96
+ }));
97
+ const data = {
98
+ version: 1,
99
+ sessions,
100
+ };
101
+ const tmpFile = SESSIONS_FILE + ".tmp";
102
+ writeFileSync(tmpFile, JSON.stringify(data, null, 2));
103
+ renameSync(tmpFile, SESSIONS_FILE);
104
+ log.debug("Sessions saved to file", { count: sessions.length });
105
+ return true;
106
+ }
107
+ catch (error) {
108
+ log.error("Failed to save sessions to file", { error });
109
+ return false;
110
+ }
111
+ }
112
+ add(session) {
113
+ this.ensureLoaded();
114
+ this.sessions.set(session.sessionId, session);
115
+ const key = makeSessionKey(session.projectName, session.slackThreadTs);
116
+ this.sessionKeyToId.set(key, session.sessionId);
117
+ this.threadToSession.set(session.slackThreadTs, session.sessionId);
118
+ log.info("Session added", {
119
+ sessionId: session.sessionId,
120
+ projectName: session.projectName,
121
+ threadTs: session.slackThreadTs,
122
+ sessionKey: key,
123
+ });
124
+ this.saveToFile();
125
+ }
126
+ get(sessionId) {
127
+ this.ensureLoaded();
128
+ return this.sessions.get(sessionId);
129
+ }
130
+ /**
131
+ * projectName과 threadTs로 세션 조회
132
+ */
133
+ getBySessionKey(projectName, threadTs) {
134
+ this.ensureLoaded();
135
+ const key = makeSessionKey(projectName, threadTs);
136
+ const sessionId = this.sessionKeyToId.get(key);
137
+ if (sessionId) {
138
+ return this.sessions.get(sessionId);
139
+ }
140
+ return undefined;
141
+ }
142
+ getByThread(threadTs) {
143
+ this.ensureLoaded();
144
+ const sessionId = this.threadToSession.get(threadTs);
145
+ if (sessionId) {
146
+ return this.sessions.get(sessionId);
147
+ }
148
+ return undefined;
149
+ }
150
+ updateActivity(sessionId) {
151
+ this.ensureLoaded();
152
+ const session = this.sessions.get(sessionId);
153
+ if (session) {
154
+ session.lastActivityAt = new Date();
155
+ this.saveToFile();
156
+ }
157
+ }
158
+ /**
159
+ * threadTs로 세션의 autopilot 플래그 변경
160
+ */
161
+ setAutopilot(threadTs, value) {
162
+ this.ensureLoaded();
163
+ const sessionId = this.threadToSession.get(threadTs);
164
+ if (!sessionId)
165
+ return;
166
+ const session = this.sessions.get(sessionId);
167
+ if (session) {
168
+ session.autopilot = value;
169
+ this.saveToFile();
170
+ log.info("Session autopilot updated", { sessionId, threadTs, autopilot: value });
171
+ }
172
+ }
173
+ remove(sessionId) {
174
+ this.ensureLoaded();
175
+ const session = this.sessions.get(sessionId);
176
+ if (session) {
177
+ const key = makeSessionKey(session.projectName, session.slackThreadTs);
178
+ this.sessionKeyToId.delete(key);
179
+ this.threadToSession.delete(session.slackThreadTs);
180
+ this.sessions.delete(sessionId);
181
+ log.info("Session removed", { sessionId });
182
+ this.saveToFile();
183
+ }
184
+ }
185
+ getActiveCount() {
186
+ this.ensureLoaded();
187
+ return this.sessions.size;
188
+ }
189
+ /**
190
+ * 비활성 세션 정리 (lastActivityAt 기준)
191
+ */
192
+ cleanup() {
193
+ this.ensureLoaded();
194
+ const now = Date.now();
195
+ let cleanedCount = 0;
196
+ for (const [sessionId, session] of this.sessions.entries()) {
197
+ const inactiveMs = now - session.lastActivityAt.getTime();
198
+ if (inactiveMs > SessionManager.MAX_INACTIVE_MS) {
199
+ const key = makeSessionKey(session.projectName, session.slackThreadTs);
200
+ this.sessionKeyToId.delete(key);
201
+ this.threadToSession.delete(session.slackThreadTs);
202
+ this.sessions.delete(sessionId);
203
+ cleanedCount++;
204
+ log.info("Session expired due to inactivity", {
205
+ sessionId,
206
+ projectName: session.projectName,
207
+ inactiveDays: Math.floor(inactiveMs / (24 * 60 * 60 * 1000)),
208
+ });
209
+ }
210
+ }
211
+ if (cleanedCount > 0) {
212
+ this.saveToFile();
213
+ log.info("Session cleanup completed", { cleanedCount, remaining: this.sessions.size });
214
+ }
215
+ return cleanedCount;
216
+ }
217
+ /**
218
+ * 주기적 정리 타이머 시작 (매 시간마다)
219
+ */
220
+ startCleanupTimer() {
221
+ if (this.cleanupInterval)
222
+ return;
223
+ // 매 시간마다 실행 (첫 실행은 1시간 후)
224
+ this.cleanupInterval = setInterval(() => {
225
+ this.cleanup();
226
+ }, 60 * 60 * 1000);
227
+ this.cleanupInterval.unref();
228
+ log.debug("Session cleanup timer started (runs every hour)");
229
+ }
230
+ /**
231
+ * 정리 타이머 중지 (주로 테스트용)
232
+ */
233
+ stopCleanupTimer() {
234
+ if (this.cleanupInterval) {
235
+ clearInterval(this.cleanupInterval);
236
+ this.cleanupInterval = null;
237
+ log.debug("Session cleanup timer stopped");
238
+ }
239
+ }
240
+ /**
241
+ * 테스트용: cleanup 수동 실행
242
+ */
243
+ runCleanup() {
244
+ return this.cleanup();
245
+ }
246
+ }
247
+ export const sessionManager = new SessionManager();
@@ -0,0 +1,5 @@
1
+ /**
2
+ * .env 파일을 확인하고 비어있는 값이 있으면 인터랙티브 프롬프트로 입력받는다.
3
+ * 모든 값이 채워져 있으면 프롬프트 없이 통과한다.
4
+ */
5
+ export declare function ensureConfig(daemon: boolean): Promise<void>;
package/dist/setup.js ADDED
@@ -0,0 +1,132 @@
1
+ import { existsSync, readFileSync, writeFileSync, chmodSync } from "fs";
2
+ import dotenv from "dotenv";
3
+ import { input } from "@inquirer/prompts";
4
+ import { ENV_FILE } from "./core/paths.js";
5
+ import { initLocale, t } from "./i18n/index.js";
6
+ import { getDefaultBaseDir, expandTilde } from "./core/platform.js";
7
+ import { getDisplayWidth } from "./utils/display-width.js";
8
+ function loadCurrentEnv() {
9
+ if (!existsSync(ENV_FILE))
10
+ return {};
11
+ const parsed = dotenv.parse(readFileSync(ENV_FILE, "utf-8"));
12
+ return {
13
+ SLACK_APP_TOKEN: parsed.SLACK_APP_TOKEN || undefined,
14
+ SLACK_BOT_TOKEN: parsed.SLACK_BOT_TOKEN || undefined,
15
+ TWINDEVBOT_BASE_DIR: parsed.TWINDEVBOT_BASE_DIR || undefined,
16
+ LOG_LEVEL: parsed.LOG_LEVEL || undefined,
17
+ };
18
+ }
19
+ function writeEnvFile(values) {
20
+ const lines = [
21
+ `SLACK_BOT_TOKEN="${values.SLACK_BOT_TOKEN}"`,
22
+ `SLACK_APP_TOKEN="${values.SLACK_APP_TOKEN}"`,
23
+ `TWINDEVBOT_BASE_DIR="${values.TWINDEVBOT_BASE_DIR}"`,
24
+ `LOG_LEVEL="${values.LOG_LEVEL}"`,
25
+ ];
26
+ writeFileSync(ENV_FILE, lines.join("\n") + "\n");
27
+ // Unix에서 .env 파일 권한을 소유자만 읽기/쓰기로 설정 (토큰 보호)
28
+ if (process.platform !== "win32") {
29
+ try {
30
+ chmodSync(ENV_FILE, 0o600);
31
+ }
32
+ catch { /* ignore */ }
33
+ }
34
+ }
35
+ const DEFAULT_BASE_DIR = getDefaultBaseDir();
36
+ function printBanner() {
37
+ const title = t("cli.setup.banner");
38
+ const titleWidth = getDisplayWidth(title);
39
+ const innerWidth = 34;
40
+ const padLeft = Math.floor((innerWidth - titleWidth) / 2);
41
+ const padRight = innerWidth - titleWidth - padLeft;
42
+ console.log("");
43
+ console.log(` ┌${"─".repeat(innerWidth)}┐`);
44
+ console.log(` │${" ".repeat(padLeft)}${title}${" ".repeat(padRight)}│`);
45
+ console.log(` └${"─".repeat(innerWidth)}┘`);
46
+ console.log("");
47
+ }
48
+ function printStartMessage(daemon) {
49
+ console.log("");
50
+ if (daemon) {
51
+ console.log(` ✅ ${t("cli.setup.startDaemonMessage")}`);
52
+ }
53
+ else {
54
+ console.log(` ✅ ${t("cli.setup.startMessage")}`);
55
+ }
56
+ console.log("");
57
+ }
58
+ function applyToProcessEnv(values) {
59
+ process.env.SLACK_APP_TOKEN = values.SLACK_APP_TOKEN;
60
+ process.env.SLACK_BOT_TOKEN = values.SLACK_BOT_TOKEN;
61
+ process.env.TWINDEVBOT_BASE_DIR = values.TWINDEVBOT_BASE_DIR;
62
+ process.env.LOG_LEVEL = values.LOG_LEVEL;
63
+ }
64
+ /**
65
+ * .env 파일을 확인하고 비어있는 값이 있으면 인터랙티브 프롬프트로 입력받는다.
66
+ * 모든 값이 채워져 있으면 프롬프트 없이 통과한다.
67
+ */
68
+ export async function ensureConfig(daemon) {
69
+ const current = loadCurrentEnv();
70
+ const needAppToken = !current.SLACK_APP_TOKEN;
71
+ const needBotToken = !current.SLACK_BOT_TOKEN;
72
+ const needBaseDir = !current.TWINDEVBOT_BASE_DIR;
73
+ const needsSetup = needAppToken || needBotToken || needBaseDir;
74
+ if (!needsSetup) {
75
+ const values = {
76
+ SLACK_APP_TOKEN: current.SLACK_APP_TOKEN,
77
+ SLACK_BOT_TOKEN: current.SLACK_BOT_TOKEN,
78
+ TWINDEVBOT_BASE_DIR: current.TWINDEVBOT_BASE_DIR,
79
+ LOG_LEVEL: current.LOG_LEVEL ?? "info",
80
+ };
81
+ if (!current.LOG_LEVEL) {
82
+ writeEnvFile(values);
83
+ }
84
+ applyToProcessEnv(values);
85
+ return;
86
+ }
87
+ // 비대화형 환경(launchd 데몬 등)에서는 프롬프트를 실행할 수 없으므로 즉시 종료
88
+ if (!process.stdin.isTTY) {
89
+ const missing = [
90
+ needAppToken && "SLACK_APP_TOKEN",
91
+ needBotToken && "SLACK_BOT_TOKEN",
92
+ needBaseDir && "TWINDEVBOT_BASE_DIR",
93
+ ].filter(Boolean);
94
+ console.error(`[twindevbot] Missing required config: ${missing.join(", ")}. ` +
95
+ `Run "twindevbot start" interactively to set up, or edit ${ENV_FILE} manually.`);
96
+ process.exit(1);
97
+ }
98
+ printBanner();
99
+ const maskToken = (v, { isFinal }) => isFinal && v.length > 10 ? v.slice(0, 10) + "..." : v;
100
+ const appToken = needAppToken
101
+ ? (await input({
102
+ message: t("cli.setup.promptAppToken") + "\n ",
103
+ transformer: maskToken,
104
+ validate: (v) => (v.trim() ? true : t("cli.setup.required")),
105
+ })).trim()
106
+ : current.SLACK_APP_TOKEN;
107
+ const botToken = needBotToken
108
+ ? (await input({
109
+ message: t("cli.setup.promptBotToken") + "\n ",
110
+ transformer: maskToken,
111
+ validate: (v) => (v.trim() ? true : t("cli.setup.required")),
112
+ })).trim()
113
+ : current.SLACK_BOT_TOKEN;
114
+ const baseDir = needBaseDir
115
+ ? expandTilde((await input({
116
+ message: t("cli.setup.promptBaseDir") + "\n ",
117
+ default: DEFAULT_BASE_DIR,
118
+ })).trim())
119
+ : current.TWINDEVBOT_BASE_DIR;
120
+ const values = {
121
+ SLACK_APP_TOKEN: appToken,
122
+ SLACK_BOT_TOKEN: botToken,
123
+ TWINDEVBOT_BASE_DIR: baseDir,
124
+ LOG_LEVEL: current.LOG_LEVEL ?? "info",
125
+ };
126
+ writeEnvFile(values);
127
+ applyToProcessEnv(values);
128
+ initLocale();
129
+ console.log("");
130
+ console.log(` ✔ ${t("cli.setup.saved", { path: ENV_FILE })}`);
131
+ printStartMessage(daemon);
132
+ }
@@ -0,0 +1,47 @@
1
+ import type { WebClient } from "@slack/web-api";
2
+ export interface ProgressTrackerOptions {
3
+ client: WebClient;
4
+ channelId: string;
5
+ threadTs: string;
6
+ userMessageTs?: string;
7
+ }
8
+ export declare class ProgressTracker {
9
+ private client;
10
+ private channelId;
11
+ private threadTs;
12
+ private userMessageTs?;
13
+ private statusMessageTs?;
14
+ private startTime;
15
+ private currentReaction?;
16
+ private static readonly THROTTLE_MS;
17
+ private lastUpdateTime;
18
+ private pendingUpdate;
19
+ private pendingUpdateTimer;
20
+ private disposed;
21
+ constructor(options: ProgressTrackerOptions);
22
+ /** 사용자 메시지에 👀 리액션 추가 (접수 확인) */
23
+ markReceived(): Promise<void>;
24
+ /** 작업 시작 — 👀→⚙️ 교체 + 상태 메시지 게시 */
25
+ markWorking(): Promise<void>;
26
+ /** 도구 사용 — 상태 메시지 갱신 (리액션 변경 없음, 5초 쓰로틀) */
27
+ updateToolUse(toolName: string): Promise<void>;
28
+ /** 작업 완료 — ⚙️→✅ 교체 + 상태 메시지 갱신 */
29
+ markCompleted(): Promise<void>;
30
+ /** 오류 — ⚙️→❌ 교체 + 상태 메시지 갱신 */
31
+ markError(errorMessage: string): Promise<void>;
32
+ /** 계획 승인 — ⚙️→👍 교체 + 상태 메시지 갱신 (구현 시작 전 전환 상태) */
33
+ markPlanApproved(): Promise<void>;
34
+ /** Autopilot 자동 응답 후 계속 진행 — 리액션 변경 없이 상태 메시지만 갱신 */
35
+ markAutopilotContinue(): Promise<void>;
36
+ /** 질문 전송 — ⚙️→✋ 교체 + 상태 메시지 갱신 */
37
+ markAskUser(): Promise<void>;
38
+ /** 타이머 정리 — 인스턴스를 더 이상 사용하지 않을 때 호출 */
39
+ dispose(): void;
40
+ private setReaction;
41
+ private swapReaction;
42
+ private postStatusMessage;
43
+ private updateStatusMessage;
44
+ private flushPendingUpdate;
45
+ private clearPendingUpdate;
46
+ private getElapsedText;
47
+ }
@@ -0,0 +1,218 @@
1
+ import { createLogger } from "../core/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 {};