
Claude Remote Sessions
- 2 installs
- 3 repo stars
- Updated August 5, 2026
- broomva/skills
claude-remote-sessions is a Claude skill that runs an isolated Claude Code session per Discord/Telegram channel or thread in tmux, with a watchdog daemon for auto-respawn, auto-discovery, and stale cleanup.
About
claude-remote-sessions runs one isolated Claude Code session per Discord channel, thread, or Telegram chat, each in its own tmux pane with per-channel access control and project-specific workdirs. A watchdog daemon keeps sessions alive by auto-respawning dead ones, auto-discovering new channels and threads, and cleaning up stale sessions on a schedule. A developer uses it to operate many remote agent sessions across messaging channels and keep them running across reboots via launchd. It matters because it turns a chat server into a persistent multi-session control plane for Claude Code.
- Maps each Discord channel or thread to its own isolated Claude Code session running in a tmux pane
- Ships a watchdog daemon that auto-respawns dead sessions, auto-discovers new channels/threads, and cleans up stale ones
- Uses deterministic UUID v5 session IDs so conversation history persists across watchdog respawns
Claude Remote Sessions by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,839 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
claude-remote-sessions capabilities & compatibility
- Capabilities
- session management · watchdog daemon · auto discovery · boot persistence
- Works with
- slack
- Use cases
- orchestration
- Platforms
- macOS
What claude-remote-sessions says it does
Each Discord channel or thread maps to its own independent Claude Code session running in a tmux pane.
Every 30s: respawns dead sessions. Every 60s: discovers new channels and threads. Every 5m: cleans up stale sessions (deleted channels, archived threads).
Sessions survive watchdog respawns by using Claude Code's `--session-id` flag.
npx skills add https://github.com/broomva/skills --skill claude-remote-sessionsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 3 |
| Last updated | August 5, 2026 |
| Repository | broomva/skills ↗ |
What it does
Run and keep alive one isolated Claude Code session per Discord/Telegram channel or thread with a watchdog and boot persistence.
Who is it for?
Operators running multiple remote Claude Code sessions across Discord or Telegram channels
Skip if: Setups without --channels support (requires Claude Code v2.1.80+) or without tmux
When should I use this skill?
Setting up per-channel Discord/Telegram sessions, managing multiple sessions, auto-discovering channels/threads, or keeping remote agent sessions alive.
What you get
Each channel or thread gets a persistent, auto-managed Claude Code session that survives respawns and reboots.
- per-channel tmux sessions
- watchdog daemon
- launchd boot persistence
By the numbers
- respawn check every 30s
- discovery every 60s
- stale cleanup every 5m
Files
Claude Remote Sessions
Each Discord channel or thread maps to its own independent Claude Code session running in a tmux pane. Discord messaging is handled natively by the MCP plugin inside each session. A watchdog daemon keeps sessions alive and auto-discovers new channels/threads.
Prerequisites
- Claude Code with
--channelssupport (v2.1.80+) - Discord bot configured:
/discord:configure <token> - tmux installed
- Discord bot invited to your server with permissions: View Channels, Send Messages,
Read History, Attach Files, Add Reactions, Manage Channels, Create Threads
Setup
1. Configure environment
Create ~/.claude/discord-sessions/config.env:
# Required
DISCORD_ALLOWED_USER_ID="your-discord-user-id"
DISCORD_GUILD_ID="your-guild-server-id"
# Optional (defaults shown)
DISCORD_SESSION_WORKDIR="$HOME" # Default workdir for new sessions
DISCORD_WATCHDOG_INTERVAL=30 # Respawn check frequency (seconds)
DISCORD_DISCOVER_INTERVAL=60 # Channel/thread discovery frequency (seconds)
DISCORD_CLEANUP_INTERVAL=300 # Stale session cleanup frequency (seconds)Find your user ID: Discord Settings → Advanced → Enable Developer Mode → right-click your name → Copy User ID. Find your guild ID: Right-click your server name → Copy Server ID.
2. Install scripts
Copy scripts to your project:
cp scripts/discord-session-manager.sh ~/your-project/scripts/
cp scripts/discord-watchdog.sh ~/your-project/scripts/
chmod +x ~/your-project/scripts/discord-session-manager.sh
chmod +x ~/your-project/scripts/discord-watchdog.sh3. Start
# Discover all channels + threads and spawn sessions
./scripts/discord-session-manager.sh discover-all
# Start the watchdog (auto-respawn + auto-discover every 60s)
./scripts/discord-watchdog.sh --daemonSession Manager
Script: scripts/discord-session-manager.sh
Spawn
# Channel session
./scripts/discord-session-manager.sh spawn <channel_id> --name <label> [--workdir <path>]
# Thread session (fetches last 20 parent messages as context)
./scripts/discord-session-manager.sh spawn-thread <thread_id> <parent_id> [--name <label>] [--workdir <path>]
# Fresh session — resets conversation history for a channel
./scripts/discord-session-manager.sh spawn <channel_id> --name <label> --freshDefault workdir comes from config.env. Override per-session with --workdir to scope a session to a specific project — it loads that project's CLAUDE.md chain automatically.
Each session gets:
- A tmux session
dc-<id>running Claude Code with--channels discord - A deterministic
--session-id(UUID v5 derived from the channel ID) so conversation
history persists across watchdog respawns
- A per-channel
DISCORD_STATE_DIRwith scopedaccess.json - A persisted
.session-idfile in the state directory - A registry entry in
sessions.json
Auto-Discovery
./scripts/discord-session-manager.sh discover # new channels
./scripts/discord-session-manager.sh discover-threads # new threads with parent context
./scripts/discord-session-manager.sh discover-all # bothThe watchdog runs discover-all every 60 seconds. Create a channel or thread on Discord → a session spawns automatically.
Create a Channel
./scripts/discord-session-manager.sh create-channel <name>Creates the Discord channel via API AND spawns its session.
Stale Session Cleanup
./scripts/discord-session-manager.sh cleanup-staleChecks each registered session against the Discord API. Kills and deregisters sessions whose channel has been deleted (HTTP 404) or whose thread has been archived (thread_metadata.archived: true). The watchdog runs this automatically every 5 minutes (configurable via DISCORD_CLEANUP_INTERVAL).
Manage
./scripts/discord-session-manager.sh list # UP/DOWN status
./scripts/discord-session-manager.sh status # overview
./scripts/discord-session-manager.sh attach <id> # attach to tmux session
./scripts/discord-session-manager.sh kill <id> # kill and deregister
./scripts/discord-session-manager.sh kill-all # kill everythingWatchdog Daemon
Script: scripts/discord-watchdog.sh
./scripts/discord-watchdog.sh --daemon # start in tmux: dc-watchdog
./scripts/discord-watchdog.sh --stop # stop
./scripts/discord-watchdog.sh --status # check if runningEvery 30s: respawns dead sessions. Every 60s: discovers new channels and threads. Every 5m: cleans up stale sessions (deleted channels, archived threads).
Boot Persistence (macOS)
See references/launchd.md for a launchd plist template that starts the watchdog on login.
Session Persistence
Sessions survive watchdog respawns by using Claude Code's --session-id flag. When a session is spawned, a deterministic UUID v5 is generated from a fixed namespace and the channel/thread ID. This means:
- Same channel = same session ID — the conversation resumes where it left off after
a crash or respawn.
- The UUID is persisted to
$SESSIONS_DIR/<channel_id>/.session-idsorespawn-dead
reads it back and passes it to the new claude process.
- Use
--freshwhen spawning to generate a random UUID v4 instead, resetting the
conversation history for that channel.
How it works
1. spawn / spawn-thread calls _generate_session_id(channel_id) which produces a deterministic UUID v5 via python3 -c "uuid.uuid5(namespace, channel_id)". 2. The UUID is saved to <state_dir>/.session-id and passed as --session-id <uuid> to the claude command. 3. When the watchdog calls respawn-dead, the persisted UUID is read from .session-id and passed back to _spawn_tmux, so claude resumes the same conversation. 4. --fresh overrides this with a new random UUID v4, giving the channel a clean slate.
Thread Detection (In-Session)
When a session receives a message where chat_id differs from its assigned channel, it is a thread message. The session should:
1. Check if a session exists: ./scripts/discord-session-manager.sh list 2. If not, spawn one: ./scripts/discord-session-manager.sh spawn-thread <chat_id> <channel_id> 3. Reply acknowledging the handoff
In practice, the watchdog handles this automatically via discover-threads.
Channel-to-Workdir Mapping
By default, all sessions use the DISCORD_SESSION_WORKDIR from config.env. To assign different project directories to specific channels, create a mapping file:
File: ~/.claude/discord-sessions/workdir-map.json
{
"general": "$HOME/myproject",
"health-os": "$HOME/myproject/apps/healthOS",
"life": "$HOME/myproject/core/life",
"design-system": "$HOME/myproject/apps/arcan-glass"
}How it works:
- When
discoverordiscover-threadsspawns a new session, it looks up the channel/thread
name in workdir-map.json.
- If a match is found, the session starts in that directory (with that project's CLAUDE.md chain).
- If no match is found, the default
DISCORD_SESSION_WORKDIRis used. - If the file does not exist, everything works as before — the feature is fully optional.
- Environment variables in paths (like
$HOME) are expanded automatically.
You can also use --workdir on individual spawn / spawn-thread commands to override the mapping for a single session.
Tip: After updating workdir-map.json, run kill-all then discover-all to re-spawn all sessions with the new workdir assignments. Existing sessions are not affected until they are killed and re-spawned.
Slash Commands
The slash command daemon provides Discord-native /command interaction for managing sessions without leaving the chat interface.
Starting the Daemon
The watchdog automatically manages the slash daemon. When the watchdog runs, it checks for the dc-slash-daemon tmux session and spawns it if missing.
# Manual start (standalone)
cd scripts && bun discord-slash-daemon.ts
# Or let the watchdog handle it
./scripts/discord-watchdog.sh --daemonAvailable Commands
| Command | Description |
|---|---|
/session status | Show session info (workdir, uptime, name, tmux session) as an embed |
/session restart | Kill + respawn the session. Use fresh: true for a clean conversation |
/session refresh | Kill + respawn with the same session-id (picks up new skills/CLAUDE.md) |
/session kill | Kill the session |
/session wake | Wake a suspended session |
/session workdir [path] | Show current workdir, or change it (kills + respawns with new workdir) |
/skills list | List installed skills for the channel's session |
/skills install <name> | Install a skill via npx skills add in the session |
/discover | Trigger channel/thread discovery |
/ask <prompt> | Send a prompt to the channel's Claude session |
Autocomplete
The /session workdir command supports autocomplete. It reads entries from ~/.claude/discord-sessions/workdir-map.json and suggests matching paths as the user types.
Skills Install + Refresh
/skills install <name> sends the install command directly to the tmux session. After installation completes, use /session refresh to kill and respawn the session so it picks up the newly installed skill.
Registering Commands
Commands are registered automatically when the daemon starts. To register or update commands without running the full daemon:
bun scripts/register-slash-commands.ts # Register/update commands
bun scripts/register-slash-commands.ts --clear # Remove all guild commandsAdding Custom Commands
1. Add the command definition to the SLASH_COMMANDS array in discord-slash-daemon.ts 2. Add a handler function (handleYourCommand) 3. Add the routing case in handleInteraction 4. Run bun scripts/register-slash-commands.ts to update Discord 5. Restart the daemon (or let the watchdog cycle pick it up)
Prerequisites
The daemon requires:
bunruntimediscord.jsv14 (install:cd scripts && bun install)- Bot token at
~/.claude/channels/discord/.env - Guild ID in
~/.claude/discord-sessions/config.env - The bot must have the
applications.commandsscope in the guild
Troubleshooting
Sessions crash immediately after account switch
Symptom: After logging into a new Claude account (claude login), sessions spawn but immediately die. Only the first session survives; all subsequent ones exit silently.
Cause: Stale .session-id files from the previous account. Claude Code's --session-id flag tries to resume a conversation that doesn't exist under the new account, causing the process to exit.
Fix:
# 1. Kill all sessions
./scripts/discord-session-manager.sh kill-all
# 2. Clear stale session IDs
for d in ~/.claude/discord-sessions/*/; do rm -f "$d/.session-id"; done
# 3. Clear session state
echo '{}' > ~/.claude/discord-sessions/sessions.json
# 4. Update OAuth token in config.env (if using CLAUDE_CODE_OAUTH_TOKEN)
# Edit ~/.claude/discord-sessions/config.env with your new token
# 5. Respawn all sessions
./scripts/discord-session-manager.sh discover-allSessions start but Discord messages don't arrive
Symptom: Sessions show the Claude Code TUI prompt but no "Listening for channel messages from: plugin:discord" banner.
Cause: The --channels plugin:discord@claude-plugins-official flag is missing from the spawn command. The Discord plugin MCP server only connects when launched with --channels.
Fix: Ensure the session manager includes --channels in the claude command. The flag is required even though the plugin is installed globally.
"You're out of extra usage" rate limit
Symptom: Session shows a rate-limit prompt with options to wait, switch to extra usage, or upgrade.
Fix: Use /session send 2 from Discord to select "Switch to extra usage", or /session send 1 to wait for reset.
Architecture
Discord #general → tmux: dc-<a> → Claude Code (workdir A, CLAUDE.md chain A)
Discord #project-x → tmux: dc-<b> → Claude Code (workdir B, CLAUDE.md chain B)
Thread: "design" → tmux: dc-<c> → Claude Code (parent context injected)
dc-watchdog → Respawns dead + discovers new + cleans stale
dc-slash-daemon → Handles /session, /skills, /ask, /discoverDiscord Dispatcher Daemon — Architecture
Replaces the bash watchdog + per-channel gateway pattern with a single-gateway
dispatcher that routes messages to per-channel Claude Code sessions on demand.
Problem Statement
The current system spawns one full Claude Code instance per Discord channel, each with its own Discord gateway connection and MCP servers. This creates:
- N gateway connections for N channels (wasteful, rate-limited)
- ~350 MB per channel (Claude Code + Discord MCP + Telegram MCP)
- No wake-on-message — suspended sessions silently drop messages
- Orphan processes from crash loops that leak memory
- Aggressive idle suspension that kills sessions before users can interact
Architecture
Discord Gateway (1 WebSocket)
│
┌────────┴────────┐
│ DISPATCHER │ (~120 MB Bun, ~40 MB Rust)
│ │
│ - discord.js │
│ - channel router│
│ - session FSM │
│ - message queue│
│ - slash cmds │
└────────┬────────┘
│ Unix socket (NDJSON)
┌───────────┼───────────┐
│ │ │
┌─────┴─────┐ ┌──┴──┐ ┌────┴────┐
│ Proxy MCP │ │ ... │ │ (none) │
│ ~20 MB │ │ │ │SUSPENDED│
└─────┬─────┘ └──┬──┘ └─────────┘
stdio stdio
│ │
┌─────┴─────┐ ┌──┴──────┐
│Claude Code│ │Claude │
│ ~365 MB │ │Code │
└───────────┘ └─────────┘Key Insight: MCP Notification Interface
The Discord MCP plugin delivers messages to Claude Code via MCP notifications over stdio:
mcp.notification({
method: 'notifications/claude/channel',
params: { content, meta: { chat_id, message_id, user, user_id, ts } },
})Outbound calls (reply, react, edit_message) use MCP tool calls over the same stdio pipe. Claude Code doesn't know or care where the MCP server gets its data — the proxy is indistinguishable from the real plugin.
Components
Dispatcher Daemon (single process)
- Maintains ONE
discord.jsClientwith the gateway connection - Receives all
messageCreateevents across all channels - Routes inbound messages to the correct session based on channel ID
- Queues messages for suspended/spawning sessions (bounded, 50 max)
- Manages session lifecycle via the state machine
- Exposes Unix domain socket at
~/.claude/discord-dispatcher/dispatch.sock - Handles outbound API calls on behalf of proxy servers
- Subsumes slash daemon functionality
Proxy MCP Server (one per active session)
- Speaks the exact same MCP stdio protocol as the official
server.ts - Claude Code sees it as a normal Discord channel plugin (same tools, same notifications)
- Connects to dispatcher via IPC (Unix socket) instead of Discord gateway
- Inbound: dispatcher → IPC → proxy writes MCP notification to Claude's stdin
- Outbound: Claude calls MCP tool → proxy forwards to dispatcher → Discord API → result returned
- Estimated memory: ~20 MB (vs ~110 MB for full gateway server)
Session State Machine
IDLE ──message──▶ SPAWNING ──mcp connected──▶ ACTIVE ──idle timeout──▶ SUSPENDED
▲ │
└──────────────── message arrives ─────────────────────┘| State | Processes | Memory | Behavior |
|---|---|---|---|
| IDLE | None | 0 MB | Channel registered, no session. First message triggers spawn. |
| SPAWNING | tmux starting | 0 MB (growing) | Messages queued. Proxy connects when ready. |
| ACTIVE | Claude Code + Proxy MCP | ~385 MB | Processing messages normally. |
| SUSPENDED | None | 0 MB | Dispatcher still listening. Message triggers wake. |
| DEAD | None | 0 MB | Channel deleted/archived. Cleaned up. |
Critical difference from current system: a message arriving for a SUSPENDED session triggers wake-on-message. The dispatcher is always listening, always connected to the gateway.
IPC Protocol
Unix domain socket at ~/.claude/discord-dispatcher/dispatch.sock. Protocol: newline-delimited JSON (NDJSON).
// Proxy → Dispatcher: Register session
{ "type": "register", "session_id": "...", "channel_id": "..." }
// Dispatcher → Proxy: Inbound message
{ "type": "inbound", "channel_id": "...", "content": "...", "meta": { ... } }
// Proxy → Dispatcher: Outbound tool call
{ "type": "tool_call", "request_id": "...", "name": "reply", "args": { ... } }
// Dispatcher → Proxy: Tool call result
{ "type": "tool_result", "request_id": "...", "result": { ... } }
// Dispatcher → Proxy: Permission request relay
{ "type": "permission_request", "request_id": "...", ... }
// Proxy → Dispatcher: Permission response relay
{ "type": "permission_response", "request_id": "...", "behavior": "allow" }Message Flow
Inbound (Discord → Claude)
1. Discord gateway delivers messageCreate to dispatcher's single Client 2. Dispatcher runs gate() logic (access control, mention check) 3. Looks up channelId in session registry 4. ACTIVE: Forward to connected proxy MCP via IPC 5. SUSPENDED: Enqueue message → transition to SPAWNING → spawn tmux → flush on connect 6. IDLE: Auto-discover if in configured guild → spawn → queue → flush 7. SPAWNING: Enqueue (bounded queue, oldest dropped with warning)
Outbound (Claude → Discord)
1. Claude Code calls MCP tool (e.g., reply) 2. Proxy receives CallToolRequest via stdio 3. Proxy forwards to dispatcher via IPC 4. Dispatcher calls Discord API via its single Client 5. Result returned through IPC → proxy → Claude Code
Memory Budget Comparison
| Scenario | Current | Proposed | Savings |
|---|---|---|---|
| 6 channels, all active | 3.4 GB | 2.4 GB | 30% (no Telegram sidecar) |
| 6 channels, 2 active, 4 suspended | 3.4 GB | 0.9 GB | 74% |
| 20 channels (with threads) | ~11.5 GB | 1.0 GB (2 active) | 91% |
Plugin Registration
The proxy MCP registers as a local plugin:
~/.claude/plugins/local/discord-proxy/
├── .claude-plugin/plugin.json
├── .mcp.json # Points to proxy script
├── package.json # @modelcontextprotocol/sdk only
└── server.ts # Proxy implementationSessions launch with --channels plugin:discord-proxy@local instead of the official plugin. Claude Code sees the same MCP interface — no core changes needed.
Terminal Chrome Filtering
The proxy MCP should strip Claude Code terminal chrome before posting to Discord:
- Lines entirely composed of box-drawing characters (
─,━) - Status line (matching
Sonnet|Opus|Haiku|Claude \d+\.\d+ |) - Bare prompt characters (
❯,>) - Collapse excessive blank lines
This filtering lives in the proxy's reply and edit_message handlers, not in the official plugin (which gets overwritten on updates).
Implementation Phases
| Phase | Scope | Effort | Language |
|---|---|---|---|
| 1. MVP | Single gateway + proxy MCP + wake-on-message | 2-3 days | TypeScript/Bun |
| 2. Slash commands | Absorb discord-slash-daemon.ts | 1 day | TS |
| 3. Message queue | Bounded queue for SPAWNING, graceful shutdown | 1 day | TS |
| 4. Rust migration | Rewrite dispatcher as arcan-discord crate | 1-2 weeks | Rust |
| 5. arcan-fleet | Discord sessions as managed agent instances | Future | Rust |
Phase 1 deliverables
skills/claude-remote-sessions/scripts/
├── discord-dispatcher.ts # Main dispatcher daemon
├── discord-proxy-mcp.ts # Proxy MCP server
├── discord-session-manager.sh # Existing (minimal changes)
├── discord-watchdog.sh # Replaced by dispatcher
├── discord-slash-daemon.ts # Absorbed in Phase 2Phase 4 deliverables
core/life/arcan/crates/arcan-discord/
├── Cargo.toml
└── src/
├── dispatcher.rs # Gateway + routing + session FSM
├── ipc.rs # Unix socket NDJSON protocol
├── session.rs # Session lifecycle management
└── main.rs # CLI entry pointLanguage Rationale
Start TypeScript/Bun (Phase 1-3):
- Reuses
discord.js+@modelcontextprotocol/sdk— identical protocol - Same language as the official plugin = guaranteed compatibility
- 2-3 days to MVP
Migrate to Rust (Phase 4):
- 3-5x lower memory (~40 MB vs ~120 MB)
- No GC pauses for a long-running daemon
- Fits the Agent OS stack (
core/life/) twilight-gatewayfor low-level gateway control- Integration with Lago/Arcan/Spaces infrastructure
Risk Mitigation
| Risk | Mitigation |
|---|---|
| Proxy diverges from official plugin protocol | Pin @modelcontextprotocol/sdk version. E2E test: send → deliver → reply |
| Queue overflow during long spawn | Bounded queue (50 msgs). Oldest dropped. Typing indicator during spawn. |
| Dispatcher crash = all channels down | launchd watchdog restarts. Discord retains history; fetch_messages on reconnect |
| Race: two messages trigger spawn | Mutex on per-channel state. Only IDLE/SUSPENDED → SPAWNING allowed |
| Plugin system changes in Claude Code updates | Fallback: DISCORD_STATE_DIR override works regardless |
Related
- Current bash scripts:
scripts/discord-session-manager.sh,scripts/discord-watchdog.sh - Official Discord plugin:
~/.claude/plugins/marketplaces/.../discord/server.ts - Slash daemon:
scripts/discord-slash-daemon.ts - Session config:
~/.claude/discord-sessions/config.env
Boot Persistence with launchd (macOS)
Create a launchd plist to start the watchdog automatically on login.
Template
Save to ~/Library/LaunchAgents/com.claude-remote-sessions.watchdog.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.claude-remote-sessions.watchdog</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:$HOME/.local/bin</string>
<key>HOME</key>
<string>$HOME</string>
</dict>
<key>ProgramArguments</key>
<array>
<string>$PROJECT_DIR/scripts/discord-watchdog.sh</string>
<string>--daemon</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>$HOME/.claude/discord-sessions/watchdog-launchd.log</string>
<key>StandardErrorPath</key>
<string>$HOME/.claude/discord-sessions/watchdog-launchd.log</string>
</dict>
</plist>Replace $HOME and $PROJECT_DIR with actual paths. Then:
launchctl load ~/Library/LaunchAgents/com.claude-remote-sessions.watchdog.plistImportant
The PATH must include directories for tmux, claude, python3, and curl. Check with which tmux claude python3 curl and add those directories.
Linux (systemd)
[Unit]
Description=Claude Remote Sessions Watchdog
After=network.target
[Service]
ExecStart=/path/to/scripts/discord-watchdog.sh
Restart=always
Environment=HOME=/home/youruser
[Install]
WantedBy=default.targetSave to ~/.config/systemd/user/discord-watchdog.service, then:
systemctl --user enable discord-watchdog
systemctl --user start discord-watchdog#!/usr/bin/env bash
# discord-session-manager.sh — Spawn and track per-channel Claude Code sessions
#
# Each Discord channel/thread gets its own tmux session running Claude Code
# with --channels discord. Discord I/O is handled entirely within Claude.
set -euo pipefail
DISCORD_MAIN_DIR="$HOME/.claude/channels/discord"
SESSIONS_DIR="$HOME/.claude/discord-sessions"
SESSIONS_REGISTRY="$SESSIONS_DIR/sessions.json"
SESSIONS_MD="$SESSIONS_DIR/SESSIONS.md"
CONFIG_FILE="$SESSIONS_DIR/config.env"
WORKDIR_MAP="$SESSIONS_DIR/workdir-map.json"
PROFILES_FILE="$SESSIONS_DIR/profiles.env"
CHANNEL_PROFILES="$SESSIONS_DIR/channel-profiles.json"
TMUX_PREFIX="dc"
# ── Load config ──────────────────────────────────────────────────────────
_load_config() {
if [[ -f "$CONFIG_FILE" ]]; then
set -a; source "$CONFIG_FILE"; set +a
fi
}
_load_config
ALLOWED_USER_ID="${DISCORD_ALLOWED_USER_ID:-}"
GUILD_ID="${DISCORD_GUILD_ID:-}"
WORKDIR_MAP="$SESSIONS_DIR/workdir-map.json"
WORKDIR="${DISCORD_SESSION_WORKDIR:-$HOME}"
_require_config() {
if [[ -z "$ALLOWED_USER_ID" ]]; then
echo "ERROR: DISCORD_ALLOWED_USER_ID not set. Add it to $CONFIG_FILE"
exit 1
fi
if [[ -z "$GUILD_ID" ]]; then
echo "ERROR: DISCORD_GUILD_ID not set. Add it to $CONFIG_FILE"
exit 1
fi
}
# ── Helpers ──────────────────────────────────────────────────────────────
_require_main_config() {
if [[ ! -f "$DISCORD_MAIN_DIR/.env" ]]; then
echo "ERROR: No Discord bot token at $DISCORD_MAIN_DIR/.env"
echo "Run: /discord:configure <token> first"
exit 1
fi
}
_session_name() { echo "${TMUX_PREFIX}-${1}"; }
_state_dir() { echo "${SESSIONS_DIR}/${1}"; }
_ensure_dirs() {
mkdir -p "$SESSIONS_DIR"
[[ -f "$SESSIONS_REGISTRY" ]] || echo '{}' > "$SESSIONS_REGISTRY"
}
_ensure_state_dir() {
local id="$1"
local dir
dir="$(_state_dir "$id")"
mkdir -p "$dir/approved" "$dir/inbox"
[[ -L "$dir/.env" ]] || ln -sf "$DISCORD_MAIN_DIR/.env" "$dir/.env"
cat > "$dir/access.json" <<EOF
{
"dmPolicy": "allowlist",
"allowFrom": ["$ALLOWED_USER_ID"],
"groups": {
"$id": { "requireMention": false, "allowFrom": [] }
},
"pending": {}
}
EOF
}
_registry_set() {
local id="$1" type="$2" name="$3" parent="${4:-}"
_ensure_dirs
python3 -c "
import json, datetime
with open('$SESSIONS_REGISTRY') as f: reg = json.load(f)
reg['$id'] = {
'type': '$type',
'name': '$name',
'tmux': '$(echo $(_session_name $id))',
'parent': '$parent' or None,
'created': datetime.datetime.now().isoformat(timespec='seconds')
}
with open('$SESSIONS_REGISTRY', 'w') as f: json.dump(reg, f, indent=2)
"
_rebuild_md
}
_registry_remove() {
local id="$1"
python3 -c "
import json
with open('$SESSIONS_REGISTRY') as f: reg = json.load(f)
reg.pop('$id', None)
with open('$SESSIONS_REGISTRY', 'w') as f: json.dump(reg, f, indent=2)
"
_rebuild_md
}
_rebuild_md() {
python3 -c "
import json
with open('$SESSIONS_REGISTRY') as f: reg = json.load(f)
lines = ['# Active Discord Sessions\n']
lines.append('| Channel/Thread ID | Type | tmux Session | Name | Parent | Created |')
lines.append('|---|---|---|---|---|---|')
for cid, info in sorted(reg.items(), key=lambda x: x[1].get('created','')):
lines.append(f'| {cid} | {info[\"type\"]} | \`{info[\"tmux\"]}\` | {info[\"name\"]} | {info.get(\"parent\") or \"-\"} | {info[\"created\"]} |')
lines.append('')
with open('$SESSIONS_MD', 'w') as f: f.write('\n'.join(lines))
"
}
_is_alive() { tmux has-session -t "$(_session_name "$1")" 2>/dev/null; }
_resolve_workdir() {
local name="$1"
if [[ -z "$name" || ! -f "$WORKDIR_MAP" ]]; then
echo "$WORKDIR"
return
fi
local mapped
mapped=$(python3 -c "
import json, sys, os
try:
with open('$WORKDIR_MAP') as f:
m = json.load(f)
path = m.get('$name', '')
if path:
print(os.path.expandvars(path))
else:
print('')
except Exception:
print('')
" 2>/dev/null) || mapped=""
if [[ -n "$mapped" ]]; then
echo "$mapped"
else
echo "$WORKDIR"
fi
}
_resolve_profile() {
# Given a channel name, return the profile name (or "default")
local name="$1"
if [[ -z "$name" || ! -f "$CHANNEL_PROFILES" ]]; then
echo "default"
return
fi
local profile
profile=$(python3 -c "
import json
try:
with open('$CHANNEL_PROFILES') as f:
m = json.load(f)
print(m.get('$name', 'default'))
except Exception:
print('default')
" 2>/dev/null) || profile="default"
echo "$profile"
}
_load_profile_env() {
# Parse INI-style profiles.env and return env vars for a given profile
# Output: KEY=VALUE lines (default section merged with profile-specific)
local profile="$1"
if [[ ! -f "$PROFILES_FILE" ]]; then
return
fi
python3 -c "
import re
profile = '$profile'
current_section = None
vars_by_section = {}
with open('$PROFILES_FILE') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
m = re.match(r'^\[(.+)\]$', line)
if m:
current_section = m.group(1)
vars_by_section.setdefault(current_section, {})
continue
if current_section and '=' in line:
key, _, val = line.partition('=')
val = val.strip().strip('\"').strip(\"'\")
vars_by_section[current_section][key.strip()] = val
# Merge: default first, then profile-specific overrides
merged = {}
merged.update(vars_by_section.get('default', {}))
if profile != 'default':
merged.update(vars_by_section.get(profile, {}))
for k, v in merged.items():
print(f'{k}={v}')
" 2>/dev/null
}
_generate_session_id() {
python3 -c "import uuid; print(uuid.uuid4())"
}
_persist_session_id() {
local id="$1" session_uuid="$2"
local dir
dir="$(_state_dir "$id")"
printf '%s' "$session_uuid" > "$dir/.session-id"
}
_read_session_id() {
local id="$1"
local f
f="$(_state_dir "$id")/.session-id"
[[ -f "$f" ]] && cat "$f" || echo ""
}
_read_bot_token() {
sed -n 's/^DISCORD_BOT_TOKEN=//p' "$DISCORD_MAIN_DIR/.env"
}
_fetch_channel_messages() {
local channel_id="$1" limit="${2:-20}"
local token
token="$(_read_bot_token)"
curl -sS -H "Authorization: Bot $token" \
"https://discord.com/api/v10/channels/${channel_id}/messages?limit=${limit}" \
| python3 -c "
import json, sys
msgs = json.load(sys.stdin)
if isinstance(msgs, dict) and 'message' in msgs:
print(f'ERROR: {msgs[\"message\"]}', file=sys.stderr); sys.exit(1)
for m in reversed(msgs):
print(f'[{m[\"timestamp\"][:19]}] {m[\"author\"][\"username\"]}: {m[\"content\"]}')
"
}
_spawn_tmux() {
local id="$1" name="$2" system_prompt="${3:-}" workdir="${4:-$WORKDIR}" session_uuid="${5:-}" profile="${6:-default}"
local session_name state_dir claude_cmd
session_name="$(_session_name "$id")"
state_dir="$(_state_dir "$id")"
# Generate a deterministic session ID if none provided
if [[ -z "$session_uuid" ]]; then
session_uuid="$(_read_session_id "$id")"
fi
if [[ -z "$session_uuid" ]]; then
session_uuid="$(_generate_session_id "$id")"
fi
# Persist the session ID so respawns can resume the conversation
_persist_session_id "$id" "$session_uuid"
# Load profile-specific env vars (overrides global config)
local profile_env=""
profile_env=$(_load_profile_env "$profile")
local oauth_token="${CLAUDE_CODE_OAUTH_TOKEN:-}"
local claude_model="" claude_effort=""
local extra_env_vars=""
if [[ -n "$profile_env" ]]; then
while IFS='=' read -r key val; do
[[ -z "$key" ]] && continue
case "$key" in
CLAUDE_CODE_OAUTH_TOKEN) oauth_token="$val" ;;
CLAUDE_MODEL) claude_model="$val" ;;
CLAUDE_EFFORT) claude_effort="$val" ;;
*) extra_env_vars+=" ${key}='${val}'" ;;
esac
done <<< "$profile_env"
fi
# Persist the profile name for status/respawn
echo "$profile" > "$state_dir/.profile"
claude_cmd="unset ANTHROPIC_API_KEY CLAUDE_API_KEY;"
[[ -n "$oauth_token" ]] && claude_cmd+=" CLAUDE_CODE_OAUTH_TOKEN='$oauth_token'"
[[ -n "$extra_env_vars" ]] && claude_cmd+="$extra_env_vars"
claude_cmd+=" DISCORD_STATE_DIR='$state_dir' claude"
claude_cmd+=" --channels plugin:discord@claude-plugins-official"
claude_cmd+=" --dangerously-skip-permissions"
claude_cmd+=" --session-id '${session_uuid}'"
claude_cmd+=" --name '${name}'"
[[ -n "$claude_model" ]] && claude_cmd+=" --model '${claude_model}'"
[[ -n "$claude_effort" ]] && claude_cmd+=" --effort '${claude_effort}'"
if [[ -n "$system_prompt" ]]; then
local pf="$state_dir/.system-prompt"
printf '%s' "$system_prompt" > "$pf"
claude_cmd+=" --system-prompt \"\$(cat '$pf')\""
fi
echo "$workdir" > "$state_dir/.workdir"
tmux new-session -d -s "$session_name" -c "$workdir" "bash -c '${claude_cmd}'"
echo "$session_name"
}
# ── Commands ─────────────────────────────────────────────────────────────
cmd_spawn() {
local channel_id="" label="" system_prompt="" workdir="" fresh=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) label="$2"; shift 2 ;;
--system-prompt) system_prompt="$2"; shift 2 ;;
--workdir) workdir="$2"; shift 2 ;;
--fresh) fresh=true; shift ;;
*) channel_id="$1"; shift ;;
esac
done
[[ -n "$channel_id" ]] || { echo "Usage: spawn <channel_id> [--name <label>] [--workdir <path>] [--fresh]"; exit 1; }
_require_main_config
_require_config
_ensure_dirs
if _is_alive "$channel_id"; then
echo "ALIVE $(_session_name "$channel_id")"
return 0
fi
local name="${label:-ch-${channel_id: -6}}"
_ensure_state_dir "$channel_id"
# Resolve workdir from map if not explicitly passed
if [[ -z "$workdir" ]]; then
workdir="$(_resolve_workdir "$name")"
fi
# Resolve profile for this channel
local profile
profile="$(_resolve_profile "$name")"
# --fresh: generate a new UUID to start a clean conversation
local session_uuid=""
if $fresh; then
session_uuid="$(python3 -c 'import uuid; print(uuid.uuid4())')"
echo "FRESH new session-id=$session_uuid"
fi
_spawn_tmux "$channel_id" "$name" "$system_prompt" "$workdir" "$session_uuid" "$profile"
_registry_set "$channel_id" "channel" "$name"
echo "SPAWNED $(_session_name "$channel_id") name=$name workdir=${workdir} profile=$profile"
}
cmd_spawn_thread() {
local thread_id="" parent_id="" limit=20 label="" workdir=""
while [[ $# -gt 0 ]]; do
case "$1" in
--limit) limit="$2"; shift 2 ;;
--name) label="$2"; shift 2 ;;
--workdir) workdir="$2"; shift 2 ;;
*)
if [[ -z "$thread_id" ]]; then thread_id="$1"
elif [[ -z "$parent_id" ]]; then parent_id="$1"
fi; shift ;;
esac
done
[[ -n "$thread_id" && -n "$parent_id" ]] || { echo "Usage: spawn-thread <thread_id> <parent_channel_id> [--limit <n>] [--workdir <path>]"; exit 1; }
_require_main_config
_require_config
_ensure_dirs
if _is_alive "$thread_id"; then
echo "ALIVE $(_session_name "$thread_id")"
return 0
fi
local context
context="$(_fetch_channel_messages "$parent_id" "$limit" 2>/dev/null)" || context=""
local prompt="You are continuing a conversation from Discord channel $parent_id.
Prior context from the parent channel:
---
${context:-"(could not fetch parent messages)"}
---
You are now responding in thread $thread_id. Continue naturally."
local name="${label:-th-${thread_id: -6}}"
_ensure_state_dir "$thread_id"
python3 -c "
import json
p = '$(_state_dir "$thread_id")/access.json'
with open(p) as f: cfg = json.load(f)
cfg['groups']['$parent_id'] = {'requireMention': False, 'allowFrom': []}
with open(p, 'w') as f: json.dump(cfg, f, indent=2)
"
# Inherit workdir from parent channel session if not explicitly set
if [[ -z "$workdir" ]]; then
local parent_wd_file
parent_wd_file="$(_state_dir "$parent_id")/.workdir"
if [[ -f "$parent_wd_file" ]]; then
workdir="$(cat "$parent_wd_file")"
fi
fi
# Inherit profile from parent channel
local profile="default"
local parent_profile_file="$(_state_dir "$parent_id")/.profile"
[[ -f "$parent_profile_file" ]] && profile="$(cat "$parent_profile_file")"
_spawn_tmux "$thread_id" "$name" "$prompt" "$workdir" "" "$profile"
_registry_set "$thread_id" "thread" "$name" "$parent_id"
echo "SPAWNED $(_session_name "$thread_id") name=$name parent=$parent_id workdir=${workdir:-$WORKDIR} profile=$profile"
}
cmd_list() {
_ensure_dirs
echo "Discord Sessions:"
python3 -c "
import json, subprocess, os
with open('$SESSIONS_REGISTRY') as f: reg = json.load(f)
if not reg: print(' (none)'); exit()
for cid, info in sorted(reg.items(), key=lambda x: x[1].get('created','')):
alive = subprocess.run(['tmux', 'has-session', '-t', info['tmux']], capture_output=True).returncode == 0
status = 'UP' if alive else 'DOWN'
parent = f' parent={info[\"parent\"]}' if info.get('parent') else ''
profile_file = os.path.join('$SESSIONS_DIR', cid, '.profile')
profile = 'default'
if os.path.isfile(profile_file):
with open(profile_file) as pf:
profile = pf.read().strip()
print(f' [{status:4}] {info[\"tmux\"]:30} {info[\"type\"]:7} {info[\"name\"]:20} profile={profile}{parent}')
"
}
cmd_attach() {
local id="${1:?Usage: attach <channel_id>}"
_is_alive "$id" || { echo "Session not running: $(_session_name "$id")"; exit 1; }
tmux attach -t "$(_session_name "$id")"
}
cmd_kill() {
local id="${1:?Usage: kill <channel_id>}"
local sn
sn="$(_session_name "$id")"
if _is_alive "$id"; then
tmux kill-session -t "$sn"
echo "KILLED $sn"
fi
_registry_remove "$id"
}
cmd_kill_all() {
_ensure_dirs
local killed=0
for sn in $(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^${TMUX_PREFIX}-" || true); do
tmux kill-session -t "$sn"; echo "KILLED $sn"; killed=$((killed + 1))
done
echo '{}' > "$SESSIONS_REGISTRY"
_rebuild_md
echo "Total: $killed"
}
cmd_respawn_dead() {
_ensure_dirs
python3 -c "
import json
with open('$SESSIONS_REGISTRY') as f: reg = json.load(f)
for cid, info in reg.items():
print(f'{cid} {info[\"type\"]} {info[\"name\"]} {info.get(\"parent\") or \"-\"}')
" | while IFS=' ' read -r cid type name parent; do
if ! _is_alive "$cid"; then
# Skip suspended sessions
if [[ -f "$(_state_dir "$cid")/.suspended" ]]; then
continue
fi
echo "RESPAWNING $(_session_name "$cid") ($type: $name)"
_ensure_state_dir "$cid"
local wd=""
[[ -f "$(_state_dir "$cid")/.workdir" ]] && wd="$(cat "$(_state_dir "$cid")/.workdir")"
# Read the persisted session ID so the respawned session resumes the conversation
local session_uuid=""
session_uuid="$(_read_session_id "$cid")"
# Read persisted profile
local profile="default"
[[ -f "$(_state_dir "$cid")/.profile" ]] && profile="$(cat "$(_state_dir "$cid")/.profile")"
if [[ "$type" == "thread" && "$parent" != "-" ]]; then
local pf="$(_state_dir "$cid")/.system-prompt"
local sp=""
[[ -f "$pf" ]] && sp="$(cat "$pf")"
_spawn_tmux "$cid" "$name" "$sp" "$wd" "$session_uuid" "$profile"
else
_spawn_tmux "$cid" "$name" "" "$wd" "$session_uuid" "$profile"
fi
fi
done
}
_is_session_busy() {
# Check if a Claude Code session is actively working (not at the idle prompt)
# Returns 0 (true) if busy, 1 (false) if idle
local sn="$1"
local pane_content
pane_content=$(tmux capture-pane -t "$sn" -p 2>/dev/null | tail -5)
# If the pane shows the idle prompt (❯) with no active indicators, it's idle
# Active indicators: spinning, tool calls, "Churned", "Cooked", streaming text
if echo "$pane_content" | grep -qE '⏺|Churning|Cooking|streaming|Running|SPAWNED|RESPAWNING|Thinking'; then
return 0 # busy
fi
# Check if the last visible line is the idle prompt
if echo "$pane_content" | grep -q '❯'; then
return 1 # idle
fi
# Default: assume busy (safer — don't kill working sessions)
return 0
}
cmd_suspend_idle() {
# Kill sessions that have been idle (no tmux activity) beyond the threshold
# Two checks: (1) tmux pane_last_activity timestamp and (2) Claude is at idle prompt
local idle_minutes="${DISCORD_IDLE_TIMEOUT:-120}"
local idle_seconds=$((idle_minutes * 60))
_ensure_dirs
local suspended=0
local now
now=$(date +%s)
python3 -c "
import json
with open('$SESSIONS_REGISTRY') as f: reg = json.load(f)
for cid, info in reg.items():
print(f'{cid} {info[\"name\"]}')
" | while IFS=' ' read -r cid name; do
if _is_alive "$cid"; then
local sn
sn="$(_session_name "$cid")"
# Skip pinned sessions
if [[ -f "$(_state_dir "$cid")/.no-idle" ]]; then
continue
fi
# Check 1: Is the session actively working? Never suspend busy sessions.
if _is_session_busy "$sn"; then
continue
fi
# Check 2: Has enough idle time passed?
local last_activity
last_activity=$(tmux display-message -t "$sn" -p '#{pane_last_activity}' 2>/dev/null || echo "$now")
local idle_for=$((now - last_activity))
if (( idle_for > idle_seconds )); then
local idle_min=$((idle_for / 60))
echo " SUSPEND ${name} (idle ${idle_min}m)"
tmux kill-session -t "$sn" 2>/dev/null || true
echo "$now" > "$(_state_dir "$cid")/.suspended"
suspended=$((suspended + 1))
fi
fi
done
echo "Suspended $suspended sessions (threshold: ${idle_minutes}m)"
}
cmd_wake() {
# Wake a specific suspended session
local id="${1:?Usage: wake <channel_id>}"
local suspend_file
suspend_file="$(_state_dir "$id")/.suspended"
[[ -f "$suspend_file" ]] && rm -f "$suspend_file"
if ! _is_alive "$id"; then
# Trigger respawn
local name="" wd="" session_uuid=""
name=$(python3 -c "
import json
with open('$SESSIONS_REGISTRY') as f: reg = json.load(f)
print(reg.get('$id', {}).get('name', 'unknown'))
" 2>/dev/null)
[[ -f "$(_state_dir "$id")/.workdir" ]] && wd="$(cat "$(_state_dir "$id")/.workdir")"
session_uuid="$(_read_session_id "$id")"
local profile="default"
[[ -f "$(_state_dir "$id")/.profile" ]] && profile="$(cat "$(_state_dir "$id")/.profile")"
_ensure_state_dir "$id"
_spawn_tmux "$id" "$name" "" "$wd" "$session_uuid" "$profile"
echo "WOKE $(_session_name "$id") name=$name profile=$profile"
else
echo "ALIVE $(_session_name "$id") (not suspended)"
fi
}
cmd_wake_all() {
_ensure_dirs
local woke=0
for sf in "$SESSIONS_DIR"/*/.suspended; do
[[ -f "$sf" ]] || continue
local cid
cid=$(basename "$(dirname "$sf")")
rm -f "$sf"
woke=$((woke + 1))
done
echo "Cleared $woke suspend flags. Run respawn-dead to bring them back."
cmd_respawn_dead
}
cmd_pin() {
local id="${1:?Usage: pin <channel_id>}"
touch "$(_state_dir "$id")/.no-idle"
echo "PINNED $(_session_name "$id") — will not be suspended"
}
cmd_unpin() {
local id="${1:?Usage: unpin <channel_id>}"
rm -f "$(_state_dir "$id")/.no-idle"
echo "UNPINNED $(_session_name "$id")"
}
cmd_cleanup_stale() {
_require_main_config
_ensure_dirs
local token
token="$(_read_bot_token)"
local cleaned=0 checked=0
# Build list of registered channel IDs first, then check each
local ids
ids=$(python3 -c "
import json
with open('$SESSIONS_REGISTRY') as f: reg = json.load(f)
for cid, info in reg.items():
print(f'{cid} {info[\"type\"]} {info[\"name\"]}')
")
if [[ -z "$ids" ]]; then
echo "No registered sessions to check."
return 0
fi
while IFS=' ' read -r cid type name; do
checked=$((checked + 1))
local response http_code body
response=$(curl -sS -w "\n%{http_code}" -H "Authorization: Bot $token" \
"https://discord.com/api/v10/channels/${cid}" 2>/dev/null) || true
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | sed '$d')
local stale=false reason=""
if [[ "$http_code" == "404" ]]; then
stale=true
reason="channel deleted (404)"
elif echo "$body" | python3 -c "
import json, sys
try:
ch = json.load(sys.stdin)
md = ch.get('thread_metadata', {})
if md.get('archived', False):
sys.exit(0)
sys.exit(1)
except:
sys.exit(1)
" 2>/dev/null; then
stale=true
reason="thread archived"
fi
if [[ "$stale" == "true" ]]; then
echo " STALE ${name} (${cid}) — ${reason}"
local sn
sn="$(_session_name "$cid")"
if _is_alive "$cid"; then
tmux kill-session -t "$sn" 2>/dev/null || true
echo " killed tmux session $sn"
fi
_registry_remove "$cid"
cleaned=$((cleaned + 1))
else
echo " OK ${name} (${cid})"
fi
done <<< "$ids"
echo ""
echo "Checked $checked sessions, cleaned $cleaned stale"
}
cmd_discover() {
_require_main_config
_require_config
_ensure_dirs
local token
token="$(_read_bot_token)"
local manager_path="$0"
curl -sS -H "Authorization: Bot $token" \
"https://discord.com/api/v10/guilds/${GUILD_ID}/channels" \
| REGISTRY="$SESSIONS_REGISTRY" MANAGER="$manager_path" WORKDIR_MAP_FILE="$WORKDIR_MAP" DEFAULT_WORKDIR="$WORKDIR" python3 -c "
import json, subprocess, sys, os
channels = json.load(sys.stdin)
registry = os.environ['REGISTRY']
manager = os.environ['MANAGER']
workdir_map_file = os.environ['WORKDIR_MAP_FILE']
default_workdir = os.environ['DEFAULT_WORKDIR']
# Load workdir map (graceful fallback if missing or invalid)
workdir_map = {}
try:
with open(workdir_map_file) as f:
workdir_map = json.load(f)
except Exception:
pass
def resolve_workdir(name):
path = workdir_map.get(name, '')
if path:
return os.path.expandvars(path)
return default_workdir
with open(registry) as f:
reg = json.load(f)
text_channels = [c for c in channels if c['type'] in (0, 5)]
new_count = 0
for ch in sorted(text_channels, key=lambda c: c.get('position', 0)):
cid = ch['id']
name = ch['name']
if cid in reg:
print(f' EXISTS {name:25} ({cid})')
else:
wd = resolve_workdir(name)
wd_note = f' workdir={wd}' if wd != default_workdir else ''
print(f' NEW {name:25} ({cid}) — spawning...{wd_note}')
cmd = [manager, 'spawn', cid, '--name', name]
if wd != default_workdir:
cmd += ['--workdir', wd]
subprocess.run(cmd, check=True)
new_count += 1
print(f'\nDiscovered {len(text_channels)} channels, spawned {new_count} new sessions')
"
}
cmd_discover_threads() {
_require_main_config
_require_config
_ensure_dirs
local token
token="$(_read_bot_token)"
curl -sS -H "Authorization: Bot $token" \
"https://discord.com/api/v10/guilds/${GUILD_ID}/threads/active" \
| REGISTRY="$SESSIONS_REGISTRY" MANAGER="$0" WORKDIR_MAP_FILE="$WORKDIR_MAP" DEFAULT_WORKDIR="$WORKDIR" python3 -c "
import json, subprocess, sys, os
data = json.load(sys.stdin)
threads = data.get('threads', [])
registry = os.environ['REGISTRY']
manager = os.environ['MANAGER']
workdir_map_file = os.environ['WORKDIR_MAP_FILE']
default_workdir = os.environ['DEFAULT_WORKDIR']
# Load workdir map (graceful fallback if missing or invalid)
workdir_map = {}
try:
with open(workdir_map_file) as f:
workdir_map = json.load(f)
except Exception:
pass
def resolve_workdir(name):
path = workdir_map.get(name, '')
if path:
return os.path.expandvars(path)
return default_workdir
with open(registry) as f:
reg = json.load(f)
new_count = 0
for t in sorted(threads, key=lambda x: x.get('name', '')):
tid = t['id']
name = t['name'][:30]
parent = t['parent_id']
if tid in reg:
print(f' EXISTS {name:30} ({tid}) parent={parent}')
else:
wd = resolve_workdir(name)
wd_note = f' workdir={wd}' if wd != default_workdir else ''
print(f' NEW {name:30} ({tid}) parent={parent} — spawning...{wd_note}')
cmd = [manager, 'spawn-thread', tid, parent, '--name', name]
if wd != default_workdir:
cmd += ['--workdir', wd]
subprocess.run(cmd, check=True)
new_count += 1
print(f'\nDiscovered {len(threads)} active threads, spawned {new_count} new sessions')
"
}
_update_workdir_map() {
# Add or update a channel name → workdir entry in workdir-map.json
local name="$1" workdir="$2"
[[ -z "$name" || -z "$workdir" ]] && return 0
python3 -c "
import json, os
path = '$WORKDIR_MAP'
try:
with open(path) as f: m = json.load(f)
except Exception:
m = {}
m['$name'] = '$workdir'
with open(path, 'w') as f: json.dump(m, f, indent=2)
" 2>/dev/null || true
}
cmd_create_channel() {
local name="" workdir=""
while [[ $# -gt 0 ]]; do
case "$1" in
--workdir) workdir="$2"; shift 2 ;;
*) name="$1"; shift ;;
esac
done
[[ -n "$name" ]] || { echo "Usage: create-channel <name> [--workdir <path>]"; exit 1; }
_require_main_config
_require_config
local token
token="$(_read_bot_token)"
local result
result=$(curl -sS -X POST \
-H "Authorization: Bot $token" \
-H "Content-Type: application/json" \
-d "{\"name\": \"$name\", \"type\": 0}" \
"https://discord.com/api/v10/guilds/${GUILD_ID}/channels" \
| python3 -c "import json,sys; ch=json.load(sys.stdin); print(f'{ch[\"id\"]} {ch[\"name\"]}')")
local channel_id channel_name
channel_id="${result%% *}"
channel_name="${result#* }"
# Update workdir map if workdir specified
if [[ -n "$workdir" ]]; then
_update_workdir_map "$channel_name" "$workdir"
echo "CREATED #$channel_name ($channel_id) workdir=$workdir"
cmd_spawn "$channel_id" --name "$channel_name" --workdir "$workdir"
else
local resolved
resolved="$(_resolve_workdir "$channel_name")"
echo "CREATED #$channel_name ($channel_id) workdir=$resolved"
cmd_spawn "$channel_id" --name "$channel_name" --workdir "$resolved"
fi
}
_count_active_sessions() {
_ensure_dirs
python3 -c "
import json, subprocess
with open('$SESSIONS_REGISTRY') as f: reg = json.load(f)
count = sum(1 for info in reg.values()
if subprocess.run(['tmux', 'has-session', '-t', info['tmux']], capture_output=True).returncode == 0)
print(count)
"
}
cmd_motd() {
local up
up="$(_count_active_sessions)"
local total
total=$(python3 -c "import json; print(len(json.load(open('$SESSIONS_REGISTRY'))))" 2>/dev/null || echo 0)
echo "$up/$total sessions active | watchdog: $(./scripts/discord-watchdog.sh --status 2>/dev/null | head -1)"
}
cmd_set_channel_topic() {
local STATUS_CHANNEL_ID="${DISCORD_STATUS_CHANNEL_ID:-}"
[[ -z "$STATUS_CHANNEL_ID" ]] && return 0
_require_main_config
local token
token="$(_read_bot_token)"
local topic
topic="$(cmd_motd)"
local escaped_topic
escaped_topic=$(python3 -c "import json,sys; print(json.dumps('$topic'))")
curl -sS -X PATCH \
-H "Authorization: Bot $token" \
-H "Content-Type: application/json" \
-d "{\"topic\": $escaped_topic}" \
"https://discord.com/api/v10/channels/${STATUS_CHANNEL_ID}" > /dev/null 2>&1 || true
}
cmd_status() {
echo "Discord Session Manager"
echo "═══════════════════════"
[[ -f "$DISCORD_MAIN_DIR/.env" ]] && echo "Bot token: configured" || echo "Bot token: MISSING"
[[ -n "$GUILD_ID" ]] && echo "Guild: $GUILD_ID" || echo "Guild: NOT SET"
echo "Sessions dir: $SESSIONS_DIR"
echo ""
cmd_list
}
cmd_init() {
# Interactive setup
_ensure_dirs
echo "Discord Sessions — Setup"
echo "========================"
echo ""
if [[ -f "$CONFIG_FILE" ]]; then
echo "Config exists at $CONFIG_FILE"
cat "$CONFIG_FILE"
echo ""
read -p "Overwrite? [y/N] " -r
[[ "$REPLY" =~ ^[Yy]$ ]] || { echo "Kept existing config."; return 0; }
fi
read -p "Your Discord user ID: " uid
read -p "Your Discord guild (server) ID: " gid
read -p "Default workdir [$HOME]: " wd
wd="${wd:-$HOME}"
cat > "$CONFIG_FILE" <<EOF
DISCORD_ALLOWED_USER_ID="$uid"
DISCORD_GUILD_ID="$gid"
DISCORD_SESSION_WORKDIR="$wd"
EOF
echo ""
echo "Config saved to $CONFIG_FILE"
echo "Next: ./scripts/discord-session-manager.sh discover-all"
}
# ── Main ─────────────────────────────────────────────────────────────────
case "${1:-help}" in
spawn) shift; cmd_spawn "$@" ;;
spawn-thread) shift; cmd_spawn_thread "$@" ;;
discover) cmd_discover ;;
discover-threads) cmd_discover_threads ;;
discover-all) cmd_discover; echo ""; cmd_discover_threads ;;
cleanup-stale) cmd_cleanup_stale ;;
suspend-idle) cmd_suspend_idle ;;
wake) shift; cmd_wake "$@" ;;
wake-all) cmd_wake_all ;;
pin) shift; cmd_pin "$@" ;;
unpin) shift; cmd_unpin "$@" ;;
create-channel) shift; cmd_create_channel "$@" ;;
motd) cmd_motd ;;
set-status) cmd_set_channel_topic ;;
init) cmd_init ;;
list) cmd_list ;;
attach) shift; cmd_attach "$@" ;;
kill) shift; cmd_kill "$@" ;;
kill-all) cmd_kill_all ;;
respawn-dead) cmd_respawn_dead ;;
status) cmd_status ;;
help|--help|-h)
cat <<'HELP'
Discord Session Manager — per-channel Claude Code sessions via tmux
init Setup — configure user ID, guild ID, workdir
spawn <channel_id> [--name <label>] [--workdir <path>] [--system-prompt <text>] [--fresh]
spawn-thread <thread_id> <parent_channel_id> [--limit <n>] [--name <label>] [--workdir <path>]
discover Auto-detect guild channels and spawn sessions
discover-threads Auto-detect active threads and spawn sessions
discover-all Both channels + threads
cleanup-stale Kill sessions for deleted channels or archived threads
suspend-idle Suspend sessions idle beyond DISCORD_IDLE_TIMEOUT (default: 30m)
wake <id> Wake a suspended session
wake-all Wake all suspended sessions
pin <id> Pin a session — never suspend it
unpin <id> Unpin — allow idle suspension again
create-channel <name> [--workdir <path>] Create a Discord channel + spawn its session
motd Show active session count and watchdog status
set-status Update the status channel topic with session count
list List sessions with UP/DOWN status
attach <id> Attach to a tmux session
kill <id> Kill and deregister a session
kill-all Kill all Discord sessions
respawn-dead Respawn any DOWN sessions (used by watchdog)
status Overview
Channel-to-workdir mapping:
Create ~/.claude/discord-sessions/workdir-map.json to map channel names
to project directories. discover and discover-threads will use the mapped
workdir when spawning new sessions. Example:
{ "general": "$HOME/myproject", "health-os": "$HOME/apps/healthOS" }
Flags:
--fresh (spawn only) Start a fresh conversation — generates a new
session ID instead of resuming the previous one
HELP
;;
*) echo "Unknown: $1 (try --help)"; exit 1 ;;
esac
#!/usr/bin/env bun
/**
* discord-slash-daemon.ts — Discord slash command daemon for Claude Remote Sessions
*
* A single Bun process that registers and handles slash commands, bridging them
* to the tmux-managed Claude Code sessions via discord-session-manager.sh.
*
* Startup:
* bun scripts/discord-slash-daemon.ts
*
* Or via watchdog (auto-managed in dc-slash-daemon tmux session).
*/
import {
Client,
GatewayIntentBits,
REST,
Routes,
EmbedBuilder,
AttachmentBuilder,
InteractionType,
ApplicationCommandType,
ApplicationCommandOptionType,
type ChatInputCommandInteraction,
type AutocompleteInteraction,
} from "discord.js";
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import { $ } from "bun";
// ── Config ────────────────────────────────────────────────────────────────
const DISCORD_ENV_PATH = join(homedir(), ".claude/channels/discord/.env");
const CONFIG_ENV_PATH = join(homedir(), ".claude/discord-sessions/config.env");
const SESSIONS_DIR = join(homedir(), ".claude/discord-sessions");
const SESSIONS_REGISTRY = join(SESSIONS_DIR, "sessions.json");
const WORKDIR_MAP_PATH = join(SESSIONS_DIR, "workdir-map.json");
const MANAGER_PATH = join(import.meta.dir, "discord-session-manager.sh");
function readEnvValue(filePath: string, key: string): string {
if (!existsSync(filePath)) return "";
const content = readFileSync(filePath, "utf8");
const match = content.match(new RegExp(`^${key}=["']?(.+?)["']?$`, "m"));
return match?.[1] ?? "";
}
const BOT_TOKEN = readEnvValue(DISCORD_ENV_PATH, "DISCORD_BOT_TOKEN");
if (!BOT_TOKEN) {
console.error(
`[slash-daemon] FATAL: No DISCORD_BOT_TOKEN found in ${DISCORD_ENV_PATH}`
);
console.error("Run: /discord:configure <token> first");
process.exit(1);
}
const GUILD_ID = readEnvValue(CONFIG_ENV_PATH, "DISCORD_GUILD_ID");
if (!GUILD_ID) {
console.error(
`[slash-daemon] FATAL: No DISCORD_GUILD_ID found in ${CONFIG_ENV_PATH}`
);
process.exit(1);
}
// ── Helpers ───────────────────────────────────────────────────────────────
function readSessionsRegistry(): Record<
string,
{
type: string;
name: string;
tmux: string;
parent: string | null;
created: string;
}
> {
try {
if (!existsSync(SESSIONS_REGISTRY)) return {};
return JSON.parse(readFileSync(SESSIONS_REGISTRY, "utf8"));
} catch {
return {};
}
}
function readWorkdirMap(): Record<string, string> {
try {
if (!existsSync(WORKDIR_MAP_PATH)) return {};
return JSON.parse(readFileSync(WORKDIR_MAP_PATH, "utf8"));
} catch {
return {};
}
}
function findSessionByChannel(
channelId: string
): {
id: string;
type: string;
name: string;
tmux: string;
parent: string | null;
created: string;
} | null {
const registry = readSessionsRegistry();
if (registry[channelId]) {
return { id: channelId, ...registry[channelId] };
}
return null;
}
async function isTmuxAlive(sessionName: string): Promise<boolean> {
try {
const result = await $`tmux has-session -t ${sessionName} 2>/dev/null`.nothrow().quiet();
return result.exitCode === 0;
} catch {
return false;
}
}
async function readWorkdir(channelId: string): Promise<string> {
const wdFile = join(SESSIONS_DIR, channelId, ".workdir");
try {
if (existsSync(wdFile)) return readFileSync(wdFile, "utf8").trim();
} catch {}
return "(unknown)";
}
async function readSessionId(channelId: string): Promise<string> {
const sidFile = join(SESSIONS_DIR, channelId, ".session-id");
try {
if (existsSync(sidFile)) return readFileSync(sidFile, "utf8").trim();
} catch {}
return "(none)";
}
function readProfile(channelId: string): string {
const profileFile = join(SESSIONS_DIR, channelId, ".profile");
try {
if (existsSync(profileFile)) return readFileSync(profileFile, "utf8").trim();
} catch {}
return "default";
}
async function runManager(...args: string[]): Promise<string> {
const result = await $`bash ${MANAGER_PATH} ${args}`.nothrow().quiet();
const out = result.stdout?.toString()?.trim();
const err = result.stderr?.toString()?.trim();
return out || err || (result.exitCode === 0 ? "OK" : "Command failed");
}
function escapeForTmux(input: string): string {
// Escape single quotes and newlines for tmux send-keys
return input
.replace(/'/g, "'\\''")
.replace(/\n/g, " ")
.replace(/\r/g, "");
}
function formatUptime(createdIso: string): string {
try {
const created = new Date(createdIso);
const now = new Date();
const diffMs = now.getTime() - created.getTime();
const hours = Math.floor(diffMs / 3600000);
const minutes = Math.floor((diffMs % 3600000) / 60000);
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days}d ${hours % 24}h`;
}
return `${hours}h ${minutes}m`;
} catch {
return "unknown";
}
}
function extractSkillDescription(skillMdPath: string, fallback: string): string {
try {
const content = readFileSync(skillMdPath, "utf8");
const descMatch = content.match(
/^description:\s*>?\s*\n?\s*(.+?)(?:\n\S|\n---)/ms
);
return descMatch
? descMatch[1].replace(/\n\s*/g, " ").trim().slice(0, 90)
: fallback;
} catch {
return fallback;
}
}
function scanAvailableSkills(): Array<{ name: string; description: string }> {
const skills: Map<string, string> = new Map();
// 1. User-installed skills (~/.claude/skills/ and ~/.agents/skills/)
const userSkillDirs = [
join(homedir(), ".claude/skills"),
join(homedir(), ".agents/skills"),
];
for (const dir of userSkillDirs) {
if (!existsSync(dir)) continue;
try {
for (const entry of readdirSync(dir)) {
if (skills.has(entry)) continue;
const skillMd = join(dir, entry, "SKILL.md");
if (!existsSync(skillMd)) continue;
skills.set(entry, extractSkillDescription(skillMd, entry));
}
} catch {}
}
// 2. Plugin skills (~/.claude/plugins/installed_plugins.json → each plugin's skills/)
const installedPluginsPath = join(
homedir(),
".claude/plugins/installed_plugins.json"
);
if (existsSync(installedPluginsPath)) {
try {
const pluginsData = JSON.parse(
readFileSync(installedPluginsPath, "utf8")
);
const plugins = pluginsData?.plugins ?? {};
for (const [pluginKey, installs] of Object.entries(plugins)) {
const installArr = installs as Array<{ installPath: string }>;
if (!installArr?.length) continue;
const installPath = installArr[0].installPath;
// Plugin name is the part before @marketplace (e.g., "superpowers" from "superpowers@claude-plugins-official")
const pluginName = pluginKey.split("@")[0];
const pluginSkillsDir = join(installPath, "skills");
if (!existsSync(pluginSkillsDir)) continue;
try {
for (const entry of readdirSync(pluginSkillsDir)) {
const skillMd = join(pluginSkillsDir, entry, "SKILL.md");
if (!existsSync(skillMd) || !statSync(join(pluginSkillsDir, entry)).isDirectory()) continue;
const namespacedName = `${pluginName}:${entry}`;
if (skills.has(namespacedName)) continue;
skills.set(
namespacedName,
extractSkillDescription(skillMd, entry)
);
}
} catch {}
}
} catch {}
}
return Array.from(skills.entries())
.map(([name, description]) => ({ name, description }))
.sort((a, b) => a.name.localeCompare(b.name));
}
// Cache skills list (refresh every 5 minutes)
let _skillsCache: Array<{ name: string; description: string }> = [];
let _skillsCacheTime = 0;
function getSkillsWithCache(): Array<{ name: string; description: string }> {
const now = Date.now();
if (now - _skillsCacheTime > 5 * 60 * 1000 || _skillsCache.length === 0) {
_skillsCache = scanAvailableSkills();
_skillsCacheTime = now;
console.log(
`[slash-daemon] Skills cache refreshed: ${_skillsCache.length} skills`
);
}
return _skillsCache;
}
// Scan project-local skills for a specific workdir
function scanProjectSkills(
workdir: string
): Array<{ name: string; description: string }> {
const skills: Array<{ name: string; description: string }> = [];
const seen = new Set<string>();
for (const subdir of [
join(workdir, ".claude", "skills"),
join(workdir, "skills"),
]) {
if (!existsSync(subdir)) continue;
try {
for (const entry of readdirSync(subdir)) {
if (seen.has(entry)) continue;
const skillMd = join(subdir, entry, "SKILL.md");
if (!existsSync(skillMd)) continue;
seen.add(entry);
skills.push({
name: entry,
description: extractSkillDescription(skillMd, entry),
});
}
} catch {}
}
return skills.sort((a, b) => a.name.localeCompare(b.name));
}
// ── Slash Command Definitions ─────────────────────────────────────────────
const SLASH_COMMANDS = [
{
name: "session",
description: "Manage the Claude Code session for this channel",
type: ApplicationCommandType.ChatInput,
options: [
{
name: "status",
description: "Show session info (workdir, uptime, name) for this channel",
type: ApplicationCommandOptionType.Subcommand,
},
{
name: "restart",
description: "Kill and respawn the session",
type: ApplicationCommandOptionType.Subcommand,
options: [
{
name: "fresh",
description: "Start a clean conversation (new session ID)",
type: ApplicationCommandOptionType.Boolean,
required: false,
},
],
},
{
name: "refresh",
description:
"Kill and respawn with same session-id (picks up new skills/CLAUDE.md)",
type: ApplicationCommandOptionType.Subcommand,
},
{
name: "kill",
description: "Kill the session",
type: ApplicationCommandOptionType.Subcommand,
},
{
name: "wake",
description: "Wake a suspended session",
type: ApplicationCommandOptionType.Subcommand,
},
{
name: "workdir",
description: "Show or change the session's working directory",
type: ApplicationCommandOptionType.Subcommand,
options: [
{
name: "path",
description: "New working directory (leave empty to show current)",
type: ApplicationCommandOptionType.String,
required: false,
autocomplete: true,
},
],
},
{
name: "watch",
description: "Stream agent activity to this channel (toggle on/off)",
type: ApplicationCommandOptionType.Subcommand,
options: [
{
name: "mode",
description: "Display mode (default: live)",
type: ApplicationCommandOptionType.String,
required: false,
choices: [
{ name: "live — updating snapshot of current state", value: "live" },
{ name: "log — timestamped history of all activity", value: "log" },
],
},
],
},
{
name: "snapshot",
description: "Current pane view as a scrollable text file",
type: ApplicationCommandOptionType.Subcommand,
},
{
name: "history",
description: "Full session scrollback (entire conversation) as a text file",
type: ApplicationCommandOptionType.Subcommand,
},
{
name: "send",
description: "Send a keypress or text to the session (for prompts, approvals, feedback)",
type: ApplicationCommandOptionType.Subcommand,
options: [
{
name: "input",
description: "Text to send, or a key: yes, no, esc, enter, tab, up, down, 1, 2, 3",
type: ApplicationCommandOptionType.String,
required: true,
autocomplete: true,
},
],
},
{
name: "profile",
description: "Show or switch the auth profile for this channel",
type: ApplicationCommandOptionType.Subcommand,
options: [
{
name: "name",
description: "Profile to switch to (leave empty to show current)",
type: ApplicationCommandOptionType.String,
required: false,
autocomplete: true,
},
],
},
],
},
{
name: "skills",
description: "Manage skills for this channel's Claude session",
type: ApplicationCommandType.ChatInput,
options: [
{
name: "list",
description: "List installed skills for this channel's session",
type: ApplicationCommandOptionType.Subcommand,
},
{
name: "install",
description: "Install a skill (runs npx skills add in the session)",
type: ApplicationCommandOptionType.Subcommand,
options: [
{
name: "name",
description: "Skill name to install",
type: ApplicationCommandOptionType.String,
required: true,
},
],
},
],
},
{
name: "discover",
description: "Trigger channel/thread discovery",
type: ApplicationCommandType.ChatInput,
},
{
name: "ask",
description: "Send a prompt to the channel's Claude session",
type: ApplicationCommandType.ChatInput,
options: [
{
name: "prompt",
description: "The prompt to send",
type: ApplicationCommandOptionType.String,
required: true,
},
],
},
{
name: "run",
description: "Run a Claude Code skill (slash command) in this channel's session",
type: ApplicationCommandType.ChatInput,
options: [
{
name: "skill",
description: "Skill name (e.g. commit, review-pr, ship)",
type: ApplicationCommandOptionType.String,
required: true,
autocomplete: true,
},
{
name: "args",
description: "Optional arguments to pass to the skill",
type: ApplicationCommandOptionType.String,
required: false,
},
],
},
];
// ── Command Handlers ──────────────────────────────────────────────────────
async function handleSessionStatus(
interaction: ChatInputCommandInteraction
): Promise<string> {
const channelId = interaction.channelId;
const session = findSessionByChannel(channelId);
if (!session) {
return "No session registered for this channel. Try `/discover` first.";
}
const alive = await isTmuxAlive(session.tmux);
const workdir = await readWorkdir(channelId);
const sessionId = await readSessionId(channelId);
const profile = readProfile(channelId);
const uptime = formatUptime(session.created);
const isSuspended = existsSync(
join(SESSIONS_DIR, channelId, ".suspended")
);
let status = alive ? "UP" : "DOWN";
if (isSuspended) status = "SUSPENDED";
const embed = new EmbedBuilder()
.setTitle(`Session: ${session.name}`)
.setColor(alive ? 0x00ff00 : isSuspended ? 0xffaa00 : 0xff0000)
.addFields(
{ name: "Status", value: status, inline: true },
{ name: "Type", value: session.type, inline: true },
{ name: "Profile", value: `\`${profile}\``, inline: true },
{ name: "Uptime", value: uptime, inline: true },
{ name: "tmux", value: `\`${session.tmux}\``, inline: true },
{ name: "Session ID", value: `\`${sessionId.slice(0, 8)}...\``, inline: true },
{ name: "Workdir", value: `\`${workdir}\``, inline: false }
);
if (session.parent) {
embed.addFields({
name: "Parent",
value: `<#${session.parent}>`,
inline: true,
});
}
// Return the embed as a serialized instruction — we'll handle this specially
return JSON.stringify({ embed: embed.toJSON() });
}
function clearSessionId(channelId: string): void {
const sidFile = join(SESSIONS_DIR, channelId, ".session-id");
try {
if (existsSync(sidFile)) {
const { unlinkSync } = require("fs");
unlinkSync(sidFile);
}
} catch {}
}
async function handleSessionRestart(
interaction: ChatInputCommandInteraction
): Promise<string> {
const channelId = interaction.channelId;
const session = findSessionByChannel(channelId);
if (!session) {
return "No session registered for this channel. Try `/discover` first.";
}
const fresh = interaction.options.getBoolean("fresh") ?? false;
// Kill existing session
await runManager("kill", channelId);
// Clear stale session-id lock — Claude Code doesn't release on kill
clearSessionId(channelId);
// Respawn
const args = ["spawn", channelId, "--name", session.name];
if (fresh) args.push("--fresh");
const workdir = await readWorkdir(channelId);
if (workdir && workdir !== "(unknown)") {
args.push("--workdir", workdir);
}
const output = await runManager(...args);
const freshNote = fresh ? " (fresh conversation)" : " (resumed conversation)";
return `Session restarted${freshNote}\n\`\`\`\n${output}\n\`\`\``;
}
async function handleSessionRefresh(
interaction: ChatInputCommandInteraction
): Promise<string> {
const channelId = interaction.channelId;
const session = findSessionByChannel(channelId);
if (!session) {
return "No session registered for this channel. Try `/discover` first.";
}
// Kill and respawn — picks up new skills/CLAUDE.md
await runManager("kill", channelId);
// Clear stale session-id lock — Claude Code doesn't release on kill
clearSessionId(channelId);
const args = ["spawn", channelId, "--name", session.name];
const workdir = await readWorkdir(channelId);
if (workdir && workdir !== "(unknown)") {
args.push("--workdir", workdir);
}
const output = await runManager(...args);
return `Session refreshed (picks up new skills/CLAUDE.md)\n\`\`\`\n${output}\n\`\`\``;
}
async function handleSessionKill(
interaction: ChatInputCommandInteraction
): Promise<string> {
const channelId = interaction.channelId;
const session = findSessionByChannel(channelId);
if (!session) {
return "No session registered for this channel.";
}
const output = await runManager("kill", channelId);
return `Session killed.\n\`\`\`\n${output}\n\`\`\``;
}
async function handleSessionWake(
interaction: ChatInputCommandInteraction
): Promise<string> {
const channelId = interaction.channelId;
const session = findSessionByChannel(channelId);
if (!session) {
return "No session registered for this channel. Try `/discover` first.";
}
const output = await runManager("wake", channelId);
return `\`\`\`\n${output}\n\`\`\``;
}
async function handleSessionWorkdir(
interaction: ChatInputCommandInteraction
): Promise<string> {
const channelId = interaction.channelId;
const session = findSessionByChannel(channelId);
if (!session) {
return "No session registered for this channel. Try `/discover` first.";
}
const newPath = interaction.options.getString("path");
if (!newPath) {
// Show current workdir
const workdir = await readWorkdir(channelId);
return `Current workdir: \`${workdir}\``;
}
// Change workdir: kill + respawn with new workdir
await runManager("kill", channelId);
clearSessionId(channelId);
const output = await runManager(
"spawn",
channelId,
"--name",
session.name,
"--workdir",
newPath
);
return `Workdir changed to \`${newPath}\`. Session respawned.\n\`\`\`\n${output}\n\`\`\``;
}
function readChannelProfiles(): Record<string, string> {
const cpFile = join(SESSIONS_DIR, "channel-profiles.json");
try {
if (existsSync(cpFile)) return JSON.parse(readFileSync(cpFile, "utf8"));
} catch {}
return {};
}
function listAvailableProfiles(): string[] {
const profilesFile = join(SESSIONS_DIR, "profiles.env");
try {
if (!existsSync(profilesFile)) return ["default"];
const content = readFileSync(profilesFile, "utf8");
const profiles: string[] = ["default"];
for (const line of content.split("\n")) {
const m = line.trim().match(/^\[(.+)\]$/);
if (m && m[1] !== "default") profiles.push(m[1]);
}
return profiles;
} catch {
return ["default"];
}
}
function writeChannelProfiles(profiles: Record<string, string>): void {
const cpFile = join(SESSIONS_DIR, "channel-profiles.json");
writeFileSync(cpFile, JSON.stringify(profiles, null, 2) + "\n");
}
async function handleSessionProfile(
interaction: ChatInputCommandInteraction
): Promise<string> {
const channelId = interaction.channelId;
const session = findSessionByChannel(channelId);
if (!session) {
return "No session registered for this channel. Try `/discover` first.";
}
const newProfile = interaction.options.getString("name");
if (!newProfile) {
// Show current profile and available profiles
const current = readProfile(channelId);
const available = listAvailableProfiles();
return `Current profile: \`${current}\`\nAvailable: ${available.map(p => `\`${p}\``).join(", ")}\n\nUse \`/session profile name:<profile>\` to switch.`;
}
// Validate profile exists
const available = listAvailableProfiles();
if (!available.includes(newProfile)) {
return `Profile \`${newProfile}\` not found. Available: ${available.map(p => `\`${p}\``).join(", ")}`;
}
// Update channel-profiles.json
const channelProfiles = readChannelProfiles();
channelProfiles[session.name] = newProfile;
writeChannelProfiles(channelProfiles);
// Kill + respawn with same session-id (preserves history)
await runManager("kill", channelId);
// Don't clear session-id — we want to preserve conversation history
const output = await runManager(
"spawn",
channelId,
"--name",
session.name
);
return `Profile switched to \`${newProfile}\`. Session respawned with same history.\n\`\`\`\n${output}\n\`\`\``;
}
async function handleSkillsList(
interaction: ChatInputCommandInteraction
): Promise<string> {
const channelId = interaction.channelId;
const session = findSessionByChannel(channelId);
if (!session) {
return "No session registered for this channel. Try `/discover` first.";
}
const workdir = await readWorkdir(channelId);
// Collect skills by category
const projectSkills: string[] = [];
const globalSkills: string[] = [];
const pluginSkills: string[] = [];
// 1. Project-local skills (workdir/.claude/skills/ and workdir/skills/)
if (workdir && workdir !== "(unknown)") {
for (const subdir of [join(workdir, ".claude", "skills"), join(workdir, "skills")]) {
if (!existsSync(subdir)) continue;
try {
for (const entry of readdirSync(subdir)) {
if (existsSync(join(subdir, entry, "SKILL.md"))) {
if (!projectSkills.includes(entry)) projectSkills.push(entry);
}
}
} catch {}
}
}
// 2. Global user skills (~/.claude/skills/ and ~/.agents/skills/)
const seen = new Set<string>();
for (const dir of [join(homedir(), ".claude/skills"), join(homedir(), ".agents/skills")]) {
if (!existsSync(dir)) continue;
try {
for (const entry of readdirSync(dir)) {
if (seen.has(entry)) continue;
if (existsSync(join(dir, entry, "SKILL.md"))) {
seen.add(entry);
globalSkills.push(entry);
}
}
} catch {}
}
// 3. Plugin skills
const installedPluginsPath = join(homedir(), ".claude/plugins/installed_plugins.json");
if (existsSync(installedPluginsPath)) {
try {
const pluginsData = JSON.parse(readFileSync(installedPluginsPath, "utf8"));
for (const [key, installs] of Object.entries(pluginsData?.plugins ?? {})) {
const arr = installs as Array<{ installPath: string }>;
if (!arr?.length) continue;
const pluginName = key.split("@")[0];
const sd = join(arr[0].installPath, "skills");
if (!existsSync(sd)) continue;
try {
for (const entry of readdirSync(sd)) {
if (existsSync(join(sd, entry, "SKILL.md")) && statSync(join(sd, entry)).isDirectory()) {
pluginSkills.push(`${pluginName}:${entry}`);
}
}
} catch {}
}
} catch {}
}
const total = projectSkills.length + globalSkills.length + pluginSkills.length;
if (total === 0) {
return "No skills found.";
}
const parts: string[] = [];
if (projectSkills.length > 0) {
parts.push(`**Project** (${projectSkills.length}): ${projectSkills.map(s => `\`${s}\``).join(", ")}`);
}
parts.push(`**Global** (${globalSkills.length}): ${globalSkills.length} skills installed`);
parts.push(`**Plugins** (${pluginSkills.length}): ${[...new Set(pluginSkills.map(s => s.split(":")[0]))].map(p => `\`${p}:*\``).join(", ")}`);
parts.push(`\nTotal: **${total}** skills | Workdir: \`${workdir}\``);
parts.push(`Use \`/run\` with autocomplete to invoke any skill.`);
return parts.join("\n");
}
async function handleSkillsInstall(
interaction: ChatInputCommandInteraction
): Promise<string> {
const channelId = interaction.channelId;
const session = findSessionByChannel(channelId);
if (!session) {
return "No session registered for this channel. Try `/discover` first.";
}
const skillName = interaction.options.getString("name", true);
const alive = await isTmuxAlive(session.tmux);
if (!alive) {
return `Session \`${session.tmux}\` is not running. Use \`/session wake\` or \`/session restart\` first.`;
}
// Send the install command to the tmux session
const escapedCmd = escapeForTmux(`npx @anthropic-ai/claude-code skills add ${skillName} -g -y`);
try {
await $`tmux send-keys -t ${session.tmux} ${escapedCmd} Enter`.quiet();
} catch (e: any) {
return `Failed to send install command: ${e.message}`;
}
// Wait a bit, then refresh the session to pick up the new skill
return `Installing skill \`${skillName}\`... Command sent to session \`${session.tmux}\`.\nUse \`/session refresh\` after installation completes to reload.`;
}
async function handleDiscover(
interaction: ChatInputCommandInteraction
): Promise<string> {
const output = await runManager("discover-all");
return `Discovery complete.\n\`\`\`\n${output}\n\`\`\``;
}
async function handleAsk(
interaction: ChatInputCommandInteraction
): Promise<string> {
const channelId = interaction.channelId;
const session = findSessionByChannel(channelId);
if (!session) {
return "No session registered for this channel. Try `/discover` first.";
}
const alive = await isTmuxAlive(session.tmux);
if (!alive) {
return `Session \`${session.tmux}\` is not running. Use \`/session wake\` or \`/session restart\` first.`;
}
const prompt = interaction.options.getString("prompt", true);
const escapedPrompt = escapeForTmux(prompt);
try {
await $`tmux send-keys -t ${session.tmux} ${escapedPrompt} Enter`.quiet();
} catch (e: any) {
return `Failed to send prompt: ${e.message}`;
}
// Auto-start activity watch
startAutoWatch(channelId);
return `Sent to session \`${session.name}\`:\n> ${prompt.length > 200 ? prompt.slice(0, 200) + "..." : prompt}`;
}
async function handleRun(
interaction: ChatInputCommandInteraction
): Promise<string> {
const channelId = interaction.channelId;
const session = findSessionByChannel(channelId);
if (!session) {
return "No session registered for this channel. Try `/discover` first.";
}
const alive = await isTmuxAlive(session.tmux);
if (!alive) {
return `Session \`${session.tmux}\` is not running. Use \`/session wake\` or \`/session restart\` first.`;
}
const skillName = interaction.options.getString("skill", true);
const args = interaction.options.getString("args") ?? "";
const command = `/${skillName}${args ? " " + args : ""}`;
const escapedCmd = escapeForTmux(command);
try {
await $`tmux send-keys -t ${session.tmux} ${escapedCmd} Enter`.quiet();
} catch (e: any) {
return `Failed to send command: ${e.message}`;
}
// Auto-start activity watch
startAutoWatch(channelId);
return `Sent \`${command}\` to session \`${session.name}\``;
}
// ── Activity Watcher ─────────────────────────────────────────────────────
type WatchMode = "live" | "log";
interface WatchState {
channelId: string;
messageId: string;
tmuxSession: string;
sessionName: string;
interval: ReturnType<typeof setInterval>;
lastContent: string;
lastRawLines: Set<string>;
logBuffer: string[];
logMessageId: string;
mode: WatchMode;
idleCount: number;
startedAt: number;
}
const activeWatches: Map<string, WatchState> = new Map();
const WATCH_INTERVAL_MS = 4000;
const IDLE_STOP_COUNT = 15; // stop after ~60s of idle
function parseAgentActivity(raw: string): string {
const lines = raw.split("\n").filter((l) => l.trim());
const status: string[] = [];
let isIdle = false;
for (const line of lines) {
const trimmed = line.trim();
// Agent activity indicators
if (trimmed.match(/^[●⏺]\s/)) {
status.push(trimmed.replace(/[●⏺]\s*/, "▸ "));
}
// Agent tree lines
else if (trimmed.match(/^[├└─│┊┆|]\s*─/)) {
const cleaned = trimmed
.replace(/[├└┊┆│]/g, "")
.replace(/─+\s*/, " ")
.trim();
if (cleaned) status.push(` ${cleaned}`);
}
// Exploring/thinking indicator
else if (trimmed.match(/^[✱✲*]\s/)) {
status.push(trimmed.replace(/^[✱✲*]\s*/, "⟳ "));
}
// Task checklist
else if (trimmed.match(/^[□■☐☑]\s/)) {
const icon = trimmed.startsWith("■") || trimmed.startsWith("☑") ? "✓" : "○";
status.push(`${icon} ${trimmed.replace(/^[□■☐☑]\s*/, "")}`);
}
// Done indicators
else if (trimmed.match(/Done|Completed|✓/i) && trimmed.length < 80) {
status.push(`✓ ${trimmed}`);
}
// Tool use lines
else if (trimmed.match(/tool uses?|tokens/i) && trimmed.length < 100) {
status.push(` ${trimmed}`);
}
// Running N agents
else if (trimmed.match(/Running \d+ agents?/)) {
status.push(`▸ ${trimmed}`);
}
// Thought for Xs
else if (trimmed.match(/thought for \d+/i)) {
status.push(` ${trimmed}`);
}
// Idle prompt detection
else if (trimmed.match(/[❯>]\s*$/) || trimmed.match(/bypass permissions|hold Space/)) {
isIdle = true;
}
}
return status.length > 0
? status.slice(-40).join("\n")
: isIdle
? "__idle__"
: "";
}
async function captureTmuxPane(sessionName: string): Promise<string> {
try {
// Resize pane wider so long lines aren't wrapped/truncated in the buffer
await $`tmux resize-window -t ${sessionName} -x 220`.nothrow().quiet();
const result = await $`tmux capture-pane -t ${sessionName} -p -J -S -120`.text();
return result;
} catch {
return "";
}
}
let _discordClient: Client | null = null;
// Bump watch to bottom: delete + resend (used when new messages push it up)
async function bumpWatchToBottom(state: WatchState): Promise<void> {
if (!_discordClient) return;
try {
const channel = await _discordClient.channels.fetch(state.channelId);
if (!channel?.isTextBased()) return;
const ch = channel as any;
// Read current content before deleting
let oldContent = "";
let oldFiles: any[] = [];
try {
const oldMsg = await ch.messages.fetch(state.messageId);
oldContent = oldMsg.content || "";
if (oldMsg.attachments.size > 0) {
// Re-upload the attachment
const att = oldMsg.attachments.first();
if (att) {
const resp = await fetch(att.url);
const buf = Buffer.from(await resp.arrayBuffer());
oldFiles = [new AttachmentBuilder(buf, { name: att.name || "session.txt" })];
}
}
} catch {}
try { await ch.messages.delete(state.messageId); } catch {}
const msg = await ch.send({
content: oldContent || `**${state.sessionName}** — watching...`,
files: oldFiles.length ? oldFiles : undefined,
});
state.messageId = msg.id;
} catch {}
}
async function editWatchMessage(
state: WatchState,
content: string,
paneText?: string
): Promise<void> {
if (!_discordClient) return;
try {
const channel = await _discordClient.channels.fetch(state.channelId);
if (!channel?.isTextBased()) return;
const ch = channel as any;
// Edit in place (no flicker, no notifications)
if (paneText && paneText.length > 100) {
const elapsed = Math.round((Date.now() - state.startedAt) / 1000);
const header = `**${state.sessionName}** — working (${elapsed}s)`;
const attachment = new AttachmentBuilder(Buffer.from(paneText, "utf8"), {
name: "session.txt",
});
await ch.messages.edit(state.messageId, {
content: header,
files: [attachment],
});
} else {
await ch.messages.edit(state.messageId, { content });
}
} catch (e: any) {
// Message was deleted — recreate it
if (e.code === 10008) {
try {
const channel = await _discordClient!.channels.fetch(state.channelId);
if (channel?.isTextBased()) {
const sendOpts: any = { content: content || `**${state.sessionName}** — watching...` };
if (paneText && paneText.length > 100) {
sendOpts.files = [new AttachmentBuilder(Buffer.from(paneText, "utf8"), { name: "session.txt" })];
}
const msg = await (channel as any).send(sendOpts);
state.messageId = msg.id;
}
} catch {}
}
console.error(`[watcher] Edit failed:`, e.message);
}
}
// ── Log mode: raw diff-based extraction ──────────────────────────────────
// No regex parsing of Claude's output. We diff raw pane snapshots
// and post genuinely new lines. The only "parsing" is detecting the
// idle prompt to know when to stop.
function isIdleLine(line: string): boolean {
const t = line.trim();
return !!(t.match(/[❯>]\s*$/) || t.match(/bypass permissions|hold Space|shift\+tab/));
}
// Diff two pane snapshots and return only the lines that are new
function diffPaneLines(
prevLines: string[],
currLines: string[]
): string[] {
// Build a multiset of previous lines (handle duplicates)
const prevCounts = new Map<string, number>();
for (const l of prevLines) {
const t = l.trim();
if (t) prevCounts.set(t, (prevCounts.get(t) || 0) + 1);
}
const newLines: string[] = [];
for (const l of currLines) {
const t = l.trim();
if (!t) continue;
const count = prevCounts.get(t) || 0;
if (count > 0) {
prevCounts.set(t, count - 1); // consume one occurrence
} else {
newLines.push(t);
}
}
return newLines;
}
async function watchTickLog(state: WatchState): Promise<void> {
if (!_discordClient) return;
const raw = await captureTmuxPane(state.tmuxSession);
if (!raw) {
state.idleCount++;
if (state.idleCount >= IDLE_STOP_COUNT) stopWatch(state.channelId, "session gone");
return;
}
const currLines = raw.split("\n");
const prevLines = state.lastContent ? state.lastContent.split("\n") : [];
state.lastContent = raw;
// Check idle
const isIdle = currLines.some((l) => isIdleLine(l));
// Diff to find new lines
const newLines = diffPaneLines(prevLines, currLines);
if (newLines.length === 0) {
if (isIdle) {
state.idleCount++;
if (state.idleCount >= IDLE_STOP_COUNT) {
const elapsed = Math.round((Date.now() - state.startedAt) / 1000);
const total = state.logBuffer.length;
await editWatchMessage(
state,
`**${state.sessionName}** — done (${elapsed}s, ${total} entries)`
);
stopWatch(state.channelId, "idle");
}
}
return;
}
state.idleCount = 0;
const timestamp = new Date().toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
for (const line of newLines) {
let display = line.length > 150 ? line.slice(0, 147) + "..." : line;
state.logBuffer.push(`\`${timestamp}\` ${display}`);
}
// Build message: header + as many recent entries as fit in 1900 chars
const elapsed = Math.round((Date.now() - state.startedAt) / 1000);
const header = `**${state.sessionName}** — log (${elapsed}s, ${state.logBuffer.length} entries)\n`;
// Take entries from the end, fitting within limit
const maxBody = 1900 - header.length;
const display: string[] = [];
let bodyLen = 0;
for (let i = state.logBuffer.length - 1; i >= 0; i--) {
const line = state.logBuffer[i];
if (bodyLen + line.length + 1 > maxBody) break;
display.unshift(line);
bodyLen += line.length + 1;
}
const skipped = state.logBuffer.length - display.length;
const skipNote = skipped > 0 ? `_(${skipped} earlier entries)_\n` : "";
await editWatchMessage(state, `${header}${skipNote}${display.join("\n")}`);
}
async function sendSilent(channelId: string, content: string): Promise<void> {
if (!_discordClient) return;
try {
const channel = await _discordClient.channels.fetch(channelId);
if (channel?.isTextBased()) {
await (channel as any).send({
content,
flags: 1 << 12, // SUPPRESS_NOTIFICATIONS
});
}
} catch {}
}
async function watchTick(state: WatchState): Promise<void> {
if (state.mode === "log") return watchTickLog(state);
// ── Live mode (snapshot as scrollable file) ──
if (!_discordClient) return;
const raw = await captureTmuxPane(state.tmuxSession);
if (!raw) {
state.idleCount++;
if (state.idleCount >= IDLE_STOP_COUNT) stopWatch(state.channelId, "session gone");
return;
}
// Check idle by looking at the raw pane
const lines = raw.split("\n");
const isIdle = lines.some((l) => isIdleLine(l));
if (isIdle && raw.trim() === state.lastContent?.trim()) {
state.idleCount++;
if (state.idleCount >= IDLE_STOP_COUNT) {
const elapsed = Math.round((Date.now() - state.startedAt) / 1000);
await editWatchMessage(
state,
`**${state.sessionName}** — done (${elapsed}s)`
);
stopWatch(state.channelId, "idle");
}
return;
}
// Skip if pane hasn't changed
if (raw.trim() === state.lastContent?.trim()) return;
state.lastContent = raw;
state.idleCount = 0;
// Build code block from the tail of the pane that fits in Discord's limit
const elapsed = Math.round((Date.now() - state.startedAt) / 1000);
const header = `**${state.sessionName}** — working (${elapsed}s)\n`;
const maxBody = 2000 - header.length - 10; // 10 for ``` markers + newlines
// Filter TUI chrome from ALL lines (separators, prompt, keybinding hints)
// Keep status bar (model, tokens, cost, rate) — it's useful info
const isTuiChrome = (line: string): boolean => {
const t = line.trim();
if (!t) return false; // blank lines kept for readability
if (t.match(/^[─━═▔▁_\-─]{3,}$/)) return true; // separator (pure)
if (t.match(/^[─━═▔▁_\-─\s]+$/) && t.length > 3) return true; // separator with spaces
if (t.match(/^[❯>]\s*$/)) return true; // bare prompt
if (t.match(/^❯\s*$/)) return true; // bare prompt (unicode)
if (t.match(/bypass permissions/)) return true;
if (t.match(/shift\+tab/)) return true;
if (t.match(/^⏵⏵/)) return true;
return false;
};
const allLines = raw.split("\n").filter((line) => !isTuiChrome(line));
// Strip leading/trailing blank lines
while (allLines.length && !allLines[0].trim()) allLines.shift();
while (allLines.length && !allLines[allLines.length - 1].trim()) allLines.pop();
const display: string[] = [];
let bodyLen = 0;
for (let i = allLines.length - 1; i >= 0; i--) {
const line = allLines[i];
if (bodyLen + line.length + 1 > maxBody) break;
display.unshift(line);
bodyLen += line.length + 1;
}
const content = `${header}\`\`\`\n${display.join("\n")}\n\`\`\``;
await editWatchMessage(state, content);
}
function stopWatch(channelId: string, reason: string): void {
const state = activeWatches.get(channelId);
if (!state) return;
clearInterval(state.interval);
activeWatches.delete(channelId);
console.log(`[watcher] Stopped watch for ${state.sessionName}: ${reason}`);
}
async function handleSessionSnapshot(
interaction: ChatInputCommandInteraction
): Promise<string> {
const channelId = interaction.channelId;
const session = findSessionByChannel(channelId);
if (!session) {
return "No session registered for this channel. Try `/discover` first.";
}
const alive = await isTmuxAlive(session.tmux);
if (!alive) {
return `Session \`${session.tmux}\` is not running.`;
}
// Capture a large chunk of the pane
let paneText = "";
try {
await $`tmux resize-window -t ${session.tmux} -x 220`.nothrow().quiet();
paneText = await $`tmux capture-pane -t ${session.tmux} -p -J -S -500`.text();
} catch {
return "Failed to capture session pane.";
}
// Strip TUI chrome from bottom
const lines = paneText.split("\n");
while (lines.length) {
const last = lines[lines.length - 1].trim();
if (
!last ||
last.match(/^[─━═▔▁_]{3,}$/) ||
last.match(/^[❯>]\s*$/) ||
last.match(/bypass permissions/) ||
last.match(/auto-compact/) ||
last.match(/shift\+tab/) ||
last.match(/esc to interrupt/) ||
last.match(/hold Space/)
) {
lines.pop();
} else {
break;
}
}
// Strip leading blank lines
while (lines.length && !lines[0].trim()) lines.shift();
const cleaned = lines.join("\n").trimEnd();
if (!cleaned) return "Session pane is empty.";
// Return a marker so the router uploads the file
return JSON.stringify({ __snapshot: true, name: session.name, content: cleaned });
}
async function handleSessionHistory(
interaction: ChatInputCommandInteraction
): Promise<string> {
const channelId = interaction.channelId;
const session = findSessionByChannel(channelId);
if (!session) {
return "No session registered for this channel. Try `/discover` first.";
}
const alive = await isTmuxAlive(session.tmux);
if (!alive) {
return `Session \`${session.tmux}\` is not running.`;
}
// Capture the ENTIRE scrollback buffer
let paneText = "";
try {
await $`tmux resize-window -t ${session.tmux} -x 220`.nothrow().quiet();
// -S - = from the very start, -E - = to the very end
paneText = await $`tmux capture-pane -t ${session.tmux} -p -J -S - -E -`.text();
} catch {
return "Failed to capture session history.";
}
// Strip TUI chrome from bottom
const lines = paneText.split("\n");
while (lines.length) {
const last = lines[lines.length - 1].trim();
if (
!last ||
last.match(/^[─━═▔▁_]{3,}$/) ||
last.match(/^[❯>]\s*$/) ||
last.match(/bypass permissions/) ||
last.match(/auto-compact/) ||
last.match(/shift\+tab/) ||
last.match(/esc to interrupt/) ||
last.match(/hold Space/)
) {
lines.pop();
} else {
break;
}
}
while (lines.length && !lines[0].trim()) lines.shift();
const cleaned = lines.join("\n").trimEnd();
if (!cleaned) return "Session history is empty.";
return JSON.stringify({ __snapshot: true, name: `${session.name}-history`, content: cleaned });
}
// Map friendly names to tmux key sequences
const KEY_MAP: Record<string, string> = {
yes: "Enter",
no: "Escape",
esc: "Escape",
escape: "Escape",
enter: "Enter",
tab: "Tab",
up: "Up",
down: "Down",
left: "Left",
right: "Right",
space: "Space",
"shift+tab": "BTab",
"ctrl+c": "C-c",
"ctrl+d": "C-d",
};
async function handleSessionSend(
interaction: ChatInputCommandInteraction
): Promise<string> {
const channelId = interaction.channelId;
const session = findSessionByChannel(channelId);
if (!session) {
return "No session registered for this channel. Try `/discover` first.";
}
const alive = await isTmuxAlive(session.tmux);
if (!alive) {
return `Session \`${session.tmux}\` is not running.`;
}
const rawInput = interaction.options.getString("input", true);
const lower = rawInput.toLowerCase().trim();
// Check if it's a special key
const mappedKey = KEY_MAP[lower];
try {
if (mappedKey) {
// Send as a tmux key
await $`tmux send-keys -t ${session.tmux} ${mappedKey}`.quiet();
return `Sent key \`${lower}\` → \`${mappedKey}\` to session \`${session.name}\``;
} else if (lower === "1") {
// Option 1 — cursor starts here, just press Enter
await $`tmux send-keys -t ${session.tmux} Enter`.quiet();
return `Selected option 1 (Enter) in session \`${session.name}\``;
} else if (lower === "2") {
// Option 2 — Down then Enter
await $`tmux send-keys -t ${session.tmux} Down Enter`.quiet();
return `Selected option 2 (Down+Enter) in session \`${session.name}\``;
} else if (lower === "3") {
// Option 3 — Down Down Enter
await $`tmux send-keys -t ${session.tmux} Down Down Enter`.quiet();
return `Selected option 3 (Down+Down+Enter) in session \`${session.name}\``;
} else if (lower === "4") {
await $`tmux send-keys -t ${session.tmux} Down Down Down Enter`.quiet();
return `Selected option 4 in session \`${session.name}\``;
} else if (lower === "5") {
await $`tmux send-keys -t ${session.tmux} Down Down Down Down Enter`.quiet();
return `Selected option 5 in session \`${session.name}\``;
} else {
// Send as text + Enter
const escaped = escapeForTmux(rawInput);
await $`tmux send-keys -t ${session.tmux} ${escaped} Enter`.quiet();
return `Sent text to session \`${session.name}\`:\n> ${rawInput.length > 200 ? rawInput.slice(0, 200) + "..." : rawInput}`;
}
} catch (e: any) {
return `Failed to send: ${e.message}`;
}
}
async function handleSessionWatch(
interaction: ChatInputCommandInteraction
): Promise<string> {
const channelId = interaction.channelId;
// Toggle off if already watching
if (activeWatches.has(channelId)) {
stopWatch(channelId, "user toggled off");
return "Activity streaming stopped.";
}
const session = findSessionByChannel(channelId);
if (!session) {
return "No session registered for this channel. Try `/discover` first.";
}
const alive = await isTmuxAlive(session.tmux);
if (!alive) {
return `Session \`${session.tmux}\` is not running.`;
}
const mode = (interaction.options.getString("mode") ?? "live") as WatchMode;
return JSON.stringify({
__watch: true,
tmux: session.tmux,
name: session.name,
mode,
});
}
async function startAutoWatch(channelId: string, mode: WatchMode = "live"): Promise<void> {
if (activeWatches.has(channelId) || !_discordClient) return;
const session = findSessionByChannel(channelId);
if (!session) return;
// Don't auto-start watch on a parent channel if any of its threads have active watches
// (thread messages leak to parent's plugin session — would show duplicate activity)
if (session.type === "channel") {
const registry = readSessionsRegistry();
for (const [id, info] of Object.entries(registry)) {
if ((info as any).parent === channelId && activeWatches.has(id)) {
console.log(`[watcher] Skipping auto-watch for parent ${channelId} — thread ${id} is being watched`);
return;
}
}
}
try {
const channel = await _discordClient.channels.fetch(channelId);
if (!channel?.isTextBased()) return;
const modeLabel = mode === "log" ? "log mode" : "live mode";
const msg = await (channel as any).send({
content: `**${session.name}** — watching (${modeLabel})...\nWaiting for agent output...`,
flags: 1 << 12, // SUPPRESS_NOTIFICATIONS
});
const state: WatchState = {
channelId,
messageId: msg.id,
tmuxSession: session.tmux,
sessionName: session.name,
interval: setInterval(() => watchTick(state), WATCH_INTERVAL_MS),
lastContent: "",
lastRawLines: new Set(),
logBuffer: [],
logMessageId: "",
mode,
idleCount: 0,
startedAt: Date.now(),
};
activeWatches.set(channelId, state);
console.log(`[watcher] Auto-started watch (${modeLabel}) for ${session.name}`);
} catch (e: any) {
console.error(`[watcher] Auto-start failed:`, e.message);
}
}
// ── Autocomplete Handler ──────────────────────────────────────────────────
async function handleAutocomplete(
interaction: AutocompleteInteraction
): Promise<void> {
const focused = interaction.options.getFocused(true);
if (
interaction.commandName === "session" &&
focused.name === "path"
) {
const workdirMap = readWorkdirMap();
const typed = (focused.value as string).toLowerCase();
const choices = Object.entries(workdirMap)
.filter(
([name, path]) =>
name.toLowerCase().includes(typed) ||
path.toLowerCase().includes(typed)
)
.slice(0, 25)
.map(([name, path]) => {
const displayPath = path.replace(/\$HOME/g, "~");
return {
name: `${name} → ${displayPath}`.slice(0, 100),
value: path.replace(/\$HOME/g, homedir()),
};
});
await interaction.respond(choices);
} else if (
interaction.commandName === "run" &&
focused.name === "skill"
) {
const typed = (focused.value as string).toLowerCase().trim();
const globalSkills = getSkillsWithCache();
// Get project-local skills for this channel's workdir
const channelId = interaction.channelId;
const wdFile = join(SESSIONS_DIR, channelId, ".workdir");
let projectSkills: Array<{ name: string; description: string }> = [];
let workdirLabel = "";
try {
if (existsSync(wdFile)) {
const wd = readFileSync(wdFile, "utf8").trim();
projectSkills = scanProjectSkills(wd);
workdirLabel = wd.replace(homedir(), "~").split("/").pop() || "";
}
} catch {}
if (!typed) {
// Empty input: show project skills first, then category summaries
const choices: Array<{ name: string; value: string }> = [];
// Project-local skills first
if (projectSkills.length > 0) {
for (const s of projectSkills.slice(0, 5)) {
choices.push({
name: `⭐ /${s.name} — [${workdirLabel}] ${s.description}`.slice(0, 100),
value: s.name,
});
}
}
// Category summaries for plugin skills
const categories: Record<string, number> = {};
const userSkillCount = globalSkills.filter(
(s) => !s.name.includes(":")
).length;
for (const s of globalSkills) {
const colonIdx = s.name.indexOf(":");
if (colonIdx > 0) {
const prefix = s.name.slice(0, colonIdx);
categories[prefix] = (categories[prefix] || 0) + 1;
}
}
// User skills category
choices.push({
name: `📂 user skills (${userSkillCount}) — type any name to search`.slice(0, 100),
value: "a", // starts filtering from 'a' to show user skills
});
// Plugin categories sorted by count
const sorted = Object.entries(categories).sort((a, b) => b[1] - a[1]);
for (const [prefix, count] of sorted) {
if (choices.length >= 25) break;
choices.push({
name: `📦 ${prefix}:* (${count} skills)`.slice(0, 100),
value: prefix,
});
}
await interaction.respond(choices.slice(0, 25));
} else {
// Filtered: project skills first, then global matches
const projectNames = new Set(projectSkills.map((s) => s.name));
const projectMatches = projectSkills
.filter((s) => s.name.toLowerCase().includes(typed))
.map((s) => ({
name: `⭐ /${s.name} — [${workdirLabel}] ${s.description}`.slice(0, 100),
value: s.name,
}));
const globalMatches = globalSkills
.filter(
(s) =>
s.name.toLowerCase().includes(typed) &&
!projectNames.has(s.name)
)
.map((s) => ({
name: `/${s.name} — ${s.description}`.slice(0, 100),
value: s.name,
}));
const choices = [...projectMatches, ...globalMatches].slice(0, 25);
await interaction.respond(choices);
}
} else if (
interaction.commandName === "session" &&
focused.name === "input"
) {
const typed = (focused.value as string).toLowerCase().trim();
const commonChoices = [
{ name: "1 — Select option 1 (Enter — cursor starts here)", value: "1" },
{ name: "2 — Select option 2 (Down + Enter)", value: "2" },
{ name: "3 — Select option 3 (Down×2 + Enter)", value: "3" },
{ name: "yes — Press Enter (confirm current selection)", value: "yes" },
{ name: "no — Press Escape (cancel)", value: "no" },
{ name: "enter — Press Enter", value: "enter" },
{ name: "esc — Cancel / dismiss", value: "esc" },
{ name: "tab — Next option", value: "tab" },
{ name: "shift+tab — Toggle permissions mode", value: "shift+tab" },
{ name: "up — Navigate up", value: "up" },
{ name: "down — Navigate down", value: "down" },
{ name: "space — Select / toggle", value: "space" },
{ name: "ctrl+c — Interrupt", value: "ctrl+c" },
];
const filtered = typed
? commonChoices.filter((c) => c.name.toLowerCase().includes(typed) || c.value.includes(typed))
: commonChoices;
await interaction.respond(filtered.slice(0, 25));
} else if (
interaction.commandName === "session" &&
focused.name === "name" &&
interaction.options.getSubcommand() === "profile"
) {
const typed = (focused.value as string).toLowerCase().trim();
const profiles = listAvailableProfiles();
const current = readProfile(interaction.channelId);
const choices = profiles
.filter((p) => !typed || p.toLowerCase().includes(typed))
.map((p) => ({
name: `${p}${p === current ? " (current)" : ""}`,
value: p,
}));
await interaction.respond(choices.slice(0, 25));
}
}
// ── Interaction Router ────────────────────────────────────────────────────
async function handleInteraction(
interaction: ChatInputCommandInteraction
): Promise<void> {
const { commandName } = interaction;
// Determine if response should be ephemeral
const isEphemeral =
(commandName === "session" &&
interaction.options.getSubcommand() === "status") ||
(commandName === "skills" &&
interaction.options.getSubcommand() === "list");
// Defer reply
await interaction.deferReply({ ephemeral: isEphemeral });
let response: string;
try {
switch (commandName) {
case "session": {
const sub = interaction.options.getSubcommand();
switch (sub) {
case "status":
response = await handleSessionStatus(interaction);
break;
case "restart":
response = await handleSessionRestart(interaction);
break;
case "refresh":
response = await handleSessionRefresh(interaction);
break;
case "kill":
response = await handleSessionKill(interaction);
break;
case "wake":
response = await handleSessionWake(interaction);
break;
case "workdir":
response = await handleSessionWorkdir(interaction);
break;
case "watch":
response = await handleSessionWatch(interaction);
break;
case "snapshot":
response = await handleSessionSnapshot(interaction);
break;
case "history":
response = await handleSessionHistory(interaction);
break;
case "send":
response = await handleSessionSend(interaction);
break;
case "profile":
response = await handleSessionProfile(interaction);
break;
default:
response = `Unknown subcommand: ${sub}`;
}
break;
}
case "skills": {
const sub = interaction.options.getSubcommand();
switch (sub) {
case "list":
response = await handleSkillsList(interaction);
break;
case "install":
response = await handleSkillsInstall(interaction);
break;
default:
response = `Unknown subcommand: ${sub}`;
}
break;
}
case "discover":
response = await handleDiscover(interaction);
break;
case "ask":
response = await handleAsk(interaction);
break;
case "run":
response = await handleRun(interaction);
break;
default:
response = `Unknown command: ${commandName}`;
}
} catch (e: any) {
response = `Error: ${e.message}`;
console.error(`[slash-daemon] Error handling /${commandName}:`, e);
}
// Edit the deferred response
try {
// Check for watch start signal
if (response.startsWith("{") && response.includes('"__watch"')) {
const parsed = JSON.parse(response);
const mode: WatchMode = parsed.mode || "live";
const modeLabel = mode === "log" ? "log mode" : "live mode";
const msg = await interaction.editReply({
content: `**${parsed.name}** — watching (${modeLabel})...\n\`\`\`\nWaiting for agent output...\n\`\`\``,
});
const messageId = typeof msg === "string" ? msg : msg.id;
const channelId = interaction.channelId;
const state: WatchState = {
channelId,
messageId,
tmuxSession: parsed.tmux,
sessionName: parsed.name,
interval: setInterval(() => watchTick(state), WATCH_INTERVAL_MS),
lastContent: "",
lastRawLines: new Set(),
logBuffer: [],
logMessageId: "",
mode,
idleCount: 0,
startedAt: Date.now(),
};
activeWatches.set(channelId, state);
console.log(`[watcher] Started watch (${modeLabel}) for ${parsed.name} in ${channelId}`);
}
// Check for snapshot signal — upload full pane as .txt file
else if (response.startsWith("{") && response.includes('"__snapshot"')) {
const parsed = JSON.parse(response);
const attachment = new AttachmentBuilder(
Buffer.from(parsed.content, "utf8"),
{ name: `${parsed.name}-snapshot.txt` }
);
await interaction.editReply({
content: `**${parsed.name}** — full session snapshot`,
files: [attachment],
});
}
// Check if response contains an embed
else if (response.startsWith("{") && response.includes('"embed"')) {
const parsed = JSON.parse(response);
await interaction.editReply({ embeds: [parsed.embed] });
} else {
// Truncate if too long for Discord
if (response.length > 2000) {
response = response.slice(0, 1997) + "...";
}
await interaction.editReply({ content: response });
}
} catch (e: any) {
console.error("[slash-daemon] Failed to edit reply:", e);
}
}
// ── Register Commands ─────────────────────────────────────────────────────
async function registerCommands(rest: REST): Promise<void> {
console.log("[slash-daemon] Fetching application ID...");
const app = (await rest.get(Routes.oauth2CurrentApplication())) as {
id: string;
name: string;
};
const appId = app.id;
console.log(`[slash-daemon] Application: ${app.name} (${appId})`);
console.log(
`[slash-daemon] Registering ${SLASH_COMMANDS.length} slash commands for guild ${GUILD_ID}...`
);
await rest.put(Routes.applicationGuildCommands(appId, GUILD_ID), {
body: SLASH_COMMANDS,
});
console.log("[slash-daemon] Slash commands registered successfully.");
}
// ── Main ──────────────────────────────────────────────────────────────────
async function main() {
console.log("[slash-daemon] Starting Discord slash command daemon...");
console.log(`[slash-daemon] Sessions dir: ${SESSIONS_DIR}`);
console.log(`[slash-daemon] Manager: ${MANAGER_PATH}`);
// Set up REST client and register commands
const rest = new REST({ version: "10" }).setToken(BOT_TOKEN);
await registerCommands(rest);
// Create the Gateway client
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
client.on("ready", () => {
_discordClient = client;
console.log(
`[slash-daemon] Connected as ${client.user?.tag} — listening for slash commands`
);
});
client.on("interactionCreate", async (interaction) => {
if (interaction.isAutocomplete()) {
try {
await handleAutocomplete(interaction as AutocompleteInteraction);
} catch (e) {
console.error("[slash-daemon] Autocomplete error:", e);
}
return;
}
if (interaction.isChatInputCommand()) {
await handleInteraction(interaction as ChatInputCommandInteraction);
}
});
// Auto-start watch + bump to bottom when user sends a message
client.on("messageCreate", async (message) => {
if (message.author.bot) return;
// Use the exact channel/thread ID the message was posted in
const channelId = message.channelId;
const isThread = message.channel.isThread();
const parentId = isThread ? (message.channel as any).parentId : null;
// If already watching this exact channel/thread, just bump to bottom
const watch = activeWatches.get(channelId);
if (watch) {
setTimeout(() => bumpWatchToBottom(watch), 1500);
return;
}
// Auto-start watch for the channel/thread where the message was posted
const session = findSessionByChannel(channelId);
if (session) {
const alive = await isTmuxAlive(session.tmux);
if (alive) {
setTimeout(() => startAutoWatch(channelId), 3000);
}
}
// If this is a thread message, do NOT also trigger the parent channel's watch.
// The parent's Discord plugin session may pick up the message, but we don't
// want the parent's watch to show thread activity.
if (isThread && parentId) {
const parentWatch = activeWatches.get(parentId);
if (parentWatch) {
// Parent watch is active but this message is in a thread — ignore
// (don't bump parent to bottom for thread messages)
return;
}
}
});
// Graceful shutdown
const shutdown = () => {
console.log("[slash-daemon] Shutting down...");
for (const [channelId] of activeWatches) {
stopWatch(channelId, "daemon shutdown");
}
client.destroy();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
await client.login(BOT_TOKEN);
}
main().catch((e) => {
console.error("[slash-daemon] Fatal error:", e);
process.exit(1);
});
#!/usr/bin/env bash
# discord-watchdog.sh — Keep Discord tmux sessions alive
#
# Runs as a background daemon. Respawns dead sessions and discovers new
# channels/threads on interval.
set -euo pipefail
SESSIONS_DIR="$HOME/.claude/discord-sessions"
CONFIG_FILE="$SESSIONS_DIR/config.env"
# Load config for interval overrides
[[ -f "$CONFIG_FILE" ]] && { set -a; source "$CONFIG_FILE"; set +a; }
INTERVAL="${DISCORD_WATCHDOG_INTERVAL:-30}"
DISCOVER_INTERVAL="${DISCORD_DISCOVER_INTERVAL:-60}"
CLEANUP_INTERVAL="${DISCORD_CLEANUP_INTERVAL:-300}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
MANAGER="$SCRIPT_DIR/discord-session-manager.sh"
TMUX_SESSION="dc-watchdog"
SLASH_DAEMON_SESSION="dc-slash-daemon"
PIDFILE="$SESSIONS_DIR/watchdog.pid"
_log() { echo "[$(date +%H:%M:%S)] $*"; }
_ensure_slash_daemon() {
if ! tmux has-session -t "$SLASH_DAEMON_SESSION" 2>/dev/null; then
_log "Slash daemon not running — spawning in tmux: $SLASH_DAEMON_SESSION"
tmux new-session -d -s "$SLASH_DAEMON_SESSION" -c "$SCRIPT_DIR" \
"bun $SCRIPT_DIR/discord-slash-daemon.ts"
_log "Slash daemon started"
fi
}
cmd_run() {
_log "Watchdog started (interval=${INTERVAL}s, discover=${DISCOVER_INTERVAL}s, cleanup=${CLEANUP_INTERVAL}s, pid=$$)"
mkdir -p "$SESSIONS_DIR"
echo $$ > "$PIDFILE"
trap 'rm -f "$PIDFILE"; _log "Watchdog stopped"; exit 0' INT TERM
local last_discover=0
local last_cleanup=0
while true; do
local now
now=$(date +%s)
if [[ -f "$SESSIONS_DIR/sessions.json" ]]; then
"$MANAGER" respawn-dead 2>&1 | while read -r line; do
[[ -n "$line" ]] && _log "$line"
done
fi
# Ensure the slash command daemon is alive
_ensure_slash_daemon
if (( now - last_discover >= DISCOVER_INTERVAL )); then
_log "Discovering new channels and threads..."
"$MANAGER" discover-all 2>&1 | while read -r line; do
[[ -n "$line" ]] && _log "$line"
done
# Update status channel topic after discovery
"$MANAGER" set-status 2>/dev/null || true
last_discover=$now
fi
if (( now - last_cleanup >= CLEANUP_INTERVAL )); then
_log "Running stale session cleanup + idle suspension..."
"$MANAGER" cleanup-stale 2>&1 | while read -r line; do
[[ -n "$line" ]] && _log "$line"
done
"$MANAGER" suspend-idle 2>&1 | while read -r line; do
[[ -n "$line" ]] && _log "$line"
done
last_cleanup=$now
fi
sleep "$INTERVAL"
done
}
cmd_daemon() {
if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
echo "Watchdog already running in tmux session '$TMUX_SESSION'"
return 0
fi
tmux new-session -d -s "$TMUX_SESSION" "$0"
echo "Watchdog started in tmux session '$TMUX_SESSION'"
echo " Attach: tmux attach -t $TMUX_SESSION"
}
cmd_stop() {
if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
tmux kill-session -t "$TMUX_SESSION"
echo "Watchdog stopped"
elif [[ -f "$PIDFILE" ]]; then
kill "$(cat "$PIDFILE")" 2>/dev/null || true
rm -f "$PIDFILE"
echo "Watchdog stopped (via pid)"
else
echo "Watchdog not running"
fi
# Also stop the slash daemon if running
if tmux has-session -t "$SLASH_DAEMON_SESSION" 2>/dev/null; then
tmux kill-session -t "$SLASH_DAEMON_SESSION"
echo "Slash daemon stopped"
fi
}
cmd_status() {
if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
echo "Watchdog: RUNNING (tmux: $TMUX_SESSION)"
elif [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "Watchdog: RUNNING (pid: $(cat "$PIDFILE"))"
else
echo "Watchdog: STOPPED"
fi
if tmux has-session -t "$SLASH_DAEMON_SESSION" 2>/dev/null; then
echo "Slash daemon: RUNNING (tmux: $SLASH_DAEMON_SESSION)"
else
echo "Slash daemon: STOPPED"
fi
}
case "${1:-}" in
--daemon) cmd_daemon ;;
--stop) cmd_stop ;;
--status) cmd_status ;;
*) cmd_run ;;
esac
{
"name": "claude-remote-sessions-daemon",
"private": true,
"dependencies": {
"discord.js": "^14.25.0"
}
}
#!/usr/bin/env bash
# telegram-watchdog.sh — Keep Telegram tmux sessions alive (respawn only, no discovery)
set -euo pipefail
SESSIONS_DIR="$HOME/.claude/telegram-sessions"
CONFIG_FILE="$SESSIONS_DIR/config.env"
[[ -f "$CONFIG_FILE" ]] && { set -a; source "$CONFIG_FILE"; set +a; }
INTERVAL="${TELEGRAM_WATCHDOG_INTERVAL:-30}"
MANAGER="$(cd "$(dirname "$0")" && pwd)/telegram-session-manager.sh"
TMUX_SESSION="tg-watchdog"
PIDFILE="$SESSIONS_DIR/watchdog.pid"
_log() { echo "[$(date +%H:%M:%S)] $*"; }
cmd_run() {
_log "Telegram watchdog started (interval=${INTERVAL}s, pid=$$)"
mkdir -p "$SESSIONS_DIR"
echo $$ > "$PIDFILE"
trap 'rm -f "$PIDFILE"; _log "Watchdog stopped"; exit 0' INT TERM
while true; do
if [[ -f "$SESSIONS_DIR/sessions.json" ]]; then
"$MANAGER" respawn-dead 2>&1 | while read -r line; do
[[ -n "$line" ]] && _log "$line"
done
fi
sleep "$INTERVAL"
done
}
cmd_daemon() {
if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
echo "Telegram watchdog already running in '$TMUX_SESSION'"
return 0
fi
tmux new-session -d -s "$TMUX_SESSION" "$0"
echo "Telegram watchdog started in tmux '$TMUX_SESSION'"
}
cmd_stop() {
if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
tmux kill-session -t "$TMUX_SESSION"
echo "Telegram watchdog stopped"
elif [[ -f "$PIDFILE" ]]; then
kill "$(cat "$PIDFILE")" 2>/dev/null || true
rm -f "$PIDFILE"
echo "Telegram watchdog stopped (via pid)"
else
echo "Telegram watchdog not running"
fi
}
cmd_status() {
if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
echo "Telegram watchdog: RUNNING (tmux: $TMUX_SESSION)"
elif [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "Telegram watchdog: RUNNING (pid: $(cat "$PIDFILE"))"
else
echo "Telegram watchdog: STOPPED"
fi
}
case "${1:-}" in
--daemon) cmd_daemon ;;
--stop) cmd_stop ;;
--status) cmd_status ;;
*) cmd_run ;;
esac
Related skills
FAQ
How does conversation history survive a watchdog respawn?
Each session uses a deterministic UUID v5 --session-id derived from the channel ID, so history persists across respawns.
How often does the watchdog act?
Every 30s it respawns dead sessions, every 60s it discovers new channels and threads, and every 5m it cleans up stale sessions.