@visorcraft/idlehands 1.3.4 → 1.3.6

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 (65) hide show
  1. package/dist/agent/formatting.js +1 -5
  2. package/dist/agent/formatting.js.map +1 -1
  3. package/dist/agent.js +114 -26
  4. package/dist/agent.js.map +1 -1
  5. package/dist/anton/reporter.js +2 -20
  6. package/dist/anton/reporter.js.map +1 -1
  7. package/dist/bot/command-format.js +56 -0
  8. package/dist/bot/command-format.js.map +1 -0
  9. package/dist/bot/command-logic.js +651 -0
  10. package/dist/bot/command-logic.js.map +1 -0
  11. package/dist/bot/commands.js +100 -552
  12. package/dist/bot/commands.js.map +1 -1
  13. package/dist/bot/discord-commands.js +431 -0
  14. package/dist/bot/discord-commands.js.map +1 -0
  15. package/dist/bot/discord-routing.js +1 -8
  16. package/dist/bot/discord-routing.js.map +1 -1
  17. package/dist/bot/discord.js +16 -870
  18. package/dist/bot/discord.js.map +1 -1
  19. package/dist/bot/session-manager.js +60 -44
  20. package/dist/bot/session-manager.js.map +1 -1
  21. package/dist/bot/telegram-commands.js +201 -0
  22. package/dist/bot/telegram-commands.js.map +1 -0
  23. package/dist/bot/telegram.js +10 -309
  24. package/dist/bot/telegram.js.map +1 -1
  25. package/dist/bot/turn-lifecycle.js +66 -0
  26. package/dist/bot/turn-lifecycle.js.map +1 -0
  27. package/dist/cli/commands/project.js +52 -0
  28. package/dist/cli/commands/project.js.map +1 -1
  29. package/dist/context.js +1 -3
  30. package/dist/context.js.map +1 -1
  31. package/dist/progress/ir.js +0 -3
  32. package/dist/progress/ir.js.map +1 -1
  33. package/dist/progress/tool-summary.js +1 -4
  34. package/dist/progress/tool-summary.js.map +1 -1
  35. package/dist/progress/turn-progress.js +1 -5
  36. package/dist/progress/turn-progress.js.map +1 -1
  37. package/dist/runtime/executor.js +1 -3
  38. package/dist/runtime/executor.js.map +1 -1
  39. package/dist/runtime/health.js +2 -1
  40. package/dist/runtime/health.js.map +1 -1
  41. package/dist/shared/async.js +5 -0
  42. package/dist/shared/async.js.map +1 -0
  43. package/dist/shared/config-utils.js +8 -0
  44. package/dist/shared/config-utils.js.map +1 -0
  45. package/dist/shared/format.js +19 -0
  46. package/dist/shared/format.js.map +1 -0
  47. package/dist/shared/math.js +5 -0
  48. package/dist/shared/math.js.map +1 -0
  49. package/dist/shared/strings.js +8 -0
  50. package/dist/shared/strings.js.map +1 -0
  51. package/dist/tools/patch.js +82 -0
  52. package/dist/tools/patch.js.map +1 -0
  53. package/dist/tools/path-safety.js +89 -0
  54. package/dist/tools/path-safety.js.map +1 -0
  55. package/dist/tools/undo.js +141 -0
  56. package/dist/tools/undo.js.map +1 -0
  57. package/dist/tools.js +11 -289
  58. package/dist/tools.js.map +1 -1
  59. package/dist/tui/event-bridge.js +1 -3
  60. package/dist/tui/event-bridge.js.map +1 -1
  61. package/dist/tui/render.js +1 -5
  62. package/dist/tui/render.js.map +1 -1
  63. package/dist/vault.js +1 -5
  64. package/dist/vault.js.map +1 -1
  65. package/package.json +1 -1
@@ -1,70 +1,38 @@
1
1
  /**
2
2
  * Telegram bot command handlers.
3
3
  * Each handler receives the grammy Context and the SessionManager.
4
+ *
5
+ * Business logic lives in command-logic.ts; this file is a thin wrapper
6
+ * that maps grammy Context → shared logic → HTML reply.
4
7
  */
5
- import fs from 'node:fs/promises';
6
- import path from 'node:path';
7
- import { runAnton } from '../anton/controller.js';
8
- import { parseTaskFile } from '../anton/parser.js';
9
- import { formatRunSummary, formatProgressBar, formatTaskStart, formatTaskEnd, formatTaskSkip, formatToolLoopEvent, formatCompactionEvent, formatVerificationDetail, } from '../anton/reporter.js';
10
8
  import { firstToken } from '../cli/command-utils.js';
11
- import { WATCHDOG_RECOMMENDED_TUNING_TEXT, resolveWatchdogSettings, shouldRecommendWatchdogTuning, } from '../watchdog.js';
9
+ import { formatHtml } from './command-format.js';
10
+ import { versionCommand, startCommand, helpCommand, modelCommand, compactCommand, statusCommand, watchdogCommand, dirShowCommand, approvalShowCommand, approvalSetCommand, modeShowCommand, modeSetCommand, subagentsShowCommand, subagentsSetCommand, changesCommand, undoCommand, vaultCommand, agentCommand, agentsCommand, escalateShowCommand, escalateSetCommand, deescalateCommand, gitStatusCommand, antonCommand, } from './command-logic.js';
12
11
  import { escapeHtml } from './format.js';
12
+ /** Send formatted CmdResult as Telegram HTML. */
13
+ async function reply(ctx, result) {
14
+ const text = formatHtml(result);
15
+ if (!text)
16
+ return;
17
+ await ctx.reply(text, { parse_mode: 'HTML' });
18
+ }
13
19
  export async function handleVersion({ ctx, botConfig }) {
14
- const lines = [
15
- `<b>Idle Hands</b> v${botConfig.version || 'unknown'}`,
16
- '',
17
- `<b>Model:</b> <code>${escapeHtml(botConfig.model || 'auto')}</code>`,
18
- `<b>Endpoint:</b> <code>${escapeHtml(botConfig.endpoint || '?')}</code>`,
19
- ];
20
- await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
20
+ await reply(ctx, versionCommand({
21
+ version: botConfig.version,
22
+ model: botConfig.model,
23
+ endpoint: botConfig.endpoint,
24
+ }));
21
25
  }
22
26
  export async function handleStart({ ctx, botConfig }) {
23
- const lines = [
24
- '<b>🔧 Idle Hands</b> — Local-first coding agent',
25
- '',
26
- `<b>Model:</b> <code>${escapeHtml(botConfig.model || 'auto')}</code>`,
27
- `<b>Endpoint:</b> <code>${escapeHtml(botConfig.endpoint || '?')}</code>`,
28
- `<b>Default dir:</b> <code>${escapeHtml(botConfig.defaultDir || '~')}</code>`,
29
- '',
30
- 'Send me a coding task, or use /help for commands.',
31
- ];
32
- await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
27
+ await reply(ctx, startCommand({
28
+ version: botConfig.version,
29
+ model: botConfig.model,
30
+ endpoint: botConfig.endpoint,
31
+ defaultDir: botConfig.defaultDir,
32
+ }));
33
33
  }
34
34
  export async function handleHelp({ ctx }) {
35
- const lines = [
36
- '<b>Commands:</b>',
37
- '',
38
- '/start — Welcome + config summary',
39
- '/help — This message',
40
- '/new — Start a new session',
41
- '/cancel — Abort current generation',
42
- '/status — Session stats',
43
- '/watchdog [status] — Show active watchdog settings',
44
- '/agent — Show current agent info',
45
- '/agents — List all configured agents',
46
- '/escalate [model] — Use larger model for next message',
47
- '/deescalate — Return to base model',
48
- '/dir [path] — Get/set working directory',
49
- '/pin — Pin current working directory',
50
- '/model — Show current model',
51
- '/approval [mode] — Get/set approval mode',
52
- '/mode [code|sys] — Get/set mode',
53
- '/compact — Trigger context compaction',
54
- '/changes — Show files modified this session',
55
- '/undo — Undo last edit',
56
- '/subagents [on|off] — Toggle sub-agent delegation',
57
- '/vault [query] — Search vault entries',
58
- '/anton &lt;file&gt; — Start autonomous task runner',
59
- '/anton status — Show task runner progress',
60
- '/anton stop — Stop task runner',
61
- '/anton last — Show last run results',
62
- '/git_status — Show git status for working directory',
63
- '/restart_bot — Restart the bot service',
64
- '',
65
- 'Or just send any text as a coding task.',
66
- ];
67
- await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
35
+ await reply(ctx, helpCommand('telegram'));
68
36
  }
69
37
  export async function handleNew({ ctx, sessions }) {
70
38
  const chatId = ctx.chat?.id;
@@ -89,24 +57,7 @@ export async function handleStatus({ ctx, sessions }) {
89
57
  await ctx.reply('No active session. Send a message to start one.');
90
58
  return;
91
59
  }
92
- const s = managed.session;
93
- const contextPct = s.contextWindow > 0
94
- ? Math.min(100, (s.currentContextTokens / s.contextWindow) * 100).toFixed(1)
95
- : '?';
96
- const lines = [
97
- '<b>Session Status</b>',
98
- '',
99
- `<b>Model:</b> <code>${escapeHtml(s.model)}</code>`,
100
- `<b>Harness:</b> <code>${escapeHtml(s.harness)}</code>`,
101
- `<b>Dir:</b> <code>${escapeHtml(managed.workingDir)}</code>`,
102
- `<b>Dir pinned:</b> ${managed.dirPinned ? 'yes' : 'no'}`,
103
- `<b>Context:</b> ~${s.currentContextTokens.toLocaleString()} / ${s.contextWindow.toLocaleString()} (${contextPct}%)`,
104
- `<b>Tokens:</b> prompt=${s.usage.prompt.toLocaleString()}, completion=${s.usage.completion.toLocaleString()}`,
105
- `<b>In-flight:</b> ${managed.inFlight ? 'yes' : 'no'}`,
106
- `<b>State:</b> ${managed.state}`,
107
- `<b>Queue:</b> ${managed.pendingQueue.length} pending`,
108
- ];
109
- await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
60
+ await reply(ctx, statusCommand(managed));
110
61
  }
111
62
  export async function handleWatchdog({ ctx, sessions, botConfig }) {
112
63
  const chatId = ctx.chat?.id;
@@ -122,34 +73,7 @@ export async function handleWatchdog({ ctx, sessions, botConfig }) {
122
73
  return;
123
74
  }
124
75
  const managed = sessions.get(chatId);
125
- const cfg = botConfig.watchdog ?? resolveWatchdogSettings();
126
- const lines = [
127
- '<b>Watchdog Status</b>',
128
- '',
129
- `<b>Timeout:</b> ${cfg.timeoutMs.toLocaleString()} ms (${Math.round(cfg.timeoutMs / 1000)}s)`,
130
- `<b>Max compactions:</b> ${cfg.maxCompactions}`,
131
- `<b>Grace windows:</b> ${cfg.idleGraceTimeouts}`,
132
- `<b>Debug abort reason:</b> ${cfg.debugAbortReason ? 'on' : 'off'}`,
133
- ];
134
- if (shouldRecommendWatchdogTuning(cfg)) {
135
- lines.push('');
136
- lines.push(`<b>Recommended tuning:</b> ${escapeHtml(WATCHDOG_RECOMMENDED_TUNING_TEXT)}`);
137
- }
138
- if (managed) {
139
- const idleSec = managed.lastProgressAt > 0
140
- ? ((Date.now() - managed.lastProgressAt) / 1000).toFixed(1)
141
- : 'n/a';
142
- lines.push('');
143
- lines.push(`<b>In-flight:</b> ${managed.inFlight ? 'yes' : 'no'}`);
144
- lines.push(`<b>State:</b> ${escapeHtml(managed.state)}`);
145
- lines.push(`<b>Compaction attempts (turn):</b> ${managed.watchdogCompactAttempts}`);
146
- lines.push(`<b>Idle since progress:</b> ${escapeHtml(idleSec)}s`);
147
- }
148
- else {
149
- lines.push('');
150
- lines.push('No active session yet. Send a message to start one.');
151
- }
152
- await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
76
+ await reply(ctx, watchdogCommand(managed, botConfig.watchdog));
153
77
  }
154
78
  export async function handleDir({ ctx, sessions }) {
155
79
  const chatId = ctx.chat?.id;
@@ -159,21 +83,7 @@ export async function handleDir({ ctx, sessions }) {
159
83
  const arg = text.replace(/^\/dir\s*/, '').trim();
160
84
  const managed = sessions.get(chatId);
161
85
  if (!arg) {
162
- // Show current dir + pin state
163
- const dir = managed?.workingDir ?? '(no session)';
164
- const lines = [`<b>Working directory:</b> <code>${escapeHtml(dir)}</code>`];
165
- if (managed) {
166
- lines.push(`<b>Directory pinned:</b> ${managed.dirPinned ? 'yes' : 'no'}`);
167
- if (!managed.dirPinned && managed.repoCandidates.length > 1) {
168
- lines.push('<b>Action required:</b> run <code>/dir &lt;repo-root&gt;</code> before file edits.');
169
- const preview = managed.repoCandidates
170
- .slice(0, 5)
171
- .map((p) => `<code>${escapeHtml(p)}</code>`)
172
- .join(', ');
173
- lines.push(`<b>Detected repos:</b> ${preview}`);
174
- }
175
- }
176
- await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
86
+ await reply(ctx, dirShowCommand(managed));
177
87
  return;
178
88
  }
179
89
  // Set new dir
@@ -203,7 +113,6 @@ export async function handlePin({ ctx, sessions }) {
203
113
  await ctx.reply('No working directory set. Use /dir to set one first.');
204
114
  return;
205
115
  }
206
- // Re-use setDir logic to pin the current directory
207
116
  const ok = await sessions.setDir(chatId, currentDir);
208
117
  if (ok) {
209
118
  await ctx.reply(`✅ Working directory pinned to <code>${escapeHtml(currentDir)}</code>`, {
@@ -214,6 +123,29 @@ export async function handlePin({ ctx, sessions }) {
214
123
  await ctx.reply('❌ Directory not allowed or session error. Check bot.telegram.allowed_dirs / persona.allowed_dirs.');
215
124
  }
216
125
  }
126
+ export async function handleUnpin({ ctx, sessions }) {
127
+ const chatId = ctx.chat?.id;
128
+ if (!chatId)
129
+ return;
130
+ const managed = sessions.get(chatId);
131
+ if (!managed) {
132
+ await ctx.reply('No active session. Send a message to start one.');
133
+ return;
134
+ }
135
+ if (!managed.dirPinned) {
136
+ await ctx.reply('Directory is not pinned.');
137
+ return;
138
+ }
139
+ const ok = await sessions.unpin(chatId);
140
+ if (ok) {
141
+ await ctx.reply(`✅ Directory unpinned. Working directory remains at <code>${escapeHtml(managed.workingDir)}</code>`, {
142
+ parse_mode: 'HTML',
143
+ });
144
+ }
145
+ else {
146
+ await ctx.reply('❌ Failed to unpin directory.');
147
+ }
148
+ }
217
149
  export async function handleModel({ ctx, sessions }) {
218
150
  const chatId = ctx.chat?.id;
219
151
  if (!chatId)
@@ -223,7 +155,7 @@ export async function handleModel({ ctx, sessions }) {
223
155
  await ctx.reply('No active session. Send a message to start one.');
224
156
  return;
225
157
  }
226
- await ctx.reply(`<b>Model:</b> <code>${escapeHtml(managed.session.model)}</code>\n<b>Harness:</b> <code>${escapeHtml(managed.session.harness)}</code>`, { parse_mode: 'HTML' });
158
+ await reply(ctx, modelCommand(managed));
227
159
  }
228
160
  export async function handleCompact({ ctx, sessions }) {
229
161
  const chatId = ctx.chat?.id;
@@ -234,9 +166,7 @@ export async function handleCompact({ ctx, sessions }) {
234
166
  await ctx.reply('No active session.');
235
167
  return;
236
168
  }
237
- // Reset is the simplest form of compaction for now
238
- managed.session.reset();
239
- await ctx.reply('🗜 Session context compacted (reset to system prompt).');
169
+ await reply(ctx, compactCommand(managed));
240
170
  }
241
171
  export async function handleApproval({ ctx, sessions }) {
242
172
  const chatId = ctx.chat?.id;
@@ -244,22 +174,28 @@ export async function handleApproval({ ctx, sessions }) {
244
174
  return;
245
175
  const text = ctx.message?.text ?? '';
246
176
  const arg = text.replace(/^\/approval\s*/, '').trim();
247
- const modes = ['plan', 'default', 'auto-edit', 'yolo'];
248
177
  const managed = sessions.get(chatId);
249
178
  if (!arg) {
250
- const current = managed?.approvalMode ?? 'auto-edit';
251
- await ctx.reply(`<b>Approval mode:</b> <code>${escapeHtml(current)}</code>\n\nOptions: ${modes.join(', ')}`, { parse_mode: 'HTML' });
179
+ if (!managed) {
180
+ await ctx.reply('No active session.');
181
+ return;
182
+ }
183
+ await reply(ctx, approvalShowCommand(managed));
252
184
  return;
253
185
  }
186
+ if (managed) {
187
+ const result = approvalSetCommand(managed, arg);
188
+ if (result) {
189
+ await reply(ctx, result);
190
+ return;
191
+ }
192
+ }
193
+ // If no managed session but arg given, still try to validate
194
+ const modes = ['plan', 'default', 'auto-edit', 'yolo'];
254
195
  if (!modes.includes(arg)) {
255
196
  await ctx.reply(`Invalid mode. Options: ${modes.join(', ')}`);
256
197
  return;
257
198
  }
258
- if (managed) {
259
- managed.approvalMode = arg;
260
- managed.config.approval_mode = arg;
261
- managed.config.no_confirm = arg === 'yolo';
262
- }
263
199
  await ctx.reply(`✅ Approval mode set to <code>${escapeHtml(arg)}</code>`, {
264
200
  parse_mode: 'HTML',
265
201
  });
@@ -279,21 +215,10 @@ export async function handleMode({ ctx, sessions }) {
279
215
  return;
280
216
  }
281
217
  if (!arg) {
282
- await ctx.reply(`<b>Mode:</b> <code>${escapeHtml(managed.config.mode ?? 'code')}</code>`, {
283
- parse_mode: 'HTML',
284
- });
218
+ await reply(ctx, modeShowCommand(managed));
285
219
  return;
286
220
  }
287
- if (arg !== 'code' && arg !== 'sys') {
288
- await ctx.reply('Invalid mode. Options: code, sys');
289
- return;
290
- }
291
- managed.config.mode = arg;
292
- if (arg === 'sys' && managed.config.approval_mode === 'auto-edit') {
293
- managed.config.approval_mode = 'default';
294
- managed.approvalMode = 'default';
295
- }
296
- await ctx.reply(`✅ Mode set to <code>${escapeHtml(arg)}</code>`, { parse_mode: 'HTML' });
221
+ await reply(ctx, modeSetCommand(managed, arg));
297
222
  }
298
223
  export async function handleSubAgents({ ctx, sessions }) {
299
224
  const chatId = ctx.chat?.id;
@@ -309,18 +234,11 @@ export async function handleSubAgents({ ctx, sessions }) {
309
234
  await ctx.reply('No active session. Send a message to start one.');
310
235
  return;
311
236
  }
312
- const current = managed.config.sub_agents?.enabled !== false;
313
237
  if (!arg) {
314
- await ctx.reply(`<b>Sub-agents:</b> <code>${current ? 'on' : 'off'}</code>\n\nUsage: /subagents on | off`, { parse_mode: 'HTML' });
315
- return;
316
- }
317
- if (arg !== 'on' && arg !== 'off') {
318
- await ctx.reply('Invalid value. Usage: /subagents on | off');
238
+ await reply(ctx, subagentsShowCommand(managed));
319
239
  return;
320
240
  }
321
- const enabled = arg === 'on';
322
- managed.config.sub_agents = { ...(managed.config.sub_agents ?? {}), enabled };
323
- await ctx.reply(`✅ Sub-agents <code>${enabled ? 'on' : 'off'}</code>${!enabled ? ' — spawn_task disabled for this session' : ''}`, { parse_mode: 'HTML' });
241
+ await reply(ctx, subagentsSetCommand(managed, arg));
324
242
  }
325
243
  export async function handleChanges({ ctx, sessions }) {
326
244
  const chatId = ctx.chat?.id;
@@ -331,31 +249,7 @@ export async function handleChanges({ ctx, sessions }) {
331
249
  await ctx.reply('No active session.');
332
250
  return;
333
251
  }
334
- const replay = managed.session.replay;
335
- if (!replay) {
336
- await ctx.reply('Replay is disabled. No change tracking available.');
337
- return;
338
- }
339
- try {
340
- const checkpoints = await replay.list(50);
341
- if (!checkpoints.length) {
342
- await ctx.reply('No file changes this session.');
343
- return;
344
- }
345
- // Group by file path for diffstat
346
- const byFile = new Map();
347
- for (const cp of checkpoints) {
348
- byFile.set(cp.filePath, (byFile.get(cp.filePath) ?? 0) + 1);
349
- }
350
- const lines = [`<b>Session changes (${byFile.size} files):</b>`, ''];
351
- for (const [fp, count] of byFile) {
352
- lines.push(` ✎ <code>${escapeHtml(fp)}</code> (${count} edit${count > 1 ? 's' : ''})`);
353
- }
354
- await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
355
- }
356
- catch (e) {
357
- await ctx.reply(`Error listing changes: ${e?.message ?? e}`);
358
- }
252
+ await reply(ctx, await changesCommand(managed));
359
253
  }
360
254
  export async function handleUndo({ ctx, sessions }) {
361
255
  const chatId = ctx.chat?.id;
@@ -366,25 +260,7 @@ export async function handleUndo({ ctx, sessions }) {
366
260
  await ctx.reply('No active session.');
367
261
  return;
368
262
  }
369
- const lastPath = managed.session.lastEditedPath;
370
- if (!lastPath) {
371
- await ctx.reply('No recent edits to undo.');
372
- return;
373
- }
374
- try {
375
- // Use the undo_path tool function
376
- const { undo_path } = await import('../tools.js');
377
- const ctx2 = {
378
- cwd: managed.workingDir,
379
- noConfirm: true,
380
- dryRun: false,
381
- };
382
- const result = await undo_path(ctx2, { path: lastPath });
383
- await ctx.reply(`✅ ${result}`);
384
- }
385
- catch (e) {
386
- await ctx.reply(`❌ Undo failed: ${e?.message ?? e}`);
387
- }
263
+ await reply(ctx, await undoCommand(managed));
388
264
  }
389
265
  export async function handleVault({ ctx, sessions }) {
390
266
  const chatId = ctx.chat?.id;
@@ -395,37 +271,12 @@ export async function handleVault({ ctx, sessions }) {
395
271
  await ctx.reply('No active session.');
396
272
  return;
397
273
  }
398
- const vault = managed.session.vault;
399
- if (!vault) {
400
- await ctx.reply('Vault is disabled.');
401
- return;
402
- }
403
274
  const text = ctx.message?.text ?? '';
404
275
  const query = text.replace(/^\/vault\s*/, '').trim();
405
- if (!query) {
406
- await ctx.reply('Usage: /vault &lt;search query&gt;', { parse_mode: 'HTML' });
407
- return;
408
- }
409
- try {
410
- const results = await vault.search(query, 5);
411
- if (!results.length) {
412
- await ctx.reply(`No vault results for "${escapeHtml(query)}"`, { parse_mode: 'HTML' });
413
- return;
414
- }
415
- const lines = [`<b>Vault results for "${escapeHtml(query)}":</b>`, ''];
416
- for (const r of results) {
417
- const title = r.kind === 'note' ? `note:${r.key}` : `tool:${r.tool || r.key || '?'}`;
418
- const body = (r.value ?? r.snippet ?? r.content ?? '').replace(/\s+/g, ' ').slice(0, 120);
419
- lines.push(`• <b>${escapeHtml(title)}</b>: ${escapeHtml(body)}`);
420
- }
421
- await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
422
- }
423
- catch (e) {
424
- await ctx.reply(`Error searching vault: ${e?.message ?? e}`);
425
- }
276
+ await reply(ctx, await vaultCommand(managed, query));
426
277
  }
427
278
  // ── Anton ───────────────────────────────────────────────────────────
428
- const ANTON_RATE_LIMIT_MS = 10_000; // min 10s between progress updates
279
+ const ANTON_RATE_LIMIT_MS = 10_000;
429
280
  export async function handleAnton({ ctx, sessions }) {
430
281
  const chatId = ctx.chat?.id;
431
282
  const userId = ctx.from?.id;
@@ -434,209 +285,23 @@ export async function handleAnton({ ctx, sessions }) {
434
285
  const text = ctx.message?.text ?? '';
435
286
  const args = text.replace(/^\/anton\s*/, '').trim();
436
287
  const sub = firstToken(args);
437
- const managed = sessions.get(chatId);
438
- // status
439
- if (!sub || sub === 'status') {
440
- if (!managed?.antonActive) {
441
- await ctx.reply('No Anton run in progress.');
288
+ let managed = sessions.get(chatId);
289
+ // For status/stop/last we need an existing session
290
+ if (!sub || sub === 'status' || sub === 'stop' || sub === 'last') {
291
+ if (!managed) {
292
+ await ctx.reply('No active session.');
442
293
  return;
443
294
  }
444
- if (managed.antonAbortSignal?.aborted) {
445
- await ctx.reply('🛑 Anton is stopping. Please wait for the current attempt to unwind.');
446
- return;
447
- }
448
- if (managed.antonProgress) {
449
- const line1 = formatProgressBar(managed.antonProgress);
450
- if (managed.antonProgress.currentTask) {
451
- await ctx.reply(`${line1}\n\n<b>Working on:</b> <i>${escapeHtml(managed.antonProgress.currentTask)}</i> (Attempt ${managed.antonProgress.currentAttempt})`, { parse_mode: 'HTML' });
452
- }
453
- else {
454
- await ctx.reply(line1);
455
- }
456
- }
457
- else {
458
- await ctx.reply('🤖 Anton is running (no progress data yet).');
459
- }
295
+ await reply(ctx, await antonCommand(managed, args, (t) => { ctx.reply(t).catch(() => { }); }, ANTON_RATE_LIMIT_MS));
460
296
  return;
461
297
  }
462
- // stop
463
- if (sub === 'stop') {
464
- if (!managed?.antonActive || !managed.antonAbortSignal) {
465
- await ctx.reply('No Anton run in progress.');
466
- return;
467
- }
468
- managed.lastActivity = Date.now();
469
- managed.antonAbortSignal.aborted = true;
470
- await ctx.reply('🛑 Anton stop requested. Run will halt after the current task.');
471
- return;
472
- }
473
- // last
474
- if (sub === 'last') {
475
- if (!managed?.antonLastResult) {
476
- await ctx.reply('No previous Anton run.');
477
- return;
478
- }
479
- await ctx.reply(formatRunSummary(managed.antonLastResult));
480
- return;
481
- }
482
- // start run — args is the file path (possibly with "run" prefix)
483
- const filePart = sub === 'run' ? args.replace(/^\S+\s*/, '').trim() : args;
484
- if (!filePart) {
485
- await ctx.reply([
486
- '<b>/anton</b> — Autonomous task runner',
487
- '',
488
- '/anton &lt;file&gt; — Start run',
489
- '/anton status — Show progress',
490
- '/anton stop — Stop running',
491
- '/anton last — Last run results',
492
- ].join('\n'), { parse_mode: 'HTML' });
493
- return;
494
- }
495
- // Ensure session exists
298
+ // For start — ensure session exists
496
299
  const session = managed || (await sessions.getOrCreate(chatId, userId));
497
300
  if (!session) {
498
301
  await ctx.reply('⚠️ Too many active sessions. Try again later (or wait for an old session to expire).');
499
302
  return;
500
303
  }
501
- if (session.antonActive) {
502
- const staleMs = Date.now() - session.lastActivity;
503
- if (staleMs > 120_000) {
504
- session.antonActive = false;
505
- session.antonAbortSignal = null;
506
- session.antonProgress = null;
507
- await ctx.reply('♻️ Recovered stale Anton run state. Starting a fresh run...');
508
- }
509
- else {
510
- const msg = session.antonAbortSignal?.aborted
511
- ? '🛑 Anton is still stopping. Please wait a moment, then try again.'
512
- : '⚠️ Anton is already running. Use /anton stop first.';
513
- await ctx.reply(msg);
514
- return;
515
- }
516
- }
517
- const cwd = session.workingDir;
518
- const filePath = path.resolve(cwd, filePart);
519
- try {
520
- await fs.stat(filePath);
521
- }
522
- catch {
523
- await ctx.reply(`File not found: ${escapeHtml(filePath)}`, { parse_mode: 'HTML' });
524
- return;
525
- }
526
- const defaults = session.config.anton || {};
527
- const runConfig = {
528
- taskFile: filePath,
529
- projectDir: cwd,
530
- maxRetriesPerTask: defaults.max_retries ?? 3,
531
- maxIterations: defaults.max_iterations ?? 200,
532
- taskMaxIterations: defaults.task_max_iterations ?? 50,
533
- taskTimeoutSec: defaults.task_timeout_sec ?? 600,
534
- totalTimeoutSec: defaults.total_timeout_sec ?? 7200,
535
- maxTotalTokens: defaults.max_total_tokens ?? Infinity,
536
- maxPromptTokensPerAttempt: defaults.max_prompt_tokens_per_attempt ?? 128_000,
537
- autoCommit: defaults.auto_commit ?? true,
538
- branch: false,
539
- allowDirty: false,
540
- aggressiveCleanOnFail: false,
541
- verifyAi: defaults.verify_ai ?? true,
542
- verifyModel: undefined,
543
- decompose: defaults.decompose ?? true,
544
- maxDecomposeDepth: defaults.max_decompose_depth ?? 2,
545
- maxTotalTasks: defaults.max_total_tasks ?? 500,
546
- buildCommand: undefined,
547
- testCommand: undefined,
548
- lintCommand: undefined,
549
- skipOnFail: defaults.skip_on_fail ?? false,
550
- skipOnBlocked: defaults.skip_on_blocked ?? true,
551
- rollbackOnFail: defaults.rollback_on_fail ?? false,
552
- maxIdenticalFailures: defaults.max_identical_failures ?? 5,
553
- approvalMode: (defaults.approval_mode ?? 'yolo'),
554
- verbose: false,
555
- dryRun: false,
556
- };
557
- const abortSignal = { aborted: false };
558
- session.antonActive = true;
559
- session.antonAbortSignal = abortSignal;
560
- session.antonProgress = null;
561
- let lastProgressAt = 0;
562
- const progress = {
563
- onTaskStart(task, attempt, prog) {
564
- session.antonProgress = prog;
565
- session.lastActivity = Date.now();
566
- const now = Date.now();
567
- if (now - lastProgressAt >= ANTON_RATE_LIMIT_MS) {
568
- lastProgressAt = now;
569
- ctx.reply(formatTaskStart(task, attempt, prog)).catch(() => { });
570
- }
571
- },
572
- onTaskEnd(task, result, prog) {
573
- session.antonProgress = prog;
574
- session.lastActivity = Date.now();
575
- const now = Date.now();
576
- if (now - lastProgressAt >= ANTON_RATE_LIMIT_MS) {
577
- lastProgressAt = now;
578
- ctx.reply(formatTaskEnd(task, result, prog)).catch(() => { });
579
- }
580
- },
581
- onTaskSkip(task, reason) {
582
- session.lastActivity = Date.now();
583
- ctx.reply(formatTaskSkip(task, reason)).catch(() => { });
584
- },
585
- onRunComplete(result) {
586
- session.lastActivity = Date.now();
587
- session.antonLastResult = result;
588
- session.antonActive = false;
589
- session.antonAbortSignal = null;
590
- session.antonProgress = null;
591
- ctx.reply(formatRunSummary(result)).catch(() => { });
592
- },
593
- onHeartbeat() {
594
- session.lastActivity = Date.now();
595
- },
596
- onToolLoop(taskText, event) {
597
- session.lastActivity = Date.now();
598
- if (defaults.progress_events !== false) {
599
- ctx.reply(formatToolLoopEvent(taskText, event)).catch(() => { });
600
- }
601
- },
602
- onCompaction(taskText, event) {
603
- session.lastActivity = Date.now();
604
- if (defaults.progress_events !== false && event.droppedMessages >= 5) {
605
- ctx.reply(formatCompactionEvent(taskText, event)).catch(() => { });
606
- }
607
- },
608
- onVerification(taskText, verification) {
609
- session.lastActivity = Date.now();
610
- if (defaults.progress_events !== false && !verification.passed) {
611
- ctx.reply(formatVerificationDetail(taskText, verification)).catch(() => { });
612
- }
613
- },
614
- };
615
- let pendingCount = 0;
616
- try {
617
- const tf = await parseTaskFile(filePath);
618
- pendingCount = tf.pending.length;
619
- }
620
- catch {
621
- /* non-fatal */
622
- }
623
- await ctx.reply(`🤖 Anton started on ${escapeHtml(filePart)} (${pendingCount} tasks pending)`, {
624
- parse_mode: 'HTML',
625
- });
626
- runAnton({
627
- config: runConfig,
628
- idlehandsConfig: session.config,
629
- progress,
630
- abortSignal,
631
- vault: session.session.vault,
632
- lens: session.session.lens,
633
- }).catch((err) => {
634
- session.lastActivity = Date.now();
635
- session.antonActive = false;
636
- session.antonAbortSignal = null;
637
- session.antonProgress = null;
638
- ctx.reply(`Anton error: ${err.message}`).catch(() => { });
639
- });
304
+ await reply(ctx, await antonCommand(session, args, (t) => { ctx.reply(t).catch(() => { }); }, ANTON_RATE_LIMIT_MS));
640
305
  }
641
306
  // ── Multi-agent commands ───────────────────────────────────────────────
642
307
  export async function handleAgent({ ctx, sessions, botConfig: _botConfig, }) {
@@ -644,71 +309,25 @@ export async function handleAgent({ ctx, sessions, botConfig: _botConfig, }) {
644
309
  if (!chatId)
645
310
  return;
646
311
  const managed = sessions.get(chatId);
647
- if (!managed?.agentPersona) {
312
+ if (!managed) {
648
313
  await ctx.reply('No agent configured. Using global config.');
649
314
  return;
650
315
  }
651
- const p = managed.agentPersona;
652
- const lines = [
653
- `<b>Agent: ${escapeHtml(p.display_name || managed.agentId)}</b> (<code>${escapeHtml(managed.agentId)}</code>)`,
654
- ...(p.model ? [`<b>Model:</b> <code>${escapeHtml(p.model)}</code>`] : []),
655
- ...(p.endpoint ? [`<b>Endpoint:</b> <code>${escapeHtml(p.endpoint)}</code>`] : []),
656
- ...(p.approval_mode ? [`<b>Approval:</b> <code>${escapeHtml(p.approval_mode)}</code>`] : []),
657
- ...(p.default_dir ? [`<b>Default dir:</b> <code>${escapeHtml(p.default_dir)}</code>`] : []),
658
- ...(p.allowed_dirs?.length
659
- ? [
660
- `<b>Allowed dirs:</b> ${p.allowed_dirs.map((d) => `<code>${escapeHtml(d)}</code>`).join(', ')}`,
661
- ]
662
- : []),
663
- ];
664
- // Show escalation info if configured
665
- if (p.escalation?.models?.length) {
666
- lines.push('');
667
- lines.push(`<b>Escalation models:</b> ${p.escalation.models.map((m) => `<code>${escapeHtml(m)}</code>`).join(', ')}`);
668
- if (managed.currentModelIndex > 0) {
669
- lines.push(`<b>Current tier:</b> ${managed.currentModelIndex} (escalated)`);
670
- }
671
- if (managed.pendingEscalation) {
672
- lines.push(`<b>Pending escalation:</b> <code>${escapeHtml(managed.pendingEscalation)}</code>`);
673
- }
674
- }
675
- await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
316
+ await reply(ctx, agentCommand(managed));
676
317
  }
677
318
  export async function handleAgents({ ctx, sessions, botConfig }) {
678
319
  const chatId = ctx.chat?.id;
679
320
  if (!chatId)
680
321
  return;
681
- const agents = botConfig.telegram?.agents;
682
- if (!agents || Object.keys(agents).length === 0) {
322
+ const managed = sessions.get(chatId);
323
+ if (!managed) {
683
324
  await ctx.reply('No agents configured. Using global config.');
684
325
  return;
685
326
  }
686
- const managed = sessions.get(chatId);
687
- const currentAgentId = managed?.agentId;
688
- const lines = ['<b>Configured Agents:</b>', ''];
689
- for (const [id, agent] of Object.entries(agents)) {
690
- const current = id === currentAgentId ? ' ← current' : '';
691
- const model = agent.model ? ` (${escapeHtml(agent.model)})` : '';
692
- lines.push(`• <b>${escapeHtml(agent.display_name || id)}</b> (<code>${escapeHtml(id)}</code>)${model}${current}`);
693
- }
694
- // Show routing rules
695
- const routing = botConfig.telegram?.routing;
696
- if (routing) {
697
- lines.push('', '<b>Routing:</b>');
698
- if (routing.default)
699
- lines.push(`Default: <code>${escapeHtml(routing.default)}</code>`);
700
- if (routing.users && Object.keys(routing.users).length > 0) {
701
- lines.push(`Users: ${Object.entries(routing.users)
702
- .map(([u, a]) => `${u}→${escapeHtml(a)}`)
703
- .join(', ')}`);
704
- }
705
- if (routing.chats && Object.keys(routing.chats).length > 0) {
706
- lines.push(`Chats: ${Object.entries(routing.chats)
707
- .map(([c, a]) => `${c}→${escapeHtml(a)}`)
708
- .join(', ')}`);
709
- }
710
- }
711
- await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
327
+ await reply(ctx, agentsCommand(managed, {
328
+ agents: botConfig.telegram?.agents,
329
+ routing: botConfig.telegram?.routing,
330
+ }));
712
331
  }
713
332
  export async function handleEscalate({ ctx, sessions, botConfig }) {
714
333
  const chatId = ctx.chat?.id;
@@ -720,50 +339,20 @@ export async function handleEscalate({ ctx, sessions, botConfig }) {
720
339
  await ctx.reply('No active session. Send a message first.');
721
340
  return;
722
341
  }
723
- const escalation = managed.agentPersona?.escalation;
342
+ const m = managed;
343
+ const escalation = m.agentPersona?.escalation;
724
344
  if (!escalation || !escalation.models?.length) {
725
345
  await ctx.reply('❌ No escalation models configured for this agent.');
726
346
  return;
727
347
  }
728
348
  const text = ctx.message?.text ?? '';
729
349
  const arg = text.replace(/^\/escalate\s*/, '').trim();
730
- // No arg: show available models and current state
731
350
  if (!arg) {
732
351
  const currentModel = managed.config.model || botConfig.model || 'default';
733
- const lines = [
734
- `<b>Current model:</b> <code>${escapeHtml(currentModel)}</code>`,
735
- `<b>Escalation models:</b> ${escalation.models.map((m) => `<code>${escapeHtml(m)}</code>`).join(', ')}`,
736
- '',
737
- 'Usage: /escalate &lt;model&gt; or /escalate next',
738
- 'Then send your message - it will use the escalated model.',
739
- ];
740
- if (managed.pendingEscalation) {
741
- lines.push('', `⚡ <b>Pending escalation:</b> <code>${escapeHtml(managed.pendingEscalation)}</code> (next message will use this)`);
742
- }
743
- await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
352
+ await reply(ctx, escalateShowCommand(m, currentModel));
744
353
  return;
745
354
  }
746
- // Handle 'next' - escalate to next model in chain
747
- let targetModel;
748
- let targetEndpoint;
749
- if (arg.toLowerCase() === 'next') {
750
- const nextIndex = Math.min(managed.currentModelIndex, escalation.models.length - 1);
751
- targetModel = escalation.models[nextIndex];
752
- targetEndpoint = escalation.tiers?.[nextIndex]?.endpoint;
753
- }
754
- else {
755
- // Specific model requested
756
- if (!escalation.models.includes(arg)) {
757
- await ctx.reply(`❌ Model <code>${escapeHtml(arg)}</code> not in escalation chain. Available: ${escalation.models.map((m) => `<code>${escapeHtml(m)}</code>`).join(', ')}`, { parse_mode: 'HTML' });
758
- return;
759
- }
760
- targetModel = arg;
761
- const idx = escalation.models.indexOf(arg);
762
- targetEndpoint = escalation.tiers?.[idx]?.endpoint;
763
- }
764
- managed.pendingEscalation = targetModel;
765
- managed.pendingEscalationEndpoint = targetEndpoint || null;
766
- await ctx.reply(`⚡ Next message will use <code>${escapeHtml(targetModel)}</code>. Send your request now.`, { parse_mode: 'HTML' });
355
+ await reply(ctx, escalateSetCommand(m, arg));
767
356
  }
768
357
  export async function handleDeescalate({ ctx, sessions, botConfig, }) {
769
358
  const chatId = ctx.chat?.id;
@@ -774,15 +363,13 @@ export async function handleDeescalate({ ctx, sessions, botConfig, }) {
774
363
  await ctx.reply('No active session.');
775
364
  return;
776
365
  }
777
- if (managed.currentModelIndex === 0 && !managed.pendingEscalation) {
778
- await ctx.reply('Already using base model.');
366
+ const m = managed;
367
+ const baseModel = m.agentPersona?.model || botConfig.model || 'default';
368
+ const result = deescalateCommand(m, baseModel);
369
+ if (result !== 'recreate') {
370
+ await reply(ctx, result);
779
371
  return;
780
372
  }
781
- const baseModel = managed.agentPersona?.model || botConfig.model || 'default';
782
- managed.pendingEscalation = null;
783
- managed.pendingEscalationEndpoint = null;
784
- managed.currentModelIndex = 0;
785
- // Recreate session with base model
786
373
  try {
787
374
  await sessions.recreateSession(chatId, { model: baseModel });
788
375
  await ctx.reply(`✅ Returned to base model: <code>${escapeHtml(baseModel)}</code>`, {
@@ -796,7 +383,6 @@ export async function handleDeescalate({ ctx, sessions, botConfig, }) {
796
383
  export async function handleRestartBot({ ctx }) {
797
384
  const { spawn } = await import('node:child_process');
798
385
  await ctx.reply('🔄 Restarting idlehands-bot service...');
799
- // Spawn detached process to restart after we return
800
386
  spawn('systemctl', ['--user', 'restart', 'idlehands-bot'], {
801
387
  detached: true,
802
388
  stdio: 'ignore',
@@ -816,44 +402,6 @@ export async function handleGitStatus({ ctx, sessions }) {
816
402
  await ctx.reply('No working directory set. Use /dir to set one.');
817
403
  return;
818
404
  }
819
- const { spawnSync } = await import('node:child_process');
820
- // Run git status -s (short format)
821
- const statusResult = spawnSync('git', ['status', '-s'], {
822
- cwd,
823
- encoding: 'utf8',
824
- timeout: 5000,
825
- });
826
- if (statusResult.status !== 0) {
827
- const err = String(statusResult.stderr || statusResult.error || 'Unknown error');
828
- if (err.includes('not a git repository') || err.includes('not in a git')) {
829
- await ctx.reply('❌ Not a git repository.');
830
- }
831
- else {
832
- await ctx.reply(`❌ git status failed: ${escapeHtml(err.slice(0, 200))}`);
833
- }
834
- return;
835
- }
836
- const statusOut = String(statusResult.stdout || '').trim();
837
- // Also get branch info
838
- const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
839
- cwd,
840
- encoding: 'utf8',
841
- timeout: 2000,
842
- });
843
- const branch = branchResult.status === 0 ? String(branchResult.stdout || '').trim() : 'unknown';
844
- if (!statusOut) {
845
- await ctx.reply(`📁 <b>${escapeHtml(cwd)}</b>\n🌿 Branch: <code>${escapeHtml(branch)}</code>\n\n✅ Working tree clean`, { parse_mode: 'HTML' });
846
- return;
847
- }
848
- const lines = statusOut.split('\n').slice(0, 30); // Limit to 30 lines
849
- const truncated = statusOut.split('\n').length > 30;
850
- const formatted = lines
851
- .map((line) => {
852
- const code = line.slice(0, 2);
853
- const file = line.slice(3);
854
- return `<code>${escapeHtml(code)}</code> ${escapeHtml(file)}`;
855
- })
856
- .join('\n');
857
- await ctx.reply(`📁 <b>${escapeHtml(cwd)}</b>\n🌿 Branch: <code>${escapeHtml(branch)}</code>\n\n<pre>${formatted}${truncated ? '\n...' : ''}</pre>`, { parse_mode: 'HTML' });
405
+ await reply(ctx, await gitStatusCommand(cwd));
858
406
  }
859
407
  //# sourceMappingURL=commands.js.map