@selucas12/cheesy 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +22 -0
- package/CHANGELOG.md +268 -0
- package/LICENSE +21 -0
- package/README.md +394 -0
- package/ccgram.service +24 -0
- package/config/channels.json +58 -0
- package/config/default.json +27 -0
- package/config/defaults/config.json +16 -0
- package/config/defaults/i18n.json +32 -0
- package/config/email-template.json +31 -0
- package/config/test-with-subagent.json +16 -0
- package/config/user.json +27 -0
- package/dist/claude-hook-notify.d.ts +7 -0
- package/dist/claude-hook-notify.d.ts.map +1 -0
- package/dist/claude-hook-notify.js +154 -0
- package/dist/claude-hook-notify.js.map +1 -0
- package/dist/claude-remote.d.ts +50 -0
- package/dist/claude-remote.d.ts.map +1 -0
- package/dist/claude-remote.js +927 -0
- package/dist/claude-remote.js.map +1 -0
- package/dist/elicitation-notify.d.ts +20 -0
- package/dist/elicitation-notify.d.ts.map +1 -0
- package/dist/elicitation-notify.js +241 -0
- package/dist/elicitation-notify.js.map +1 -0
- package/dist/enhanced-hook-notify.d.ts +23 -0
- package/dist/enhanced-hook-notify.d.ts.map +1 -0
- package/dist/enhanced-hook-notify.js +402 -0
- package/dist/enhanced-hook-notify.js.map +1 -0
- package/dist/permission-denied-notify.d.ts +11 -0
- package/dist/permission-denied-notify.d.ts.map +1 -0
- package/dist/permission-denied-notify.js +193 -0
- package/dist/permission-denied-notify.js.map +1 -0
- package/dist/permission-hook.d.ts +15 -0
- package/dist/permission-hook.d.ts.map +1 -0
- package/dist/permission-hook.js +386 -0
- package/dist/permission-hook.js.map +1 -0
- package/dist/pre-compact-notify.d.ts +13 -0
- package/dist/pre-compact-notify.d.ts.map +1 -0
- package/dist/pre-compact-notify.js +197 -0
- package/dist/pre-compact-notify.js.map +1 -0
- package/dist/prompt-bridge.d.ts +50 -0
- package/dist/prompt-bridge.d.ts.map +1 -0
- package/dist/prompt-bridge.js +173 -0
- package/dist/prompt-bridge.js.map +1 -0
- package/dist/question-notify.d.ts +17 -0
- package/dist/question-notify.d.ts.map +1 -0
- package/dist/question-notify.js +356 -0
- package/dist/question-notify.js.map +1 -0
- package/dist/setup.d.ts +10 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +649 -0
- package/dist/setup.js.map +1 -0
- package/dist/smart-monitor.d.ts +7 -0
- package/dist/smart-monitor.d.ts.map +1 -0
- package/dist/smart-monitor.js +256 -0
- package/dist/smart-monitor.js.map +1 -0
- package/dist/src/automation/clipboard-automation.d.ts +35 -0
- package/dist/src/automation/clipboard-automation.d.ts.map +1 -0
- package/dist/src/automation/clipboard-automation.js +242 -0
- package/dist/src/automation/clipboard-automation.js.map +1 -0
- package/dist/src/channels/base/channel.d.ts +60 -0
- package/dist/src/channels/base/channel.d.ts.map +1 -0
- package/dist/src/channels/base/channel.js +96 -0
- package/dist/src/channels/base/channel.js.map +1 -0
- package/dist/src/channels/email/smtp.d.ts +74 -0
- package/dist/src/channels/email/smtp.d.ts.map +1 -0
- package/dist/src/channels/email/smtp.js +605 -0
- package/dist/src/channels/email/smtp.js.map +1 -0
- package/dist/src/channels/line/line.d.ts +36 -0
- package/dist/src/channels/line/line.d.ts.map +1 -0
- package/dist/src/channels/line/line.js +180 -0
- package/dist/src/channels/line/line.js.map +1 -0
- package/dist/src/channels/line/webhook.d.ts +55 -0
- package/dist/src/channels/line/webhook.d.ts.map +1 -0
- package/dist/src/channels/line/webhook.js +191 -0
- package/dist/src/channels/line/webhook.js.map +1 -0
- package/dist/src/channels/local/desktop.d.ts +30 -0
- package/dist/src/channels/local/desktop.d.ts.map +1 -0
- package/dist/src/channels/local/desktop.js +161 -0
- package/dist/src/channels/local/desktop.js.map +1 -0
- package/dist/src/channels/telegram/telegram.d.ts +43 -0
- package/dist/src/channels/telegram/telegram.d.ts.map +1 -0
- package/dist/src/channels/telegram/telegram.js +223 -0
- package/dist/src/channels/telegram/telegram.js.map +1 -0
- package/dist/src/channels/telegram/webhook.d.ts +75 -0
- package/dist/src/channels/telegram/webhook.d.ts.map +1 -0
- package/dist/src/channels/telegram/webhook.js +278 -0
- package/dist/src/channels/telegram/webhook.js.map +1 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +99 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/commands/hooks.d.ts +10 -0
- package/dist/src/commands/hooks.d.ts.map +1 -0
- package/dist/src/commands/hooks.js +50 -0
- package/dist/src/commands/hooks.js.map +1 -0
- package/dist/src/commands/init.d.ts +20 -0
- package/dist/src/commands/init.d.ts.map +1 -0
- package/dist/src/commands/init.js +173 -0
- package/dist/src/commands/init.js.map +1 -0
- package/dist/src/commands/license.d.ts +15 -0
- package/dist/src/commands/license.d.ts.map +1 -0
- package/dist/src/commands/license.js +107 -0
- package/dist/src/commands/license.js.map +1 -0
- package/dist/src/commands/start.d.ts +17 -0
- package/dist/src/commands/start.d.ts.map +1 -0
- package/dist/src/commands/start.js +150 -0
- package/dist/src/commands/start.js.map +1 -0
- package/dist/src/commands/status.d.ts +8 -0
- package/dist/src/commands/status.d.ts.map +1 -0
- package/dist/src/commands/status.js +95 -0
- package/dist/src/commands/status.js.map +1 -0
- package/dist/src/commands/stop.d.ts +8 -0
- package/dist/src/commands/stop.d.ts.map +1 -0
- package/dist/src/commands/stop.js +64 -0
- package/dist/src/commands/stop.js.map +1 -0
- package/dist/src/config-manager.d.ts +16 -0
- package/dist/src/config-manager.d.ts.map +1 -0
- package/dist/src/config-manager.js +152 -0
- package/dist/src/config-manager.js.map +1 -0
- package/dist/src/core/config.d.ts +28 -0
- package/dist/src/core/config.d.ts.map +1 -0
- package/dist/src/core/config.js +248 -0
- package/dist/src/core/config.js.map +1 -0
- package/dist/src/core/logger.d.ts +19 -0
- package/dist/src/core/logger.d.ts.map +1 -0
- package/dist/src/core/logger.js +47 -0
- package/dist/src/core/logger.js.map +1 -0
- package/dist/src/core/notifier.d.ts +45 -0
- package/dist/src/core/notifier.d.ts.map +1 -0
- package/dist/src/core/notifier.js +189 -0
- package/dist/src/core/notifier.js.map +1 -0
- package/dist/src/lib/license-validator.d.ts +120 -0
- package/dist/src/lib/license-validator.d.ts.map +1 -0
- package/dist/src/lib/license-validator.js +294 -0
- package/dist/src/lib/license-validator.js.map +1 -0
- package/dist/src/lib/preflight.d.ts +28 -0
- package/dist/src/lib/preflight.d.ts.map +1 -0
- package/dist/src/lib/preflight.js +90 -0
- package/dist/src/lib/preflight.js.map +1 -0
- package/dist/src/relay/claude-command-bridge.d.ts +57 -0
- package/dist/src/relay/claude-command-bridge.d.ts.map +1 -0
- package/dist/src/relay/claude-command-bridge.js +188 -0
- package/dist/src/relay/claude-command-bridge.js.map +1 -0
- package/dist/src/relay/email-listener.d.ts +65 -0
- package/dist/src/relay/email-listener.d.ts.map +1 -0
- package/dist/src/relay/email-listener.js +460 -0
- package/dist/src/relay/email-listener.js.map +1 -0
- package/dist/src/relay/relay-pty.d.ts +21 -0
- package/dist/src/relay/relay-pty.d.ts.map +1 -0
- package/dist/src/relay/relay-pty.js +696 -0
- package/dist/src/relay/relay-pty.js.map +1 -0
- package/dist/src/relay/smart-injector.d.ts +30 -0
- package/dist/src/relay/smart-injector.d.ts.map +1 -0
- package/dist/src/relay/smart-injector.js +233 -0
- package/dist/src/relay/smart-injector.js.map +1 -0
- package/dist/src/relay/tmux-injector.d.ts +46 -0
- package/dist/src/relay/tmux-injector.d.ts.map +1 -0
- package/dist/src/relay/tmux-injector.js +413 -0
- package/dist/src/relay/tmux-injector.js.map +1 -0
- package/dist/src/tools/config-manager.d.ts +33 -0
- package/dist/src/tools/config-manager.d.ts.map +1 -0
- package/dist/src/tools/config-manager.js +448 -0
- package/dist/src/tools/config-manager.js.map +1 -0
- package/dist/src/tools/installer.d.ts +38 -0
- package/dist/src/tools/installer.d.ts.map +1 -0
- package/dist/src/tools/installer.js +222 -0
- package/dist/src/tools/installer.js.map +1 -0
- package/dist/src/types/callbacks.d.ts +53 -0
- package/dist/src/types/callbacks.d.ts.map +1 -0
- package/dist/src/types/callbacks.js +7 -0
- package/dist/src/types/callbacks.js.map +1 -0
- package/dist/src/types/config.d.ts +56 -0
- package/dist/src/types/config.d.ts.map +1 -0
- package/dist/src/types/config.js +6 -0
- package/dist/src/types/config.js.map +1 -0
- package/dist/src/types/hooks.d.ts +47 -0
- package/dist/src/types/hooks.d.ts.map +1 -0
- package/dist/src/types/hooks.js +6 -0
- package/dist/src/types/hooks.js.map +1 -0
- package/dist/src/types/index.d.ts +7 -0
- package/dist/src/types/index.d.ts.map +1 -0
- package/dist/src/types/index.js +23 -0
- package/dist/src/types/index.js.map +1 -0
- package/dist/src/types/ipc.d.ts +43 -0
- package/dist/src/types/ipc.d.ts.map +1 -0
- package/dist/src/types/ipc.js +7 -0
- package/dist/src/types/ipc.js.map +1 -0
- package/dist/src/types/session.d.ts +87 -0
- package/dist/src/types/session.d.ts.map +1 -0
- package/dist/src/types/session.js +9 -0
- package/dist/src/types/session.js.map +1 -0
- package/dist/src/types/telegram.d.ts +58 -0
- package/dist/src/types/telegram.d.ts.map +1 -0
- package/dist/src/types/telegram.js +6 -0
- package/dist/src/types/telegram.js.map +1 -0
- package/dist/src/utils/active-check.d.ts +20 -0
- package/dist/src/utils/active-check.d.ts.map +1 -0
- package/dist/src/utils/active-check.js +42 -0
- package/dist/src/utils/active-check.js.map +1 -0
- package/dist/src/utils/callback-parser.d.ts +23 -0
- package/dist/src/utils/callback-parser.d.ts.map +1 -0
- package/dist/src/utils/callback-parser.js +85 -0
- package/dist/src/utils/callback-parser.js.map +1 -0
- package/dist/src/utils/controller-injector.d.ts +21 -0
- package/dist/src/utils/controller-injector.d.ts.map +1 -0
- package/dist/src/utils/controller-injector.js +108 -0
- package/dist/src/utils/controller-injector.js.map +1 -0
- package/dist/src/utils/conversation-tracker.d.ts +32 -0
- package/dist/src/utils/conversation-tracker.d.ts.map +1 -0
- package/dist/src/utils/conversation-tracker.js +119 -0
- package/dist/src/utils/conversation-tracker.js.map +1 -0
- package/dist/src/utils/deep-link.d.ts +22 -0
- package/dist/src/utils/deep-link.d.ts.map +1 -0
- package/dist/src/utils/deep-link.js +43 -0
- package/dist/src/utils/deep-link.js.map +1 -0
- package/dist/src/utils/ghostty-session-manager.d.ts +81 -0
- package/dist/src/utils/ghostty-session-manager.d.ts.map +1 -0
- package/dist/src/utils/ghostty-session-manager.js +370 -0
- package/dist/src/utils/ghostty-session-manager.js.map +1 -0
- package/dist/src/utils/hook-definitions.d.ts +25 -0
- package/dist/src/utils/hook-definitions.d.ts.map +1 -0
- package/dist/src/utils/hook-definitions.js +36 -0
- package/dist/src/utils/hook-definitions.js.map +1 -0
- package/dist/src/utils/http-request.d.ts +25 -0
- package/dist/src/utils/http-request.d.ts.map +1 -0
- package/dist/src/utils/http-request.js +66 -0
- package/dist/src/utils/http-request.js.map +1 -0
- package/dist/src/utils/optional-require.d.ts +13 -0
- package/dist/src/utils/optional-require.d.ts.map +1 -0
- package/dist/src/utils/optional-require.js +37 -0
- package/dist/src/utils/optional-require.js.map +1 -0
- package/dist/src/utils/paths.d.ts +13 -0
- package/dist/src/utils/paths.d.ts.map +1 -0
- package/dist/src/utils/paths.js +30 -0
- package/dist/src/utils/paths.js.map +1 -0
- package/dist/src/utils/pty-session-manager.d.ts +43 -0
- package/dist/src/utils/pty-session-manager.d.ts.map +1 -0
- package/dist/src/utils/pty-session-manager.js +183 -0
- package/dist/src/utils/pty-session-manager.js.map +1 -0
- package/dist/src/utils/subagent-tracker.d.ts +64 -0
- package/dist/src/utils/subagent-tracker.d.ts.map +1 -0
- package/dist/src/utils/subagent-tracker.js +191 -0
- package/dist/src/utils/subagent-tracker.js.map +1 -0
- package/dist/src/utils/tmux-monitor.d.ts +102 -0
- package/dist/src/utils/tmux-monitor.d.ts.map +1 -0
- package/dist/src/utils/tmux-monitor.js +642 -0
- package/dist/src/utils/tmux-monitor.js.map +1 -0
- package/dist/src/utils/trace-capture.d.ts +42 -0
- package/dist/src/utils/trace-capture.d.ts.map +1 -0
- package/dist/src/utils/trace-capture.js +102 -0
- package/dist/src/utils/trace-capture.js.map +1 -0
- package/dist/src/utils/transcript-reader.d.ts +57 -0
- package/dist/src/utils/transcript-reader.d.ts.map +1 -0
- package/dist/src/utils/transcript-reader.js +229 -0
- package/dist/src/utils/transcript-reader.js.map +1 -0
- package/dist/start-all-webhooks.d.ts +7 -0
- package/dist/start-all-webhooks.d.ts.map +1 -0
- package/dist/start-all-webhooks.js +98 -0
- package/dist/start-all-webhooks.js.map +1 -0
- package/dist/start-line-webhook.d.ts +7 -0
- package/dist/start-line-webhook.d.ts.map +1 -0
- package/dist/start-line-webhook.js +59 -0
- package/dist/start-line-webhook.js.map +1 -0
- package/dist/start-relay-pty.d.ts +7 -0
- package/dist/start-relay-pty.d.ts.map +1 -0
- package/dist/start-relay-pty.js +173 -0
- package/dist/start-relay-pty.js.map +1 -0
- package/dist/start-telegram-webhook.d.ts +7 -0
- package/dist/start-telegram-webhook.d.ts.map +1 -0
- package/dist/start-telegram-webhook.js +80 -0
- package/dist/start-telegram-webhook.js.map +1 -0
- package/dist/user-prompt-hook.d.ts +13 -0
- package/dist/user-prompt-hook.d.ts.map +1 -0
- package/dist/user-prompt-hook.js +45 -0
- package/dist/user-prompt-hook.js.map +1 -0
- package/dist/workspace-router.d.ts +114 -0
- package/dist/workspace-router.d.ts.map +1 -0
- package/dist/workspace-router.js +572 -0
- package/dist/workspace-router.js.map +1 -0
- package/dist/workspace-telegram-bot.d.ts +3 -0
- package/dist/workspace-telegram-bot.d.ts.map +1 -0
- package/dist/workspace-telegram-bot.js +1847 -0
- package/dist/workspace-telegram-bot.js.map +1 -0
- package/package.json +85 -0
- package/src/types/callbacks.ts +73 -0
- package/src/types/config.ts +63 -0
- package/src/types/hooks.ts +50 -0
- package/src/types/index.ts +6 -0
- package/src/types/ipc.ts +55 -0
- package/src/types/session.ts +91 -0
- package/src/types/telegram.ts +66 -0
|
@@ -0,0 +1,1847 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
// Node.js version check — must run before anything else
|
|
8
|
+
const [major] = process.versions.node.split('.').map(Number);
|
|
9
|
+
if (major < 18) {
|
|
10
|
+
console.error(`Cheesyboy requires Node.js >= 18.0.0 (you have ${process.version}).`);
|
|
11
|
+
console.error('Upgrade: https://nodejs.org/ or use nvm: nvm install 18');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Workspace Telegram Bot — long-polling bot for remote Claude Code control.
|
|
16
|
+
*
|
|
17
|
+
* Commands:
|
|
18
|
+
* /<workspace> <command> Route a command to the Claude session in that workspace
|
|
19
|
+
* /sessions List all active sessions with workspace names
|
|
20
|
+
* /cmd <TOKEN> <command> Token-based fallback for direct session access
|
|
21
|
+
* /help Show available commands
|
|
22
|
+
* /status [workspace] Show tmux pane output for a workspace
|
|
23
|
+
* /stop [workspace] Interrupt running prompt (Ctrl+C)
|
|
24
|
+
* /compact [workspace] Compact context in a workspace session
|
|
25
|
+
*/
|
|
26
|
+
const path_1 = __importDefault(require("path"));
|
|
27
|
+
const paths_1 = require("./src/utils/paths");
|
|
28
|
+
require('dotenv').config({ path: path_1.default.join(paths_1.PROJECT_ROOT, '.env'), quiet: true });
|
|
29
|
+
const https_1 = __importDefault(require("https"));
|
|
30
|
+
const http_1 = __importDefault(require("http"));
|
|
31
|
+
const fs_1 = __importDefault(require("fs"));
|
|
32
|
+
const child_process_1 = require("child_process");
|
|
33
|
+
const workspace_router_1 = require("./workspace-router");
|
|
34
|
+
const prompt_bridge_1 = require("./prompt-bridge");
|
|
35
|
+
const callback_parser_1 = require("./src/utils/callback-parser");
|
|
36
|
+
const pty_session_manager_1 = require("./src/utils/pty-session-manager");
|
|
37
|
+
const ghostty_session_manager_1 = require("./src/utils/ghostty-session-manager");
|
|
38
|
+
const deep_link_1 = require("./src/utils/deep-link");
|
|
39
|
+
const transcript_reader_1 = require("./src/utils/transcript-reader");
|
|
40
|
+
const logger_1 = __importDefault(require("./src/core/logger"));
|
|
41
|
+
const logger = new logger_1.default('bot');
|
|
42
|
+
const INJECTION_MODE = process.env.INJECTION_MODE || 'tmux';
|
|
43
|
+
const TMUX_AVAILABLE = (() => {
|
|
44
|
+
try {
|
|
45
|
+
(0, child_process_1.execSync)('tmux -V', { stdio: 'ignore' });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
})();
|
|
52
|
+
/**
|
|
53
|
+
* Determine the active injection backend.
|
|
54
|
+
* Respects INJECTION_MODE env var, falls back in order: ghostty → pty → tmux.
|
|
55
|
+
*/
|
|
56
|
+
function getEffectiveMode() {
|
|
57
|
+
if (INJECTION_MODE === 'ghostty' && ghostty_session_manager_1.ghosttySessionManager.isAvailable())
|
|
58
|
+
return 'ghostty';
|
|
59
|
+
if (INJECTION_MODE === 'pty' && pty_session_manager_1.ptySessionManager.isAvailable())
|
|
60
|
+
return 'pty';
|
|
61
|
+
if (TMUX_AVAILABLE && INJECTION_MODE !== 'pty' && INJECTION_MODE !== 'ghostty')
|
|
62
|
+
return 'tmux';
|
|
63
|
+
if (ghostty_session_manager_1.ghosttySessionManager.isAvailable())
|
|
64
|
+
return 'ghostty';
|
|
65
|
+
if (pty_session_manager_1.ptySessionManager.isAvailable())
|
|
66
|
+
return 'pty';
|
|
67
|
+
return 'tmux';
|
|
68
|
+
}
|
|
69
|
+
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
|
|
70
|
+
const CHAT_ID = process.env.TELEGRAM_CHAT_ID;
|
|
71
|
+
if (!BOT_TOKEN || BOT_TOKEN === 'YOUR_BOT_TOKEN_HERE') {
|
|
72
|
+
logger.error('TELEGRAM_BOT_TOKEN not configured in .env');
|
|
73
|
+
logger.error(' Get your token from @BotFather: https://t.me/BotFather');
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
if (!CHAT_ID || CHAT_ID === 'YOUR_CHAT_ID_HERE') {
|
|
77
|
+
logger.error('TELEGRAM_CHAT_ID not configured in .env');
|
|
78
|
+
logger.error(' Get your chat ID from @userinfobot: https://t.me/userinfobot');
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
let lastUpdateId = 0;
|
|
82
|
+
let lastPollTime = null; // timestamp of last successful getUpdates call
|
|
83
|
+
const startTime = Date.now();
|
|
84
|
+
const activeTypingIntervals = new Map(); // workspace → intervalId
|
|
85
|
+
// ── Telegram API helpers ────────────────────────────────────────
|
|
86
|
+
function telegramAPI(method, body) {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
const payload = JSON.stringify(body);
|
|
89
|
+
const options = {
|
|
90
|
+
hostname: 'api.telegram.org',
|
|
91
|
+
path: `/bot${BOT_TOKEN}/${method}`,
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: {
|
|
94
|
+
'Content-Type': 'application/json',
|
|
95
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
96
|
+
},
|
|
97
|
+
timeout: method === 'getUpdates' ? 35000 : 10000,
|
|
98
|
+
};
|
|
99
|
+
const req = https_1.default.request(options, (res) => {
|
|
100
|
+
let data = '';
|
|
101
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
102
|
+
res.on('end', () => {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(data);
|
|
105
|
+
if (!parsed.ok) {
|
|
106
|
+
reject(new Error(`Telegram API error: ${parsed.description || data}`));
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
resolve(parsed.result);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
reject(new Error(`Invalid JSON from Telegram: ${data.slice(0, 200)}`));
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
req.on('error', reject);
|
|
118
|
+
req.on('timeout', () => {
|
|
119
|
+
req.destroy();
|
|
120
|
+
reject(new Error('Telegram request timed out'));
|
|
121
|
+
});
|
|
122
|
+
req.write(payload);
|
|
123
|
+
req.end();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function sendMessage(text) {
|
|
127
|
+
return telegramAPI('sendMessage', {
|
|
128
|
+
chat_id: CHAT_ID,
|
|
129
|
+
text,
|
|
130
|
+
parse_mode: 'Markdown',
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
function sendHtmlMessage(text) {
|
|
134
|
+
return telegramAPI('sendMessage', {
|
|
135
|
+
chat_id: CHAT_ID,
|
|
136
|
+
text,
|
|
137
|
+
parse_mode: 'HTML',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const TYPING_SIGNAL_PATH = path_1.default.join(paths_1.PROJECT_ROOT, 'src/data', 'typing-active');
|
|
141
|
+
function startTypingIndicator() {
|
|
142
|
+
stopTypingIndicator();
|
|
143
|
+
try {
|
|
144
|
+
fs_1.default.writeFileSync(TYPING_SIGNAL_PATH, String(Date.now()));
|
|
145
|
+
}
|
|
146
|
+
catch { }
|
|
147
|
+
const tick = () => {
|
|
148
|
+
if (!fs_1.default.existsSync(TYPING_SIGNAL_PATH)) {
|
|
149
|
+
stopTypingIndicator();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
telegramAPI('sendChatAction', { chat_id: CHAT_ID, action: 'typing' }).catch(() => { });
|
|
153
|
+
};
|
|
154
|
+
tick();
|
|
155
|
+
const intervalId = setInterval(tick, 4500);
|
|
156
|
+
const timeoutId = setTimeout(() => stopTypingIndicator(), 5 * 60 * 1000);
|
|
157
|
+
activeTypingIntervals.set('_active', { intervalId, timeoutId });
|
|
158
|
+
}
|
|
159
|
+
function stopTypingIndicator() {
|
|
160
|
+
const entry = activeTypingIntervals.get('_active');
|
|
161
|
+
if (entry) {
|
|
162
|
+
clearInterval(entry.intervalId);
|
|
163
|
+
clearTimeout(entry.timeoutId);
|
|
164
|
+
activeTypingIntervals.delete('_active');
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
fs_1.default.unlinkSync(TYPING_SIGNAL_PATH);
|
|
168
|
+
}
|
|
169
|
+
catch { }
|
|
170
|
+
}
|
|
171
|
+
async function registerBotCommands() {
|
|
172
|
+
const commands = [
|
|
173
|
+
{ command: 'new', description: 'Start Claude in a project directory' },
|
|
174
|
+
{ command: 'resume', description: 'Resume a past Claude conversation' },
|
|
175
|
+
{ command: 'link', description: 'Generate deep link to open Claude' },
|
|
176
|
+
{ command: 'sessions', description: 'List all active Claude sessions' },
|
|
177
|
+
{ command: 'use', description: 'Set or show default workspace' },
|
|
178
|
+
{ command: 'status', description: 'Show current session output' },
|
|
179
|
+
{ command: 'stop', description: 'Interrupt the running prompt' },
|
|
180
|
+
{ command: 'compact', description: 'Compact context in the current session' },
|
|
181
|
+
{ command: 'effort', description: 'Set thinking effort (low/medium/high)' },
|
|
182
|
+
{ command: 'model', description: 'Switch Claude model (sonnet/opus/haiku)' },
|
|
183
|
+
{ command: 'help', description: 'Show available commands' },
|
|
184
|
+
];
|
|
185
|
+
try {
|
|
186
|
+
// Set for both scopes: all_private_chats takes priority over default in private chats.
|
|
187
|
+
// If all_private_chats was ever set (e.g. via BotFather), default scope is blocked.
|
|
188
|
+
await telegramAPI('setMyCommands', { commands, scope: { type: 'all_private_chats' } });
|
|
189
|
+
await telegramAPI('setMyCommands', { commands, scope: { type: 'default' } });
|
|
190
|
+
logger.info('Bot commands registered with Telegram');
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
logger.error(`Failed to register bot commands: ${err.message}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function answerCallbackQuery(callbackQueryId, text) {
|
|
197
|
+
return telegramAPI('answerCallbackQuery', {
|
|
198
|
+
callback_query_id: callbackQueryId,
|
|
199
|
+
text: text || '',
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
function editMessageText(chatId, messageId, text, replyMarkup) {
|
|
203
|
+
const body = {
|
|
204
|
+
chat_id: chatId,
|
|
205
|
+
message_id: messageId,
|
|
206
|
+
text,
|
|
207
|
+
parse_mode: 'Markdown',
|
|
208
|
+
};
|
|
209
|
+
if (replyMarkup)
|
|
210
|
+
body.reply_markup = replyMarkup;
|
|
211
|
+
return telegramAPI('editMessageText', body);
|
|
212
|
+
}
|
|
213
|
+
// ── Command handlers ────────────────────────────────────────────
|
|
214
|
+
/**
|
|
215
|
+
* Helper to resolve workspace from arg or default.
|
|
216
|
+
* Returns the resolved result with workspace and session.
|
|
217
|
+
*/
|
|
218
|
+
function resolveDefaultWorkspace() {
|
|
219
|
+
const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
|
|
220
|
+
if (!defaultWs) {
|
|
221
|
+
return { type: 'none' };
|
|
222
|
+
}
|
|
223
|
+
return (0, workspace_router_1.resolveWorkspace)(defaultWs);
|
|
224
|
+
}
|
|
225
|
+
async function handleHelp() {
|
|
226
|
+
const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
|
|
227
|
+
const msg = [
|
|
228
|
+
'*Claude Remote Control*',
|
|
229
|
+
'',
|
|
230
|
+
'`/<workspace> <command>` — Send command to workspace',
|
|
231
|
+
'`/use <workspace>` — Set default workspace',
|
|
232
|
+
'`/use` — Show current default',
|
|
233
|
+
'`/use clear` — Clear default',
|
|
234
|
+
'`/compact [workspace]` — Compact context in workspace',
|
|
235
|
+
'`/new [project]` — Start Claude in a project (shows recent if no arg)',
|
|
236
|
+
'`/resume [project]` — Resume a past Claude conversation',
|
|
237
|
+
'`/link <prompt>` — Generate deep link to open Claude',
|
|
238
|
+
'`/effort [workspace] low|medium|high` — Set thinking effort',
|
|
239
|
+
'`/model [workspace] <model>` — Switch Claude model',
|
|
240
|
+
'`/sessions` — List active sessions',
|
|
241
|
+
'`/status [workspace]` — Show tmux output',
|
|
242
|
+
'`/stop [workspace]` — Interrupt running prompt',
|
|
243
|
+
'`/cmd <TOKEN> <command>` — Token-based fallback',
|
|
244
|
+
'`/help` — This message',
|
|
245
|
+
'',
|
|
246
|
+
'_Prefix matching:_ `/ass hello` matches `assistant`',
|
|
247
|
+
'_Reply-to:_ Reply to any notification to route to that workspace',
|
|
248
|
+
defaultWs ? `_Default:_ plain text routes to *${escapeMarkdown(defaultWs)}*` : '_Tip:_ Use `/use <workspace>` to send plain text without a prefix',
|
|
249
|
+
].join('\n');
|
|
250
|
+
await sendMessage(msg);
|
|
251
|
+
}
|
|
252
|
+
async function handleLink(prompt) {
|
|
253
|
+
if (!prompt) {
|
|
254
|
+
await sendMessage('Usage: `/link <prompt>`\n\nGenerates a clickable link that opens Claude Code with your prompt.');
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (!(0, deep_link_1.canGenerateDeepLink)(prompt)) {
|
|
258
|
+
await sendMessage('\u26a0\ufe0f Prompt too long for deep link (max ~4500 characters).');
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const deepLink = (0, deep_link_1.generateDeepLink)(prompt);
|
|
262
|
+
if (!deepLink) {
|
|
263
|
+
await sendMessage('\u26a0\ufe0f Failed to generate deep link.');
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
// Send the deep link as a clickable button
|
|
267
|
+
const keyboard = {
|
|
268
|
+
inline_keyboard: [[
|
|
269
|
+
{ text: '\ud83d\udcbb Open in Claude Code', url: deepLink },
|
|
270
|
+
]],
|
|
271
|
+
};
|
|
272
|
+
await telegramAPI('sendMessage', {
|
|
273
|
+
chat_id: CHAT_ID,
|
|
274
|
+
text: `*Deep Link Generated*\n\n_Tap the button to open Claude Code with:_\n\`${escapeMarkdown(prompt.slice(0, 100))}${prompt.length > 100 ? '...' : ''}\``,
|
|
275
|
+
parse_mode: 'Markdown',
|
|
276
|
+
reply_markup: keyboard,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* /effort [workspace] low|medium|high — Set Claude's thinking effort level
|
|
281
|
+
*/
|
|
282
|
+
async function handleEffort(args) {
|
|
283
|
+
const validLevels = ['low', 'medium', 'high'];
|
|
284
|
+
if (!args) {
|
|
285
|
+
await sendMessage('Usage: `/effort [workspace] low|medium|high`\n\nSet Claude\'s thinking effort level.');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const parts = args.split(/\s+/);
|
|
289
|
+
let workspaceArg = null;
|
|
290
|
+
let level;
|
|
291
|
+
// Check if first arg is a valid level or a workspace
|
|
292
|
+
if (validLevels.includes(parts[0].toLowerCase())) {
|
|
293
|
+
level = parts[0].toLowerCase();
|
|
294
|
+
}
|
|
295
|
+
else if (parts.length >= 2 && validLevels.includes(parts[1].toLowerCase())) {
|
|
296
|
+
workspaceArg = parts[0];
|
|
297
|
+
level = parts[1].toLowerCase();
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
await sendMessage(`Invalid effort level. Use: \`low\`, \`medium\`, or \`high\``);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// Resolve workspace
|
|
304
|
+
const resolved = workspaceArg ? (0, workspace_router_1.resolveWorkspace)(workspaceArg) : resolveDefaultWorkspace();
|
|
305
|
+
if (resolved.type === 'none') {
|
|
306
|
+
await sendMessage(workspaceArg
|
|
307
|
+
? `No session found for workspace \`${escapeMarkdown(workspaceArg)}\``
|
|
308
|
+
: 'No default workspace set. Use `/use <workspace>` first.');
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (resolved.type === 'ambiguous') {
|
|
312
|
+
const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
|
|
313
|
+
await sendMessage(`Multiple matches: ${names}. Be more specific.`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const session = resolved.match.session;
|
|
317
|
+
const workspace = resolved.workspace;
|
|
318
|
+
const slashCommand = `/effort ${level}`;
|
|
319
|
+
const injected = await injectAndRespond(session, slashCommand, workspace);
|
|
320
|
+
if (injected) {
|
|
321
|
+
startTypingIndicator();
|
|
322
|
+
await sendMessage(`\u2699\ufe0f Effort set to *${level}* in *${escapeMarkdown(workspace)}*`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* /model [workspace] <model> — Switch Claude model
|
|
327
|
+
*/
|
|
328
|
+
async function handleModel(args) {
|
|
329
|
+
if (!args) {
|
|
330
|
+
await sendMessage('Usage: `/model [workspace] <model>`\n\nSwitch Claude model (e.g., `sonnet`, `opus`, `haiku`).');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const parts = args.split(/\s+/);
|
|
334
|
+
let workspaceArg = null;
|
|
335
|
+
let model;
|
|
336
|
+
// If 2+ parts, first might be workspace
|
|
337
|
+
if (parts.length >= 2) {
|
|
338
|
+
// Try to resolve first part as workspace
|
|
339
|
+
const maybeWs = (0, workspace_router_1.resolveWorkspace)(parts[0]);
|
|
340
|
+
if (maybeWs.type === 'exact' || maybeWs.type === 'prefix') {
|
|
341
|
+
workspaceArg = parts[0];
|
|
342
|
+
model = parts.slice(1).join(' ');
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
model = parts.join(' ');
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
model = parts[0];
|
|
350
|
+
}
|
|
351
|
+
// Resolve workspace
|
|
352
|
+
const resolved = workspaceArg ? (0, workspace_router_1.resolveWorkspace)(workspaceArg) : resolveDefaultWorkspace();
|
|
353
|
+
if (resolved.type === 'none') {
|
|
354
|
+
await sendMessage(workspaceArg
|
|
355
|
+
? `No session found for workspace \`${escapeMarkdown(workspaceArg)}\``
|
|
356
|
+
: 'No default workspace set. Use `/use <workspace>` first.');
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (resolved.type === 'ambiguous') {
|
|
360
|
+
const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
|
|
361
|
+
await sendMessage(`Multiple matches: ${names}. Be more specific.`);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const session = resolved.match.session;
|
|
365
|
+
const workspace = resolved.workspace;
|
|
366
|
+
const slashCommand = `/model ${model}`;
|
|
367
|
+
const injected = await injectAndRespond(session, slashCommand, workspace);
|
|
368
|
+
if (injected) {
|
|
369
|
+
startTypingIndicator();
|
|
370
|
+
await sendMessage(`\ud83e\udde0 Model switched to *${escapeMarkdown(model)}* in *${escapeMarkdown(workspace)}*`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async function handleSessions() {
|
|
374
|
+
(0, workspace_router_1.pruneExpired)();
|
|
375
|
+
const sessions = (0, workspace_router_1.listActiveSessions)();
|
|
376
|
+
if (sessions.length === 0) {
|
|
377
|
+
await sendMessage('No active sessions.');
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const lines = sessions.map((s) => {
|
|
381
|
+
const icon = sessionIcon(s);
|
|
382
|
+
return `${icon} *${escapeMarkdown(s.workspace)}* (${s.age})`;
|
|
383
|
+
});
|
|
384
|
+
let footer = '';
|
|
385
|
+
const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
|
|
386
|
+
if (defaultWs) {
|
|
387
|
+
footer = `\n\n_Default workspace:_ *${escapeMarkdown(defaultWs)}*`;
|
|
388
|
+
}
|
|
389
|
+
await sendMessage(`*Active Sessions*\n\n${lines.join('\n')}${footer}`);
|
|
390
|
+
}
|
|
391
|
+
async function handleStatus(workspaceArg) {
|
|
392
|
+
let workspace;
|
|
393
|
+
if (workspaceArg) {
|
|
394
|
+
workspace = workspaceArg;
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
|
|
398
|
+
if (defaultWs) {
|
|
399
|
+
workspace = defaultWs;
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
await sendMessage('Usage: `/status <workspace>` or set a default with `/use`.');
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const resolved = (0, workspace_router_1.resolveWorkspace)(workspace);
|
|
407
|
+
if (resolved.type === 'none') {
|
|
408
|
+
await sendMessage(`No active session for *${escapeMarkdown(workspace)}*.`);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (resolved.type === 'ambiguous') {
|
|
412
|
+
const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
|
|
413
|
+
await sendMessage(`Multiple matches: ${names}. Be more specific.`);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const match = resolved.match;
|
|
417
|
+
const resolvedName = resolved.workspace;
|
|
418
|
+
const tmuxName = match.session.tmuxSession;
|
|
419
|
+
const session = match.session;
|
|
420
|
+
// Read transcript status (model, context, branch, last message, ...).
|
|
421
|
+
// Available for any terminal type — Ghostty, tmux, PTY, bare.
|
|
422
|
+
const transcript = (0, transcript_reader_1.readTranscriptStatus)(session.cwd, session.sessionId ?? undefined);
|
|
423
|
+
// For tmux/PTY, capture the pane output as before.
|
|
424
|
+
// For Ghostty (where scrollback capture doesn't work via AppleScript),
|
|
425
|
+
// skip the pane capture and rely on the transcript's last assistant message.
|
|
426
|
+
const useGhostty = (isGhosttySession(session) || ghostty_session_manager_1.ghosttySessionManager.has(tmuxName));
|
|
427
|
+
let paneOutput = null;
|
|
428
|
+
if (!useGhostty) {
|
|
429
|
+
try {
|
|
430
|
+
const raw = await sessionCaptureOutput(tmuxName, session);
|
|
431
|
+
paneOutput = raw.trim().split('\n').slice(-20).join('\n');
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
paneOutput = `(capture failed: ${err.message})`;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const htmlMsg = buildStatusMessage(resolvedName, session, transcript, paneOutput);
|
|
438
|
+
try {
|
|
439
|
+
await sendHtmlMessage(htmlMsg);
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
// Fallback: strip HTML tags and send as plain text.
|
|
443
|
+
const plain = htmlMsg.replace(/<[^>]+>/g, '');
|
|
444
|
+
await telegramAPI('sendMessage', { chat_id: CHAT_ID, text: plain });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/** Format the rich /status message as HTML for Telegram. */
|
|
448
|
+
function buildStatusMessage(workspace, session, transcript, paneOutput) {
|
|
449
|
+
const lines = [];
|
|
450
|
+
lines.push(`\u{1F4CA} <b>${escapeHtml(workspace)}</b>`);
|
|
451
|
+
// Model + version
|
|
452
|
+
if (transcript?.model) {
|
|
453
|
+
const versionStr = transcript.version ? ` <i>(cc ${escapeHtml(transcript.version)})</i>` : '';
|
|
454
|
+
lines.push(`\u{1F916} <b>Model:</b> <code>${escapeHtml(transcript.model)}</code>${versionStr}`);
|
|
455
|
+
}
|
|
456
|
+
// Working directory
|
|
457
|
+
const cwd = transcript?.cwd || session.cwd;
|
|
458
|
+
if (cwd)
|
|
459
|
+
lines.push(`\u{1F4C1} <b>Path:</b> <code>${escapeHtml(cwd)}</code>`);
|
|
460
|
+
// Git branch
|
|
461
|
+
if (transcript?.gitBranch) {
|
|
462
|
+
lines.push(`\u{1F33F} <b>Branch:</b> <code>${escapeHtml(transcript.gitBranch)}</code>`);
|
|
463
|
+
}
|
|
464
|
+
// Session
|
|
465
|
+
if (transcript?.sessionId || session.sessionId) {
|
|
466
|
+
const sid = (transcript?.sessionId || session.sessionId);
|
|
467
|
+
const slugStr = transcript?.slug ? ` <i>(${escapeHtml(transcript.slug)})</i>` : '';
|
|
468
|
+
lines.push(`\u{1F194} <b>Session:</b> <code>${escapeHtml(sid.slice(0, 8))}</code>${slugStr}`);
|
|
469
|
+
}
|
|
470
|
+
// Context window usage
|
|
471
|
+
if (transcript?.contextTokens !== undefined) {
|
|
472
|
+
const tokensStr = transcript.contextTokens.toLocaleString();
|
|
473
|
+
if (transcript.contextLimit && transcript.contextPct !== undefined) {
|
|
474
|
+
const limitStr = transcript.contextLimit.toLocaleString();
|
|
475
|
+
lines.push(`\u{1F4C8} <b>Context:</b> ${tokensStr} / ${limitStr} (${transcript.contextPct}%)`);
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
lines.push(`\u{1F4C8} <b>Context:</b> ${tokensStr} tokens`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// Last activity
|
|
482
|
+
if (transcript?.lastAssistantTimestamp) {
|
|
483
|
+
const ago = formatRelativeTime(new Date(transcript.lastAssistantTimestamp));
|
|
484
|
+
if (ago)
|
|
485
|
+
lines.push(`\u23F1 <b>Last activity:</b> ${ago}`);
|
|
486
|
+
}
|
|
487
|
+
// Rate limit
|
|
488
|
+
const rateLimit = (0, workspace_router_1.getSessionRateLimit)(workspace);
|
|
489
|
+
if (rateLimit && rateLimit.remaining !== undefined) {
|
|
490
|
+
const pct = rateLimit.limit ? Math.round((rateLimit.remaining / rateLimit.limit) * 100) : null;
|
|
491
|
+
const pctStr = pct !== null ? ` (${pct}%)` : '';
|
|
492
|
+
let resetStr = '';
|
|
493
|
+
if (rateLimit.resetsAt) {
|
|
494
|
+
const resetMs = rateLimit.resetsAt * 1000 - Date.now();
|
|
495
|
+
if (resetMs > 0) {
|
|
496
|
+
const mins = Math.ceil(resetMs / 60000);
|
|
497
|
+
resetStr = mins > 60 ? ` \u2022 resets in ${Math.round(mins / 60)}h` : ` \u2022 resets in ${mins}m`;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
lines.push(`\u{1F4E1} <b>Rate limit:</b> ${rateLimit.remaining}/${rateLimit.limit || '?'}${pctStr}${resetStr}`);
|
|
501
|
+
}
|
|
502
|
+
// Last assistant message (Ghostty only — for tmux/PTY we show pane output instead)
|
|
503
|
+
if (paneOutput === null && transcript?.lastAssistantMessage) {
|
|
504
|
+
lines.push('');
|
|
505
|
+
lines.push('\u{1F4AC} <b>Last message:</b>');
|
|
506
|
+
lines.push(`<pre>${escapeHtml(transcript.lastAssistantMessage)}</pre>`);
|
|
507
|
+
}
|
|
508
|
+
// Pane output for tmux/PTY
|
|
509
|
+
if (paneOutput !== null) {
|
|
510
|
+
lines.push('');
|
|
511
|
+
lines.push('\u{1F4DD} <b>Recent output:</b>');
|
|
512
|
+
lines.push(`<pre>${escapeHtml(paneOutput)}</pre>`);
|
|
513
|
+
}
|
|
514
|
+
// Fallback when we have nothing
|
|
515
|
+
if (!transcript && paneOutput === null) {
|
|
516
|
+
lines.push('');
|
|
517
|
+
lines.push('<i>No transcript or pane output available.</i>');
|
|
518
|
+
}
|
|
519
|
+
return lines.join('\n');
|
|
520
|
+
}
|
|
521
|
+
/** Format a Date as "Xs ago" / "Xm ago" / "Xh ago" relative to now. */
|
|
522
|
+
function formatRelativeTime(d) {
|
|
523
|
+
const ms = Date.now() - d.getTime();
|
|
524
|
+
if (ms < 0)
|
|
525
|
+
return null;
|
|
526
|
+
const sec = Math.round(ms / 1000);
|
|
527
|
+
if (sec < 60)
|
|
528
|
+
return `${sec}s ago`;
|
|
529
|
+
const min = Math.round(sec / 60);
|
|
530
|
+
if (min < 60)
|
|
531
|
+
return `${min} min ago`;
|
|
532
|
+
const hr = Math.round(min / 60);
|
|
533
|
+
if (hr < 24)
|
|
534
|
+
return `${hr}h ago`;
|
|
535
|
+
const day = Math.round(hr / 24);
|
|
536
|
+
return `${day}d ago`;
|
|
537
|
+
}
|
|
538
|
+
async function handleStop(workspaceArg) {
|
|
539
|
+
let workspace;
|
|
540
|
+
if (workspaceArg) {
|
|
541
|
+
workspace = workspaceArg;
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
|
|
545
|
+
if (defaultWs) {
|
|
546
|
+
workspace = defaultWs;
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
await sendMessage('Usage: `/stop <workspace>` or set a default with `/use`.');
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const resolved = (0, workspace_router_1.resolveWorkspace)(workspace);
|
|
554
|
+
if (resolved.type === 'none') {
|
|
555
|
+
await sendMessage(`No active session for *${escapeMarkdown(workspace)}*.`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (resolved.type === 'ambiguous') {
|
|
559
|
+
const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
|
|
560
|
+
await sendMessage(`Multiple matches: ${names}. Be more specific.`);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const resolvedName = resolved.workspace;
|
|
564
|
+
const tmuxName = resolved.match.session.tmuxSession;
|
|
565
|
+
if (!await sessionExists(tmuxName, resolved.match.session)) {
|
|
566
|
+
await sendMessage(`Session \`${tmuxName}\` not found.`);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
try {
|
|
570
|
+
await sessionInterrupt(tmuxName, resolved.match.session);
|
|
571
|
+
await sendMessage(`\u26d4 Sent interrupt to *${escapeMarkdown(resolvedName)}*`);
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
await sendMessage(`\u274c Failed to interrupt: ${err.message}`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
async function handleCmd(token, command) {
|
|
578
|
+
const map = (0, workspace_router_1.readSessionMap)();
|
|
579
|
+
const session = map[token];
|
|
580
|
+
if (!session) {
|
|
581
|
+
await sendMessage(`No session found for token \`${token}\`.`);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if ((0, workspace_router_1.isExpired)(session)) {
|
|
585
|
+
await sendMessage(`Session \`${token}\` has expired.`);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
await injectAndRespond(session, command, (0, workspace_router_1.extractWorkspaceName)(session.cwd));
|
|
589
|
+
}
|
|
590
|
+
async function handleWorkspaceCommand(workspace, command) {
|
|
591
|
+
const resolved = (0, workspace_router_1.resolveWorkspace)(workspace);
|
|
592
|
+
if (resolved.type === 'none') {
|
|
593
|
+
await sendMessage(`No active session for *${escapeMarkdown(workspace)}*. Use /sessions to see available workspaces.`);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (resolved.type === 'ambiguous') {
|
|
597
|
+
const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
|
|
598
|
+
await sendMessage(`Multiple matches: ${names}. Be more specific.`);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
await injectAndRespond(resolved.match.session, command, resolved.workspace);
|
|
602
|
+
}
|
|
603
|
+
async function handleUse(arg) {
|
|
604
|
+
// /use — show current default
|
|
605
|
+
if (!arg) {
|
|
606
|
+
const current = (0, workspace_router_1.getDefaultWorkspace)();
|
|
607
|
+
if (current) {
|
|
608
|
+
await sendMessage(`Default workspace: *${escapeMarkdown(current)}*\n\nPlain text messages will route here. Use \`/use clear\` to unset.`);
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
await sendMessage('No default workspace set. Use `/use <workspace>` to set one.');
|
|
612
|
+
}
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
// /use clear | /use none — clear default
|
|
616
|
+
if (arg === 'clear' || arg === 'none') {
|
|
617
|
+
(0, workspace_router_1.setDefaultWorkspace)(null);
|
|
618
|
+
await sendMessage('Default workspace cleared.');
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
// /use <workspace> — resolve and set
|
|
622
|
+
const resolved = (0, workspace_router_1.resolveWorkspace)(arg);
|
|
623
|
+
if (resolved.type === 'none') {
|
|
624
|
+
await sendMessage(`No active session for *${escapeMarkdown(arg)}*. Use /sessions to see available workspaces.`);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (resolved.type === 'ambiguous') {
|
|
628
|
+
const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
|
|
629
|
+
await sendMessage(`Multiple matches: ${names}. Be more specific.`);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const fullName = resolved.workspace;
|
|
633
|
+
(0, workspace_router_1.setDefaultWorkspace)(fullName);
|
|
634
|
+
await sendMessage(`Default workspace set to *${escapeMarkdown(fullName)}*. Plain text messages will route here.`);
|
|
635
|
+
}
|
|
636
|
+
async function handleCompact(workspaceArg) {
|
|
637
|
+
let workspace;
|
|
638
|
+
if (workspaceArg) {
|
|
639
|
+
workspace = workspaceArg;
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
|
|
643
|
+
if (defaultWs) {
|
|
644
|
+
workspace = defaultWs;
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
await sendMessage('Usage: `/compact <workspace>` or set a default with `/use`.');
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const resolved = (0, workspace_router_1.resolveWorkspace)(workspace);
|
|
652
|
+
if (resolved.type === 'none') {
|
|
653
|
+
await sendMessage(`No active session for *${escapeMarkdown(workspace)}*. Use /sessions to see available workspaces.`);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (resolved.type === 'ambiguous') {
|
|
657
|
+
const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
|
|
658
|
+
await sendMessage(`Multiple matches: ${names}. Be more specific.`);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
const tmuxName = resolved.match.session.tmuxSession;
|
|
662
|
+
const compactSession = resolved.match.session;
|
|
663
|
+
// Inject /compact into tmux
|
|
664
|
+
const injected = await injectAndRespond(resolved.match.session, '/compact', resolved.workspace);
|
|
665
|
+
if (!injected)
|
|
666
|
+
return;
|
|
667
|
+
// Two-phase polling to detect compact completion:
|
|
668
|
+
// Phase 1: Wait for "Compacting" to appear (command started processing)
|
|
669
|
+
// Phase 2: Wait for "Compacting" to disappear (command finished)
|
|
670
|
+
let started = false;
|
|
671
|
+
// Phase 1: Wait up to 10s for compact to start
|
|
672
|
+
for (let i = 0; i < 5; i++) {
|
|
673
|
+
await sleep(2000);
|
|
674
|
+
try {
|
|
675
|
+
const output = await sessionCaptureOutput(tmuxName, compactSession);
|
|
676
|
+
if (output.includes('Compacting')) {
|
|
677
|
+
started = true;
|
|
678
|
+
break;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (!started) {
|
|
686
|
+
// Command may have finished very quickly or failed to start
|
|
687
|
+
try {
|
|
688
|
+
const output = await sessionCaptureOutput(tmuxName, compactSession);
|
|
689
|
+
if (output.includes('Compacted')) {
|
|
690
|
+
const lines = output.trim().split('\n').slice(-10).join('\n');
|
|
691
|
+
await sendMessage(`\u2705 *${escapeMarkdown(resolved.workspace)}* compact done:\n\`\`\`\n${lines}\n\`\`\``);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
// ignore
|
|
696
|
+
}
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
// Phase 2: Wait up to 60s for "Compacting" to disappear (compact finished)
|
|
700
|
+
for (let i = 0; i < 30; i++) {
|
|
701
|
+
await sleep(2000);
|
|
702
|
+
try {
|
|
703
|
+
const output = await sessionCaptureOutput(tmuxName, compactSession);
|
|
704
|
+
if (!output.includes('Compacting')) {
|
|
705
|
+
const lines = output.trim().split('\n').slice(-10).join('\n');
|
|
706
|
+
await sendMessage(`\u2705 *${escapeMarkdown(resolved.workspace)}* compact done:\n\`\`\`\n${lines}\n\`\`\``);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
catch {
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// Timeout — show current session state
|
|
715
|
+
try {
|
|
716
|
+
const output = await sessionCaptureOutput(tmuxName, compactSession);
|
|
717
|
+
const trimmed = output.trim().split('\n').slice(-5).join('\n');
|
|
718
|
+
await sendMessage(`\u23f3 *${escapeMarkdown(resolved.workspace)}* compact may still be running:\n\`\`\`\n${trimmed}\n\`\`\``);
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
// ignore
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
async function handleNew(nameArg) {
|
|
725
|
+
if (!nameArg) {
|
|
726
|
+
const recent = (0, workspace_router_1.getRecentProjects)(10);
|
|
727
|
+
if (recent.length === 0) {
|
|
728
|
+
const home = process.env.HOME;
|
|
729
|
+
const dirs = process.env.PROJECT_DIRS
|
|
730
|
+
? process.env.PROJECT_DIRS.split(',').map(d => d.trim().replace(home, '~')).join(', ')
|
|
731
|
+
: '~/projects, ~/tools';
|
|
732
|
+
await sendMessage(`No project history yet.\n\nUse \`/new <project-name>\` to start.\nSearches: ${dirs}, ~/`);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const keyboard = [];
|
|
736
|
+
for (let i = 0; i < recent.length; i += 2) {
|
|
737
|
+
const row = recent.slice(i, i + 2).map(p => ({
|
|
738
|
+
text: p.name,
|
|
739
|
+
callback_data: `new:${p.name}`,
|
|
740
|
+
}));
|
|
741
|
+
keyboard.push(row);
|
|
742
|
+
}
|
|
743
|
+
await telegramAPI('sendMessage', {
|
|
744
|
+
chat_id: CHAT_ID,
|
|
745
|
+
text: '*Start Claude Session*\n\nSelect a project or use `/new <name>`:',
|
|
746
|
+
parse_mode: 'Markdown',
|
|
747
|
+
reply_markup: { inline_keyboard: keyboard },
|
|
748
|
+
});
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
await startProject(nameArg);
|
|
752
|
+
}
|
|
753
|
+
async function startProject(name) {
|
|
754
|
+
const home = process.env.HOME;
|
|
755
|
+
// 1. Find project directory — exact match first
|
|
756
|
+
const configuredDirs = process.env.PROJECT_DIRS
|
|
757
|
+
? process.env.PROJECT_DIRS.split(',').map(d => d.trim()).filter(Boolean)
|
|
758
|
+
: [path_1.default.join(home, 'projects'), path_1.default.join(home, 'tools')];
|
|
759
|
+
const candidates = [
|
|
760
|
+
...configuredDirs.map(d => path_1.default.join(d, name)),
|
|
761
|
+
path_1.default.join(home, name),
|
|
762
|
+
];
|
|
763
|
+
let projectDir = null;
|
|
764
|
+
for (const dir of candidates) {
|
|
765
|
+
try {
|
|
766
|
+
if (fs_1.default.statSync(dir).isDirectory()) {
|
|
767
|
+
projectDir = dir;
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
catch { }
|
|
772
|
+
}
|
|
773
|
+
// 2. If no exact match, prefix match against configured dirs ONLY
|
|
774
|
+
// (skip ~/ to avoid matching Desktop, Documents, Downloads, Library, etc.)
|
|
775
|
+
if (!projectDir) {
|
|
776
|
+
const searchDirs = configuredDirs;
|
|
777
|
+
const matches = [];
|
|
778
|
+
for (const base of searchDirs) {
|
|
779
|
+
try {
|
|
780
|
+
const entries = fs_1.default.readdirSync(base, { withFileTypes: true });
|
|
781
|
+
for (const e of entries) {
|
|
782
|
+
if (e.isDirectory() && e.name.toLowerCase().startsWith(name.toLowerCase())) {
|
|
783
|
+
matches.push({ name: e.name, path: path_1.default.join(base, e.name) });
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
catch { }
|
|
788
|
+
}
|
|
789
|
+
// Deduplicate by name (prefer ~/projects/ over ~/tools/)
|
|
790
|
+
const unique = [...new Map(matches.map(m => [m.name, m])).values()];
|
|
791
|
+
if (unique.length === 1) {
|
|
792
|
+
projectDir = unique[0].path;
|
|
793
|
+
name = unique[0].name;
|
|
794
|
+
}
|
|
795
|
+
else if (unique.length > 1) {
|
|
796
|
+
// Show matches as inline buttons (max 10)
|
|
797
|
+
const limited = unique.slice(0, 10);
|
|
798
|
+
const keyboard = [];
|
|
799
|
+
for (let i = 0; i < limited.length; i += 2) {
|
|
800
|
+
keyboard.push(limited.slice(i, i + 2).map(m => ({
|
|
801
|
+
text: m.name, callback_data: `new:${m.name}`,
|
|
802
|
+
})));
|
|
803
|
+
}
|
|
804
|
+
await telegramAPI('sendMessage', {
|
|
805
|
+
chat_id: CHAT_ID,
|
|
806
|
+
text: `Multiple matches for *${escapeMarkdown(name)}*:`,
|
|
807
|
+
parse_mode: 'Markdown',
|
|
808
|
+
reply_markup: { inline_keyboard: keyboard },
|
|
809
|
+
});
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (!projectDir) {
|
|
814
|
+
const searchedPaths = configuredDirs.map(d => d.replace(home, '~')).join(', ') + ', ~/';
|
|
815
|
+
await sendMessage(`Project \`${escapeMarkdown(name)}\` not found.\n\nSearched: ${searchedPaths}`);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
// 3. Sanitize tmux session name (dots, colons, spaces are invalid in tmux)
|
|
819
|
+
const tmuxName = name.replace(/[.:\s]/g, '-');
|
|
820
|
+
// 4. Check existing session (PTY, Ghostty, or tmux)
|
|
821
|
+
const existingEntry = Object.values((0, workspace_router_1.readSessionMap)()).find(s => s.tmuxSession === tmuxName && !(0, workspace_router_1.isExpired)(s));
|
|
822
|
+
const alreadyRunning = existingEntry
|
|
823
|
+
? await sessionExists(tmuxName, existingEntry)
|
|
824
|
+
: await sessionExists(tmuxName);
|
|
825
|
+
if (alreadyRunning) {
|
|
826
|
+
(0, workspace_router_1.upsertSession)({ cwd: projectDir, tmuxSession: tmuxName, status: 'waiting', sessionId: null });
|
|
827
|
+
(0, workspace_router_1.recordProjectUsage)(name, projectDir);
|
|
828
|
+
(0, workspace_router_1.setDefaultWorkspace)(name);
|
|
829
|
+
await sendMessage(`Session \`${tmuxName}\` already running.\nSet as default — send messages directly.`);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
// 5. Create session — Ghostty, PTY, or tmux
|
|
833
|
+
const mode = getEffectiveMode();
|
|
834
|
+
if (mode === 'tmux') {
|
|
835
|
+
// tmux path (existing behaviour)
|
|
836
|
+
try {
|
|
837
|
+
await tmuxExec(`tmux new-session -d -s "${tmuxName}" -c "${projectDir}"`);
|
|
838
|
+
await sleep(300);
|
|
839
|
+
await tmuxExec(`tmux send-keys -t "${tmuxName}" 'claude' C-m`);
|
|
840
|
+
}
|
|
841
|
+
catch (err) {
|
|
842
|
+
await sendMessage(`Failed to start session: ${err.message}`);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
(0, workspace_router_1.upsertSession)({ cwd: projectDir, tmuxSession: tmuxName, status: 'starting', sessionId: null, sessionType: 'tmux' });
|
|
846
|
+
(0, workspace_router_1.recordProjectUsage)(name, projectDir);
|
|
847
|
+
(0, workspace_router_1.setDefaultWorkspace)(name);
|
|
848
|
+
const msg = await sendMessage(`Started Claude in *${escapeMarkdown(name)}*\n\n` +
|
|
849
|
+
`*Path:* \`${projectDir}\`\n` +
|
|
850
|
+
`*Session:* \`${tmuxName}\`\n\n` +
|
|
851
|
+
`Default workspace set — send messages directly.`);
|
|
852
|
+
if (msg && msg.message_id) {
|
|
853
|
+
(0, workspace_router_1.trackNotificationMessage)(msg.message_id, name, 'new-session');
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
else if (mode === 'ghostty') {
|
|
857
|
+
// Ghostty path — opens a new tab in the front Ghostty window
|
|
858
|
+
const ok = await ghostty_session_manager_1.ghosttySessionManager.openNewTab(projectDir, 'claude');
|
|
859
|
+
if (!ok) {
|
|
860
|
+
await sendMessage('Failed to open Ghostty tab.');
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
ghostty_session_manager_1.ghosttySessionManager.register(tmuxName, projectDir);
|
|
864
|
+
(0, workspace_router_1.upsertSession)({ cwd: projectDir, tmuxSession: tmuxName, status: 'starting', sessionId: null, sessionType: 'ghostty' });
|
|
865
|
+
(0, workspace_router_1.recordProjectUsage)(name, projectDir);
|
|
866
|
+
(0, workspace_router_1.setDefaultWorkspace)(name);
|
|
867
|
+
const msg = await sendMessage(`Started Claude in *${escapeMarkdown(name)}*\n\n` +
|
|
868
|
+
`*Path:* \`${projectDir}\`\n` +
|
|
869
|
+
`*Session:* \`${tmuxName}\`\n\n` +
|
|
870
|
+
`Default workspace set — send messages directly.\n\n` +
|
|
871
|
+
`_Ghostty tab — visible in your Ghostty window._`);
|
|
872
|
+
if (msg && msg.message_id) {
|
|
873
|
+
(0, workspace_router_1.trackNotificationMessage)(msg.message_id, name, 'new-session');
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
else if (mode === 'pty') {
|
|
877
|
+
// PTY path — spawns 'claude' directly (no separate send-keys step)
|
|
878
|
+
const ok = pty_session_manager_1.ptySessionManager.spawn(tmuxName, projectDir);
|
|
879
|
+
if (!ok) {
|
|
880
|
+
await sendMessage('Failed to spawn PTY session.');
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
(0, workspace_router_1.upsertSession)({ cwd: projectDir, tmuxSession: tmuxName, status: 'starting', sessionId: null, sessionType: 'pty' });
|
|
884
|
+
(0, workspace_router_1.recordProjectUsage)(name, projectDir);
|
|
885
|
+
(0, workspace_router_1.setDefaultWorkspace)(name);
|
|
886
|
+
const msg = await sendMessage(`Started Claude in *${escapeMarkdown(name)}*\n\n` +
|
|
887
|
+
`*Path:* \`${projectDir}\`\n` +
|
|
888
|
+
`*Session:* \`${tmuxName}\`\n\n` +
|
|
889
|
+
`Default workspace set — send messages directly.\n\n` +
|
|
890
|
+
`_Headless PTY mode — full Telegram control. Not attachable from terminal._`);
|
|
891
|
+
if (msg && msg.message_id) {
|
|
892
|
+
(0, workspace_router_1.trackNotificationMessage)(msg.message_id, name, 'new-session');
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
else {
|
|
896
|
+
await sendMessage('\u26a0\ufe0f No injection backend available.\n' +
|
|
897
|
+
'Install tmux, run Ghostty, or run: `npm install node-pty` in ~/.ccgram/');
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
// ── Resume feature ───────────────────────────────────────────────
|
|
901
|
+
/** Format a Unix ms timestamp as a human-readable age (e.g. "2h ago"). */
|
|
902
|
+
function formatSessionAge(ms) {
|
|
903
|
+
const diff = Math.floor((Date.now() - ms) / 60000); // minutes
|
|
904
|
+
if (diff < 1)
|
|
905
|
+
return 'just now';
|
|
906
|
+
if (diff < 60)
|
|
907
|
+
return `${diff}m ago`;
|
|
908
|
+
if (diff < 1440)
|
|
909
|
+
return `${Math.floor(diff / 60)}h ago`;
|
|
910
|
+
return `${Math.floor(diff / 1440)}d ago`;
|
|
911
|
+
}
|
|
912
|
+
async function handleResume(nameArg) {
|
|
913
|
+
if (nameArg) {
|
|
914
|
+
await resumeProject(nameArg);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const allProjects = (0, workspace_router_1.getRecentProjects)(20);
|
|
918
|
+
const projects = allProjects
|
|
919
|
+
.map(p => ({ ...p, sessions: (0, workspace_router_1.getClaudeSessionsForProject)(p.path, 1) }))
|
|
920
|
+
.filter(p => p.sessions.length > 0);
|
|
921
|
+
if (projects.length === 0) {
|
|
922
|
+
await sendMessage('No sessions to resume.\n\nUse `/new` to start one — session IDs are saved automatically.');
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
const keyboard = [];
|
|
926
|
+
for (let i = 0; i < projects.length; i += 2) {
|
|
927
|
+
const row = projects.slice(i, i + 2).map(p => ({
|
|
928
|
+
text: `${p.name} \u2022 ${formatSessionAge(p.sessions[0].lastActivity)}`,
|
|
929
|
+
callback_data: `rp:${p.name}`,
|
|
930
|
+
}));
|
|
931
|
+
keyboard.push(row);
|
|
932
|
+
}
|
|
933
|
+
await telegramAPI('sendMessage', {
|
|
934
|
+
chat_id: CHAT_ID,
|
|
935
|
+
text: '*Resume Session*\n\nSelect a project:',
|
|
936
|
+
parse_mode: 'Markdown',
|
|
937
|
+
reply_markup: { inline_keyboard: keyboard },
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
async function resumeProject(projectName) {
|
|
941
|
+
const allProjects = (0, workspace_router_1.getRecentProjects)(20);
|
|
942
|
+
const project = allProjects.find(p => p.name === projectName);
|
|
943
|
+
if (!project) {
|
|
944
|
+
await sendMessage(`No project found for \`${escapeMarkdown(projectName)}\`.\n\nTry /resume to see available projects.`);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
const sessions = (0, workspace_router_1.getClaudeSessionsForProject)(project.path, 5);
|
|
948
|
+
if (sessions.length === 0) {
|
|
949
|
+
await sendMessage(`No sessions found for \`${escapeMarkdown(projectName)}\`.\n\nUse /new to start one.`);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
// Always show picker — one session per row (full width for snippet)
|
|
953
|
+
const keyboard = sessions.map((s, idx) => [{
|
|
954
|
+
text: `${formatSessionAge(s.lastActivity)}${s.snippet ? ' \u2022 ' + s.snippet : ''}`,
|
|
955
|
+
callback_data: `rs:${projectName}:${idx}`,
|
|
956
|
+
}]);
|
|
957
|
+
await telegramAPI('sendMessage', {
|
|
958
|
+
chat_id: CHAT_ID,
|
|
959
|
+
text: `*Resume: ${escapeMarkdown(projectName)}*\n\nChoose a conversation:`,
|
|
960
|
+
parse_mode: 'Markdown',
|
|
961
|
+
reply_markup: { inline_keyboard: keyboard },
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
async function resumeSession(projectName, sessionIdx, force = false) {
|
|
965
|
+
const allProjects = (0, workspace_router_1.getRecentProjects)(20);
|
|
966
|
+
const project = allProjects.find(p => p.name === projectName);
|
|
967
|
+
if (!project) {
|
|
968
|
+
await sendMessage('Session not found. Try /resume again.');
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
const sessions = (0, workspace_router_1.getClaudeSessionsForProject)(project.path, 5);
|
|
972
|
+
if (sessionIdx < 0 || sessionIdx >= sessions.length) {
|
|
973
|
+
await sendMessage('Session not found. Try /resume again.');
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
const sessionId = sessions[sessionIdx].id;
|
|
977
|
+
const tmuxName = projectName.replace(/[.:\s]/g, '-');
|
|
978
|
+
// Look up the bot's tracked session BEFORE checking sessionExists
|
|
979
|
+
const map = (0, workspace_router_1.readSessionMap)();
|
|
980
|
+
const currentEntry = Object.values(map).find(s => s.tmuxSession === tmuxName && !(0, workspace_router_1.isExpired)(s));
|
|
981
|
+
const running = currentEntry
|
|
982
|
+
? await sessionExists(tmuxName, currentEntry)
|
|
983
|
+
: (isPtySession(tmuxName) || (TMUX_AVAILABLE && await sessionExists(tmuxName)));
|
|
984
|
+
const botOwnsThisSession = currentEntry?.sessionId === sessionId;
|
|
985
|
+
// If the bot already has this exact session running, just re-route to it
|
|
986
|
+
if (running && botOwnsThisSession) {
|
|
987
|
+
(0, workspace_router_1.upsertSession)({ cwd: project.path, tmuxSession: tmuxName, status: 'waiting', sessionId });
|
|
988
|
+
(0, workspace_router_1.recordProjectUsage)(projectName, project.path);
|
|
989
|
+
(0, workspace_router_1.setDefaultWorkspace)(projectName);
|
|
990
|
+
await sendMessage(`Session \`${tmuxName}\` already running.\nSet as default — send messages directly.`);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
// Check if the JSONL file was written to very recently — the session may be
|
|
994
|
+
// active in a direct terminal (not managed by the bot). Warn before creating
|
|
995
|
+
// a second Claude instance on the same conversation.
|
|
996
|
+
if (!force && !botOwnsThisSession) {
|
|
997
|
+
const activeThresholdMs = 5 * 60 * 1000; // 5 minutes
|
|
998
|
+
const age = Date.now() - sessions[sessionIdx].lastActivity;
|
|
999
|
+
if (age < activeThresholdMs) {
|
|
1000
|
+
await telegramAPI('sendMessage', {
|
|
1001
|
+
chat_id: CHAT_ID,
|
|
1002
|
+
text: `\u26a0\ufe0f This session appears to be *active* (last activity ${formatSessionAge(sessions[sessionIdx].lastActivity)})\n\n` +
|
|
1003
|
+
`Claude Code may be running in a terminal. ` +
|
|
1004
|
+
`Resuming the same session in two places can cause conflicts.\n\n` +
|
|
1005
|
+
`_If you just finished this session, you can safely resume._`,
|
|
1006
|
+
parse_mode: 'Markdown',
|
|
1007
|
+
reply_markup: {
|
|
1008
|
+
inline_keyboard: [[
|
|
1009
|
+
{ text: '\u25b6\ufe0f Resume anyway', callback_data: `rc:${projectName}:${sessionIdx}` },
|
|
1010
|
+
]],
|
|
1011
|
+
},
|
|
1012
|
+
});
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
// Handle bot-managed session that needs switching
|
|
1017
|
+
if (running && !botOwnsThisSession) {
|
|
1018
|
+
if (isPtySession(tmuxName)) {
|
|
1019
|
+
// PTY: headless, not reattachable — warn before killing
|
|
1020
|
+
if (!force) {
|
|
1021
|
+
await telegramAPI('sendMessage', {
|
|
1022
|
+
chat_id: CHAT_ID,
|
|
1023
|
+
text: `\u26a0\ufe0f *${escapeMarkdown(projectName)}* has an active PTY session\n\n` +
|
|
1024
|
+
`Resuming a different conversation will terminate it.\n\n` +
|
|
1025
|
+
`_PTY sessions cannot be reattached from a terminal. ` +
|
|
1026
|
+
`You will need to use /resume again if you want to return to the current conversation._`,
|
|
1027
|
+
parse_mode: 'Markdown',
|
|
1028
|
+
reply_markup: {
|
|
1029
|
+
inline_keyboard: [[
|
|
1030
|
+
{ text: '\u25b6\ufe0f Resume anyway', callback_data: `rc:${projectName}:${sessionIdx}` },
|
|
1031
|
+
]],
|
|
1032
|
+
},
|
|
1033
|
+
});
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
// Confirmed — kill the PTY so startProjectResume can respawn
|
|
1037
|
+
pty_session_manager_1.ptySessionManager.kill(tmuxName);
|
|
1038
|
+
await sleep(300);
|
|
1039
|
+
}
|
|
1040
|
+
else if (currentEntry && isGhosttySession(currentEntry)) {
|
|
1041
|
+
// Ghostty: old tab stays open idle — warn before opening a new tab
|
|
1042
|
+
if (!force) {
|
|
1043
|
+
await telegramAPI('sendMessage', {
|
|
1044
|
+
chat_id: CHAT_ID,
|
|
1045
|
+
text: `\u26a0\ufe0f *${escapeMarkdown(projectName)}* has an active Ghostty session\n\n` +
|
|
1046
|
+
`Resuming will open a new tab. The existing tab will stay open but idle.\n\n` +
|
|
1047
|
+
`_You can close the old tab manually._`,
|
|
1048
|
+
parse_mode: 'Markdown',
|
|
1049
|
+
reply_markup: {
|
|
1050
|
+
inline_keyboard: [[
|
|
1051
|
+
{ text: '\u25b6\ufe0f Resume anyway', callback_data: `rc:${projectName}:${sessionIdx}` },
|
|
1052
|
+
]],
|
|
1053
|
+
},
|
|
1054
|
+
});
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
// Confirmed — unregister handle so startProjectResume opens a fresh tab
|
|
1058
|
+
ghostty_session_manager_1.ghosttySessionManager.unregister(tmuxName);
|
|
1059
|
+
}
|
|
1060
|
+
// tmux: no warning needed — startProjectResume switches inline
|
|
1061
|
+
}
|
|
1062
|
+
await startProjectResume(projectName, project.path, sessionId);
|
|
1063
|
+
}
|
|
1064
|
+
async function startProjectResume(name, projectDir, sessionId) {
|
|
1065
|
+
const tmuxName = name.replace(/[.:\s]/g, '-');
|
|
1066
|
+
const shortId = sessionId.slice(0, 8);
|
|
1067
|
+
// Look up the current session entry BEFORE checking sessionExists
|
|
1068
|
+
const map = (0, workspace_router_1.readSessionMap)();
|
|
1069
|
+
const currentEntry = Object.values(map).find(s => s.tmuxSession === tmuxName && !(0, workspace_router_1.isExpired)(s));
|
|
1070
|
+
const running = currentEntry
|
|
1071
|
+
? await sessionExists(tmuxName, currentEntry)
|
|
1072
|
+
: (isPtySession(tmuxName) || (TMUX_AVAILABLE && await sessionExists(tmuxName)));
|
|
1073
|
+
// If a tmux session is already running (and not PTY/Ghostty), switch Claude inline
|
|
1074
|
+
// (exit + resume) instead of killing the tmux session. This keeps the terminal attached.
|
|
1075
|
+
if (!isPtySession(tmuxName) && !(currentEntry && isGhosttySession(currentEntry)) && running) {
|
|
1076
|
+
try {
|
|
1077
|
+
// Double Ctrl+C: first interrupts any running Claude task,
|
|
1078
|
+
// second clears the input line if Claude returned to its prompt
|
|
1079
|
+
await tmuxExec(`tmux send-keys -t "${tmuxName}" C-c`);
|
|
1080
|
+
await sleep(500);
|
|
1081
|
+
await tmuxExec(`tmux send-keys -t "${tmuxName}" C-c`);
|
|
1082
|
+
await sleep(500);
|
|
1083
|
+
// Exit Claude — if Claude already exited, /exit is harmless in bash
|
|
1084
|
+
// (just an unknown command, won't affect the subsequent claude launch)
|
|
1085
|
+
await tmuxExec(`tmux send-keys -t "${tmuxName}" '/exit' C-m`);
|
|
1086
|
+
await sleep(2000);
|
|
1087
|
+
await tmuxExec(`tmux send-keys -t "${tmuxName}" 'claude --resume ${sessionId}' C-m`);
|
|
1088
|
+
}
|
|
1089
|
+
catch (err) {
|
|
1090
|
+
await sendMessage(`Failed to switch session: ${err.message}`);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
(0, workspace_router_1.upsertSession)({ cwd: projectDir, tmuxSession: tmuxName, status: 'starting', sessionId, sessionType: 'tmux' });
|
|
1094
|
+
(0, workspace_router_1.recordProjectUsage)(name, projectDir);
|
|
1095
|
+
(0, workspace_router_1.setDefaultWorkspace)(name);
|
|
1096
|
+
const msg = await sendMessage(`Switched Claude session in *${escapeMarkdown(name)}*\n\n` +
|
|
1097
|
+
`*Path:* \`${projectDir}\`\n` +
|
|
1098
|
+
`*Session:* \`${tmuxName}\`\n` +
|
|
1099
|
+
`*Resumed:* \`${shortId}...\`\n\n` +
|
|
1100
|
+
`Default workspace set — send messages directly.`);
|
|
1101
|
+
if (msg && msg.message_id) {
|
|
1102
|
+
(0, workspace_router_1.trackNotificationMessage)(msg.message_id, name, 'resume-session');
|
|
1103
|
+
}
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
// No session running (or was PTY/Ghostty that's been killed) — create a new one
|
|
1107
|
+
const mode = getEffectiveMode();
|
|
1108
|
+
if (mode === 'tmux') {
|
|
1109
|
+
try {
|
|
1110
|
+
await tmuxExec(`tmux new-session -d -s "${tmuxName}" -c "${projectDir}"`);
|
|
1111
|
+
await sleep(300);
|
|
1112
|
+
await tmuxExec(`tmux send-keys -t "${tmuxName}" 'claude --resume ${sessionId}' C-m`);
|
|
1113
|
+
}
|
|
1114
|
+
catch (err) {
|
|
1115
|
+
await sendMessage(`Failed to start session: ${err.message}`);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
(0, workspace_router_1.upsertSession)({ cwd: projectDir, tmuxSession: tmuxName, status: 'starting', sessionId, sessionType: 'tmux' });
|
|
1119
|
+
(0, workspace_router_1.recordProjectUsage)(name, projectDir);
|
|
1120
|
+
(0, workspace_router_1.setDefaultWorkspace)(name);
|
|
1121
|
+
const msg = await sendMessage(`Resumed Claude in *${escapeMarkdown(name)}*\n\n` +
|
|
1122
|
+
`*Path:* \`${projectDir}\`\n` +
|
|
1123
|
+
`*Session:* \`${tmuxName}\`\n` +
|
|
1124
|
+
`*Resumed:* \`${shortId}...\`\n\n` +
|
|
1125
|
+
`Default workspace set — send messages directly.`);
|
|
1126
|
+
if (msg && msg.message_id) {
|
|
1127
|
+
(0, workspace_router_1.trackNotificationMessage)(msg.message_id, name, 'resume-session');
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
else if (mode === 'ghostty') {
|
|
1131
|
+
const ok = await ghostty_session_manager_1.ghosttySessionManager.openNewTab(projectDir, `claude --resume ${sessionId}`);
|
|
1132
|
+
if (!ok) {
|
|
1133
|
+
await sendMessage('Failed to open Ghostty tab.');
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
ghostty_session_manager_1.ghosttySessionManager.register(tmuxName, projectDir);
|
|
1137
|
+
(0, workspace_router_1.upsertSession)({ cwd: projectDir, tmuxSession: tmuxName, status: 'starting', sessionId, sessionType: 'ghostty' });
|
|
1138
|
+
(0, workspace_router_1.recordProjectUsage)(name, projectDir);
|
|
1139
|
+
(0, workspace_router_1.setDefaultWorkspace)(name);
|
|
1140
|
+
const msg = await sendMessage(`Resumed Claude in *${escapeMarkdown(name)}*\n\n` +
|
|
1141
|
+
`*Path:* \`${projectDir}\`\n` +
|
|
1142
|
+
`*Session:* \`${tmuxName}\`\n` +
|
|
1143
|
+
`*Resumed:* \`${shortId}...\`\n\n` +
|
|
1144
|
+
`Default workspace set — send messages directly.\n\n` +
|
|
1145
|
+
`_Ghostty tab — visible in your Ghostty window._`);
|
|
1146
|
+
if (msg && msg.message_id) {
|
|
1147
|
+
(0, workspace_router_1.trackNotificationMessage)(msg.message_id, name, 'resume-session');
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
else if (mode === 'pty') {
|
|
1151
|
+
const ok = pty_session_manager_1.ptySessionManager.spawn(tmuxName, projectDir, ['--resume', sessionId]);
|
|
1152
|
+
if (!ok) {
|
|
1153
|
+
await sendMessage('Failed to spawn PTY session.');
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
(0, workspace_router_1.upsertSession)({ cwd: projectDir, tmuxSession: tmuxName, status: 'starting', sessionId, sessionType: 'pty' });
|
|
1157
|
+
(0, workspace_router_1.recordProjectUsage)(name, projectDir);
|
|
1158
|
+
(0, workspace_router_1.setDefaultWorkspace)(name);
|
|
1159
|
+
const msg = await sendMessage(`Resumed Claude in *${escapeMarkdown(name)}*\n\n` +
|
|
1160
|
+
`*Path:* \`${projectDir}\`\n` +
|
|
1161
|
+
`*Session:* \`${tmuxName}\`\n` +
|
|
1162
|
+
`*Resumed:* \`${shortId}...\`\n\n` +
|
|
1163
|
+
`Default workspace set — send messages directly.\n\n` +
|
|
1164
|
+
`_Headless PTY mode — full Telegram control. Not attachable from terminal._`);
|
|
1165
|
+
if (msg && msg.message_id) {
|
|
1166
|
+
(0, workspace_router_1.trackNotificationMessage)(msg.message_id, name, 'resume-session');
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
else {
|
|
1170
|
+
await sendMessage('\u26a0\ufe0f No injection backend available.\n' +
|
|
1171
|
+
'Install tmux, run Ghostty, or run: `npm install node-pty` in ~/.ccgram/');
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
async function injectAndRespond(session, command, workspace) {
|
|
1175
|
+
const tmuxName = session.tmuxSession;
|
|
1176
|
+
if (!await sessionExists(tmuxName, session)) {
|
|
1177
|
+
await sendMessage(`\u26a0\ufe0f Session not found. Start Claude via /new for full remote control, or use tmux.`);
|
|
1178
|
+
return false;
|
|
1179
|
+
}
|
|
1180
|
+
try {
|
|
1181
|
+
if (isPtySession(tmuxName)) {
|
|
1182
|
+
// PTY: write raw bytes directly — no shell quoting needed
|
|
1183
|
+
pty_session_manager_1.ptySessionManager.write(tmuxName, '\x15'); // Ctrl+U: clear line
|
|
1184
|
+
await sleep(150);
|
|
1185
|
+
pty_session_manager_1.ptySessionManager.write(tmuxName, command); // raw command text
|
|
1186
|
+
await sleep(150);
|
|
1187
|
+
pty_session_manager_1.ptySessionManager.write(tmuxName, '\r'); // Enter
|
|
1188
|
+
}
|
|
1189
|
+
else if (isGhosttySession(session)) {
|
|
1190
|
+
// Ghostty: inject via AppleScript input text
|
|
1191
|
+
ghostty_session_manager_1.ghosttySessionManager.register(tmuxName, session.cwd);
|
|
1192
|
+
await ghostty_session_manager_1.ghosttySessionManager.sendKey(tmuxName, 'C-u'); // Ctrl+U: clear line
|
|
1193
|
+
await sleep(150);
|
|
1194
|
+
await ghostty_session_manager_1.ghosttySessionManager.writeLine(tmuxName, command); // text + send key "return" atomically
|
|
1195
|
+
}
|
|
1196
|
+
else {
|
|
1197
|
+
// tmux: existing shell-escaped path
|
|
1198
|
+
const escapedCommand = command.replace(/'/g, "'\"'\"'");
|
|
1199
|
+
await tmuxExec(`tmux send-keys -t ${tmuxName} C-u`);
|
|
1200
|
+
await sleep(150);
|
|
1201
|
+
await tmuxExec(`tmux send-keys -t ${tmuxName} '${escapedCommand}'`);
|
|
1202
|
+
await sleep(150);
|
|
1203
|
+
await tmuxExec(`tmux send-keys -t ${tmuxName} C-m`);
|
|
1204
|
+
}
|
|
1205
|
+
startTypingIndicator();
|
|
1206
|
+
return true;
|
|
1207
|
+
}
|
|
1208
|
+
catch (err) {
|
|
1209
|
+
await sendMessage(`\u274c Failed: ${err.message}`);
|
|
1210
|
+
return false;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
function tmuxExec(cmd) {
|
|
1214
|
+
return new Promise((resolve, reject) => {
|
|
1215
|
+
(0, child_process_1.exec)(cmd, (err) => {
|
|
1216
|
+
if (err)
|
|
1217
|
+
reject(err);
|
|
1218
|
+
else
|
|
1219
|
+
resolve(true);
|
|
1220
|
+
});
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
// ── PTY / tmux / Ghostty dispatch helpers ────────────────────────
|
|
1224
|
+
/** Is this session managed as a live PTY handle by this bot process? */
|
|
1225
|
+
function isPtySession(sessionName) {
|
|
1226
|
+
return pty_session_manager_1.ptySessionManager.has(sessionName);
|
|
1227
|
+
}
|
|
1228
|
+
/** Is this session a Ghostty session (by stored sessionType)? */
|
|
1229
|
+
function isGhosttySession(session) {
|
|
1230
|
+
return session.sessionType === 'ghostty';
|
|
1231
|
+
}
|
|
1232
|
+
/** Check session exists (PTY handle, Ghostty, OR tmux session). */
|
|
1233
|
+
async function sessionExists(name, session) {
|
|
1234
|
+
if (pty_session_manager_1.ptySessionManager.has(name))
|
|
1235
|
+
return true;
|
|
1236
|
+
if (session && isGhosttySession(session)) {
|
|
1237
|
+
ghostty_session_manager_1.ghosttySessionManager.register(name, session.cwd);
|
|
1238
|
+
return ghostty_session_manager_1.ghosttySessionManager.isAvailable();
|
|
1239
|
+
}
|
|
1240
|
+
if (TMUX_AVAILABLE) {
|
|
1241
|
+
try {
|
|
1242
|
+
await tmuxExec(`tmux has-session -t ${name} 2>/dev/null`);
|
|
1243
|
+
return true;
|
|
1244
|
+
}
|
|
1245
|
+
catch {
|
|
1246
|
+
return false;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return false;
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Send a named key (Down, Up, Enter, C-m, C-c, C-u, Space) to a session.
|
|
1253
|
+
* For PTY: translates to escape sequence via ptySessionManager.sendKey.
|
|
1254
|
+
* For Ghostty: translates via ghosttySessionManager.sendKey (ANSI or modifiers).
|
|
1255
|
+
* For tmux: passes key name directly to tmux send-keys.
|
|
1256
|
+
*/
|
|
1257
|
+
async function sessionSendKey(name, key, session) {
|
|
1258
|
+
if (isPtySession(name)) {
|
|
1259
|
+
pty_session_manager_1.ptySessionManager.sendKey(name, key);
|
|
1260
|
+
}
|
|
1261
|
+
else if ((session && isGhosttySession(session)) || ghostty_session_manager_1.ghosttySessionManager.has(name)) {
|
|
1262
|
+
if (session)
|
|
1263
|
+
ghostty_session_manager_1.ghosttySessionManager.register(name, session.cwd);
|
|
1264
|
+
await ghostty_session_manager_1.ghosttySessionManager.sendKey(name, key);
|
|
1265
|
+
}
|
|
1266
|
+
else {
|
|
1267
|
+
await tmuxExec(`tmux send-keys -t ${name} ${key}`);
|
|
1268
|
+
}
|
|
1269
|
+
await sleep(100);
|
|
1270
|
+
}
|
|
1271
|
+
/** Capture session output. */
|
|
1272
|
+
async function sessionCaptureOutput(name, session) {
|
|
1273
|
+
if (isPtySession(name))
|
|
1274
|
+
return pty_session_manager_1.ptySessionManager.capture(name, 20) ?? '';
|
|
1275
|
+
if ((session && isGhosttySession(session)) || ghostty_session_manager_1.ghosttySessionManager.has(name)) {
|
|
1276
|
+
if (session)
|
|
1277
|
+
ghostty_session_manager_1.ghosttySessionManager.register(name, session.cwd);
|
|
1278
|
+
return await ghostty_session_manager_1.ghosttySessionManager.capture(name) ?? '(Ghostty scrollback capture unavailable)';
|
|
1279
|
+
}
|
|
1280
|
+
return capturePane(name);
|
|
1281
|
+
}
|
|
1282
|
+
/** Send Ctrl+C interrupt to a session. */
|
|
1283
|
+
async function sessionInterrupt(name, session) {
|
|
1284
|
+
if (isPtySession(name))
|
|
1285
|
+
pty_session_manager_1.ptySessionManager.interrupt(name);
|
|
1286
|
+
else if ((session && isGhosttySession(session)) || ghostty_session_manager_1.ghosttySessionManager.has(name)) {
|
|
1287
|
+
if (session)
|
|
1288
|
+
ghostty_session_manager_1.ghosttySessionManager.register(name, session.cwd);
|
|
1289
|
+
await ghostty_session_manager_1.ghosttySessionManager.interrupt(name);
|
|
1290
|
+
}
|
|
1291
|
+
else {
|
|
1292
|
+
await tmuxExec(`tmux send-keys -t ${name} C-c`);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
/** Icon for /sessions listing based on session type and live status. */
|
|
1296
|
+
function sessionIcon(s) {
|
|
1297
|
+
if (s.session.sessionType === 'ghostty')
|
|
1298
|
+
return '\u{1F47B}'; // 👻
|
|
1299
|
+
if (s.session.sessionType === 'pty') {
|
|
1300
|
+
return pty_session_manager_1.ptySessionManager.has(s.session.tmuxSession) ? '\u{1F916}' : '\u{1F4A4}'; // 🤖 or 💤
|
|
1301
|
+
}
|
|
1302
|
+
return s.session.description?.startsWith('waiting') ? '\u23f3' : '\u2705'; // ⏳ or ✅
|
|
1303
|
+
}
|
|
1304
|
+
// ── Callback query handler ───────────────────────────────────────
|
|
1305
|
+
async function processCallbackQuery(query) {
|
|
1306
|
+
const chatId = String(query.message?.chat?.id);
|
|
1307
|
+
if (chatId !== String(CHAT_ID)) {
|
|
1308
|
+
logger.warn(`Ignoring callback from unauthorized chat: ${chatId}`);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
const data = query.data || '';
|
|
1312
|
+
const messageId = query.message?.message_id;
|
|
1313
|
+
const originalText = query.message?.text || '';
|
|
1314
|
+
logger.info(`Callback: ${data}`);
|
|
1315
|
+
const parsed = (0, callback_parser_1.parseCallbackData)(data);
|
|
1316
|
+
if (!parsed) {
|
|
1317
|
+
await answerCallbackQuery(query.id, 'Invalid callback');
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
const { type } = parsed;
|
|
1321
|
+
// Handle new: callback (format: new:<projectName>)
|
|
1322
|
+
if (type === 'new') {
|
|
1323
|
+
const { projectName } = parsed;
|
|
1324
|
+
await answerCallbackQuery(query.id, `Starting ${projectName}...`);
|
|
1325
|
+
try {
|
|
1326
|
+
await editMessageText(chatId, messageId, `${originalText}\n\n— Starting *${escapeMarkdown(projectName)}*...`);
|
|
1327
|
+
}
|
|
1328
|
+
catch { }
|
|
1329
|
+
await startProject(projectName);
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
// Handle rp: callback (format: rp:<projectName>) — show session picker or resume directly
|
|
1333
|
+
if (type === 'rp') {
|
|
1334
|
+
const { projectName } = parsed;
|
|
1335
|
+
await answerCallbackQuery(query.id, `Loading ${projectName}...`);
|
|
1336
|
+
try {
|
|
1337
|
+
await editMessageText(chatId, messageId, `${originalText}\n\n— Loading sessions...`);
|
|
1338
|
+
}
|
|
1339
|
+
catch { }
|
|
1340
|
+
await resumeProject(projectName);
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
// Handle rs: callback (format: rs:<projectName>:<sessionIdx>) — resume specific session
|
|
1344
|
+
if (type === 'rs') {
|
|
1345
|
+
const { projectName, sessionIdx } = parsed;
|
|
1346
|
+
await answerCallbackQuery(query.id, 'Starting resume...');
|
|
1347
|
+
await resumeSession(projectName, sessionIdx);
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
// Handle rc: callback (format: rc:<projectName>:<sessionIdx>) — confirmed resume (kill active + restart)
|
|
1351
|
+
if (type === 'rc') {
|
|
1352
|
+
const { projectName, sessionIdx } = parsed;
|
|
1353
|
+
await answerCallbackQuery(query.id, 'Resuming...');
|
|
1354
|
+
try {
|
|
1355
|
+
await editMessageText(chatId, messageId, `${originalText}\n\n\u2014 Resuming...`);
|
|
1356
|
+
}
|
|
1357
|
+
catch { }
|
|
1358
|
+
await resumeSession(projectName, sessionIdx, true);
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
const { promptId } = parsed;
|
|
1362
|
+
if (type === 'perm') {
|
|
1363
|
+
// Permission response: write response file for the polling hook
|
|
1364
|
+
const pending = (0, prompt_bridge_1.readPending)(promptId);
|
|
1365
|
+
if (!pending) {
|
|
1366
|
+
await answerCallbackQuery(query.id, 'Session not found');
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
const { action } = parsed;
|
|
1370
|
+
// Cheesyboy 4-button vocab: yes / yes_stop / no / explain.
|
|
1371
|
+
// Legacy ccgram vocab still recognized: allow / always / defer / deny.
|
|
1372
|
+
const label = action === 'yes' ? '\u2705 Yes'
|
|
1373
|
+
: action === 'yes_stop' ? '\u2705 Yes, stop asking'
|
|
1374
|
+
: action === 'no' ? '\u274c No'
|
|
1375
|
+
: action === 'explain' ? '\u{1F4AC} Explain requested'
|
|
1376
|
+
: action === 'allow' ? '\u2705 Allowed'
|
|
1377
|
+
: action === 'always' ? '\ud83d\udd13 Always Allowed'
|
|
1378
|
+
: action === 'defer' ? '\u23F8 Deferred'
|
|
1379
|
+
: '\u274c Denied';
|
|
1380
|
+
// Write response file — the permission-hook.js is polling for this
|
|
1381
|
+
try {
|
|
1382
|
+
(0, prompt_bridge_1.writeResponse)(promptId, { action });
|
|
1383
|
+
logger.info(`Wrote permission response for promptId=${promptId}: action=${action}`);
|
|
1384
|
+
await answerCallbackQuery(query.id, label);
|
|
1385
|
+
}
|
|
1386
|
+
catch (err) {
|
|
1387
|
+
logger.error(`Failed to write permission response: ${err.message}`);
|
|
1388
|
+
await answerCallbackQuery(query.id, 'Failed to save response');
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
// Edit message to show result and remove buttons
|
|
1392
|
+
try {
|
|
1393
|
+
await editMessageText(chatId, messageId, `${originalText}\n\n— ${label}`);
|
|
1394
|
+
}
|
|
1395
|
+
catch (err) {
|
|
1396
|
+
logger.error(`Failed to edit message: ${err.message}`);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
else if (type === 'opt') {
|
|
1400
|
+
// Question option: write response file so hook returns updatedInput
|
|
1401
|
+
// (No keystroke injection needed — hook polls for this file)
|
|
1402
|
+
const pending = (0, prompt_bridge_1.readPending)(promptId);
|
|
1403
|
+
if (!pending) {
|
|
1404
|
+
await answerCallbackQuery(query.id, 'Session expired');
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
const optIdx = parsed.optionIndex - 1;
|
|
1408
|
+
const optionLabel = pending.options && pending.options[optIdx]
|
|
1409
|
+
? pending.options[optIdx]
|
|
1410
|
+
: `Option ${parsed.optionIndex}`;
|
|
1411
|
+
// Multi-select: toggle selection state, update buttons, don't submit yet
|
|
1412
|
+
if (pending.multiSelect) {
|
|
1413
|
+
const selected = pending.selectedOptions || pending.options.map(() => false);
|
|
1414
|
+
selected[optIdx] = !selected[optIdx];
|
|
1415
|
+
(0, prompt_bridge_1.updatePending)(promptId, { selectedOptions: selected });
|
|
1416
|
+
// Rebuild keyboard with updated checkboxes
|
|
1417
|
+
const buttons = pending.options.map((label, idx) => ({
|
|
1418
|
+
text: `${selected[idx] ? '\u2611' : '\u2610'} ${idx + 1}. ${label}`,
|
|
1419
|
+
callback_data: `opt:${promptId}:${idx + 1}`,
|
|
1420
|
+
}));
|
|
1421
|
+
const keyboard = [];
|
|
1422
|
+
for (let i = 0; i < buttons.length; i += 2) {
|
|
1423
|
+
keyboard.push(buttons.slice(i, i + 2));
|
|
1424
|
+
}
|
|
1425
|
+
keyboard.push([{ text: '\u2705 Submit', callback_data: `opt-submit:${promptId}` }]);
|
|
1426
|
+
const checkLabel = selected[optIdx] ? '\u2611' : '\u2610';
|
|
1427
|
+
await answerCallbackQuery(query.id, `${checkLabel} ${optionLabel}`);
|
|
1428
|
+
// Edit message to show updated buttons
|
|
1429
|
+
try {
|
|
1430
|
+
await editMessageText(chatId, messageId, originalText, { inline_keyboard: keyboard });
|
|
1431
|
+
}
|
|
1432
|
+
catch (err) {
|
|
1433
|
+
logger.error(`Failed to edit message: ${err.message}`);
|
|
1434
|
+
}
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
// Single-select: write response file so hook can return updatedInput
|
|
1438
|
+
// (No keystroke injection needed — hook polls for this file)
|
|
1439
|
+
(0, prompt_bridge_1.writeResponse)(promptId, {
|
|
1440
|
+
action: 'answer',
|
|
1441
|
+
selectedOption: parsed.optionIndex,
|
|
1442
|
+
selectedLabel: optionLabel,
|
|
1443
|
+
});
|
|
1444
|
+
logger.info(`Wrote question response for promptId=${promptId}: selectedLabel=${optionLabel}`);
|
|
1445
|
+
await answerCallbackQuery(query.id, `Selected: ${optionLabel}`);
|
|
1446
|
+
// Edit message to show selection and remove buttons
|
|
1447
|
+
try {
|
|
1448
|
+
await editMessageText(chatId, messageId, `${originalText}\n\n\u2714 Selected: *${escapeMarkdown(optionLabel)}*`);
|
|
1449
|
+
}
|
|
1450
|
+
catch (err) {
|
|
1451
|
+
logger.error(`Failed to edit message: ${err.message}`);
|
|
1452
|
+
}
|
|
1453
|
+
// Note: hook will clean up the prompt files after reading the response
|
|
1454
|
+
}
|
|
1455
|
+
else if (type === 'opt-submit') {
|
|
1456
|
+
// Multi-select submit: write response file so hook returns updatedInput
|
|
1457
|
+
const pending = (0, prompt_bridge_1.readPending)(promptId);
|
|
1458
|
+
if (!pending) {
|
|
1459
|
+
await answerCallbackQuery(query.id, 'Session expired');
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
const selected = pending.selectedOptions || [];
|
|
1463
|
+
const selectedLabels = pending.options.filter((_, idx) => selected[idx]);
|
|
1464
|
+
if (selectedLabels.length === 0) {
|
|
1465
|
+
await answerCallbackQuery(query.id, 'No options selected');
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
// Multi-select submit: write response file so hook can return updatedInput
|
|
1469
|
+
// (No keystroke injection needed — hook polls for this file)
|
|
1470
|
+
const selectedIndices = selected
|
|
1471
|
+
.map((sel, idx) => sel ? idx + 1 : null)
|
|
1472
|
+
.filter((idx) => idx !== null);
|
|
1473
|
+
(0, prompt_bridge_1.writeResponse)(promptId, {
|
|
1474
|
+
action: 'answer',
|
|
1475
|
+
selectedOptions: selectedIndices,
|
|
1476
|
+
selectedLabels,
|
|
1477
|
+
});
|
|
1478
|
+
await answerCallbackQuery(query.id, `Submitted ${selectedLabels.length} options`);
|
|
1479
|
+
// Edit message to show selections and remove buttons
|
|
1480
|
+
const selectionText = selectedLabels.map(l => `\u2022 ${escapeMarkdown(l)}`).join('\n');
|
|
1481
|
+
try {
|
|
1482
|
+
await editMessageText(chatId, messageId, `${originalText}\n\n\u2714 Selected:\n${selectionText}`);
|
|
1483
|
+
}
|
|
1484
|
+
catch (err) {
|
|
1485
|
+
logger.error(`Failed to edit message: ${err.message}`);
|
|
1486
|
+
}
|
|
1487
|
+
// Note: hook will clean up the prompt files after reading the response
|
|
1488
|
+
}
|
|
1489
|
+
else if (type === 'qperm') {
|
|
1490
|
+
// Combined question+permission: allow permission AND inject answer keystroke
|
|
1491
|
+
const optIdx = parsed.optionIndex - 1;
|
|
1492
|
+
const pending = (0, prompt_bridge_1.readPending)(promptId);
|
|
1493
|
+
if (!pending) {
|
|
1494
|
+
await answerCallbackQuery(query.id, 'Session not found');
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
const optionLabel = pending.options && pending.options[optIdx]
|
|
1498
|
+
? pending.options[optIdx]
|
|
1499
|
+
: `Option ${parsed.optionIndex}`;
|
|
1500
|
+
// 1. Write permission response (allow) — unblocks the permission hook
|
|
1501
|
+
try {
|
|
1502
|
+
(0, prompt_bridge_1.writeResponse)(promptId, { action: 'allow', selectedOption: parsed.optionIndex });
|
|
1503
|
+
logger.info(`Wrote qperm response for promptId=${promptId}: option=${parsed.optionIndex}`);
|
|
1504
|
+
await answerCallbackQuery(query.id, `Selected: ${optionLabel}`);
|
|
1505
|
+
}
|
|
1506
|
+
catch (err) {
|
|
1507
|
+
logger.error(`Failed to write qperm response: ${err.message}`);
|
|
1508
|
+
await answerCallbackQuery(query.id, 'Failed to save response');
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
// 2. Schedule keystroke injection after a delay (wait for question UI to appear)
|
|
1512
|
+
if (pending.tmuxSession) {
|
|
1513
|
+
const tmux = pending.tmuxSession;
|
|
1514
|
+
const downPresses = optIdx;
|
|
1515
|
+
const sessionEntryQperm = (0, workspace_router_1.findSessionByTmuxName)(tmux);
|
|
1516
|
+
setTimeout(async () => {
|
|
1517
|
+
try {
|
|
1518
|
+
for (let i = 0; i < downPresses; i++) {
|
|
1519
|
+
await sessionSendKey(tmux, 'Down', sessionEntryQperm);
|
|
1520
|
+
}
|
|
1521
|
+
await sessionSendKey(tmux, 'Enter', sessionEntryQperm);
|
|
1522
|
+
startTypingIndicator(); // ensure Stop hook routes response back to Telegram
|
|
1523
|
+
logger.info(`Injected question answer into ${tmux}: option ${parsed.optionIndex}`);
|
|
1524
|
+
}
|
|
1525
|
+
catch (err) {
|
|
1526
|
+
logger.error(`Failed to inject question answer: ${err.message}`);
|
|
1527
|
+
}
|
|
1528
|
+
}, 4000); // 4s delay for permission hook to return + question UI to render
|
|
1529
|
+
}
|
|
1530
|
+
// Edit message to show selection
|
|
1531
|
+
try {
|
|
1532
|
+
await editMessageText(chatId, messageId, `${originalText}\n\n— Selected: *${escapeMarkdown(optionLabel)}*`);
|
|
1533
|
+
}
|
|
1534
|
+
catch (err) {
|
|
1535
|
+
logger.error(`Failed to edit message: ${err.message}`);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
else if (type === 'perm-denied') {
|
|
1539
|
+
// Permission denied retry/dismiss
|
|
1540
|
+
const { action } = parsed;
|
|
1541
|
+
const label = action === 'retry' ? '\ud83d\udd04 Retrying...' : '\u274c Dismissed';
|
|
1542
|
+
// Write response file for the polling hook
|
|
1543
|
+
try {
|
|
1544
|
+
(0, prompt_bridge_1.writeResponse)(promptId, { action });
|
|
1545
|
+
logger.info(`Wrote perm-denied response for promptId=${promptId}: action=${action}`);
|
|
1546
|
+
await answerCallbackQuery(query.id, label);
|
|
1547
|
+
}
|
|
1548
|
+
catch (err) {
|
|
1549
|
+
logger.error(`Failed to write perm-denied response: ${err.message}`);
|
|
1550
|
+
await answerCallbackQuery(query.id, 'Failed to save response');
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
// Edit message to show result and remove buttons
|
|
1554
|
+
try {
|
|
1555
|
+
await editMessageText(chatId, messageId, `${originalText}\n\n— ${label}`);
|
|
1556
|
+
}
|
|
1557
|
+
catch (err) {
|
|
1558
|
+
logger.error(`Failed to edit message: ${err.message}`);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
else if (type === 'pre-compact') {
|
|
1562
|
+
// Pre-compact proceed/block
|
|
1563
|
+
const { action } = parsed;
|
|
1564
|
+
const label = action === 'block' ? '\ud83d\uded1 Blocked' : '\u2705 Proceeding...';
|
|
1565
|
+
// Write response file for the polling hook
|
|
1566
|
+
try {
|
|
1567
|
+
(0, prompt_bridge_1.writeResponse)(promptId, { action });
|
|
1568
|
+
logger.info(`Wrote pre-compact response for promptId=${promptId}: action=${action}`);
|
|
1569
|
+
await answerCallbackQuery(query.id, label);
|
|
1570
|
+
}
|
|
1571
|
+
catch (err) {
|
|
1572
|
+
logger.error(`Failed to write pre-compact response: ${err.message}`);
|
|
1573
|
+
await answerCallbackQuery(query.id, 'Failed to save response');
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
// Edit message to show result and remove buttons
|
|
1577
|
+
try {
|
|
1578
|
+
await editMessageText(chatId, messageId, `${originalText}\n\n— ${label}`);
|
|
1579
|
+
}
|
|
1580
|
+
catch (err) {
|
|
1581
|
+
logger.error(`Failed to edit message: ${err.message}`);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
// ── Message router ──────────────────────────────────────────────
|
|
1586
|
+
async function processMessage(msg) {
|
|
1587
|
+
stopTypingIndicator();
|
|
1588
|
+
// Only accept messages from the configured chat
|
|
1589
|
+
const chatId = String(msg.chat.id);
|
|
1590
|
+
if (chatId !== String(CHAT_ID)) {
|
|
1591
|
+
logger.warn(`Ignoring message from unauthorized chat: ${chatId}`);
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
const text = (msg.text || '').trim();
|
|
1595
|
+
if (!text)
|
|
1596
|
+
return;
|
|
1597
|
+
logger.info(`Received: ${text}`);
|
|
1598
|
+
// /help
|
|
1599
|
+
if (text === '/help' || text === '/start') {
|
|
1600
|
+
await handleHelp();
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
// /sessions
|
|
1604
|
+
if (text === '/sessions') {
|
|
1605
|
+
await handleSessions();
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
// /status [workspace]
|
|
1609
|
+
const statusMatch = text.match(/^\/status(?:\s+(\S+))?$/);
|
|
1610
|
+
if (statusMatch) {
|
|
1611
|
+
await handleStatus(statusMatch[1] || null);
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
// /stop [workspace]
|
|
1615
|
+
const stopMatch = text.match(/^\/stop(?:\s+(\S+))?$/);
|
|
1616
|
+
if (stopMatch) {
|
|
1617
|
+
await handleStop(stopMatch[1] || null);
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
// /use [workspace]
|
|
1621
|
+
const useMatch = text.match(/^\/use(?:\s+(.*))?$/);
|
|
1622
|
+
if (useMatch) {
|
|
1623
|
+
await handleUse(useMatch[1] ? useMatch[1].trim() : null);
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
// /compact [workspace]
|
|
1627
|
+
const compactMatch = text.match(/^\/compact(?:\s+(\S+))?$/);
|
|
1628
|
+
if (compactMatch) {
|
|
1629
|
+
await handleCompact(compactMatch[1] || null);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
// /new [project]
|
|
1633
|
+
const newMatch = text.match(/^\/new(?:\s+(.+))?$/);
|
|
1634
|
+
if (newMatch) {
|
|
1635
|
+
await handleNew(newMatch[1] ? newMatch[1].trim() : null);
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
// /resume [project]
|
|
1639
|
+
const resumeMatch = text.match(/^\/resume(?:\s+(.+))?$/);
|
|
1640
|
+
if (resumeMatch) {
|
|
1641
|
+
await handleResume(resumeMatch[1] ? resumeMatch[1].trim() : null);
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
// /link <prompt>
|
|
1645
|
+
const linkMatch = text.match(/^\/link(?:\s+(.+))?$/s);
|
|
1646
|
+
if (linkMatch) {
|
|
1647
|
+
await handleLink(linkMatch[1] ? linkMatch[1].trim() : '');
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
// /effort [workspace] low|medium|high
|
|
1651
|
+
const effortMatch = text.match(/^\/effort(?:\s+(.+))?$/);
|
|
1652
|
+
if (effortMatch) {
|
|
1653
|
+
await handleEffort(effortMatch[1] ? effortMatch[1].trim() : '');
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
// /model [workspace] <model>
|
|
1657
|
+
const modelMatch = text.match(/^\/model(?:\s+(.+))?$/);
|
|
1658
|
+
if (modelMatch) {
|
|
1659
|
+
await handleModel(modelMatch[1] ? modelMatch[1].trim() : '');
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
// /cmd TOKEN command
|
|
1663
|
+
const cmdMatch = text.match(/^\/cmd\s+(\S+)\s+(.+)/s);
|
|
1664
|
+
if (cmdMatch) {
|
|
1665
|
+
await handleCmd(cmdMatch[1], cmdMatch[2]);
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
// /<workspace> command (anything starting with / that isn't a known command)
|
|
1669
|
+
const wsMatch = text.match(/^\/(\S+)\s+(.+)/s);
|
|
1670
|
+
if (wsMatch) {
|
|
1671
|
+
const workspace = wsMatch[1];
|
|
1672
|
+
const command = wsMatch[2];
|
|
1673
|
+
// Skip Telegram built-in bot commands that start with @
|
|
1674
|
+
if (workspace.includes('@'))
|
|
1675
|
+
return;
|
|
1676
|
+
await handleWorkspaceCommand(workspace, command);
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
// If just a slash command with no args, check if it's a workspace (with prefix matching)
|
|
1680
|
+
const bareWs = text.match(/^\/(\S+)$/);
|
|
1681
|
+
if (bareWs) {
|
|
1682
|
+
const resolved = (0, workspace_router_1.resolveWorkspace)(bareWs[1]);
|
|
1683
|
+
if (resolved.type === 'exact' || resolved.type === 'prefix') {
|
|
1684
|
+
await handleStatus(resolved.workspace);
|
|
1685
|
+
}
|
|
1686
|
+
else if (resolved.type === 'ambiguous') {
|
|
1687
|
+
const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
|
|
1688
|
+
await sendMessage(`Multiple matches: ${names}. Be more specific.`);
|
|
1689
|
+
}
|
|
1690
|
+
else {
|
|
1691
|
+
await sendMessage(`Unknown command: \`${text}\`. Try /help`);
|
|
1692
|
+
}
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
// Plain text — try reply-to routing, then default workspace, then show hint
|
|
1696
|
+
const replyToId = msg.reply_to_message && msg.reply_to_message.message_id;
|
|
1697
|
+
if (replyToId) {
|
|
1698
|
+
const replyWorkspace = (0, workspace_router_1.getWorkspaceForMessage)(replyToId);
|
|
1699
|
+
if (replyWorkspace) {
|
|
1700
|
+
await handleWorkspaceCommand(replyWorkspace, text);
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
|
|
1705
|
+
if (defaultWs) {
|
|
1706
|
+
await handleWorkspaceCommand(defaultWs, text);
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
await sendMessage('Use `/help` to see available commands, or `/use <workspace>` to set a default.');
|
|
1710
|
+
}
|
|
1711
|
+
// ── Long polling loop ───────────────────────────────────────────
|
|
1712
|
+
async function poll() {
|
|
1713
|
+
while (true) {
|
|
1714
|
+
try {
|
|
1715
|
+
const updates = await telegramAPI('getUpdates', {
|
|
1716
|
+
offset: lastUpdateId + 1,
|
|
1717
|
+
timeout: 30,
|
|
1718
|
+
allowed_updates: ['message', 'callback_query'],
|
|
1719
|
+
});
|
|
1720
|
+
lastPollTime = Date.now();
|
|
1721
|
+
for (const update of updates) {
|
|
1722
|
+
lastUpdateId = update.update_id;
|
|
1723
|
+
if (update.callback_query) {
|
|
1724
|
+
try {
|
|
1725
|
+
await processCallbackQuery(update.callback_query);
|
|
1726
|
+
}
|
|
1727
|
+
catch (err) {
|
|
1728
|
+
logger.error(`Error processing callback query: ${err.message}`);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
else if (update.message) {
|
|
1732
|
+
try {
|
|
1733
|
+
await processMessage(update.message);
|
|
1734
|
+
}
|
|
1735
|
+
catch (err) {
|
|
1736
|
+
logger.error(`Error processing message: ${err.message}`);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
catch (err) {
|
|
1742
|
+
// Network error — back off and retry
|
|
1743
|
+
logger.error(`Polling error: ${err.message}`);
|
|
1744
|
+
await sleep(5000);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
1749
|
+
function capturePane(tmuxSession) {
|
|
1750
|
+
return new Promise((resolve, reject) => {
|
|
1751
|
+
(0, child_process_1.exec)(`tmux capture-pane -t ${tmuxSession} -p`, (err, stdout) => {
|
|
1752
|
+
if (err)
|
|
1753
|
+
reject(err);
|
|
1754
|
+
else
|
|
1755
|
+
resolve(stdout);
|
|
1756
|
+
});
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
function escapeMarkdown(text) {
|
|
1760
|
+
// Telegram Markdown v1 only needs these escaped
|
|
1761
|
+
return text.replace(/([_*`\[])/g, '\\$1');
|
|
1762
|
+
}
|
|
1763
|
+
function escapeHtml(text) {
|
|
1764
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1765
|
+
}
|
|
1766
|
+
function sleep(ms) {
|
|
1767
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1768
|
+
}
|
|
1769
|
+
// ── Health check server ──────────────────────────────────────────
|
|
1770
|
+
function startHealthServer(port) {
|
|
1771
|
+
const server = http_1.default.createServer((req, res) => {
|
|
1772
|
+
if (req.url !== '/health') {
|
|
1773
|
+
res.writeHead(404);
|
|
1774
|
+
res.end('Not found');
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
const now = Date.now();
|
|
1778
|
+
const pollAge = lastPollTime ? now - lastPollTime : null;
|
|
1779
|
+
const stale = pollAge === null || pollAge > 60000;
|
|
1780
|
+
const sessions = (0, workspace_router_1.listActiveSessions)();
|
|
1781
|
+
let pendingCount = 0;
|
|
1782
|
+
try {
|
|
1783
|
+
const files = fs_1.default.readdirSync(prompt_bridge_1.PROMPTS_DIR).filter(f => f.startsWith('pending-'));
|
|
1784
|
+
pendingCount = files.length;
|
|
1785
|
+
}
|
|
1786
|
+
catch { }
|
|
1787
|
+
const body = JSON.stringify({
|
|
1788
|
+
status: stale ? 'unhealthy' : 'ok',
|
|
1789
|
+
uptime: Math.floor((now - startTime) / 1000),
|
|
1790
|
+
lastPollAge: pollAge !== null ? Math.floor(pollAge / 1000) : null,
|
|
1791
|
+
activeSessions: sessions.length,
|
|
1792
|
+
pendingPrompts: pendingCount,
|
|
1793
|
+
}, null, 2);
|
|
1794
|
+
res.writeHead(stale ? 503 : 200, { 'Content-Type': 'application/json' });
|
|
1795
|
+
res.end(body);
|
|
1796
|
+
});
|
|
1797
|
+
server.listen(port, '127.0.0.1', () => {
|
|
1798
|
+
logger.info(`Health endpoint: http://127.0.0.1:${port}/health`);
|
|
1799
|
+
});
|
|
1800
|
+
server.on('error', (err) => {
|
|
1801
|
+
logger.warn(`Health server error: ${err.message}`);
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
// ── Startup ─────────────────────────────────────────────────────
|
|
1805
|
+
async function start() {
|
|
1806
|
+
// Ensure data directory exists
|
|
1807
|
+
const dataDir = path_1.default.join(paths_1.PROJECT_ROOT, 'src/data');
|
|
1808
|
+
fs_1.default.mkdirSync(dataDir, { recursive: true });
|
|
1809
|
+
const { version } = require(path_1.default.join(paths_1.PROJECT_ROOT, 'package.json'));
|
|
1810
|
+
logger.info(`Cheesyboy v${version} — Starting Telegram bot (long polling)...`);
|
|
1811
|
+
logger.info(`Chat ID: ${CHAT_ID}`);
|
|
1812
|
+
// Prune expired sessions on startup
|
|
1813
|
+
const pruned = (0, workspace_router_1.pruneExpired)();
|
|
1814
|
+
if (pruned > 0) {
|
|
1815
|
+
logger.info(`Pruned ${pruned} expired sessions`);
|
|
1816
|
+
}
|
|
1817
|
+
// Delete any existing webhook to ensure long polling works
|
|
1818
|
+
try {
|
|
1819
|
+
await telegramAPI('deleteWebhook', {});
|
|
1820
|
+
logger.info('Webhook cleared, using long polling');
|
|
1821
|
+
}
|
|
1822
|
+
catch (err) {
|
|
1823
|
+
logger.warn(`Could not delete webhook: ${err.message}`);
|
|
1824
|
+
}
|
|
1825
|
+
// Register bot commands with Telegram (populates the "/" menu in chat)
|
|
1826
|
+
await registerBotCommands();
|
|
1827
|
+
// Start optional health check server
|
|
1828
|
+
const healthPort = parseInt(process.env.HEALTH_PORT, 10);
|
|
1829
|
+
if (healthPort) {
|
|
1830
|
+
startHealthServer(healthPort);
|
|
1831
|
+
}
|
|
1832
|
+
await poll();
|
|
1833
|
+
}
|
|
1834
|
+
// Graceful shutdown
|
|
1835
|
+
process.on('SIGINT', () => {
|
|
1836
|
+
logger.info('Shutting down...');
|
|
1837
|
+
process.exit(0);
|
|
1838
|
+
});
|
|
1839
|
+
process.on('SIGTERM', () => {
|
|
1840
|
+
logger.info('Shutting down...');
|
|
1841
|
+
process.exit(0);
|
|
1842
|
+
});
|
|
1843
|
+
start().catch((err) => {
|
|
1844
|
+
logger.error(`Fatal: ${err.message}`);
|
|
1845
|
+
process.exit(1);
|
|
1846
|
+
});
|
|
1847
|
+
//# sourceMappingURL=workspace-telegram-bot.js.map
|