diff --git a/skills/buildr/README.md b/skills/buildr/README.md new file mode 100644 index 000000000..f07341001 --- /dev/null +++ b/skills/buildr/README.md @@ -0,0 +1,124 @@ +# Buildr + +**Bridge Claude Code to Telegram** - control your coding sessions from your phone. + +Buildr mirrors your Claude Code session to a Telegram bot. Same conversation, two windows. No extra API tokens wasted - it's pure file-based IPC. + +## What You Get + +- **Full mirror**: Every CC response appears in Telegram, every TG message reaches CC +- **Permission forwarding**: Tool approval requests come to TG - reply YES/NO from your phone +- **STOP command**: Send `STOP` in Telegram to halt CC immediately +- **Await system**: When CC is idle, it waits for your TG message (no timeout, no session death) +- **Offline fallback**: If CC dies (laptop closed), messages are processed via `claude -p` +- **Persistence**: Optional tmux setup keeps CC alive when you close your laptop +- **Typing indicator**: Shows "typing..." animation before every message + +## Requirements + +- Node.js 18+ +- Python 3 +- Claude Code CLI (`claude`) +- PM2 (`npm install -g pm2`) +- A Telegram bot token (from [@BotFather](https://t.me/BotFather)) +- Your Telegram user ID (from [@userinfobot](https://t.me/userinfobot)) + +## Install + +```bash +git clone +cd buildr +./setup.sh +``` + +Or run interactively (prompts for token and ID): +```bash +./setup.sh +``` + +That's it. The setup: +1. Creates `~/.buildr/` with all files +2. Starts the relay daemon via PM2 +3. Configures Claude Code hooks automatically +4. Sends a test message to your Telegram + +## Laptop Persistence + +To keep CC alive when you close your laptop: + +```bash +~/.buildr/start-cc.sh +``` + +This starts Claude Code inside a tmux session that survives SSH disconnects. Attach/detach with: +```bash +tmux attach -t buildr-cc # attach +# Ctrl+B then D # detach +``` + +## Telegram Commands + +| Command | Description | +|---------|-------------| +| `/status` | Check CC session status | +| `/help` | Show available commands | +| `/clear` | Clear message inbox | +| `/reconnect` | Info about reconnecting | +| `STOP` | Halt CC immediately | + +When CC is waiting, reply: +- **YES** - continue +- **Any message** - CC receives it as instructions + +## How It Works + +``` +┌─────────────┐ files ┌─────────────┐ HTTPS ┌──────────┐ +│ Claude Code │ ←── inbox ─── │ Relay │ ←── poll ─── │ Telegram │ +│ (hooks) │ ─── outbox ──→ │ (PM2 daemon)│ ─── send ──→ │ Bot │ +│ │ ─── heartbeat │ │ │ │ +└─────────────┘ └─────────────┘ └──────────┘ +``` + +**Files** (all in `~/.buildr/`): +- `inbox.jsonl` - TG messages (relay writes, CC reads via hook) +- `outbox.jsonl` - CC responses (CC writes, relay sends to TG) +- `heartbeat` - CC liveness timestamp (hook writes on every tool call) +- `stop-flag` - STOP signal (relay creates, hook checks) +- `await-flag` - CC waiting for user (CC creates, hook polls) +- `config.env` - Bot token + user ID + +**Components**: +- `relay.js` - Always-on PM2 daemon. Polls TG, sends outbox, monitors heartbeat +- `bridge-hook.py` - PreToolUse hook. Auto-heartbeat, STOP, await, message injection +- `perm-hook.sh` - PermissionRequest hook. Forwards approvals to TG + +## Uninstall + +```bash +./teardown.sh +``` + +Stops PM2 process, removes hooks from Claude Code, optionally deletes `~/.buildr/`. + +## Security + +- Only the configured user ID can interact with the bot +- All other Telegram users are silently ignored +- Bot token and user ID stored in `~/.buildr/config.env` + +## Troubleshooting + +**Messages not appearing in Telegram?** +```bash +pm2 logs buildr-relay # check relay logs +pm2 restart buildr-relay # restart if needed +``` + +**CC not reading TG messages?** +- Ensure Claude Code hooks are configured: check `~/.claude/settings.json` +- The PreToolUse hook runs before every tool call - CC must be actively working + +**Session dying when laptop closes?** +- Use `~/.buildr/start-cc.sh` to run CC in tmux +- Or: relay automatically uses `claude -p` as offline fallback diff --git a/skills/buildr/SKILL.md b/skills/buildr/SKILL.md new file mode 100644 index 000000000..70f55d1c9 --- /dev/null +++ b/skills/buildr/SKILL.md @@ -0,0 +1,96 @@ +--- +name: buildr +description: Bridge Claude Code to Telegram - mirror your CC session to a Telegram bot. Send messages, approve permissions, stop tasks, and control coding sessions from your phone. One command setup with just a bot token and Telegram user ID. +license: MIT +--- + +# Buildr - Telegram Bridge for Claude Code + +Bridge your Claude Code session to a Telegram bot. Same conversation, two windows. Control your coding sessions from your phone. + +## Setup + +Run the setup script with your Telegram bot token and user ID: + +```bash +cd scripts +./setup.sh +``` + +Get these from: +- Bot token: [@BotFather](https://t.me/BotFather) on Telegram +- User ID: [@userinfobot](https://t.me/userinfobot) on Telegram + +Setup handles everything automatically - creates `~/.buildr/`, starts PM2 relay daemon, configures Claude Code hooks. + +## What It Does + +- **Message mirroring**: Every CC response goes to Telegram, every TG message reaches CC +- **Permission forwarding**: Tool approval requests come to your phone - reply YES/NO +- **STOP command**: Send `STOP` to halt CC instantly from Telegram +- **Await system**: When CC is idle, it waits for your TG message (no timeout) +- **Offline fallback**: If CC dies (laptop closed), messages processed via `claude -p` +- **Persistence**: Optional tmux setup keeps CC alive when laptop closes + +## Protocol + +When this skill is active, follow these rules for every response: + +### 1. Mirror to Telegram +Write every response to the outbox: +``` +python3 -c "import json, os; home=os.environ.get('BUILDR_HOME',os.path.expanduser('~/.buildr')); open(os.path.join(home,'outbox.jsonl'),'a').write(json.dumps({'text':'YOUR MESSAGE HERE'}) + '\n')" +``` + +### 2. On Compaction +Send "COMPACTED" to Telegram so the user knows the session continued. + +### 3. When Idle/Waiting +Create the await flag, then make any tool call: +``` +touch ~/.buildr/await-flag +``` +The hook notifies the user on Telegram and polls until they respond. No timeout. + +## Rules +- Do NOT use curl or the Telegram API directly - use the outbox file only +- If user sends STOP on Telegram - halt immediately +- All Telegram messages arrive via the PreToolUse hook (blocks once to show you) +- Permission requests are forwarded to Telegram automatically +- The user can reply YES, NO, or send a custom message to any prompt + +## Architecture + +``` +Claude Code <-- inbox.jsonl -- Relay Daemon (PM2) <-- poll -- Telegram Bot + --> outbox.jsonl -> --> send -> + --> heartbeat +``` + +**Components** (in `scripts/`): +- `relay.js` - Always-on PM2 daemon. Polls TG, sends outbox, monitors heartbeat +- `bridge-hook.py` - PreToolUse hook. Auto-heartbeat, STOP, await, message injection +- `perm-hook.sh` - PermissionRequest hook. Forwards tool approvals to TG +- `setup.sh` - One-command installer +- `teardown.sh` - Clean uninstaller + +## Telegram Commands + +- `/status` - Check CC session status +- `/help` - Show available commands +- `/clear` - Clear message inbox +- `STOP` - Halt CC immediately + +## Uninstall + +```bash +cd scripts +./teardown.sh +``` + +## Requirements + +- Node.js 18+ +- Python 3 +- PM2 (`npm install -g pm2`) +- Claude Code CLI diff --git a/skills/buildr/scripts/bridge-hook.py b/skills/buildr/scripts/bridge-hook.py new file mode 100644 index 000000000..5a237be3b --- /dev/null +++ b/skills/buildr/scripts/bridge-hook.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +Buildr Bridge Hook - PreToolUse hook for CC <> Telegram bridge + +Runs before every Claude Code tool call: +1. Auto-heartbeat (keeps relay daemon informed CC is alive) +2. STOP flag → block CC, auto-enter await mode +3. Await flag → notify user on TG, poll until they respond (NO TIMEOUT) +4. New unread messages → inject into CC context (block once) +5. Otherwise → allow tool call + +Zero API calls. Pure file-based IPC. +""" +import json, os, sys, time + +# --- Paths --- +BUILDR_HOME = os.environ.get('BUILDR_HOME', os.path.join(os.path.expanduser('~'), '.buildr')) + +STOP_FLAG = os.path.join(BUILDR_HOME, 'stop-flag') +AWAIT_FLAG = os.path.join(BUILDR_HOME, 'await-flag') +INBOX = os.path.join(BUILDR_HOME, 'inbox.jsonl') +OUTBOX = os.path.join(BUILDR_HOME, 'outbox.jsonl') +SHOWN_FILE = os.path.join(BUILDR_HOME, 'hook-shown') +HEARTBEAT = os.path.join(BUILDR_HOME, 'heartbeat') + +# --- Auto-heartbeat on every tool call --- +try: + with open(HEARTBEAT, 'w') as f: + f.write(str(int(time.time() * 1000))) +except: + pass + +# --- Helpers --- +def read_inbox(): + msgs = [] + if os.path.exists(INBOX): + with open(INBOX) as f: + for line in f: + line = line.strip() + if line: + try: + msgs.append(json.loads(line)) + except: + pass + return msgs + +def get_last_shown(): + try: + with open(SHOWN_FILE) as f: + return int(f.read().strip()) + except: + return 0 + +def set_last_shown(uid): + with open(SHOWN_FILE, 'w') as f: + f.write(str(uid)) + +def write_outbox(text): + with open(OUTBOX, 'a') as f: + f.write(json.dumps({'text': text}) + '\n') + +def output(decision, reason=None): + result = {"decision": decision} + if reason: + result["reason"] = reason + print(json.dumps(result)) + sys.exit(0) + +# --- 1. STOP check --- +if os.path.exists(STOP_FLAG): + try: + os.unlink(STOP_FLAG) + except: + pass + # Auto-create await flag so CC enters wait mode on next tool call + with open(AWAIT_FLAG, 'w') as f: + f.write('stop') + msgs = read_inbox() + recent = msgs[-5:] + lines = [f" - {m.get('from','User')}: {m.get('text','')[:200]}" for m in recent] + reason = ( + "\U0001f6d1 STOP received from Telegram. Halting current work.\n\n" + "Recent messages:\n" + "\n".join(lines) + "\n\n" + "Await mode auto-created. Make any tool call to wait for user input.\n" + "The hook will notify the user on Telegram and wait for their response." + ) + output("block", reason) + +# --- 2. Await mode (poll TG until user responds - NO TIMEOUT) --- +if os.path.exists(AWAIT_FLAG): + try: + os.unlink(AWAIT_FLAG) + except: + pass + + # Clean up stale permission files so relay doesn't intercept await messages + for f in ['perm-reqid', 'perm-response']: + p = os.path.join(BUILDR_HOME, f) + if os.path.exists(p): + try: os.unlink(p) + except: pass + + # Notify user on TG + write_outbox( + "CC is waiting for you.\n\n" + "Reply:\n" + "\u2022 YES - continue\n" + "\u2022 Or send any message with instructions\n\n" + "(No timeout - waiting until you respond)" + ) + + # Baseline: current max update_id + msgs = read_inbox() + baseline = max((m.get('update_id', 0) for m in msgs), default=0) + + # Poll forever + while True: + time.sleep(2) + # Keep heartbeat alive during polling + try: + with open(HEARTBEAT, 'w') as f: + f.write(str(int(time.time() * 1000))) + except: + pass + msgs = read_inbox() + for m in msgs: + uid = m.get('update_id', 0) + text = m.get('text', '').strip() + if uid > baseline and text.lower() != 'stop': + set_last_shown(uid) + if text.lower() in ('yes', 'y'): + output("allow") + else: + reason = ( + "User responded on Telegram:\n\n" + f" [{m.get('from','User')}]: {text[:500]}\n\n" + "Process this message and respond.\n" + "Mirror your reply to TG via outbox:\n" + f"python3 -c \"import json; open('{OUTBOX}','a').write(json.dumps({{'text':'YOUR REPLY'}}) + '\\n')\"" + ) + output("block", reason) + +# --- 3. New unread messages → inject once --- +last_shown = get_last_shown() +msgs = read_inbox() +new_msgs = [] +max_uid = last_shown + +SKIP = {'yes', 'y', 'no', 'n', 'stop'} +for m in msgs: + uid = m.get('update_id', 0) + text = m.get('text', '').strip().lower() + if uid > last_shown and text not in SKIP: + new_msgs.append(m) + max_uid = max(max_uid, uid) + +if new_msgs: + set_last_shown(max_uid) + lines = [] + for m in new_msgs: + sender = m.get('from', 'User') + text = m.get('text', '')[:500] + lines.append(f" [{sender}]: {text}") + + reason = ( + "New Telegram message(s):\n\n" + + "\n".join(lines) + "\n\n" + "Respond to these, then mirror reply to TG via outbox:\n" + f"python3 -c \"import json; open('{OUTBOX}','a').write(json.dumps({{'text':'YOUR REPLY'}}) + '\\n')\"\n\n" + "After responding, continue with your current task." + ) + output("block", reason) + +# --- 4. All clear --- +output("allow") diff --git a/skills/buildr/scripts/perm-hook.sh b/skills/buildr/scripts/perm-hook.sh new file mode 100755 index 000000000..6258a03db --- /dev/null +++ b/skills/buildr/scripts/perm-hook.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# Buildr Permission Hook - PermissionRequest hook for CC <> Telegram bridge +# +# Forwards permission requests to Telegram via outbox file. +# Polls for user response (YES / NO / custom message). +# NO TIMEOUT - waits forever until user responds. + +BUILDR_HOME="${BUILDR_HOME:-$HOME/.buildr}" +OUTBOX="$BUILDR_HOME/outbox.jsonl" +RESPONSE_FILE="$BUILDR_HOME/perm-response" +REQID_FILE="$BUILDR_HOME/perm-reqid" +HEARTBEAT="$BUILDR_HOME/heartbeat" + +# Read permission request from stdin +INPUT=$(cat) + +# Parse tool info and build message +MSG=$(echo "$INPUT" | python3 -c " +import json, sys +try: + d = json.load(sys.stdin) + tool = d.get('tool_name', 'unknown') + ti = d.get('tool_input', {}) + cmd = ti.get('command', '') + desc = ti.get('description', '') + fp = ti.get('file_path', '') + parts = [] + if cmd: parts.append('Cmd: ' + cmd[:300]) + if desc: parts.append('Desc: ' + desc[:150]) + if fp: parts.append('File: ' + fp) + if not parts: parts.append(json.dumps(ti)[:300]) + detail = chr(10).join(parts) + print(f'PERMISSION REQUEST\n\nTool: {tool}\n{detail}\n\nReply YES or NO (waiting for your response)') +except: + print('PERMISSION REQUEST\n\nReply YES or NO') +" 2>/dev/null) + +# Fallback if parsing failed +if [ -z "$MSG" ]; then + MSG="PERMISSION REQUEST\n\nReply YES or NO" +fi + +# Clear previous state +rm -f "$RESPONSE_FILE" "$REQID_FILE" + +# Unique request ID +REQ_ID="perm_$(date +%s%3N)" +echo "$REQ_ID" > "$REQID_FILE" + +# Send to TG via outbox (relay delivers within 2s) +# Use base64 to safely pass message (avoids quote/escape issues) +MSG_B64=$(echo "$MSG" | base64 -w0) +python3 -c " +import json, os, base64 +msg = base64.b64decode('$MSG_B64').decode('utf-8').strip() +outbox = os.environ.get('BUILDR_HOME', os.path.expanduser('~/.buildr')) + '/outbox.jsonl' +with open(outbox, 'a') as f: + f.write(json.dumps({'text': msg}) + '\n') +" 2>/dev/null + +# Fallback if outbox write failed +if [ $? -ne 0 ]; then + python3 << 'PYEOF' +import json, os +outbox = os.environ.get('BUILDR_HOME', os.path.expanduser('~/.buildr')) + '/outbox.jsonl' +with open(outbox, 'a') as f: + f.write(json.dumps({"text": "PERMISSION REQUEST - Reply YES or NO"}) + "\n") +PYEOF +fi + +# Poll for response (NO TIMEOUT - waits forever) +while true; do + # Keep heartbeat alive while waiting + echo "$(date +%s%3N)" > "$HEARTBEAT" 2>/dev/null + + if [ -f "$RESPONSE_FILE" ]; then + RESP_REQID=$(head -1 "$RESPONSE_FILE") + RESP_ANSWER=$(tail -1 "$RESPONSE_FILE") + + if [ "$RESP_REQID" = "$REQ_ID" ]; then + rm -f "$RESPONSE_FILE" "$REQID_FILE" + RESP_LOWER=$(echo "$RESP_ANSWER" | tr '[:upper:]' '[:lower:]') + + if [ "$RESP_LOWER" = "yes" ] || [ "$RESP_LOWER" = "y" ]; then + echo '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}' + exit 0 + elif [ "$RESP_LOWER" = "no" ] || [ "$RESP_LOWER" = "n" ]; then + echo '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Denied via Telegram"}}}' + exit 0 + else + # Custom response - deny but include message so CC sees it + ESCAPED=$(echo "$RESP_ANSWER" | python3 -c "import json,sys; print(json.dumps(sys.stdin.read().strip()))" 2>/dev/null | sed 's/^"//;s/"$//') + echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"deny\",\"message\":\"User message from Telegram: $ESCAPED\"}}}" + exit 0 + fi + fi + fi + sleep 1 +done diff --git a/skills/buildr/scripts/relay.js b/skills/buildr/scripts/relay.js new file mode 100644 index 000000000..ea5e2bf34 --- /dev/null +++ b/skills/buildr/scripts/relay.js @@ -0,0 +1,361 @@ +#!/usr/bin/env node +/** + * Buildr Relay - Always-on Telegram relay daemon for Claude Code + * + * Run via PM2: pm2 start relay.js --name buildr-relay + * + * Responsibilities: + * 1. Poll Telegram for messages → write to inbox file + * 2. Watch outbox file → send to Telegram + * 3. Monitor CC heartbeat → detect offline, fallback to claude -p + * 4. Handle STOP, permissions, typing indicators + * 5. Slash commands: /status /help /clear /reconnect + */ + +const https = require('https'); +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +// --- Load config --- +const HOME = process.env.BUILDR_HOME || path.join(process.env.HOME || '/root', '.buildr'); +const CONFIG_FILE = path.join(HOME, 'config.env'); + +function loadConfig() { + const cfg = {}; + try { + const lines = fs.readFileSync(CONFIG_FILE, 'utf8').split('\n'); + for (const line of lines) { + const match = line.match(/^([A-Z_]+)=(.+)$/); + if (match) cfg[match[1]] = match[2].trim().replace(/^['"]|['"]$/g, ''); + } + } catch (err) { + console.error(`[relay] Cannot read config: ${CONFIG_FILE}`); + console.error('[relay] Run setup.sh first.'); + process.exit(1); + } + return cfg; +} + +const config = loadConfig(); +const TOKEN = config.BOT_TOKEN; +const USER_ID = config.USER_ID; + +if (!TOKEN || !USER_ID) { + console.error('[relay] BOT_TOKEN and USER_ID must be set in config.env'); + process.exit(1); +} + +// --- File paths --- +const INBOX = path.join(HOME, 'inbox.jsonl'); +const OUTBOX = path.join(HOME, 'outbox.jsonl'); +const OFFSET_FILE = path.join(HOME, 'poll-offset'); +const HEARTBEAT = path.join(HOME, 'heartbeat'); +const ALIVE_FILE = path.join(HOME, 'cc-alive'); +const STOP_FLAG = path.join(HOME, 'stop-flag'); +const PERM_REQID = path.join(HOME, 'perm-reqid'); +const PERM_RESP = path.join(HOME, 'perm-response'); + +// --- Settings --- +const HEARTBEAT_TIMEOUT = 5 * 60 * 1000; // 5 min = CC considered dead +const OUTBOX_INTERVAL = 2000; // check outbox every 2s +const HEARTBEAT_CHECK = 30000; // check heartbeat every 30s + +// --- State --- +let pollOffset = 0; +try { pollOffset = parseInt(fs.readFileSync(OFFSET_FILE, 'utf8').trim()) || 0; } catch {} +const seenIds = new Set(); +let ccAlive = false; +let lastDeadNotify = 0; +let offlineBusy = false; + +// --- Telegram API --- +function tg(method, body) { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(body || {}); + const req = https.request({ + hostname: 'api.telegram.org', + path: `/bot${TOKEN}/${method}`, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }, + timeout: 40000, + }, res => { + let data = ''; + res.on('data', c => data += c); + res.on('end', () => { + try { resolve(JSON.parse(data)); } catch { resolve({ ok: false }); } + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); }); + req.write(payload); + req.end(); + }); +} + +async function send(text) { + try { + // Split long messages (TG limit 4096) + const chunks = []; + let remaining = text; + while (remaining.length > 0) { + chunks.push(remaining.slice(0, 4000)); + remaining = remaining.slice(4000); + } + for (const chunk of chunks) { + await tg('sendMessage', { chat_id: USER_ID, text: chunk }); + } + } catch (err) { + console.error(`[relay] send error: ${err.message}`); + } +} + +function typing() { + return tg('sendChatAction', { chat_id: USER_ID, action: 'typing' }).catch(() => {}); +} + +// --- Helpers --- +function readFile(p) { + try { return fs.readFileSync(p, 'utf8').trim(); } catch { return null; } +} + +function countUnread() { + try { + const lastRead = parseInt(readFile(path.join(HOME, 'last-read')) || '0'); + const lines = fs.readFileSync(INBOX, 'utf8').trim().split('\n').filter(Boolean); + return lines.filter(l => { + try { return JSON.parse(l).update_id > lastRead; } catch { return false; } + }).length; + } catch { return 0; } +} + +// --- Telegram Polling --- +async function poll() { + while (true) { + try { + const res = await tg('getUpdates', { offset: pollOffset, timeout: 30, limit: 10 }); + if (!res.ok || !res.result || res.result.length === 0) continue; + + for (const update of res.result) { + pollOffset = update.update_id + 1; + fs.writeFileSync(OFFSET_FILE, String(pollOffset)); + + const msg = update.message; + if (!msg || !msg.text) continue; + if (String(msg.from?.id || '') !== USER_ID) continue; + + const uid = String(update.update_id); + if (seenIds.has(uid)) continue; + seenIds.add(uid); + + // Write to inbox + const entry = { + ts: Date.now() / 1000, + update_id: update.update_id, + chat_id: String(msg.chat.id), + text: msg.text, + from: msg.from.first_name || 'User', + }; + fs.appendFileSync(INBOX, JSON.stringify(entry) + '\n'); + console.log(`[relay] ${new Date().toISOString()} | in: ${msg.text.slice(0, 80)}`); + + const textLower = msg.text.trim().toLowerCase(); + + // Typing indicator when CC is alive + if (ccAlive && textLower !== 'stop' && !msg.text.startsWith('/')) { + typing(); + } + + // Permission response handling + if (fs.existsSync(PERM_REQID) && !fs.existsSync(PERM_RESP)) { + const reqId = readFile(PERM_REQID); + if (reqId) { + fs.writeFileSync(PERM_RESP, `${reqId}\n${msg.text.trim()}`); + if (textLower === 'yes' || textLower === 'y') { + console.log(`[relay] Permission APPROVED`); + await send('Permission APPROVED.'); + } else if (textLower === 'no' || textLower === 'n') { + console.log(`[relay] Permission DENIED`); + await send('Permission DENIED.'); + } else { + console.log(`[relay] Permission: custom response`); + await send('Custom response sent to CC.'); + } + try { fs.unlinkSync(PERM_REQID); } catch {} + } + } + + // STOP command + if (textLower === 'stop') { + fs.writeFileSync(STOP_FLAG, String(Date.now())); + console.log('[relay] STOP flag set'); + await send('STOP received. CC will halt on next action.'); + continue; + } + + // Clear STOP on any non-stop, non-perm message + if (fs.existsSync(STOP_FLAG) && !['yes', 'y', 'no', 'n'].includes(textLower)) { + try { fs.unlinkSync(STOP_FLAG); } catch {} + console.log('[relay] STOP cleared (new message)'); + } + + // Slash commands + if (msg.text.startsWith('/')) { + await handleCommand(msg.text); + continue; + } + + // Offline fallback + if (!ccAlive && !fs.existsSync(PERM_REQID) && + !['yes', 'y', 'no', 'n', 'stop'].includes(textLower)) { + offlineReply(msg.text); + } + } + } catch (err) { + console.error(`[relay] poll error: ${err.message}`); + await new Promise(r => setTimeout(r, 3000)); + } + } +} + +// --- Commands --- +async function handleCommand(text) { + const cmd = text.split(' ')[0].toLowerCase(); + switch (cmd) { + case '/status': { + const age = heartbeatAge(); + const hbStr = age === Infinity ? 'never' : `${Math.round(age / 1000)}s ago`; + const mode = ccAlive ? 'ONLINE (full session)' : 'OFFLINE (claude -p fallback)'; + await send(`Buildr Status\n\nCC: ${ccAlive ? 'ALIVE' : 'DOWN'} (heartbeat: ${hbStr})\nMode: ${mode}\nUnread: ${countUnread()}`); + break; + } + case '/clear': + fs.writeFileSync(INBOX, ''); + await send('Inbox cleared.'); + break; + case '/help': + await send( + 'Buildr Commands\n\n' + + '/status - Check CC session status\n' + + '/clear - Clear message inbox\n' + + '/help - Show this help\n' + + 'STOP - Halt CC immediately\n\n' + + 'Online: messages handled by CC hooks\n' + + 'Offline: messages processed via claude -p' + ); + break; + case '/reconnect': + await send('CC needs VS Code to reconnect for full session.\nOffline messages are processed with claude -p in the meantime.'); + break; + } +} + +// --- Heartbeat Monitor --- +function heartbeatAge() { + try { + return Date.now() - parseInt(fs.readFileSync(HEARTBEAT, 'utf8').trim()); + } catch { + return Infinity; + } +} + +function checkHeartbeat() { + const age = heartbeatAge(); + const wasAlive = ccAlive; + ccAlive = age < HEARTBEAT_TIMEOUT; + fs.writeFileSync(ALIVE_FILE, ccAlive ? '1' : '0'); + + if (wasAlive && !ccAlive) { + console.log(`[relay] CC went OFFLINE (heartbeat: ${Math.round(age / 1000)}s)`); + const now = Date.now(); + if (now - lastDeadNotify > 60000) { + lastDeadNotify = now; + send( + 'CC session went offline.\n\n' + + 'Send messages here - I\'ll process them with claude -p.\n' + + 'Full session resumes when VS Code reconnects.' + ); + } + } else if (!wasAlive && ccAlive) { + console.log('[relay] CC is back ONLINE'); + send('CC session is back online!'); + } +} + +// --- Outbox Watcher --- +async function checkOutbox() { + try { + if (!fs.existsSync(OUTBOX)) return; + const content = fs.readFileSync(OUTBOX, 'utf8').trim(); + if (!content) return; + fs.writeFileSync(OUTBOX, ''); // clear immediately + const lines = content.split('\n').filter(Boolean); + for (const line of lines) { + try { + const msg = JSON.parse(line); + await typing(); + await new Promise(r => setTimeout(r, 500)); + await send(msg.text); + console.log(`[relay] ${new Date().toISOString()} | out: ${msg.text.slice(0, 80)}`); + } catch (err) { + console.error(`[relay] outbox parse error: ${err.message}`); + } + } + } catch (err) { + if (err.code !== 'ENOENT') console.error(`[relay] outbox error: ${err.message}`); + } +} + +// --- Offline Fallback (claude -p) --- +async function offlineReply(text) { + if (offlineBusy) return; + offlineBusy = true; + try { + await typing(); + const prompt = `You are responding via Telegram. The user's Claude Code session is offline. ` + + `The user sent: "${text.replace(/"/g, '\\"')}"\n\n` + + `Respond helpfully and concisely. Complex tasks should wait for the full CC session.`; + const result = execSync( + `claude -p "${prompt.replace(/"/g, '\\"')}" --max-turns 1 2>/dev/null`, + { timeout: 120000, encoding: 'utf8', cwd: process.env.HOME || '/root' } + ); + if (result && result.trim()) { + await send(result.trim().slice(0, 4000)); + } else { + await send('Message received. Full processing resumes when VS Code reconnects.'); + } + } catch (err) { + console.error(`[relay] offline error: ${err.message}`); + await send('Got your message. Saved for when full session resumes.'); + } finally { + offlineBusy = false; + } +} + +// --- Intervals --- +setInterval(checkOutbox, OUTBOX_INTERVAL); +setInterval(checkHeartbeat, HEARTBEAT_CHECK); +setInterval(() => { if (seenIds.size > 1000) seenIds.clear(); }, 3600000); + +// Auto-cleanup stale permission files (prevents message interception) +setInterval(() => { + try { + if (fs.existsSync(PERM_REQID)) { + const age = Date.now() - fs.statSync(PERM_REQID).mtimeMs; + if (age > 120000) { // 2 min = stale + fs.unlinkSync(PERM_REQID); + try { fs.unlinkSync(PERM_RESP); } catch {} + console.log('[relay] Cleaned stale permission files'); + } + } + } catch {} +}, 30000); + +// --- Start --- +console.log(`[relay] Buildr Relay starting`); +console.log(`[relay] Home: ${HOME}`); +console.log(`[relay] User: ${USER_ID}`); +console.log(`[relay] Heartbeat timeout: ${HEARTBEAT_TIMEOUT / 1000}s`); + +checkHeartbeat(); +poll(); diff --git a/skills/buildr/scripts/setup.sh b/skills/buildr/scripts/setup.sh new file mode 100755 index 000000000..96855444e --- /dev/null +++ b/skills/buildr/scripts/setup.sh @@ -0,0 +1,300 @@ +#!/bin/bash +# ============================================================ +# Buildr Setup - One-command installer for CC <> Telegram bridge +# +# Usage: +# ./setup.sh +# ./setup.sh (interactive - prompts for token and user ID) +# +# What this does: +# 1. Creates ~/.buildr/ working directory +# 2. Copies relay, hooks to ~/.buildr/ +# 3. Writes config.env with token + user ID +# 4. Installs PM2 if needed, starts relay daemon +# 5. Adds PreToolUse + PermissionRequest hooks to Claude Code +# 6. Sets up tmux session for CC persistence (survives laptop close) +# 7. Sends test message to verify connection +# ============================================================ + +set -e + +BUILDR_HOME="${HOME}/.buildr" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SETTINGS_FILE="${HOME}/.claude/settings.json" + +# --- Colors --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +log() { echo -e "${GREEN}[buildr]${NC} $1"; } +warn() { echo -e "${YELLOW}[buildr]${NC} $1"; } +err() { echo -e "${RED}[buildr]${NC} $1"; } + +# --- Get credentials --- +BOT_TOKEN="${1:-}" +USER_ID="${2:-}" + +if [ -z "$BOT_TOKEN" ]; then + echo -e "${CYAN}Buildr Setup${NC}" + echo "" + echo "You need:" + echo " 1. A Telegram bot token (from @BotFather)" + echo " 2. Your Telegram user ID (from @userinfobot)" + echo "" + read -p "Bot token: " BOT_TOKEN +fi + +if [ -z "$USER_ID" ]; then + read -p "Your Telegram user ID: " USER_ID +fi + +if [ -z "$BOT_TOKEN" ] || [ -z "$USER_ID" ]; then + err "Both BOT_TOKEN and USER_ID are required." + exit 1 +fi + +log "Setting up Buildr..." +log "Home: $BUILDR_HOME" +log "User ID: $USER_ID" + +# --- 1. Create directory --- +mkdir -p "$BUILDR_HOME" +log "Created $BUILDR_HOME" + +# --- 2. Copy files --- +cp "$SCRIPT_DIR/relay.js" "$BUILDR_HOME/relay.js" +cp "$SCRIPT_DIR/bridge-hook.py" "$BUILDR_HOME/bridge-hook.py" +cp "$SCRIPT_DIR/perm-hook.sh" "$BUILDR_HOME/perm-hook.sh" +chmod +x "$BUILDR_HOME/perm-hook.sh" +chmod +x "$BUILDR_HOME/bridge-hook.py" +log "Copied relay, hooks to $BUILDR_HOME" + +# --- 3. Write config --- +cat > "$BUILDR_HOME/config.env" << EOF +BOT_TOKEN=$BOT_TOKEN +USER_ID=$USER_ID +EOF +log "Config written to $BUILDR_HOME/config.env" + +# --- 4. Initialize files --- +touch "$BUILDR_HOME/inbox.jsonl" +touch "$BUILDR_HOME/outbox.jsonl" +echo "0" > "$BUILDR_HOME/heartbeat" +log "Initialized data files" + +# --- 5. PM2 setup --- +if ! command -v pm2 &>/dev/null; then + warn "PM2 not found. Installing globally..." + npm install -g pm2 2>/dev/null || { + err "Failed to install PM2. Please install it: npm install -g pm2" + exit 1 + } +fi + +# Stop existing if running +pm2 delete buildr-relay 2>/dev/null || true + +# Create PM2 ecosystem file for proper env var passing +cat > "$BUILDR_HOME/ecosystem.config.js" << ECOEOF +module.exports = { + apps: [{ + name: 'buildr-relay', + script: '$BUILDR_HOME/relay.js', + env: { BUILDR_HOME: '$BUILDR_HOME' }, + autorestart: true, + restart_delay: 3000, + max_restarts: 50, + watch: false, + }] +}; +ECOEOF + +# Start relay daemon +pm2 start "$BUILDR_HOME/ecosystem.config.js" +pm2 save 2>/dev/null || true +log "Relay daemon started via PM2 (buildr-relay)" + +# Try to set up PM2 startup (survives server reboot) +pm2 startup 2>/dev/null || warn "Run 'pm2 startup' manually to survive reboots" + +# --- 6. Claude Code hooks --- +mkdir -p "$(dirname "$SETTINGS_FILE")" + +# Create or update settings.json +if [ -f "$SETTINGS_FILE" ]; then + # Merge hooks into existing settings + python3 << PYEOF +import json, os + +settings_file = "$SETTINGS_FILE" +buildr_home = "$BUILDR_HOME" + +with open(settings_file) as f: + settings = json.load(f) + +hooks = settings.get('hooks', {}) + +# PreToolUse hook +pre_hooks = hooks.get('PreToolUse', []) +# Remove any existing buildr hooks +pre_hooks = [h for h in pre_hooks if not any('buildr' in (hk.get('command','') or '') for hk in h.get('hooks', []))] +pre_hooks.append({ + "matcher": "", + "hooks": [{ + "type": "command", + "command": f"python3 {buildr_home}/bridge-hook.py", + "timeout": 86400 + }] +}) +hooks['PreToolUse'] = pre_hooks + +# PermissionRequest hook +perm_hooks = hooks.get('PermissionRequest', []) +perm_hooks = [h for h in perm_hooks if not any('buildr' in (hk.get('command','') or '') for hk in h.get('hooks', []))] +perm_hooks.append({ + "matcher": "", + "hooks": [{ + "type": "command", + "command": f"{buildr_home}/perm-hook.sh", + "timeout": 86400 + }] +}) +hooks['PermissionRequest'] = perm_hooks + +settings['hooks'] = hooks + +with open(settings_file, 'w') as f: + json.dump(settings, f, indent=2) + +print("Hooks merged into existing settings.json") +PYEOF +else + # Create fresh settings.json + python3 << PYEOF +import json + +buildr_home = "$BUILDR_HOME" +settings = { + "hooks": { + "PreToolUse": [{ + "matcher": "", + "hooks": [{ + "type": "command", + "command": f"python3 {buildr_home}/bridge-hook.py", + "timeout": 86400 + }] + }], + "PermissionRequest": [{ + "matcher": "", + "hooks": [{ + "type": "command", + "command": f"{buildr_home}/perm-hook.sh", + "timeout": 86400 + }] + }] + } +} + +with open("$SETTINGS_FILE", 'w') as f: + json.dump(settings, f, indent=2) + +print("Created settings.json with hooks") +PYEOF +fi +log "Claude Code hooks configured" + +# --- 7. Tmux persistence setup --- +# Create a startup script that launches CC in tmux +cat > "$BUILDR_HOME/start-cc.sh" << 'TMUXEOF' +#!/bin/bash +# Start Claude Code in a persistent tmux session +# This keeps CC alive even when VS Code / SSH disconnects + +SESSION="buildr-cc" +BUILDR_HOME="${BUILDR_HOME:-$HOME/.buildr}" + +# Check if tmux is available +if ! command -v tmux &>/dev/null; then + echo "[buildr] tmux not installed. Install with: apt install tmux" + exit 1 +fi + +# Check if session already exists +if tmux has-session -t "$SESSION" 2>/dev/null; then + echo "[buildr] tmux session '$SESSION' already running" + echo "[buildr] Attach with: tmux attach -t $SESSION" + exit 0 +fi + +# Create detached tmux session running claude +tmux new-session -d -s "$SESSION" -x 200 -y 50 +tmux send-keys -t "$SESSION" "export BUILDR_HOME=$BUILDR_HOME && claude" Enter + +echo "[buildr] Started CC in tmux session '$SESSION'" +echo "[buildr] Attach: tmux attach -t $SESSION" +echo "[buildr] Detach: Ctrl+B then D" +TMUXEOF +chmod +x "$BUILDR_HOME/start-cc.sh" +log "Tmux persistence script created: $BUILDR_HOME/start-cc.sh" + +# --- 8. Create CLAUDE.md instructions for new sessions --- +cat > "$BUILDR_HOME/CLAUDE.md" << 'CLAUDEEOF' +# Buildr - Telegram Bridge Protocol + +You are bridged to Telegram. Every response you give here is mirrored to the user's Telegram chat. + +## MANDATORY - Every Response: + +1. **Mirror to TG**: Write every response to the outbox file: + ``` + python3 -c "import json, os; home=os.environ.get('BUILDR_HOME',os.path.expanduser('~/.buildr')); open(os.path.join(home,'outbox.jsonl'),'a').write(json.dumps({'text':'YOUR MESSAGE HERE'}) + '\n')" + ``` + +2. **On compaction**: Send "COMPACTED" to TG (saves space, keeps user informed) + +3. **When done/waiting**: Create await flag, then make any tool call: + ``` + touch ~/.buildr/await-flag + ``` + The hook will notify the user and wait for their response. + +## Rules: +- Do NOT use curl/API for TG - use the outbox file only (relay delivers within 2s) +- If user says STOP on Telegram - halt immediately +- All TG messages arrive via the PreToolUse hook (blocks once to show you) +- Permission requests are forwarded to TG automatically +CLAUDEEOF +log "CLAUDE.md bridge instructions created" + +# --- 9. Test connection --- +log "Testing Telegram connection..." +python3 << PYEOF +import json +outbox = "$BUILDR_HOME/outbox.jsonl" +with open(outbox, 'a') as f: + f.write(json.dumps({'text': 'Buildr setup complete! Bridge is active.\n\nCommands:\n/status - Check status\n/help - Show help\nSTOP - Halt CC\n\nYour messages here will be forwarded to Claude Code.'}) + '\n') +PYEOF + +# Wait briefly for relay to pick up and send +sleep 3 + +echo "" +echo -e "${GREEN}============================================${NC}" +echo -e "${GREEN} Buildr Setup Complete!${NC}" +echo -e "${GREEN}============================================${NC}" +echo "" +echo -e " Home: ${CYAN}$BUILDR_HOME${NC}" +echo -e " Relay: ${CYAN}pm2 status buildr-relay${NC}" +echo -e " Logs: ${CYAN}pm2 logs buildr-relay${NC}" +echo "" +echo -e " ${YELLOW}For persistent CC (survives laptop close):${NC}" +echo -e " ${CYAN}$BUILDR_HOME/start-cc.sh${NC}" +echo "" +echo -e " ${YELLOW}To uninstall:${NC}" +echo -e " ${CYAN}$(dirname "$0")/teardown.sh${NC}" +echo "" +echo -e " Send a message to your bot on Telegram to test!" +echo "" diff --git a/skills/buildr/scripts/teardown.sh b/skills/buildr/scripts/teardown.sh new file mode 100755 index 000000000..5b46b8f5b --- /dev/null +++ b/skills/buildr/scripts/teardown.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# ============================================================ +# Buildr Teardown - Clean uninstaller +# +# Usage: ./teardown.sh +# +# What this does: +# 1. Stops and removes PM2 relay process +# 2. Kills tmux CC session if running +# 3. Removes Buildr hooks from Claude Code settings +# 4. Optionally removes ~/.buildr/ directory +# ============================================================ + +set -e + +BUILDR_HOME="${HOME}/.buildr" +SETTINGS_FILE="${HOME}/.claude/settings.json" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log() { echo -e "${GREEN}[buildr]${NC} $1"; } +warn() { echo -e "${YELLOW}[buildr]${NC} $1"; } + +echo -e "${YELLOW}Buildr Teardown${NC}" +echo "" + +# --- 1. Stop PM2 relay --- +if command -v pm2 &>/dev/null; then + if pm2 describe buildr-relay &>/dev/null; then + pm2 delete buildr-relay 2>/dev/null || true + pm2 save 2>/dev/null || true + log "Stopped PM2 relay daemon" + else + log "PM2 relay not running (already stopped)" + fi +else + warn "PM2 not found, skipping" +fi + +# --- 2. Kill tmux session --- +if command -v tmux &>/dev/null; then + if tmux has-session -t buildr-cc 2>/dev/null; then + tmux kill-session -t buildr-cc 2>/dev/null || true + log "Killed tmux CC session" + else + log "No tmux CC session running" + fi +fi + +# --- 3. Remove hooks from Claude Code settings --- +if [ -f "$SETTINGS_FILE" ]; then + python3 << PYEOF +import json + +with open("$SETTINGS_FILE") as f: + settings = json.load(f) + +hooks = settings.get('hooks', {}) +changed = False + +for hook_type in ['PreToolUse', 'PermissionRequest']: + if hook_type in hooks: + original = len(hooks[hook_type]) + hooks[hook_type] = [ + h for h in hooks[hook_type] + if not any('buildr' in (hk.get('command','') or '') for hk in h.get('hooks', [])) + ] + if len(hooks[hook_type]) != original: + changed = True + if not hooks[hook_type]: + del hooks[hook_type] + +if not hooks: + if 'hooks' in settings: + del settings['hooks'] + +with open("$SETTINGS_FILE", 'w') as f: + json.dump(settings, f, indent=2) + +if changed: + print("Removed Buildr hooks from settings.json") +else: + print("No Buildr hooks found in settings.json") +PYEOF + log "Claude Code hooks cleaned" +else + log "No settings.json found, skipping" +fi + +# --- 4. Remove data directory --- +echo "" +if [ -d "$BUILDR_HOME" ]; then + read -p "Remove $BUILDR_HOME and all data? [y/N]: " CONFIRM + if [ "$CONFIRM" = "y" ] || [ "$CONFIRM" = "Y" ]; then + rm -rf "$BUILDR_HOME" + log "Removed $BUILDR_HOME" + else + log "Kept $BUILDR_HOME (you can remove it manually later)" + fi +else + log "No $BUILDR_HOME directory found" +fi + +echo "" +echo -e "${GREEN}Buildr has been uninstalled.${NC}" +echo ""