@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/README.md ADDED
@@ -0,0 +1,415 @@
1
+ # TwinDevBot
2
+
3
+ <p align="center">
4
+ <img src="logo.png" alt="TwinDevBot" width="150" />
5
+ </p>
6
+
7
+ A **Slack bot** that lets you develop with **Claude Code** through Slack conversations — **from anywhere**.
8
+
9
+ TwinDevBot connects your Slack workspace to Claude Code running on your machine. You send messages in Slack threads, and Claude Code works on your local codebase in real time.
10
+
11
+ ## When is this useful?
12
+
13
+ - You want to give Claude Code development tasks **remotely** (e.g. from your phone or another computer)
14
+ - You want to manage multiple projects through **separate Slack channels**
15
+ - You want to manage multiple tasks for a single project — each task lives in its own **Slack thread**
16
+ - You want Claude Code to work **autonomously** on tasks while you're away (Autopilot mode)
17
+
18
+ > [!CAUTION]
19
+ > **TwinDevBot launches Claude Code with the `--dangerously-skip-permissions` flag.** This means Claude Code can read, write, and execute files on your machine **without asking for permission**.
20
+ >
21
+ > Only use TwinDevBot if you fully understand what this means. The source code is fully open on GitHub. **No one is responsible for any incidents caused by using TwinDevBot.**
22
+
23
+ ---
24
+
25
+ ## Requirements
26
+
27
+ Before you begin, make sure you have the following:
28
+
29
+ 1. **Node.js 18 or later**
30
+ - Check by running `node --version` in your terminal
31
+ - If not installed, download from [nodejs.org](https://nodejs.org)
32
+
33
+ 2. **Claude Code CLI**
34
+ - The `claude` command must be available in your terminal
35
+ - Install with: `npm install -g @anthropic-ai/claude-code`
36
+ - Verify by running: `claude --version`
37
+
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`
41
+
42
+ 4. **A Slack workspace** where you have permission to install apps
43
+
44
+ ---
45
+
46
+ ## Step 1: Create a Slack App
47
+
48
+ You need to create a Slack App in your workspace. This is a one-time setup.
49
+
50
+ ### 1.1 Create the app
51
+
52
+ 1. Go to [https://api.slack.com/apps](https://api.slack.com/apps)
53
+ 2. Click **"Create New App"**
54
+ 3. Choose **"From scratch"**
55
+ 4. Enter an app name (e.g. `TwinDevBot`) and select your workspace
56
+ 5. Click **"Create App"**
57
+
58
+ ### 1.2 Enable Socket Mode
59
+
60
+ 1. In the left sidebar, click **"Socket Mode"**
61
+ 2. Toggle **"Enable Socket Mode"** to ON
62
+ 3. You will be asked to create an **App-Level Token**:
63
+ - Add the `connections:write` scope
64
+ - Click **"Generate"**
65
+ 4. **Copy the token** (starts with `xapp-`) — you will need this later
66
+
67
+ ### 1.3 Add a Slash Command
68
+
69
+ 1. In the left sidebar, click **"Slash Commands"**
70
+ 2. Click **"Create New Command"**
71
+ 3. Fill in:
72
+ - **Command:** `/twindevbot`
73
+ - **Short Description:** `TwinDevBot Commands`
74
+ - **Usage Hint:** `init | task | new | stop`
75
+ 4. Click **"Save"**
76
+
77
+ ### 1.4 Set Bot Permissions
78
+
79
+ 1. In the left sidebar, click **"OAuth & Permissions"**
80
+ 2. Scroll down to **"Scopes"** → **"Bot Token Scopes"**
81
+ 3. Click **"Add an OAuth Scope"** and add all of the following:
82
+
83
+ | Scope | What it's for |
84
+ | ------------------ | -------------------------------------- |
85
+ | `chat:write` | Send messages to channels |
86
+ | `commands` | Handle the `/twindevbot` slash command |
87
+ | `reactions:write` | Add emoji reactions to show progress |
88
+ | `channels:history` | Read messages in public channels |
89
+ | `groups:history` | Read messages in private channels |
90
+
91
+ ### 1.5 Enable Event Subscriptions
92
+
93
+ 1. In the left sidebar, click **"Event Subscriptions"**
94
+ 2. Toggle **"Enable Events"** to ON
95
+ 3. Expand **"Subscribe to bot events"**
96
+ 4. Click **"Add Bot User Event"** and add:
97
+ - `message.channels` (messages in public channels)
98
+ - `message.groups` (messages in private channels)
99
+ 5. Click **"Save Changes"**
100
+
101
+ ### 1.6 Enable Interactivity
102
+
103
+ 1. In the left sidebar, click **"Interactivity & Shortcuts"**
104
+ 2. Toggle **"Interactivity"** to ON
105
+ 3. Click **"Save Changes"**
106
+
107
+ > You do **not** need to enter a Request URL — Socket Mode handles this automatically.
108
+
109
+ ### 1.7 Install the App to Your Workspace
110
+
111
+ 1. In the left sidebar, click **"Install App"**
112
+ 2. Click **"Install to Workspace"**
113
+ 3. Review the permissions and click **"Allow"**
114
+ 4. **Copy the Bot Token** (starts with `xoxb-`) — you will need this later
115
+
116
+ ---
117
+
118
+ ## Step 2: Install TwinDevBot
119
+
120
+ Open your terminal and run:
121
+
122
+ ```bash
123
+ npm install -g twin-dev-bot
124
+ ```
125
+
126
+ Verify the installation:
127
+
128
+ ```bash
129
+ twindevbot help
130
+ ```
131
+
132
+ ---
133
+
134
+ ## Step 3: Start the Server
135
+
136
+ Navigate to the directory where you want TwinDevBot to store its data (your Desktop is recommended). For example:
137
+
138
+ ```bash
139
+ cd ~/Desktop
140
+ ```
141
+
142
+ Then start the server:
143
+
144
+ ```bash
145
+ twindevbot start
146
+ ```
147
+
148
+ **If this is your first time**, a setup wizard will appear asking for:
149
+
150
+ 1. **Slack App Token** — paste the `xapp-...` token from Step 1.2
151
+ 2. **Slack Bot Token** — paste the `xoxb-...` token from Step 1.7
152
+ 3. **Project base directory** — the parent folder where your projects live (default: your Desktop folder)
153
+
154
+ After completing the setup, the server will start and connect to Slack.
155
+
156
+ ### Running in the Background (Recommended)
157
+
158
+ To keep TwinDevBot running even after you close the terminal:
159
+
160
+ ```bash
161
+ twindevbot start --daemon
162
+ ```
163
+
164
+ This registers TwinDevBot as a background service that starts automatically on login (macOS launchd).
165
+
166
+ ### Managing the Server
167
+
168
+ ```bash
169
+ twindevbot status # Check if the background service is running
170
+ twindevbot stop # Stop and unregister the background service
171
+ twindevbot show # View saved Claude sessions
172
+ twindevbot clear # Delete saved data (sessions + workspaces)
173
+ ```
174
+
175
+ > `status` and `stop` are available only when daemon mode is supported (macOS).
176
+
177
+ ---
178
+
179
+ ## Step 4: Invite the Bot to a Slack Channel
180
+
181
+ Before using TwinDevBot in any channel, you must **invite the bot**:
182
+
183
+ 1. Go to the Slack channel where you want to use TwinDevBot
184
+ 2. Type `/invite @TwinDevBot` (use the name you gave your Slack app)
185
+ 3. The bot should now appear as a channel member
186
+
187
+ > The bot **cannot receive messages** in channels where it is not a member.
188
+
189
+ ---
190
+
191
+ ## Using TwinDevBot in Slack
192
+
193
+ ### Setting Up a Channel
194
+
195
+ Before starting any work, tell TwinDevBot which project directory to use for this channel:
196
+
197
+ ```
198
+ /twindevbot init
199
+ ```
200
+
201
+ This shows a list of folders inside your project base directory. Click a folder button to select it, or click **"Enter path manually"** to type a custom path.
202
+
203
+ You only need to do this **once per channel**.
204
+
205
+ ### Starting a Task
206
+
207
+ Once a channel is set up, start a new work session:
208
+
209
+ ```
210
+ /twindevbot task
211
+ ```
212
+
213
+ This creates a message in the channel. **Click on the thread** of that message to begin chatting with Claude Code.
214
+
215
+ Type your instructions in the thread (e.g. "Create a login page with email and password fields"), and Claude Code will start working on your local codebase.
216
+
217
+ ### Creating a New Project
218
+
219
+ **Create an empty project:**
220
+
221
+ ```
222
+ /twindevbot new my-app --empty
223
+ ```
224
+
225
+ **Create a project from a template:**
226
+
227
+ ```
228
+ /twindevbot new my-app --template react
229
+ ```
230
+
231
+ This creates the project in your base directory, sets it as the channel's working directory, and opens a new thread automatically.
232
+
233
+ **Available templates:**
234
+
235
+ | Category | Templates |
236
+ | -------- | ------------------------------------------------------------------------------------------------------------- |
237
+ | Frontend | `react`, `nextjs`, `vue`, `nuxt`, `sveltekit`, `angular`, `react-native-expo`, `react-native-bare`, `flutter` |
238
+ | Backend | `express`, `nestjs`, `fastify`, `spring-boot`, `django`, `fastapi`, `go`, `rails`, `laravel` |
239
+
240
+ ### Autopilot Mode
241
+
242
+ In Autopilot mode, Claude Code automatically answers its own questions and keeps working without waiting for your input. Great for **small tasks** or **kicking off work right before bed** — let Claude handle it while you sleep.
243
+
244
+ ```
245
+ /twindevbot task --autopilot
246
+ ```
247
+
248
+ Or with a new project:
249
+
250
+ ```
251
+ /twindevbot new my-app --template react --autopilot
252
+ ```
253
+
254
+ In Autopilot mode:
255
+
256
+ - Claude automatically selects the recommended option for each question
257
+ - All questions and auto-selected answers are logged in the thread for your review
258
+ - You can **interrupt** Autopilot by sending a message in the thread — you'll be asked to confirm before it stops
259
+
260
+ ### Stopping a Running Task
261
+
262
+ To cancel the current Claude Code task in a channel:
263
+
264
+ ```
265
+ /twindevbot stop
266
+ ```
267
+
268
+ ### Getting Help
269
+
270
+ ```
271
+ /twindevbot
272
+ ```
273
+
274
+ Use `/twindevbot` with no subcommand to show the help message. Any unknown subcommand shows the same help.
275
+
276
+ ---
277
+
278
+ ## How a Conversation Works
279
+
280
+ Here's what happens step by step:
281
+
282
+ 1. **You run** `/twindevbot task` → a parent message appears in the channel
283
+ 2. **You type** your instructions in the **thread** of that message (e.g. "Add a dark mode toggle")
284
+ 3. **Claude Code starts working** — you'll see emoji reactions showing progress:
285
+ - 👀 = Message received
286
+ - ⚙️ = Working (with tool usage updates like "Reading file", "Editing file", etc.)
287
+ - ✅ = Completed
288
+ - ❌ = Error occurred
289
+ 4. **If Claude has a question**, buttons appear in the thread:
290
+ - Click a button to select an option
291
+ - Or click **"Custom Input"** to type your own answer
292
+ - For multi-select questions, toggle options and click **"Submit Selection"**
293
+ 5. **When the task is done**, the elapsed time is displayed
294
+ 6. **Send another message** in the same thread to continue working — Claude remembers the entire conversation
295
+
296
+ ### Interrupting a Running Task
297
+
298
+ If Claude is still working and you send a new message in the thread, you'll see a confirmation prompt:
299
+
300
+ - In **normal mode**: you'll be asked whether to stop the current task and start a new one
301
+ - In **Autopilot mode**: you'll be asked to stop autopilot before running your new message
302
+
303
+ Click **Yes** to stop the current task and start your new one, or **No** to let it finish.
304
+
305
+ ---
306
+
307
+ ## Inactivity Timeout
308
+
309
+ If Claude Code receives no events for **30 minutes** (configurable), the process is automatically terminated to save resources. You'll see a notification in the thread. Simply send a new message to restart.
310
+
311
+ To change the timeout, add this to your `.env` file:
312
+
313
+ ```
314
+ INACTIVITY_TIMEOUT_MINUTES=60
315
+ ```
316
+
317
+ ---
318
+
319
+ ## Configuration Reference
320
+
321
+ All settings are stored in the `.env` file in the directory where you started TwinDevBot.
322
+
323
+ | Variable | Required | Description | Default |
324
+ | ---------------------------- | -------- | -------------------------------------- | ------------ |
325
+ | `SLACK_BOT_TOKEN` | Yes | Slack Bot Token (`xoxb-...`) | — |
326
+ | `SLACK_APP_TOKEN` | Yes | Slack App Token (`xapp-...`) | — |
327
+ | `TWINDEVBOT_BASE_DIR` | No | Parent directory for projects | Home Desktop |
328
+ | `INACTIVITY_TIMEOUT_MINUTES` | No | Minutes before idle Claude is stopped | `30` |
329
+ | `LOG_LEVEL` | No | `debug` \| `info` \| `warn` \| `error` | `info` |
330
+
331
+ ---
332
+
333
+ ## File Locations
334
+
335
+ TwinDevBot stores its data in the directory where you started the server:
336
+
337
+ | File | Purpose |
338
+ | ------------------------- | -------------------------------------- |
339
+ | `.env` | Configuration (Slack tokens, settings) |
340
+ | `data/sessions.json` | Saved Claude Code sessions |
341
+ | `data/workspaces.json` | Thread-to-directory mappings |
342
+ | `data/channels.json` | Channel-to-directory mappings |
343
+ | `data/twindevbot.pid` | Server process ID |
344
+ | `logs/twindevbot.err.log` | Error log |
345
+ | `logs/twindevbot.out.log` | Output log |
346
+
347
+ ---
348
+
349
+ ## Troubleshooting
350
+
351
+ ### "Claude CLI is not installed or not found in PATH"
352
+
353
+ Make sure the `claude` command works in your terminal:
354
+
355
+ ```bash
356
+ claude --version
357
+ ```
358
+
359
+ If not, install it:
360
+
361
+ ```bash
362
+ npm install -g @anthropic-ai/claude-code
363
+ ```
364
+
365
+ ### Bot doesn't respond to messages
366
+
367
+ 1. Make sure the TwinDevBot server is running:
368
+ - If you're using daemon mode (macOS): `twindevbot status`
369
+ - Otherwise, confirm the `twindevbot start` process is still running
370
+ 2. Make sure the bot is invited to the channel: `/invite @TwinDevBot`
371
+ 3. Make sure you're typing in a **thread**, not directly in the channel
372
+
373
+ ### "Session expired" message
374
+
375
+ Sessions are cleaned up after 24 hours of inactivity (cleanup runs hourly). Start a new session with `/twindevbot task`.
376
+
377
+ ### Server won't start
378
+
379
+ Check the error log for details:
380
+
381
+ ```bash
382
+ tail -f ./logs/twindevbot.err.log
383
+ ```
384
+
385
+ (Run from the directory where you started TwinDevBot)
386
+
387
+ ### Something seems wrong with the server
388
+
389
+ Check the error log:
390
+
391
+ ```bash
392
+ tail -f ./logs/twindevbot.err.log
393
+ ```
394
+
395
+ To clear all saved data and start fresh:
396
+
397
+ ```bash
398
+ # If you're running in daemon mode (macOS)
399
+ twindevbot stop
400
+ twindevbot clear
401
+ twindevbot start --daemon
402
+ ```
403
+
404
+ If you're running in the foreground, stop it with Ctrl+C first, then run:
405
+
406
+ ```bash
407
+ twindevbot clear
408
+ twindevbot start
409
+ ```
410
+
411
+ ---
412
+
413
+ ## License
414
+
415
+ [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html) — You are free to use, modify, and distribute this software. If you distribute modified versions or run it as a network service, you must release your source code under the same license.
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ import { execSync, execFileSync } from "child_process";
3
+ import { dirname, join } from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const projectRoot = join(__dirname, "..");
8
+ const entry = join(projectRoot, "dist", "cli.js");
9
+
10
+ // start, help 명령만 빌드 (stop, status는 기존 dist 사용)
11
+ const cmd = process.argv[2];
12
+ if (!cmd || cmd === "start" || cmd === "help" || cmd === "--help" || cmd === "-h") {
13
+ try {
14
+ execSync("npx tsc", { cwd: projectRoot, stdio: "inherit" });
15
+ } catch {
16
+ // noEmitOnError 미설정 → dist/ 파일은 갱신됨
17
+ }
18
+ }
19
+ execFileSync(process.execPath, [entry, ...process.argv.slice(2)], {
20
+ cwd: projectRoot,
21
+ stdio: "inherit",
22
+ });
@@ -0,0 +1,22 @@
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;
@@ -0,0 +1,54 @@
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
+ }
@@ -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 "./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
+ }
@@ -0,0 +1,16 @@
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;