@pollit/twin-dev-bot 0.0.1 → 0.0.2

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/README.md CHANGED
@@ -24,6 +24,8 @@ TwinDevBot connects your Slack workspace to Claude Code running on your machine.
24
24
 
25
25
  ## Requirements
26
26
 
27
+ **TwinDevBot is currently only available on macOS.**
28
+
27
29
  Before you begin, make sure you have the following:
28
30
 
29
31
  1. **Node.js 18 or later**
@@ -35,9 +37,8 @@ Before you begin, make sure you have the following:
35
37
  - Install with: `npm install -g @anthropic-ai/claude-code`
36
38
  - Verify by running: `claude --version`
37
39
 
38
- 3. **macOS for background service**
39
- - `twindevbot start --daemon`, `stop`, and `status` are supported on macOS (launchd)
40
- - On other platforms, run in the foreground with `twindevbot start`
40
+ 3. **Background service**
41
+ - `twindevbot start --daemon`, `stop`, and `status` are supported (macOS launchd)
41
42
 
42
43
  4. **A Slack workspace** where you have permission to install apps
43
44
 
@@ -60,9 +61,13 @@ You need to create a Slack App in your workspace. This is a one-time setup.
60
61
  1. In the left sidebar, click **"Socket Mode"**
61
62
  2. Toggle **"Enable Socket Mode"** to ON
62
63
  3. You will be asked to create an **App-Level Token**:
64
+ - Enter a name for the token (e.g., `twindevbot-socket`)
63
65
  - Add the `connections:write` scope
64
66
  - Click **"Generate"**
65
- 4. **Copy the token** (starts with `xapp-`) — you will need this later
67
+ 4. **Copy the token** (starts with `xapp-`)
68
+
69
+ > [!CAUTION]
70
+ > You will need this later. Save it somewhere safe now.
66
71
 
67
72
  ### 1.3 Add a Slash Command
68
73
 
@@ -111,7 +116,10 @@ You need to create a Slack App in your workspace. This is a one-time setup.
111
116
  1. In the left sidebar, click **"Install App"**
112
117
  2. Click **"Install to Workspace"**
113
118
  3. Review the permissions and click **"Allow"**
114
- 4. **Copy the Bot Token** (starts with `xoxb-`) — you will need this later
119
+ 4. **Copy the Bot Token** (starts with `xoxb-`)
120
+
121
+ > [!CAUTION]
122
+ > You will need this later. Save it somewhere safe now.
115
123
 
116
124
  ---
117
125
 
@@ -120,7 +128,7 @@ You need to create a Slack App in your workspace. This is a one-time setup.
120
128
  Open your terminal and run:
121
129
 
122
130
  ```bash
123
- npm install -g twin-dev-bot
131
+ npm install -g @pollit/twin-dev-bot
124
132
  ```
125
133
 
126
134
  Verify the installation:
package/bin/twindevbot.js CHANGED
@@ -9,11 +9,20 @@ const entry = join(projectRoot, "dist", "cli.js");
9
9
 
10
10
  // start, help 명령만 빌드 (stop, status는 기존 dist 사용)
11
11
  const cmd = process.argv[2];
12
- if (!cmd || cmd === "start" || cmd === "help" || cmd === "--help" || cmd === "-h") {
12
+ if (
13
+ !cmd ||
14
+ cmd === "start" ||
15
+ cmd === "help" ||
16
+ cmd === "--help" ||
17
+ cmd === "-h"
18
+ ) {
13
19
  try {
14
- execSync("npx tsc", { cwd: projectRoot, stdio: "inherit" });
15
- } catch {
16
- // noEmitOnError 미설정 dist/ 파일은 갱신됨
20
+ execSync("npx tsc", { cwd: projectRoot, stdio: "pipe" });
21
+ } catch (error) {
22
+ // 빌드 에러 시에만 stderr 출력
23
+ if (error.stderr) {
24
+ process.stderr.write(error.stderr);
25
+ }
17
26
  }
18
27
  }
19
28
  execFileSync(process.execPath, [entry, ...process.argv.slice(2)], {
package/dist/cli.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pollit/twin-dev-bot",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Slack bot that runs Claude Code via thread-based conversations",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,22 +0,0 @@
1
- /**
2
- * Slack action.value (~2,000 bytes) 및 private_metadata (~3,000 bytes) 크기 제한 대응.
3
- * 큰 페이로드를 서버 사이드에 저장하고 짧은 키만 버튼 값에 포함.
4
- * TTL 기반 자동 정리로 메모리 누수 방지.
5
- */
6
- /**
7
- * 페이로드 저장
8
- */
9
- export declare function setPayload(key: string, data: unknown, ttlMs?: number): void;
10
- /**
11
- * 페이로드 조회 (기본: 조회 후 삭제하지 않음)
12
- * @param remove true이면 조회 후 삭제 (일회성 데이터용)
13
- */
14
- export declare function getPayload<T>(key: string, remove?: boolean): T | null;
15
- /**
16
- * 페이로드 삭제
17
- */
18
- export declare function removePayload(key: string): void;
19
- /**
20
- * 테스트용: 스토어 전체 초기화
21
- */
22
- export declare function clearAllPayloads(): void;
@@ -1,54 +0,0 @@
1
- /**
2
- * Slack action.value (~2,000 bytes) 및 private_metadata (~3,000 bytes) 크기 제한 대응.
3
- * 큰 페이로드를 서버 사이드에 저장하고 짧은 키만 버튼 값에 포함.
4
- * TTL 기반 자동 정리로 메모리 누수 방지.
5
- */
6
- const store = new Map();
7
- const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000; // 2시간
8
- function cleanup() {
9
- const now = Date.now();
10
- for (const [key, entry] of store) {
11
- if (now > entry.expiresAt)
12
- store.delete(key);
13
- }
14
- }
15
- /**
16
- * 페이로드 저장
17
- */
18
- export function setPayload(key, data, ttlMs = DEFAULT_TTL_MS) {
19
- cleanup();
20
- store.set(key, { data, expiresAt: Date.now() + ttlMs });
21
- }
22
- /**
23
- * 페이로드 조회 (기본: 조회 후 삭제하지 않음)
24
- * @param remove true이면 조회 후 삭제 (일회성 데이터용)
25
- */
26
- export function getPayload(key, remove = false) {
27
- const entry = store.get(key);
28
- if (!entry)
29
- return null;
30
- if (Date.now() > entry.expiresAt) {
31
- store.delete(key);
32
- return null;
33
- }
34
- if (remove) {
35
- store.delete(key);
36
- }
37
- else {
38
- // TTL 갱신: 접근 시 만료 시간 리셋 (사용자가 상호작용 중임을 표시)
39
- entry.expiresAt = Date.now() + DEFAULT_TTL_MS;
40
- }
41
- return entry.data;
42
- }
43
- /**
44
- * 페이로드 삭제
45
- */
46
- export function removePayload(key) {
47
- store.delete(key);
48
- }
49
- /**
50
- * 테스트용: 스토어 전체 초기화
51
- */
52
- export function clearAllPayloads() {
53
- store.clear();
54
- }
@@ -1,44 +0,0 @@
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;
@@ -1,114 +0,0 @@
1
- /**
2
- * 활성 러너 레지스트리
3
- *
4
- * Slack 스레드(threadTs)별로 실행 중인 ClaudeRunner 인스턴스를 추적하여
5
- * 동일 스레드에서 여러 Claude 프로세스가 동시에 실행되는 것을 방지합니다.
6
- *
7
- * - 마지막 이벤트 수신 후 config.inactivityTimeoutMs(기본 30분) 동안
8
- * 활동이 없으면 프로세스를 자동 종료하고 스레드 잠금을 해제합니다.
9
- * - onTimeout 콜백을 통해 Slack 타임아웃 알림 등 외부 처리가 가능합니다.
10
- */
11
- import { createLogger } from "./logger.js";
12
- import { config } from "./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
- }
@@ -1,16 +0,0 @@
1
- /**
2
- * Channel Store
3
- *
4
- * 슬랙 채널과 작업 디렉토리의 매핑을 관리합니다.
5
- * /twindevbot init으로 설정된 채널별 작업 디렉토리를 영속적으로 저장합니다.
6
- *
7
- * 키: channelId (Slack 채널 ID)
8
- * 값: { directory, projectName }
9
- */
10
- export interface ChannelDir {
11
- directory: string;
12
- projectName: string;
13
- }
14
- export declare function setChannelDir(channelId: string, dir: ChannelDir): void;
15
- export declare function getChannelDir(channelId: string): ChannelDir | undefined;
16
- export declare function removeChannelDir(channelId: string): void;
@@ -1,91 +0,0 @@
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
- }
@@ -1,57 +0,0 @@
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;