@visorcraft/idlehands 0.9.1

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 (197) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +30 -0
  3. package/dist/agent.js +2604 -0
  4. package/dist/agent.js.map +1 -0
  5. package/dist/anton/controller.js +341 -0
  6. package/dist/anton/controller.js.map +1 -0
  7. package/dist/anton/lock.js +110 -0
  8. package/dist/anton/lock.js.map +1 -0
  9. package/dist/anton/parser.js +303 -0
  10. package/dist/anton/parser.js.map +1 -0
  11. package/dist/anton/prompt.js +203 -0
  12. package/dist/anton/prompt.js.map +1 -0
  13. package/dist/anton/reporter.js +119 -0
  14. package/dist/anton/reporter.js.map +1 -0
  15. package/dist/anton/session.js +51 -0
  16. package/dist/anton/session.js.map +1 -0
  17. package/dist/anton/types.js +7 -0
  18. package/dist/anton/types.js.map +1 -0
  19. package/dist/anton/verifier.js +263 -0
  20. package/dist/anton/verifier.js.map +1 -0
  21. package/dist/bench/compare.js +239 -0
  22. package/dist/bench/compare.js.map +1 -0
  23. package/dist/bench/debug_hooks.js +17 -0
  24. package/dist/bench/debug_hooks.js.map +1 -0
  25. package/dist/bench/json_extract.js +22 -0
  26. package/dist/bench/json_extract.js.map +1 -0
  27. package/dist/bench/openclaw.js +86 -0
  28. package/dist/bench/openclaw.js.map +1 -0
  29. package/dist/bench/report.js +116 -0
  30. package/dist/bench/report.js.map +1 -0
  31. package/dist/bench/runner.js +312 -0
  32. package/dist/bench/runner.js.map +1 -0
  33. package/dist/bench/types.js +2 -0
  34. package/dist/bench/types.js.map +1 -0
  35. package/dist/bot/commands.js +444 -0
  36. package/dist/bot/commands.js.map +1 -0
  37. package/dist/bot/confirm-discord.js +133 -0
  38. package/dist/bot/confirm-discord.js.map +1 -0
  39. package/dist/bot/confirm-telegram.js +290 -0
  40. package/dist/bot/confirm-telegram.js.map +1 -0
  41. package/dist/bot/discord.js +826 -0
  42. package/dist/bot/discord.js.map +1 -0
  43. package/dist/bot/format.js +210 -0
  44. package/dist/bot/format.js.map +1 -0
  45. package/dist/bot/session-manager.js +270 -0
  46. package/dist/bot/session-manager.js.map +1 -0
  47. package/dist/bot/telegram.js +678 -0
  48. package/dist/bot/telegram.js.map +1 -0
  49. package/dist/cli/agent-turn.js +45 -0
  50. package/dist/cli/agent-turn.js.map +1 -0
  51. package/dist/cli/args.js +236 -0
  52. package/dist/cli/args.js.map +1 -0
  53. package/dist/cli/bot.js +252 -0
  54. package/dist/cli/bot.js.map +1 -0
  55. package/dist/cli/build-repl-context.js +365 -0
  56. package/dist/cli/build-repl-context.js.map +1 -0
  57. package/dist/cli/command-registry.js +20 -0
  58. package/dist/cli/command-registry.js.map +1 -0
  59. package/dist/cli/commands/anton.js +271 -0
  60. package/dist/cli/commands/anton.js.map +1 -0
  61. package/dist/cli/commands/editing.js +328 -0
  62. package/dist/cli/commands/editing.js.map +1 -0
  63. package/dist/cli/commands/model.js +274 -0
  64. package/dist/cli/commands/model.js.map +1 -0
  65. package/dist/cli/commands/project.js +255 -0
  66. package/dist/cli/commands/project.js.map +1 -0
  67. package/dist/cli/commands/runtime.js +63 -0
  68. package/dist/cli/commands/runtime.js.map +1 -0
  69. package/dist/cli/commands/session.js +281 -0
  70. package/dist/cli/commands/session.js.map +1 -0
  71. package/dist/cli/commands/tools.js +126 -0
  72. package/dist/cli/commands/tools.js.map +1 -0
  73. package/dist/cli/commands/trifecta.js +221 -0
  74. package/dist/cli/commands/trifecta.js.map +1 -0
  75. package/dist/cli/commands/tui.js +17 -0
  76. package/dist/cli/commands/tui.js.map +1 -0
  77. package/dist/cli/init.js +222 -0
  78. package/dist/cli/init.js.map +1 -0
  79. package/dist/cli/input.js +360 -0
  80. package/dist/cli/input.js.map +1 -0
  81. package/dist/cli/oneshot.js +254 -0
  82. package/dist/cli/oneshot.js.map +1 -0
  83. package/dist/cli/repl-context.js +2 -0
  84. package/dist/cli/repl-context.js.map +1 -0
  85. package/dist/cli/runtime-cmds.js +811 -0
  86. package/dist/cli/runtime-cmds.js.map +1 -0
  87. package/dist/cli/service.js +145 -0
  88. package/dist/cli/service.js.map +1 -0
  89. package/dist/cli/session-state.js +130 -0
  90. package/dist/cli/session-state.js.map +1 -0
  91. package/dist/cli/setup.js +815 -0
  92. package/dist/cli/setup.js.map +1 -0
  93. package/dist/cli/shell.js +79 -0
  94. package/dist/cli/shell.js.map +1 -0
  95. package/dist/cli/status.js +392 -0
  96. package/dist/cli/status.js.map +1 -0
  97. package/dist/cli/watch.js +33 -0
  98. package/dist/cli/watch.js.map +1 -0
  99. package/dist/client.js +676 -0
  100. package/dist/client.js.map +1 -0
  101. package/dist/commands.js +194 -0
  102. package/dist/commands.js.map +1 -0
  103. package/dist/config.js +507 -0
  104. package/dist/config.js.map +1 -0
  105. package/dist/confirm/auto.js +13 -0
  106. package/dist/confirm/auto.js.map +1 -0
  107. package/dist/confirm/headless.js +41 -0
  108. package/dist/confirm/headless.js.map +1 -0
  109. package/dist/confirm/terminal.js +90 -0
  110. package/dist/confirm/terminal.js.map +1 -0
  111. package/dist/context.js +49 -0
  112. package/dist/context.js.map +1 -0
  113. package/dist/git.js +136 -0
  114. package/dist/git.js.map +1 -0
  115. package/dist/harnesses.js +171 -0
  116. package/dist/harnesses.js.map +1 -0
  117. package/dist/history.js +139 -0
  118. package/dist/history.js.map +1 -0
  119. package/dist/index.js +700 -0
  120. package/dist/index.js.map +1 -0
  121. package/dist/indexer.js +374 -0
  122. package/dist/indexer.js.map +1 -0
  123. package/dist/jsonrpc.js +76 -0
  124. package/dist/jsonrpc.js.map +1 -0
  125. package/dist/lens.js +525 -0
  126. package/dist/lens.js.map +1 -0
  127. package/dist/lsp.js +605 -0
  128. package/dist/lsp.js.map +1 -0
  129. package/dist/markdown.js +275 -0
  130. package/dist/markdown.js.map +1 -0
  131. package/dist/mcp.js +554 -0
  132. package/dist/mcp.js.map +1 -0
  133. package/dist/recovery.js +178 -0
  134. package/dist/recovery.js.map +1 -0
  135. package/dist/replay.js +132 -0
  136. package/dist/replay.js.map +1 -0
  137. package/dist/replay_cli.js +24 -0
  138. package/dist/replay_cli.js.map +1 -0
  139. package/dist/runtime/executor.js +418 -0
  140. package/dist/runtime/executor.js.map +1 -0
  141. package/dist/runtime/planner.js +197 -0
  142. package/dist/runtime/planner.js.map +1 -0
  143. package/dist/runtime/store.js +289 -0
  144. package/dist/runtime/store.js.map +1 -0
  145. package/dist/runtime/types.js +2 -0
  146. package/dist/runtime/types.js.map +1 -0
  147. package/dist/safety.js +446 -0
  148. package/dist/safety.js.map +1 -0
  149. package/dist/spinner.js +224 -0
  150. package/dist/spinner.js.map +1 -0
  151. package/dist/sys/context.js +124 -0
  152. package/dist/sys/context.js.map +1 -0
  153. package/dist/sys/snapshot.sh +97 -0
  154. package/dist/term.js +61 -0
  155. package/dist/term.js.map +1 -0
  156. package/dist/themes.js +135 -0
  157. package/dist/themes.js.map +1 -0
  158. package/dist/tools.js +1114 -0
  159. package/dist/tools.js.map +1 -0
  160. package/dist/tui/branch-picker.js +65 -0
  161. package/dist/tui/branch-picker.js.map +1 -0
  162. package/dist/tui/command-handler.js +108 -0
  163. package/dist/tui/command-handler.js.map +1 -0
  164. package/dist/tui/confirm.js +90 -0
  165. package/dist/tui/confirm.js.map +1 -0
  166. package/dist/tui/controller.js +463 -0
  167. package/dist/tui/controller.js.map +1 -0
  168. package/dist/tui/event-bridge.js +44 -0
  169. package/dist/tui/event-bridge.js.map +1 -0
  170. package/dist/tui/events.js +2 -0
  171. package/dist/tui/events.js.map +1 -0
  172. package/dist/tui/keymap.js +144 -0
  173. package/dist/tui/keymap.js.map +1 -0
  174. package/dist/tui/layout.js +11 -0
  175. package/dist/tui/layout.js.map +1 -0
  176. package/dist/tui/render.js +186 -0
  177. package/dist/tui/render.js.map +1 -0
  178. package/dist/tui/screen.js +48 -0
  179. package/dist/tui/screen.js.map +1 -0
  180. package/dist/tui/state.js +167 -0
  181. package/dist/tui/state.js.map +1 -0
  182. package/dist/tui/theme.js +70 -0
  183. package/dist/tui/theme.js.map +1 -0
  184. package/dist/tui/types.js +2 -0
  185. package/dist/tui/types.js.map +1 -0
  186. package/dist/types.js +2 -0
  187. package/dist/types.js.map +1 -0
  188. package/dist/upgrade.js +412 -0
  189. package/dist/upgrade.js.map +1 -0
  190. package/dist/utils.js +87 -0
  191. package/dist/utils.js.map +1 -0
  192. package/dist/vault.js +520 -0
  193. package/dist/vault.js.map +1 -0
  194. package/dist/vim.js +160 -0
  195. package/dist/vim.js.map +1 -0
  196. package/package.json +67 -0
  197. package/src/sys/snapshot.sh +97 -0
@@ -0,0 +1,678 @@
1
+ /**
2
+ * Idle Hands Telegram Bot — main entry point.
3
+ * grammy-based long-polling bot that wraps the agent core.
4
+ */
5
+ import { Bot, InputFile } from 'grammy';
6
+ import { SessionManager } from './session-manager.js';
7
+ import { markdownToTelegramHtml, splitMessage, escapeHtml, formatToolCallSummary } from './format.js';
8
+ import { handleStart, handleHelp, handleReset, handleCancel, handleStatus, handleDir, handleModel, handleCompact, handleApproval, handleMode, handleChanges, handleUndo, handleVault, handleAnton, } from './commands.js';
9
+ import { TelegramConfirmProvider } from './confirm-telegram.js';
10
+ // ---------------------------------------------------------------------------
11
+ // Streaming message helper
12
+ // ---------------------------------------------------------------------------
13
+ class StreamingMessage {
14
+ bot;
15
+ chatId;
16
+ editIntervalMs;
17
+ replyToId;
18
+ fileThresholdChars;
19
+ buffer = '';
20
+ toolLines = [];
21
+ lastToolLine = "";
22
+ lastToolRepeat = 0;
23
+ messageId = null;
24
+ editTimer = null;
25
+ typingTimer = null;
26
+ lastEditText = '';
27
+ finalized = false;
28
+ backoffMs = 0;
29
+ constructor(bot, chatId, editIntervalMs, replyToId, fileThresholdChars = 8192) {
30
+ this.bot = bot;
31
+ this.chatId = chatId;
32
+ this.editIntervalMs = editIntervalMs;
33
+ this.replyToId = replyToId;
34
+ this.fileThresholdChars = fileThresholdChars;
35
+ }
36
+ async init() {
37
+ // Show "typing..." indicator immediately; repeat every 4s (Telegram auto-expires at ~5s)
38
+ this.bot.api.sendChatAction(this.chatId, 'typing').catch(() => { });
39
+ this.typingTimer = setInterval(() => {
40
+ if (!this.finalized) {
41
+ this.bot.api.sendChatAction(this.chatId, 'typing').catch(() => { });
42
+ }
43
+ }, 4_000);
44
+ const msg = await this.bot.api.sendMessage(this.chatId, '⏳ Thinking...', {
45
+ reply_to_message_id: this.replyToId,
46
+ });
47
+ this.messageId = msg.message_id;
48
+ this.startEditLoop();
49
+ }
50
+ stopTyping() {
51
+ if (this.typingTimer) {
52
+ clearInterval(this.typingTimer);
53
+ this.typingTimer = null;
54
+ }
55
+ }
56
+ onToken(token) {
57
+ this.buffer += token;
58
+ }
59
+ onToolCall(call) {
60
+ const summary = formatToolCallSummary(call);
61
+ const line = `◆ ${summary}...`;
62
+ if (this.lastToolLine === line && this.toolLines.length > 0) {
63
+ this.lastToolRepeat += 1;
64
+ this.toolLines[this.toolLines.length - 1] = `${line} (x${this.lastToolRepeat + 1})`;
65
+ return;
66
+ }
67
+ this.lastToolLine = line;
68
+ this.lastToolRepeat = 0;
69
+ this.toolLines.push(line);
70
+ }
71
+ onToolResult(result) {
72
+ this.lastToolLine = "";
73
+ this.lastToolRepeat = 0;
74
+ if (this.toolLines.length > 0) {
75
+ const icon = result.success ? '✓' : '✗';
76
+ this.toolLines[this.toolLines.length - 1] = `${icon} ${result.name}: ${result.summary}`;
77
+ }
78
+ }
79
+ startEditLoop() {
80
+ this.editTimer = setInterval(() => this.flush(), this.editIntervalMs);
81
+ }
82
+ async flush() {
83
+ if (!this.messageId || this.finalized)
84
+ return;
85
+ if (this.backoffMs > 0) {
86
+ this.backoffMs = Math.max(0, this.backoffMs - this.editIntervalMs);
87
+ return; // skip this edit cycle while backing off
88
+ }
89
+ const text = this.render();
90
+ if (!text || text === this.lastEditText)
91
+ return;
92
+ this.lastEditText = text;
93
+ try {
94
+ await this.bot.api.editMessageText(this.chatId, this.messageId, text, {
95
+ parse_mode: 'HTML',
96
+ });
97
+ }
98
+ catch (e) {
99
+ const desc = e?.description ?? e?.message ?? '';
100
+ if (desc.includes('Too Many Requests') || desc.includes('429')) {
101
+ // Exponential backoff on rate limit
102
+ const retryAfter = (e?.parameters?.retry_after ?? 3) * 1000;
103
+ this.backoffMs = Math.min(retryAfter * 2, 30_000);
104
+ console.error(`[bot] rate limited, backing off ${this.backoffMs}ms`);
105
+ }
106
+ else if (!desc.includes('message is not modified') && !desc.includes('message to edit not found')) {
107
+ console.error(`[bot] edit error: ${desc}`);
108
+ }
109
+ }
110
+ }
111
+ render() {
112
+ let out = '';
113
+ if (this.toolLines.length) {
114
+ out += `<pre>${escapeHtml(this.toolLines.join('\n'))}</pre>\n\n`;
115
+ }
116
+ if (this.buffer) {
117
+ out += markdownToTelegramHtml(this.buffer);
118
+ }
119
+ if (!out.trim()) {
120
+ out = '⏳ Thinking...';
121
+ }
122
+ return out.slice(0, 4096);
123
+ }
124
+ /** Finalize: stop the edit loop and send the final response. */
125
+ async finalize(text) {
126
+ this.finalized = true;
127
+ this.stopTyping();
128
+ if (this.editTimer) {
129
+ clearInterval(this.editTimer);
130
+ this.editTimer = null;
131
+ }
132
+ const html = this.renderFinal(text);
133
+ // Large output fallback: send as .md file attachment
134
+ if (text.length > this.fileThresholdChars) {
135
+ // Edit placeholder to a summary
136
+ const summary = text.slice(0, 200).replace(/\n/g, ' ').trim();
137
+ const summaryHtml = `📄 Response is ${text.length.toLocaleString()} chars — sent as file.\n\n<i>${escapeHtml(summary)}…</i>`;
138
+ if (this.messageId) {
139
+ await this.bot.api.editMessageText(this.chatId, this.messageId, summaryHtml, {
140
+ parse_mode: 'HTML',
141
+ }).catch(() => { });
142
+ }
143
+ const fileContent = Buffer.from(text, 'utf-8');
144
+ await this.bot.api.sendDocument(this.chatId, new InputFile(fileContent, 'response.md'), {
145
+ caption: `Full response (${text.length.toLocaleString()} chars)`,
146
+ }).catch((e) => {
147
+ console.error(`[bot] sendDocument error: ${e?.message ?? e}`);
148
+ });
149
+ return;
150
+ }
151
+ const chunks = splitMessage(html, 4096);
152
+ // Edit the first message with the first chunk
153
+ if (this.messageId && chunks.length > 0) {
154
+ try {
155
+ await this.bot.api.editMessageText(this.chatId, this.messageId, chunks[0], {
156
+ parse_mode: 'HTML',
157
+ });
158
+ }
159
+ catch (e) {
160
+ // If edit fails (too old, etc.), send as new message
161
+ const desc = e?.description ?? '';
162
+ if (desc.includes('message to edit not found')) {
163
+ await this.bot.api.sendMessage(this.chatId, chunks[0], { parse_mode: 'HTML' }).catch(() => { });
164
+ }
165
+ }
166
+ }
167
+ // Send remaining chunks as new messages
168
+ for (let i = 1; i < chunks.length && i < 10; i++) {
169
+ try {
170
+ await this.bot.api.sendMessage(this.chatId, chunks[i], { parse_mode: 'HTML' });
171
+ }
172
+ catch (e) {
173
+ console.error(`[bot] send chunk ${i} error: ${e?.message ?? e}`);
174
+ break;
175
+ }
176
+ }
177
+ if (chunks.length > 10) {
178
+ await this.bot.api.sendMessage(this.chatId, '[truncated — response too long]').catch(() => { });
179
+ }
180
+ }
181
+ renderFinal(text) {
182
+ let out = '';
183
+ if (this.toolLines.length) {
184
+ out += `<pre>${escapeHtml(this.toolLines.join('\n'))}</pre>\n\n`;
185
+ }
186
+ out += markdownToTelegramHtml(text);
187
+ return out || '(empty response)';
188
+ }
189
+ /** Finalize with an error message. */
190
+ async finalizeError(errMsg) {
191
+ this.finalized = true;
192
+ this.stopTyping();
193
+ if (this.editTimer) {
194
+ clearInterval(this.editTimer);
195
+ this.editTimer = null;
196
+ }
197
+ let html = '';
198
+ if (this.toolLines.length) {
199
+ html += `<pre>${escapeHtml(this.toolLines.join('\n'))}</pre>\n\n`;
200
+ }
201
+ if (this.buffer.trim()) {
202
+ html += markdownToTelegramHtml(this.buffer) + '\n\n';
203
+ }
204
+ html += `❌ ${escapeHtml(errMsg)}`;
205
+ if (this.messageId) {
206
+ try {
207
+ await this.bot.api.editMessageText(this.chatId, this.messageId, html.slice(0, 4096), {
208
+ parse_mode: 'HTML',
209
+ });
210
+ return;
211
+ }
212
+ catch {
213
+ // fall through to send
214
+ }
215
+ }
216
+ await this.bot.api.sendMessage(this.chatId, html.slice(0, 4096), { parse_mode: 'HTML' }).catch(() => { });
217
+ }
218
+ }
219
+ // ---------------------------------------------------------------------------
220
+ // Bot startup
221
+ // ---------------------------------------------------------------------------
222
+ export async function startTelegramBot(config, botConfig) {
223
+ // Validate config
224
+ const token = process.env.IDLEHANDS_TG_TOKEN || botConfig.token;
225
+ if (!token) {
226
+ console.error('[bot] IDLEHANDS_TG_TOKEN not set and bot.telegram.token is empty.');
227
+ process.exit(1);
228
+ }
229
+ const allowedUsersEnv = process.env.IDLEHANDS_TG_ALLOWED_USERS;
230
+ const rawUsers = allowedUsersEnv
231
+ ? allowedUsersEnv.split(',').map(Number).filter(Boolean)
232
+ : Array.isArray(botConfig.allowed_users)
233
+ ? botConfig.allowed_users
234
+ : botConfig.allowed_users != null
235
+ ? [Number(botConfig.allowed_users)].filter(Boolean)
236
+ : [];
237
+ const allowedUsers = new Set(rawUsers);
238
+ if (allowedUsers.size === 0) {
239
+ console.error('[bot] bot.telegram.allowed_users is empty — refusing to start an unauthenticated bot.');
240
+ process.exit(1);
241
+ }
242
+ const bot = new Bot(token);
243
+ const sessions = new SessionManager(config, botConfig, (chatId) => new TelegramConfirmProvider(bot, chatId, botConfig.confirm_timeout_sec ?? 300));
244
+ const editIntervalMs = botConfig.edit_interval_ms ?? 1500;
245
+ // Override default_dir from env
246
+ if (process.env.IDLEHANDS_TG_DIR) {
247
+ botConfig.default_dir = process.env.IDLEHANDS_TG_DIR;
248
+ }
249
+ const cmdCtx = (ctx) => ({
250
+ ctx,
251
+ sessions,
252
+ botConfig: {
253
+ model: config.model,
254
+ endpoint: config.endpoint,
255
+ defaultDir: botConfig.default_dir || config.dir,
256
+ },
257
+ });
258
+ // ---------------------------------------------------------------------------
259
+ // Auth middleware
260
+ // ---------------------------------------------------------------------------
261
+ bot.use(async (ctx, next) => {
262
+ const userId = ctx.from?.id;
263
+ if (!userId || !allowedUsers.has(userId)) {
264
+ // Silent ignore — don't reveal the bot exists
265
+ if (config.verbose) {
266
+ console.error(`[bot] ignored message from unauthorized user ${userId}`);
267
+ }
268
+ return;
269
+ }
270
+ // Group chat guard
271
+ if (!(botConfig.allow_groups ?? false)) {
272
+ const chatType = ctx.chat?.type;
273
+ if (chatType && chatType !== 'private') {
274
+ return; // Silent ignore
275
+ }
276
+ }
277
+ await next();
278
+ });
279
+ // ---------------------------------------------------------------------------
280
+ // Command handlers
281
+ // ---------------------------------------------------------------------------
282
+ bot.command('start', (ctx) => handleStart(cmdCtx(ctx)));
283
+ bot.command('help', (ctx) => handleHelp(cmdCtx(ctx)));
284
+ bot.command('reset', (ctx) => handleReset(cmdCtx(ctx)));
285
+ bot.command('cancel', (ctx) => handleCancel(cmdCtx(ctx)));
286
+ bot.command('status', (ctx) => handleStatus(cmdCtx(ctx)));
287
+ bot.command('dir', (ctx) => handleDir(cmdCtx(ctx)));
288
+ bot.command('model', (ctx) => handleModel(cmdCtx(ctx)));
289
+ bot.command('compact', (ctx) => handleCompact(cmdCtx(ctx)));
290
+ bot.command('approval', (ctx) => handleApproval(cmdCtx(ctx)));
291
+ bot.command('mode', (ctx) => handleMode(cmdCtx(ctx)));
292
+ bot.command('changes', (ctx) => handleChanges(cmdCtx(ctx)));
293
+ bot.command('undo', (ctx) => handleUndo(cmdCtx(ctx)));
294
+ bot.command('vault', (ctx) => handleVault(cmdCtx(ctx)));
295
+ bot.command('anton', (ctx) => handleAnton(cmdCtx(ctx)));
296
+ bot.command('hosts', async (ctx) => {
297
+ try {
298
+ const { loadRuntimes, redactConfig } = await import('../runtime/store.js');
299
+ const config = await loadRuntimes();
300
+ const redacted = redactConfig(config);
301
+ if (!redacted.hosts.length) {
302
+ await ctx.reply('No hosts configured. Use `idlehands hosts add` in CLI.');
303
+ return;
304
+ }
305
+ const lines = redacted.hosts.map((h) => `${h.enabled ? '🟢' : '🔴'} *${h.display_name}* (\`${h.id}\`)\n Transport: ${h.transport}`);
306
+ await ctx.reply(lines.join('\n\n'), { parse_mode: 'Markdown' });
307
+ }
308
+ catch (e) {
309
+ await ctx.reply(`❌ Failed to load hosts: ${e?.message ?? String(e)}`);
310
+ }
311
+ });
312
+ bot.command('backends', async (ctx) => {
313
+ try {
314
+ const { loadRuntimes, redactConfig } = await import('../runtime/store.js');
315
+ const config = await loadRuntimes();
316
+ const redacted = redactConfig(config);
317
+ if (!redacted.backends.length) {
318
+ await ctx.reply('No backends configured. Use `idlehands backends add` in CLI.');
319
+ return;
320
+ }
321
+ const lines = redacted.backends.map((b) => `${b.enabled ? '🟢' : '🔴'} *${b.display_name}* (\`${b.id}\`)\n Type: ${b.type}`);
322
+ await ctx.reply(lines.join('\n\n'), { parse_mode: 'Markdown' });
323
+ }
324
+ catch (e) {
325
+ await ctx.reply(`❌ Failed to load backends: ${e?.message ?? String(e)}`);
326
+ }
327
+ });
328
+ bot.command('rtmodels', async (ctx) => {
329
+ try {
330
+ const { loadRuntimes } = await import('../runtime/store.js');
331
+ const config = await loadRuntimes();
332
+ if (!config.models.length) {
333
+ await ctx.reply('No runtime models configured.');
334
+ return;
335
+ }
336
+ const lines = config.models.map((m) => `${m.enabled ? '🟢' : '🔴'} *${m.display_name}* (\`${m.id}\`)\n Source: \`${m.source}\``);
337
+ await ctx.reply(lines.join('\n\n'), { parse_mode: 'Markdown' });
338
+ }
339
+ catch (e) {
340
+ await ctx.reply(`❌ Failed to load runtime models: ${e?.message ?? String(e)}`);
341
+ }
342
+ });
343
+ bot.command('rtstatus', async (ctx) => {
344
+ try {
345
+ const { loadActiveRuntime } = await import('../runtime/executor.js');
346
+ const active = await loadActiveRuntime();
347
+ if (!active) {
348
+ await ctx.reply('No active runtime.');
349
+ return;
350
+ }
351
+ const lines = [
352
+ '*Active Runtime*',
353
+ `Model: \`${active.modelId}\``,
354
+ `Backend: \`${active.backendId ?? 'none'}\``,
355
+ `Hosts: ${active.hostIds.map((id) => `\`${id}\``).join(', ') || 'none'}`,
356
+ `Healthy: ${active.healthy ? '✅ yes' : '❌ no'}`,
357
+ `Endpoint: \`${active.endpoint ?? 'unknown'}\``,
358
+ `Started: \`${active.startedAt}\``,
359
+ ];
360
+ await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown' });
361
+ }
362
+ catch (e) {
363
+ await ctx.reply(`❌ Failed to read runtime status: ${e?.message ?? String(e)}`);
364
+ }
365
+ });
366
+ bot.command('switch', async (ctx) => {
367
+ try {
368
+ const modelId = ctx.match?.trim();
369
+ if (!modelId) {
370
+ await ctx.reply('Usage: /switch <model-id>');
371
+ return;
372
+ }
373
+ const { plan } = await import('../runtime/planner.js');
374
+ const { execute, loadActiveRuntime } = await import('../runtime/executor.js');
375
+ const { loadRuntimes } = await import('../runtime/store.js');
376
+ const rtConfig = await loadRuntimes();
377
+ const active = await loadActiveRuntime();
378
+ const result = plan({ modelId, mode: 'live' }, rtConfig, active);
379
+ if (!result.ok) {
380
+ await ctx.reply(`❌ Plan failed: ${result.reason}`);
381
+ return;
382
+ }
383
+ if (result.reuse) {
384
+ await ctx.reply('✅ Runtime already active and healthy.');
385
+ return;
386
+ }
387
+ const statusMsg = await ctx.reply(`⏳ Switching to *${result.model.display_name}*...`, { parse_mode: 'Markdown' });
388
+ const execResult = await execute(result, {
389
+ onStep: async (step, status) => {
390
+ if (status === 'done') {
391
+ await ctx.api.editMessageText(ctx.chat.id, statusMsg.message_id, `⏳ ${step.description}... ✓`).catch(() => { });
392
+ }
393
+ },
394
+ confirm: async (prompt) => {
395
+ await ctx.reply(`⚠️ ${prompt}\nAuto-approving for bot context.`);
396
+ return true;
397
+ },
398
+ });
399
+ if (execResult.ok) {
400
+ await ctx.reply(`✅ Switched to *${result.model.display_name}*`, { parse_mode: 'Markdown' });
401
+ }
402
+ else {
403
+ await ctx.reply(`❌ Switch failed: ${execResult.error || 'unknown error'}`);
404
+ }
405
+ }
406
+ catch (e) {
407
+ await ctx.reply(`❌ Switch failed: ${e?.message ?? String(e)}`);
408
+ }
409
+ });
410
+ // ---------------------------------------------------------------------------
411
+ // Callback query handler (inline button presses for confirmations)
412
+ // ---------------------------------------------------------------------------
413
+ bot.on('callback_query:data', async (ctx) => {
414
+ const data = ctx.callbackQuery.data;
415
+ const chatId = ctx.chat?.id;
416
+ if (!chatId)
417
+ return;
418
+ const managed = sessions.get(chatId);
419
+ if (!managed?.confirmProvider) {
420
+ await ctx.answerCallbackQuery({ text: 'No active session.' }).catch(() => { });
421
+ return;
422
+ }
423
+ const provider = managed.confirmProvider;
424
+ const handled = await provider.handleCallback(data);
425
+ await ctx.answerCallbackQuery(handled ? undefined : { text: 'Unknown action.' }).catch(() => { });
426
+ });
427
+ // ---------------------------------------------------------------------------
428
+ // Message handler (core flow)
429
+ // ---------------------------------------------------------------------------
430
+ bot.on('message:text', async (ctx) => {
431
+ const chatId = ctx.chat.id;
432
+ const userId = ctx.from.id;
433
+ const text = ctx.message.text;
434
+ // Skip commands (already handled above)
435
+ if (text.startsWith('/'))
436
+ return;
437
+ const msgPreview = text.length > 50 ? text.slice(0, 47) + '...' : text;
438
+ console.error(`[bot] ${chatId} ${ctx.from.username ?? userId}: "${msgPreview}"`);
439
+ // Get or create session
440
+ const managed = await sessions.getOrCreate(chatId, userId);
441
+ if (!managed) {
442
+ await ctx.reply('⚠️ Too many active sessions. Try again later or /reset an existing one.');
443
+ return;
444
+ }
445
+ // Concurrency guard
446
+ if (managed.inFlight) {
447
+ if (managed.pendingQueue.length >= sessions.maxQueue) {
448
+ await ctx.reply(`⏳ Queued (${managed.pendingQueue.length} pending). Use /cancel to abort the current task.`);
449
+ return;
450
+ }
451
+ managed.pendingQueue.push(text);
452
+ await ctx.reply(`⏳ Queued (#${managed.pendingQueue.length}). Still working on the previous request.`);
453
+ return;
454
+ }
455
+ const fileThreshold = botConfig.file_threshold_chars ?? 8192;
456
+ await processMessage(bot, sessions, managed, text, editIntervalMs, fileThreshold, ctx.message.message_id);
457
+ });
458
+ // ---------------------------------------------------------------------------
459
+ // Session cleanup on timeout
460
+ // ---------------------------------------------------------------------------
461
+ const origCleanup = sessions.cleanupExpired.bind(sessions);
462
+ const wrappedCleanup = () => {
463
+ const expired = origCleanup();
464
+ for (const chatId of expired) {
465
+ console.error(`[bot] session ${chatId} expired`);
466
+ bot.api.sendMessage(chatId, '⏱ Session expired due to inactivity. Send a new message to start fresh.').catch(() => { });
467
+ }
468
+ };
469
+ // Override the internal cleanup to also notify users
470
+ // (The SessionManager calls cleanupExpired internally on interval;
471
+ // we handle notification here on the bot level.)
472
+ const cleanupInterval = setInterval(wrappedCleanup, 60_000);
473
+ // ---------------------------------------------------------------------------
474
+ // Graceful shutdown
475
+ // ---------------------------------------------------------------------------
476
+ const shutdown = () => {
477
+ console.error('[bot] Shutting down...');
478
+ clearInterval(cleanupInterval);
479
+ sessions.stop();
480
+ bot.stop();
481
+ process.exit(0);
482
+ };
483
+ process.on('SIGINT', shutdown);
484
+ process.on('SIGTERM', shutdown);
485
+ // ---------------------------------------------------------------------------
486
+ // Register commands with Telegram
487
+ // ---------------------------------------------------------------------------
488
+ await bot.api.setMyCommands([
489
+ { command: 'start', description: 'Welcome + config summary' },
490
+ { command: 'help', description: 'List commands' },
491
+ { command: 'reset', description: 'Clear session' },
492
+ { command: 'cancel', description: 'Abort current generation' },
493
+ { command: 'status', description: 'Session stats' },
494
+ { command: 'dir', description: 'Get/set working directory' },
495
+ { command: 'model', description: 'Show current model' },
496
+ { command: 'compact', description: 'Compact context' },
497
+ { command: 'approval', description: 'Get/set approval mode' },
498
+ { command: 'mode', description: 'Get/set mode (code/sys)' },
499
+ { command: 'changes', description: 'Files modified this session' },
500
+ { command: 'undo', description: 'Undo last edit' },
501
+ { command: 'vault', description: 'Search vault entries' },
502
+ { command: 'hosts', description: 'List runtime hosts' },
503
+ { command: 'backends', description: 'List runtime backends' },
504
+ { command: 'rtmodels', description: 'List runtime models' },
505
+ { command: 'rtstatus', description: 'Show active runtime status' },
506
+ { command: 'switch', description: 'Switch runtime model' },
507
+ { command: 'anton', description: 'Autonomous task runner' },
508
+ ]).catch((e) => console.error(`[bot] setMyCommands failed: ${e?.message}`));
509
+ // ---------------------------------------------------------------------------
510
+ // Start polling
511
+ // ---------------------------------------------------------------------------
512
+ // ---------------------------------------------------------------------------
513
+ // BotFather hardening check
514
+ // ---------------------------------------------------------------------------
515
+ try {
516
+ const botInfo = await bot.api.getMe();
517
+ if (botInfo.can_join_groups) {
518
+ console.error('[bot] ⚠️ WARNING: Bot has "Allow Groups" enabled in BotFather.');
519
+ console.error('[bot] Groups are blocked in code, but disable at the source:');
520
+ console.error('[bot] → Open @BotFather → /mybots → select bot → Bot Settings → Allow Groups → Turn OFF');
521
+ }
522
+ if (botInfo.can_read_all_group_messages) {
523
+ console.error('[bot] ⚠️ WARNING: Bot has "Group Privacy" disabled (can read all messages).');
524
+ console.error('[bot] → Open @BotFather → /mybots → select bot → Bot Settings → Group Privacy → Turn ON');
525
+ }
526
+ }
527
+ catch (e) {
528
+ console.error(`[bot] getMe() failed: ${e?.message ?? e}`);
529
+ }
530
+ // ---------------------------------------------------------------------------
531
+ // Start polling
532
+ // ---------------------------------------------------------------------------
533
+ sessions.start();
534
+ // ---------------------------------------------------------------------------
535
+ // Global error handler — catches unhandled errors in middleware/handlers
536
+ // ---------------------------------------------------------------------------
537
+ bot.catch(async (err) => {
538
+ const desc = err?.error?.message ?? err?.message ?? String(err);
539
+ console.error(`[bot] unhandled error: ${desc}`);
540
+ const chatId = err?.ctx?.chat?.id;
541
+ if (!chatId)
542
+ return;
543
+ const userMsg = desc.length > 300 ? desc.slice(0, 297) + '...' : desc;
544
+ await bot.api.sendMessage(chatId, `⚠️ Bot error: ${userMsg}`).catch(() => { });
545
+ });
546
+ // ---------------------------------------------------------------------------
547
+ // Start polling
548
+ // ---------------------------------------------------------------------------
549
+ console.error(`[bot] Telegram bot started (polling)`);
550
+ console.error(`[bot] Model: ${config.model || 'auto'} | Endpoint: ${config.endpoint}`);
551
+ console.error(`[bot] Allowed users: [${[...allowedUsers].join(', ')}]`);
552
+ console.error(`[bot] Default dir: ${botConfig.default_dir || config.dir || '~'}`);
553
+ bot.start({
554
+ onStart: () => console.error('[bot] Polling active'),
555
+ });
556
+ }
557
+ async function probeModelEndpoint(endpoint) {
558
+ const base = endpoint.replace(/\/$/, '');
559
+ const healthUrl = base.replace(/\/v1$/, '') + '/health';
560
+ const modelsUrl = base.replace(/\/$/, '') + '/models';
561
+ try {
562
+ const h = await fetch(healthUrl, { method: 'GET' });
563
+ if (!h.ok)
564
+ return false;
565
+ const m = await fetch(modelsUrl, { method: 'GET' });
566
+ return m.ok;
567
+ }
568
+ catch {
569
+ return false;
570
+ }
571
+ }
572
+ async function waitForModelEndpoint(endpoint, totalMs = 60_000, stepMs = 2_500) {
573
+ const started = Date.now();
574
+ while (Date.now() - started < totalMs) {
575
+ if (await probeModelEndpoint(endpoint))
576
+ return true;
577
+ await new Promise((r) => setTimeout(r, stepMs));
578
+ }
579
+ return false;
580
+ }
581
+ // ---------------------------------------------------------------------------
582
+ // Core: process a user message through the agent
583
+ // ---------------------------------------------------------------------------
584
+ async function processMessage(bot, sessions, managed, text, editIntervalMs, fileThresholdChars, replyToId) {
585
+ const turn = sessions.beginTurn(managed.chatId);
586
+ if (!turn)
587
+ return;
588
+ const turnId = turn.turnId;
589
+ const streaming = new StreamingMessage(bot, managed.chatId, editIntervalMs, replyToId, fileThresholdChars);
590
+ await streaming.init();
591
+ const hooks = {
592
+ onToken: (t) => {
593
+ if (!sessions.isTurnActive(managed.chatId, turnId))
594
+ return;
595
+ sessions.markProgress(managed.chatId, turnId);
596
+ streaming.onToken(t);
597
+ },
598
+ onToolCall: (call) => {
599
+ if (!sessions.isTurnActive(managed.chatId, turnId))
600
+ return;
601
+ sessions.markProgress(managed.chatId, turnId);
602
+ streaming.onToolCall(call);
603
+ },
604
+ onToolResult: (result) => {
605
+ if (!sessions.isTurnActive(managed.chatId, turnId))
606
+ return;
607
+ sessions.markProgress(managed.chatId, turnId);
608
+ streaming.onToolResult(result);
609
+ },
610
+ };
611
+ const watchdogMs = 120_000;
612
+ const watchdog = setInterval(() => {
613
+ const current = sessions.get(managed.chatId);
614
+ if (!current || current.activeTurnId !== turnId || !current.inFlight)
615
+ return;
616
+ if (Date.now() - current.lastProgressAt > watchdogMs) {
617
+ console.error(`[bot] ${managed.chatId} watchdog timeout on turn ${turnId}`);
618
+ sessions.cancelActive(managed.chatId);
619
+ }
620
+ }, 5_000);
621
+ const startTime = Date.now();
622
+ try {
623
+ const result = await managed.session.ask(text, { ...hooks, signal: turn.controller.signal });
624
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
625
+ console.error(`[bot] ${managed.chatId} ask() completed: ${result.turns} turns, ${result.toolCalls} tool calls, ${elapsed}s`);
626
+ if (sessions.isTurnActive(managed.chatId, turnId))
627
+ await streaming.finalize(result.text);
628
+ }
629
+ catch (e) {
630
+ const msg = e?.message ?? String(e);
631
+ console.error(`[bot] ${managed.chatId} ask() error: ${msg}`);
632
+ if (msg.includes('aborted') || msg.includes('AbortError')) {
633
+ if (sessions.isTurnActive(managed.chatId, turnId))
634
+ await streaming.finalizeError('Aborted.');
635
+ }
636
+ else if (msg.includes('ECONNREFUSED') || msg.includes('Connection timeout') || msg.includes('503') || msg.includes('model loading')) {
637
+ const endpoint = managed.session?.endpoint || '';
638
+ const recovered = endpoint ? await waitForModelEndpoint(endpoint, 60_000, 2_500) : false;
639
+ if (recovered) {
640
+ try {
641
+ const retry = await managed.session.ask(text, { ...hooks, signal: turn.controller.signal });
642
+ if (sessions.isTurnActive(managed.chatId, turnId))
643
+ await streaming.finalize(retry.text);
644
+ return;
645
+ }
646
+ catch (retryErr) {
647
+ const retryMsg = retryErr?.message ?? String(retryErr);
648
+ if (sessions.isTurnActive(managed.chatId, turnId))
649
+ await streaming.finalizeError(`Model server came back but retry failed: ${retryMsg.length > 140 ? retryMsg.slice(0, 137) + '...' : retryMsg}`);
650
+ return;
651
+ }
652
+ }
653
+ if (sessions.isTurnActive(managed.chatId, turnId))
654
+ await streaming.finalizeError('Model server is starting up or restarting. I waited up to 60s but it is still unavailable — please retry shortly.');
655
+ }
656
+ else {
657
+ if (sessions.isTurnActive(managed.chatId, turnId))
658
+ await streaming.finalizeError(msg.length > 200 ? msg.slice(0, 197) + '...' : msg);
659
+ }
660
+ }
661
+ finally {
662
+ clearInterval(watchdog);
663
+ const current = sessions.finishTurn(managed.chatId, turnId);
664
+ if (!current)
665
+ return;
666
+ // Process queued messages only if this session still exists and is idle.
667
+ const next = sessions.dequeueNext(managed.chatId);
668
+ if (next && current.state === 'idle' && !current.inFlight) {
669
+ setTimeout(() => {
670
+ const fresh = sessions.get(managed.chatId);
671
+ if (!fresh)
672
+ return;
673
+ void processMessage(bot, sessions, fresh, next, editIntervalMs, fileThresholdChars);
674
+ }, 500);
675
+ }
676
+ }
677
+ }
678
+ //# sourceMappingURL=telegram.js.map