@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.
- package/LICENSE +661 -0
- package/README.md +415 -0
- package/bin/twindevbot.js +22 -0
- package/dist/action-payload-store.d.ts +22 -0
- package/dist/action-payload-store.js +54 -0
- package/dist/active-runners.d.ts +44 -0
- package/dist/active-runners.js +114 -0
- package/dist/channel-store.d.ts +16 -0
- package/dist/channel-store.js +91 -0
- package/dist/claude/active-runners.d.ts +44 -0
- package/dist/claude/active-runners.js +114 -0
- package/dist/claude/claude-runner.d.ts +57 -0
- package/dist/claude/claude-runner.js +210 -0
- package/dist/claude/session-manager.d.ts +62 -0
- package/dist/claude/session-manager.js +247 -0
- package/dist/claude-runner.d.ts +57 -0
- package/dist/claude-runner.js +210 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +271 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +49 -0
- package/dist/conversation-store.d.ts +53 -0
- package/dist/conversation-store.js +173 -0
- package/dist/core/config.d.ts +9 -0
- package/dist/core/config.js +49 -0
- package/dist/core/logger.d.ts +34 -0
- package/dist/core/logger.js +110 -0
- package/dist/core/paths.d.ts +11 -0
- package/dist/core/paths.js +18 -0
- package/dist/core/platform.d.ts +18 -0
- package/dist/core/platform.js +33 -0
- package/dist/daemon/index.d.ts +3 -0
- package/dist/daemon/index.js +14 -0
- package/dist/daemon/macos.d.ts +8 -0
- package/dist/daemon/macos.js +150 -0
- package/dist/daemon/types.d.ts +9 -0
- package/dist/daemon/types.js +1 -0
- package/dist/daemon/windows.d.ts +8 -0
- package/dist/daemon/windows.js +137 -0
- package/dist/handlers/claude-command.d.ts +2 -0
- package/dist/handlers/claude-command.js +634 -0
- package/dist/handlers/claude-runner-setup.d.ts +16 -0
- package/dist/handlers/claude-runner-setup.js +445 -0
- package/dist/handlers/index.d.ts +3 -0
- package/dist/handlers/index.js +3 -0
- package/dist/handlers/init-handlers.d.ts +2 -0
- package/dist/handlers/init-handlers.js +189 -0
- package/dist/handlers/question-handlers.d.ts +2 -0
- package/dist/handlers/question-handlers.js +835 -0
- package/dist/i18n/en.d.ts +150 -0
- package/dist/i18n/en.js +163 -0
- package/dist/i18n/index.d.ts +20 -0
- package/dist/i18n/index.js +31 -0
- package/dist/i18n/ko.d.ts +1 -0
- package/dist/i18n/ko.js +141 -0
- package/dist/logger.d.ts +34 -0
- package/dist/logger.js +110 -0
- package/dist/multi-select-state.d.ts +58 -0
- package/dist/multi-select-state.js +151 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +18 -0
- package/dist/pending-questions.d.ts +53 -0
- package/dist/pending-questions.js +139 -0
- package/dist/platform.d.ts +18 -0
- package/dist/platform.js +33 -0
- package/dist/progress-tracker.d.ts +47 -0
- package/dist/progress-tracker.js +218 -0
- package/dist/question-blocks.d.ts +27 -0
- package/dist/question-blocks.js +235 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +83 -0
- package/dist/session-manager.d.ts +62 -0
- package/dist/session-manager.js +247 -0
- package/dist/setup.d.ts +5 -0
- package/dist/setup.js +132 -0
- package/dist/slack/progress-tracker.d.ts +47 -0
- package/dist/slack/progress-tracker.js +218 -0
- package/dist/slack/question-blocks.d.ts +27 -0
- package/dist/slack/question-blocks.js +235 -0
- package/dist/stores/action-payload-store.d.ts +22 -0
- package/dist/stores/action-payload-store.js +54 -0
- package/dist/stores/channel-store.d.ts +16 -0
- package/dist/stores/channel-store.js +91 -0
- package/dist/stores/multi-select-state.d.ts +58 -0
- package/dist/stores/multi-select-state.js +151 -0
- package/dist/stores/pending-questions.d.ts +53 -0
- package/dist/stores/pending-questions.js +139 -0
- package/dist/stores/workspace-store.d.ts +27 -0
- package/dist/stores/workspace-store.js +160 -0
- package/dist/templates.d.ts +23 -0
- package/dist/templates.js +292 -0
- package/dist/types/claude-stream.d.ts +116 -0
- package/dist/types/claude-stream.js +3 -0
- package/dist/types/conversation.d.ts +16 -0
- package/dist/types/conversation.js +4 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/types/slack.d.ts +51 -0
- package/dist/types/slack.js +1 -0
- package/dist/utils/display-width.d.ts +8 -0
- package/dist/utils/display-width.js +33 -0
- package/dist/utils/safe-async.d.ts +6 -0
- package/dist/utils/safe-async.js +14 -0
- package/dist/utils/slack-message.d.ts +73 -0
- package/dist/utils/slack-message.js +220 -0
- package/dist/utils/slack-rate-limit.d.ts +5 -0
- package/dist/utils/slack-rate-limit.js +49 -0
- package/dist/workspace-store.d.ts +27 -0
- package/dist/workspace-store.js +160 -0
- package/package.json +51 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
|
|
2
|
+
import { createLogger } from "../core/logger.js";
|
|
3
|
+
import { SESSIONS_FILE } from "../core/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,57 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
export interface ClaudeRunnerOptions {
|
|
3
|
+
directory: string;
|
|
4
|
+
prompt: string;
|
|
5
|
+
sessionId?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface AskUserQuestionInput {
|
|
8
|
+
questions: Array<{
|
|
9
|
+
question: string;
|
|
10
|
+
header?: string;
|
|
11
|
+
options: Array<{
|
|
12
|
+
label: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
}>;
|
|
15
|
+
multiSelect?: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export interface AskUserEvent {
|
|
19
|
+
input: AskUserQuestionInput;
|
|
20
|
+
}
|
|
21
|
+
export interface InitEvent {
|
|
22
|
+
sessionId: string;
|
|
23
|
+
model?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface TextEvent {
|
|
26
|
+
text: string;
|
|
27
|
+
}
|
|
28
|
+
export interface ToolUseEvent {
|
|
29
|
+
toolName: string;
|
|
30
|
+
input: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
export interface ResultEvent {
|
|
33
|
+
result?: string;
|
|
34
|
+
costUsd?: number;
|
|
35
|
+
}
|
|
36
|
+
export declare class ClaudeRunner extends EventEmitter {
|
|
37
|
+
private options;
|
|
38
|
+
private sessionId;
|
|
39
|
+
private process;
|
|
40
|
+
/** 이미 처리한 tool_use ID를 추적 */
|
|
41
|
+
private processedToolUseIds;
|
|
42
|
+
/** init 이벤트 발생 여부 */
|
|
43
|
+
private initEmitted;
|
|
44
|
+
/** result 이벤트 발생 여부 */
|
|
45
|
+
private resultEmitted;
|
|
46
|
+
/** stderr 출력 누적 버퍼 */
|
|
47
|
+
private stderrLines;
|
|
48
|
+
constructor(options: ClaudeRunnerOptions);
|
|
49
|
+
get currentSessionId(): string | null;
|
|
50
|
+
/** 누적된 stderr 출력을 반환 */
|
|
51
|
+
get stderrOutput(): string;
|
|
52
|
+
run(): void;
|
|
53
|
+
private handleLine;
|
|
54
|
+
private handleEvent;
|
|
55
|
+
kill(): void;
|
|
56
|
+
}
|
|
57
|
+
export declare function runClaude(options: ClaudeRunnerOptions): ClaudeRunner;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { spawn, execSync } from "child_process";
|
|
2
|
+
import { EventEmitter } from "events";
|
|
3
|
+
import { createLogger } from "./logger.js";
|
|
4
|
+
const log = createLogger("claude-runner");
|
|
5
|
+
export class ClaudeRunner extends EventEmitter {
|
|
6
|
+
options;
|
|
7
|
+
sessionId = null;
|
|
8
|
+
process = null;
|
|
9
|
+
// 중복 방지용 상태
|
|
10
|
+
/** 이미 처리한 tool_use ID를 추적 */
|
|
11
|
+
processedToolUseIds = new Set();
|
|
12
|
+
/** init 이벤트 발생 여부 */
|
|
13
|
+
initEmitted = false;
|
|
14
|
+
/** result 이벤트 발생 여부 */
|
|
15
|
+
resultEmitted = false;
|
|
16
|
+
/** stderr 출력 누적 버퍼 */
|
|
17
|
+
stderrLines = [];
|
|
18
|
+
constructor(options) {
|
|
19
|
+
super();
|
|
20
|
+
this.options = options;
|
|
21
|
+
this.sessionId = options.sessionId || null;
|
|
22
|
+
}
|
|
23
|
+
get currentSessionId() {
|
|
24
|
+
return this.sessionId;
|
|
25
|
+
}
|
|
26
|
+
/** 누적된 stderr 출력을 반환 */
|
|
27
|
+
get stderrOutput() {
|
|
28
|
+
return this.stderrLines.join("").trim();
|
|
29
|
+
}
|
|
30
|
+
run() {
|
|
31
|
+
const args = [
|
|
32
|
+
"-p", this.options.prompt,
|
|
33
|
+
"--output-format", "stream-json",
|
|
34
|
+
"--verbose",
|
|
35
|
+
"--dangerously-skip-permissions",
|
|
36
|
+
];
|
|
37
|
+
// 세션 ID가 있으면 --resume 추가
|
|
38
|
+
if (this.options.sessionId) {
|
|
39
|
+
args.unshift("--resume", this.options.sessionId);
|
|
40
|
+
}
|
|
41
|
+
log.info("Running Claude", {
|
|
42
|
+
directory: this.options.directory,
|
|
43
|
+
prompt: this.options.prompt,
|
|
44
|
+
sessionId: this.options.sessionId || "new",
|
|
45
|
+
});
|
|
46
|
+
this.process = spawn("claude", args, {
|
|
47
|
+
cwd: this.options.directory,
|
|
48
|
+
stdio: ["ignore", "pipe", "pipe"], // stdin은 ignore로 설정 (pipe로 하면 Claude가 입력 대기 상태가 됨)
|
|
49
|
+
shell: process.platform === "win32", // Windows에서 .cmd/.exe PATH 해석에 필요
|
|
50
|
+
});
|
|
51
|
+
let buffer = "";
|
|
52
|
+
this.process.stdout?.on("data", (data) => {
|
|
53
|
+
const chunk = data.toString();
|
|
54
|
+
buffer += chunk;
|
|
55
|
+
// 줄 단위로 파싱
|
|
56
|
+
const lines = buffer.split("\n");
|
|
57
|
+
buffer = lines.pop() || "";
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
const trimmed = line.trim();
|
|
60
|
+
if (trimmed) {
|
|
61
|
+
this.handleLine(trimmed);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
this.process.stderr?.on("data", (data) => {
|
|
66
|
+
const text = data.toString();
|
|
67
|
+
log.debug("Claude stderr", { text });
|
|
68
|
+
this.stderrLines.push(text);
|
|
69
|
+
});
|
|
70
|
+
this.process.on("error", (err) => {
|
|
71
|
+
log.error("Claude process error", { error: err.message });
|
|
72
|
+
this.emit("error", err);
|
|
73
|
+
});
|
|
74
|
+
this.process.on("exit", (code) => {
|
|
75
|
+
log.info("Claude process exited", { code });
|
|
76
|
+
// 남은 버퍼 처리
|
|
77
|
+
if (buffer.trim()) {
|
|
78
|
+
this.handleLine(buffer.trim());
|
|
79
|
+
}
|
|
80
|
+
this.emit("exit", code);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
handleLine(line) {
|
|
84
|
+
log.debug("Claude output", { line });
|
|
85
|
+
try {
|
|
86
|
+
const event = JSON.parse(line);
|
|
87
|
+
this.handleEvent(event);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Non-JSON output from Claude CLI (e.g., progress messages)
|
|
91
|
+
log.debug("Non-JSON line from Claude (ignored)", { line: line.slice(0, 200) });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
handleEvent(event) {
|
|
95
|
+
log.debug("Claude event", { type: event.type });
|
|
96
|
+
switch (event.type) {
|
|
97
|
+
case "system":
|
|
98
|
+
if ("subtype" in event && event.subtype === "init") {
|
|
99
|
+
// 중복 init 방지
|
|
100
|
+
if (this.initEmitted) {
|
|
101
|
+
log.debug("Skipping duplicate init event");
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
this.initEmitted = true;
|
|
105
|
+
const initEvent = event;
|
|
106
|
+
this.sessionId = initEvent.session_id;
|
|
107
|
+
this.emit("init", {
|
|
108
|
+
sessionId: initEvent.session_id,
|
|
109
|
+
model: initEvent.model,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
case "assistant":
|
|
114
|
+
const msg = event.message;
|
|
115
|
+
if (msg?.content) {
|
|
116
|
+
for (const block of msg.content) {
|
|
117
|
+
if (block.type === "text" && block.text) {
|
|
118
|
+
this.emit("text", { text: block.text });
|
|
119
|
+
}
|
|
120
|
+
else if (block.type === "tool_use" && block.name) {
|
|
121
|
+
// tool_use 블록 수신 시 전체 구조를 로그로 기록 (문제 진단용)
|
|
122
|
+
log.debug("tool_use block received", {
|
|
123
|
+
id: block.id,
|
|
124
|
+
name: block.name,
|
|
125
|
+
hasId: !!block.id,
|
|
126
|
+
blockKeys: Object.keys(block),
|
|
127
|
+
});
|
|
128
|
+
// 중복 tool_use 필터링 (id가 있는 경우)
|
|
129
|
+
const toolUseId = block.id;
|
|
130
|
+
if (toolUseId && this.processedToolUseIds.has(toolUseId)) {
|
|
131
|
+
log.info("Skipping duplicate tool_use", { toolUseId, toolName: block.name });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (toolUseId) {
|
|
135
|
+
this.processedToolUseIds.add(toolUseId);
|
|
136
|
+
}
|
|
137
|
+
const toolEvent = {
|
|
138
|
+
toolName: block.name,
|
|
139
|
+
input: block.input || {},
|
|
140
|
+
};
|
|
141
|
+
this.emit("toolUse", toolEvent);
|
|
142
|
+
// AskUserQuestion 처리 흐름:
|
|
143
|
+
// ─────────────────────────────────────────────────────────────
|
|
144
|
+
// 1. Claude CLI가 AskUserQuestion tool_use를 출력
|
|
145
|
+
// 2. 여기서 "askUser" 이벤트를 emit
|
|
146
|
+
// 3. claude-runner-setup.ts의 핸들러가 Slack 스레드로 질문 블록 전송
|
|
147
|
+
// 4. Slack 전송 완료 후 runner.kill()을 호출하여 프로세스를 종료
|
|
148
|
+
// - stdin이 "ignore"라서 Claude CLI가 응답을 받을 수 없음
|
|
149
|
+
// - 종료하지 않으면 Claude가 에러를 받고 같은 질문을 반복함
|
|
150
|
+
// 5. 사용자가 Slack에서 버튼 클릭 또는 직접 입력으로 응답
|
|
151
|
+
// 6. question-handlers.ts에서 --resume으로 새 프로세스를 시작
|
|
152
|
+
// ─────────────────────────────────────────────────────────────
|
|
153
|
+
if (block.name === "AskUserQuestion") {
|
|
154
|
+
this.emit("askUser", {
|
|
155
|
+
input: block.input,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// ExitPlanMode 처리:
|
|
159
|
+
// stdin이 "ignore"라 CLI가 사용자 승인을 받을 수 없어 실패함.
|
|
160
|
+
// askUser와 동일한 패턴으로 프로세스 kill → resume으로 처리.
|
|
161
|
+
if (block.name === "ExitPlanMode") {
|
|
162
|
+
this.emit("exitPlanMode", {});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
case "result":
|
|
169
|
+
// 중복 result 방지
|
|
170
|
+
if (this.resultEmitted) {
|
|
171
|
+
log.debug("Skipping duplicate result event");
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
this.resultEmitted = true;
|
|
175
|
+
const resultEvent = event;
|
|
176
|
+
this.emit("result", {
|
|
177
|
+
result: resultEvent.result,
|
|
178
|
+
costUsd: resultEvent.total_cost_usd,
|
|
179
|
+
});
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
kill() {
|
|
184
|
+
if (this.process) {
|
|
185
|
+
if (process.platform === "win32" && this.process.pid) {
|
|
186
|
+
// Windows에서 shell: true 사용 시 프로세스 트리 전체를 종료해야 함
|
|
187
|
+
try {
|
|
188
|
+
execSync(`taskkill /pid ${this.process.pid} /T /F`, { stdio: "ignore" });
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
this.process.kill();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
try {
|
|
196
|
+
this.process.kill();
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// Process already exited - ignore ESRCH
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
this.process = null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
export function runClaude(options) {
|
|
207
|
+
const runner = new ClaudeRunner(options);
|
|
208
|
+
runner.run();
|
|
209
|
+
return runner;
|
|
210
|
+
}
|
package/dist/cli.d.ts
ADDED