@visorcraft/idlehands 1.0.1 → 1.0.2

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,6 +1,7 @@
1
1
  import { Client, Events, GatewayIntentBits, Partials, } from 'discord.js';
2
2
  import { createSession } from '../agent.js';
3
3
  import { DiscordConfirmProvider } from './confirm-discord.js';
4
+ import { sanitizeBotOutputText } from './format.js';
4
5
  import { projectDir } from '../utils.js';
5
6
  import path from 'node:path';
6
7
  import fs from 'node:fs/promises';
@@ -36,7 +37,7 @@ function splitDiscord(text, limit = 1900) {
36
37
  return chunks;
37
38
  }
38
39
  function safeContent(text) {
39
- const t = text.trim();
40
+ const t = sanitizeBotOutputText(text).trim();
40
41
  return t.length ? t : '(empty response)';
41
42
  }
42
43
  function sessionKeyForMessage(msg, allowGuilds) {
@@ -65,6 +66,7 @@ export async function startDiscordBot(config, botConfig) {
65
66
  const sessionTimeoutMs = (botConfig.session_timeout_min ?? 30) * 60_000;
66
67
  const approvalMode = normalizeApprovalMode(botConfig.approval_mode, config.approval_mode ?? 'auto-edit');
67
68
  const defaultDir = botConfig.default_dir || projectDir(config);
69
+ const replyToUserMessages = botConfig.reply_to_user_messages === true;
68
70
  const sessions = new Map();
69
71
  const client = new Client({
70
72
  intents: [
@@ -75,6 +77,11 @@ export async function startDiscordBot(config, botConfig) {
75
77
  ],
76
78
  partials: [Partials.Channel],
77
79
  });
80
+ const sendUserVisible = async (msg, content) => {
81
+ if (replyToUserMessages)
82
+ return await msg.reply(content);
83
+ return await msg.channel.send(content);
84
+ };
78
85
  async function getOrCreate(msg) {
79
86
  const key = sessionKeyForMessage(msg, allowGuilds);
80
87
  const existing = sessions.get(key);
@@ -193,7 +200,7 @@ export async function startDiscordBot(config, botConfig) {
193
200
  if (!turn)
194
201
  return;
195
202
  const turnId = turn.turnId;
196
- const placeholder = await msg.reply('⏳ Thinking...').catch(() => null);
203
+ const placeholder = await sendUserVisible(msg, '⏳ Thinking...').catch(() => null);
197
204
  let streamed = '';
198
205
  const hooks = {
199
206
  onToken: (t) => {
@@ -223,7 +230,7 @@ export async function startDiscordBot(config, botConfig) {
223
230
  await placeholder.edit(chunks[0]).catch(() => { });
224
231
  }
225
232
  else {
226
- await msg.reply(chunks[0]).catch(() => { });
233
+ await sendUserVisible(msg, chunks[0]).catch(() => { });
227
234
  }
228
235
  for (let i = 1; i < chunks.length && i < 10; i++) {
229
236
  if (!isTurnActive(managed, turnId))
@@ -242,7 +249,7 @@ export async function startDiscordBot(config, botConfig) {
242
249
  if (placeholder)
243
250
  await placeholder.edit('⏹ Cancelled.').catch(() => { });
244
251
  else
245
- await msg.reply('⏹ Cancelled.').catch(() => { });
252
+ await sendUserVisible(msg, '⏹ Cancelled.').catch(() => { });
246
253
  }
247
254
  else {
248
255
  const errMsg = raw.slice(0, 400);
@@ -250,7 +257,7 @@ export async function startDiscordBot(config, botConfig) {
250
257
  await placeholder.edit(`❌ ${errMsg}`).catch(() => { });
251
258
  }
252
259
  else {
253
- await msg.reply(`❌ ${errMsg}`).catch(() => { });
260
+ await sendUserVisible(msg, `❌ ${errMsg}`).catch(() => { });
254
261
  }
255
262
  }
256
263
  }
@@ -315,17 +322,17 @@ export async function startDiscordBot(config, botConfig) {
315
322
  const key = sessionKeyForMessage(msg, allowGuilds);
316
323
  if (content === '/new') {
317
324
  destroySession(key);
318
- await msg.reply('✨ New session started. Send a message to begin.').catch(() => { });
325
+ await sendUserVisible(msg, '✨ New session started. Send a message to begin.').catch(() => { });
319
326
  return;
320
327
  }
321
328
  const managed = await getOrCreate(msg);
322
329
  if (!managed) {
323
- await msg.reply('⚠️ Too many active sessions. Please retry later.').catch(() => { });
330
+ await sendUserVisible(msg, '⚠️ Too many active sessions. Please retry later.').catch(() => { });
324
331
  return;
325
332
  }
326
333
  if (content === '/cancel') {
327
334
  const res = cancelActive(managed);
328
- await msg.reply(res.message).catch(() => { });
335
+ await sendUserVisible(msg, res.message).catch(() => { });
329
336
  return;
330
337
  }
331
338
  if (content === '/start') {
@@ -338,7 +345,7 @@ export async function startDiscordBot(config, botConfig) {
338
345
  '',
339
346
  'Send me a coding task, or use /help for commands.',
340
347
  ];
341
- await msg.reply(lines.join('\n')).catch(() => { });
348
+ await sendUserVisible(msg, lines.join('\n')).catch(() => { });
342
349
  return;
343
350
  }
344
351
  if (content === '/help') {
@@ -361,22 +368,22 @@ export async function startDiscordBot(config, botConfig) {
361
368
  '/anton <file> — Start autonomous task runner',
362
369
  '/anton status | /anton stop | /anton last',
363
370
  ];
364
- await msg.reply(lines.join('\n')).catch(() => { });
371
+ await sendUserVisible(msg, lines.join('\n')).catch(() => { });
365
372
  return;
366
373
  }
367
374
  if (content === '/model') {
368
- await msg.reply(`Model: \`${managed.session.model}\`\nHarness: \`${managed.session.harness}\``).catch(() => { });
375
+ await sendUserVisible(msg, `Model: \`${managed.session.model}\`\nHarness: \`${managed.session.harness}\``).catch(() => { });
369
376
  return;
370
377
  }
371
378
  if (content === '/compact') {
372
379
  managed.session.reset();
373
- await msg.reply('🗜 Session context compacted (reset to system prompt).').catch(() => { });
380
+ await sendUserVisible(msg, '🗜 Session context compacted (reset to system prompt).').catch(() => { });
374
381
  return;
375
382
  }
376
383
  if (content === '/dir' || content.startsWith('/dir ')) {
377
384
  const arg = content.slice('/dir'.length).trim();
378
385
  if (!arg) {
379
- await msg.reply(`Working directory: \`${managed.config.dir || defaultDir}\``).catch(() => { });
386
+ await sendUserVisible(msg, `Working directory: \`${managed.config.dir || defaultDir}\``).catch(() => { });
380
387
  return;
381
388
  }
382
389
  const allowedDirs = botConfig.allowed_dirs ?? ['~'];
@@ -384,7 +391,7 @@ export async function startDiscordBot(config, botConfig) {
384
391
  const resolvedDir = arg.replace(/^~/, homeDir);
385
392
  const allowed = allowedDirs.some((d) => resolvedDir.startsWith(d.replace(/^~/, homeDir)));
386
393
  if (!allowed) {
387
- await msg.reply('❌ Directory not allowed. Check bot.discord.allowed_dirs.').catch(() => { });
394
+ await sendUserVisible(msg, '❌ Directory not allowed. Check bot.discord.allowed_dirs.').catch(() => { });
388
395
  return;
389
396
  }
390
397
  const cfg = {
@@ -392,68 +399,68 @@ export async function startDiscordBot(config, botConfig) {
392
399
  dir: resolvedDir,
393
400
  };
394
401
  await recreateSession(managed, cfg);
395
- await msg.reply(`✅ Working directory set to \`${resolvedDir}\``).catch(() => { });
402
+ await sendUserVisible(msg, `✅ Working directory set to \`${resolvedDir}\``).catch(() => { });
396
403
  return;
397
404
  }
398
405
  if (content === '/approval' || content.startsWith('/approval ')) {
399
406
  const arg = content.slice('/approval'.length).trim().toLowerCase();
400
407
  const modes = ['plan', 'default', 'auto-edit', 'yolo'];
401
408
  if (!arg) {
402
- await msg.reply(`Approval mode: \`${managed.config.approval_mode || approvalMode}\`\nOptions: ${modes.join(', ')}`).catch(() => { });
409
+ await sendUserVisible(msg, `Approval mode: \`${managed.config.approval_mode || approvalMode}\`\nOptions: ${modes.join(', ')}`).catch(() => { });
403
410
  return;
404
411
  }
405
412
  if (!modes.includes(arg)) {
406
- await msg.reply(`Invalid mode. Options: ${modes.join(', ')}`).catch(() => { });
413
+ await sendUserVisible(msg, `Invalid mode. Options: ${modes.join(', ')}`).catch(() => { });
407
414
  return;
408
415
  }
409
416
  managed.config.approval_mode = arg;
410
417
  managed.config.no_confirm = arg === 'yolo';
411
- await msg.reply(`✅ Approval mode set to \`${arg}\``).catch(() => { });
418
+ await sendUserVisible(msg, `✅ Approval mode set to \`${arg}\``).catch(() => { });
412
419
  return;
413
420
  }
414
421
  if (content === '/mode' || content.startsWith('/mode ')) {
415
422
  const arg = content.slice('/mode'.length).trim().toLowerCase();
416
423
  if (!arg) {
417
- await msg.reply(`Mode: \`${managed.config.mode || 'code'}\``).catch(() => { });
424
+ await sendUserVisible(msg, `Mode: \`${managed.config.mode || 'code'}\``).catch(() => { });
418
425
  return;
419
426
  }
420
427
  if (arg !== 'code' && arg !== 'sys') {
421
- await msg.reply('Invalid mode. Options: code, sys').catch(() => { });
428
+ await sendUserVisible(msg, 'Invalid mode. Options: code, sys').catch(() => { });
422
429
  return;
423
430
  }
424
431
  managed.config.mode = arg;
425
432
  if (arg === 'sys' && managed.config.approval_mode === 'auto-edit') {
426
433
  managed.config.approval_mode = 'default';
427
434
  }
428
- await msg.reply(`✅ Mode set to \`${arg}\``).catch(() => { });
435
+ await sendUserVisible(msg, `✅ Mode set to \`${arg}\``).catch(() => { });
429
436
  return;
430
437
  }
431
438
  if (content === '/subagents' || content.startsWith('/subagents ')) {
432
439
  const arg = content.slice('/subagents'.length).trim().toLowerCase();
433
440
  const current = managed.config.sub_agents?.enabled !== false;
434
441
  if (!arg) {
435
- await msg.reply(`Sub-agents: \`${current ? 'on' : 'off'}\`\nUsage: /subagents on | off`).catch(() => { });
442
+ await sendUserVisible(msg, `Sub-agents: \`${current ? 'on' : 'off'}\`\nUsage: /subagents on | off`).catch(() => { });
436
443
  return;
437
444
  }
438
445
  if (arg !== 'on' && arg !== 'off') {
439
- await msg.reply('Invalid value. Usage: /subagents on | off').catch(() => { });
446
+ await sendUserVisible(msg, 'Invalid value. Usage: /subagents on | off').catch(() => { });
440
447
  return;
441
448
  }
442
449
  const enabled = arg === 'on';
443
450
  managed.config.sub_agents = { ...(managed.config.sub_agents ?? {}), enabled };
444
- await msg.reply(`✅ Sub-agents \`${enabled ? 'on' : 'off'}\`${!enabled ? ' — spawn_task disabled for this session' : ''}`).catch(() => { });
451
+ await sendUserVisible(msg, `✅ Sub-agents \`${enabled ? 'on' : 'off'}\`${!enabled ? ' — spawn_task disabled for this session' : ''}`).catch(() => { });
445
452
  return;
446
453
  }
447
454
  if (content === '/changes') {
448
455
  const replay = managed.session.replay;
449
456
  if (!replay) {
450
- await msg.reply('Replay is disabled. No change tracking available.').catch(() => { });
457
+ await sendUserVisible(msg, 'Replay is disabled. No change tracking available.').catch(() => { });
451
458
  return;
452
459
  }
453
460
  try {
454
461
  const checkpoints = await replay.list(50);
455
462
  if (!checkpoints.length) {
456
- await msg.reply('No file changes this session.').catch(() => { });
463
+ await sendUserVisible(msg, 'No file changes this session.').catch(() => { });
457
464
  return;
458
465
  }
459
466
  const byFile = new Map();
@@ -462,44 +469,44 @@ export async function startDiscordBot(config, botConfig) {
462
469
  const lines = [`Session changes (${byFile.size} files):`];
463
470
  for (const [fp, count] of byFile)
464
471
  lines.push(`✎ \`${fp}\` (${count} edit${count > 1 ? 's' : ''})`);
465
- await msg.reply(lines.join('\n')).catch(() => { });
472
+ await sendUserVisible(msg, lines.join('\n')).catch(() => { });
466
473
  }
467
474
  catch (e) {
468
- await msg.reply(`Error listing changes: ${e?.message ?? String(e)}`).catch(() => { });
475
+ await sendUserVisible(msg, `Error listing changes: ${e?.message ?? String(e)}`).catch(() => { });
469
476
  }
470
477
  return;
471
478
  }
472
479
  if (content === '/undo') {
473
480
  const lastPath = managed.session.lastEditedPath;
474
481
  if (!lastPath) {
475
- await msg.reply('No recent edits to undo.').catch(() => { });
482
+ await sendUserVisible(msg, 'No recent edits to undo.').catch(() => { });
476
483
  return;
477
484
  }
478
485
  try {
479
486
  const { undo_path } = await import('../tools.js');
480
487
  const result = await undo_path({ cwd: managed.config.dir || defaultDir, noConfirm: true, dryRun: false }, { path: lastPath });
481
- await msg.reply(`✅ ${result}`).catch(() => { });
488
+ await sendUserVisible(msg, `✅ ${result}`).catch(() => { });
482
489
  }
483
490
  catch (e) {
484
- await msg.reply(`❌ Undo failed: ${e?.message ?? String(e)}`).catch(() => { });
491
+ await sendUserVisible(msg, `❌ Undo failed: ${e?.message ?? String(e)}`).catch(() => { });
485
492
  }
486
493
  return;
487
494
  }
488
495
  if (content === '/vault' || content.startsWith('/vault ')) {
489
496
  const query = content.slice('/vault'.length).trim();
490
497
  if (!query) {
491
- await msg.reply('Usage: /vault <search query>').catch(() => { });
498
+ await sendUserVisible(msg, 'Usage: /vault <search query>').catch(() => { });
492
499
  return;
493
500
  }
494
501
  const vault = managed.session.vault;
495
502
  if (!vault) {
496
- await msg.reply('Vault is disabled.').catch(() => { });
503
+ await sendUserVisible(msg, 'Vault is disabled.').catch(() => { });
497
504
  return;
498
505
  }
499
506
  try {
500
507
  const results = await vault.search(query, 5);
501
508
  if (!results.length) {
502
- await msg.reply(`No vault results for "${query}"`).catch(() => { });
509
+ await sendUserVisible(msg, `No vault results for "${query}"`).catch(() => { });
503
510
  return;
504
511
  }
505
512
  const lines = [`Vault results for "${query}":`];
@@ -508,10 +515,10 @@ export async function startDiscordBot(config, botConfig) {
508
515
  const body = (r.value ?? r.snippet ?? r.content ?? '').replace(/\s+/g, ' ').slice(0, 120);
509
516
  lines.push(`• ${title}: ${body}`);
510
517
  }
511
- await msg.reply(lines.join('\n')).catch(() => { });
518
+ await sendUserVisible(msg, lines.join('\n')).catch(() => { });
512
519
  }
513
520
  catch (e) {
514
- await msg.reply(`Error searching vault: ${e?.message ?? String(e)}`).catch(() => { });
521
+ await sendUserVisible(msg, `Error searching vault: ${e?.message ?? String(e)}`).catch(() => { });
515
522
  }
516
523
  return;
517
524
  }
@@ -520,7 +527,7 @@ export async function startDiscordBot(config, botConfig) {
520
527
  const pct = managed.session.contextWindow > 0
521
528
  ? ((used / managed.session.contextWindow) * 100).toFixed(1)
522
529
  : '?';
523
- await msg.reply([
530
+ await sendUserVisible(msg, [
524
531
  `Mode: ${managed.config.mode ?? 'code'}`,
525
532
  `Approval: ${managed.config.approval_mode}`,
526
533
  `Model: ${managed.session.model}`,
@@ -537,20 +544,20 @@ export async function startDiscordBot(config, botConfig) {
537
544
  const config = await loadRuntimes();
538
545
  const redacted = redactConfig(config);
539
546
  if (!redacted.hosts.length) {
540
- await msg.reply('No hosts configured. Use `idlehands hosts add` in CLI.').catch(() => { });
547
+ await sendUserVisible(msg, 'No hosts configured. Use `idlehands hosts add` in CLI.').catch(() => { });
541
548
  return;
542
549
  }
543
550
  const lines = redacted.hosts.map((h) => `${h.enabled ? '🟢' : '🔴'} ${h.display_name} (\`${h.id}\`)\n Transport: ${h.transport}`);
544
551
  const chunks = splitDiscord(lines.join('\n\n'));
545
552
  for (const [i, chunk] of chunks.entries()) {
546
553
  if (i === 0)
547
- await msg.reply(chunk).catch(() => { });
554
+ await sendUserVisible(msg, chunk).catch(() => { });
548
555
  else
549
556
  await msg.channel.send(chunk).catch(() => { });
550
557
  }
551
558
  }
552
559
  catch (e) {
553
- await msg.reply(`❌ Failed to load hosts: ${e?.message ?? String(e)}`).catch(() => { });
560
+ await sendUserVisible(msg, `❌ Failed to load hosts: ${e?.message ?? String(e)}`).catch(() => { });
554
561
  }
555
562
  return;
556
563
  }
@@ -560,20 +567,20 @@ export async function startDiscordBot(config, botConfig) {
560
567
  const config = await loadRuntimes();
561
568
  const redacted = redactConfig(config);
562
569
  if (!redacted.backends.length) {
563
- await msg.reply('No backends configured. Use `idlehands backends add` in CLI.').catch(() => { });
570
+ await sendUserVisible(msg, 'No backends configured. Use `idlehands backends add` in CLI.').catch(() => { });
564
571
  return;
565
572
  }
566
573
  const lines = redacted.backends.map((b) => `${b.enabled ? '🟢' : '🔴'} ${b.display_name} (\`${b.id}\`)\n Type: ${b.type}`);
567
574
  const chunks = splitDiscord(lines.join('\n\n'));
568
575
  for (const [i, chunk] of chunks.entries()) {
569
576
  if (i === 0)
570
- await msg.reply(chunk).catch(() => { });
577
+ await sendUserVisible(msg, chunk).catch(() => { });
571
578
  else
572
579
  await msg.channel.send(chunk).catch(() => { });
573
580
  }
574
581
  }
575
582
  catch (e) {
576
- await msg.reply(`❌ Failed to load backends: ${e?.message ?? String(e)}`).catch(() => { });
583
+ await sendUserVisible(msg, `❌ Failed to load backends: ${e?.message ?? String(e)}`).catch(() => { });
577
584
  }
578
585
  return;
579
586
  }
@@ -582,20 +589,20 @@ export async function startDiscordBot(config, botConfig) {
582
589
  const { loadRuntimes } = await import('../runtime/store.js');
583
590
  const config = await loadRuntimes();
584
591
  if (!config.models.length) {
585
- await msg.reply('No runtime models configured.').catch(() => { });
592
+ await sendUserVisible(msg, 'No runtime models configured.').catch(() => { });
586
593
  return;
587
594
  }
588
595
  const lines = config.models.map((m) => `${m.enabled ? '🟢' : '🔴'} ${m.display_name} (\`${m.id}\`)\n Source: \`${m.source}\``);
589
596
  const chunks = splitDiscord(lines.join('\n\n'));
590
597
  for (const [i, chunk] of chunks.entries()) {
591
598
  if (i === 0)
592
- await msg.reply(chunk).catch(() => { });
599
+ await sendUserVisible(msg, chunk).catch(() => { });
593
600
  else
594
601
  await msg.channel.send(chunk).catch(() => { });
595
602
  }
596
603
  }
597
604
  catch (e) {
598
- await msg.reply(`❌ Failed to load runtime models: ${e?.message ?? String(e)}`).catch(() => { });
605
+ await sendUserVisible(msg, `❌ Failed to load runtime models: ${e?.message ?? String(e)}`).catch(() => { });
599
606
  }
600
607
  return;
601
608
  }
@@ -604,7 +611,7 @@ export async function startDiscordBot(config, botConfig) {
604
611
  const { loadActiveRuntime } = await import('../runtime/executor.js');
605
612
  const active = await loadActiveRuntime();
606
613
  if (!active) {
607
- await msg.reply('No active runtime.').catch(() => { });
614
+ await sendUserVisible(msg, 'No active runtime.').catch(() => { });
608
615
  return;
609
616
  }
610
617
  const lines = [
@@ -619,13 +626,13 @@ export async function startDiscordBot(config, botConfig) {
619
626
  const chunks = splitDiscord(lines.join('\n'));
620
627
  for (const [i, chunk] of chunks.entries()) {
621
628
  if (i === 0)
622
- await msg.reply(chunk).catch(() => { });
629
+ await sendUserVisible(msg, chunk).catch(() => { });
623
630
  else
624
631
  await msg.channel.send(chunk).catch(() => { });
625
632
  }
626
633
  }
627
634
  catch (e) {
628
- await msg.reply(`❌ Failed to read runtime status: ${e?.message ?? String(e)}`).catch(() => { });
635
+ await sendUserVisible(msg, `❌ Failed to read runtime status: ${e?.message ?? String(e)}`).catch(() => { });
629
636
  }
630
637
  return;
631
638
  }
@@ -633,7 +640,7 @@ export async function startDiscordBot(config, botConfig) {
633
640
  try {
634
641
  const modelId = content.slice('/switch'.length).trim();
635
642
  if (!modelId) {
636
- await msg.reply('Usage: /switch <model-id>').catch(() => { });
643
+ await sendUserVisible(msg, 'Usage: /switch <model-id>').catch(() => { });
637
644
  return;
638
645
  }
639
646
  const { plan } = await import('../runtime/planner.js');
@@ -643,14 +650,14 @@ export async function startDiscordBot(config, botConfig) {
643
650
  const active = await loadActiveRuntime();
644
651
  const result = plan({ modelId, mode: 'live' }, rtConfig, active);
645
652
  if (!result.ok) {
646
- await msg.reply(`❌ Plan failed: ${result.reason}`).catch(() => { });
653
+ await sendUserVisible(msg, `❌ Plan failed: ${result.reason}`).catch(() => { });
647
654
  return;
648
655
  }
649
656
  if (result.reuse) {
650
- await msg.reply('✅ Runtime already active and healthy.').catch(() => { });
657
+ await sendUserVisible(msg, '✅ Runtime already active and healthy.').catch(() => { });
651
658
  return;
652
659
  }
653
- const statusMsg = await msg.reply(`⏳ Switching to \`${result.model.display_name}\`...`).catch(() => null);
660
+ const statusMsg = await sendUserVisible(msg, `⏳ Switching to \`${result.model.display_name}\`...`).catch(() => null);
654
661
  const execResult = await execute(result, {
655
662
  onStep: async (step, status) => {
656
663
  if (status === 'done' && statusMsg) {
@@ -658,7 +665,7 @@ export async function startDiscordBot(config, botConfig) {
658
665
  }
659
666
  },
660
667
  confirm: async (prompt) => {
661
- await msg.reply(`⚠️ ${prompt}\nAuto-approving for bot context.`).catch(() => { });
668
+ await sendUserVisible(msg, `⚠️ ${prompt}\nAuto-approving for bot context.`).catch(() => { });
662
669
  return true;
663
670
  },
664
671
  });
@@ -667,7 +674,7 @@ export async function startDiscordBot(config, botConfig) {
667
674
  await statusMsg.edit(`✅ Switched to \`${result.model.display_name}\``).catch(() => { });
668
675
  }
669
676
  else {
670
- await msg.reply(`✅ Switched to \`${result.model.display_name}\``).catch(() => { });
677
+ await sendUserVisible(msg, `✅ Switched to \`${result.model.display_name}\``).catch(() => { });
671
678
  }
672
679
  }
673
680
  else {
@@ -676,12 +683,12 @@ export async function startDiscordBot(config, botConfig) {
676
683
  await statusMsg.edit(err).catch(() => { });
677
684
  }
678
685
  else {
679
- await msg.reply(err).catch(() => { });
686
+ await sendUserVisible(msg, err).catch(() => { });
680
687
  }
681
688
  }
682
689
  }
683
690
  catch (e) {
684
- await msg.reply(`❌ Switch failed: ${e?.message ?? String(e)}`).catch(() => { });
691
+ await sendUserVisible(msg, `❌ Switch failed: ${e?.message ?? String(e)}`).catch(() => { });
685
692
  }
686
693
  return;
687
694
  }
@@ -692,11 +699,11 @@ export async function startDiscordBot(config, botConfig) {
692
699
  }
693
700
  if (managed.inFlight) {
694
701
  if (managed.pendingQueue.length >= maxQueue) {
695
- await msg.reply(`⏳ Queue full (${managed.pendingQueue.length}/${maxQueue}). Use /cancel.`).catch(() => { });
702
+ await sendUserVisible(msg, `⏳ Queue full (${managed.pendingQueue.length}/${maxQueue}). Use /cancel.`).catch(() => { });
696
703
  return;
697
704
  }
698
705
  managed.pendingQueue.push(msg);
699
- await msg.reply(`⏳ Queued (#${managed.pendingQueue.length}).`).catch(() => { });
706
+ await sendUserVisible(msg, `⏳ Queued (#${managed.pendingQueue.length}).`).catch(() => { });
700
707
  return;
701
708
  }
702
709
  console.error(`[bot:discord] ${msg.author.id}: ${content.slice(0, 50)}${content.length > 50 ? '…' : ''}`);
@@ -708,40 +715,40 @@ export async function startDiscordBot(config, botConfig) {
708
715
  const sub = args.split(/\s+/)[0]?.toLowerCase() || '';
709
716
  if (!sub || sub === 'status') {
710
717
  if (!managed.antonActive) {
711
- await msg.reply('No Anton run in progress.').catch(() => { });
718
+ await sendUserVisible(msg, 'No Anton run in progress.').catch(() => { });
712
719
  }
713
720
  else if (managed.antonProgress) {
714
- await msg.reply(formatProgressBar(managed.antonProgress)).catch(() => { });
721
+ await sendUserVisible(msg, formatProgressBar(managed.antonProgress)).catch(() => { });
715
722
  }
716
723
  else {
717
- await msg.reply('🤖 Anton is running (no progress data yet).').catch(() => { });
724
+ await sendUserVisible(msg, '🤖 Anton is running (no progress data yet).').catch(() => { });
718
725
  }
719
726
  return;
720
727
  }
721
728
  if (sub === 'stop') {
722
729
  if (!managed.antonActive || !managed.antonAbortSignal) {
723
- await msg.reply('No Anton run in progress.').catch(() => { });
730
+ await sendUserVisible(msg, 'No Anton run in progress.').catch(() => { });
724
731
  return;
725
732
  }
726
733
  managed.antonAbortSignal.aborted = true;
727
- await msg.reply('🛑 Anton stop requested.').catch(() => { });
734
+ await sendUserVisible(msg, '🛑 Anton stop requested.').catch(() => { });
728
735
  return;
729
736
  }
730
737
  if (sub === 'last') {
731
738
  if (!managed.antonLastResult) {
732
- await msg.reply('No previous Anton run.').catch(() => { });
739
+ await sendUserVisible(msg, 'No previous Anton run.').catch(() => { });
733
740
  return;
734
741
  }
735
- await msg.reply(formatRunSummary(managed.antonLastResult)).catch(() => { });
742
+ await sendUserVisible(msg, formatRunSummary(managed.antonLastResult)).catch(() => { });
736
743
  return;
737
744
  }
738
745
  const filePart = sub === 'run' ? args.replace(/^\S+\s*/, '').trim() : args;
739
746
  if (!filePart) {
740
- await msg.reply('/anton <file> — start | /anton status | /anton stop | /anton last').catch(() => { });
747
+ await sendUserVisible(msg, '/anton <file> — start | /anton status | /anton stop | /anton last').catch(() => { });
741
748
  return;
742
749
  }
743
750
  if (managed.antonActive) {
744
- await msg.reply('⚠️ Anton is already running. Use /anton stop first.').catch(() => { });
751
+ await sendUserVisible(msg, '⚠️ Anton is already running. Use /anton stop first.').catch(() => { });
745
752
  return;
746
753
  }
747
754
  const cwd = managed.config.dir || process.cwd();
@@ -750,7 +757,7 @@ export async function startDiscordBot(config, botConfig) {
750
757
  await fs.stat(filePath);
751
758
  }
752
759
  catch {
753
- await msg.reply(`File not found: ${filePath}`).catch(() => { });
760
+ await sendUserVisible(msg, `File not found: ${filePath}`).catch(() => { });
754
761
  return;
755
762
  }
756
763
  const defaults = managed.config.anton || {};
@@ -814,7 +821,7 @@ export async function startDiscordBot(config, botConfig) {
814
821
  pendingCount = tf.pending.length;
815
822
  }
816
823
  catch { }
817
- await msg.reply(`🤖 Anton started on ${filePart} (${pendingCount} tasks pending)`).catch(() => { });
824
+ await sendUserVisible(msg, `🤖 Anton started on ${filePart} (${pendingCount} tasks pending)`).catch(() => { });
818
825
  runAnton({
819
826
  config: runConfig,
820
827
  idlehandsConfig: managed.config,