@jsayubi/ccgram 1.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.
Files changed (247) hide show
  1. package/.env.example +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +338 -0
  4. package/ccgram.service +24 -0
  5. package/config/channels.json +58 -0
  6. package/config/default.json +27 -0
  7. package/config/defaults/config.json +16 -0
  8. package/config/defaults/i18n.json +32 -0
  9. package/config/email-template.json +31 -0
  10. package/config/test-with-subagent.json +16 -0
  11. package/config/user.json +27 -0
  12. package/dist/claude-hook-notify.d.ts +7 -0
  13. package/dist/claude-hook-notify.d.ts.map +1 -0
  14. package/dist/claude-hook-notify.js +154 -0
  15. package/dist/claude-hook-notify.js.map +1 -0
  16. package/dist/claude-remote.d.ts +50 -0
  17. package/dist/claude-remote.d.ts.map +1 -0
  18. package/dist/claude-remote.js +927 -0
  19. package/dist/claude-remote.js.map +1 -0
  20. package/dist/cli.d.ts +3 -0
  21. package/dist/cli.d.ts.map +1 -0
  22. package/dist/cli.js +110 -0
  23. package/dist/cli.js.map +1 -0
  24. package/dist/enhanced-hook-notify.d.ts +16 -0
  25. package/dist/enhanced-hook-notify.d.ts.map +1 -0
  26. package/dist/enhanced-hook-notify.js +288 -0
  27. package/dist/enhanced-hook-notify.js.map +1 -0
  28. package/dist/permission-hook.d.ts +15 -0
  29. package/dist/permission-hook.d.ts.map +1 -0
  30. package/dist/permission-hook.js +357 -0
  31. package/dist/permission-hook.js.map +1 -0
  32. package/dist/prompt-bridge.d.ts +50 -0
  33. package/dist/prompt-bridge.d.ts.map +1 -0
  34. package/dist/prompt-bridge.js +173 -0
  35. package/dist/prompt-bridge.js.map +1 -0
  36. package/dist/question-notify.d.ts +16 -0
  37. package/dist/question-notify.d.ts.map +1 -0
  38. package/dist/question-notify.js +272 -0
  39. package/dist/question-notify.js.map +1 -0
  40. package/dist/setup.d.ts +10 -0
  41. package/dist/setup.d.ts.map +1 -0
  42. package/dist/setup.js +649 -0
  43. package/dist/setup.js.map +1 -0
  44. package/dist/smart-monitor.d.ts +7 -0
  45. package/dist/smart-monitor.d.ts.map +1 -0
  46. package/dist/smart-monitor.js +256 -0
  47. package/dist/smart-monitor.js.map +1 -0
  48. package/dist/src/automation/claude-automation.d.ts +45 -0
  49. package/dist/src/automation/claude-automation.d.ts.map +1 -0
  50. package/dist/src/automation/claude-automation.js +367 -0
  51. package/dist/src/automation/claude-automation.js.map +1 -0
  52. package/dist/src/automation/clipboard-automation.d.ts +35 -0
  53. package/dist/src/automation/clipboard-automation.d.ts.map +1 -0
  54. package/dist/src/automation/clipboard-automation.js +242 -0
  55. package/dist/src/automation/clipboard-automation.js.map +1 -0
  56. package/dist/src/automation/simple-automation.d.ts +56 -0
  57. package/dist/src/automation/simple-automation.d.ts.map +1 -0
  58. package/dist/src/automation/simple-automation.js +283 -0
  59. package/dist/src/automation/simple-automation.js.map +1 -0
  60. package/dist/src/channels/base/channel.d.ts +60 -0
  61. package/dist/src/channels/base/channel.d.ts.map +1 -0
  62. package/dist/src/channels/base/channel.js +96 -0
  63. package/dist/src/channels/base/channel.js.map +1 -0
  64. package/dist/src/channels/email/smtp.d.ts +74 -0
  65. package/dist/src/channels/email/smtp.d.ts.map +1 -0
  66. package/dist/src/channels/email/smtp.js +605 -0
  67. package/dist/src/channels/email/smtp.js.map +1 -0
  68. package/dist/src/channels/line/line.d.ts +36 -0
  69. package/dist/src/channels/line/line.d.ts.map +1 -0
  70. package/dist/src/channels/line/line.js +180 -0
  71. package/dist/src/channels/line/line.js.map +1 -0
  72. package/dist/src/channels/line/webhook.d.ts +55 -0
  73. package/dist/src/channels/line/webhook.d.ts.map +1 -0
  74. package/dist/src/channels/line/webhook.js +191 -0
  75. package/dist/src/channels/line/webhook.js.map +1 -0
  76. package/dist/src/channels/local/desktop.d.ts +30 -0
  77. package/dist/src/channels/local/desktop.d.ts.map +1 -0
  78. package/dist/src/channels/local/desktop.js +161 -0
  79. package/dist/src/channels/local/desktop.js.map +1 -0
  80. package/dist/src/channels/telegram/telegram.d.ts +43 -0
  81. package/dist/src/channels/telegram/telegram.d.ts.map +1 -0
  82. package/dist/src/channels/telegram/telegram.js +223 -0
  83. package/dist/src/channels/telegram/telegram.js.map +1 -0
  84. package/dist/src/channels/telegram/webhook.d.ts +75 -0
  85. package/dist/src/channels/telegram/webhook.d.ts.map +1 -0
  86. package/dist/src/channels/telegram/webhook.js +278 -0
  87. package/dist/src/channels/telegram/webhook.js.map +1 -0
  88. package/dist/src/config-manager.d.ts +16 -0
  89. package/dist/src/config-manager.d.ts.map +1 -0
  90. package/dist/src/config-manager.js +152 -0
  91. package/dist/src/config-manager.js.map +1 -0
  92. package/dist/src/core/config.d.ts +28 -0
  93. package/dist/src/core/config.d.ts.map +1 -0
  94. package/dist/src/core/config.js +248 -0
  95. package/dist/src/core/config.js.map +1 -0
  96. package/dist/src/core/logger.d.ts +19 -0
  97. package/dist/src/core/logger.d.ts.map +1 -0
  98. package/dist/src/core/logger.js +47 -0
  99. package/dist/src/core/logger.js.map +1 -0
  100. package/dist/src/core/notifier.d.ts +45 -0
  101. package/dist/src/core/notifier.d.ts.map +1 -0
  102. package/dist/src/core/notifier.js +189 -0
  103. package/dist/src/core/notifier.js.map +1 -0
  104. package/dist/src/daemon/taskping-daemon.d.ts +38 -0
  105. package/dist/src/daemon/taskping-daemon.d.ts.map +1 -0
  106. package/dist/src/daemon/taskping-daemon.js +306 -0
  107. package/dist/src/daemon/taskping-daemon.js.map +1 -0
  108. package/dist/src/relay/claude-command-bridge.d.ts +57 -0
  109. package/dist/src/relay/claude-command-bridge.d.ts.map +1 -0
  110. package/dist/src/relay/claude-command-bridge.js +188 -0
  111. package/dist/src/relay/claude-command-bridge.js.map +1 -0
  112. package/dist/src/relay/command-relay.d.ts +94 -0
  113. package/dist/src/relay/command-relay.d.ts.map +1 -0
  114. package/dist/src/relay/command-relay.js +463 -0
  115. package/dist/src/relay/command-relay.js.map +1 -0
  116. package/dist/src/relay/email-listener.d.ts +65 -0
  117. package/dist/src/relay/email-listener.d.ts.map +1 -0
  118. package/dist/src/relay/email-listener.js +460 -0
  119. package/dist/src/relay/email-listener.js.map +1 -0
  120. package/dist/src/relay/relay-pty.d.ts +21 -0
  121. package/dist/src/relay/relay-pty.d.ts.map +1 -0
  122. package/dist/src/relay/relay-pty.js +696 -0
  123. package/dist/src/relay/relay-pty.js.map +1 -0
  124. package/dist/src/relay/smart-injector.d.ts +30 -0
  125. package/dist/src/relay/smart-injector.d.ts.map +1 -0
  126. package/dist/src/relay/smart-injector.js +233 -0
  127. package/dist/src/relay/smart-injector.js.map +1 -0
  128. package/dist/src/relay/tmux-injector.d.ts +46 -0
  129. package/dist/src/relay/tmux-injector.d.ts.map +1 -0
  130. package/dist/src/relay/tmux-injector.js +413 -0
  131. package/dist/src/relay/tmux-injector.js.map +1 -0
  132. package/dist/src/tools/config-manager.d.ts +33 -0
  133. package/dist/src/tools/config-manager.d.ts.map +1 -0
  134. package/dist/src/tools/config-manager.js +448 -0
  135. package/dist/src/tools/config-manager.js.map +1 -0
  136. package/dist/src/tools/installer.d.ts +38 -0
  137. package/dist/src/tools/installer.d.ts.map +1 -0
  138. package/dist/src/tools/installer.js +222 -0
  139. package/dist/src/tools/installer.js.map +1 -0
  140. package/dist/src/types/callbacks.d.ts +29 -0
  141. package/dist/src/types/callbacks.d.ts.map +1 -0
  142. package/dist/src/types/callbacks.js +7 -0
  143. package/dist/src/types/callbacks.js.map +1 -0
  144. package/dist/src/types/config.d.ts +56 -0
  145. package/dist/src/types/config.d.ts.map +1 -0
  146. package/dist/src/types/config.js +6 -0
  147. package/dist/src/types/config.js.map +1 -0
  148. package/dist/src/types/hooks.d.ts +47 -0
  149. package/dist/src/types/hooks.d.ts.map +1 -0
  150. package/dist/src/types/hooks.js +6 -0
  151. package/dist/src/types/hooks.js.map +1 -0
  152. package/dist/src/types/index.d.ts +7 -0
  153. package/dist/src/types/index.d.ts.map +1 -0
  154. package/dist/src/types/index.js +23 -0
  155. package/dist/src/types/index.js.map +1 -0
  156. package/dist/src/types/ipc.d.ts +43 -0
  157. package/dist/src/types/ipc.d.ts.map +1 -0
  158. package/dist/src/types/ipc.js +7 -0
  159. package/dist/src/types/ipc.js.map +1 -0
  160. package/dist/src/types/session.d.ts +70 -0
  161. package/dist/src/types/session.d.ts.map +1 -0
  162. package/dist/src/types/session.js +9 -0
  163. package/dist/src/types/session.js.map +1 -0
  164. package/dist/src/types/telegram.d.ts +58 -0
  165. package/dist/src/types/telegram.d.ts.map +1 -0
  166. package/dist/src/types/telegram.js +6 -0
  167. package/dist/src/types/telegram.js.map +1 -0
  168. package/dist/src/utils/active-check.d.ts +19 -0
  169. package/dist/src/utils/active-check.d.ts.map +1 -0
  170. package/dist/src/utils/active-check.js +41 -0
  171. package/dist/src/utils/active-check.js.map +1 -0
  172. package/dist/src/utils/callback-parser.d.ts +21 -0
  173. package/dist/src/utils/callback-parser.d.ts.map +1 -0
  174. package/dist/src/utils/callback-parser.js +58 -0
  175. package/dist/src/utils/callback-parser.js.map +1 -0
  176. package/dist/src/utils/controller-injector.d.ts +21 -0
  177. package/dist/src/utils/controller-injector.d.ts.map +1 -0
  178. package/dist/src/utils/controller-injector.js +108 -0
  179. package/dist/src/utils/controller-injector.js.map +1 -0
  180. package/dist/src/utils/conversation-tracker.d.ts +32 -0
  181. package/dist/src/utils/conversation-tracker.d.ts.map +1 -0
  182. package/dist/src/utils/conversation-tracker.js +119 -0
  183. package/dist/src/utils/conversation-tracker.js.map +1 -0
  184. package/dist/src/utils/http-request.d.ts +25 -0
  185. package/dist/src/utils/http-request.d.ts.map +1 -0
  186. package/dist/src/utils/http-request.js +66 -0
  187. package/dist/src/utils/http-request.js.map +1 -0
  188. package/dist/src/utils/optional-require.d.ts +13 -0
  189. package/dist/src/utils/optional-require.d.ts.map +1 -0
  190. package/dist/src/utils/optional-require.js +37 -0
  191. package/dist/src/utils/optional-require.js.map +1 -0
  192. package/dist/src/utils/paths.d.ts +11 -0
  193. package/dist/src/utils/paths.d.ts.map +1 -0
  194. package/dist/src/utils/paths.js +28 -0
  195. package/dist/src/utils/paths.js.map +1 -0
  196. package/dist/src/utils/pty-session-manager.d.ts +42 -0
  197. package/dist/src/utils/pty-session-manager.d.ts.map +1 -0
  198. package/dist/src/utils/pty-session-manager.js +182 -0
  199. package/dist/src/utils/pty-session-manager.js.map +1 -0
  200. package/dist/src/utils/subagent-tracker.d.ts +64 -0
  201. package/dist/src/utils/subagent-tracker.d.ts.map +1 -0
  202. package/dist/src/utils/subagent-tracker.js +191 -0
  203. package/dist/src/utils/subagent-tracker.js.map +1 -0
  204. package/dist/src/utils/tmux-monitor.d.ts +102 -0
  205. package/dist/src/utils/tmux-monitor.d.ts.map +1 -0
  206. package/dist/src/utils/tmux-monitor.js +642 -0
  207. package/dist/src/utils/tmux-monitor.js.map +1 -0
  208. package/dist/src/utils/trace-capture.d.ts +42 -0
  209. package/dist/src/utils/trace-capture.d.ts.map +1 -0
  210. package/dist/src/utils/trace-capture.js +102 -0
  211. package/dist/src/utils/trace-capture.js.map +1 -0
  212. package/dist/start-all-webhooks.d.ts +7 -0
  213. package/dist/start-all-webhooks.d.ts.map +1 -0
  214. package/dist/start-all-webhooks.js +98 -0
  215. package/dist/start-all-webhooks.js.map +1 -0
  216. package/dist/start-line-webhook.d.ts +7 -0
  217. package/dist/start-line-webhook.d.ts.map +1 -0
  218. package/dist/start-line-webhook.js +59 -0
  219. package/dist/start-line-webhook.js.map +1 -0
  220. package/dist/start-relay-pty.d.ts +7 -0
  221. package/dist/start-relay-pty.d.ts.map +1 -0
  222. package/dist/start-relay-pty.js +173 -0
  223. package/dist/start-relay-pty.js.map +1 -0
  224. package/dist/start-telegram-webhook.d.ts +7 -0
  225. package/dist/start-telegram-webhook.d.ts.map +1 -0
  226. package/dist/start-telegram-webhook.js +80 -0
  227. package/dist/start-telegram-webhook.js.map +1 -0
  228. package/dist/user-prompt-hook.d.ts +13 -0
  229. package/dist/user-prompt-hook.d.ts.map +1 -0
  230. package/dist/user-prompt-hook.js +45 -0
  231. package/dist/user-prompt-hook.js.map +1 -0
  232. package/dist/workspace-router.d.ts +78 -0
  233. package/dist/workspace-router.d.ts.map +1 -0
  234. package/dist/workspace-router.js +408 -0
  235. package/dist/workspace-router.js.map +1 -0
  236. package/dist/workspace-telegram-bot.d.ts +3 -0
  237. package/dist/workspace-telegram-bot.d.ts.map +1 -0
  238. package/dist/workspace-telegram-bot.js +1172 -0
  239. package/dist/workspace-telegram-bot.js.map +1 -0
  240. package/package.json +80 -0
  241. package/src/types/callbacks.ts +39 -0
  242. package/src/types/config.ts +63 -0
  243. package/src/types/hooks.ts +50 -0
  244. package/src/types/index.ts +6 -0
  245. package/src/types/ipc.ts +55 -0
  246. package/src/types/session.ts +72 -0
  247. package/src/types/telegram.ts +66 -0
@@ -0,0 +1,1172 @@
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(`CCGram 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 logger_1 = __importDefault(require("./src/core/logger"));
38
+ const logger = new logger_1.default('bot');
39
+ const INJECTION_MODE = process.env.INJECTION_MODE || 'tmux';
40
+ const TMUX_AVAILABLE = (() => {
41
+ try {
42
+ (0, child_process_1.execSync)('tmux -V', { stdio: 'ignore' });
43
+ return true;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ })();
49
+ const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
50
+ const CHAT_ID = process.env.TELEGRAM_CHAT_ID;
51
+ if (!BOT_TOKEN || BOT_TOKEN === 'YOUR_BOT_TOKEN_HERE') {
52
+ logger.error('TELEGRAM_BOT_TOKEN not configured in .env');
53
+ logger.error(' Get your token from @BotFather: https://t.me/BotFather');
54
+ process.exit(1);
55
+ }
56
+ if (!CHAT_ID || CHAT_ID === 'YOUR_CHAT_ID_HERE') {
57
+ logger.error('TELEGRAM_CHAT_ID not configured in .env');
58
+ logger.error(' Get your chat ID from @userinfobot: https://t.me/userinfobot');
59
+ process.exit(1);
60
+ }
61
+ let lastUpdateId = 0;
62
+ let lastPollTime = null; // timestamp of last successful getUpdates call
63
+ const startTime = Date.now();
64
+ const activeTypingIntervals = new Map(); // workspace → intervalId
65
+ // ── Telegram API helpers ────────────────────────────────────────
66
+ function telegramAPI(method, body) {
67
+ return new Promise((resolve, reject) => {
68
+ const payload = JSON.stringify(body);
69
+ const options = {
70
+ hostname: 'api.telegram.org',
71
+ path: `/bot${BOT_TOKEN}/${method}`,
72
+ method: 'POST',
73
+ headers: {
74
+ 'Content-Type': 'application/json',
75
+ 'Content-Length': Buffer.byteLength(payload),
76
+ },
77
+ timeout: method === 'getUpdates' ? 35000 : 10000,
78
+ };
79
+ const req = https_1.default.request(options, (res) => {
80
+ let data = '';
81
+ res.on('data', (chunk) => { data += chunk; });
82
+ res.on('end', () => {
83
+ try {
84
+ const parsed = JSON.parse(data);
85
+ if (!parsed.ok) {
86
+ reject(new Error(`Telegram API error: ${parsed.description || data}`));
87
+ }
88
+ else {
89
+ resolve(parsed.result);
90
+ }
91
+ }
92
+ catch {
93
+ reject(new Error(`Invalid JSON from Telegram: ${data.slice(0, 200)}`));
94
+ }
95
+ });
96
+ });
97
+ req.on('error', reject);
98
+ req.on('timeout', () => {
99
+ req.destroy();
100
+ reject(new Error('Telegram request timed out'));
101
+ });
102
+ req.write(payload);
103
+ req.end();
104
+ });
105
+ }
106
+ function sendMessage(text) {
107
+ return telegramAPI('sendMessage', {
108
+ chat_id: CHAT_ID,
109
+ text,
110
+ parse_mode: 'Markdown',
111
+ });
112
+ }
113
+ function sendHtmlMessage(text) {
114
+ return telegramAPI('sendMessage', {
115
+ chat_id: CHAT_ID,
116
+ text,
117
+ parse_mode: 'HTML',
118
+ });
119
+ }
120
+ const TYPING_SIGNAL_PATH = path_1.default.join(paths_1.PROJECT_ROOT, 'src/data', 'typing-active');
121
+ function startTypingIndicator() {
122
+ stopTypingIndicator();
123
+ try {
124
+ fs_1.default.writeFileSync(TYPING_SIGNAL_PATH, String(Date.now()));
125
+ }
126
+ catch { }
127
+ const tick = () => {
128
+ if (!fs_1.default.existsSync(TYPING_SIGNAL_PATH)) {
129
+ stopTypingIndicator();
130
+ return;
131
+ }
132
+ telegramAPI('sendChatAction', { chat_id: CHAT_ID, action: 'typing' }).catch(() => { });
133
+ };
134
+ tick();
135
+ const intervalId = setInterval(tick, 4500);
136
+ const timeoutId = setTimeout(() => stopTypingIndicator(), 5 * 60 * 1000);
137
+ activeTypingIntervals.set('_active', { intervalId, timeoutId });
138
+ }
139
+ function stopTypingIndicator() {
140
+ const entry = activeTypingIntervals.get('_active');
141
+ if (entry) {
142
+ clearInterval(entry.intervalId);
143
+ clearTimeout(entry.timeoutId);
144
+ activeTypingIntervals.delete('_active');
145
+ }
146
+ try {
147
+ fs_1.default.unlinkSync(TYPING_SIGNAL_PATH);
148
+ }
149
+ catch { }
150
+ }
151
+ async function registerBotCommands() {
152
+ const commands = [
153
+ { command: 'new', description: 'Start Claude in a project directory' },
154
+ { command: 'sessions', description: 'List all active Claude sessions' },
155
+ { command: 'use', description: 'Set or show default workspace' },
156
+ { command: 'status', description: 'Show current session output' },
157
+ { command: 'stop', description: 'Interrupt the running prompt' },
158
+ { command: 'compact', description: 'Compact context in the current session' },
159
+ { command: 'help', description: 'Show available commands' },
160
+ ];
161
+ try {
162
+ await telegramAPI('setMyCommands', { commands });
163
+ logger.info('Bot commands registered with Telegram');
164
+ }
165
+ catch (err) {
166
+ logger.warn(`Failed to register bot commands: ${err.message}`);
167
+ }
168
+ }
169
+ function answerCallbackQuery(callbackQueryId, text) {
170
+ return telegramAPI('answerCallbackQuery', {
171
+ callback_query_id: callbackQueryId,
172
+ text: text || '',
173
+ });
174
+ }
175
+ function editMessageText(chatId, messageId, text, replyMarkup) {
176
+ const body = {
177
+ chat_id: chatId,
178
+ message_id: messageId,
179
+ text,
180
+ parse_mode: 'Markdown',
181
+ };
182
+ if (replyMarkup)
183
+ body.reply_markup = replyMarkup;
184
+ return telegramAPI('editMessageText', body);
185
+ }
186
+ // ── Command handlers ────────────────────────────────────────────
187
+ async function handleHelp() {
188
+ const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
189
+ const msg = [
190
+ '*Claude Remote Control*',
191
+ '',
192
+ '`/<workspace> <command>` — Send command to workspace',
193
+ '`/use <workspace>` — Set default workspace',
194
+ '`/use` — Show current default',
195
+ '`/use clear` — Clear default',
196
+ '`/compact [workspace]` — Compact context in workspace',
197
+ '`/new [project]` — Start Claude in a project (shows recent if no arg)',
198
+ '`/sessions` — List active sessions',
199
+ '`/status [workspace]` — Show tmux output',
200
+ '`/stop [workspace]` — Interrupt running prompt',
201
+ '`/cmd <TOKEN> <command>` — Token-based fallback',
202
+ '`/help` — This message',
203
+ '',
204
+ '_Prefix matching:_ `/ass hello` matches `assistant`',
205
+ '_Reply-to:_ Reply to any notification to route to that workspace',
206
+ defaultWs ? `_Default:_ plain text routes to *${escapeMarkdown(defaultWs)}*` : '_Tip:_ Use `/use <workspace>` to send plain text without a prefix',
207
+ ].join('\n');
208
+ await sendMessage(msg);
209
+ }
210
+ async function handleSessions() {
211
+ (0, workspace_router_1.pruneExpired)();
212
+ const sessions = (0, workspace_router_1.listActiveSessions)();
213
+ if (sessions.length === 0) {
214
+ await sendMessage('No active sessions.');
215
+ return;
216
+ }
217
+ const lines = sessions.map((s) => {
218
+ const icon = sessionIcon(s);
219
+ return `${icon} *${escapeMarkdown(s.workspace)}* (${s.age})`;
220
+ });
221
+ let footer = '';
222
+ const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
223
+ if (defaultWs) {
224
+ footer = `\n\n_Default workspace:_ *${escapeMarkdown(defaultWs)}*`;
225
+ }
226
+ await sendMessage(`*Active Sessions*\n\n${lines.join('\n')}${footer}`);
227
+ }
228
+ async function handleStatus(workspaceArg) {
229
+ let workspace;
230
+ if (workspaceArg) {
231
+ workspace = workspaceArg;
232
+ }
233
+ else {
234
+ const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
235
+ if (defaultWs) {
236
+ workspace = defaultWs;
237
+ }
238
+ else {
239
+ await sendMessage('Usage: `/status <workspace>` or set a default with `/use`.');
240
+ return;
241
+ }
242
+ }
243
+ const resolved = (0, workspace_router_1.resolveWorkspace)(workspace);
244
+ if (resolved.type === 'none') {
245
+ await sendMessage(`No active session for *${escapeMarkdown(workspace)}*.`);
246
+ return;
247
+ }
248
+ if (resolved.type === 'ambiguous') {
249
+ const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
250
+ await sendMessage(`Multiple matches: ${names}. Be more specific.`);
251
+ return;
252
+ }
253
+ const match = resolved.match;
254
+ const resolvedName = resolved.workspace;
255
+ const tmuxName = match.session.tmuxSession;
256
+ try {
257
+ const output = await sessionCaptureOutput(tmuxName);
258
+ // Trim and take last 20 lines to avoid message length limits
259
+ const trimmed = output.trim().split('\n').slice(-20).join('\n');
260
+ const htmlMsg = `<b>${escapeHtml(resolvedName)}</b> session output:\n<pre>${escapeHtml(trimmed)}</pre>`;
261
+ try {
262
+ await sendHtmlMessage(htmlMsg);
263
+ }
264
+ catch {
265
+ // Fallback to plain text if HTML fails
266
+ await telegramAPI('sendMessage', { chat_id: CHAT_ID, text: `${resolvedName} session output:\n${trimmed}` });
267
+ }
268
+ }
269
+ catch (err) {
270
+ await sendMessage(`Could not read session \`${tmuxName}\`: ${err.message}`);
271
+ }
272
+ }
273
+ async function handleStop(workspaceArg) {
274
+ let workspace;
275
+ if (workspaceArg) {
276
+ workspace = workspaceArg;
277
+ }
278
+ else {
279
+ const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
280
+ if (defaultWs) {
281
+ workspace = defaultWs;
282
+ }
283
+ else {
284
+ await sendMessage('Usage: `/stop <workspace>` or set a default with `/use`.');
285
+ return;
286
+ }
287
+ }
288
+ const resolved = (0, workspace_router_1.resolveWorkspace)(workspace);
289
+ if (resolved.type === 'none') {
290
+ await sendMessage(`No active session for *${escapeMarkdown(workspace)}*.`);
291
+ return;
292
+ }
293
+ if (resolved.type === 'ambiguous') {
294
+ const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
295
+ await sendMessage(`Multiple matches: ${names}. Be more specific.`);
296
+ return;
297
+ }
298
+ const resolvedName = resolved.workspace;
299
+ const tmuxName = resolved.match.session.tmuxSession;
300
+ if (!await sessionExists(tmuxName)) {
301
+ await sendMessage(`Session \`${tmuxName}\` not found.`);
302
+ return;
303
+ }
304
+ try {
305
+ await sessionInterrupt(tmuxName);
306
+ await sendMessage(`\u26d4 Sent interrupt to *${escapeMarkdown(resolvedName)}*`);
307
+ }
308
+ catch (err) {
309
+ await sendMessage(`\u274c Failed to interrupt: ${err.message}`);
310
+ }
311
+ }
312
+ async function handleCmd(token, command) {
313
+ const map = (0, workspace_router_1.readSessionMap)();
314
+ const session = map[token];
315
+ if (!session) {
316
+ await sendMessage(`No session found for token \`${token}\`.`);
317
+ return;
318
+ }
319
+ if ((0, workspace_router_1.isExpired)(session)) {
320
+ await sendMessage(`Session \`${token}\` has expired.`);
321
+ return;
322
+ }
323
+ await injectAndRespond(session, command, (0, workspace_router_1.extractWorkspaceName)(session.cwd));
324
+ }
325
+ async function handleWorkspaceCommand(workspace, command) {
326
+ const resolved = (0, workspace_router_1.resolveWorkspace)(workspace);
327
+ if (resolved.type === 'none') {
328
+ await sendMessage(`No active session for *${escapeMarkdown(workspace)}*. Use /sessions to see available workspaces.`);
329
+ return;
330
+ }
331
+ if (resolved.type === 'ambiguous') {
332
+ const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
333
+ await sendMessage(`Multiple matches: ${names}. Be more specific.`);
334
+ return;
335
+ }
336
+ await injectAndRespond(resolved.match.session, command, resolved.workspace);
337
+ }
338
+ async function handleUse(arg) {
339
+ // /use — show current default
340
+ if (!arg) {
341
+ const current = (0, workspace_router_1.getDefaultWorkspace)();
342
+ if (current) {
343
+ await sendMessage(`Default workspace: *${escapeMarkdown(current)}*\n\nPlain text messages will route here. Use \`/use clear\` to unset.`);
344
+ }
345
+ else {
346
+ await sendMessage('No default workspace set. Use `/use <workspace>` to set one.');
347
+ }
348
+ return;
349
+ }
350
+ // /use clear | /use none — clear default
351
+ if (arg === 'clear' || arg === 'none') {
352
+ (0, workspace_router_1.setDefaultWorkspace)(null);
353
+ await sendMessage('Default workspace cleared.');
354
+ return;
355
+ }
356
+ // /use <workspace> — resolve and set
357
+ const resolved = (0, workspace_router_1.resolveWorkspace)(arg);
358
+ if (resolved.type === 'none') {
359
+ await sendMessage(`No active session for *${escapeMarkdown(arg)}*. Use /sessions to see available workspaces.`);
360
+ return;
361
+ }
362
+ if (resolved.type === 'ambiguous') {
363
+ const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
364
+ await sendMessage(`Multiple matches: ${names}. Be more specific.`);
365
+ return;
366
+ }
367
+ const fullName = resolved.workspace;
368
+ (0, workspace_router_1.setDefaultWorkspace)(fullName);
369
+ await sendMessage(`Default workspace set to *${escapeMarkdown(fullName)}*. Plain text messages will route here.`);
370
+ }
371
+ async function handleCompact(workspaceArg) {
372
+ let workspace;
373
+ if (workspaceArg) {
374
+ workspace = workspaceArg;
375
+ }
376
+ else {
377
+ const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
378
+ if (defaultWs) {
379
+ workspace = defaultWs;
380
+ }
381
+ else {
382
+ await sendMessage('Usage: `/compact <workspace>` or set a default with `/use`.');
383
+ return;
384
+ }
385
+ }
386
+ const resolved = (0, workspace_router_1.resolveWorkspace)(workspace);
387
+ if (resolved.type === 'none') {
388
+ await sendMessage(`No active session for *${escapeMarkdown(workspace)}*. Use /sessions to see available workspaces.`);
389
+ return;
390
+ }
391
+ if (resolved.type === 'ambiguous') {
392
+ const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
393
+ await sendMessage(`Multiple matches: ${names}. Be more specific.`);
394
+ return;
395
+ }
396
+ const tmuxName = resolved.match.session.tmuxSession;
397
+ // Inject /compact into tmux
398
+ const injected = await injectAndRespond(resolved.match.session, '/compact', resolved.workspace);
399
+ if (!injected)
400
+ return;
401
+ // Two-phase polling to detect compact completion:
402
+ // Phase 1: Wait for "Compacting" to appear (command started processing)
403
+ // Phase 2: Wait for "Compacting" to disappear (command finished)
404
+ let started = false;
405
+ // Phase 1: Wait up to 10s for compact to start
406
+ for (let i = 0; i < 5; i++) {
407
+ await sleep(2000);
408
+ try {
409
+ const output = await sessionCaptureOutput(tmuxName);
410
+ if (output.includes('Compacting')) {
411
+ started = true;
412
+ break;
413
+ }
414
+ }
415
+ catch {
416
+ break;
417
+ }
418
+ }
419
+ if (!started) {
420
+ // Command may have finished very quickly or failed to start
421
+ try {
422
+ const output = await sessionCaptureOutput(tmuxName);
423
+ if (output.includes('Compacted')) {
424
+ const lines = output.trim().split('\n').slice(-10).join('\n');
425
+ await sendMessage(`\u2705 *${escapeMarkdown(resolved.workspace)}* compact done:\n\`\`\`\n${lines}\n\`\`\``);
426
+ }
427
+ }
428
+ catch {
429
+ // ignore
430
+ }
431
+ return;
432
+ }
433
+ // Phase 2: Wait up to 60s for "Compacting" to disappear (compact finished)
434
+ for (let i = 0; i < 30; i++) {
435
+ await sleep(2000);
436
+ try {
437
+ const output = await sessionCaptureOutput(tmuxName);
438
+ if (!output.includes('Compacting')) {
439
+ const lines = output.trim().split('\n').slice(-10).join('\n');
440
+ await sendMessage(`\u2705 *${escapeMarkdown(resolved.workspace)}* compact done:\n\`\`\`\n${lines}\n\`\`\``);
441
+ return;
442
+ }
443
+ }
444
+ catch {
445
+ break;
446
+ }
447
+ }
448
+ // Timeout — show current session state
449
+ try {
450
+ const output = await sessionCaptureOutput(tmuxName);
451
+ const trimmed = output.trim().split('\n').slice(-5).join('\n');
452
+ await sendMessage(`\u23f3 *${escapeMarkdown(resolved.workspace)}* compact may still be running:\n\`\`\`\n${trimmed}\n\`\`\``);
453
+ }
454
+ catch {
455
+ // ignore
456
+ }
457
+ }
458
+ async function handleNew(nameArg) {
459
+ if (!nameArg) {
460
+ const recent = (0, workspace_router_1.getRecentProjects)(10);
461
+ if (recent.length === 0) {
462
+ const home = process.env.HOME;
463
+ const dirs = process.env.PROJECT_DIRS
464
+ ? process.env.PROJECT_DIRS.split(',').map(d => d.trim().replace(home, '~')).join(', ')
465
+ : '~/projects, ~/tools';
466
+ await sendMessage(`No project history yet.\n\nUse \`/new <project-name>\` to start.\nSearches: ${dirs}, ~/`);
467
+ return;
468
+ }
469
+ const keyboard = [];
470
+ for (let i = 0; i < recent.length; i += 2) {
471
+ const row = recent.slice(i, i + 2).map(p => ({
472
+ text: p.name,
473
+ callback_data: `new:${p.name}`,
474
+ }));
475
+ keyboard.push(row);
476
+ }
477
+ await telegramAPI('sendMessage', {
478
+ chat_id: CHAT_ID,
479
+ text: '*Start Claude Session*\n\nSelect a project or use `/new <name>`:',
480
+ parse_mode: 'Markdown',
481
+ reply_markup: { inline_keyboard: keyboard },
482
+ });
483
+ return;
484
+ }
485
+ await startProject(nameArg);
486
+ }
487
+ async function startProject(name) {
488
+ const home = process.env.HOME;
489
+ // 1. Find project directory — exact match first
490
+ const configuredDirs = process.env.PROJECT_DIRS
491
+ ? process.env.PROJECT_DIRS.split(',').map(d => d.trim()).filter(Boolean)
492
+ : [path_1.default.join(home, 'projects'), path_1.default.join(home, 'tools')];
493
+ const candidates = [
494
+ ...configuredDirs.map(d => path_1.default.join(d, name)),
495
+ path_1.default.join(home, name),
496
+ ];
497
+ let projectDir = null;
498
+ for (const dir of candidates) {
499
+ try {
500
+ if (fs_1.default.statSync(dir).isDirectory()) {
501
+ projectDir = dir;
502
+ break;
503
+ }
504
+ }
505
+ catch { }
506
+ }
507
+ // 2. If no exact match, prefix match against configured dirs ONLY
508
+ // (skip ~/ to avoid matching Desktop, Documents, Downloads, Library, etc.)
509
+ if (!projectDir) {
510
+ const searchDirs = configuredDirs;
511
+ const matches = [];
512
+ for (const base of searchDirs) {
513
+ try {
514
+ const entries = fs_1.default.readdirSync(base, { withFileTypes: true });
515
+ for (const e of entries) {
516
+ if (e.isDirectory() && e.name.toLowerCase().startsWith(name.toLowerCase())) {
517
+ matches.push({ name: e.name, path: path_1.default.join(base, e.name) });
518
+ }
519
+ }
520
+ }
521
+ catch { }
522
+ }
523
+ // Deduplicate by name (prefer ~/projects/ over ~/tools/)
524
+ const unique = [...new Map(matches.map(m => [m.name, m])).values()];
525
+ if (unique.length === 1) {
526
+ projectDir = unique[0].path;
527
+ name = unique[0].name;
528
+ }
529
+ else if (unique.length > 1) {
530
+ // Show matches as inline buttons (max 10)
531
+ const limited = unique.slice(0, 10);
532
+ const keyboard = [];
533
+ for (let i = 0; i < limited.length; i += 2) {
534
+ keyboard.push(limited.slice(i, i + 2).map(m => ({
535
+ text: m.name, callback_data: `new:${m.name}`,
536
+ })));
537
+ }
538
+ await telegramAPI('sendMessage', {
539
+ chat_id: CHAT_ID,
540
+ text: `Multiple matches for *${escapeMarkdown(name)}*:`,
541
+ parse_mode: 'Markdown',
542
+ reply_markup: { inline_keyboard: keyboard },
543
+ });
544
+ return;
545
+ }
546
+ }
547
+ if (!projectDir) {
548
+ const searchedPaths = configuredDirs.map(d => d.replace(home, '~')).join(', ') + ', ~/';
549
+ await sendMessage(`Project \`${escapeMarkdown(name)}\` not found.\n\nSearched: ${searchedPaths}`);
550
+ return;
551
+ }
552
+ // 3. Sanitize tmux session name (dots, colons, spaces are invalid in tmux)
553
+ const tmuxName = name.replace(/[.:\s]/g, '-');
554
+ // 4. Check existing session (PTY or tmux)
555
+ const alreadyRunning = await sessionExists(tmuxName);
556
+ if (alreadyRunning) {
557
+ (0, workspace_router_1.upsertSession)({ cwd: projectDir, tmuxSession: tmuxName, status: 'waiting', sessionId: null });
558
+ (0, workspace_router_1.recordProjectUsage)(name, projectDir);
559
+ (0, workspace_router_1.setDefaultWorkspace)(name);
560
+ await sendMessage(`Session \`${tmuxName}\` already running.\nSet as default — send messages directly.`);
561
+ return;
562
+ }
563
+ // 5. Create session — PTY or tmux
564
+ const usePty = !TMUX_AVAILABLE || INJECTION_MODE === 'pty';
565
+ if (!usePty) {
566
+ // tmux path (existing behaviour)
567
+ try {
568
+ await tmuxExec(`tmux new-session -d -s "${tmuxName}" -c "${projectDir}"`);
569
+ await sleep(300);
570
+ await tmuxExec(`tmux send-keys -t "${tmuxName}" 'claude' C-m`);
571
+ }
572
+ catch (err) {
573
+ await sendMessage(`Failed to start session: ${err.message}`);
574
+ return;
575
+ }
576
+ (0, workspace_router_1.upsertSession)({ cwd: projectDir, tmuxSession: tmuxName, status: 'starting', sessionId: null, sessionType: 'tmux' });
577
+ (0, workspace_router_1.recordProjectUsage)(name, projectDir);
578
+ (0, workspace_router_1.setDefaultWorkspace)(name);
579
+ const msg = await sendMessage(`Started Claude in *${escapeMarkdown(name)}*\n\n` +
580
+ `*Path:* \`${projectDir}\`\n` +
581
+ `*Session:* \`${tmuxName}\`\n\n` +
582
+ `Default workspace set — send messages directly.`);
583
+ if (msg && msg.message_id) {
584
+ (0, workspace_router_1.trackNotificationMessage)(msg.message_id, name, 'new-session');
585
+ }
586
+ }
587
+ else if (pty_session_manager_1.ptySessionManager.isAvailable()) {
588
+ // PTY path — spawns 'claude' directly (no separate send-keys step)
589
+ const ok = pty_session_manager_1.ptySessionManager.spawn(tmuxName, projectDir);
590
+ if (!ok) {
591
+ await sendMessage('Failed to spawn PTY session.');
592
+ return;
593
+ }
594
+ (0, workspace_router_1.upsertSession)({ cwd: projectDir, tmuxSession: tmuxName, status: 'starting', sessionId: null, sessionType: 'pty' });
595
+ (0, workspace_router_1.recordProjectUsage)(name, projectDir);
596
+ (0, workspace_router_1.setDefaultWorkspace)(name);
597
+ const msg = await sendMessage(`Started Claude in *${escapeMarkdown(name)}*\n\n` +
598
+ `*Path:* \`${projectDir}\`\n` +
599
+ `*Session:* \`${tmuxName}\`\n\n` +
600
+ `Default workspace set — send messages directly.\n\n` +
601
+ `_Headless PTY mode — full Telegram control. Not attachable from terminal._`);
602
+ if (msg && msg.message_id) {
603
+ (0, workspace_router_1.trackNotificationMessage)(msg.message_id, name, 'new-session');
604
+ }
605
+ }
606
+ else {
607
+ await sendMessage('\u26a0\ufe0f tmux not found and node-pty not installed.\n' +
608
+ 'Install tmux or run: `npm install node-pty` in ~/.ccgram/');
609
+ }
610
+ }
611
+ async function injectAndRespond(session, command, workspace) {
612
+ const tmuxName = session.tmuxSession;
613
+ if (!await sessionExists(tmuxName)) {
614
+ await sendMessage(`\u26a0\ufe0f Session not found. Start Claude via /new for full remote control, or use tmux.`);
615
+ return false;
616
+ }
617
+ try {
618
+ if (isPtySession(tmuxName)) {
619
+ // PTY: write raw bytes directly — no shell quoting needed
620
+ pty_session_manager_1.ptySessionManager.write(tmuxName, '\x15'); // Ctrl+U: clear line
621
+ await sleep(150);
622
+ pty_session_manager_1.ptySessionManager.write(tmuxName, command); // raw command text
623
+ await sleep(150);
624
+ pty_session_manager_1.ptySessionManager.write(tmuxName, '\r'); // Enter
625
+ }
626
+ else {
627
+ // tmux: existing shell-escaped path
628
+ const escapedCommand = command.replace(/'/g, "'\"'\"'");
629
+ await tmuxExec(`tmux send-keys -t ${tmuxName} C-u`);
630
+ await sleep(150);
631
+ await tmuxExec(`tmux send-keys -t ${tmuxName} '${escapedCommand}'`);
632
+ await sleep(150);
633
+ await tmuxExec(`tmux send-keys -t ${tmuxName} C-m`);
634
+ }
635
+ startTypingIndicator();
636
+ return true;
637
+ }
638
+ catch (err) {
639
+ await sendMessage(`\u274c Failed: ${err.message}`);
640
+ return false;
641
+ }
642
+ }
643
+ function tmuxExec(cmd) {
644
+ return new Promise((resolve, reject) => {
645
+ (0, child_process_1.exec)(cmd, (err) => {
646
+ if (err)
647
+ reject(err);
648
+ else
649
+ resolve(true);
650
+ });
651
+ });
652
+ }
653
+ // ── PTY / tmux dispatch helpers ──────────────────────────────────
654
+ /** Is this session managed as a live PTY handle by this bot process? */
655
+ function isPtySession(sessionName) {
656
+ return pty_session_manager_1.ptySessionManager.has(sessionName);
657
+ }
658
+ /** Check session exists (PTY handle OR tmux session). */
659
+ async function sessionExists(name) {
660
+ if (pty_session_manager_1.ptySessionManager.has(name))
661
+ return true;
662
+ if (TMUX_AVAILABLE) {
663
+ try {
664
+ await tmuxExec(`tmux has-session -t ${name} 2>/dev/null`);
665
+ return true;
666
+ }
667
+ catch {
668
+ return false;
669
+ }
670
+ }
671
+ return false;
672
+ }
673
+ /**
674
+ * Send a named key (Down, Up, Enter, C-m, C-c, C-u, Space) to a session.
675
+ * For PTY: translates to escape sequence via ptySessionManager.sendKey.
676
+ * For tmux: passes key name directly to tmux send-keys.
677
+ */
678
+ async function sessionSendKey(name, key) {
679
+ if (isPtySession(name)) {
680
+ pty_session_manager_1.ptySessionManager.sendKey(name, key);
681
+ }
682
+ else {
683
+ await tmuxExec(`tmux send-keys -t ${name} ${key}`);
684
+ }
685
+ await sleep(100);
686
+ }
687
+ /** Capture session output (last 20 lines). */
688
+ async function sessionCaptureOutput(name) {
689
+ if (isPtySession(name))
690
+ return pty_session_manager_1.ptySessionManager.capture(name, 20) ?? '';
691
+ return capturePane(name);
692
+ }
693
+ /** Send Ctrl+C interrupt to a session. */
694
+ async function sessionInterrupt(name) {
695
+ if (isPtySession(name))
696
+ pty_session_manager_1.ptySessionManager.interrupt(name);
697
+ else
698
+ await tmuxExec(`tmux send-keys -t ${name} C-c`);
699
+ }
700
+ /** Icon for /sessions listing based on session type and live status. */
701
+ function sessionIcon(s) {
702
+ if (s.session.sessionType === 'pty') {
703
+ return pty_session_manager_1.ptySessionManager.has(s.session.tmuxSession) ? '\u{1F916}' : '\u{1F4A4}'; // 🤖 or 💤
704
+ }
705
+ return s.session.description?.startsWith('waiting') ? '\u23f3' : '\u2705'; // ⏳ or ✅
706
+ }
707
+ // ── Callback query handler ───────────────────────────────────────
708
+ async function processCallbackQuery(query) {
709
+ const chatId = String(query.message?.chat?.id);
710
+ if (chatId !== String(CHAT_ID)) {
711
+ logger.warn(`Ignoring callback from unauthorized chat: ${chatId}`);
712
+ return;
713
+ }
714
+ const data = query.data || '';
715
+ const messageId = query.message?.message_id;
716
+ const originalText = query.message?.text || '';
717
+ logger.info(`Callback: ${data}`);
718
+ const parsed = (0, callback_parser_1.parseCallbackData)(data);
719
+ if (!parsed) {
720
+ await answerCallbackQuery(query.id, 'Invalid callback');
721
+ return;
722
+ }
723
+ const { type } = parsed;
724
+ // Handle new: callback (format: new:<projectName>)
725
+ if (type === 'new') {
726
+ const { projectName } = parsed;
727
+ await answerCallbackQuery(query.id, `Starting ${projectName}...`);
728
+ try {
729
+ await editMessageText(chatId, messageId, `${originalText}\n\n— Starting *${escapeMarkdown(projectName)}*...`);
730
+ }
731
+ catch { }
732
+ await startProject(projectName);
733
+ return;
734
+ }
735
+ const { promptId } = parsed;
736
+ if (type === 'perm') {
737
+ // Permission response: write response file for the polling hook
738
+ const pending = (0, prompt_bridge_1.readPending)(promptId);
739
+ if (!pending) {
740
+ await answerCallbackQuery(query.id, 'Session not found');
741
+ return;
742
+ }
743
+ const { action } = parsed;
744
+ const label = action === 'allow' ? '\u2705 Allowed' : action === 'always' ? '\ud83d\udd13 Always Allowed' : '\u274c Denied';
745
+ // Write response file — the permission-hook.js is polling for this
746
+ try {
747
+ (0, prompt_bridge_1.writeResponse)(promptId, { action });
748
+ logger.info(`Wrote permission response for promptId=${promptId}: action=${action}`);
749
+ await answerCallbackQuery(query.id, label);
750
+ }
751
+ catch (err) {
752
+ logger.error(`Failed to write permission response: ${err.message}`);
753
+ await answerCallbackQuery(query.id, 'Failed to save response');
754
+ return;
755
+ }
756
+ // Edit message to show result and remove buttons
757
+ try {
758
+ await editMessageText(chatId, messageId, `${originalText}\n\n— ${label}`);
759
+ }
760
+ catch (err) {
761
+ logger.error(`Failed to edit message: ${err.message}`);
762
+ }
763
+ }
764
+ else if (type === 'opt') {
765
+ // Question option: inject keystroke via tmux
766
+ const pending = (0, prompt_bridge_1.readPending)(promptId);
767
+ if (!pending || !pending.tmuxSession) {
768
+ await answerCallbackQuery(query.id, 'Session not found');
769
+ return;
770
+ }
771
+ const optIdx = parsed.optionIndex - 1;
772
+ const optionLabel = pending.options && pending.options[optIdx]
773
+ ? pending.options[optIdx]
774
+ : `Option ${parsed.optionIndex}`;
775
+ // Multi-select: toggle selection state, update buttons, don't submit yet
776
+ if (pending.multiSelect) {
777
+ const selected = pending.selectedOptions || pending.options.map(() => false);
778
+ selected[optIdx] = !selected[optIdx];
779
+ (0, prompt_bridge_1.updatePending)(promptId, { selectedOptions: selected });
780
+ // Rebuild keyboard with updated checkboxes
781
+ const buttons = pending.options.map((label, idx) => ({
782
+ text: `${selected[idx] ? '\u2611' : '\u2610'} ${idx + 1}. ${label}`,
783
+ callback_data: `opt:${promptId}:${idx + 1}`,
784
+ }));
785
+ const keyboard = [];
786
+ for (let i = 0; i < buttons.length; i += 2) {
787
+ keyboard.push(buttons.slice(i, i + 2));
788
+ }
789
+ keyboard.push([{ text: '\u2705 Submit', callback_data: `opt-submit:${promptId}` }]);
790
+ const checkLabel = selected[optIdx] ? '\u2611' : '\u2610';
791
+ await answerCallbackQuery(query.id, `${checkLabel} ${optionLabel}`);
792
+ // Edit message to show updated buttons
793
+ try {
794
+ await editMessageText(chatId, messageId, originalText, { inline_keyboard: keyboard });
795
+ }
796
+ catch (err) {
797
+ logger.error(`Failed to edit message: ${err.message}`);
798
+ }
799
+ return;
800
+ }
801
+ // Single-select: inject arrow keys + Enter into session
802
+ // Claude Code's AskUserQuestion UI: first option pre-highlighted, Down (N-1) times + Enter
803
+ const downPresses = optIdx;
804
+ const tmuxSessOpt = pending.tmuxSession;
805
+ try {
806
+ for (let i = 0; i < downPresses; i++) {
807
+ await sessionSendKey(tmuxSessOpt, 'Down');
808
+ }
809
+ await sessionSendKey(tmuxSessOpt, 'Enter');
810
+ // For multi-question flows: after the last question, send an extra
811
+ // Enter to confirm the preview/submit step
812
+ if (pending.isLast) {
813
+ await sleep(500);
814
+ await sessionSendKey(tmuxSessOpt, 'Enter');
815
+ }
816
+ await answerCallbackQuery(query.id, `Selected: ${optionLabel}`);
817
+ startTypingIndicator(); // ensure Stop hook routes response back to Telegram
818
+ }
819
+ catch (err) {
820
+ logger.error(`Failed to inject keystroke: ${err.message}`);
821
+ await answerCallbackQuery(query.id, 'Failed to send selection');
822
+ return;
823
+ }
824
+ // Edit message to show selection and remove buttons
825
+ try {
826
+ await editMessageText(chatId, messageId, `${originalText}\n\n— Selected: *${escapeMarkdown(optionLabel)}*`);
827
+ }
828
+ catch (err) {
829
+ logger.error(`Failed to edit message: ${err.message}`);
830
+ }
831
+ (0, prompt_bridge_1.cleanPrompt)(promptId);
832
+ }
833
+ else if (type === 'opt-submit') {
834
+ // Multi-select submit: inject Space toggles for selected options, then Enter
835
+ const pending = (0, prompt_bridge_1.readPending)(promptId);
836
+ if (!pending || !pending.tmuxSession) {
837
+ await answerCallbackQuery(query.id, 'Session not found');
838
+ return;
839
+ }
840
+ const selected = pending.selectedOptions || [];
841
+ const selectedLabels = pending.options.filter((_, idx) => selected[idx]);
842
+ if (selectedLabels.length === 0) {
843
+ await answerCallbackQuery(query.id, 'No options selected');
844
+ return;
845
+ }
846
+ // Inject keystrokes: iterate each option from top, Space if selected, Down to next
847
+ // Claude Code multi-select UI starts with cursor on first option
848
+ // After the listed options, Claude Code adds an auto-generated "Other" option,
849
+ // then Submit. So we need: options.length Downs + 1 more Down to skip "Other"
850
+ const tmuxSessSubmit = pending.tmuxSession;
851
+ try {
852
+ for (let i = 0; i < pending.options.length; i++) {
853
+ if (selected[i]) {
854
+ await sessionSendKey(tmuxSessSubmit, 'Space');
855
+ }
856
+ await sessionSendKey(tmuxSessSubmit, 'Down');
857
+ }
858
+ // Skip past the auto-added "Other" option to reach Submit
859
+ await sessionSendKey(tmuxSessSubmit, 'Down');
860
+ // Cursor is now on Submit — press Enter
861
+ await sessionSendKey(tmuxSessSubmit, 'Enter');
862
+ // For multi-question flows: extra Enter to confirm
863
+ if (pending.isLast) {
864
+ await sleep(500);
865
+ await sessionSendKey(tmuxSessSubmit, 'Enter');
866
+ }
867
+ await answerCallbackQuery(query.id, `Submitted ${selectedLabels.length} options`);
868
+ startTypingIndicator(); // ensure Stop hook routes response back to Telegram
869
+ }
870
+ catch (err) {
871
+ logger.error(`Failed to inject keystrokes: ${err.message}`);
872
+ await answerCallbackQuery(query.id, 'Failed to send selections');
873
+ return;
874
+ }
875
+ // Edit message to show selections and remove buttons
876
+ const selectionText = selectedLabels.map(l => `\u2022 ${escapeMarkdown(l)}`).join('\n');
877
+ try {
878
+ await editMessageText(chatId, messageId, `${originalText}\n\n— Selected:\n${selectionText}`);
879
+ }
880
+ catch (err) {
881
+ logger.error(`Failed to edit message: ${err.message}`);
882
+ }
883
+ (0, prompt_bridge_1.cleanPrompt)(promptId);
884
+ }
885
+ else if (type === 'qperm') {
886
+ // Combined question+permission: allow permission AND inject answer keystroke
887
+ const optIdx = parsed.optionIndex - 1;
888
+ const pending = (0, prompt_bridge_1.readPending)(promptId);
889
+ if (!pending) {
890
+ await answerCallbackQuery(query.id, 'Session not found');
891
+ return;
892
+ }
893
+ const optionLabel = pending.options && pending.options[optIdx]
894
+ ? pending.options[optIdx]
895
+ : `Option ${parsed.optionIndex}`;
896
+ // 1. Write permission response (allow) — unblocks the permission hook
897
+ try {
898
+ (0, prompt_bridge_1.writeResponse)(promptId, { action: 'allow', selectedOption: parsed.optionIndex });
899
+ logger.info(`Wrote qperm response for promptId=${promptId}: option=${parsed.optionIndex}`);
900
+ await answerCallbackQuery(query.id, `Selected: ${optionLabel}`);
901
+ }
902
+ catch (err) {
903
+ logger.error(`Failed to write qperm response: ${err.message}`);
904
+ await answerCallbackQuery(query.id, 'Failed to save response');
905
+ return;
906
+ }
907
+ // 2. Schedule keystroke injection after a delay (wait for question UI to appear)
908
+ if (pending.tmuxSession) {
909
+ const tmux = pending.tmuxSession;
910
+ const downPresses = optIdx;
911
+ setTimeout(async () => {
912
+ try {
913
+ for (let i = 0; i < downPresses; i++) {
914
+ await sessionSendKey(tmux, 'Down');
915
+ }
916
+ await sessionSendKey(tmux, 'Enter');
917
+ startTypingIndicator(); // ensure Stop hook routes response back to Telegram
918
+ logger.info(`Injected question answer into ${tmux}: option ${parsed.optionIndex}`);
919
+ }
920
+ catch (err) {
921
+ logger.error(`Failed to inject question answer: ${err.message}`);
922
+ }
923
+ }, 4000); // 4s delay for permission hook to return + question UI to render
924
+ }
925
+ // Edit message to show selection
926
+ try {
927
+ await editMessageText(chatId, messageId, `${originalText}\n\n— Selected: *${escapeMarkdown(optionLabel)}*`);
928
+ }
929
+ catch (err) {
930
+ logger.error(`Failed to edit message: ${err.message}`);
931
+ }
932
+ }
933
+ }
934
+ // ── Message router ──────────────────────────────────────────────
935
+ async function processMessage(msg) {
936
+ stopTypingIndicator();
937
+ // Only accept messages from the configured chat
938
+ const chatId = String(msg.chat.id);
939
+ if (chatId !== String(CHAT_ID)) {
940
+ logger.warn(`Ignoring message from unauthorized chat: ${chatId}`);
941
+ return;
942
+ }
943
+ const text = (msg.text || '').trim();
944
+ if (!text)
945
+ return;
946
+ logger.info(`Received: ${text}`);
947
+ // /help
948
+ if (text === '/help' || text === '/start') {
949
+ await handleHelp();
950
+ return;
951
+ }
952
+ // /sessions
953
+ if (text === '/sessions') {
954
+ await handleSessions();
955
+ return;
956
+ }
957
+ // /status [workspace]
958
+ const statusMatch = text.match(/^\/status(?:\s+(\S+))?$/);
959
+ if (statusMatch) {
960
+ await handleStatus(statusMatch[1] || null);
961
+ return;
962
+ }
963
+ // /stop [workspace]
964
+ const stopMatch = text.match(/^\/stop(?:\s+(\S+))?$/);
965
+ if (stopMatch) {
966
+ await handleStop(stopMatch[1] || null);
967
+ return;
968
+ }
969
+ // /use [workspace]
970
+ const useMatch = text.match(/^\/use(?:\s+(.*))?$/);
971
+ if (useMatch) {
972
+ await handleUse(useMatch[1] ? useMatch[1].trim() : null);
973
+ return;
974
+ }
975
+ // /compact [workspace]
976
+ const compactMatch = text.match(/^\/compact(?:\s+(\S+))?$/);
977
+ if (compactMatch) {
978
+ await handleCompact(compactMatch[1] || null);
979
+ return;
980
+ }
981
+ // /new [project]
982
+ const newMatch = text.match(/^\/new(?:\s+(.+))?$/);
983
+ if (newMatch) {
984
+ await handleNew(newMatch[1] ? newMatch[1].trim() : null);
985
+ return;
986
+ }
987
+ // /cmd TOKEN command
988
+ const cmdMatch = text.match(/^\/cmd\s+(\S+)\s+(.+)/s);
989
+ if (cmdMatch) {
990
+ await handleCmd(cmdMatch[1], cmdMatch[2]);
991
+ return;
992
+ }
993
+ // /<workspace> command (anything starting with / that isn't a known command)
994
+ const wsMatch = text.match(/^\/(\S+)\s+(.+)/s);
995
+ if (wsMatch) {
996
+ const workspace = wsMatch[1];
997
+ const command = wsMatch[2];
998
+ // Skip Telegram built-in bot commands that start with @
999
+ if (workspace.includes('@'))
1000
+ return;
1001
+ await handleWorkspaceCommand(workspace, command);
1002
+ return;
1003
+ }
1004
+ // If just a slash command with no args, check if it's a workspace (with prefix matching)
1005
+ const bareWs = text.match(/^\/(\S+)$/);
1006
+ if (bareWs) {
1007
+ const resolved = (0, workspace_router_1.resolveWorkspace)(bareWs[1]);
1008
+ if (resolved.type === 'exact' || resolved.type === 'prefix') {
1009
+ await handleStatus(resolved.workspace);
1010
+ }
1011
+ else if (resolved.type === 'ambiguous') {
1012
+ const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
1013
+ await sendMessage(`Multiple matches: ${names}. Be more specific.`);
1014
+ }
1015
+ else {
1016
+ await sendMessage(`Unknown command: \`${text}\`. Try /help`);
1017
+ }
1018
+ return;
1019
+ }
1020
+ // Plain text — try reply-to routing, then default workspace, then show hint
1021
+ const replyToId = msg.reply_to_message && msg.reply_to_message.message_id;
1022
+ if (replyToId) {
1023
+ const replyWorkspace = (0, workspace_router_1.getWorkspaceForMessage)(replyToId);
1024
+ if (replyWorkspace) {
1025
+ await handleWorkspaceCommand(replyWorkspace, text);
1026
+ return;
1027
+ }
1028
+ }
1029
+ const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
1030
+ if (defaultWs) {
1031
+ await handleWorkspaceCommand(defaultWs, text);
1032
+ return;
1033
+ }
1034
+ await sendMessage('Use `/help` to see available commands, or `/use <workspace>` to set a default.');
1035
+ }
1036
+ // ── Long polling loop ───────────────────────────────────────────
1037
+ async function poll() {
1038
+ while (true) {
1039
+ try {
1040
+ const updates = await telegramAPI('getUpdates', {
1041
+ offset: lastUpdateId + 1,
1042
+ timeout: 30,
1043
+ allowed_updates: ['message', 'callback_query'],
1044
+ });
1045
+ lastPollTime = Date.now();
1046
+ for (const update of updates) {
1047
+ lastUpdateId = update.update_id;
1048
+ if (update.callback_query) {
1049
+ try {
1050
+ await processCallbackQuery(update.callback_query);
1051
+ }
1052
+ catch (err) {
1053
+ logger.error(`Error processing callback query: ${err.message}`);
1054
+ }
1055
+ }
1056
+ else if (update.message) {
1057
+ try {
1058
+ await processMessage(update.message);
1059
+ }
1060
+ catch (err) {
1061
+ logger.error(`Error processing message: ${err.message}`);
1062
+ }
1063
+ }
1064
+ }
1065
+ }
1066
+ catch (err) {
1067
+ // Network error — back off and retry
1068
+ logger.error(`Polling error: ${err.message}`);
1069
+ await sleep(5000);
1070
+ }
1071
+ }
1072
+ }
1073
+ // ── Helpers ──────────────────────────────────────────────────────
1074
+ function capturePane(tmuxSession) {
1075
+ return new Promise((resolve, reject) => {
1076
+ (0, child_process_1.exec)(`tmux capture-pane -t ${tmuxSession} -p`, (err, stdout) => {
1077
+ if (err)
1078
+ reject(err);
1079
+ else
1080
+ resolve(stdout);
1081
+ });
1082
+ });
1083
+ }
1084
+ function escapeMarkdown(text) {
1085
+ // Telegram Markdown v1 only needs these escaped
1086
+ return text.replace(/([_*`\[])/g, '\\$1');
1087
+ }
1088
+ function escapeHtml(text) {
1089
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1090
+ }
1091
+ function sleep(ms) {
1092
+ return new Promise((resolve) => setTimeout(resolve, ms));
1093
+ }
1094
+ // ── Health check server ──────────────────────────────────────────
1095
+ function startHealthServer(port) {
1096
+ const server = http_1.default.createServer((req, res) => {
1097
+ if (req.url !== '/health') {
1098
+ res.writeHead(404);
1099
+ res.end('Not found');
1100
+ return;
1101
+ }
1102
+ const now = Date.now();
1103
+ const pollAge = lastPollTime ? now - lastPollTime : null;
1104
+ const stale = pollAge === null || pollAge > 60000;
1105
+ const sessions = (0, workspace_router_1.listActiveSessions)();
1106
+ let pendingCount = 0;
1107
+ try {
1108
+ const files = fs_1.default.readdirSync(prompt_bridge_1.PROMPTS_DIR).filter(f => f.startsWith('pending-'));
1109
+ pendingCount = files.length;
1110
+ }
1111
+ catch { }
1112
+ const body = JSON.stringify({
1113
+ status: stale ? 'unhealthy' : 'ok',
1114
+ uptime: Math.floor((now - startTime) / 1000),
1115
+ lastPollAge: pollAge !== null ? Math.floor(pollAge / 1000) : null,
1116
+ activeSessions: sessions.length,
1117
+ pendingPrompts: pendingCount,
1118
+ }, null, 2);
1119
+ res.writeHead(stale ? 503 : 200, { 'Content-Type': 'application/json' });
1120
+ res.end(body);
1121
+ });
1122
+ server.listen(port, '127.0.0.1', () => {
1123
+ logger.info(`Health endpoint: http://127.0.0.1:${port}/health`);
1124
+ });
1125
+ server.on('error', (err) => {
1126
+ logger.warn(`Health server error: ${err.message}`);
1127
+ });
1128
+ }
1129
+ // ── Startup ─────────────────────────────────────────────────────
1130
+ async function start() {
1131
+ // Ensure data directory exists
1132
+ const dataDir = path_1.default.join(paths_1.PROJECT_ROOT, 'src/data');
1133
+ fs_1.default.mkdirSync(dataDir, { recursive: true });
1134
+ const { version } = require(path_1.default.join(paths_1.PROJECT_ROOT, 'package.json'));
1135
+ logger.info(`CCGram v${version} — Starting Telegram bot (long polling)...`);
1136
+ logger.info(`Chat ID: ${CHAT_ID}`);
1137
+ // Prune expired sessions on startup
1138
+ const pruned = (0, workspace_router_1.pruneExpired)();
1139
+ if (pruned > 0) {
1140
+ logger.info(`Pruned ${pruned} expired sessions`);
1141
+ }
1142
+ // Delete any existing webhook to ensure long polling works
1143
+ try {
1144
+ await telegramAPI('deleteWebhook', {});
1145
+ logger.info('Webhook cleared, using long polling');
1146
+ }
1147
+ catch (err) {
1148
+ logger.warn(`Could not delete webhook: ${err.message}`);
1149
+ }
1150
+ // Register bot commands with Telegram (populates the "/" menu in chat)
1151
+ await registerBotCommands();
1152
+ // Start optional health check server
1153
+ const healthPort = parseInt(process.env.HEALTH_PORT, 10);
1154
+ if (healthPort) {
1155
+ startHealthServer(healthPort);
1156
+ }
1157
+ await poll();
1158
+ }
1159
+ // Graceful shutdown
1160
+ process.on('SIGINT', () => {
1161
+ logger.info('Shutting down...');
1162
+ process.exit(0);
1163
+ });
1164
+ process.on('SIGTERM', () => {
1165
+ logger.info('Shutting down...');
1166
+ process.exit(0);
1167
+ });
1168
+ start().catch((err) => {
1169
+ logger.error(`Fatal: ${err.message}`);
1170
+ process.exit(1);
1171
+ });
1172
+ //# sourceMappingURL=workspace-telegram-bot.js.map