Skip to content

Commit 6fcb8ef

Browse files
committed
feat: add thread-per-session support (#19)
2 parents cfa934b + d85e486 commit 6fcb8ef

11 files changed

Lines changed: 780 additions & 270 deletions

File tree

claude/command.ts

Lines changed: 215 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,70 @@ import { sendToClaudeCode, type ClaudeModelOptions } from "./client.ts";
33
import { convertToClaudeMessages } from "./message-converter.ts";
44
import { 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
737
export 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

4395
export 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

Comments
 (0)