@visorcraft/idlehands 1.3.3 → 1.3.5

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 (70) hide show
  1. package/README.md +28 -1
  2. package/dist/agent/formatting.js +1 -5
  3. package/dist/agent/formatting.js.map +1 -1
  4. package/dist/agent.js +130 -22
  5. package/dist/agent.js.map +1 -1
  6. package/dist/anton/controller.js +20 -1
  7. package/dist/anton/controller.js.map +1 -1
  8. package/dist/anton/reporter.js +2 -20
  9. package/dist/anton/reporter.js.map +1 -1
  10. package/dist/bot/auto-continue.js +24 -0
  11. package/dist/bot/auto-continue.js.map +1 -0
  12. package/dist/bot/commands.js +50 -0
  13. package/dist/bot/commands.js.map +1 -1
  14. package/dist/bot/discord-commands.js +833 -0
  15. package/dist/bot/discord-commands.js.map +1 -0
  16. package/dist/bot/discord-routing.js +1 -8
  17. package/dist/bot/discord-routing.js.map +1 -1
  18. package/dist/bot/discord.js +36 -789
  19. package/dist/bot/discord.js.map +1 -1
  20. package/dist/bot/session-manager.js +52 -0
  21. package/dist/bot/session-manager.js.map +1 -1
  22. package/dist/bot/telegram-commands.js +201 -0
  23. package/dist/bot/telegram-commands.js.map +1 -0
  24. package/dist/bot/telegram.js +32 -310
  25. package/dist/bot/telegram.js.map +1 -1
  26. package/dist/bot/ux/events.js +142 -0
  27. package/dist/bot/ux/events.js.map +1 -0
  28. package/dist/cli/commands/project.js +52 -0
  29. package/dist/cli/commands/project.js.map +1 -1
  30. package/dist/config.js +16 -0
  31. package/dist/config.js.map +1 -1
  32. package/dist/context.js +1 -3
  33. package/dist/context.js.map +1 -1
  34. package/dist/progress/ir.js +0 -3
  35. package/dist/progress/ir.js.map +1 -1
  36. package/dist/progress/tool-summary.js +1 -4
  37. package/dist/progress/tool-summary.js.map +1 -1
  38. package/dist/progress/turn-progress.js +1 -5
  39. package/dist/progress/turn-progress.js.map +1 -1
  40. package/dist/runtime/executor.js +1 -3
  41. package/dist/runtime/executor.js.map +1 -1
  42. package/dist/runtime/health.js +2 -1
  43. package/dist/runtime/health.js.map +1 -1
  44. package/dist/shared/async.js +5 -0
  45. package/dist/shared/async.js.map +1 -0
  46. package/dist/shared/config-utils.js +8 -0
  47. package/dist/shared/config-utils.js.map +1 -0
  48. package/dist/shared/format.js +19 -0
  49. package/dist/shared/format.js.map +1 -0
  50. package/dist/shared/math.js +5 -0
  51. package/dist/shared/math.js.map +1 -0
  52. package/dist/shared/strings.js +8 -0
  53. package/dist/shared/strings.js.map +1 -0
  54. package/dist/tools/patch.js +82 -0
  55. package/dist/tools/patch.js.map +1 -0
  56. package/dist/tools/path-safety.js +89 -0
  57. package/dist/tools/path-safety.js.map +1 -0
  58. package/dist/tools/undo.js +141 -0
  59. package/dist/tools/undo.js.map +1 -0
  60. package/dist/tools.js +11 -289
  61. package/dist/tools.js.map +1 -1
  62. package/dist/tui/controller.js +24 -1
  63. package/dist/tui/controller.js.map +1 -1
  64. package/dist/tui/event-bridge.js +1 -3
  65. package/dist/tui/event-bridge.js.map +1 -1
  66. package/dist/tui/render.js +1 -5
  67. package/dist/tui/render.js.map +1 -1
  68. package/dist/vault.js +1 -5
  69. package/dist/vault.js.map +1 -1
  70. package/package.json +1 -1
@@ -1,18 +1,15 @@
1
- import fs from 'node:fs/promises';
2
1
  import path from 'node:path';
3
2
  import { Client, Events, GatewayIntentBits, Partials, REST, Routes, SlashCommandBuilder, } from 'discord.js';
4
3
  import { createSession } from '../agent.js';
5
- import { runAnton } from '../anton/controller.js';
6
- import { parseTaskFile } from '../anton/parser.js';
7
- import { formatRunSummary, formatProgressBar, formatTaskStart, formatTaskEnd, formatTaskSkip, formatToolLoopEvent, formatCompactionEvent, formatVerificationDetail, } from '../anton/reporter.js';
8
- import { firstToken } from '../cli/command-utils.js';
9
4
  import { chainAgentHooks } from '../progress/agent-hooks.js';
10
5
  import { projectDir, PKG_VERSION } from '../utils.js';
11
6
  import { WATCHDOG_RECOMMENDED_TUNING_TEXT, formatWatchdogCancelMessage, resolveWatchdogSettings, shouldRecommendWatchdogTuning, } from '../watchdog.js';
12
7
  import { DiscordConfirmProvider } from './confirm-discord.js';
13
- import { detectRepoCandidates, expandHome, isPathAllowed, normalizeAllowedDirs, } from './dir-guard.js';
14
- import { parseAllowedUsers, normalizeApprovalMode, splitDiscord, safeContent, detectEscalation, checkKeywordEscalation, resolveAgentForMessage, sessionKeyForMessage, } from './discord-routing.js';
8
+ import { detectRepoCandidates, expandHome, normalizeAllowedDirs, } from './dir-guard.js';
9
+ import { parseAllowedUsers, normalizeApprovalMode, safeContent, detectEscalation, checkKeywordEscalation, resolveAgentForMessage, sessionKeyForMessage, } from './discord-routing.js';
15
10
  import { DiscordStreamingMessage } from './discord-streaming.js';
11
+ import { isToolLoopBreak, formatAutoContinueNotice, AUTO_CONTINUE_PROMPT } from './auto-continue.js';
12
+ import { handleTextCommand } from './discord-commands.js';
16
13
  export async function startDiscordBot(config, botConfig) {
17
14
  const token = process.env.IDLEHANDS_DISCORD_TOKEN || botConfig.token;
18
15
  if (!token) {
@@ -428,6 +425,11 @@ When you escalate, your request will be re-run on a more capable model.`;
428
425
  try {
429
426
  let askComplete = false;
430
427
  let isRetryAfterCompaction = false;
428
+ let isToolLoopRetry = false;
429
+ let toolLoopRetryCount = 0;
430
+ const autoContinueCfg = managed.config.tool_loop_auto_continue;
431
+ const autoContinueEnabled = autoContinueCfg?.enabled !== false;
432
+ const autoContinueMaxRetries = autoContinueCfg?.max_retries ?? 5;
431
433
  while (!askComplete) {
432
434
  // Create a fresh AbortController for each attempt (watchdog compaction aborts the previous one)
433
435
  const attemptController = new AbortController();
@@ -435,7 +437,10 @@ When you escalate, your request will be re-run on a more capable model.`;
435
437
  turn.controller = attemptController;
436
438
  let askText = isRetryAfterCompaction
437
439
  ? 'Continue working on the task from where you left off. Context was compacted to free memory — do NOT restart from the beginning.'
438
- : msg.content;
440
+ : isToolLoopRetry
441
+ ? AUTO_CONTINUE_PROMPT
442
+ : msg.content;
443
+ isToolLoopRetry = false;
439
444
  if (managed.antonActive) {
440
445
  askText = `${askText}\n\n[System Runtime Context: Anton task runner is CURRENTLY ACTIVE and running autonomously in the background for this project.]`;
441
446
  }
@@ -496,6 +501,17 @@ When you escalate, your request will be re-run on a more capable model.`;
496
501
  isRetryAfterCompaction = true;
497
502
  continue;
498
503
  }
504
+ // Auto-continue on tool-loop breaks
505
+ if (!isAbort && isToolLoopBreak(e) && autoContinueEnabled && toolLoopRetryCount < autoContinueMaxRetries) {
506
+ toolLoopRetryCount++;
507
+ const notice = formatAutoContinueNotice(raw, toolLoopRetryCount, autoContinueMaxRetries);
508
+ console.error(`[bot:discord] ${managed.userId} tool-loop auto-continue (retry ${toolLoopRetryCount}/${autoContinueMaxRetries})`);
509
+ if (isTurnActive(managed, turnId)) {
510
+ await streamer.finalizeError(notice);
511
+ }
512
+ isToolLoopRetry = true;
513
+ continue;
514
+ }
499
515
  // If aborted by watchdog after max compaction attempts, it's a real cancel
500
516
  if (!isTurnActive(managed, turnId))
501
517
  return;
@@ -950,601 +966,19 @@ When you escalate, your request will be re-run on a more capable model.`;
950
966
  await sendUserVisible(msg, '⚠️ Too many active sessions. Please retry later.').catch(() => { });
951
967
  return;
952
968
  }
953
- if (content === '/cancel') {
954
- const res = cancelActive(managed);
955
- await sendUserVisible(msg, res.message).catch(() => { });
956
- return;
957
- }
958
- if (content === '/start') {
959
- const agentLine = managed.agentPersona
960
- ? `Agent: **${managed.agentPersona.display_name || managed.agentId}**`
961
- : null;
962
- const lines = [
963
- '🔧 Idle Hands — Local-first coding agent',
964
- '',
965
- ...(agentLine ? [agentLine] : []),
966
- `Model: \`${managed.session.model}\``,
967
- `Endpoint: \`${managed.config.endpoint || '?'}\``,
968
- `Default dir: \`${managed.config.dir || defaultDir}\``,
969
- '',
970
- 'Send me a coding task, or use /help for commands.',
971
- ];
972
- await sendUserVisible(msg, lines.join('\n')).catch(() => { });
973
- return;
974
- }
975
- if (content === '/help') {
976
- const lines = [
977
- 'Commands:',
978
- '/start — Welcome + config summary',
979
- '/help — This message',
980
- '/version — Show version',
981
- '/new — Start a new session',
982
- '/cancel — Abort current generation',
983
- '/status — Session stats',
984
- '/watchdog [status] — Show watchdog settings/status',
985
- '/agent — Show current agent',
986
- '/agents — List all configured agents',
987
- '/escalate [model] — Use larger model for next message',
988
- '/deescalate — Return to base model',
989
- '/dir [path] — Get/set working directory',
990
- '/model — Show current model',
991
- '/approval [mode] — Get/set approval mode',
992
- '/mode [code|sys] — Get/set mode',
993
- '/subagents [on|off] — Toggle sub-agents',
994
- '/compact — Trigger context compaction',
995
- '/changes — Show files modified this session',
996
- '/undo — Undo last edit',
997
- '/vault <query> — Search vault entries',
998
- '/anton <file> — Start autonomous task runner',
999
- '/anton status | /anton stop | /anton last',
1000
- ];
1001
- await sendUserVisible(msg, lines.join('\n')).catch(() => { });
1002
- return;
1003
- }
1004
- if (content === '/model') {
1005
- await sendUserVisible(msg, `Model: \`${managed.session.model}\`\nHarness: \`${managed.session.harness}\``).catch(() => { });
1006
- return;
1007
- }
1008
- if (content === '/version') {
1009
- await sendUserVisible(msg, `Idle Hands v${PKG_VERSION}`).catch(() => { });
1010
- return;
1011
- }
1012
- if (content === '/compact') {
1013
- managed.session.reset();
1014
- await sendUserVisible(msg, '🗜 Session context compacted (reset to system prompt).').catch(() => { });
1015
- return;
1016
- }
1017
- if (content === '/dir' || content.startsWith('/dir ')) {
1018
- const arg = content.slice('/dir'.length).trim();
1019
- if (!arg) {
1020
- const lines = [
1021
- `Working directory: \`${managed.config.dir || defaultDir}\``,
1022
- `Directory pinned: ${managed.dirPinned ? 'yes' : 'no'}`,
1023
- ];
1024
- if (!managed.dirPinned && managed.repoCandidates.length > 1) {
1025
- lines.push('Action required: run `/dir <repo-root>` before file edits.');
1026
- lines.push(`Detected repos: ${managed.repoCandidates
1027
- .slice(0, 5)
1028
- .map((p) => `\`${p}\``)
1029
- .join(', ')}`);
1030
- }
1031
- await sendUserVisible(msg, lines.join('\n')).catch(() => { });
1032
- return;
1033
- }
1034
- const resolvedDir = path.resolve(expandHome(arg));
1035
- if (!isPathAllowed(resolvedDir, managed.allowedDirs)) {
1036
- await sendUserVisible(msg, `❌ Directory not allowed. Allowed roots: ${managed.allowedDirs.map((d) => `\`${d}\``).join(', ')}`).catch(() => { });
1037
- return;
1038
- }
1039
- const repoCandidates = await detectRepoCandidates(resolvedDir, managed.allowedDirs).catch(() => managed.repoCandidates);
1040
- const cfg = {
1041
- ...managed.config,
1042
- dir: resolvedDir,
1043
- allowed_write_roots: managed.allowedDirs,
1044
- dir_pinned: true,
1045
- repo_candidates: repoCandidates,
1046
- };
1047
- await recreateSession(managed, cfg);
1048
- managed.dirPinned = true;
1049
- managed.repoCandidates = repoCandidates;
1050
- await sendUserVisible(msg, `✅ Working directory pinned to \`${resolvedDir}\``).catch(() => { });
1051
- return;
1052
- }
1053
- if (content === '/approval' || content.startsWith('/approval ')) {
1054
- const arg = content.slice('/approval'.length).trim().toLowerCase();
1055
- const modes = ['plan', 'default', 'auto-edit', 'yolo'];
1056
- if (!arg) {
1057
- await sendUserVisible(msg, `Approval mode: \`${managed.config.approval_mode || approvalMode}\`\nOptions: ${modes.join(', ')}`).catch(() => { });
1058
- return;
1059
- }
1060
- if (!modes.includes(arg)) {
1061
- await sendUserVisible(msg, `Invalid mode. Options: ${modes.join(', ')}`).catch(() => { });
1062
- return;
1063
- }
1064
- managed.config.approval_mode = arg;
1065
- managed.config.no_confirm = arg === 'yolo';
1066
- await sendUserVisible(msg, `✅ Approval mode set to \`${arg}\``).catch(() => { });
1067
- return;
1068
- }
1069
- if (content === '/mode' || content.startsWith('/mode ')) {
1070
- const arg = content.slice('/mode'.length).trim().toLowerCase();
1071
- if (!arg) {
1072
- await sendUserVisible(msg, `Mode: \`${managed.config.mode || 'code'}\``).catch(() => { });
1073
- return;
1074
- }
1075
- if (arg !== 'code' && arg !== 'sys') {
1076
- await sendUserVisible(msg, 'Invalid mode. Options: code, sys').catch(() => { });
1077
- return;
1078
- }
1079
- managed.config.mode = arg;
1080
- if (arg === 'sys' && managed.config.approval_mode === 'auto-edit') {
1081
- managed.config.approval_mode = 'default';
1082
- }
1083
- await sendUserVisible(msg, `✅ Mode set to \`${arg}\``).catch(() => { });
1084
- return;
1085
- }
1086
- if (content === '/subagents' || content.startsWith('/subagents ')) {
1087
- const arg = content.slice('/subagents'.length).trim().toLowerCase();
1088
- const current = managed.config.sub_agents?.enabled !== false;
1089
- if (!arg) {
1090
- await sendUserVisible(msg, `Sub-agents: \`${current ? 'on' : 'off'}\`\nUsage: /subagents on | off`).catch(() => { });
1091
- return;
1092
- }
1093
- if (arg !== 'on' && arg !== 'off') {
1094
- await sendUserVisible(msg, 'Invalid value. Usage: /subagents on | off').catch(() => { });
1095
- return;
1096
- }
1097
- const enabled = arg === 'on';
1098
- managed.config.sub_agents = { ...(managed.config.sub_agents ?? {}), enabled };
1099
- await sendUserVisible(msg, `✅ Sub-agents \`${enabled ? 'on' : 'off'}\`${!enabled ? ' — spawn_task disabled for this session' : ''}`).catch(() => { });
1100
- return;
1101
- }
1102
- if (content === '/changes') {
1103
- const replay = managed.session.replay;
1104
- if (!replay) {
1105
- await sendUserVisible(msg, 'Replay is disabled. No change tracking available.').catch(() => { });
1106
- return;
1107
- }
1108
- try {
1109
- const checkpoints = await replay.list(50);
1110
- if (!checkpoints.length) {
1111
- await sendUserVisible(msg, 'No file changes this session.').catch(() => { });
1112
- return;
1113
- }
1114
- const byFile = new Map();
1115
- for (const cp of checkpoints)
1116
- byFile.set(cp.filePath, (byFile.get(cp.filePath) ?? 0) + 1);
1117
- const lines = [`Session changes (${byFile.size} files):`];
1118
- for (const [fp, count] of byFile)
1119
- lines.push(`✎ \`${fp}\` (${count} edit${count > 1 ? 's' : ''})`);
1120
- await sendUserVisible(msg, lines.join('\n')).catch(() => { });
1121
- }
1122
- catch (e) {
1123
- await sendUserVisible(msg, `Error listing changes: ${e?.message ?? String(e)}`).catch(() => { });
1124
- }
1125
- return;
1126
- }
1127
- if (content === '/undo') {
1128
- const lastPath = managed.session.lastEditedPath;
1129
- if (!lastPath) {
1130
- await sendUserVisible(msg, 'No recent edits to undo.').catch(() => { });
1131
- return;
1132
- }
1133
- try {
1134
- const { undo_path } = await import('../tools.js');
1135
- const result = await undo_path({ cwd: managed.config.dir || defaultDir, noConfirm: true, dryRun: false }, { path: lastPath });
1136
- await sendUserVisible(msg, `✅ ${result}`).catch(() => { });
1137
- }
1138
- catch (e) {
1139
- await sendUserVisible(msg, `❌ Undo failed: ${e?.message ?? String(e)}`).catch(() => { });
1140
- }
1141
- return;
1142
- }
1143
- if (content === '/vault' || content.startsWith('/vault ')) {
1144
- const query = content.slice('/vault'.length).trim();
1145
- if (!query) {
1146
- await sendUserVisible(msg, 'Usage: /vault <search query>').catch(() => { });
1147
- return;
1148
- }
1149
- const vault = managed.session.vault;
1150
- if (!vault) {
1151
- await sendUserVisible(msg, 'Vault is disabled.').catch(() => { });
1152
- return;
1153
- }
1154
- try {
1155
- const results = await vault.search(query, 5);
1156
- if (!results.length) {
1157
- await sendUserVisible(msg, `No vault results for "${query}"`).catch(() => { });
1158
- return;
1159
- }
1160
- const lines = [`Vault results for "${query}":`];
1161
- for (const r of results) {
1162
- const title = r.kind === 'note' ? `note:${r.key}` : `tool:${r.tool || r.key || '?'}`;
1163
- const body = (r.value ?? r.snippet ?? r.content ?? '').replace(/\s+/g, ' ').slice(0, 120);
1164
- lines.push(`• ${title}: ${body}`);
1165
- }
1166
- await sendUserVisible(msg, lines.join('\n')).catch(() => { });
1167
- }
1168
- catch (e) {
1169
- await sendUserVisible(msg, `Error searching vault: ${e?.message ?? String(e)}`).catch(() => { });
1170
- }
1171
- return;
1172
- }
1173
- if (content === '/status') {
1174
- const used = managed.session.currentContextTokens;
1175
- const pct = managed.session.contextWindow > 0
1176
- ? Math.min(100, (used / managed.session.contextWindow) * 100).toFixed(1)
1177
- : '?';
1178
- const agentLine = managed.agentPersona
1179
- ? `Agent: ${managed.agentPersona.display_name || managed.agentId}`
1180
- : null;
1181
- await sendUserVisible(msg, [
1182
- ...(agentLine ? [agentLine] : []),
1183
- `Mode: ${managed.config.mode ?? 'code'}`,
1184
- `Approval: ${managed.config.approval_mode}`,
1185
- `Model: ${managed.session.model}`,
1186
- `Harness: ${managed.session.harness}`,
1187
- `Dir: ${managed.config.dir ?? defaultDir}`,
1188
- `Dir pinned: ${managed.dirPinned ? 'yes' : 'no'}`,
1189
- `Context: ~${used}/${managed.session.contextWindow} (${pct}%)`,
1190
- `State: ${managed.state}`,
1191
- `Queue: ${managed.pendingQueue.length}/${maxQueue}`,
1192
- ].join('\n')).catch(() => { });
1193
- return;
1194
- }
1195
- if (content === '/watchdog' || content === '/watchdog status') {
1196
- await sendUserVisible(msg, watchdogStatusText(managed)).catch(() => { });
1197
- return;
1198
- }
1199
- if (content.startsWith('/watchdog ')) {
1200
- await sendUserVisible(msg, 'Usage: /watchdog or /watchdog status').catch(() => { });
1201
- return;
1202
- }
1203
- // /agent - show current agent info
1204
- if (content === '/agent') {
1205
- if (!managed.agentPersona) {
1206
- await sendUserVisible(msg, 'No agent configured. Using global config.').catch(() => { });
1207
- return;
1208
- }
1209
- const p = managed.agentPersona;
1210
- const lines = [
1211
- `**Agent: ${p.display_name || managed.agentId}** (\`${managed.agentId}\`)`,
1212
- ...(p.model ? [`Model: \`${p.model}\``] : []),
1213
- ...(p.endpoint ? [`Endpoint: \`${p.endpoint}\``] : []),
1214
- ...(p.approval_mode ? [`Approval: \`${p.approval_mode}\``] : []),
1215
- ...(p.default_dir ? [`Default dir: \`${p.default_dir}\``] : []),
1216
- ...(p.allowed_dirs?.length
1217
- ? [`Allowed dirs: ${p.allowed_dirs.map((d) => `\`${d}\``).join(', ')}`]
1218
- : []),
1219
- ];
1220
- await sendUserVisible(msg, lines.join('\n')).catch(() => { });
1221
- return;
1222
- }
1223
- // /agents - list all configured agents
1224
- if (content === '/agents') {
1225
- const agents = botConfig.agents;
1226
- if (!agents || Object.keys(agents).length === 0) {
1227
- await sendUserVisible(msg, 'No agents configured. Using global config.').catch(() => { });
1228
- return;
1229
- }
1230
- const lines = ['**Configured Agents:**'];
1231
- for (const [id, p] of Object.entries(agents)) {
1232
- const current = id === managed.agentId ? ' ← current' : '';
1233
- const model = p.model ? ` (${p.model})` : '';
1234
- lines.push(`• **${p.display_name || id}** (\`${id}\`)${model}${current}`);
1235
- }
1236
- // Show routing rules
1237
- const routing = botConfig.routing;
1238
- if (routing) {
1239
- lines.push('', '**Routing:**');
1240
- if (routing.default)
1241
- lines.push(`Default: \`${routing.default}\``);
1242
- if (routing.users && Object.keys(routing.users).length > 0) {
1243
- lines.push(`Users: ${Object.entries(routing.users)
1244
- .map(([u, a]) => `${u}→${a}`)
1245
- .join(', ')}`);
1246
- }
1247
- if (routing.channels && Object.keys(routing.channels).length > 0) {
1248
- lines.push(`Channels: ${Object.entries(routing.channels)
1249
- .map(([c, a]) => `${c}→${a}`)
1250
- .join(', ')}`);
1251
- }
1252
- if (routing.guilds && Object.keys(routing.guilds).length > 0) {
1253
- lines.push(`Guilds: ${Object.entries(routing.guilds)
1254
- .map(([g, a]) => `${g}→${a}`)
1255
- .join(', ')}`);
1256
- }
1257
- }
1258
- await sendUserVisible(msg, lines.join('\n')).catch(() => { });
1259
- return;
1260
- }
1261
- // /escalate - explicitly escalate to a larger model for next message
1262
- if (content === '/escalate' || content.startsWith('/escalate ')) {
1263
- const escalation = managed.agentPersona?.escalation;
1264
- if (!escalation || !escalation.models?.length) {
1265
- await sendUserVisible(msg, '❌ No escalation models configured for this agent.').catch(() => { });
1266
- return;
1267
- }
1268
- const arg = content.slice('/escalate'.length).trim();
1269
- // No arg: show available models and current state
1270
- if (!arg) {
1271
- const currentModel = managed.config.model || config.model || 'default';
1272
- const lines = [
1273
- `**Current model:** \`${currentModel}\``,
1274
- `**Escalation models:** ${escalation.models.map((m) => `\`${m}\``).join(', ')}`,
1275
- '',
1276
- 'Usage: `/escalate <model>` or `/escalate next`',
1277
- 'Then send your message - it will use the escalated model.',
1278
- ];
1279
- if (managed.pendingEscalation) {
1280
- lines.push('', `⚡ **Pending escalation:** \`${managed.pendingEscalation}\` (next message will use this)`);
1281
- }
1282
- await sendUserVisible(msg, lines.join('\n')).catch(() => { });
1283
- return;
1284
- }
1285
- // Handle 'next' - escalate to next model in chain
1286
- let targetModel;
1287
- if (arg.toLowerCase() === 'next') {
1288
- const nextIndex = Math.min(managed.currentModelIndex, escalation.models.length - 1);
1289
- targetModel = escalation.models[nextIndex];
1290
- }
1291
- else {
1292
- // Specific model requested
1293
- if (!escalation.models.includes(arg)) {
1294
- await sendUserVisible(msg, `❌ Model \`${arg}\` not in escalation chain. Available: ${escalation.models.map((m) => `\`${m}\``).join(', ')}`).catch(() => { });
1295
- return;
1296
- }
1297
- targetModel = arg;
1298
- }
1299
- managed.pendingEscalation = targetModel;
1300
- await sendUserVisible(msg, `⚡ Next message will use \`${targetModel}\`. Send your request now.`).catch(() => { });
1301
- return;
1302
- }
1303
- // /deescalate - return to base model
1304
- if (content === '/deescalate') {
1305
- if (managed.currentModelIndex === 0 && !managed.pendingEscalation) {
1306
- await sendUserVisible(msg, 'Already using base model.').catch(() => { });
1307
- return;
1308
- }
1309
- const baseModel = managed.agentPersona?.model || config.model || 'default';
1310
- managed.pendingEscalation = null;
1311
- managed.currentModelIndex = 0;
1312
- // Recreate session with base model
1313
- const cfg = {
1314
- ...managed.config,
1315
- model: baseModel,
1316
- };
1317
- await recreateSession(managed, cfg);
1318
- await sendUserVisible(msg, `✅ Returned to base model: \`${baseModel}\``).catch(() => { });
1319
- return;
1320
- }
1321
- // /git_status - show git status for working directory
1322
- if (content === '/git_status') {
1323
- const cwd = managed.config.dir || defaultDir;
1324
- if (!cwd) {
1325
- await sendUserVisible(msg, 'No working directory set. Use `/dir` to set one.').catch(() => { });
1326
- return;
1327
- }
1328
- try {
1329
- const { spawnSync } = await import('node:child_process');
1330
- // Run git status -s
1331
- const statusResult = spawnSync('git', ['status', '-s'], {
1332
- cwd,
1333
- encoding: 'utf8',
1334
- timeout: 5000,
1335
- });
1336
- if (statusResult.status !== 0) {
1337
- const err = String(statusResult.stderr || statusResult.error || 'Unknown error');
1338
- if (err.includes('not a git repository') || err.includes('not in a git')) {
1339
- await sendUserVisible(msg, '❌ Not a git repository.').catch(() => { });
1340
- }
1341
- else {
1342
- await sendUserVisible(msg, `❌ git status failed: ${err.slice(0, 200)}`).catch(() => { });
1343
- }
1344
- return;
1345
- }
1346
- const statusOut = String(statusResult.stdout || '').trim();
1347
- // Get branch info
1348
- const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
1349
- cwd,
1350
- encoding: 'utf8',
1351
- timeout: 2000,
1352
- });
1353
- const branch = branchResult.status === 0 ? String(branchResult.stdout || '').trim() : 'unknown';
1354
- if (!statusOut) {
1355
- await sendUserVisible(msg, `📁 \`${cwd}\`\n🌿 Branch: \`${branch}\`\n\n✅ Working tree clean`).catch(() => { });
1356
- return;
1357
- }
1358
- const lines = statusOut.split('\n').slice(0, 30);
1359
- const truncated = statusOut.split('\n').length > 30;
1360
- const formatted = lines
1361
- .map((line) => `\`${line.slice(0, 2)}\` ${line.slice(3)}`)
1362
- .join('\n');
1363
- await sendUserVisible(msg, `📁 \`${cwd}\`\n🌿 Branch: \`${branch}\`\n\n\`\`\`\n${formatted}${truncated ? '\n...' : ''}\`\`\``).catch(() => { });
1364
- }
1365
- catch (e) {
1366
- await sendUserVisible(msg, `❌ git status failed: ${e?.message ?? String(e)}`).catch(() => { });
1367
- }
1368
- return;
1369
- }
1370
- if (content === '/hosts') {
1371
- try {
1372
- const { loadRuntimes, redactConfig } = await import('../runtime/store.js');
1373
- const config = await loadRuntimes();
1374
- const redacted = redactConfig(config);
1375
- if (!redacted.hosts.length) {
1376
- await sendUserVisible(msg, 'No hosts configured. Use `idlehands hosts add` in CLI.').catch(() => { });
1377
- return;
1378
- }
1379
- const lines = redacted.hosts.map((h) => `${h.enabled ? '🟢' : '🔴'} ${h.display_name} (\`${h.id}\`)\n Transport: ${h.transport}`);
1380
- const chunks = splitDiscord(lines.join('\n\n'));
1381
- for (const [i, chunk] of chunks.entries()) {
1382
- if (i === 0)
1383
- await sendUserVisible(msg, chunk).catch(() => { });
1384
- else
1385
- await msg.channel.send(chunk).catch(() => { });
1386
- }
1387
- }
1388
- catch (e) {
1389
- await sendUserVisible(msg, `❌ Failed to load hosts: ${e?.message ?? String(e)}`).catch(() => { });
1390
- }
1391
- return;
1392
- }
1393
- if (content === '/backends') {
1394
- try {
1395
- const { loadRuntimes, redactConfig } = await import('../runtime/store.js');
1396
- const config = await loadRuntimes();
1397
- const redacted = redactConfig(config);
1398
- if (!redacted.backends.length) {
1399
- await sendUserVisible(msg, 'No backends configured. Use `idlehands backends add` in CLI.').catch(() => { });
1400
- return;
1401
- }
1402
- const lines = redacted.backends.map((b) => `${b.enabled ? '🟢' : '🔴'} ${b.display_name} (\`${b.id}\`)\n Type: ${b.type}`);
1403
- const chunks = splitDiscord(lines.join('\n\n'));
1404
- for (const [i, chunk] of chunks.entries()) {
1405
- if (i === 0)
1406
- await sendUserVisible(msg, chunk).catch(() => { });
1407
- else
1408
- await msg.channel.send(chunk).catch(() => { });
1409
- }
1410
- }
1411
- catch (e) {
1412
- await sendUserVisible(msg, `❌ Failed to load backends: ${e?.message ?? String(e)}`).catch(() => { });
1413
- }
1414
- return;
1415
- }
1416
- if (content === '/models' || content === '/rtmodels') {
1417
- try {
1418
- const { loadRuntimes } = await import('../runtime/store.js');
1419
- const config = await loadRuntimes();
1420
- if (!config.models.length) {
1421
- await sendUserVisible(msg, 'No runtime models configured.').catch(() => { });
1422
- return;
1423
- }
1424
- const enabledModels = config.models.filter((m) => m.enabled);
1425
- if (!enabledModels.length) {
1426
- await sendUserVisible(msg, 'No enabled runtime models. Use `idlehands models enable <id>` in CLI.').catch(() => { });
1427
- return;
1428
- }
1429
- // Create buttons for model selection (Discord max 5 buttons per row, 5 rows)
1430
- const { ActionRowBuilder, ButtonBuilder, ButtonStyle } = await import('discord.js');
1431
- const rows = [];
1432
- let currentRow = new ActionRowBuilder();
1433
- for (const m of enabledModels) {
1434
- const btn = new ButtonBuilder()
1435
- .setCustomId(`model_switch:${m.id}`)
1436
- .setLabel(m.display_name.slice(0, 80))
1437
- .setStyle(ButtonStyle.Primary);
1438
- currentRow.addComponents(btn);
1439
- if (currentRow.components.length >= 5) {
1440
- rows.push(currentRow);
1441
- currentRow = new ActionRowBuilder();
1442
- }
1443
- }
1444
- if (currentRow.components.length > 0) {
1445
- rows.push(currentRow);
1446
- }
1447
- await msg.channel.send({
1448
- content: '📋 **Select a model to switch to:**',
1449
- components: rows.slice(0, 5), // Discord max 5 rows
1450
- }).catch(() => { });
1451
- }
1452
- catch (e) {
1453
- await sendUserVisible(msg, `❌ Failed to load runtime models: ${e?.message ?? String(e)}`).catch(() => { });
1454
- }
1455
- return;
1456
- }
1457
- if (content === '/rtstatus') {
1458
- try {
1459
- const { loadActiveRuntime } = await import('../runtime/executor.js');
1460
- const active = await loadActiveRuntime();
1461
- if (!active) {
1462
- await sendUserVisible(msg, 'No active runtime.').catch(() => { });
1463
- return;
1464
- }
1465
- const lines = [
1466
- 'Active Runtime',
1467
- `Model: \`${active.modelId}\``,
1468
- `Backend: \`${active.backendId ?? 'none'}\``,
1469
- `Hosts: ${active.hostIds.map((id) => `\`${id}\``).join(', ') || 'none'}`,
1470
- `Healthy: ${active.healthy ? '✅ yes' : '❌ no'}`,
1471
- `Endpoint: \`${active.endpoint ?? 'unknown'}\``,
1472
- `Started: \`${active.startedAt}\``,
1473
- ];
1474
- const chunks = splitDiscord(lines.join('\n'));
1475
- for (const [i, chunk] of chunks.entries()) {
1476
- if (i === 0)
1477
- await sendUserVisible(msg, chunk).catch(() => { });
1478
- else
1479
- await msg.channel.send(chunk).catch(() => { });
1480
- }
1481
- }
1482
- catch (e) {
1483
- await sendUserVisible(msg, `❌ Failed to read runtime status: ${e?.message ?? String(e)}`).catch(() => { });
1484
- }
1485
- return;
1486
- }
1487
- if (content === '/switch' || content.startsWith('/switch ')) {
1488
- try {
1489
- const modelId = content.slice('/switch'.length).trim();
1490
- if (!modelId) {
1491
- await sendUserVisible(msg, 'Usage: /switch <model-id>').catch(() => { });
1492
- return;
1493
- }
1494
- const { plan } = await import('../runtime/planner.js');
1495
- const { execute, loadActiveRuntime } = await import('../runtime/executor.js');
1496
- const { loadRuntimes } = await import('../runtime/store.js');
1497
- const rtConfig = await loadRuntimes();
1498
- const active = await loadActiveRuntime();
1499
- const result = plan({ modelId, mode: 'live' }, rtConfig, active);
1500
- if (!result.ok) {
1501
- await sendUserVisible(msg, `❌ Plan failed: ${result.reason}`).catch(() => { });
1502
- return;
1503
- }
1504
- if (result.reuse) {
1505
- await sendUserVisible(msg, '✅ Runtime already active and healthy.').catch(() => { });
1506
- return;
1507
- }
1508
- const statusMsg = await sendUserVisible(msg, `⏳ Switching to \`${result.model.display_name}\`...`).catch(() => null);
1509
- const execResult = await execute(result, {
1510
- onStep: async (step, status) => {
1511
- if (status === 'done' && statusMsg) {
1512
- await statusMsg.edit(`⏳ ${step.description}... ✓`).catch(() => { });
1513
- }
1514
- },
1515
- confirm: async (prompt) => {
1516
- await sendUserVisible(msg, `⚠️ ${prompt}\nAuto-approving for bot context.`).catch(() => { });
1517
- return true;
1518
- },
1519
- });
1520
- if (execResult.ok) {
1521
- if (statusMsg) {
1522
- await statusMsg.edit(`✅ Switched to \`${result.model.display_name}\``).catch(() => { });
1523
- }
1524
- else {
1525
- await sendUserVisible(msg, `✅ Switched to \`${result.model.display_name}\``).catch(() => { });
1526
- }
1527
- }
1528
- else {
1529
- const err = `❌ Switch failed: ${execResult.error || 'unknown error'}`;
1530
- if (statusMsg) {
1531
- await statusMsg.edit(err).catch(() => { });
1532
- }
1533
- else {
1534
- await sendUserVisible(msg, err).catch(() => { });
1535
- }
1536
- }
1537
- }
1538
- catch (e) {
1539
- await sendUserVisible(msg, `❌ Switch failed: ${e?.message ?? String(e)}`).catch(() => { });
1540
- }
1541
- return;
1542
- }
1543
- // /anton command
1544
- if (content === '/anton' || content.startsWith('/anton ')) {
1545
- await handleDiscordAnton(managed, msg, content);
969
+ const cmdCtx = {
970
+ sendUserVisible,
971
+ cancelActive,
972
+ recreateSession,
973
+ watchdogStatusText,
974
+ defaultDir,
975
+ config,
976
+ botConfig,
977
+ approvalMode,
978
+ maxQueue,
979
+ };
980
+ if (await handleTextCommand(managed, msg, content, cmdCtx))
1546
981
  return;
1547
- }
1548
982
  if (managed.inFlight) {
1549
983
  if (managed.pendingQueue.length >= maxQueue) {
1550
984
  await sendUserVisible(msg, `⏳ Queue full (${managed.pendingQueue.length}/${maxQueue}). Use /cancel.`).catch(() => { });
@@ -1564,193 +998,6 @@ When you escalate, your request will be re-run on a more capable model.`;
1564
998
  await sendUserVisible(msg, `⚠️ Bot error: ${errMsg.length > 300 ? errMsg.slice(0, 297) + '...' : errMsg}`).catch(() => { });
1565
999
  });
1566
1000
  });
1567
- const DISCORD_RATE_LIMIT_MS = 15_000;
1568
- async function handleDiscordAnton(managed, msg, content) {
1569
- const args = content.replace(/^\/anton\s*/, '').trim();
1570
- const sub = firstToken(args);
1571
- if (!sub || sub === 'status') {
1572
- if (!managed.antonActive) {
1573
- await sendUserVisible(msg, 'No Anton run in progress.').catch(() => { });
1574
- }
1575
- else if (managed.antonAbortSignal?.aborted) {
1576
- await sendUserVisible(msg, '🛑 Anton is stopping. Please wait for the current attempt to unwind.').catch(() => { });
1577
- }
1578
- else if (managed.antonProgress) {
1579
- const line1 = formatProgressBar(managed.antonProgress);
1580
- if (managed.antonProgress.currentTask) {
1581
- await sendUserVisible(msg, `${line1}\n\n**Working on:** *${managed.antonProgress.currentTask}* (Attempt ${managed.antonProgress.currentAttempt})`).catch(() => { });
1582
- }
1583
- else {
1584
- await sendUserVisible(msg, line1).catch(() => { });
1585
- }
1586
- }
1587
- else {
1588
- await sendUserVisible(msg, '🤖 Anton is running (no progress data yet).').catch(() => { });
1589
- }
1590
- return;
1591
- }
1592
- if (sub === 'stop') {
1593
- if (!managed.antonActive || !managed.antonAbortSignal) {
1594
- await sendUserVisible(msg, 'No Anton run in progress.').catch(() => { });
1595
- return;
1596
- }
1597
- managed.lastActivity = Date.now();
1598
- managed.antonAbortSignal.aborted = true;
1599
- await sendUserVisible(msg, '🛑 Anton stop requested.').catch(() => { });
1600
- return;
1601
- }
1602
- if (sub === 'last') {
1603
- if (!managed.antonLastResult) {
1604
- await sendUserVisible(msg, 'No previous Anton run.').catch(() => { });
1605
- return;
1606
- }
1607
- await sendUserVisible(msg, formatRunSummary(managed.antonLastResult)).catch(() => { });
1608
- return;
1609
- }
1610
- const filePart = sub === 'run' ? args.replace(/^\S+\s*/, '').trim() : args;
1611
- if (!filePart) {
1612
- await sendUserVisible(msg, '/anton <file> — start | /anton status | /anton stop | /anton last').catch(() => { });
1613
- return;
1614
- }
1615
- if (managed.antonActive) {
1616
- const staleMs = Date.now() - managed.lastActivity;
1617
- if (staleMs > 120_000) {
1618
- managed.antonActive = false;
1619
- managed.antonAbortSignal = null;
1620
- managed.antonProgress = null;
1621
- await sendUserVisible(msg, '♻️ Recovered stale Anton run state. Starting a fresh run...').catch(() => { });
1622
- }
1623
- else {
1624
- const runningMsg = managed.antonAbortSignal?.aborted
1625
- ? '🛑 Anton is still stopping. Please wait a moment, then try again.'
1626
- : '⚠️ Anton is already running. Use /anton stop first.';
1627
- await sendUserVisible(msg, runningMsg).catch(() => { });
1628
- return;
1629
- }
1630
- }
1631
- const cwd = managed.config.dir || process.cwd();
1632
- const filePath = path.resolve(cwd, filePart);
1633
- try {
1634
- await fs.stat(filePath);
1635
- }
1636
- catch {
1637
- await sendUserVisible(msg, `File not found: ${filePath}`).catch(() => { });
1638
- return;
1639
- }
1640
- const defaults = managed.config.anton || {};
1641
- const runConfig = {
1642
- taskFile: filePath,
1643
- projectDir: defaults.project_dir || cwd,
1644
- maxRetriesPerTask: defaults.max_retries ?? 3,
1645
- maxIterations: defaults.max_iterations ?? 200,
1646
- taskMaxIterations: defaults.task_max_iterations ?? 50,
1647
- taskTimeoutSec: defaults.task_timeout_sec ?? 600,
1648
- totalTimeoutSec: defaults.total_timeout_sec ?? 7200,
1649
- maxTotalTokens: defaults.max_total_tokens ?? Infinity,
1650
- maxPromptTokensPerAttempt: defaults.max_prompt_tokens_per_attempt ?? 128_000,
1651
- autoCommit: defaults.auto_commit ?? true,
1652
- branch: false,
1653
- allowDirty: false,
1654
- aggressiveCleanOnFail: false,
1655
- verifyAi: defaults.verify_ai ?? true,
1656
- verifyModel: undefined,
1657
- decompose: defaults.decompose ?? true,
1658
- maxDecomposeDepth: defaults.max_decompose_depth ?? 2,
1659
- maxTotalTasks: defaults.max_total_tasks ?? 500,
1660
- buildCommand: defaults.build_command ?? undefined,
1661
- testCommand: defaults.test_command ?? undefined,
1662
- lintCommand: defaults.lint_command ?? undefined,
1663
- skipOnFail: defaults.skip_on_fail ?? false,
1664
- skipOnBlocked: defaults.skip_on_blocked ?? true,
1665
- rollbackOnFail: defaults.rollback_on_fail ?? false,
1666
- maxIdenticalFailures: defaults.max_identical_failures ?? 5,
1667
- approvalMode: (defaults.approval_mode ?? 'yolo'),
1668
- verbose: false,
1669
- dryRun: false,
1670
- };
1671
- const abortSignal = { aborted: false };
1672
- managed.antonActive = true;
1673
- managed.antonAbortSignal = abortSignal;
1674
- managed.antonProgress = null;
1675
- let lastProgressAt = 0;
1676
- const channel = msg.channel;
1677
- const progress = {
1678
- onTaskStart(task, attempt, prog) {
1679
- managed.antonProgress = prog;
1680
- managed.lastActivity = Date.now();
1681
- const now = Date.now();
1682
- if (now - lastProgressAt >= DISCORD_RATE_LIMIT_MS) {
1683
- lastProgressAt = now;
1684
- channel.send(formatTaskStart(task, attempt, prog)).catch(() => { });
1685
- }
1686
- },
1687
- onTaskEnd(task, result, prog) {
1688
- managed.antonProgress = prog;
1689
- managed.lastActivity = Date.now();
1690
- const now = Date.now();
1691
- if (now - lastProgressAt >= DISCORD_RATE_LIMIT_MS) {
1692
- lastProgressAt = now;
1693
- channel.send(formatTaskEnd(task, result, prog)).catch(() => { });
1694
- }
1695
- },
1696
- onTaskSkip(task, reason) {
1697
- managed.lastActivity = Date.now();
1698
- channel.send(formatTaskSkip(task, reason)).catch(() => { });
1699
- },
1700
- onRunComplete(result) {
1701
- managed.lastActivity = Date.now();
1702
- managed.antonLastResult = result;
1703
- managed.antonActive = false;
1704
- managed.antonAbortSignal = null;
1705
- managed.antonProgress = null;
1706
- channel.send(formatRunSummary(result)).catch(() => { });
1707
- },
1708
- onHeartbeat() {
1709
- managed.lastActivity = Date.now();
1710
- },
1711
- onToolLoop(taskText, event) {
1712
- managed.lastActivity = Date.now();
1713
- if (defaults.progress_events !== false) {
1714
- channel.send(formatToolLoopEvent(taskText, event)).catch(() => { });
1715
- }
1716
- },
1717
- onCompaction(taskText, event) {
1718
- managed.lastActivity = Date.now();
1719
- // Only send for significant compactions to avoid noise
1720
- if (defaults.progress_events !== false && event.droppedMessages >= 5) {
1721
- channel.send(formatCompactionEvent(taskText, event)).catch(() => { });
1722
- }
1723
- },
1724
- onVerification(taskText, verification) {
1725
- managed.lastActivity = Date.now();
1726
- // Only send for failures — successes are already reported in onTaskEnd
1727
- if (defaults.progress_events !== false && !verification.passed) {
1728
- channel.send(formatVerificationDetail(taskText, verification)).catch(() => { });
1729
- }
1730
- },
1731
- };
1732
- let pendingCount = 0;
1733
- try {
1734
- const tf = await parseTaskFile(filePath);
1735
- pendingCount = tf.pending.length;
1736
- }
1737
- catch { }
1738
- await sendUserVisible(msg, `🤖 Anton started on ${filePart} (${pendingCount} tasks pending)`).catch(() => { });
1739
- runAnton({
1740
- config: runConfig,
1741
- idlehandsConfig: managed.config,
1742
- progress,
1743
- abortSignal,
1744
- vault: managed.session.vault,
1745
- lens: managed.session.lens,
1746
- }).catch((err) => {
1747
- managed.lastActivity = Date.now();
1748
- managed.antonActive = false;
1749
- managed.antonAbortSignal = null;
1750
- managed.antonProgress = null;
1751
- channel.send(`Anton error: ${err.message}`).catch(() => { });
1752
- });
1753
- }
1754
1001
  const shutdown = async () => {
1755
1002
  clearInterval(cleanupTimer);
1756
1003
  for (const key of sessions.keys())