@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.
Files changed (292) hide show
  1. package/.env.example +22 -0
  2. package/CHANGELOG.md +268 -0
  3. package/LICENSE +21 -0
  4. package/README.md +394 -0
  5. package/ccgram.service +24 -0
  6. package/config/channels.json +58 -0
  7. package/config/default.json +27 -0
  8. package/config/defaults/config.json +16 -0
  9. package/config/defaults/i18n.json +32 -0
  10. package/config/email-template.json +31 -0
  11. package/config/test-with-subagent.json +16 -0
  12. package/config/user.json +27 -0
  13. package/dist/claude-hook-notify.d.ts +7 -0
  14. package/dist/claude-hook-notify.d.ts.map +1 -0
  15. package/dist/claude-hook-notify.js +154 -0
  16. package/dist/claude-hook-notify.js.map +1 -0
  17. package/dist/claude-remote.d.ts +50 -0
  18. package/dist/claude-remote.d.ts.map +1 -0
  19. package/dist/claude-remote.js +927 -0
  20. package/dist/claude-remote.js.map +1 -0
  21. package/dist/elicitation-notify.d.ts +20 -0
  22. package/dist/elicitation-notify.d.ts.map +1 -0
  23. package/dist/elicitation-notify.js +241 -0
  24. package/dist/elicitation-notify.js.map +1 -0
  25. package/dist/enhanced-hook-notify.d.ts +23 -0
  26. package/dist/enhanced-hook-notify.d.ts.map +1 -0
  27. package/dist/enhanced-hook-notify.js +402 -0
  28. package/dist/enhanced-hook-notify.js.map +1 -0
  29. package/dist/permission-denied-notify.d.ts +11 -0
  30. package/dist/permission-denied-notify.d.ts.map +1 -0
  31. package/dist/permission-denied-notify.js +193 -0
  32. package/dist/permission-denied-notify.js.map +1 -0
  33. package/dist/permission-hook.d.ts +15 -0
  34. package/dist/permission-hook.d.ts.map +1 -0
  35. package/dist/permission-hook.js +386 -0
  36. package/dist/permission-hook.js.map +1 -0
  37. package/dist/pre-compact-notify.d.ts +13 -0
  38. package/dist/pre-compact-notify.d.ts.map +1 -0
  39. package/dist/pre-compact-notify.js +197 -0
  40. package/dist/pre-compact-notify.js.map +1 -0
  41. package/dist/prompt-bridge.d.ts +50 -0
  42. package/dist/prompt-bridge.d.ts.map +1 -0
  43. package/dist/prompt-bridge.js +173 -0
  44. package/dist/prompt-bridge.js.map +1 -0
  45. package/dist/question-notify.d.ts +17 -0
  46. package/dist/question-notify.d.ts.map +1 -0
  47. package/dist/question-notify.js +356 -0
  48. package/dist/question-notify.js.map +1 -0
  49. package/dist/setup.d.ts +10 -0
  50. package/dist/setup.d.ts.map +1 -0
  51. package/dist/setup.js +649 -0
  52. package/dist/setup.js.map +1 -0
  53. package/dist/smart-monitor.d.ts +7 -0
  54. package/dist/smart-monitor.d.ts.map +1 -0
  55. package/dist/smart-monitor.js +256 -0
  56. package/dist/smart-monitor.js.map +1 -0
  57. package/dist/src/automation/clipboard-automation.d.ts +35 -0
  58. package/dist/src/automation/clipboard-automation.d.ts.map +1 -0
  59. package/dist/src/automation/clipboard-automation.js +242 -0
  60. package/dist/src/automation/clipboard-automation.js.map +1 -0
  61. package/dist/src/channels/base/channel.d.ts +60 -0
  62. package/dist/src/channels/base/channel.d.ts.map +1 -0
  63. package/dist/src/channels/base/channel.js +96 -0
  64. package/dist/src/channels/base/channel.js.map +1 -0
  65. package/dist/src/channels/email/smtp.d.ts +74 -0
  66. package/dist/src/channels/email/smtp.d.ts.map +1 -0
  67. package/dist/src/channels/email/smtp.js +605 -0
  68. package/dist/src/channels/email/smtp.js.map +1 -0
  69. package/dist/src/channels/line/line.d.ts +36 -0
  70. package/dist/src/channels/line/line.d.ts.map +1 -0
  71. package/dist/src/channels/line/line.js +180 -0
  72. package/dist/src/channels/line/line.js.map +1 -0
  73. package/dist/src/channels/line/webhook.d.ts +55 -0
  74. package/dist/src/channels/line/webhook.d.ts.map +1 -0
  75. package/dist/src/channels/line/webhook.js +191 -0
  76. package/dist/src/channels/line/webhook.js.map +1 -0
  77. package/dist/src/channels/local/desktop.d.ts +30 -0
  78. package/dist/src/channels/local/desktop.d.ts.map +1 -0
  79. package/dist/src/channels/local/desktop.js +161 -0
  80. package/dist/src/channels/local/desktop.js.map +1 -0
  81. package/dist/src/channels/telegram/telegram.d.ts +43 -0
  82. package/dist/src/channels/telegram/telegram.d.ts.map +1 -0
  83. package/dist/src/channels/telegram/telegram.js +223 -0
  84. package/dist/src/channels/telegram/telegram.js.map +1 -0
  85. package/dist/src/channels/telegram/webhook.d.ts +75 -0
  86. package/dist/src/channels/telegram/webhook.d.ts.map +1 -0
  87. package/dist/src/channels/telegram/webhook.js +278 -0
  88. package/dist/src/channels/telegram/webhook.js.map +1 -0
  89. package/dist/src/cli.d.ts +9 -0
  90. package/dist/src/cli.d.ts.map +1 -0
  91. package/dist/src/cli.js +99 -0
  92. package/dist/src/cli.js.map +1 -0
  93. package/dist/src/commands/hooks.d.ts +10 -0
  94. package/dist/src/commands/hooks.d.ts.map +1 -0
  95. package/dist/src/commands/hooks.js +50 -0
  96. package/dist/src/commands/hooks.js.map +1 -0
  97. package/dist/src/commands/init.d.ts +20 -0
  98. package/dist/src/commands/init.d.ts.map +1 -0
  99. package/dist/src/commands/init.js +173 -0
  100. package/dist/src/commands/init.js.map +1 -0
  101. package/dist/src/commands/license.d.ts +15 -0
  102. package/dist/src/commands/license.d.ts.map +1 -0
  103. package/dist/src/commands/license.js +107 -0
  104. package/dist/src/commands/license.js.map +1 -0
  105. package/dist/src/commands/start.d.ts +17 -0
  106. package/dist/src/commands/start.d.ts.map +1 -0
  107. package/dist/src/commands/start.js +150 -0
  108. package/dist/src/commands/start.js.map +1 -0
  109. package/dist/src/commands/status.d.ts +8 -0
  110. package/dist/src/commands/status.d.ts.map +1 -0
  111. package/dist/src/commands/status.js +95 -0
  112. package/dist/src/commands/status.js.map +1 -0
  113. package/dist/src/commands/stop.d.ts +8 -0
  114. package/dist/src/commands/stop.d.ts.map +1 -0
  115. package/dist/src/commands/stop.js +64 -0
  116. package/dist/src/commands/stop.js.map +1 -0
  117. package/dist/src/config-manager.d.ts +16 -0
  118. package/dist/src/config-manager.d.ts.map +1 -0
  119. package/dist/src/config-manager.js +152 -0
  120. package/dist/src/config-manager.js.map +1 -0
  121. package/dist/src/core/config.d.ts +28 -0
  122. package/dist/src/core/config.d.ts.map +1 -0
  123. package/dist/src/core/config.js +248 -0
  124. package/dist/src/core/config.js.map +1 -0
  125. package/dist/src/core/logger.d.ts +19 -0
  126. package/dist/src/core/logger.d.ts.map +1 -0
  127. package/dist/src/core/logger.js +47 -0
  128. package/dist/src/core/logger.js.map +1 -0
  129. package/dist/src/core/notifier.d.ts +45 -0
  130. package/dist/src/core/notifier.d.ts.map +1 -0
  131. package/dist/src/core/notifier.js +189 -0
  132. package/dist/src/core/notifier.js.map +1 -0
  133. package/dist/src/lib/license-validator.d.ts +120 -0
  134. package/dist/src/lib/license-validator.d.ts.map +1 -0
  135. package/dist/src/lib/license-validator.js +294 -0
  136. package/dist/src/lib/license-validator.js.map +1 -0
  137. package/dist/src/lib/preflight.d.ts +28 -0
  138. package/dist/src/lib/preflight.d.ts.map +1 -0
  139. package/dist/src/lib/preflight.js +90 -0
  140. package/dist/src/lib/preflight.js.map +1 -0
  141. package/dist/src/relay/claude-command-bridge.d.ts +57 -0
  142. package/dist/src/relay/claude-command-bridge.d.ts.map +1 -0
  143. package/dist/src/relay/claude-command-bridge.js +188 -0
  144. package/dist/src/relay/claude-command-bridge.js.map +1 -0
  145. package/dist/src/relay/email-listener.d.ts +65 -0
  146. package/dist/src/relay/email-listener.d.ts.map +1 -0
  147. package/dist/src/relay/email-listener.js +460 -0
  148. package/dist/src/relay/email-listener.js.map +1 -0
  149. package/dist/src/relay/relay-pty.d.ts +21 -0
  150. package/dist/src/relay/relay-pty.d.ts.map +1 -0
  151. package/dist/src/relay/relay-pty.js +696 -0
  152. package/dist/src/relay/relay-pty.js.map +1 -0
  153. package/dist/src/relay/smart-injector.d.ts +30 -0
  154. package/dist/src/relay/smart-injector.d.ts.map +1 -0
  155. package/dist/src/relay/smart-injector.js +233 -0
  156. package/dist/src/relay/smart-injector.js.map +1 -0
  157. package/dist/src/relay/tmux-injector.d.ts +46 -0
  158. package/dist/src/relay/tmux-injector.d.ts.map +1 -0
  159. package/dist/src/relay/tmux-injector.js +413 -0
  160. package/dist/src/relay/tmux-injector.js.map +1 -0
  161. package/dist/src/tools/config-manager.d.ts +33 -0
  162. package/dist/src/tools/config-manager.d.ts.map +1 -0
  163. package/dist/src/tools/config-manager.js +448 -0
  164. package/dist/src/tools/config-manager.js.map +1 -0
  165. package/dist/src/tools/installer.d.ts +38 -0
  166. package/dist/src/tools/installer.d.ts.map +1 -0
  167. package/dist/src/tools/installer.js +222 -0
  168. package/dist/src/tools/installer.js.map +1 -0
  169. package/dist/src/types/callbacks.d.ts +53 -0
  170. package/dist/src/types/callbacks.d.ts.map +1 -0
  171. package/dist/src/types/callbacks.js +7 -0
  172. package/dist/src/types/callbacks.js.map +1 -0
  173. package/dist/src/types/config.d.ts +56 -0
  174. package/dist/src/types/config.d.ts.map +1 -0
  175. package/dist/src/types/config.js +6 -0
  176. package/dist/src/types/config.js.map +1 -0
  177. package/dist/src/types/hooks.d.ts +47 -0
  178. package/dist/src/types/hooks.d.ts.map +1 -0
  179. package/dist/src/types/hooks.js +6 -0
  180. package/dist/src/types/hooks.js.map +1 -0
  181. package/dist/src/types/index.d.ts +7 -0
  182. package/dist/src/types/index.d.ts.map +1 -0
  183. package/dist/src/types/index.js +23 -0
  184. package/dist/src/types/index.js.map +1 -0
  185. package/dist/src/types/ipc.d.ts +43 -0
  186. package/dist/src/types/ipc.d.ts.map +1 -0
  187. package/dist/src/types/ipc.js +7 -0
  188. package/dist/src/types/ipc.js.map +1 -0
  189. package/dist/src/types/session.d.ts +87 -0
  190. package/dist/src/types/session.d.ts.map +1 -0
  191. package/dist/src/types/session.js +9 -0
  192. package/dist/src/types/session.js.map +1 -0
  193. package/dist/src/types/telegram.d.ts +58 -0
  194. package/dist/src/types/telegram.d.ts.map +1 -0
  195. package/dist/src/types/telegram.js +6 -0
  196. package/dist/src/types/telegram.js.map +1 -0
  197. package/dist/src/utils/active-check.d.ts +20 -0
  198. package/dist/src/utils/active-check.d.ts.map +1 -0
  199. package/dist/src/utils/active-check.js +42 -0
  200. package/dist/src/utils/active-check.js.map +1 -0
  201. package/dist/src/utils/callback-parser.d.ts +23 -0
  202. package/dist/src/utils/callback-parser.d.ts.map +1 -0
  203. package/dist/src/utils/callback-parser.js +85 -0
  204. package/dist/src/utils/callback-parser.js.map +1 -0
  205. package/dist/src/utils/controller-injector.d.ts +21 -0
  206. package/dist/src/utils/controller-injector.d.ts.map +1 -0
  207. package/dist/src/utils/controller-injector.js +108 -0
  208. package/dist/src/utils/controller-injector.js.map +1 -0
  209. package/dist/src/utils/conversation-tracker.d.ts +32 -0
  210. package/dist/src/utils/conversation-tracker.d.ts.map +1 -0
  211. package/dist/src/utils/conversation-tracker.js +119 -0
  212. package/dist/src/utils/conversation-tracker.js.map +1 -0
  213. package/dist/src/utils/deep-link.d.ts +22 -0
  214. package/dist/src/utils/deep-link.d.ts.map +1 -0
  215. package/dist/src/utils/deep-link.js +43 -0
  216. package/dist/src/utils/deep-link.js.map +1 -0
  217. package/dist/src/utils/ghostty-session-manager.d.ts +81 -0
  218. package/dist/src/utils/ghostty-session-manager.d.ts.map +1 -0
  219. package/dist/src/utils/ghostty-session-manager.js +370 -0
  220. package/dist/src/utils/ghostty-session-manager.js.map +1 -0
  221. package/dist/src/utils/hook-definitions.d.ts +25 -0
  222. package/dist/src/utils/hook-definitions.d.ts.map +1 -0
  223. package/dist/src/utils/hook-definitions.js +36 -0
  224. package/dist/src/utils/hook-definitions.js.map +1 -0
  225. package/dist/src/utils/http-request.d.ts +25 -0
  226. package/dist/src/utils/http-request.d.ts.map +1 -0
  227. package/dist/src/utils/http-request.js +66 -0
  228. package/dist/src/utils/http-request.js.map +1 -0
  229. package/dist/src/utils/optional-require.d.ts +13 -0
  230. package/dist/src/utils/optional-require.d.ts.map +1 -0
  231. package/dist/src/utils/optional-require.js +37 -0
  232. package/dist/src/utils/optional-require.js.map +1 -0
  233. package/dist/src/utils/paths.d.ts +13 -0
  234. package/dist/src/utils/paths.d.ts.map +1 -0
  235. package/dist/src/utils/paths.js +30 -0
  236. package/dist/src/utils/paths.js.map +1 -0
  237. package/dist/src/utils/pty-session-manager.d.ts +43 -0
  238. package/dist/src/utils/pty-session-manager.d.ts.map +1 -0
  239. package/dist/src/utils/pty-session-manager.js +183 -0
  240. package/dist/src/utils/pty-session-manager.js.map +1 -0
  241. package/dist/src/utils/subagent-tracker.d.ts +64 -0
  242. package/dist/src/utils/subagent-tracker.d.ts.map +1 -0
  243. package/dist/src/utils/subagent-tracker.js +191 -0
  244. package/dist/src/utils/subagent-tracker.js.map +1 -0
  245. package/dist/src/utils/tmux-monitor.d.ts +102 -0
  246. package/dist/src/utils/tmux-monitor.d.ts.map +1 -0
  247. package/dist/src/utils/tmux-monitor.js +642 -0
  248. package/dist/src/utils/tmux-monitor.js.map +1 -0
  249. package/dist/src/utils/trace-capture.d.ts +42 -0
  250. package/dist/src/utils/trace-capture.d.ts.map +1 -0
  251. package/dist/src/utils/trace-capture.js +102 -0
  252. package/dist/src/utils/trace-capture.js.map +1 -0
  253. package/dist/src/utils/transcript-reader.d.ts +57 -0
  254. package/dist/src/utils/transcript-reader.d.ts.map +1 -0
  255. package/dist/src/utils/transcript-reader.js +229 -0
  256. package/dist/src/utils/transcript-reader.js.map +1 -0
  257. package/dist/start-all-webhooks.d.ts +7 -0
  258. package/dist/start-all-webhooks.d.ts.map +1 -0
  259. package/dist/start-all-webhooks.js +98 -0
  260. package/dist/start-all-webhooks.js.map +1 -0
  261. package/dist/start-line-webhook.d.ts +7 -0
  262. package/dist/start-line-webhook.d.ts.map +1 -0
  263. package/dist/start-line-webhook.js +59 -0
  264. package/dist/start-line-webhook.js.map +1 -0
  265. package/dist/start-relay-pty.d.ts +7 -0
  266. package/dist/start-relay-pty.d.ts.map +1 -0
  267. package/dist/start-relay-pty.js +173 -0
  268. package/dist/start-relay-pty.js.map +1 -0
  269. package/dist/start-telegram-webhook.d.ts +7 -0
  270. package/dist/start-telegram-webhook.d.ts.map +1 -0
  271. package/dist/start-telegram-webhook.js +80 -0
  272. package/dist/start-telegram-webhook.js.map +1 -0
  273. package/dist/user-prompt-hook.d.ts +13 -0
  274. package/dist/user-prompt-hook.d.ts.map +1 -0
  275. package/dist/user-prompt-hook.js +45 -0
  276. package/dist/user-prompt-hook.js.map +1 -0
  277. package/dist/workspace-router.d.ts +114 -0
  278. package/dist/workspace-router.d.ts.map +1 -0
  279. package/dist/workspace-router.js +572 -0
  280. package/dist/workspace-router.js.map +1 -0
  281. package/dist/workspace-telegram-bot.d.ts +3 -0
  282. package/dist/workspace-telegram-bot.d.ts.map +1 -0
  283. package/dist/workspace-telegram-bot.js +1847 -0
  284. package/dist/workspace-telegram-bot.js.map +1 -0
  285. package/package.json +85 -0
  286. package/src/types/callbacks.ts +73 -0
  287. package/src/types/config.ts +63 -0
  288. package/src/types/hooks.ts +50 -0
  289. package/src/types/index.ts +6 -0
  290. package/src/types/ipc.ts +55 -0
  291. package/src/types/session.ts +91 -0
  292. 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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