@visorcraft/idlehands 1.0.7 → 1.0.9

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,4 +1,4 @@
1
- import { Client, Events, GatewayIntentBits, Partials, } from 'discord.js';
1
+ import { Client, Events, GatewayIntentBits, Partials, REST, Routes, SlashCommandBuilder, } from 'discord.js';
2
2
  import { createSession } from '../agent.js';
3
3
  import { DiscordConfirmProvider } from './confirm-discord.js';
4
4
  import { sanitizeBotOutputText } from './format.js';
@@ -40,13 +40,150 @@ function safeContent(text) {
40
40
  const t = sanitizeBotOutputText(text).trim();
41
41
  return t.length ? t : '(empty response)';
42
42
  }
43
- function sessionKeyForMessage(msg, allowGuilds) {
43
+ /**
44
+ * Check if the model response contains an escalation request.
45
+ * Returns { escalate: true, reason: string } if escalation marker found at start of response.
46
+ */
47
+ function detectEscalation(text) {
48
+ const trimmed = text.trim();
49
+ const match = trimmed.match(/^\[ESCALATE:\s*([^\]]+)\]/i);
50
+ if (match) {
51
+ return { escalate: true, reason: match[1].trim() };
52
+ }
53
+ return { escalate: false };
54
+ }
55
+ /** Keyword presets for common escalation triggers */
56
+ const KEYWORD_PRESETS = {
57
+ coding: ['build', 'implement', 'create', 'develop', 'architect', 'refactor', 'debug', 'fix', 'code', 'program', 'write'],
58
+ planning: ['plan', 'design', 'roadmap', 'strategy', 'analyze', 'research', 'evaluate', 'compare'],
59
+ complex: ['full', 'complete', 'comprehensive', 'multi-step', 'integrate', 'migration', 'overhaul', 'entire', 'whole'],
60
+ };
61
+ /**
62
+ * Check if text matches a set of keywords.
63
+ * Returns matched keywords or empty array if none match.
64
+ */
65
+ function matchKeywords(text, keywords, presets) {
66
+ const allKeywords = [...keywords];
67
+ // Add preset keywords
68
+ if (presets) {
69
+ for (const preset of presets) {
70
+ const presetWords = KEYWORD_PRESETS[preset];
71
+ if (presetWords)
72
+ allKeywords.push(...presetWords);
73
+ }
74
+ }
75
+ if (allKeywords.length === 0)
76
+ return [];
77
+ const lowerText = text.toLowerCase();
78
+ const matched = [];
79
+ for (const kw of allKeywords) {
80
+ if (kw.startsWith('re:')) {
81
+ // Regex pattern
82
+ try {
83
+ const regex = new RegExp(kw.slice(3), 'i');
84
+ if (regex.test(text))
85
+ matched.push(kw);
86
+ }
87
+ catch {
88
+ // Invalid regex, skip
89
+ }
90
+ }
91
+ else {
92
+ // Word boundary match (case-insensitive)
93
+ const wordRegex = new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
94
+ if (wordRegex.test(lowerText))
95
+ matched.push(kw);
96
+ }
97
+ }
98
+ return matched;
99
+ }
100
+ /**
101
+ * Check if user message matches keyword escalation triggers.
102
+ * Returns { escalate: true, tier: number, reason: string } if keywords match.
103
+ * Tier indicates which model index to escalate to (highest matching tier wins).
104
+ */
105
+ function checkKeywordEscalation(text, escalation) {
106
+ if (!escalation)
107
+ return { escalate: false };
108
+ // Tiered keyword escalation
109
+ if (escalation.tiers && escalation.tiers.length > 0) {
110
+ let highestTier = -1;
111
+ let highestReason = '';
112
+ // Check each tier, highest matching tier wins
113
+ for (let i = 0; i < escalation.tiers.length; i++) {
114
+ const tier = escalation.tiers[i];
115
+ const matched = matchKeywords(text, tier.keywords || [], tier.keyword_presets);
116
+ if (matched.length > 0 && i > highestTier) {
117
+ highestTier = i;
118
+ highestReason = `tier ${i} keyword match: ${matched.slice(0, 3).join(', ')}${matched.length > 3 ? '...' : ''}`;
119
+ }
120
+ }
121
+ if (highestTier >= 0) {
122
+ return { escalate: true, tier: highestTier, reason: highestReason };
123
+ }
124
+ return { escalate: false };
125
+ }
126
+ // Legacy flat keywords (treated as tier 0)
127
+ const matched = matchKeywords(text, escalation.keywords || [], escalation.keyword_presets);
128
+ if (matched.length > 0) {
129
+ return {
130
+ escalate: true,
131
+ tier: 0,
132
+ reason: `keyword match: ${matched.slice(0, 3).join(', ')}${matched.length > 3 ? '...' : ''}`
133
+ };
134
+ }
135
+ return { escalate: false };
136
+ }
137
+ /**
138
+ * Resolve which agent persona should handle a message.
139
+ * Priority: user > channel > guild > default > first agent > null
140
+ */
141
+ function resolveAgentForMessage(msg, agents, routing) {
142
+ const agentMap = agents ?? {};
143
+ const agentIds = Object.keys(agentMap);
144
+ // No agents configured — return null persona (use global config)
145
+ if (agentIds.length === 0) {
146
+ return { agentId: '_default', persona: null };
147
+ }
148
+ const route = routing ?? {};
149
+ let resolvedId;
150
+ // Priority 1: User-specific routing
151
+ if (route.users && route.users[msg.author.id]) {
152
+ resolvedId = route.users[msg.author.id];
153
+ }
154
+ // Priority 2: Channel-specific routing
155
+ else if (route.channels && route.channels[msg.channelId]) {
156
+ resolvedId = route.channels[msg.channelId];
157
+ }
158
+ // Priority 3: Guild-specific routing
159
+ else if (msg.guildId && route.guilds && route.guilds[msg.guildId]) {
160
+ resolvedId = route.guilds[msg.guildId];
161
+ }
162
+ // Priority 4: Default agent
163
+ else if (route.default) {
164
+ resolvedId = route.default;
165
+ }
166
+ // Priority 5: First defined agent
167
+ else {
168
+ resolvedId = agentIds[0];
169
+ }
170
+ // Validate the resolved agent exists
171
+ const persona = agentMap[resolvedId];
172
+ if (!persona) {
173
+ // Fallback to first agent if routing points to non-existent agent
174
+ const fallbackId = agentIds[0];
175
+ return { agentId: fallbackId, persona: agentMap[fallbackId] ?? null };
176
+ }
177
+ return { agentId: resolvedId, persona };
178
+ }
179
+ function sessionKeyForMessage(msg, allowGuilds, agentId) {
180
+ // Include agentId in session key so switching agents creates a new session
44
181
  if (allowGuilds) {
45
- // Per-channel+user session in guilds so multiple users can safely coexist.
46
- return `${msg.channelId}:${msg.author.id}`;
182
+ // Per-agent+channel+user session in guilds
183
+ return `${agentId}:${msg.channelId}:${msg.author.id}`;
47
184
  }
48
- // DM-only mode uses user id as session key.
49
- return msg.author.id;
185
+ // DM-only mode: per-agent+user session
186
+ return `${agentId}:${msg.author.id}`;
50
187
  }
51
188
  export async function startDiscordBot(config, botConfig) {
52
189
  const token = process.env.IDLEHANDS_DISCORD_TOKEN || botConfig.token;
@@ -83,7 +220,9 @@ export async function startDiscordBot(config, botConfig) {
83
220
  return await msg.channel.send(content);
84
221
  };
85
222
  async function getOrCreate(msg) {
86
- const key = sessionKeyForMessage(msg, allowGuilds);
223
+ // Resolve which agent should handle this message
224
+ const { agentId, persona } = resolveAgentForMessage(msg, botConfig.agents, botConfig.routing);
225
+ const key = sessionKeyForMessage(msg, allowGuilds, agentId);
87
226
  const existing = sessions.get(key);
88
227
  if (existing) {
89
228
  existing.lastActivity = Date.now();
@@ -92,11 +231,44 @@ export async function startDiscordBot(config, botConfig) {
92
231
  if (sessions.size >= maxSessions) {
93
232
  return null;
94
233
  }
234
+ // Build config with agent-specific overrides
235
+ const agentDir = persona?.default_dir || persona?.allowed_dirs?.[0] || defaultDir;
236
+ const agentApproval = persona?.approval_mode
237
+ ? normalizeApprovalMode(persona.approval_mode, approvalMode)
238
+ : approvalMode;
239
+ // Build system prompt with escalation instructions if configured
240
+ let systemPrompt = persona?.system_prompt;
241
+ if (persona?.escalation?.models?.length && persona?.escalation?.auto !== false) {
242
+ const escalationModels = persona.escalation.models.join(', ');
243
+ const escalationInstructions = `
244
+
245
+ [AUTO-ESCALATION]
246
+ You have access to more powerful models when needed: ${escalationModels}
247
+ If you encounter a task that is too complex, requires deeper reasoning, or you're struggling to solve,
248
+ you can escalate by including this exact marker at the START of your response:
249
+ [ESCALATE: brief reason]
250
+
251
+ Examples:
252
+ - [ESCALATE: complex algorithm requiring multi-step reasoning]
253
+ - [ESCALATE: need larger context window for this codebase analysis]
254
+ - [ESCALATE: struggling with this optimization problem]
255
+
256
+ Only escalate when genuinely needed. Most tasks should be handled by your current model.
257
+ When you escalate, your request will be re-run on a more capable model.`;
258
+ systemPrompt = (systemPrompt || '') + escalationInstructions;
259
+ }
95
260
  const cfg = {
96
261
  ...config,
97
- dir: defaultDir,
98
- approval_mode: approvalMode,
99
- no_confirm: approvalMode === 'yolo',
262
+ dir: agentDir,
263
+ approval_mode: agentApproval,
264
+ no_confirm: agentApproval === 'yolo',
265
+ // Agent-specific overrides
266
+ ...(persona?.model && { model: persona.model }),
267
+ ...(persona?.endpoint && { endpoint: persona.endpoint }),
268
+ ...(systemPrompt && { system_prompt_override: systemPrompt }),
269
+ ...(persona?.max_tokens && { max_tokens: persona.max_tokens }),
270
+ ...(persona?.temperature !== undefined && { temperature: persona.temperature }),
271
+ ...(persona?.top_p !== undefined && { top_p: persona.top_p }),
100
272
  };
101
273
  const confirmProvider = new DiscordConfirmProvider(msg.channel, msg.author.id, botConfig.confirm_timeout_sec ?? 300);
102
274
  const session = await createSession({
@@ -107,6 +279,8 @@ export async function startDiscordBot(config, botConfig) {
107
279
  const managed = {
108
280
  key,
109
281
  userId: msg.author.id,
282
+ agentId,
283
+ agentPersona: persona,
110
284
  channel: msg.channel,
111
285
  session,
112
286
  confirmProvider,
@@ -122,8 +296,15 @@ export async function startDiscordBot(config, botConfig) {
122
296
  antonAbortSignal: null,
123
297
  antonLastResult: null,
124
298
  antonProgress: null,
299
+ currentModelIndex: 0,
300
+ escalationCount: 0,
301
+ pendingEscalation: null,
125
302
  };
126
303
  sessions.set(key, managed);
304
+ // Log agent assignment for debugging
305
+ if (persona) {
306
+ console.error(`[bot:discord] ${msg.author.id} → agent:${agentId} (${persona.display_name || agentId})`);
307
+ }
127
308
  return managed;
128
309
  }
129
310
  function destroySession(key) {
@@ -196,10 +377,83 @@ export async function startDiscordBot(config, botConfig) {
196
377
  return { ok: true, message: '⏹ Cancel requested. Stopping current turn...' };
197
378
  }
198
379
  async function processMessage(managed, msg) {
199
- const turn = beginTurn(managed);
380
+ let turn = beginTurn(managed);
200
381
  if (!turn)
201
382
  return;
202
- const turnId = turn.turnId;
383
+ let turnId = turn.turnId;
384
+ // Handle pending escalation - switch model before processing
385
+ if (managed.pendingEscalation) {
386
+ const targetModel = managed.pendingEscalation;
387
+ managed.pendingEscalation = null;
388
+ // Find the model index in escalation chain
389
+ const escalation = managed.agentPersona?.escalation;
390
+ if (escalation?.models) {
391
+ const idx = escalation.models.indexOf(targetModel);
392
+ if (idx !== -1) {
393
+ managed.currentModelIndex = idx + 1; // +1 because 0 is base model
394
+ managed.escalationCount += 1;
395
+ }
396
+ }
397
+ // Recreate session with escalated model
398
+ const cfg = {
399
+ ...managed.config,
400
+ model: targetModel,
401
+ };
402
+ try {
403
+ await recreateSession(managed, cfg);
404
+ console.error(`[bot:discord] ${managed.userId} escalated to ${targetModel}`);
405
+ }
406
+ catch (e) {
407
+ console.error(`[bot:discord] escalation failed: ${e?.message ?? e}`);
408
+ // Continue with current model if escalation fails
409
+ }
410
+ // Re-acquire turn after recreation - must update turnId!
411
+ const newTurn = beginTurn(managed);
412
+ if (!newTurn)
413
+ return;
414
+ turn = newTurn;
415
+ turnId = newTurn.turnId;
416
+ }
417
+ // Check for keyword-based escalation BEFORE calling the model
418
+ // Allow escalation to higher tiers even if already escalated
419
+ const escalation = managed.agentPersona?.escalation;
420
+ if (escalation?.models?.length) {
421
+ const kwResult = checkKeywordEscalation(msg.content, escalation);
422
+ if (kwResult.escalate && kwResult.tier !== undefined) {
423
+ // Use the tier to select the target model (tier 0 → models[0], tier 1 → models[1], etc.)
424
+ const targetModelIndex = Math.min(kwResult.tier, escalation.models.length - 1);
425
+ // Only escalate if target tier is higher than current (currentModelIndex 0 = base, 1 = models[0], etc.)
426
+ const currentTier = managed.currentModelIndex - 1; // -1 because 0 is base model
427
+ if (targetModelIndex > currentTier) {
428
+ const targetModel = escalation.models[targetModelIndex];
429
+ // Get endpoint from tier if defined
430
+ const tierEndpoint = escalation.tiers?.[targetModelIndex]?.endpoint;
431
+ console.error(`[bot:discord] ${managed.userId} keyword escalation: ${kwResult.reason} → ${targetModel}${tierEndpoint ? ` @ ${tierEndpoint}` : ''}`);
432
+ // Set up escalation
433
+ managed.currentModelIndex = targetModelIndex + 1; // +1 because 0 is base model
434
+ managed.escalationCount += 1;
435
+ // Recreate session with escalated model and optional endpoint override
436
+ const cfg = {
437
+ ...managed.config,
438
+ model: targetModel,
439
+ ...(tierEndpoint && { endpoint: tierEndpoint }),
440
+ };
441
+ try {
442
+ await recreateSession(managed, cfg);
443
+ // Re-acquire turn after recreation - must update turnId!
444
+ const newTurn = beginTurn(managed);
445
+ if (!newTurn)
446
+ return;
447
+ turn = newTurn;
448
+ turnId = newTurn.turnId;
449
+ }
450
+ catch (e) {
451
+ console.error(`[bot:discord] keyword escalation failed: ${e?.message ?? e}`);
452
+ // Continue with current model if escalation fails
453
+ }
454
+ }
455
+ }
456
+ }
203
457
  const placeholder = await sendUserVisible(msg, '⏳ Thinking...').catch(() => null);
204
458
  let streamed = '';
205
459
  const hooks = {
@@ -225,6 +479,42 @@ export async function startDiscordBot(config, botConfig) {
225
479
  return;
226
480
  markProgress(managed, turnId);
227
481
  const finalText = safeContent(streamed || result.text);
482
+ // Check for auto-escalation request in response
483
+ const escalation = managed.agentPersona?.escalation;
484
+ const autoEscalate = escalation?.auto !== false && escalation?.models?.length;
485
+ const maxEscalations = escalation?.max_escalations ?? 2;
486
+ if (autoEscalate && managed.escalationCount < maxEscalations) {
487
+ const escResult = detectEscalation(finalText);
488
+ if (escResult.escalate) {
489
+ // Determine next model in escalation chain
490
+ const nextIndex = Math.min(managed.currentModelIndex, escalation.models.length - 1);
491
+ const targetModel = escalation.models[nextIndex];
492
+ // Get endpoint from tier if defined
493
+ const tierEndpoint = escalation.tiers?.[nextIndex]?.endpoint;
494
+ console.error(`[bot:discord] ${managed.userId} auto-escalation requested: ${escResult.reason}${tierEndpoint ? ` @ ${tierEndpoint}` : ''}`);
495
+ // Update placeholder with escalation notice
496
+ if (placeholder) {
497
+ await placeholder.edit(`⚡ Escalating to \`${targetModel}\` (${escResult.reason})...`).catch(() => { });
498
+ }
499
+ // Set up escalation for re-run
500
+ managed.pendingEscalation = targetModel;
501
+ managed.currentModelIndex = nextIndex + 1;
502
+ managed.escalationCount += 1;
503
+ // Recreate session with escalated model and optional endpoint override
504
+ const cfg = {
505
+ ...managed.config,
506
+ model: targetModel,
507
+ ...(tierEndpoint && { endpoint: tierEndpoint }),
508
+ };
509
+ await recreateSession(managed, cfg);
510
+ // Finish this turn and re-run with escalated model
511
+ clearInterval(watchdog);
512
+ finishTurn(managed, turnId);
513
+ // Re-process the original message with the escalated model
514
+ await processMessage(managed, msg);
515
+ return;
516
+ }
517
+ }
228
518
  const chunks = splitDiscord(finalText);
229
519
  if (placeholder) {
230
520
  await placeholder.edit(chunks[0]).catch(() => { });
@@ -264,6 +554,23 @@ export async function startDiscordBot(config, botConfig) {
264
554
  finally {
265
555
  clearInterval(watchdog);
266
556
  finishTurn(managed, turnId);
557
+ // Auto-deescalate back to base model after each request
558
+ if (managed.currentModelIndex > 0 && managed.agentPersona?.escalation) {
559
+ const baseModel = managed.agentPersona.model || config.model || 'default';
560
+ managed.currentModelIndex = 0;
561
+ managed.escalationCount = 0;
562
+ const cfg = {
563
+ ...managed.config,
564
+ model: baseModel,
565
+ };
566
+ try {
567
+ await recreateSession(managed, cfg);
568
+ console.error(`[bot:discord] ${managed.userId} auto-deescalated to ${baseModel}`);
569
+ }
570
+ catch (e) {
571
+ console.error(`[bot:discord] auto-deescalation failed: ${e?.message ?? e}`);
572
+ }
573
+ }
267
574
  const next = managed.pendingQueue.shift();
268
575
  if (next && managed.state === 'idle' && !managed.inFlight) {
269
576
  setTimeout(() => {
@@ -281,6 +588,8 @@ export async function startDiscordBot(config, botConfig) {
281
588
  managed.activeAbortController?.abort();
282
589
  }
283
590
  catch { }
591
+ // Preserve conversation history before destroying the old session
592
+ const oldMessages = managed.session.messages.slice();
284
593
  try {
285
594
  managed.session.cancel();
286
595
  }
@@ -290,6 +599,15 @@ export async function startDiscordBot(config, botConfig) {
290
599
  confirmProvider: managed.confirmProvider,
291
600
  confirm: async () => true,
292
601
  });
602
+ // Restore conversation history to the new session
603
+ if (oldMessages.length > 0) {
604
+ try {
605
+ session.restore(oldMessages);
606
+ }
607
+ catch (e) {
608
+ console.error(`[bot:discord] Failed to restore ${oldMessages.length} messages after escalation:`, e);
609
+ }
610
+ }
293
611
  managed.session = session;
294
612
  managed.config = cfg;
295
613
  managed.inFlight = false;
@@ -298,7 +616,7 @@ export async function startDiscordBot(config, botConfig) {
298
616
  managed.lastProgressAt = 0;
299
617
  managed.lastActivity = Date.now();
300
618
  }
301
- client.on(Events.ClientReady, () => {
619
+ client.on(Events.ClientReady, async () => {
302
620
  console.error(`[bot:discord] Connected as ${client.user?.tag ?? 'unknown'}`);
303
621
  console.error(`[bot:discord] Allowed users: [${[...allowedUsers].join(', ')}]`);
304
622
  console.error(`[bot:discord] Default dir: ${defaultDir}`);
@@ -306,6 +624,233 @@ export async function startDiscordBot(config, botConfig) {
306
624
  if (allowGuilds) {
307
625
  console.error(`[bot:discord] Guild mode enabled${guildId ? ` (guild ${guildId})` : ''}`);
308
626
  }
627
+ // Log multi-agent config
628
+ const agents = botConfig.agents;
629
+ if (agents && Object.keys(agents).length > 0) {
630
+ const agentIds = Object.keys(agents);
631
+ console.error(`[bot:discord] Multi-agent mode: ${agentIds.length} agents configured [${agentIds.join(', ')}]`);
632
+ const routing = botConfig.routing;
633
+ if (routing?.default) {
634
+ console.error(`[bot:discord] Default agent: ${routing.default}`);
635
+ }
636
+ }
637
+ // Register slash commands
638
+ try {
639
+ const commands = [
640
+ new SlashCommandBuilder().setName('help').setDescription('Show available commands'),
641
+ new SlashCommandBuilder().setName('new').setDescription('Start a new session'),
642
+ new SlashCommandBuilder().setName('status').setDescription('Show session statistics'),
643
+ new SlashCommandBuilder().setName('agent').setDescription('Show current agent info'),
644
+ new SlashCommandBuilder().setName('agents').setDescription('List all configured agents'),
645
+ new SlashCommandBuilder().setName('cancel').setDescription('Cancel the current operation'),
646
+ new SlashCommandBuilder().setName('reset').setDescription('Reset the session'),
647
+ new SlashCommandBuilder().setName('escalate').setDescription('Escalate to a larger model')
648
+ .addStringOption(option => option.setName('model').setDescription('Model name or "next"').setRequired(false)),
649
+ new SlashCommandBuilder().setName('deescalate').setDescription('Return to base model'),
650
+ ].map(cmd => cmd.toJSON());
651
+ const rest = new REST({ version: '10' }).setToken(token);
652
+ // Register globally (takes up to 1 hour to propagate) or per-guild (instant)
653
+ if (guildId) {
654
+ await rest.put(Routes.applicationGuildCommands(client.user.id, guildId), { body: commands });
655
+ console.error(`[bot:discord] Registered ${commands.length} slash commands for guild ${guildId}`);
656
+ }
657
+ else {
658
+ await rest.put(Routes.applicationCommands(client.user.id), { body: commands });
659
+ console.error(`[bot:discord] Registered ${commands.length} global slash commands`);
660
+ }
661
+ }
662
+ catch (e) {
663
+ console.error(`[bot:discord] Failed to register slash commands: ${e?.message ?? e}`);
664
+ }
665
+ });
666
+ // Handle slash command interactions
667
+ client.on(Events.InteractionCreate, async (interaction) => {
668
+ if (!interaction.isChatInputCommand())
669
+ return;
670
+ if (!allowedUsers.has(interaction.user.id)) {
671
+ await interaction.reply({ content: '⚠️ You are not authorized to use this bot.', ephemeral: true });
672
+ return;
673
+ }
674
+ const cmd = interaction.commandName;
675
+ // Create a fake message object with enough properties to work with existing handlers
676
+ const fakeMsg = {
677
+ author: interaction.user,
678
+ channel: interaction.channel,
679
+ channelId: interaction.channelId,
680
+ guildId: interaction.guildId,
681
+ content: `/${cmd}`,
682
+ reply: async (content) => {
683
+ if (interaction.replied || interaction.deferred) {
684
+ return await interaction.followUp(content);
685
+ }
686
+ return await interaction.reply(content);
687
+ },
688
+ };
689
+ // Defer reply for commands that might take a while
690
+ if (cmd === 'status' || cmd === 'agent' || cmd === 'agents') {
691
+ await interaction.deferReply();
692
+ }
693
+ // Resolve agent for this interaction
694
+ const { agentId, persona } = resolveAgentForMessage(fakeMsg, botConfig.agents, botConfig.routing);
695
+ const key = sessionKeyForMessage(fakeMsg, allowGuilds, agentId);
696
+ switch (cmd) {
697
+ case 'help': {
698
+ const lines = [
699
+ '**IdleHands Commands**',
700
+ '',
701
+ '/help — This message',
702
+ '/new — Start fresh session',
703
+ '/status — Session stats',
704
+ '/agent — Show current agent',
705
+ '/agents — List all configured agents',
706
+ '/cancel — Abort running task',
707
+ '/reset — Full session reset',
708
+ ];
709
+ await interaction.reply(lines.join('\n'));
710
+ break;
711
+ }
712
+ case 'new': {
713
+ destroySession(key);
714
+ const agentName = persona?.display_name || agentId;
715
+ const agentMsg = persona ? ` (agent: ${agentName})` : '';
716
+ await interaction.reply(`✨ New session started${agentMsg}. Send a message to begin.`);
717
+ break;
718
+ }
719
+ case 'status': {
720
+ const managed = sessions.get(key);
721
+ if (!managed) {
722
+ await interaction.editReply('No active session.');
723
+ }
724
+ else {
725
+ const lines = [
726
+ `**Session:** ${managed.key}`,
727
+ `**Agent:** ${managed.agentPersona?.display_name || managed.agentId}`,
728
+ `**Model:** ${managed.config.model ?? 'default'}`,
729
+ `**State:** ${managed.state}`,
730
+ `**Turns:** ${managed.session.messages.length}`,
731
+ ];
732
+ await interaction.editReply(lines.join('\n'));
733
+ }
734
+ break;
735
+ }
736
+ case 'agent': {
737
+ const agentName = persona?.display_name || agentId;
738
+ if (persona) {
739
+ const lines = [
740
+ `**Current Agent:** ${agentName}`,
741
+ persona.model ? `**Model:** ${persona.model}` : null,
742
+ persona.system_prompt ? `**System Prompt:** ${persona.system_prompt.slice(0, 100)}...` : null,
743
+ ].filter(Boolean);
744
+ await interaction.editReply(lines.join('\n'));
745
+ }
746
+ else {
747
+ await interaction.editReply(`**Current Agent:** Default (no persona configured)`);
748
+ }
749
+ break;
750
+ }
751
+ case 'agents': {
752
+ const agentsConfig = botConfig.agents;
753
+ if (!agentsConfig || Object.keys(agentsConfig).length === 0) {
754
+ await interaction.editReply('No agents configured.');
755
+ }
756
+ else {
757
+ const lines = ['**Configured Agents:**', ''];
758
+ for (const [id, agent] of Object.entries(agentsConfig)) {
759
+ const name = agent.display_name || id;
760
+ const model = agent.model ? ` (${agent.model})` : '';
761
+ lines.push(`• **${name}**${model}`);
762
+ }
763
+ await interaction.editReply(lines.join('\n'));
764
+ }
765
+ break;
766
+ }
767
+ case 'cancel': {
768
+ const managed = sessions.get(key);
769
+ if (!managed) {
770
+ await interaction.reply('No active session.');
771
+ }
772
+ else if (managed.state !== 'running') {
773
+ await interaction.reply('Nothing to cancel.');
774
+ }
775
+ else {
776
+ cancelActive(managed);
777
+ await interaction.reply('🛑 Cancelling...');
778
+ }
779
+ break;
780
+ }
781
+ case 'reset': {
782
+ destroySession(key);
783
+ await interaction.reply('🔄 Session reset.');
784
+ break;
785
+ }
786
+ case 'escalate': {
787
+ const managed = sessions.get(key);
788
+ const escalation = persona?.escalation;
789
+ if (!escalation || !escalation.models?.length) {
790
+ await interaction.reply('❌ No escalation models configured for this agent.');
791
+ break;
792
+ }
793
+ const arg = interaction.options.getString('model');
794
+ // No arg: show available models and current state
795
+ if (!arg) {
796
+ const currentModel = managed?.config.model || config.model || 'default';
797
+ const lines = [
798
+ `**Current model:** \`${currentModel}\``,
799
+ `**Escalation models:** ${escalation.models.map(m => `\`${m}\``).join(', ')}`,
800
+ '',
801
+ 'Usage: `/escalate model:<name>` or `/escalate model:next`',
802
+ ];
803
+ if (managed?.pendingEscalation) {
804
+ lines.push('', `⚡ **Pending escalation:** \`${managed.pendingEscalation}\``);
805
+ }
806
+ await interaction.reply(lines.join('\n'));
807
+ break;
808
+ }
809
+ if (!managed) {
810
+ await interaction.reply('No active session. Send a message first.');
811
+ break;
812
+ }
813
+ // Handle 'next' - escalate to next model in chain
814
+ let targetModel;
815
+ if (arg.toLowerCase() === 'next') {
816
+ const nextIndex = Math.min(managed.currentModelIndex, escalation.models.length - 1);
817
+ targetModel = escalation.models[nextIndex];
818
+ }
819
+ else {
820
+ // Specific model requested
821
+ if (!escalation.models.includes(arg)) {
822
+ await interaction.reply(`❌ Model \`${arg}\` not in escalation chain. Available: ${escalation.models.map(m => `\`${m}\``).join(', ')}`);
823
+ break;
824
+ }
825
+ targetModel = arg;
826
+ }
827
+ managed.pendingEscalation = targetModel;
828
+ await interaction.reply(`⚡ Next message will use \`${targetModel}\`. Send your request now.`);
829
+ break;
830
+ }
831
+ case 'deescalate': {
832
+ const managed = sessions.get(key);
833
+ if (!managed) {
834
+ await interaction.reply('No active session.');
835
+ break;
836
+ }
837
+ if (managed.currentModelIndex === 0 && !managed.pendingEscalation) {
838
+ await interaction.reply('Already using base model.');
839
+ break;
840
+ }
841
+ const baseModel = persona?.model || config.model || 'default';
842
+ managed.pendingEscalation = null;
843
+ managed.currentModelIndex = 0;
844
+ // Recreate session with base model
845
+ const cfg = {
846
+ ...managed.config,
847
+ model: baseModel,
848
+ };
849
+ await recreateSession(managed, cfg);
850
+ await interaction.reply(`✅ Returned to base model: \`${baseModel}\``);
851
+ break;
852
+ }
853
+ }
309
854
  });
310
855
  client.on(Events.MessageCreate, async (msg) => {
311
856
  if (msg.author.bot)
@@ -319,10 +864,14 @@ export async function startDiscordBot(config, botConfig) {
319
864
  const content = msg.content?.trim();
320
865
  if (!content)
321
866
  return;
322
- const key = sessionKeyForMessage(msg, allowGuilds);
867
+ // Resolve agent for this message to get the correct session key
868
+ const { agentId, persona } = resolveAgentForMessage(msg, botConfig.agents, botConfig.routing);
869
+ const key = sessionKeyForMessage(msg, allowGuilds, agentId);
323
870
  if (content === '/new') {
324
871
  destroySession(key);
325
- await sendUserVisible(msg, '✨ New session started. Send a message to begin.').catch(() => { });
872
+ const agentName = persona?.display_name || agentId;
873
+ const agentMsg = persona ? ` (agent: ${agentName})` : '';
874
+ await sendUserVisible(msg, `✨ New session started${agentMsg}. Send a message to begin.`).catch(() => { });
326
875
  return;
327
876
  }
328
877
  const managed = await getOrCreate(msg);
@@ -336,9 +885,13 @@ export async function startDiscordBot(config, botConfig) {
336
885
  return;
337
886
  }
338
887
  if (content === '/start') {
888
+ const agentLine = managed.agentPersona
889
+ ? `Agent: **${managed.agentPersona.display_name || managed.agentId}**`
890
+ : null;
339
891
  const lines = [
340
892
  '🔧 Idle Hands — Local-first coding agent',
341
893
  '',
894
+ ...(agentLine ? [agentLine] : []),
342
895
  `Model: \`${managed.session.model}\``,
343
896
  `Endpoint: \`${managed.config.endpoint || '?'}\``,
344
897
  `Default dir: \`${managed.config.dir || defaultDir}\``,
@@ -356,6 +909,10 @@ export async function startDiscordBot(config, botConfig) {
356
909
  '/new — Start a new session',
357
910
  '/cancel — Abort current generation',
358
911
  '/status — Session stats',
912
+ '/agent — Show current agent',
913
+ '/agents — List all configured agents',
914
+ '/escalate [model] — Use larger model for next message',
915
+ '/deescalate — Return to base model',
359
916
  '/dir [path] — Get/set working directory',
360
917
  '/model — Show current model',
361
918
  '/approval [mode] — Get/set approval mode',
@@ -527,7 +1084,11 @@ export async function startDiscordBot(config, botConfig) {
527
1084
  const pct = managed.session.contextWindow > 0
528
1085
  ? ((used / managed.session.contextWindow) * 100).toFixed(1)
529
1086
  : '?';
1087
+ const agentLine = managed.agentPersona
1088
+ ? `Agent: ${managed.agentPersona.display_name || managed.agentId}`
1089
+ : null;
530
1090
  await sendUserVisible(msg, [
1091
+ ...(agentLine ? [agentLine] : []),
531
1092
  `Mode: ${managed.config.mode ?? 'code'}`,
532
1093
  `Approval: ${managed.config.approval_mode}`,
533
1094
  `Model: ${managed.session.model}`,
@@ -538,6 +1099,116 @@ export async function startDiscordBot(config, botConfig) {
538
1099
  ].join('\n')).catch(() => { });
539
1100
  return;
540
1101
  }
1102
+ // /agent - show current agent info
1103
+ if (content === '/agent') {
1104
+ if (!managed.agentPersona) {
1105
+ await sendUserVisible(msg, 'No agent configured. Using global config.').catch(() => { });
1106
+ return;
1107
+ }
1108
+ const p = managed.agentPersona;
1109
+ const lines = [
1110
+ `**Agent: ${p.display_name || managed.agentId}** (\`${managed.agentId}\`)`,
1111
+ ...(p.model ? [`Model: \`${p.model}\``] : []),
1112
+ ...(p.endpoint ? [`Endpoint: \`${p.endpoint}\``] : []),
1113
+ ...(p.approval_mode ? [`Approval: \`${p.approval_mode}\``] : []),
1114
+ ...(p.default_dir ? [`Default dir: \`${p.default_dir}\``] : []),
1115
+ ...(p.allowed_dirs?.length ? [`Allowed dirs: ${p.allowed_dirs.map(d => `\`${d}\``).join(', ')}`] : []),
1116
+ ];
1117
+ await sendUserVisible(msg, lines.join('\n')).catch(() => { });
1118
+ return;
1119
+ }
1120
+ // /agents - list all configured agents
1121
+ if (content === '/agents') {
1122
+ const agents = botConfig.agents;
1123
+ if (!agents || Object.keys(agents).length === 0) {
1124
+ await sendUserVisible(msg, 'No agents configured. Using global config.').catch(() => { });
1125
+ return;
1126
+ }
1127
+ const lines = ['**Configured Agents:**'];
1128
+ for (const [id, p] of Object.entries(agents)) {
1129
+ const current = id === managed.agentId ? ' ← current' : '';
1130
+ const model = p.model ? ` (${p.model})` : '';
1131
+ lines.push(`• **${p.display_name || id}** (\`${id}\`)${model}${current}`);
1132
+ }
1133
+ // Show routing rules
1134
+ const routing = botConfig.routing;
1135
+ if (routing) {
1136
+ lines.push('', '**Routing:**');
1137
+ if (routing.default)
1138
+ lines.push(`Default: \`${routing.default}\``);
1139
+ if (routing.users && Object.keys(routing.users).length > 0) {
1140
+ lines.push(`Users: ${Object.entries(routing.users).map(([u, a]) => `${u}→${a}`).join(', ')}`);
1141
+ }
1142
+ if (routing.channels && Object.keys(routing.channels).length > 0) {
1143
+ lines.push(`Channels: ${Object.entries(routing.channels).map(([c, a]) => `${c}→${a}`).join(', ')}`);
1144
+ }
1145
+ if (routing.guilds && Object.keys(routing.guilds).length > 0) {
1146
+ lines.push(`Guilds: ${Object.entries(routing.guilds).map(([g, a]) => `${g}→${a}`).join(', ')}`);
1147
+ }
1148
+ }
1149
+ await sendUserVisible(msg, lines.join('\n')).catch(() => { });
1150
+ return;
1151
+ }
1152
+ // /escalate - explicitly escalate to a larger model for next message
1153
+ if (content === '/escalate' || content.startsWith('/escalate ')) {
1154
+ const escalation = managed.agentPersona?.escalation;
1155
+ if (!escalation || !escalation.models?.length) {
1156
+ await sendUserVisible(msg, '❌ No escalation models configured for this agent.').catch(() => { });
1157
+ return;
1158
+ }
1159
+ const arg = content.slice('/escalate'.length).trim();
1160
+ // No arg: show available models and current state
1161
+ if (!arg) {
1162
+ const currentModel = managed.config.model || config.model || 'default';
1163
+ const lines = [
1164
+ `**Current model:** \`${currentModel}\``,
1165
+ `**Escalation models:** ${escalation.models.map(m => `\`${m}\``).join(', ')}`,
1166
+ '',
1167
+ 'Usage: `/escalate <model>` or `/escalate next`',
1168
+ 'Then send your message - it will use the escalated model.',
1169
+ ];
1170
+ if (managed.pendingEscalation) {
1171
+ lines.push('', `⚡ **Pending escalation:** \`${managed.pendingEscalation}\` (next message will use this)`);
1172
+ }
1173
+ await sendUserVisible(msg, lines.join('\n')).catch(() => { });
1174
+ return;
1175
+ }
1176
+ // Handle 'next' - escalate to next model in chain
1177
+ let targetModel;
1178
+ if (arg.toLowerCase() === 'next') {
1179
+ const nextIndex = Math.min(managed.currentModelIndex, escalation.models.length - 1);
1180
+ targetModel = escalation.models[nextIndex];
1181
+ }
1182
+ else {
1183
+ // Specific model requested
1184
+ if (!escalation.models.includes(arg)) {
1185
+ await sendUserVisible(msg, `❌ Model \`${arg}\` not in escalation chain. Available: ${escalation.models.map(m => `\`${m}\``).join(', ')}`).catch(() => { });
1186
+ return;
1187
+ }
1188
+ targetModel = arg;
1189
+ }
1190
+ managed.pendingEscalation = targetModel;
1191
+ await sendUserVisible(msg, `⚡ Next message will use \`${targetModel}\`. Send your request now.`).catch(() => { });
1192
+ return;
1193
+ }
1194
+ // /deescalate - return to base model
1195
+ if (content === '/deescalate') {
1196
+ if (managed.currentModelIndex === 0 && !managed.pendingEscalation) {
1197
+ await sendUserVisible(msg, 'Already using base model.').catch(() => { });
1198
+ return;
1199
+ }
1200
+ const baseModel = managed.agentPersona?.model || config.model || 'default';
1201
+ managed.pendingEscalation = null;
1202
+ managed.currentModelIndex = 0;
1203
+ // Recreate session with base model
1204
+ const cfg = {
1205
+ ...managed.config,
1206
+ model: baseModel,
1207
+ };
1208
+ await recreateSession(managed, cfg);
1209
+ await sendUserVisible(msg, `✅ Returned to base model: \`${baseModel}\``).catch(() => { });
1210
+ return;
1211
+ }
541
1212
  if (content === '/hosts') {
542
1213
  try {
543
1214
  const { loadRuntimes, redactConfig } = await import('../runtime/store.js');