Internal architecture documentation for Twin Dev Bot.
Slack (Socket Mode / WebSocket)
|
v
+----------------------------------------------------------------------+
| server.ts |
| Slack Bolt App (Socket Mode) |
| |
| +----------------------------+ +---------------------------------+ |
| | claude-command.ts | | question-handlers.ts | |
| | - /twindevbot slash cmd | | - Button clicks (single/multi) | |
| | - Thread message handling | | - Modal text input | |
| | - Interrupt confirmation | | - Interrupt handling | |
| +----------+-----------------+ +----------+----------------------+ |
| | | |
| +----------+-------------------------------+----------------------+ |
| | init-handlers.ts | |
| | - /twindevbot init directory selection buttons/modal | |
| +-----------------------------------------------------------------+ |
+-------------+-------------------------------+------------------------+
| |
v v
+----------------------------------------------------------------------+
| claude-runner-setup.ts |
| |
| Spawns Claude CLI and binds event handlers |
| - init -> register session |
| - text -> buffer via TextBuffer, then send to Slack thread |
| - toolUse -> update progress status |
| - askUser -> send question UI to Slack or auto-answer in autopilot |
| - exitPlanMode -> auto-approve and resume |
| - result -> handle completion |
+-------------------------+--------------------------------------------+
|
v
+----------------------------------------------------------------------+
| claude-runner.ts |
| |
| spawn("claude", ["-p", prompt, "--output-format", "stream-json", |
| "--verbose", "--dangerously-skip-permissions"]) |
| |
| Parse stdout JSONL -> emit events via EventEmitter |
+----------------------------------------------------------------------+
|
v
Claude CLI
/twindevbot init-> show directory selection UI in channel -> user selects -> save channel-directory mapping inchannel-store/twindevbot task-> create parent message in channel using registered directory -> save thread-directory mapping inworkspace-store- Thread message -> look up Session or Workspace -> start/resume Claude session
One Slack thread maps to one Claude session.
- Running
/twindevbot taskcreates a parent message in the channel - When a user sends a message in that thread, a Claude session starts
- All subsequent interactions in the same thread continue the same session via
--resume
- Channel: Channel-to-directory mapping set via
/twindevbot init. Persisted indata/channels.json. - Workspace: Mapping created when
/twindevbot taskruns (thread ts -> directory info). Persisted indata/workspaces.json. - Session: Persistent data created after the first Claude execution (thread ts -> Claude sessionId). Persisted in
data/sessions.json.
On message receipt, Session is looked up first; if not found, Workspace is looked up. Once a Session is created, the corresponding Workspace is deleted (prevents memory leaks).
active-runners.ts tracks running ClaudeRunner instances per thread.
- Concurrent execution prevention: Blocks multiple Claude processes from running simultaneously on the same thread
- Inactivity timeout: Automatically terminates the process if no events are received for
INACTIVITY_TIMEOUT_MINUTES(default 30 min) after the last event - Interrupt support: Allows users to stop a running task
When Claude calls the AskUserQuestion tool:
claude-runner.tsemits anaskUsereventclaude-runner-setup.tssends a question UI (buttons) to Slack- The Claude process is
kill()ed (stdin is"ignore", so it cannot receive direct responses) - The user responds on Slack (button click or modal input)
question-handlers.tscallssetupClaudeRunner()to resume the session via--resume
Claude running -> AskUserQuestion detected -> send Slack UI -> kill()
|
User Slack response <- - - - - - - - - - - - - - - - - - - - - -+
|
v
setupClaudeRunner(sessionId, answer) -> claude --resume <sessionId> -p "answer"
Multi-question batching: When Claude sends multiple questions at once, pending-questions.ts displays them one at a time on Slack, collects all answers, then combines them and sends to Claude.
When Claude calls the ExitPlanMode tool:
- stdin is "ignore", so the CLI cannot receive user approval
- The process is killed and resumed with a "Plan approved" message to exit plan mode
Activated with /twindevbot task --autopilot.
When AskUserQuestion fires, options are auto-selected without waiting for user input:
- Options whose label contains "recommended" (case-insensitive) are preferred
- If none, the first option is selected
- For multiSelect, all "recommended" options are selected (otherwise the first option)
All questions and auto-selected answers are logged in the Slack thread for review.
Flow:
askUserevent received- Extract recommended/first option label
- Post question UI to Slack in completed state (
isSubmitted: true) runner.kill()then immediately callsetupClaudeRunner()(autopilot flag preserved)
When a user sends a message in a thread where Claude is already running, an interrupt confirmation UI is shown.
Autopilot mode interrupt:
- "Yes" -> disable autopilot -> kill existing runner -> run user message in normal mode
- "No" -> continue autopilot
Normal mode interrupt:
- "Yes" -> kill existing runner -> start new task with user message
- "No" -> continue existing task
User messages are stored server-side in action-payload-store to handle Slack's action.value size limit (~2KB).
src/
├── server.ts # Slack Bolt App initialization and startup
├── cli.ts # CLI (start, stop, status, show, clear, help)
├── setup.ts # Interactive CLI setup wizard (.env creation)
├── templates.ts # Project scaffolding templates
├── core/ # Core infrastructure
│ ├── config.ts # Environment variable loading and validation (lazy init)
│ ├── logger.ts # Level-based structured logger
│ ├── paths.ts # Path configuration (based on cwd)
│ └── platform.ts # Cross-platform utilities (OS detection, tilde expansion)
├── claude/ # Claude execution layer
│ ├── claude-runner.ts # Claude CLI process execution and stream-json parsing
│ ├── session-manager.ts # Claude session CRUD and file persistence
│ └── active-runners.ts # Per-thread running Claude process registry
├── stores/ # State management (file-based + in-memory)
│ ├── workspace-store.ts # /twindevbot task workspace mapping
│ ├── channel-store.ts # /twindevbot init channel-directory mapping
│ ├── multi-select-state.ts # Multi-select UI state management
│ ├── pending-questions.ts # Multi-question batch management
│ └── action-payload-store.ts # Server-side Slack action payload storage
├── slack/ # Slack UI components
│ ├── progress-tracker.ts # Progress display via Slack reactions/messages
│ └── question-blocks.ts # AskUserQuestion -> Slack Block Kit conversion
├── handlers/ # Slack event handlers
│ ├── index.ts # Handler barrel export
│ ├── claude-command.ts # /twindevbot slash command and message events
│ ├── claude-runner-setup.ts # Claude runner spawning and event handler binding
│ ├── question-handlers.ts # Button clicks, modal submissions, interrupt handling
│ └── init-handlers.ts # Directory selection button/modal handling
├── daemon/ # Platform-specific daemon management
│ ├── index.ts # Platform factory (createDaemonManager)
│ ├── macos.ts # macOS launchd daemon
│ ├── windows.ts # Windows Task Scheduler daemon
│ └── types.ts # DaemonManager interface
├── i18n/ # Internationalization
│ ├── index.ts # t() function, initLocale
│ └── en.ts # English translations
├── utils/ # Utilities
│ ├── slack-message.ts # Slack API wrapper (message/reaction/update)
│ ├── slack-rate-limit.ts # Slack API auto-retry wrapper
│ ├── safe-async.ts # Async error catch wrapper for EventEmitter
│ └── display-width.ts # CJK/emoji string width calculation
├── types/ # TypeScript type definitions
│ ├── index.ts # Type barrel export
│ ├── slack.ts # Button value, modal metadata, interrupt types
│ ├── conversation.ts # Question, QuestionOption types
│ └── claude-stream.ts # Claude CLI stream-json output types
└── __tests__/ # Tests
├── helpers/
│ └── mock-factories.ts # Common mock factories
└── *.test.ts # Per-module test files
Spawns the Claude CLI as a child process and parses its JSONL (line-delimited JSON) stdout output.
// Spawn command
spawn("claude", [
"-p",
prompt,
"--output-format",
"stream-json",
"--verbose",
"--dangerously-skip-permissions",
// If sessionId is present:
"--resume",
sessionId,
]);- stdin:
"ignore"(no user input possible) - stdout: JSONL parsing -> EventEmitter events
- stderr: Buffered for error diagnostics
- Deduplication: Tracks processed tool_use IDs via
Set, plus init/result duplicate flags
Emitted events:
| Event | Trigger | Key Data |
|---|---|---|
init |
Session started | sessionId, model |
text |
Text output | text |
toolUse |
Tool usage | toolName, input |
askUser |
AskUserQuestion called | input.questions[] |
exitPlanMode |
ExitPlanMode called | {} |
result |
Task completed | result, costUsd |
error |
Process error | Error object |
exit |
Process exit | exit code |
Central module that creates the Claude process and binds event handlers.
TextBuffer: Accumulates text messages in a buffer for 2 seconds, then sends them to Slack in a single batch to prevent rate limiting.
Race condition prevention state:
resultReceived/resultPromise: Ensures ordering between result and exit eventscompletionHandled: Prevents duplicate completion handlinghandlerTakeover/processExitedEarly: Handles unexpected process termination during askUser/exitPlanMode async operations
Manages Claude session information, automatically persisted to data/sessions.json.
interface ClaudeSession {
sessionId: string; // Session ID issued by Claude CLI
projectName: string;
directory: string;
slackChannelId: string;
slackThreadTs: string; // Thread parent message ts (= session identifier)
startedAt: Date;
lastActivityAt: Date;
autopilot: boolean;
}Maintains 3 indexes:
sessionId -> ClaudeSessionprojectName:threadTs -> sessionIdthreadTs -> sessionId
Automatically restored from file on server restart. Sessions inactive for 24 hours are automatically cleaned up every hour.
Registry that tracks running Claude processes per thread.
interface RunnerEntry {
runner: ClaudeRunner;
timer: NodeJS.Timeout; // Inactivity timeout timer
registeredAt: number;
lastActivityAt: number;
onTimeout?: () => void; // Timeout callback (Slack notification, etc.)
}Key functions:
registerRunner(threadTs, runner, options): Register a runner (auto-kills existing runner)refreshActivity(threadTs, runner): Refresh activity time and reset timerunregisterRunner(threadTs, runner): Unregister runner (instance identity verification)isRunnerActive(threadTs): Check if an active runner existskillActiveRunner(threadTs): Force-kill by external request (interrupt, stop)killAllRunners(): Kill all runners during graceful shutdown
Displays progress status via emoji reactions on user messages and posts status messages in the thread.
Reaction transitions:
eyes (received) -> gear (working) -> white_check_mark (completed) / x (error) / raised_hand (askUser) / thumbsup (planApproved)
Status message update (no reaction change, message text only):
While in gear (working) state -> robot_face autopilotContinue (auto-responded, continuing)
The status message is posted once in the thread and then updated via chat.update(). During tool usage, the tool name (i18n translated) is displayed with 5-second throttling. On completion, elapsed time is shown.
Converts Claude's AskUserQuestion into Slack Block Kit UI.
Single-select mode:
- Each option is an independent button (75-char limit, truncated if exceeded)
- "Custom Input" button (opens modal)
- Selection is immediate on click
Multi-select mode (multiSelect: true):
- Each option is a toggle button (click toggles checkmark)
- "Submit Selection" button (final submission)
- "Custom Input" button
- Current selection status hint at the bottom
Completed state (isSubmitted: true):
- Selected answer is displayed; action buttons are not rendered
Respects Slack's 50-block limit per message.
Thread-to-directory mapping created when /twindevbot task runs.
interface Workspace {
directory: string;
projectName: string;
channelId: string;
autopilot?: boolean;
createdAt?: Date;
}Persisted to data/workspaces.json. Workspaces inactive for 24 hours are automatically deleted.
Channel-to-directory mapping set via /twindevbot init.
interface ChannelDir {
directory: string;
projectName: string;
}Persisted to data/channels.json.
Tracks user toggle state for multi-select questions.
interface MultiSelectState {
selected: Set<number>;
options: QuestionOption[];
questionText: string;
header?: string;
}
// Key: "projectName:messageId"In-memory only, 1-hour TTL with automatic cleanup.
Manages sequential processing when Claude sends multiple questions at once.
interface PendingQuestionBatch {
questions: Question[];
answers: string[];
currentIndex: number;
projectName: string;
channelId: string;
createdAt: number;
}
// Key: threadTsFlow: Show question 1 -> user answers -> show question 2 -> ... -> combine all answers in [header]: answer format -> resume Claude.
In-memory only, 1-hour TTL with automatic cleanup.
Server-side store to handle Slack's action.value (~2KB) and private_metadata (~3KB) size limits.
Stores large payloads on the server; button values only contain short keys (messageId).
- Question data:
q:${messageId}->StoredQuestionPayload - Interrupt messages:
interrupt:${threadTs}:${userMessageTs}-> user message text
TTL-based automatic cleanup (default 2 hours). TTL is refreshed on access.
Project scaffolding templates used by /twindevbot new <dir> --template <key>.
interface FrameworkTemplate {
name: string;
category: "frontend" | "backend";
scaffold: (projectName: string) => string | ((cwd: string) => Promise<void>);
timeout?: number;
}Templates by category:
- Frontend: react, nextjs, vue, nuxt, sveltekit, angular, react-native-expo, react-native-bare, flutter
- Backend: express, nestjs, fastify, spring-boot, django, fastapi, go, rails, laravel
Each template returns either a shell command string or a Node.js API function. Timeout is configurable per template (default 5 minutes).
Platform-specific background service management.
interface DaemonManager {
start(): void;
stop(): void;
status(): void;
isRunning(): boolean;
getLogViewCommand(logPath: string): string;
}- macOS (
macos.ts): Uses launchd. Creates~/Library/LaunchAgents/com.twin-dev-bot.plist. - Windows (
windows.ts): Task Scheduler-based background service. - Factory (
index.ts):createDaemonManager()returns the appropriate manager for the platform (daemon commands are supported on macOS/Windows only).
Translates via t(key, params?). Supports {{param}}-style template variables.
t("progress.completed", { elapsed: "15s" });
// "Completed (15s)"Currently only English (en) is supported. The i18n framework (initLocale, t, getCurrentLocale) is kept in place so adding a new locale only requires creating a translation file. Fallback: current locale -> en -> raw key string.
The twindevbot command manages the server.
| Command | Description |
|---|---|
twindevbot start |
Start server in foreground (runs setup wizard if unconfigured) |
twindevbot start --daemon |
Register and start as background service (macOS launchd / Windows Task Scheduler) |
twindevbot stop |
Stop and unregister background service |
twindevbot status |
Check service status |
twindevbot show |
Display saved sessions |
twindevbot clear |
Delete data files (sessions.json, workspaces.json) |
twindevbot help |
Show help |
When twindevbot start is run and no .env file exists, an interactive setup wizard (setup.ts) launches:
- Enter Slack App Token (
xapp-...) - Enter Slack Bot Token (
xoxb-...) - Enter project base directory path (default: Desktop under your home directory)
- Save
.envfile (0600 permissions on Unix)
Sets the working directory for the channel. Displays subdirectories under config.baseDir as buttons, or allows entering a custom path via modal.
Starts a new task thread using the channel's registered directory.
--autopilot: Enables auto-answer mode for AskUserQuestion
Creates an empty directory, then automatically sets the channel directory and starts a task.
Scaffolds a project from a template, then automatically sets the channel directory and starts a task.
Stops the currently running Claude task in the channel.
| Slack Event | Handler File | Action |
|---|---|---|
/twindevbot slash command |
claude-command.ts |
Subcommand routing (init, task, new, stop) |
| Thread message (runner inactive) | claude-command.ts |
Look up session/workspace -> start/resume Claude |
| Thread message (runner active) | claude-command.ts |
Send interrupt confirmation UI |
select_option_* button click |
question-handlers.ts |
Single select -> resume |
toggle_option_* button click |
question-handlers.ts |
Multi-select toggle (UI update only) |
submit_multi_select_* button click |
question-handlers.ts |
Multi-select submit -> resume |
text_input_* button click |
question-handlers.ts |
Open modal |
text_input_modal submission |
question-handlers.ts |
Modal text -> resume |
autopilot_interrupt_yes button click |
question-handlers.ts |
Disable autopilot -> kill runner -> start new task |
autopilot_interrupt_no button click |
question-handlers.ts |
Continue autopilot |
normal_interrupt_yes button click |
question-handlers.ts |
Kill runner -> start new task with user message |
normal_interrupt_no button click |
question-handlers.ts |
Continue existing task |
init_select_dir_* button click |
init-handlers.ts |
Select directory -> save to channel-store |
init_custom_input button click |
init-handlers.ts |
Open custom input modal |
init_custom_dir_modal submission |
init-handlers.ts |
Validate path -> save to channel-store |
// data/workspaces.json
{
"version": 1,
"workspaces": [
{
"threadTs": "1706000000.000000",
"directory": "/Users/user/Desktop/my-app",
"projectName": "my-app",
"channelId": "C0123456",
"autopilot": false,
"createdAt": "2024-01-23T10:00:00.000Z"
}
]
}// data/channels.json
{
"version": 1,
"channels": [
{
"channelId": "C0123456",
"directory": "/Users/user/Desktop/my-app",
"projectName": "my-app"
}
]
}interface MultiSelectState {
selected: Set<number>; // Selected option indexes
options: QuestionOption[];
questionText: string;
header?: string;
}
// Map<"projectName:messageId", MultiSelectState>interface PendingQuestionBatch {
questions: Question[];
answers: string[];
currentIndex: number;
projectName: string;
channelId: string;
createdAt: number;
}
// Map<threadTs, PendingQuestionBatch>// Map<string, { data: unknown, expiresAt: number }>
// Key patterns:
// "q:{messageId}" -> StoredQuestionPayload
// "interrupt:{threadTs}:{userMessageTs}" -> string (user message)Multiple race condition prevention mechanisms are applied throughout the project.
| Location | Mechanism | Purpose |
|---|---|---|
claude-command.ts |
pendingSetups Set |
Prevent concurrent setupClaudeRunner on same thread |
question-handlers.ts |
pendingResumes Set |
Prevent concurrent resumeClaudeWithAnswer on same thread |
active-runners.ts |
registerRunner kills existing |
Prevent duplicate processes on same thread |
active-runners.ts |
Instance comparison (stale guard) | Prevent old runner events from unregistering new runner |
claude-runner-setup.ts |
resultReceived flag |
Handle result <-> exit event ordering |
claude-runner-setup.ts |
handlerTakeover flag |
Handle process exit during askUser/exitPlanMode |
claude-runner.ts |
processedToolUseIds Set |
Prevent duplicate tool_use event processing |
claude-runner.ts |
initEmitted / resultEmitted |
Prevent duplicate init/result event emission |
All files are stored relative to the current working directory. Directories are auto-created if missing.
| Item | Path |
|---|---|
| Config file | ./.env |
| Session data | ./data/sessions.json |
| Workspaces | ./data/workspaces.json |
| Channel mappings | ./data/channels.json |
| PID file | ./data/twindevbot.pid |
| Logs | ./logs/twindevbot.{out,err}.log |
| Package | Purpose |
|---|---|
@slack/bolt |
Slack App framework (includes Socket Mode) |
@slack/web-api |
Slack Web API client |
@slack/types |
Slack type definitions |
@inquirer/prompts |
CLI interactive prompts (setup wizard) |
dotenv |
Environment variable loading |
| Package | Purpose |
|---|---|
typescript |
Type checking and build |
vitest |
Test framework |
@vitest/coverage-v8 |
Coverage reporting |
tsx |
TypeScript execution (dev) |
@types/node |
Node.js type definitions |
- Claude CLI (
claudecommand must be available in PATH) - Node.js >= 18 (ES2022 target)
- macOS or Windows for daemon mode; other platforms can run in the foreground (daemon commands disabled)
| Variable | Required | Description | Default |
|---|---|---|---|
SLACK_BOT_TOKEN |
Yes | Slack Bot Token (xoxb-...) |
- |
SLACK_APP_TOKEN |
Yes | Slack App Token (xapp-...) |
- |
TWINDEVBOT_BASE_DIR |
No | Project base directory | Home Desktop |
INACTIVITY_TIMEOUT_MINUTES |
No | Claude inactivity timeout (min) | 30 |
LOG_LEVEL |
No | debug | info | warn | error |
info |
- Settings > Enable Socket Mode
- Create App-Level Token (
connections:writescope)
chat:write- Send messagescommands- Slash Commandschannels:history- Read public channel messagesgroups:history- Read private channel messagesreactions:write- Add/remove emoji reactions
/twindevbot
- Enable (no Request URL needed - Socket Mode)
- Bot Events:
message.channels,message.groups - (No Request URL needed - Socket Mode)
The bot must be invited to a channel as a member to receive messages.
# Build
npm run build # TypeScript -> dist/
# Development
npm run dev:server # Hot-reload server via tsx
# Production
npm run start:server # node dist/server.js
# Test
npm test # vitest run (single run)
npm run test:watch # vitest watch (file change detection)
npm run test:coverage # Coverage report (v8, text/html/lcov)Tests are located in src/__tests__/ with 21 test files. vitest.config.ts has globals, node environment, restoreMocks, and clearMocks enabled.