@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
package/dist/logger.js ADDED
@@ -0,0 +1,110 @@
1
+ import { createWriteStream } from "fs";
2
+ const LOG_LEVELS = {
3
+ debug: 0,
4
+ info: 1,
5
+ warn: 2,
6
+ error: 3,
7
+ };
8
+ function safeStringify(data) {
9
+ if (data instanceof Error) {
10
+ return JSON.stringify({ message: data.message, stack: data.stack });
11
+ }
12
+ try {
13
+ return JSON.stringify(data);
14
+ }
15
+ catch {
16
+ return "[unserializable]";
17
+ }
18
+ }
19
+ class Logger {
20
+ level;
21
+ context;
22
+ static logStream = null;
23
+ constructor(level = "info", context) {
24
+ this.level = level;
25
+ this.context = context;
26
+ }
27
+ static enableFileLogging(filePath) {
28
+ if (Logger.logStream) {
29
+ Logger.logStream.end();
30
+ }
31
+ Logger.logStream = createWriteStream(filePath, { flags: "a" });
32
+ Logger.logStream.on("error", (err) => {
33
+ console.error("Log file write error:", err.message);
34
+ });
35
+ }
36
+ shouldLog(level) {
37
+ return LOG_LEVELS[level] >= LOG_LEVELS[this.level];
38
+ }
39
+ formatMessage(level, message, data) {
40
+ const timestamp = new Date().toISOString();
41
+ const prefix = this.context ? `[${this.context}] ` : "";
42
+ const dataStr = data !== undefined ? ` ${safeStringify(data)}` : "";
43
+ return `${timestamp} ${level.toUpperCase().padEnd(5)} ${prefix}${message}${dataStr}`;
44
+ }
45
+ output(formatted) {
46
+ console.error(formatted);
47
+ Logger.logStream?.write(formatted + "\n");
48
+ }
49
+ debug(message, data) {
50
+ if (this.shouldLog("debug")) {
51
+ this.output(this.formatMessage("debug", message, data));
52
+ }
53
+ }
54
+ info(message, data) {
55
+ if (this.shouldLog("info")) {
56
+ this.output(this.formatMessage("info", message, data));
57
+ }
58
+ }
59
+ warn(message, data) {
60
+ if (this.shouldLog("warn")) {
61
+ this.output(this.formatMessage("warn", message, data));
62
+ }
63
+ }
64
+ error(message, error) {
65
+ if (this.shouldLog("error")) {
66
+ const errorData = error instanceof Error
67
+ ? { message: error.message, stack: error.stack }
68
+ : error;
69
+ this.output(this.formatMessage("error", message, errorData));
70
+ }
71
+ }
72
+ child(context) {
73
+ const childContext = this.context ? `${this.context}:${context}` : context;
74
+ return new Logger(this.level, childContext);
75
+ }
76
+ }
77
+ const VALID_LOG_LEVELS = new Set(Object.keys(LOG_LEVELS));
78
+ function parseLogLevel(envValue) {
79
+ if (envValue && VALID_LOG_LEVELS.has(envValue)) {
80
+ return envValue;
81
+ }
82
+ if (envValue) {
83
+ console.error(`Invalid LOG_LEVEL "${envValue}". Valid values: ${[...VALID_LOG_LEVELS].join(", ")}. Falling back to "info".`);
84
+ }
85
+ return "info";
86
+ }
87
+ export const logger = new Logger(parseLogLevel(process.env.LOG_LEVEL));
88
+ export function createLogger(context) {
89
+ return logger.child(context);
90
+ }
91
+ export function enableFileLogging(filePath) {
92
+ Logger.enableFileLogging(filePath);
93
+ }
94
+ /**
95
+ * Slack Bolt용 로거 어댑터.
96
+ * Bolt 내부 로그를 커스텀 로거로 라우팅하여
97
+ * 타임스탬프 + stderr 출력을 일관되게 유지한다.
98
+ */
99
+ export function createBoltLogger() {
100
+ let boltLog = logger.child("bolt");
101
+ return {
102
+ debug(...msg) { boltLog.debug(msg.map(String).join(" ")); },
103
+ info(...msg) { boltLog.info(msg.map(String).join(" ")); },
104
+ warn(...msg) { boltLog.warn(msg.map(String).join(" ")); },
105
+ error(...msg) { boltLog.error(msg.map(String).join(" ")); },
106
+ setLevel() { },
107
+ getLevel() { return "info"; },
108
+ setName(name) { boltLog = logger.child(name); },
109
+ };
110
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * 복수 선택 상태 관리
3
+ *
4
+ * multiSelect가 true인 질문에서 사용자가 선택한 옵션들을
5
+ * "선택 완료" 버튼을 누르기 전까지 임시 저장합니다.
6
+ *
7
+ * 키: `${projectName}:${messageId}`
8
+ * 값: { selected: 선택된 옵션 인덱스 Set, options: 전체 옵션 배열 }
9
+ */
10
+ import type { QuestionOption } from "./types/conversation.js";
11
+ interface MultiSelectState {
12
+ selected: Set<number>;
13
+ options: QuestionOption[];
14
+ questionText: string;
15
+ header?: string;
16
+ createdAt: number;
17
+ }
18
+ interface InitStateOptions {
19
+ projectName: string;
20
+ messageId: string;
21
+ options: QuestionOption[];
22
+ questionText: string;
23
+ header?: string;
24
+ }
25
+ /**
26
+ * 상태 초기화 (질문 생성 시 호출)
27
+ */
28
+ export declare function initState(opts: InitStateOptions): void;
29
+ /**
30
+ * 옵션 토글 (선택/해제)
31
+ * @returns 토글 후 선택 여부
32
+ */
33
+ export declare function toggleOption(projectName: string, messageId: string, optionIndex: number): boolean;
34
+ /**
35
+ * 선택된 옵션 인덱스 배열 조회
36
+ */
37
+ export declare function getSelectedOptions(projectName: string, messageId: string): number[];
38
+ /**
39
+ * 전체 상태 조회 (옵션 정보 포함)
40
+ */
41
+ export declare function getState(projectName: string, messageId: string): MultiSelectState | null;
42
+ /**
43
+ * 특정 옵션이 선택되었는지 확인
44
+ */
45
+ export declare function isOptionSelected(projectName: string, messageId: string, optionIndex: number): boolean;
46
+ /**
47
+ * 상태 삭제 (완료 후 정리용)
48
+ */
49
+ export declare function clearState(projectName: string, messageId: string): void;
50
+ /**
51
+ * 정리 타이머 중지 (주로 테스트용)
52
+ */
53
+ export declare function stopCleanupTimer(): void;
54
+ /**
55
+ * 테스트용: cleanup 수동 실행
56
+ */
57
+ export declare function runCleanup(): number;
58
+ export {};
@@ -0,0 +1,151 @@
1
+ /**
2
+ * 복수 선택 상태 관리
3
+ *
4
+ * multiSelect가 true인 질문에서 사용자가 선택한 옵션들을
5
+ * "선택 완료" 버튼을 누르기 전까지 임시 저장합니다.
6
+ *
7
+ * 키: `${projectName}:${messageId}`
8
+ * 값: { selected: 선택된 옵션 인덱스 Set, options: 전체 옵션 배열 }
9
+ */
10
+ import { createLogger } from "./logger.js";
11
+ const log = createLogger("multi-select-state");
12
+ /** 선택 상태 저장소 */
13
+ const stateMap = new Map();
14
+ // 1시간이 지난 상태는 자동 삭제 (multi-select는 오래 걸리지 않음)
15
+ const MAX_AGE_MS = 60 * 60 * 1000;
16
+ let cleanupInterval = null;
17
+ /**
18
+ * 키 생성 헬퍼
19
+ */
20
+ function makeKey(projectName, messageId) {
21
+ return `${projectName}:${messageId}`;
22
+ }
23
+ /**
24
+ * 상태 초기화 (질문 생성 시 호출)
25
+ */
26
+ export function initState(opts) {
27
+ const key = makeKey(opts.projectName, opts.messageId);
28
+ if (stateMap.has(key)) {
29
+ log.debug("State already exists, skipping init", { key });
30
+ return;
31
+ }
32
+ stateMap.set(key, {
33
+ selected: new Set(),
34
+ options: opts.options,
35
+ questionText: opts.questionText,
36
+ header: opts.header,
37
+ createdAt: Date.now(),
38
+ });
39
+ log.debug("State initialized", { key, optionCount: opts.options.length });
40
+ // 타이머가 없으면 시작
41
+ ensureCleanupTimer();
42
+ }
43
+ /**
44
+ * 옵션 토글 (선택/해제)
45
+ * @returns 토글 후 선택 여부
46
+ */
47
+ export function toggleOption(projectName, messageId, optionIndex) {
48
+ const key = makeKey(projectName, messageId);
49
+ const state = stateMap.get(key);
50
+ if (!state) {
51
+ log.warn("State not found for toggle", { key });
52
+ return false;
53
+ }
54
+ if (state.selected.has(optionIndex)) {
55
+ state.selected.delete(optionIndex);
56
+ log.debug("Option deselected", { projectName, messageId, optionIndex });
57
+ return false;
58
+ }
59
+ else {
60
+ state.selected.add(optionIndex);
61
+ log.debug("Option selected", { projectName, messageId, optionIndex });
62
+ return true;
63
+ }
64
+ }
65
+ /**
66
+ * 선택된 옵션 인덱스 배열 조회
67
+ */
68
+ export function getSelectedOptions(projectName, messageId) {
69
+ const key = makeKey(projectName, messageId);
70
+ const state = stateMap.get(key);
71
+ if (!state) {
72
+ return [];
73
+ }
74
+ return Array.from(state.selected).sort((a, b) => a - b);
75
+ }
76
+ /**
77
+ * 전체 상태 조회 (옵션 정보 포함)
78
+ */
79
+ export function getState(projectName, messageId) {
80
+ const key = makeKey(projectName, messageId);
81
+ return stateMap.get(key) ?? null;
82
+ }
83
+ /**
84
+ * 특정 옵션이 선택되었는지 확인
85
+ */
86
+ export function isOptionSelected(projectName, messageId, optionIndex) {
87
+ const key = makeKey(projectName, messageId);
88
+ const state = stateMap.get(key);
89
+ return state?.selected.has(optionIndex) ?? false;
90
+ }
91
+ /**
92
+ * 상태 삭제 (완료 후 정리용)
93
+ */
94
+ export function clearState(projectName, messageId) {
95
+ const key = makeKey(projectName, messageId);
96
+ stateMap.delete(key);
97
+ log.debug("State cleared", { projectName, messageId });
98
+ }
99
+ /**
100
+ * 오래된 상태 정리
101
+ */
102
+ function cleanup() {
103
+ const now = Date.now();
104
+ let cleanedCount = 0;
105
+ for (const [key, state] of stateMap.entries()) {
106
+ const ageMs = now - state.createdAt;
107
+ if (ageMs > MAX_AGE_MS) {
108
+ stateMap.delete(key);
109
+ cleanedCount++;
110
+ log.info("Multi-select state expired", {
111
+ key,
112
+ ageMinutes: Math.floor(ageMs / (60 * 1000)),
113
+ });
114
+ }
115
+ }
116
+ if (cleanedCount > 0) {
117
+ log.info("Multi-select cleanup completed", { cleanedCount, remaining: stateMap.size });
118
+ }
119
+ return cleanedCount;
120
+ }
121
+ /**
122
+ * 정리 타이머 시작
123
+ */
124
+ function ensureCleanupTimer() {
125
+ if (cleanupInterval)
126
+ return;
127
+ // 즉시 한 번 실행
128
+ cleanup();
129
+ // 매 시간마다 실행
130
+ cleanupInterval = setInterval(() => {
131
+ cleanup();
132
+ }, 60 * 60 * 1000);
133
+ cleanupInterval.unref();
134
+ log.debug("Multi-select cleanup timer started (runs every hour)");
135
+ }
136
+ /**
137
+ * 정리 타이머 중지 (주로 테스트용)
138
+ */
139
+ export function stopCleanupTimer() {
140
+ if (cleanupInterval) {
141
+ clearInterval(cleanupInterval);
142
+ cleanupInterval = null;
143
+ log.debug("Multi-select cleanup timer stopped");
144
+ }
145
+ }
146
+ /**
147
+ * 테스트용: cleanup 수동 실행
148
+ */
149
+ export function runCleanup() {
150
+ return cleanup();
151
+ }
@@ -0,0 +1,11 @@
1
+ export declare const ENV_FILE: string;
2
+ export declare const DATA_DIR: string;
3
+ export declare const SESSIONS_FILE: string;
4
+ export declare const WORKSPACES_FILE: string;
5
+ export declare const CHANNELS_FILE: string;
6
+ export declare const LOG_DIR: string;
7
+ export declare const LOG_OUT: string;
8
+ export declare const LOG_ERR: string;
9
+ export declare const PID_FILE: string;
10
+ /** DATA_DIR, LOG_DIR 가 없으면 생성. 서버/데몬 기동 시에만 호출할 것 */
11
+ export declare function ensureDirs(): void;
package/dist/paths.js ADDED
@@ -0,0 +1,18 @@
1
+ import { mkdirSync } from "fs";
2
+ import { join } from "path";
3
+ // 프로젝트 루트: 프로세스 실행 디렉토리 기준
4
+ const PROJECT_ROOT = process.cwd();
5
+ export const ENV_FILE = join(PROJECT_ROOT, ".env");
6
+ export const DATA_DIR = join(PROJECT_ROOT, "data");
7
+ export const SESSIONS_FILE = join(DATA_DIR, "sessions.json");
8
+ export const WORKSPACES_FILE = join(DATA_DIR, "workspaces.json");
9
+ export const CHANNELS_FILE = join(DATA_DIR, "channels.json");
10
+ export const LOG_DIR = join(PROJECT_ROOT, "logs");
11
+ export const LOG_OUT = join(LOG_DIR, "twindevbot.out.log");
12
+ export const LOG_ERR = join(LOG_DIR, "twindevbot.err.log");
13
+ export const PID_FILE = join(DATA_DIR, "twindevbot.pid");
14
+ /** DATA_DIR, LOG_DIR 가 없으면 생성. 서버/데몬 기동 시에만 호출할 것 */
15
+ export function ensureDirs() {
16
+ mkdirSync(DATA_DIR, { recursive: true });
17
+ mkdirSync(LOG_DIR, { recursive: true });
18
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 복수 질문 배치 관리
3
+ *
4
+ * Claude의 AskUserQuestion이 여러 질문을 동시에 보낼 때,
5
+ * Slack에서는 한 번에 하나씩 표시하고 모든 답변을 수집한 후
6
+ * 조합하여 Claude에 전달합니다.
7
+ *
8
+ * 키: threadTs (스레드당 하나의 배치)
9
+ */
10
+ import type { Question } from "./types/conversation.js";
11
+ export interface PendingQuestionBatch {
12
+ questions: Question[];
13
+ answers: string[];
14
+ currentIndex: number;
15
+ projectName: string;
16
+ channelId: string;
17
+ createdAt: number;
18
+ }
19
+ /**
20
+ * 배치 초기화 (questions.length > 1일 때 호출)
21
+ */
22
+ export declare function initPendingBatch(threadTs: string, questions: Question[], projectName: string, channelId: string): void;
23
+ /**
24
+ * 대기 중인 배치 존재 여부
25
+ */
26
+ export declare function hasPendingBatch(threadTs: string): boolean;
27
+ /**
28
+ * 답변 기록 후 다음 질문 반환.
29
+ * done=true이면 모든 질문에 답변 완료.
30
+ */
31
+ export declare function recordAnswerAndAdvance(threadTs: string, answer: string): {
32
+ done: boolean;
33
+ nextQuestion?: Question;
34
+ batch: PendingQuestionBatch;
35
+ } | null;
36
+ /**
37
+ * 모든 답변을 조합하여 Claude resume용 문자열 생성.
38
+ * 형식: "[header]: answer" (줄바꿈 구분)
39
+ * header가 없으면 질문 텍스트 앞 50자 사용.
40
+ */
41
+ export declare function buildCombinedAnswer(threadTs: string): string | null;
42
+ /**
43
+ * 배치 정리
44
+ */
45
+ export declare function clearPendingBatch(threadTs: string): void;
46
+ /**
47
+ * 정리 타이머 중지 (주로 테스트용)
48
+ */
49
+ export declare function stopCleanupTimer(): void;
50
+ /**
51
+ * 테스트용: cleanup 수동 실행
52
+ */
53
+ export declare function runCleanup(): number;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * 복수 질문 배치 관리
3
+ *
4
+ * Claude의 AskUserQuestion이 여러 질문을 동시에 보낼 때,
5
+ * Slack에서는 한 번에 하나씩 표시하고 모든 답변을 수집한 후
6
+ * 조합하여 Claude에 전달합니다.
7
+ *
8
+ * 키: threadTs (스레드당 하나의 배치)
9
+ */
10
+ import { createLogger } from "./logger.js";
11
+ const log = createLogger("pending-questions");
12
+ const batchMap = new Map();
13
+ // 1시간이 지난 배치는 자동 삭제 (정상적으로는 오래 걸리지 않음)
14
+ const MAX_AGE_MS = 60 * 60 * 1000;
15
+ let cleanupInterval = null;
16
+ /**
17
+ * 배치 초기화 (questions.length > 1일 때 호출)
18
+ */
19
+ export function initPendingBatch(threadTs, questions, projectName, channelId) {
20
+ batchMap.set(threadTs, {
21
+ questions,
22
+ answers: [],
23
+ currentIndex: 0,
24
+ projectName,
25
+ channelId,
26
+ createdAt: Date.now(),
27
+ });
28
+ log.info("Pending batch initialized", { threadTs, questionCount: questions.length });
29
+ // 타이머가 없으면 시작
30
+ ensureCleanupTimer();
31
+ }
32
+ /**
33
+ * 대기 중인 배치 존재 여부
34
+ */
35
+ export function hasPendingBatch(threadTs) {
36
+ return batchMap.has(threadTs);
37
+ }
38
+ /**
39
+ * 답변 기록 후 다음 질문 반환.
40
+ * done=true이면 모든 질문에 답변 완료.
41
+ */
42
+ export function recordAnswerAndAdvance(threadTs, answer) {
43
+ const batch = batchMap.get(threadTs);
44
+ if (!batch)
45
+ return null;
46
+ batch.answers.push(answer);
47
+ batch.currentIndex++;
48
+ if (batch.currentIndex >= batch.questions.length) {
49
+ return { done: true, batch };
50
+ }
51
+ return {
52
+ done: false,
53
+ nextQuestion: batch.questions[batch.currentIndex],
54
+ batch,
55
+ };
56
+ }
57
+ /**
58
+ * 모든 답변을 조합하여 Claude resume용 문자열 생성.
59
+ * 형식: "[header]: answer" (줄바꿈 구분)
60
+ * header가 없으면 질문 텍스트 앞 50자 사용.
61
+ */
62
+ export function buildCombinedAnswer(threadTs) {
63
+ const batch = batchMap.get(threadTs);
64
+ if (!batch)
65
+ return null;
66
+ if (batch.questions.length === 1) {
67
+ return batch.answers[0] ?? null;
68
+ }
69
+ return batch.questions
70
+ .map((q, i) => {
71
+ const label = q.header || q.question.slice(0, 50);
72
+ const answer = batch.answers[i] ?? "";
73
+ return `[${label}]: ${answer}`;
74
+ })
75
+ .join("\n");
76
+ }
77
+ /**
78
+ * 배치 정리
79
+ */
80
+ export function clearPendingBatch(threadTs) {
81
+ batchMap.delete(threadTs);
82
+ log.debug("Pending batch cleared", { threadTs });
83
+ }
84
+ /**
85
+ * 오래된 배치 정리
86
+ */
87
+ function cleanup() {
88
+ const now = Date.now();
89
+ let cleanedCount = 0;
90
+ for (const [threadTs, batch] of batchMap.entries()) {
91
+ const ageMs = now - batch.createdAt;
92
+ if (ageMs > MAX_AGE_MS) {
93
+ batchMap.delete(threadTs);
94
+ cleanedCount++;
95
+ log.info("Pending batch expired", {
96
+ threadTs,
97
+ projectName: batch.projectName,
98
+ ageMinutes: Math.floor(ageMs / (60 * 1000)),
99
+ answeredCount: batch.answers.length,
100
+ totalCount: batch.questions.length,
101
+ });
102
+ }
103
+ }
104
+ if (cleanedCount > 0) {
105
+ log.info("Pending batch cleanup completed", { cleanedCount, remaining: batchMap.size });
106
+ }
107
+ return cleanedCount;
108
+ }
109
+ /**
110
+ * 정리 타이머 시작
111
+ */
112
+ function ensureCleanupTimer() {
113
+ if (cleanupInterval)
114
+ return;
115
+ // 즉시 한 번 실행
116
+ cleanup();
117
+ // 매 시간마다 실행
118
+ cleanupInterval = setInterval(() => {
119
+ cleanup();
120
+ }, 60 * 60 * 1000);
121
+ cleanupInterval.unref();
122
+ log.debug("Pending batch cleanup timer started (runs every hour)");
123
+ }
124
+ /**
125
+ * 정리 타이머 중지 (주로 테스트용)
126
+ */
127
+ export function stopCleanupTimer() {
128
+ if (cleanupInterval) {
129
+ clearInterval(cleanupInterval);
130
+ cleanupInterval = null;
131
+ log.debug("Pending batch cleanup timer stopped");
132
+ }
133
+ }
134
+ /**
135
+ * 테스트용: cleanup 수동 실행
136
+ */
137
+ export function runCleanup() {
138
+ return cleanup();
139
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Cross-platform home directory.
3
+ * Replaces all instances of: process.env.HOME || "/root"
4
+ */
5
+ export declare function getHomeDir(): string;
6
+ /**
7
+ * Cross-platform tilde expansion.
8
+ * Replaces ~/ (or ~\) with the user's home directory.
9
+ */
10
+ export declare function expandTilde(p: string): string;
11
+ /**
12
+ * Default base directory for projects.
13
+ */
14
+ export declare function getDefaultBaseDir(): string;
15
+ /**
16
+ * Whether the current platform supports daemon management.
17
+ */
18
+ export declare function isDaemonSupported(): boolean;
@@ -0,0 +1,33 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ /**
4
+ * Cross-platform home directory.
5
+ * Replaces all instances of: process.env.HOME || "/root"
6
+ */
7
+ export function getHomeDir() {
8
+ return homedir();
9
+ }
10
+ /**
11
+ * Cross-platform tilde expansion.
12
+ * Replaces ~/ (or ~\) with the user's home directory.
13
+ */
14
+ export function expandTilde(p) {
15
+ if (p === "~")
16
+ return getHomeDir();
17
+ if (p.startsWith("~/") || p.startsWith("~\\")) {
18
+ return join(getHomeDir(), p.slice(2));
19
+ }
20
+ return p;
21
+ }
22
+ /**
23
+ * Default base directory for projects.
24
+ */
25
+ export function getDefaultBaseDir() {
26
+ return join(getHomeDir(), "Desktop");
27
+ }
28
+ /**
29
+ * Whether the current platform supports daemon management.
30
+ */
31
+ export function isDaemonSupported() {
32
+ return process.platform === "darwin" || process.platform === "win32";
33
+ }
@@ -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
+ }