@@ -3,28 +3,70 @@ import { sendToClaudeCode, type ClaudeModelOptions } from "./client.ts";
33import { convertToClaudeMessages } from "./message-converter.ts" ;
44import { SlashCommandBuilder } from "npm:discord.js@14.14.1" ;
55
6+ // Callback that creates (or retrieves) a session thread and returns a
7+ // sender function bound to that thread.
8+ export interface SessionThreadCallbacks {
9+ /**
10+ * Create a new Discord thread for this session and return a sender bound to it.
11+ * Also posts a summary embed in the main channel linking to the thread.
12+ *
13+ * @param prompt The user's prompt (used to name the thread)
14+ * @param sessionId Optional pre-existing session ID (reuses thread if one exists)
15+ * @returns Object with the thread-bound sender and a placeholder session key
16+ */
17+ createThreadSender ( prompt : string , sessionId ?: string , threadName ?: string ) : Promise < {
18+ sender : ( messages : ClaudeMessage [ ] ) => Promise < void > ;
19+ threadSessionKey : string ;
20+ threadChannelId : string ;
21+ } > ;
22+ /**
23+ * Look up an existing thread for a session (does NOT create one).
24+ * Returns undefined if the session has no thread.
25+ */
26+ getThreadSender ( sessionId : string ) : Promise < {
27+ sender : ( messages : ClaudeMessage [ ] ) => Promise < void > ;
28+ threadSessionKey : string ;
29+ } | undefined > ;
30+ /**
31+ * Update the session key mapping when the real SDK session ID arrives.
32+ */
33+ updateSessionId ( oldKey : string , newSessionId : string ) : void ;
34+ }
35+
636// Discord command definitions
737export const claudeCommands = [
838 new SlashCommandBuilder ( )
939 . setName ( 'claude' )
10- . setDescription ( 'Send message to Claude Code' )
40+ . setDescription ( 'Send message to Claude Code (auto-continues in current channel) ' )
1141 . addStringOption ( option =>
1242 option . setName ( 'prompt' )
1343 . setDescription ( 'Prompt for Claude Code' )
1444 . setRequired ( true ) )
1545 . addStringOption ( option =>
1646 option . setName ( 'session_id' )
17- . setDescription ( 'Session ID to continue (optional)' )
47+ . setDescription ( 'Session ID to resume (optional)' )
1848 . setRequired ( false ) ) ,
19-
49+
50+ new SlashCommandBuilder ( )
51+ . setName ( 'claude-thread' )
52+ . setDescription ( 'Start a new Claude session in a dedicated thread' )
53+ . addStringOption ( option =>
54+ option . setName ( 'name' )
55+ . setDescription ( 'Thread name' )
56+ . setRequired ( true ) )
57+ . addStringOption ( option =>
58+ option . setName ( 'prompt' )
59+ . setDescription ( 'Prompt for Claude Code' )
60+ . setRequired ( true ) ) ,
61+
2062 new SlashCommandBuilder ( )
2163 . setName ( 'resume' )
22- . setDescription ( 'Resume the previous Claude Code session' )
64+ . setDescription ( 'Resume the most recent Claude Code session (across all channels) ' )
2365 . addStringOption ( option =>
2466 option . setName ( 'prompt' )
2567 . setDescription ( 'Prompt for Claude Code (optional)' )
2668 . setRequired ( false ) ) ,
27-
69+
2870 new SlashCommandBuilder ( )
2971 . setName ( 'claude-cancel' )
3072 . setDescription ( 'Cancel currently running Claude Code command' ) ,
@@ -34,134 +76,257 @@ export interface ClaudeHandlerDeps {
3476 workDir : string ;
3577 getClaudeController : ( ) => AbortController | null ;
3678 setClaudeController : ( controller : AbortController | null ) => void ;
79+ /** Get session ID for a specific channel/thread (per-channel tracking) */
80+ getSessionForChannel : ( channelId : string ) => string | undefined ;
81+ /** Set session ID for a specific channel/thread */
82+ setSessionForChannel : ( channelId : string , sessionId : string | undefined ) => void ;
83+ /** Legacy global getter (for /resume — find most recent across channels) */
84+ getClaudeSessionId : ( ) => string | undefined ;
85+ /** Legacy global setter (keeps backward compat for session manager) */
3786 setClaudeSessionId : ( sessionId : string | undefined ) => void ;
87+ /** Default sender — used when no thread is available (fallback) */
3888 sendClaudeMessages : ( messages : ClaudeMessage [ ] ) => Promise < void > ;
3989 /** Get current runtime options from unified settings (thinking, operation, proxy) */
4090 getQueryOptions ?: ( ) => ClaudeModelOptions ;
91+ /** Thread-per-session callbacks (optional — when absent, falls back to main channel) */
92+ sessionThreads ?: SessionThreadCallbacks ;
4193}
4294
4395export function createClaudeHandlers ( deps : ClaudeHandlerDeps ) {
4496 const { workDir, sendClaudeMessages } = deps ;
45-
97+
4698 return {
99+ /**
100+ * /claude — Send a message to Claude. Auto-continues the session active in the
101+ * current channel/thread. Starts a new session only if there isn't one yet.
102+ */
103+ // deno-lint-ignore no-explicit-any
104+ async onClaude ( ctx : any , prompt : string , channelId : string , explicitSessionId ?: string ) : Promise < ClaudeResponse > {
105+ const existingController = deps . getClaudeController ( ) ;
106+ if ( existingController ) {
107+ existingController . abort ( ) ;
108+ }
109+
110+ const controller = new AbortController ( ) ;
111+ deps . setClaudeController ( controller ) ;
112+
113+ await ctx . deferReply ( ) ;
114+
115+ // Resolve which session to resume:
116+ // 1) Explicit session_id from user → resume that
117+ // 2) Active session in this channel/thread → resume that
118+ // 3) None → start a new session
119+ const activeSessionId = explicitSessionId || deps . getSessionForChannel ( channelId ) ;
120+
121+ // Pick the right sender — if this channel has a thread, use it
122+ let activeSender = sendClaudeMessages ;
123+ if ( activeSessionId && deps . sessionThreads ) {
124+ try {
125+ const existing = await deps . sessionThreads . getThreadSender ( activeSessionId ) ;
126+ if ( existing ) {
127+ activeSender = existing . sender ;
128+ }
129+ } catch { /* fallback to main sender */ }
130+ }
131+
132+ const isResuming = ! ! activeSessionId ;
133+
134+ await ctx . editReply ( {
135+ embeds : [ {
136+ color : 0xffff00 ,
137+ title : isResuming ? 'Claude Code Continuing...' : 'Claude Code Running...' ,
138+ description : isResuming ? 'Continuing session...' : 'Starting new session...' ,
139+ fields : [ { name : 'Prompt' , value : `\`${ prompt . substring ( 0 , 1020 ) } \`` , inline : false } ] ,
140+ timestamp : true
141+ } ]
142+ } ) ;
143+
144+ const result = await sendToClaudeCode (
145+ workDir ,
146+ prompt ,
147+ controller ,
148+ activeSessionId , // resume if present, new session if undefined
149+ undefined ,
150+ ( jsonData ) => {
151+ const claudeMessages = convertToClaudeMessages ( jsonData ) ;
152+ if ( claudeMessages . length > 0 ) {
153+ activeSender ( claudeMessages ) . catch ( ( ) => { } ) ;
154+ }
155+ } ,
156+ false ,
157+ deps . getQueryOptions ?.( )
158+ ) ;
159+
160+ // Track session per-channel and globally
161+ if ( result . sessionId ) {
162+ deps . setSessionForChannel ( channelId , result . sessionId ) ;
163+ }
164+ deps . setClaudeSessionId ( result . sessionId ) ;
165+ deps . setClaudeController ( null ) ;
166+
167+ return result ;
168+ } ,
169+
170+ /**
171+ * /claude-thread — Start a brand-new session in a dedicated Discord thread.
172+ */
47173 // deno-lint-ignore no-explicit-any
48- async onClaude ( ctx : any , prompt : string , sessionId ?: string ) : Promise < ClaudeResponse > {
49- // Cancel any existing session
174+ async onClaudeThread ( ctx : any , prompt : string , threadName ?: string ) : Promise < ClaudeResponse > {
50175 const existingController = deps . getClaudeController ( ) ;
51176 if ( existingController ) {
52177 existingController . abort ( ) ;
53178 }
54-
179+
55180 const controller = new AbortController ( ) ;
56181 deps . setClaudeController ( controller ) ;
57-
58- // Defer interaction (execute first)
182+
59183 await ctx . deferReply ( ) ;
60-
61- // Send initial message
184+
185+ // Create a dedicated thread for this session
186+ let activeSender = sendClaudeMessages ;
187+ let threadSessionKey : string | undefined ;
188+ let threadChannelId : string | undefined ;
189+
190+ if ( deps . sessionThreads ) {
191+ try {
192+ const threadResult = await deps . sessionThreads . createThreadSender ( prompt , undefined , threadName ) ;
193+ activeSender = threadResult . sender ;
194+ threadSessionKey = threadResult . threadSessionKey ;
195+ threadChannelId = threadResult . threadChannelId ;
196+ } catch ( err ) {
197+ console . warn ( '[SessionThread] Could not create thread, falling back to main channel:' , err ) ;
198+ }
199+ }
200+
62201 await ctx . editReply ( {
63202 embeds : [ {
64203 color : 0xffff00 ,
65204 title : 'Claude Code Running...' ,
66- description : 'Waiting for response...' ,
205+ description : threadSessionKey
206+ ? 'Session started in a dedicated thread — check below ↓'
207+ : 'Starting new session...' ,
67208 fields : [ { name : 'Prompt' , value : `\`${ prompt . substring ( 0 , 1020 ) } \`` , inline : false } ] ,
68209 timestamp : true
69210 } ]
70211 } ) ;
71-
212+
72213 const result = await sendToClaudeCode (
73214 workDir ,
74215 prompt ,
75216 controller ,
76- sessionId ,
77- undefined , // onChunk callback not used
217+ undefined , // always a new session
218+ undefined ,
78219 ( jsonData ) => {
79- // Process JSON stream data and send to Discord
80220 const claudeMessages = convertToClaudeMessages ( jsonData ) ;
81221 if ( claudeMessages . length > 0 ) {
82- sendClaudeMessages ( claudeMessages ) . catch ( ( ) => { } ) ;
222+ activeSender ( claudeMessages ) . catch ( ( ) => { } ) ;
83223 }
84224 } ,
85- false , // continueMode = false
86- deps . getQueryOptions ?.( ) // Pass runtime settings (thinking, operation, proxy)
225+ false ,
226+ deps . getQueryOptions ?.( )
87227 ) ;
88-
228+
89229 deps . setClaudeSessionId ( result . sessionId ) ;
90230 deps . setClaudeController ( null ) ;
91-
92- // Completion message is already sent via SDK streaming (result type → message-converter.ts)
93-
231+
232+ // Map the thread channel → session so /claude inside the thread auto-continues
233+ if ( threadSessionKey && result . sessionId && deps . sessionThreads ) {
234+ deps . sessionThreads . updateSessionId ( threadSessionKey , result . sessionId ) ;
235+ }
236+ if ( threadChannelId && result . sessionId ) {
237+ deps . setSessionForChannel ( threadChannelId , result . sessionId ) ;
238+ }
239+
94240 return result ;
95241 } ,
96-
242+
243+ /**
244+ * /resume — Continue the most recent session (global, not per-channel).
245+ * If that session has a thread, output goes there.
246+ */
97247 // deno-lint-ignore no-explicit-any
98248 async onContinue ( ctx : any , prompt ?: string ) : Promise < ClaudeResponse > {
99- // Cancel any existing session
100249 const existingController = deps . getClaudeController ( ) ;
101250 if ( existingController ) {
102251 existingController . abort ( ) ;
103252 }
104-
253+
105254 const controller = new AbortController ( ) ;
106255 deps . setClaudeController ( controller ) ;
107-
256+
108257 const actualPrompt = prompt || "Please continue." ;
109-
110- // Defer interaction
258+
111259 await ctx . deferReply ( ) ;
112-
113- // Send initial message
260+
261+ // Check if the most recent session has a thread — if so, reuse it
262+ let activeSender = sendClaudeMessages ;
263+ let isReusingThread = false ;
264+
265+ if ( deps . sessionThreads ) {
266+ const currentSessionId = deps . getClaudeSessionId ( ) ;
267+ if ( currentSessionId ) {
268+ try {
269+ const existing = await deps . sessionThreads . getThreadSender ( currentSessionId ) ;
270+ if ( existing ) {
271+ activeSender = existing . sender ;
272+ isReusingThread = true ;
273+ }
274+ } catch ( err ) {
275+ console . warn ( '[SessionThread] Could not reuse thread for continue, falling back:' , err ) ;
276+ }
277+ }
278+ }
279+
114280 const embedData : { color : number ; title : string ; description : string ; timestamp : boolean ; fields ?: Array < { name : string ; value : string ; inline : boolean } > } = {
115281 color : 0xffff00 ,
116282 title : 'Claude Code Continuing Conversation...' ,
117- description : 'Loading latest conversation and waiting for response...' ,
283+ description : isReusingThread
284+ ? 'Continuing in session thread...'
285+ : 'Loading latest conversation and waiting for response...' ,
118286 timestamp : true
119287 } ;
120-
288+
121289 if ( prompt ) {
122290 embedData . fields = [ { name : 'Prompt' , value : `\`${ prompt . substring ( 0 , 1020 ) } \`` , inline : false } ] ;
123291 }
124-
292+
125293 await ctx . editReply ( { embeds : [ embedData ] } ) ;
126-
294+
127295 const result = await sendToClaudeCode (
128296 workDir ,
129297 actualPrompt ,
130298 controller ,
131- undefined , // sessionId not used
132- undefined , // onChunk callback not used
299+ undefined ,
300+ undefined ,
133301 ( jsonData ) => {
134- // Process JSON stream data and send to Discord
135302 const claudeMessages = convertToClaudeMessages ( jsonData ) ;
136303 if ( claudeMessages . length > 0 ) {
137- sendClaudeMessages ( claudeMessages ) . catch ( ( ) => { } ) ;
304+ activeSender ( claudeMessages ) . catch ( ( ) => { } ) ;
138305 }
139306 } ,
140307 true , // continueMode = true
141- deps . getQueryOptions ?.( ) // Pass runtime settings (thinking, operation, proxy)
308+ deps . getQueryOptions ?.( )
142309 ) ;
143-
310+
144311 deps . setClaudeSessionId ( result . sessionId ) ;
145312 deps . setClaudeController ( null ) ;
146-
147- // Completion message is already sent via SDK streaming (result type → message-converter.ts)
148-
313+
149314 return result ;
150315 } ,
151-
316+
152317 // deno-lint-ignore no-explicit-any
153318 onClaudeCancel ( _ctx : any ) : boolean {
154319 const currentController = deps . getClaudeController ( ) ;
155320 if ( ! currentController ) {
156321 return false ;
157322 }
158-
323+
159324 console . log ( "Cancelling Claude Code session..." ) ;
160325 currentController . abort ( ) ;
161326 deps . setClaudeController ( null ) ;
162327 deps . setClaudeSessionId ( undefined ) ;
163-
328+
164329 return true ;
165330 }
166331 } ;
167- }
332+ }
0 commit comments