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