skimpyclaw 0.3.14 → 0.4.0

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 (222) hide show
  1. package/README.md +47 -37
  2. package/dist/__tests__/adapter-types.test.d.ts +4 -0
  3. package/dist/__tests__/adapter-types.test.js +63 -0
  4. package/dist/__tests__/anthropic-adapter.test.d.ts +4 -0
  5. package/dist/__tests__/anthropic-adapter.test.js +264 -0
  6. package/dist/__tests__/api.test.js +0 -1
  7. package/dist/__tests__/cli.integration.test.js +2 -4
  8. package/dist/__tests__/cli.test.js +0 -1
  9. package/dist/__tests__/code-agents-notifications.test.js +137 -0
  10. package/dist/__tests__/code-agents-parser.test.js +19 -1
  11. package/dist/__tests__/code-agents-preflight.test.js +3 -28
  12. package/dist/__tests__/code-agents-utils.test.js +34 -9
  13. package/dist/__tests__/code-agents-worktrees.test.js +116 -0
  14. package/dist/__tests__/codex-adapter.test.js +184 -0
  15. package/dist/__tests__/codex-auth.test.js +66 -0
  16. package/dist/__tests__/codex-provider-gating.test.js +35 -0
  17. package/dist/__tests__/codex-unified-loop.test.js +111 -0
  18. package/dist/__tests__/config-security.test.js +127 -0
  19. package/dist/__tests__/config.test.js +23 -0
  20. package/dist/__tests__/context-manager.test.js +243 -164
  21. package/dist/__tests__/cron-run.test.js +250 -0
  22. package/dist/__tests__/cron.test.js +12 -38
  23. package/dist/__tests__/digests.test.js +67 -0
  24. package/dist/__tests__/discord-attachments.test.js +211 -0
  25. package/dist/__tests__/discord-docs.test.d.ts +1 -0
  26. package/dist/__tests__/discord-docs.test.js +27 -0
  27. package/dist/__tests__/discord-thread-agents.test.d.ts +1 -0
  28. package/dist/__tests__/discord-thread-agents.test.js +115 -0
  29. package/dist/__tests__/discord-thread-context.test.d.ts +1 -0
  30. package/dist/__tests__/discord-thread-context.test.js +42 -0
  31. package/dist/__tests__/doctor.formatters.test.js +4 -4
  32. package/dist/__tests__/doctor.index.test.js +1 -1
  33. package/dist/__tests__/doctor.runner.test.js +3 -15
  34. package/dist/__tests__/env-sanitizer.test.d.ts +1 -0
  35. package/dist/__tests__/env-sanitizer.test.js +45 -0
  36. package/dist/__tests__/exec-approval.test.js +61 -0
  37. package/dist/__tests__/fetch-tool.test.d.ts +1 -0
  38. package/dist/__tests__/fetch-tool.test.js +85 -0
  39. package/dist/__tests__/gateway-status-auth.test.d.ts +1 -0
  40. package/dist/__tests__/gateway-status-auth.test.js +72 -0
  41. package/dist/__tests__/heartbeat.test.js +3 -3
  42. package/dist/__tests__/interactive-sessions.test.d.ts +1 -0
  43. package/dist/__tests__/interactive-sessions.test.js +96 -0
  44. package/dist/__tests__/langfuse.test.js +6 -18
  45. package/dist/__tests__/model-selection.test.js +3 -4
  46. package/dist/__tests__/providers-init.test.js +2 -8
  47. package/dist/__tests__/providers-routing.test.js +1 -1
  48. package/dist/__tests__/providers-utils.test.js +13 -3
  49. package/dist/__tests__/sessions.test.js +14 -10
  50. package/dist/__tests__/setup.test.js +12 -29
  51. package/dist/__tests__/skills.test.js +10 -7
  52. package/dist/__tests__/stream-formatter.test.d.ts +1 -0
  53. package/dist/__tests__/stream-formatter.test.js +114 -0
  54. package/dist/__tests__/token-efficiency.test.js +131 -15
  55. package/dist/__tests__/tool-loop.test.d.ts +4 -0
  56. package/dist/__tests__/tool-loop.test.js +505 -0
  57. package/dist/__tests__/tools.test.js +101 -276
  58. package/dist/__tests__/utils.test.d.ts +1 -0
  59. package/dist/__tests__/utils.test.js +14 -0
  60. package/dist/__tests__/voice.test.js +21 -0
  61. package/dist/agent.js +35 -4
  62. package/dist/api.js +113 -37
  63. package/dist/channels/discord/attachments.d.ts +50 -0
  64. package/dist/channels/discord/attachments.js +137 -0
  65. package/dist/channels/discord/delegation.d.ts +5 -0
  66. package/dist/channels/discord/delegation.js +136 -0
  67. package/dist/channels/discord/handlers.js +694 -7
  68. package/dist/channels/discord/index.d.ts +16 -1
  69. package/dist/channels/discord/index.js +64 -1
  70. package/dist/channels/discord/thread-agents.d.ts +54 -0
  71. package/dist/channels/discord/thread-agents.js +323 -0
  72. package/dist/channels/discord/threads.d.ts +58 -0
  73. package/dist/channels/discord/threads.js +192 -0
  74. package/dist/channels/discord/types.js +4 -2
  75. package/dist/channels/discord/utils.d.ts +16 -0
  76. package/dist/channels/discord/utils.js +86 -6
  77. package/dist/channels/telegram/index.d.ts +1 -1
  78. package/dist/channels/telegram/types.js +1 -1
  79. package/dist/channels/telegram/utils.js +9 -3
  80. package/dist/channels.d.ts +1 -1
  81. package/dist/cli.js +20 -400
  82. package/dist/code-agents/executor.d.ts +1 -1
  83. package/dist/code-agents/executor.js +101 -45
  84. package/dist/code-agents/index.d.ts +2 -7
  85. package/dist/code-agents/index.js +111 -80
  86. package/dist/code-agents/interactive-resume.d.ts +6 -0
  87. package/dist/code-agents/interactive-resume.js +98 -0
  88. package/dist/code-agents/interactive-sessions.d.ts +20 -0
  89. package/dist/code-agents/interactive-sessions.js +132 -0
  90. package/dist/code-agents/parser.js +5 -1
  91. package/dist/code-agents/registry.d.ts +7 -1
  92. package/dist/code-agents/registry.js +11 -23
  93. package/dist/code-agents/stream-formatter.d.ts +8 -0
  94. package/dist/code-agents/stream-formatter.js +92 -0
  95. package/dist/code-agents/types.d.ts +16 -24
  96. package/dist/code-agents/utils.d.ts +35 -11
  97. package/dist/code-agents/utils.js +349 -95
  98. package/dist/code-agents/worktrees.d.ts +37 -0
  99. package/dist/code-agents/worktrees.js +116 -0
  100. package/dist/config.d.ts +2 -4
  101. package/dist/config.js +123 -23
  102. package/dist/cron.d.ts +1 -6
  103. package/dist/cron.js +175 -82
  104. package/dist/dashboard/assets/index-B345aOO-.js +65 -0
  105. package/dist/dashboard/assets/index-ZWK4dalJ.css +1 -0
  106. package/dist/dashboard/index.html +2 -2
  107. package/dist/digests.d.ts +1 -0
  108. package/dist/digests.js +132 -42
  109. package/dist/doctor/checks.d.ts +0 -3
  110. package/dist/doctor/checks.js +1 -108
  111. package/dist/doctor/runner.js +1 -4
  112. package/dist/env-sanitizer.d.ts +2 -0
  113. package/dist/env-sanitizer.js +61 -0
  114. package/dist/exec-approval.d.ts +11 -1
  115. package/dist/exec-approval.js +17 -4
  116. package/dist/gateway.d.ts +3 -1
  117. package/dist/gateway.js +17 -7
  118. package/dist/heartbeat.js +1 -6
  119. package/dist/langfuse.js +3 -29
  120. package/dist/model-selection.js +3 -1
  121. package/dist/providers/adapter.d.ts +118 -0
  122. package/dist/providers/adapter.js +6 -0
  123. package/dist/providers/adapters/anthropic-adapter.d.ts +22 -0
  124. package/dist/providers/adapters/anthropic-adapter.js +204 -0
  125. package/dist/providers/adapters/codex-adapter.d.ts +26 -0
  126. package/dist/providers/adapters/codex-adapter.js +203 -0
  127. package/dist/providers/anthropic.d.ts +1 -0
  128. package/dist/providers/anthropic.js +10 -272
  129. package/dist/providers/codex.d.ts +21 -0
  130. package/dist/providers/codex.js +149 -330
  131. package/dist/providers/content.d.ts +1 -1
  132. package/dist/providers/content.js +2 -2
  133. package/dist/providers/context-manager.d.ts +18 -6
  134. package/dist/providers/context-manager.js +199 -223
  135. package/dist/providers/index.d.ts +9 -1
  136. package/dist/providers/index.js +73 -64
  137. package/dist/providers/loop-utils.d.ts +20 -0
  138. package/dist/providers/loop-utils.js +30 -0
  139. package/dist/providers/tool-loop.d.ts +12 -0
  140. package/dist/providers/tool-loop.js +251 -0
  141. package/dist/providers/utils.d.ts +19 -3
  142. package/dist/providers/utils.js +100 -29
  143. package/dist/secure-store.d.ts +8 -0
  144. package/dist/secure-store.js +80 -0
  145. package/dist/service.js +3 -28
  146. package/dist/sessions.d.ts +3 -0
  147. package/dist/sessions.js +147 -18
  148. package/dist/setup-templates.js +13 -25
  149. package/dist/setup.d.ts +10 -6
  150. package/dist/setup.js +84 -292
  151. package/dist/skills.js +3 -11
  152. package/dist/tools/agent-delegation.d.ts +19 -0
  153. package/dist/tools/agent-delegation.js +49 -0
  154. package/dist/tools/bash-tool.js +89 -34
  155. package/dist/tools/definitions.d.ts +199 -302
  156. package/dist/tools/definitions.js +70 -123
  157. package/dist/tools/execute-context.d.ts +13 -4
  158. package/dist/tools/fetch-tool.js +109 -13
  159. package/dist/tools/file-tools.js +7 -1
  160. package/dist/tools.d.ts +7 -7
  161. package/dist/tools.js +133 -151
  162. package/dist/types.d.ts +37 -30
  163. package/dist/utils.js +4 -6
  164. package/dist/voice.d.ts +1 -1
  165. package/dist/voice.js +17 -4
  166. package/package.json +33 -23
  167. package/templates/TOOLS.md +0 -27
  168. package/dist/__tests__/audit.test.js +0 -122
  169. package/dist/__tests__/code-agents-orchestrator.test.js +0 -216
  170. package/dist/__tests__/code-agents-sandbox.test.js +0 -163
  171. package/dist/__tests__/orchestrator.test.js +0 -425
  172. package/dist/__tests__/sandbox-bridge.test.js +0 -116
  173. package/dist/__tests__/sandbox-manager.test.js +0 -144
  174. package/dist/__tests__/sandbox-mount-security.test.js +0 -139
  175. package/dist/__tests__/sandbox-runtime.test.js +0 -176
  176. package/dist/__tests__/subagent.test.js +0 -240
  177. package/dist/__tests__/telegram.test.js +0 -42
  178. package/dist/code-agents/orchestrator.d.ts +0 -29
  179. package/dist/code-agents/orchestrator.js +0 -694
  180. package/dist/code-agents/worktree.d.ts +0 -40
  181. package/dist/code-agents/worktree.js +0 -215
  182. package/dist/dashboard/assets/index-BoTHPby4.js +0 -65
  183. package/dist/dashboard/assets/index-D4mufvBg.css +0 -1
  184. package/dist/dashboard.d.ts +0 -8
  185. package/dist/dashboard.js +0 -4071
  186. package/dist/discord.d.ts +0 -8
  187. package/dist/discord.js +0 -792
  188. package/dist/mcp-context-a8c.d.ts +0 -13
  189. package/dist/mcp-context-a8c.js +0 -34
  190. package/dist/orchestrator.d.ts +0 -15
  191. package/dist/orchestrator.js +0 -676
  192. package/dist/providers/openai.d.ts +0 -10
  193. package/dist/providers/openai.js +0 -355
  194. package/dist/sandbox/bridge.d.ts +0 -5
  195. package/dist/sandbox/bridge.js +0 -63
  196. package/dist/sandbox/index.d.ts +0 -5
  197. package/dist/sandbox/index.js +0 -4
  198. package/dist/sandbox/manager.d.ts +0 -7
  199. package/dist/sandbox/manager.js +0 -100
  200. package/dist/sandbox/mount-security.d.ts +0 -12
  201. package/dist/sandbox/mount-security.js +0 -122
  202. package/dist/sandbox/runtime.d.ts +0 -39
  203. package/dist/sandbox/runtime.js +0 -192
  204. package/dist/sandbox-utils.d.ts +0 -6
  205. package/dist/sandbox-utils.js +0 -36
  206. package/dist/subagent.d.ts +0 -19
  207. package/dist/subagent.js +0 -407
  208. package/dist/telegram.d.ts +0 -2
  209. package/dist/telegram.js +0 -11
  210. package/dist/tools/browser-tool.d.ts +0 -3
  211. package/dist/tools/browser-tool.js +0 -266
  212. package/sandbox/Dockerfile +0 -40
  213. /package/dist/__tests__/{audit.test.d.ts → code-agents-notifications.test.d.ts} +0 -0
  214. /package/dist/__tests__/{code-agents-orchestrator.test.d.ts → code-agents-worktrees.test.d.ts} +0 -0
  215. /package/dist/__tests__/{code-agents-sandbox.test.d.ts → codex-adapter.test.d.ts} +0 -0
  216. /package/dist/__tests__/{orchestrator.test.d.ts → codex-auth.test.d.ts} +0 -0
  217. /package/dist/__tests__/{sandbox-bridge.test.d.ts → codex-provider-gating.test.d.ts} +0 -0
  218. /package/dist/__tests__/{sandbox-manager.test.d.ts → codex-unified-loop.test.d.ts} +0 -0
  219. /package/dist/__tests__/{sandbox-mount-security.test.d.ts → config-security.test.d.ts} +0 -0
  220. /package/dist/__tests__/{sandbox-runtime.test.d.ts → cron-run.test.d.ts} +0 -0
  221. /package/dist/__tests__/{subagent.test.d.ts → digests.test.d.ts} +0 -0
  222. /package/dist/__tests__/{telegram.test.d.ts → discord-attachments.test.d.ts} +0 -0
@@ -1,8 +1,8 @@
1
- import { ActionRowBuilder, ButtonBuilder, ButtonStyle, AttachmentBuilder, } from 'discord.js';
1
+ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, AttachmentBuilder, ChannelType, } from 'discord.js';
2
2
  import { join } from 'path';
3
3
  import { tmpdir } from 'os';
4
4
  import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
5
- import { getCurrentModel, setCurrentModel } from '../../gateway.js';
5
+ import { getCurrentModel, getCurrentThinking, setCurrentModel, setCurrentThinking } from '../../gateway.js';
6
6
  import { getCronJobs, runCronJob } from '../../cron.js';
7
7
  import { runAgentTurn } from '../../agent.js';
8
8
  import { runHeartbeatCheck } from '../../heartbeat.js';
@@ -13,14 +13,554 @@ import { transcribeAudio, synthesizeSpeech } from '../../voice.js';
13
13
  import * as sessions from '../../sessions.js';
14
14
  import { formatAliases, formatModelSelectionError, getModelSelectionUsage, resolveModelSelection } from '../../model-selection.js';
15
15
  import { KNOWN_COMMANDS } from './types.js';
16
- import { getHistory, addToHistory, clearHistory, replaceHistory, getDiscordToolConfig, getDiscordRunContext, conversationKey, buildHelpText, sendLongText, startTypingIndicator, } from './utils.js';
16
+ import { getHistory, addToHistory, clearHistory, replaceHistory, getDiscordToolConfig, getDiscordRunContext, conversationKey, buildCodeAgentThreadContext, buildHelpText, sendLongText, sendLongTextToChannel, startTypingIndicator, startTypingIndicatorForChannel, } from './utils.js';
17
+ import { createTaskThread, buildThreadUrl } from './threads.js';
18
+ import { bindThreadAgent, getAgentProfileByAlias, getThreadAgentByThreadId, listAgentProfiles, parseDiscordAgentMention, removeAgentProfile, setAgentProfileModel, setAgentProfilePrompt, setAgentProfileThinking, upsertAgentProfile, } from './thread-agents.js';
19
+ import { isDocumentAttachment, processAttachments, supportedExtensions } from './attachments.js';
20
+ import { getSession, linkThread } from '../../code-agents/interactive-sessions.js';
21
+ import { handleInteractiveThreadMessage } from '../../code-agents/interactive-resume.js';
17
22
  // ── Command handler ─────────────────────────────────────────────────
23
+ const THREAD_AGENT_USAGE = [
24
+ 'Usage:',
25
+ '/agent create <alias> [agent-id]',
26
+ '/agent use <alias> [message]',
27
+ '/agent model [alias] <model-alias|provider/model|model-id>',
28
+ '/agent effort [alias] <none|low|medium|high|xhigh>',
29
+ '/agent prompt [alias] <prompt text>',
30
+ '/agent delete <alias>',
31
+ '/agent list',
32
+ '@alias <message>',
33
+ ].join('\n');
34
+ const THINKING_LEVELS = ['none', 'low', 'medium', 'high', 'xhigh'];
35
+ const AGENT_PROMPT_MAX_CHARS = 20_000;
36
+ function parseThinkingLevel(value) {
37
+ const normalized = (value || '').trim().toLowerCase().replace(/^x[-_ ]?high$/, 'xhigh');
38
+ if (normalized === 'off')
39
+ return 'none';
40
+ return THINKING_LEVELS.includes(normalized) ? normalized : null;
41
+ }
42
+ function formatThinkingUsage(command = '/effort') {
43
+ return `Usage: ${command} <${THINKING_LEVELS.join('|')}>`;
44
+ }
45
+ function formatAgentIds(config) {
46
+ return Object.keys(config.agents.list).join(', ') || '(none configured)';
47
+ }
48
+ function resolveConfiguredAgentId(config, rawAgentId) {
49
+ const candidate = (rawAgentId || config.agents.default).trim();
50
+ return config.agents.list[candidate] ? candidate : null;
51
+ }
52
+ function formatThreadAgent(record) {
53
+ const prompt = record.promptOverlay
54
+ ? `\nPrompt: ${record.promptOverlay.length > 120 ? record.promptOverlay.slice(0, 120) + '...' : record.promptOverlay}`
55
+ : '';
56
+ const model = record.model ? `, model ${record.model}` : '';
57
+ const thinking = record.thinking ? `, effort ${record.thinking}` : '';
58
+ return `This thread uses @${record.alias} -> ${record.agentId}${model}${thinking}${prompt}`;
59
+ }
60
+ function formatAgentProfile(profile) {
61
+ const prompt = profile.promptOverlay
62
+ ? `\nPrompt: ${profile.promptOverlay.length > 120 ? profile.promptOverlay.slice(0, 120) + '...' : profile.promptOverlay}`
63
+ : '';
64
+ const model = profile.model ? `, model ${profile.model}` : '';
65
+ const thinking = profile.thinking ? `, effort ${profile.thinking}` : '';
66
+ return `@${profile.alias} -> ${profile.agentId}${model}${thinking}${prompt}`;
67
+ }
68
+ function formatThreadAgentThreadName(alias, taskText) {
69
+ const task = (taskText || '')
70
+ .replace(/https?:\/\/\S+\/pull\/(\d+)\S*/g, '#$1')
71
+ .replace(/https?:\/\/\S+\/issues\/(\d+)\S*/g, '#$1')
72
+ .replace(/https?:\/\/\S+/g, '')
73
+ .replace(/\s+/g, ' ')
74
+ .trim();
75
+ const base = task || 'agent';
76
+ const maxTaskLength = Math.max(10, 96 - alias.length);
77
+ const suffix = base.length > maxTaskLength ? `${base.slice(0, maxTaskLength - 3).trim()}...` : base;
78
+ return `${alias}: ${suffix}`.slice(0, 100);
79
+ }
80
+ function isDiscordThreadChannel(channel) {
81
+ if (channel.isDMBased())
82
+ return false;
83
+ if (channel.isThread())
84
+ return true;
85
+ const channelType = channel.type;
86
+ return channelType === ChannelType.PublicThread
87
+ || channelType === ChannelType.PrivateThread
88
+ || channelType === ChannelType.AnnouncementThread;
89
+ }
90
+ async function createThreadAgentThread(message, alias, taskText) {
91
+ if (message.channel.isDMBased())
92
+ return null;
93
+ if (!('threads' in message.channel))
94
+ return null;
95
+ try {
96
+ const thread = await message.startThread({
97
+ name: formatThreadAgentThreadName(alias, taskText),
98
+ autoArchiveDuration: 1440,
99
+ });
100
+ return thread;
101
+ }
102
+ catch (err) {
103
+ console.error('[discord-thread-agents] Failed to create thread agent thread:', err);
104
+ return null;
105
+ }
106
+ }
107
+ function getThreadAgentForMessage(message, config) {
108
+ if (!isDiscordThreadChannel(message.channel))
109
+ return null;
110
+ const record = getThreadAgentByThreadId(message.channel.id);
111
+ if (!record)
112
+ return null;
113
+ if (!config.agents.list[record.agentId])
114
+ return null;
115
+ return record;
116
+ }
117
+ function profileAsThreadAgent(profile, message, channel) {
118
+ const channelId = isThreadChannel(channel)
119
+ ? channel.parentId ?? message.channelId
120
+ : message.channelId;
121
+ return {
122
+ ...profile,
123
+ threadId: channel.id,
124
+ profileAlias: profile.alias,
125
+ guildId: message.guildId || undefined,
126
+ channelId,
127
+ };
128
+ }
129
+ function isDMBasedChannel(channel) {
130
+ return Boolean(channel.isDMBased?.());
131
+ }
132
+ function isThreadChannel(channel) {
133
+ return Boolean(channel.isThread?.());
134
+ }
135
+ function conversationKeyForChannel(message, channel) {
136
+ if (isDMBasedChannel(channel)) {
137
+ return `dm:${message.author.id}`;
138
+ }
139
+ return `channel:${channel.id}`;
140
+ }
141
+ function sendableChannel(channel) {
142
+ return channel;
143
+ }
144
+ function getDiscordRunContextForChannel(message, channel) {
145
+ const context = getDiscordRunContext(message);
146
+ const isDm = isDMBasedChannel(channel);
147
+ const isThread = !isDm && isThreadChannel(channel);
148
+ return {
149
+ ...context,
150
+ sessionId: channel.id,
151
+ metadata: {
152
+ ...(context.metadata || {}),
153
+ isDm,
154
+ ...(isThread ? {
155
+ discordThreadId: channel.id,
156
+ discordChannelId: channel.parentId ?? message.channelId,
157
+ } : {}),
158
+ },
159
+ };
160
+ }
161
+ function getThreadAgentRunContext(message, threadAgent, channel = message.channel) {
162
+ const context = getDiscordRunContextForChannel(message, channel);
163
+ if (!threadAgent) {
164
+ return {
165
+ ...context,
166
+ metadata: {
167
+ ...(context.metadata || {}),
168
+ thinkingOverride: getCurrentThinking(),
169
+ },
170
+ };
171
+ }
172
+ return {
173
+ ...context,
174
+ metadata: {
175
+ ...(context.metadata || {}),
176
+ threadAgentAlias: threadAgent.alias,
177
+ threadAgentId: threadAgent.agentId,
178
+ threadAgentModel: threadAgent.model,
179
+ threadAgentThinking: threadAgent.thinking,
180
+ thinkingOverride: threadAgent.thinking ?? getCurrentThinking(),
181
+ threadAgentPromptOverlay: threadAgent.promptOverlay,
182
+ },
183
+ };
184
+ }
185
+ async function runThreadAgentPrompt(message, targetChannel, threadAgent, promptText, config, options = {}) {
186
+ const prompt = promptText.trim();
187
+ if (!prompt)
188
+ return;
189
+ if (options.announceTask) {
190
+ await sendLongTextToChannel(sendableChannel(targetChannel), `Task from @${message.author.username || message.author.id}:\n${prompt}`);
191
+ }
192
+ const stopTyping = startTypingIndicatorForChannel(targetChannel);
193
+ try {
194
+ const key = conversationKeyForChannel(message, targetChannel);
195
+ const history = await getHistory(key);
196
+ const response = await runAgentTurn(threadAgent.agentId, prompt, config, threadAgent.model || getCurrentModel(), getDiscordToolConfig(config), history, getThreadAgentRunContext(message, threadAgent, targetChannel));
197
+ await addToHistory(key, prompt, response);
198
+ await sendLongTextToChannel(sendableChannel(targetChannel), response);
199
+ }
200
+ catch (error) {
201
+ const msg = error instanceof Error ? error.message : 'Unknown error';
202
+ await sendLongTextToChannel(sendableChannel(targetChannel), `Error: ${msg}`);
203
+ }
204
+ finally {
205
+ stopTyping();
206
+ }
207
+ }
208
+ async function runMentionedAgentPrompt(message, text, config) {
209
+ const invocation = parseDiscordAgentMention(text);
210
+ if (!invocation)
211
+ return false;
212
+ const profile = getAgentProfileByAlias(invocation.alias);
213
+ if (!profile) {
214
+ await message.reply(`No Discord agent profile found for @${invocation.alias}. Create it with /agent create ${invocation.alias}.`);
215
+ return true;
216
+ }
217
+ if (!config.agents.list[profile.agentId]) {
218
+ await message.reply(`Agent profile @${profile.alias} points to missing configured agent "${profile.agentId}".`);
219
+ return true;
220
+ }
221
+ if (!invocation.prompt) {
222
+ await message.reply(`Usage: @${profile.alias} <message>`);
223
+ return true;
224
+ }
225
+ try {
226
+ if (message.channel.isDMBased()) {
227
+ const record = profileAsThreadAgent(profile, message, message.channel);
228
+ await runThreadAgentPrompt(message, message.channel, record, invocation.prompt, config);
229
+ return true;
230
+ }
231
+ const isThread = isDiscordThreadChannel(message.channel);
232
+ const targetChannel = isThread
233
+ ? message.channel
234
+ : await createThreadAgentThread(message, profile.alias, invocation.prompt);
235
+ if (!targetChannel) {
236
+ await message.reply('Agent mentions can only run in a DM, inside a Discord thread, or from a channel where I can create one.');
237
+ return true;
238
+ }
239
+ const record = bindThreadAgent({
240
+ threadId: targetChannel.id,
241
+ alias: profile.alias,
242
+ createdBy: message.author.id,
243
+ guildId: message.guildId,
244
+ channelId: targetChannel.isThread() ? targetChannel.parentId ?? message.channelId : message.channelId,
245
+ });
246
+ if (!isThread) {
247
+ const url = buildThreadUrl(message.guildId, targetChannel.id);
248
+ await message.reply(url ? `Started @${profile.alias}: ${url}` : `Started @${profile.alias}.`);
249
+ }
250
+ await runThreadAgentPrompt(message, targetChannel, record, invocation.prompt, config, {
251
+ announceTask: !isThread,
252
+ });
253
+ return true;
254
+ }
255
+ catch (err) {
256
+ const msg = err instanceof Error ? err.message : String(err);
257
+ await message.reply(`Error: ${msg}`);
258
+ return true;
259
+ }
260
+ }
261
+ async function readPromptAttachment(message) {
262
+ const docAttachments = message.attachments.filter(a => isDocumentAttachment(a));
263
+ if (docAttachments.size === 0)
264
+ return {};
265
+ const results = await processAttachments([...docAttachments.values()]);
266
+ const firstOk = results.find(result => result.ok && result.text?.trim());
267
+ if (firstOk?.text) {
268
+ return {
269
+ text: firstOk.text.trim(),
270
+ filename: firstOk.filename,
271
+ };
272
+ }
273
+ const errors = results.map(result => result.error).filter(Boolean);
274
+ return {
275
+ error: errors.join('\n') || `Could not read attached prompt. Supported file types: ${supportedExtensions().map(e => `.${e}`).join(', ')}`,
276
+ };
277
+ }
278
+ function normalizeAliasArg(value) {
279
+ return (value || '').trim().replace(/^@/, '').toLowerCase();
280
+ }
281
+ function getAgentProfileCommandTarget(message, alias) {
282
+ const normalizedAlias = normalizeAliasArg(alias);
283
+ if (normalizedAlias) {
284
+ const profile = getAgentProfileByAlias(normalizedAlias);
285
+ return profile ? { profile } : { profile: null, error: `No Discord agent profile found for @${normalizedAlias}.` };
286
+ }
287
+ if (isDiscordThreadChannel(message.channel)) {
288
+ const record = getThreadAgentByThreadId(message.channel.id);
289
+ if (record)
290
+ return { profile: getAgentProfileByAlias(record.alias), record };
291
+ }
292
+ const profiles = listAgentProfiles();
293
+ if (profiles.length === 1)
294
+ return { profile: profiles[0] };
295
+ return {
296
+ profile: null,
297
+ error: `No target agent profile found. Pass an alias. Known profiles: ${profiles.map(profile => `@${profile.alias}`).join(', ') || '(none)'}.`,
298
+ };
299
+ }
300
+ function getOrCreateAgentProfileCommandTarget(message, config, alias) {
301
+ const normalizedAlias = normalizeAliasArg(alias);
302
+ if (!normalizedAlias)
303
+ return getAgentProfileCommandTarget(message);
304
+ const existing = getAgentProfileByAlias(normalizedAlias);
305
+ if (existing)
306
+ return { profile: existing };
307
+ try {
308
+ return {
309
+ profile: upsertAgentProfile({
310
+ alias: normalizedAlias,
311
+ agentId: config.agents.default,
312
+ createdBy: message.author.id,
313
+ }),
314
+ created: true,
315
+ };
316
+ }
317
+ catch (err) {
318
+ const msg = err instanceof Error ? err.message : String(err);
319
+ return { profile: null, error: msg };
320
+ }
321
+ }
322
+ async function handleEffortCommand(message, args) {
323
+ if (!args[0]) {
324
+ await message.reply(`Current effort: ${getCurrentThinking() || 'default'}\n${formatThinkingUsage()}`);
325
+ return;
326
+ }
327
+ const next = parseThinkingLevel(args[0]);
328
+ if (!next) {
329
+ await message.reply(`Invalid effort: ${args[0]}\n${formatThinkingUsage()}`);
330
+ return;
331
+ }
332
+ setCurrentThinking(next);
333
+ await message.reply(`Effort set to: ${next}`);
334
+ }
335
+ async function handleThreadAgentCommand(message, args, config) {
336
+ const subcommand = (args[0] || 'show').toLowerCase();
337
+ if (subcommand === 'list') {
338
+ const profiles = listAgentProfiles();
339
+ if (profiles.length === 0) {
340
+ await message.reply('No Discord agent profiles configured.');
341
+ return;
342
+ }
343
+ const profileText = profiles.map(formatAgentProfile).join('\n\n');
344
+ await sendLongText(message, `Profiles:\n${profileText}`);
345
+ return;
346
+ }
347
+ const isThread = isDiscordThreadChannel(message.channel);
348
+ if (subcommand === 'show' || subcommand === 'status') {
349
+ if (!isThread) {
350
+ await message.reply(THREAD_AGENT_USAGE);
351
+ return;
352
+ }
353
+ const record = getThreadAgentByThreadId(message.channel.id);
354
+ await message.reply(record ? formatThreadAgent(record) : `No agent profile is active in this thread.\n\n${THREAD_AGENT_USAGE}`);
355
+ return;
356
+ }
357
+ if (subcommand === 'create') {
358
+ const alias = args[1];
359
+ const agentId = resolveConfiguredAgentId(config, args[2]);
360
+ if (!alias || !agentId) {
361
+ await message.reply(`Usage: /agent create <alias> [agent-id]\nConfigured agents: ${formatAgentIds(config)}`);
362
+ return;
363
+ }
364
+ try {
365
+ const profile = upsertAgentProfile({
366
+ alias,
367
+ agentId,
368
+ createdBy: message.author.id,
369
+ });
370
+ await message.reply(`Configured agent profile ${formatAgentProfile(profile)}`);
371
+ }
372
+ catch (err) {
373
+ const msg = err instanceof Error ? err.message : String(err);
374
+ await message.reply(`Error: ${msg}`);
375
+ }
376
+ return;
377
+ }
378
+ if (subcommand === 'use') {
379
+ const alias = args[1];
380
+ const profile = getAgentProfileByAlias(alias);
381
+ if (!alias || !profile) {
382
+ await message.reply(`Usage: /agent use <alias> [message]\nKnown profiles: ${listAgentProfiles().map(p => p.alias).join(', ') || '(none)'}`);
383
+ return;
384
+ }
385
+ const initialPrompt = args.slice(2).join(' ').trim();
386
+ try {
387
+ const targetChannel = isThread
388
+ ? message.channel
389
+ : await createThreadAgentThread(message, profile.alias, initialPrompt);
390
+ if (!targetChannel) {
391
+ await message.reply('Agent profiles can only run in a Discord thread or from a channel where I can create one.');
392
+ return;
393
+ }
394
+ const record = bindThreadAgent({
395
+ threadId: targetChannel.id,
396
+ alias: profile.alias,
397
+ createdBy: message.author.id,
398
+ guildId: message.guildId,
399
+ channelId: targetChannel.isThread() ? targetChannel.parentId ?? message.channelId : message.channelId,
400
+ });
401
+ const url = buildThreadUrl(message.guildId, targetChannel.id);
402
+ await message.reply(!isThread && url
403
+ ? `Started @${profile.alias}: ${url}`
404
+ : `This thread now uses ${formatAgentProfile(profile)}`);
405
+ if (initialPrompt) {
406
+ await runThreadAgentPrompt(message, targetChannel, record, initialPrompt, config, {
407
+ announceTask: !isThread,
408
+ });
409
+ }
410
+ }
411
+ catch (err) {
412
+ const msg = err instanceof Error ? err.message : String(err);
413
+ await message.reply(`Error: ${msg}`);
414
+ }
415
+ return;
416
+ }
417
+ if (subcommand === 'prompt') {
418
+ let alias;
419
+ let promptStart = 1;
420
+ const attachmentPrompt = await readPromptAttachment(message);
421
+ if (attachmentPrompt.error) {
422
+ await message.reply(attachmentPrompt.error);
423
+ return;
424
+ }
425
+ if (args[1]) {
426
+ const candidateAlias = normalizeAliasArg(args[1]);
427
+ if ((attachmentPrompt.text && args.length === 2) || (args.length > 2 && getAgentProfileByAlias(candidateAlias))) {
428
+ alias = args[1];
429
+ promptStart = 2;
430
+ }
431
+ }
432
+ const inlinePrompt = args.slice(promptStart).join(' ').trim();
433
+ const prompt = attachmentPrompt.text || inlinePrompt;
434
+ if (!prompt) {
435
+ await message.reply('Usage: /agent prompt [@alias] <prompt text>\nYou can also attach a .txt or .md file.');
436
+ return;
437
+ }
438
+ if (prompt.length > AGENT_PROMPT_MAX_CHARS) {
439
+ await message.reply(`Prompt is too long. Keep agent prompts under ${AGENT_PROMPT_MAX_CHARS.toLocaleString()} characters.`);
440
+ return;
441
+ }
442
+ const target = getAgentProfileCommandTarget(message, alias);
443
+ if (!target.profile) {
444
+ await message.reply(`${target.error || 'No target agent profile found.'}\n\n${THREAD_AGENT_USAGE}`);
445
+ return;
446
+ }
447
+ const profile = setAgentProfilePrompt(target.profile.alias, prompt);
448
+ if (!profile)
449
+ return;
450
+ const source = attachmentPrompt.filename ? ` from ${attachmentPrompt.filename}` : '';
451
+ await message.reply(`Updated prompt for @${profile.alias}${source}.`);
452
+ return;
453
+ }
454
+ if (subcommand === 'model') {
455
+ let alias;
456
+ let modelStart = 1;
457
+ if (args[1] && args.length > 2) {
458
+ alias = args[1];
459
+ modelStart = 2;
460
+ }
461
+ const modelInput = args.slice(modelStart).join(' ').trim();
462
+ if (!modelInput) {
463
+ await message.reply(`Usage: /agent model [agent-alias] <model-alias|provider/model|model-id>\nAvailable model aliases: ${formatAliases(config)}`);
464
+ return;
465
+ }
466
+ const selection = resolveModelSelection(modelInput, config);
467
+ if (!selection.ok || !selection.resolved) {
468
+ await message.reply(formatModelSelectionError(selection.error || 'Invalid model selection', config));
469
+ return;
470
+ }
471
+ const target = getOrCreateAgentProfileCommandTarget(message, config, alias);
472
+ if (!target.profile) {
473
+ await message.reply(`${target.error || 'No target agent profile found.'}\n\n${THREAD_AGENT_USAGE}`);
474
+ return;
475
+ }
476
+ const profile = setAgentProfileModel(target.profile.alias, selection.resolved);
477
+ if (!profile)
478
+ return;
479
+ const aliasText = selection.aliasUsed ? ` (${selection.aliasUsed})` : '';
480
+ const createdText = target.created ? `Created agent profile @${profile.alias} -> ${profile.agentId}\n` : '';
481
+ await message.reply(`${createdText}Updated model for @${profile.alias}: ${selection.resolved}${aliasText}`);
482
+ return;
483
+ }
484
+ if (subcommand === 'think' || subcommand === 'effort') {
485
+ let alias;
486
+ let effortArg = args[1];
487
+ if (args[1] && args.length > 2) {
488
+ alias = args[1];
489
+ effortArg = args[2];
490
+ }
491
+ const next = parseThinkingLevel(effortArg);
492
+ if (!effortArg || !next) {
493
+ await message.reply(`Usage: /agent ${subcommand} [agent-alias] <${THINKING_LEVELS.join('|')}>`);
494
+ return;
495
+ }
496
+ const target = getOrCreateAgentProfileCommandTarget(message, config, alias);
497
+ if (!target.profile) {
498
+ await message.reply(`${target.error || 'No target agent profile found.'}\n\n${THREAD_AGENT_USAGE}`);
499
+ return;
500
+ }
501
+ const profile = setAgentProfileThinking(target.profile.alias, next);
502
+ if (!profile)
503
+ return;
504
+ const createdText = target.created ? `Created agent profile @${profile.alias} -> ${profile.agentId}\n` : '';
505
+ await message.reply(`${createdText}Updated effort for @${profile.alias}: ${next}`);
506
+ return;
507
+ }
508
+ if (subcommand === 'clear-model') {
509
+ const target = getAgentProfileCommandTarget(message, args[1]);
510
+ if (!target.profile) {
511
+ await message.reply(target.error || 'No target agent profile found.');
512
+ return;
513
+ }
514
+ const profile = setAgentProfileModel(target.profile.alias, undefined);
515
+ await message.reply(profile ? `Cleared model override for @${profile.alias}.` : 'No target agent profile found.');
516
+ return;
517
+ }
518
+ if (subcommand === 'clear-think' || subcommand === 'clear-effort') {
519
+ const target = getAgentProfileCommandTarget(message, args[1]);
520
+ if (!target.profile) {
521
+ await message.reply(target.error || 'No target agent profile found.');
522
+ return;
523
+ }
524
+ const profile = setAgentProfileThinking(target.profile.alias, undefined);
525
+ await message.reply(profile ? `Cleared effort override for @${profile.alias}.` : 'No target agent profile found.');
526
+ return;
527
+ }
528
+ if (subcommand === 'clear-prompt') {
529
+ const target = getAgentProfileCommandTarget(message, args[1]);
530
+ if (!target.profile) {
531
+ await message.reply(target.error || 'No target agent profile found.');
532
+ return;
533
+ }
534
+ const profile = setAgentProfilePrompt(target.profile.alias, undefined);
535
+ await message.reply(profile ? `Cleared prompt for @${profile.alias}.` : 'No target agent profile found.');
536
+ return;
537
+ }
538
+ if (subcommand === 'delete') {
539
+ const alias = args[1];
540
+ if (!alias) {
541
+ await message.reply('Usage: /agent delete <alias>');
542
+ return;
543
+ }
544
+ const removed = removeAgentProfile(alias);
545
+ await message.reply(removed ? `Deleted agent profile @${normalizeAliasArg(alias)} and its thread bindings.` : `No Discord agent profile found for @${normalizeAliasArg(alias)}.`);
546
+ return;
547
+ }
548
+ await message.reply(THREAD_AGENT_USAGE);
549
+ }
18
550
  export async function handleCommand(message, command, args, config, silenceUntil, setSilenceUntil) {
19
551
  const rawArgs = args.join(' ').trim();
20
552
  if (command === 'start' || command === 'help') {
21
553
  await sendLongText(message, buildHelpText(config));
22
554
  return;
23
555
  }
556
+ if (command === 'agent') {
557
+ await handleThreadAgentCommand(message, args, config);
558
+ return;
559
+ }
560
+ if (command === 'effort' || command === 'think') {
561
+ await handleEffortCommand(message, args);
562
+ return;
563
+ }
24
564
  if (command === 'model') {
25
565
  if (!rawArgs) {
26
566
  const current = getCurrentModel();
@@ -45,6 +585,7 @@ export async function handleCommand(message, command, args, config, silenceUntil
45
585
  }
46
586
  if (command === 'status') {
47
587
  const model = getCurrentModel();
588
+ const effort = getCurrentThinking();
48
589
  const { getLastMessage } = await import('../../gateway.js');
49
590
  const last = getLastMessage();
50
591
  const jobs = getCronJobs();
@@ -77,6 +618,7 @@ export async function handleCommand(message, command, args, config, silenceUntil
77
618
  }
78
619
  await message.reply(`Agent: ${config.agents.default}\n` +
79
620
  `Model: ${model}\n` +
621
+ `Effort: ${effort || 'default'}\n` +
80
622
  `Last message: ${last?.toLocaleString() || 'never'}\n` +
81
623
  `Silence until: ${silenceUntil?.toLocaleString() || 'not silenced'}\n\n` +
82
624
  `${caLine}\n\n` +
@@ -267,6 +809,35 @@ export async function handleIncomingMessage(message, config) {
267
809
  await message.reply('Too many messages. Please wait a moment.');
268
810
  return;
269
811
  }
812
+ // Interactive coding session intercept: if this is a thread bound to an active
813
+ // interactive session, route the message to --resume instead of the main agent.
814
+ if (message.channel.isThread()) {
815
+ try {
816
+ const session = getSession(message.channel.id);
817
+ if (session) {
818
+ const stopTyping = startTypingIndicator(message);
819
+ try {
820
+ await handleInteractiveThreadMessage({
821
+ discordThreadId: message.channel.id,
822
+ userMessage: message.content,
823
+ postToThread: async (chunks) => {
824
+ for (const c of chunks) {
825
+ await message.channel.send(c);
826
+ }
827
+ },
828
+ });
829
+ }
830
+ finally {
831
+ stopTyping();
832
+ }
833
+ return;
834
+ }
835
+ }
836
+ catch (err) {
837
+ console.error('[discord] Interactive-session intercept failed:', err);
838
+ // fall through to normal handling
839
+ }
840
+ }
270
841
  // Check for image attachments
271
842
  const imageAttachments = message.attachments.filter(a => a.contentType?.startsWith('image/'));
272
843
  if (imageAttachments.size > 0) {
@@ -294,7 +865,8 @@ export async function handleIncomingMessage(message, config) {
294
865
  ];
295
866
  const key = conversationKey(message);
296
867
  const history = await getHistory(key);
297
- const response = await runAgentTurn(config.agents.default, content, config, getCurrentModel(), getDiscordToolConfig(config), history, getDiscordRunContext(message));
868
+ const threadAgent = getThreadAgentForMessage(message, config);
869
+ const response = await runAgentTurn(threadAgent?.agentId || config.agents.default, content, config, threadAgent?.model || getCurrentModel(), getDiscordToolConfig(config), history, getThreadAgentRunContext(message, threadAgent));
298
870
  await addToHistory(key, `[Image: ${caption}]`, response);
299
871
  await sendLongText(message, response);
300
872
  }
@@ -307,6 +879,53 @@ export async function handleIncomingMessage(message, config) {
307
879
  }
308
880
  return;
309
881
  }
882
+ // Check for document attachments (txt, pdf, etc.)
883
+ const docAttachments = message.attachments.filter(a => isDocumentAttachment(a));
884
+ if (docAttachments.size > 0) {
885
+ const stopTyping = startTypingIndicator(message);
886
+ try {
887
+ const results = await processAttachments([...docAttachments.values()]);
888
+ const extracted = [];
889
+ const errors = [];
890
+ for (const r of results) {
891
+ if (r.ok && r.text) {
892
+ extracted.push(`--- ${r.filename} ---\n${r.text}`);
893
+ }
894
+ else if (r.error) {
895
+ errors.push(r.error);
896
+ }
897
+ }
898
+ if (errors.length > 0 && extracted.length === 0) {
899
+ // All attachments failed
900
+ const supported = supportedExtensions().map(e => `.${e}`).join(', ');
901
+ await message.reply(errors.join('\n') + `\n\nSupported file types: ${supported}`);
902
+ return;
903
+ }
904
+ // Build context: extracted text + user's message (or default prompt)
905
+ const userText = message.content.trim() || 'Please read and summarize the attached file(s).';
906
+ const attachmentContext = extracted.join('\n\n');
907
+ const prompt = `The user uploaded the following file(s):\n\n${attachmentContext}\n\n${userText}`;
908
+ // Include any errors as a note
909
+ const errorNote = errors.length > 0
910
+ ? `\n\n(Note: some attachments could not be processed: ${errors.join('; ')})`
911
+ : '';
912
+ const key = conversationKey(message);
913
+ const history = await getHistory(key);
914
+ const threadAgent = getThreadAgentForMessage(message, config);
915
+ const response = await runAgentTurn(threadAgent?.agentId || config.agents.default, prompt + errorNote, config, threadAgent?.model || getCurrentModel(), getDiscordToolConfig(config), history, getThreadAgentRunContext(message, threadAgent));
916
+ const filenames = results.map(r => r.filename).join(', ');
917
+ await addToHistory(key, `[Attachments: ${filenames}] ${userText}`, response);
918
+ await sendLongText(message, response);
919
+ }
920
+ catch (error) {
921
+ const msg = error instanceof Error ? error.message : 'Unknown error';
922
+ await message.reply(`Error processing attachment(s): ${msg}`);
923
+ }
924
+ finally {
925
+ stopTyping();
926
+ }
927
+ return;
928
+ }
310
929
  // Check for voice message attachments
311
930
  const voiceAttachments = message.attachments.filter(a => a.contentType?.startsWith('audio/') || a.contentType?.startsWith('voice/'));
312
931
  if (voiceAttachments.size > 0) {
@@ -335,7 +954,8 @@ export async function handleIncomingMessage(message, config) {
335
954
  }
336
955
  const key = conversationKey(message);
337
956
  const history = await getHistory(key);
338
- const agentResponse = await runAgentTurn(config.agents.default, transcription, config, getCurrentModel(), getDiscordToolConfig(config), history, getDiscordRunContext(message));
957
+ const threadAgent = getThreadAgentForMessage(message, config);
958
+ const agentResponse = await runAgentTurn(threadAgent?.agentId || config.agents.default, transcription, config, threadAgent?.model || getCurrentModel(), getDiscordToolConfig(config), history, getThreadAgentRunContext(message, threadAgent));
339
959
  await addToHistory(key, transcription, agentResponse);
340
960
  console.log('[discord] TTS check - sendVoice:', config.voice?.channels?.['discord']?.sendVoice);
341
961
  if (config.voice?.channels?.['discord']?.sendVoice) {
@@ -343,7 +963,7 @@ export async function handleIncomingMessage(message, config) {
343
963
  try {
344
964
  const speech = await synthesizeSpeech(agentResponse, config.voice);
345
965
  console.log('[discord] TTS synthesis success:', speech.format, speech.provider, 'buffer size:', speech.buffer.length);
346
- const voiceAttachment = new AttachmentBuilder(speech.buffer, {
966
+ const voiceAttachment = new AttachmentBuilder(Buffer.from(speech.buffer), {
347
967
  name: `voice-reply.${speech.format}`,
348
968
  description: 'Voice reply'
349
969
  });
@@ -376,6 +996,9 @@ export async function handleIncomingMessage(message, config) {
376
996
  const text = message.content.trim();
377
997
  if (!text)
378
998
  return;
999
+ if (await runMentionedAgentPrompt(message, text, config)) {
1000
+ return;
1001
+ }
379
1002
  const isPrefixedCommand = text.startsWith('/') || text.startsWith('!');
380
1003
  const isDm = message.channel.isDMBased();
381
1004
  if (isPrefixedCommand || isDm) {
@@ -398,9 +1021,73 @@ export async function handleIncomingMessage(message, config) {
398
1021
  const stopTyping = startTypingIndicator(message);
399
1022
  try {
400
1023
  const history = await getHistory(key);
401
- const response = await runAgentTurn(config.agents.default, text, config, getCurrentModel(), getDiscordToolConfig(config), history, getDiscordRunContext(message));
1024
+ const threadAgent = getThreadAgentForMessage(message, config);
1025
+ const codeAgentContext = buildCodeAgentThreadContext(message);
1026
+ const prompt = codeAgentContext
1027
+ ? `${codeAgentContext}\n\nUser message in this Discord thread:\n${text}`
1028
+ : text;
1029
+ const response = await runAgentTurn(threadAgent?.agentId || config.agents.default, prompt, config, threadAgent?.model || getCurrentModel(), getDiscordToolConfig(config), history, getThreadAgentRunContext(message, threadAgent));
402
1030
  await addToHistory(key, text, response);
403
1031
  await sendLongText(message, response);
1032
+ // If the response started coding agent(s), create threads for status updates.
1033
+ // Only consider tasks started within the current turn (last 2 min) — older
1034
+ // unthreaded tasks are stale registry entries that will steal the single
1035
+ // thread Discord allows per message.
1036
+ const useThreads = config.channels.discord?.threadedReplies !== false;
1037
+ if (useThreads && !message.channel.isDMBased()) {
1038
+ try {
1039
+ const { getUnthreadedTasksForChat, writeCodeAgentTask } = await import('../../code-agents/registry.js');
1040
+ const chatId = Number(message.channel.id);
1041
+ const freshnessCutoffMs = Date.now() - 2 * 60 * 1000;
1042
+ const unthreadedTasks = getUnthreadedTasksForChat(chatId).filter(t => {
1043
+ const started = Date.parse(t.startedAt);
1044
+ return Number.isFinite(started) && started >= freshnessCutoffMs;
1045
+ });
1046
+ const isThread = message.channel.isThread();
1047
+ for (const task of unthreadedTasks) {
1048
+ let assignedThreadId;
1049
+ if (isThread) {
1050
+ // Already in a thread — use it directly instead of creating a sub-thread
1051
+ task.discordThreadId = message.channel.id;
1052
+ task.discordChannelId = message.channel.parentId ?? message.channelId;
1053
+ writeCodeAgentTask(task);
1054
+ assignedThreadId = task.discordThreadId;
1055
+ }
1056
+ else {
1057
+ const taskPreview = task.task.length > 90 ? task.task.slice(0, 90) + '...' : task.task;
1058
+ const threadId = await createTaskThread(message, task.id, taskPreview);
1059
+ if (threadId) {
1060
+ task.discordThreadId = threadId;
1061
+ task.discordChannelId = message.channelId;
1062
+ writeCodeAgentTask(task);
1063
+ assignedThreadId = threadId;
1064
+ // Post a clickable link to the new thread for easy mobile access.
1065
+ const url = buildThreadUrl(message.guildId, threadId);
1066
+ if (url) {
1067
+ try {
1068
+ await message.channel.send(`→ ${task.interactive ? 'Interactive session' : 'Thread'} ${task.id}: ${url}`);
1069
+ }
1070
+ catch (err) {
1071
+ console.warn(`[discord] Failed to post thread link for ${task.id}:`, err);
1072
+ }
1073
+ }
1074
+ }
1075
+ }
1076
+ // Promote any pending interactive session from taskId-keyed to threadId-keyed.
1077
+ if (assignedThreadId && task.interactive) {
1078
+ try {
1079
+ linkThread(task.id, assignedThreadId);
1080
+ }
1081
+ catch (err) {
1082
+ console.error(`[discord] Failed to link interactive session ${task.id} → ${assignedThreadId}:`, err);
1083
+ }
1084
+ }
1085
+ }
1086
+ }
1087
+ catch (err) {
1088
+ console.error(`[discord] Failed to create threads for spawned tasks:`, err);
1089
+ }
1090
+ }
404
1091
  }
405
1092
  catch (error) {
406
1093
  const msg = error instanceof Error ? error.message : 'Unknown error';