@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,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel Store
|
|
3
|
+
*
|
|
4
|
+
* 슬랙 채널과 작업 디렉토리의 매핑을 관리합니다.
|
|
5
|
+
* /twindevbot init으로 설정된 채널별 작업 디렉토리를 영속적으로 저장합니다.
|
|
6
|
+
*
|
|
7
|
+
* 키: channelId (Slack 채널 ID)
|
|
8
|
+
* 값: { directory, projectName }
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
|
|
11
|
+
import { createLogger } from "./logger.js";
|
|
12
|
+
import { CHANNELS_FILE } from "./paths.js";
|
|
13
|
+
const log = createLogger("channel-store");
|
|
14
|
+
const channels = new Map();
|
|
15
|
+
function loadFromFile() {
|
|
16
|
+
try {
|
|
17
|
+
if (!existsSync(CHANNELS_FILE)) {
|
|
18
|
+
log.info("No channels file found, starting fresh");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const content = readFileSync(CHANNELS_FILE, "utf-8");
|
|
22
|
+
let data;
|
|
23
|
+
try {
|
|
24
|
+
data = JSON.parse(content);
|
|
25
|
+
}
|
|
26
|
+
catch (parseError) {
|
|
27
|
+
log.error("Channels file is corrupted, starting fresh", { parseError });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!data.channels || !Array.isArray(data.channels)) {
|
|
31
|
+
log.error("Channels file has invalid structure, starting fresh");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
let loadedCount = 0;
|
|
35
|
+
for (const c of data.channels) {
|
|
36
|
+
try {
|
|
37
|
+
channels.set(c.channelId, {
|
|
38
|
+
directory: c.directory,
|
|
39
|
+
projectName: c.projectName,
|
|
40
|
+
});
|
|
41
|
+
loadedCount++;
|
|
42
|
+
}
|
|
43
|
+
catch (entryError) {
|
|
44
|
+
log.warn("Skipping invalid channel entry", { entry: c, error: entryError });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
log.info("Channels loaded from file", { count: loadedCount, total: data.channels.length });
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
log.error("Failed to load channels from file", { error });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function saveToFile() {
|
|
54
|
+
try {
|
|
55
|
+
const serialized = Array.from(channels.entries()).map(([channelId, c]) => ({
|
|
56
|
+
channelId,
|
|
57
|
+
directory: c.directory,
|
|
58
|
+
projectName: c.projectName,
|
|
59
|
+
}));
|
|
60
|
+
const data = {
|
|
61
|
+
version: 1,
|
|
62
|
+
channels: serialized,
|
|
63
|
+
};
|
|
64
|
+
const tmpFile = CHANNELS_FILE + ".tmp";
|
|
65
|
+
writeFileSync(tmpFile, JSON.stringify(data, null, 2));
|
|
66
|
+
renameSync(tmpFile, CHANNELS_FILE);
|
|
67
|
+
log.debug("Channels saved to file", { count: serialized.length });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
log.error("Failed to save channels to file", { error });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// 모듈 로드 시 파일에서 복원
|
|
74
|
+
loadFromFile();
|
|
75
|
+
export function setChannelDir(channelId, dir) {
|
|
76
|
+
channels.set(channelId, dir);
|
|
77
|
+
log.info("Channel directory set", {
|
|
78
|
+
channelId,
|
|
79
|
+
projectName: dir.projectName,
|
|
80
|
+
directory: dir.directory,
|
|
81
|
+
});
|
|
82
|
+
saveToFile();
|
|
83
|
+
}
|
|
84
|
+
export function getChannelDir(channelId) {
|
|
85
|
+
return channels.get(channelId);
|
|
86
|
+
}
|
|
87
|
+
export function removeChannelDir(channelId) {
|
|
88
|
+
channels.delete(channelId);
|
|
89
|
+
log.debug("Channel directory removed", { channelId });
|
|
90
|
+
saveToFile();
|
|
91
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 활성 러너 레지스트리
|
|
3
|
+
*
|
|
4
|
+
* Slack 스레드(threadTs)별로 실행 중인 ClaudeRunner 인스턴스를 추적하여
|
|
5
|
+
* 동일 스레드에서 여러 Claude 프로세스가 동시에 실행되는 것을 방지합니다.
|
|
6
|
+
*
|
|
7
|
+
* - 마지막 이벤트 수신 후 config.inactivityTimeoutMs(기본 30분) 동안
|
|
8
|
+
* 활동이 없으면 프로세스를 자동 종료하고 스레드 잠금을 해제합니다.
|
|
9
|
+
* - onTimeout 콜백을 통해 Slack 타임아웃 알림 등 외부 처리가 가능합니다.
|
|
10
|
+
*/
|
|
11
|
+
import type { ClaudeRunner } from "./claude-runner.js";
|
|
12
|
+
export interface RegisterRunnerOptions {
|
|
13
|
+
/** 타임아웃 시 호출할 콜백 (Slack 알림 등) */
|
|
14
|
+
onTimeout?: () => void;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 러너를 활성 상태로 등록
|
|
18
|
+
*/
|
|
19
|
+
export declare function registerRunner(threadTs: string, runner: ClaudeRunner, options?: RegisterRunnerOptions): void;
|
|
20
|
+
/**
|
|
21
|
+
* 이벤트 수신 시 활동 시간 갱신 및 타임아웃 리셋
|
|
22
|
+
* 저장된 runner와 동일한 인스턴스인 경우에만 갱신 (stale 갱신 방지)
|
|
23
|
+
*/
|
|
24
|
+
export declare function refreshActivity(threadTs: string, runner: ClaudeRunner): void;
|
|
25
|
+
/**
|
|
26
|
+
* 러너를 비활성 상태로 해제
|
|
27
|
+
* 저장된 runner와 동일한 인스턴스인 경우에만 해제 (stale 해제 방지)
|
|
28
|
+
*/
|
|
29
|
+
export declare function unregisterRunner(threadTs: string, runner: ClaudeRunner): void;
|
|
30
|
+
/**
|
|
31
|
+
* 해당 스레드에 활성 러너가 있는지 확인
|
|
32
|
+
*/
|
|
33
|
+
export declare function isRunnerActive(threadTs: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* 활성 러너를 강제 종료하고 등록 해제.
|
|
36
|
+
* autopilot 개입, /twindevbot stop 명령, 일반 모드 인터럽트 등
|
|
37
|
+
* 외부 요청에 의해 실행 중인 작업을 중단할 때 사용합니다.
|
|
38
|
+
*/
|
|
39
|
+
export declare function killActiveRunner(threadTs: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* 모든 활성 러너를 강제 종료하고 등록 해제
|
|
42
|
+
* (graceful shutdown 시 고아 프로세스 방지)
|
|
43
|
+
*/
|
|
44
|
+
export declare function killAllRunners(): number;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 활성 러너 레지스트리
|
|
3
|
+
*
|
|
4
|
+
* Slack 스레드(threadTs)별로 실행 중인 ClaudeRunner 인스턴스를 추적하여
|
|
5
|
+
* 동일 스레드에서 여러 Claude 프로세스가 동시에 실행되는 것을 방지합니다.
|
|
6
|
+
*
|
|
7
|
+
* - 마지막 이벤트 수신 후 config.inactivityTimeoutMs(기본 30분) 동안
|
|
8
|
+
* 활동이 없으면 프로세스를 자동 종료하고 스레드 잠금을 해제합니다.
|
|
9
|
+
* - onTimeout 콜백을 통해 Slack 타임아웃 알림 등 외부 처리가 가능합니다.
|
|
10
|
+
*/
|
|
11
|
+
import { createLogger } from "../core/logger.js";
|
|
12
|
+
import { config } from "../core/config.js";
|
|
13
|
+
const log = createLogger("active-runners");
|
|
14
|
+
const activeRunners = new Map();
|
|
15
|
+
function startTimer(threadTs, entry) {
|
|
16
|
+
return setTimeout(() => {
|
|
17
|
+
log.warn("Runner inactivity timeout", {
|
|
18
|
+
threadTs,
|
|
19
|
+
inactiveMs: Date.now() - entry.lastActivityAt,
|
|
20
|
+
totalMs: Date.now() - entry.registeredAt,
|
|
21
|
+
});
|
|
22
|
+
// 콜백 실행 (Slack 알림 등)
|
|
23
|
+
try {
|
|
24
|
+
entry.onTimeout?.();
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
log.error("onTimeout callback error", err);
|
|
28
|
+
}
|
|
29
|
+
// 프로세스 종료 및 등록 해제
|
|
30
|
+
entry.runner.kill();
|
|
31
|
+
activeRunners.delete(threadTs);
|
|
32
|
+
}, config.inactivityTimeoutMs);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 러너를 활성 상태로 등록
|
|
36
|
+
*/
|
|
37
|
+
export function registerRunner(threadTs, runner, options) {
|
|
38
|
+
// 기존 엔트리가 있으면 프로세스 종료 및 타이머 정리
|
|
39
|
+
const existing = activeRunners.get(threadTs);
|
|
40
|
+
if (existing) {
|
|
41
|
+
clearTimeout(existing.timer);
|
|
42
|
+
existing.runner.kill();
|
|
43
|
+
log.info("Previous runner killed on re-register", { threadTs });
|
|
44
|
+
}
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const entry = {
|
|
47
|
+
runner,
|
|
48
|
+
timer: null,
|
|
49
|
+
registeredAt: now,
|
|
50
|
+
lastActivityAt: now,
|
|
51
|
+
onTimeout: options?.onTimeout,
|
|
52
|
+
};
|
|
53
|
+
entry.timer = startTimer(threadTs, entry);
|
|
54
|
+
activeRunners.set(threadTs, entry);
|
|
55
|
+
log.debug("Runner registered", { threadTs });
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 이벤트 수신 시 활동 시간 갱신 및 타임아웃 리셋
|
|
59
|
+
* 저장된 runner와 동일한 인스턴스인 경우에만 갱신 (stale 갱신 방지)
|
|
60
|
+
*/
|
|
61
|
+
export function refreshActivity(threadTs, runner) {
|
|
62
|
+
const entry = activeRunners.get(threadTs);
|
|
63
|
+
if (!entry || entry.runner !== runner)
|
|
64
|
+
return;
|
|
65
|
+
entry.lastActivityAt = Date.now();
|
|
66
|
+
clearTimeout(entry.timer);
|
|
67
|
+
entry.timer = startTimer(threadTs, entry);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 러너를 비활성 상태로 해제
|
|
71
|
+
* 저장된 runner와 동일한 인스턴스인 경우에만 해제 (stale 해제 방지)
|
|
72
|
+
*/
|
|
73
|
+
export function unregisterRunner(threadTs, runner) {
|
|
74
|
+
const entry = activeRunners.get(threadTs);
|
|
75
|
+
if (!entry || entry.runner !== runner)
|
|
76
|
+
return;
|
|
77
|
+
clearTimeout(entry.timer);
|
|
78
|
+
activeRunners.delete(threadTs);
|
|
79
|
+
log.debug("Runner unregistered", { threadTs });
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 해당 스레드에 활성 러너가 있는지 확인
|
|
83
|
+
*/
|
|
84
|
+
export function isRunnerActive(threadTs) {
|
|
85
|
+
return activeRunners.has(threadTs);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 활성 러너를 강제 종료하고 등록 해제.
|
|
89
|
+
* autopilot 개입, /twindevbot stop 명령, 일반 모드 인터럽트 등
|
|
90
|
+
* 외부 요청에 의해 실행 중인 작업을 중단할 때 사용합니다.
|
|
91
|
+
*/
|
|
92
|
+
export function killActiveRunner(threadTs) {
|
|
93
|
+
const entry = activeRunners.get(threadTs);
|
|
94
|
+
if (!entry)
|
|
95
|
+
return;
|
|
96
|
+
clearTimeout(entry.timer);
|
|
97
|
+
entry.runner.kill();
|
|
98
|
+
activeRunners.delete(threadTs);
|
|
99
|
+
log.info("Runner killed by external request", { threadTs });
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 모든 활성 러너를 강제 종료하고 등록 해제
|
|
103
|
+
* (graceful shutdown 시 고아 프로세스 방지)
|
|
104
|
+
*/
|
|
105
|
+
export function killAllRunners() {
|
|
106
|
+
const count = activeRunners.size;
|
|
107
|
+
for (const [threadTs, entry] of activeRunners) {
|
|
108
|
+
clearTimeout(entry.timer);
|
|
109
|
+
entry.runner.kill();
|
|
110
|
+
log.info("Runner killed during shutdown", { threadTs });
|
|
111
|
+
}
|
|
112
|
+
activeRunners.clear();
|
|
113
|
+
return count;
|
|
114
|
+
}
|
|
@@ -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 "../core/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
|
+
}
|
|
@@ -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 {};
|