claude-threads 0.13.0 → 0.14.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.
- package/CHANGELOG.md +32 -0
- package/README.md +78 -28
- package/dist/claude/cli.d.ts +8 -0
- package/dist/claude/cli.js +16 -8
- package/dist/config/migration.d.ts +45 -0
- package/dist/config/migration.js +35 -0
- package/dist/config.d.ts +12 -18
- package/dist/config.js +7 -94
- package/dist/git/worktree.d.ts +0 -4
- package/dist/git/worktree.js +1 -1
- package/dist/index.js +31 -13
- package/dist/logo.d.ts +3 -20
- package/dist/logo.js +7 -23
- package/dist/mcp/permission-server.js +61 -112
- package/dist/onboarding.js +262 -137
- package/dist/persistence/session-store.d.ts +8 -2
- package/dist/persistence/session-store.js +41 -16
- package/dist/platform/client.d.ts +140 -0
- package/dist/platform/formatter.d.ts +74 -0
- package/dist/platform/index.d.ts +11 -0
- package/dist/platform/index.js +8 -0
- package/dist/platform/mattermost/client.d.ts +70 -0
- package/dist/{mattermost → platform/mattermost}/client.js +117 -34
- package/dist/platform/mattermost/formatter.d.ts +20 -0
- package/dist/platform/mattermost/formatter.js +46 -0
- package/dist/platform/mattermost/permission-api.d.ts +10 -0
- package/dist/platform/mattermost/permission-api.js +139 -0
- package/dist/platform/mattermost/types.js +1 -0
- package/dist/platform/permission-api-factory.d.ts +11 -0
- package/dist/platform/permission-api-factory.js +21 -0
- package/dist/platform/permission-api.d.ts +67 -0
- package/dist/platform/permission-api.js +8 -0
- package/dist/platform/types.d.ts +70 -0
- package/dist/platform/types.js +7 -0
- package/dist/session/commands.d.ts +52 -0
- package/dist/session/commands.js +323 -0
- package/dist/session/events.d.ts +25 -0
- package/dist/session/events.js +368 -0
- package/dist/session/index.d.ts +7 -0
- package/dist/session/index.js +6 -0
- package/dist/session/lifecycle.d.ts +70 -0
- package/dist/session/lifecycle.js +456 -0
- package/dist/session/manager.d.ts +96 -0
- package/dist/session/manager.js +537 -0
- package/dist/session/reactions.d.ts +25 -0
- package/dist/session/reactions.js +151 -0
- package/dist/session/streaming.d.ts +47 -0
- package/dist/session/streaming.js +152 -0
- package/dist/session/types.d.ts +78 -0
- package/dist/session/types.js +9 -0
- package/dist/session/worktree.d.ts +56 -0
- package/dist/session/worktree.js +339 -0
- package/dist/{mattermost → utils}/emoji.d.ts +3 -3
- package/dist/{mattermost → utils}/emoji.js +3 -3
- package/dist/utils/emoji.test.d.ts +1 -0
- package/dist/utils/tool-formatter.d.ts +10 -13
- package/dist/utils/tool-formatter.js +48 -43
- package/dist/utils/tool-formatter.test.js +67 -52
- package/package.json +2 -3
- package/dist/claude/session.d.ts +0 -256
- package/dist/claude/session.js +0 -1964
- package/dist/mattermost/client.d.ts +0 -56
- /package/dist/{mattermost/emoji.test.d.ts → platform/client.js} +0 -0
- /package/dist/{mattermost/types.js → platform/formatter.js} +0 -0
- /package/dist/{mattermost → platform/mattermost}/types.d.ts +0 -0
- /package/dist/{mattermost → utils}/emoji.test.js +0 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session lifecycle management module
|
|
3
|
+
*
|
|
4
|
+
* Handles session start, resume, exit, cleanup, and shutdown.
|
|
5
|
+
*/
|
|
6
|
+
import { ClaudeCli } from '../claude/cli.js';
|
|
7
|
+
import { getLogo } from '../logo.js';
|
|
8
|
+
import { randomUUID } from 'crypto';
|
|
9
|
+
import { readFileSync } from 'fs';
|
|
10
|
+
import { dirname, resolve } from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', '..', 'package.json'), 'utf-8'));
|
|
14
|
+
/**
|
|
15
|
+
* Helper to find a persisted session by raw threadId.
|
|
16
|
+
* Persisted sessions are keyed by composite sessionId, so we need to iterate.
|
|
17
|
+
*/
|
|
18
|
+
function findPersistedByThreadId(persisted, threadId) {
|
|
19
|
+
for (const session of persisted.values()) {
|
|
20
|
+
if (session.threadId === threadId) {
|
|
21
|
+
return session;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Session creation
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
/**
|
|
30
|
+
* Create a new session for a thread.
|
|
31
|
+
*/
|
|
32
|
+
export async function startSession(options, username, replyToPostId, platformId, ctx) {
|
|
33
|
+
const threadId = replyToPostId || '';
|
|
34
|
+
// Check if session already exists for this thread
|
|
35
|
+
const existingSessionId = ctx.getSessionId(platformId, threadId);
|
|
36
|
+
const existingSession = ctx.sessions.get(existingSessionId);
|
|
37
|
+
if (existingSession && existingSession.claude.isRunning()) {
|
|
38
|
+
// Send as follow-up instead
|
|
39
|
+
await sendFollowUp(existingSession, options.prompt, options.files, ctx);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const platform = ctx.platforms.get(platformId);
|
|
43
|
+
if (!platform) {
|
|
44
|
+
throw new Error(`Platform '${platformId}' not found. Call addPlatform() first.`);
|
|
45
|
+
}
|
|
46
|
+
// Check max sessions limit
|
|
47
|
+
if (ctx.sessions.size >= ctx.maxSessions) {
|
|
48
|
+
await platform.createPost(`⚠️ **Too busy** - ${ctx.sessions.size} sessions active. Please try again later.`, replyToPostId);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// Post initial session message
|
|
52
|
+
let post;
|
|
53
|
+
try {
|
|
54
|
+
post = await platform.createPost(`${getLogo(pkg.version)}\n\n*Starting session...*`, replyToPostId);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
console.error(` ❌ Failed to create session post:`, err);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const actualThreadId = replyToPostId || post.id;
|
|
61
|
+
const sessionId = ctx.getSessionId(platformId, actualThreadId);
|
|
62
|
+
// Generate a unique session ID for this Claude session
|
|
63
|
+
const claudeSessionId = randomUUID();
|
|
64
|
+
// Create Claude CLI with options
|
|
65
|
+
const platformMcpConfig = platform.getMcpConfig();
|
|
66
|
+
const cliOptions = {
|
|
67
|
+
workingDir: ctx.workingDir,
|
|
68
|
+
threadId: actualThreadId,
|
|
69
|
+
skipPermissions: ctx.skipPermissions,
|
|
70
|
+
sessionId: claudeSessionId,
|
|
71
|
+
resume: false,
|
|
72
|
+
chrome: ctx.chromeEnabled,
|
|
73
|
+
platformConfig: platformMcpConfig,
|
|
74
|
+
};
|
|
75
|
+
const claude = new ClaudeCli(cliOptions);
|
|
76
|
+
// Create the session object
|
|
77
|
+
const session = {
|
|
78
|
+
platformId,
|
|
79
|
+
threadId: actualThreadId,
|
|
80
|
+
sessionId,
|
|
81
|
+
platform,
|
|
82
|
+
claudeSessionId,
|
|
83
|
+
startedBy: username,
|
|
84
|
+
startedAt: new Date(),
|
|
85
|
+
lastActivityAt: new Date(),
|
|
86
|
+
sessionNumber: ctx.sessions.size + 1,
|
|
87
|
+
workingDir: ctx.workingDir,
|
|
88
|
+
claude,
|
|
89
|
+
currentPostId: null,
|
|
90
|
+
pendingContent: '',
|
|
91
|
+
pendingApproval: null,
|
|
92
|
+
pendingQuestionSet: null,
|
|
93
|
+
pendingMessageApproval: null,
|
|
94
|
+
planApproved: false,
|
|
95
|
+
sessionAllowedUsers: new Set([username]),
|
|
96
|
+
forceInteractivePermissions: false,
|
|
97
|
+
sessionStartPostId: post.id,
|
|
98
|
+
tasksPostId: null,
|
|
99
|
+
activeSubagents: new Map(),
|
|
100
|
+
updateTimer: null,
|
|
101
|
+
typingTimer: null,
|
|
102
|
+
timeoutWarningPosted: false,
|
|
103
|
+
isRestarting: false,
|
|
104
|
+
isResumed: false,
|
|
105
|
+
wasInterrupted: false,
|
|
106
|
+
inProgressTaskStart: null,
|
|
107
|
+
activeToolStarts: new Map(),
|
|
108
|
+
};
|
|
109
|
+
// Register session
|
|
110
|
+
ctx.sessions.set(sessionId, session);
|
|
111
|
+
ctx.registerPost(post.id, actualThreadId);
|
|
112
|
+
const shortId = actualThreadId.substring(0, 8);
|
|
113
|
+
console.log(` ▶ Session #${ctx.sessions.size} started (${shortId}…) by @${username}`);
|
|
114
|
+
// Update the header with full session info
|
|
115
|
+
await ctx.updateSessionHeader(session);
|
|
116
|
+
// Start typing indicator
|
|
117
|
+
ctx.startTyping(session);
|
|
118
|
+
// Bind event handlers (use sessionId which is the composite key)
|
|
119
|
+
claude.on('event', (e) => ctx.handleEvent(sessionId, e));
|
|
120
|
+
claude.on('exit', (code) => ctx.handleExit(sessionId, code));
|
|
121
|
+
try {
|
|
122
|
+
claude.start();
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
console.error(' ❌ Failed to start Claude:', err);
|
|
126
|
+
ctx.stopTyping(session);
|
|
127
|
+
await session.platform.createPost(`❌ ${err}`, actualThreadId);
|
|
128
|
+
ctx.sessions.delete(session.sessionId);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// Check if we should prompt for worktree
|
|
132
|
+
const shouldPrompt = await ctx.shouldPromptForWorktree(session);
|
|
133
|
+
if (shouldPrompt) {
|
|
134
|
+
session.queuedPrompt = options.prompt;
|
|
135
|
+
session.pendingWorktreePrompt = true;
|
|
136
|
+
await ctx.postWorktreePrompt(session, shouldPrompt);
|
|
137
|
+
ctx.persistSession(session);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// Send the message to Claude
|
|
141
|
+
const content = await ctx.buildMessageContent(options.prompt, session.platform, options.files);
|
|
142
|
+
claude.sendMessage(content);
|
|
143
|
+
// Persist session for resume after restart
|
|
144
|
+
ctx.persistSession(session);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Resume a session from persisted state.
|
|
148
|
+
*/
|
|
149
|
+
export async function resumeSession(state, ctx) {
|
|
150
|
+
const shortId = state.threadId.substring(0, 8);
|
|
151
|
+
// Get platform for this session
|
|
152
|
+
const platform = ctx.platforms.get(state.platformId);
|
|
153
|
+
if (!platform) {
|
|
154
|
+
console.log(` ⚠️ Platform ${state.platformId} not registered, skipping resume for ${shortId}...`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// Verify thread still exists
|
|
158
|
+
const post = await platform.getPost(state.threadId);
|
|
159
|
+
if (!post) {
|
|
160
|
+
console.log(` ⚠️ Thread ${shortId}... deleted, skipping resume`);
|
|
161
|
+
ctx.sessionStore.remove(`${state.platformId}:${state.threadId}`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// Check max sessions limit
|
|
165
|
+
if (ctx.sessions.size >= ctx.maxSessions) {
|
|
166
|
+
console.log(` ⚠️ Max sessions reached, skipping resume for ${shortId}...`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const platformId = state.platformId;
|
|
170
|
+
const sessionId = ctx.getSessionId(platformId, state.threadId);
|
|
171
|
+
// Create Claude CLI with resume flag
|
|
172
|
+
const skipPerms = ctx.skipPermissions && !state.forceInteractivePermissions;
|
|
173
|
+
const platformMcpConfig = platform.getMcpConfig();
|
|
174
|
+
const cliOptions = {
|
|
175
|
+
workingDir: state.workingDir,
|
|
176
|
+
threadId: state.threadId,
|
|
177
|
+
skipPermissions: skipPerms,
|
|
178
|
+
sessionId: state.claudeSessionId,
|
|
179
|
+
resume: true,
|
|
180
|
+
chrome: ctx.chromeEnabled,
|
|
181
|
+
platformConfig: platformMcpConfig,
|
|
182
|
+
};
|
|
183
|
+
const claude = new ClaudeCli(cliOptions);
|
|
184
|
+
// Rebuild Session object from persisted state
|
|
185
|
+
const session = {
|
|
186
|
+
platformId,
|
|
187
|
+
threadId: state.threadId,
|
|
188
|
+
sessionId,
|
|
189
|
+
platform,
|
|
190
|
+
claudeSessionId: state.claudeSessionId,
|
|
191
|
+
startedBy: state.startedBy,
|
|
192
|
+
startedAt: new Date(state.startedAt),
|
|
193
|
+
lastActivityAt: new Date(),
|
|
194
|
+
sessionNumber: state.sessionNumber,
|
|
195
|
+
workingDir: state.workingDir,
|
|
196
|
+
claude,
|
|
197
|
+
currentPostId: null,
|
|
198
|
+
pendingContent: '',
|
|
199
|
+
pendingApproval: null,
|
|
200
|
+
pendingQuestionSet: null,
|
|
201
|
+
pendingMessageApproval: null,
|
|
202
|
+
planApproved: state.planApproved,
|
|
203
|
+
sessionAllowedUsers: new Set(state.sessionAllowedUsers),
|
|
204
|
+
forceInteractivePermissions: state.forceInteractivePermissions,
|
|
205
|
+
sessionStartPostId: state.sessionStartPostId,
|
|
206
|
+
tasksPostId: state.tasksPostId,
|
|
207
|
+
activeSubagents: new Map(),
|
|
208
|
+
updateTimer: null,
|
|
209
|
+
typingTimer: null,
|
|
210
|
+
timeoutWarningPosted: false,
|
|
211
|
+
isRestarting: false,
|
|
212
|
+
isResumed: true,
|
|
213
|
+
wasInterrupted: false,
|
|
214
|
+
inProgressTaskStart: null,
|
|
215
|
+
activeToolStarts: new Map(),
|
|
216
|
+
worktreeInfo: state.worktreeInfo,
|
|
217
|
+
pendingWorktreePrompt: state.pendingWorktreePrompt,
|
|
218
|
+
worktreePromptDisabled: state.worktreePromptDisabled,
|
|
219
|
+
queuedPrompt: state.queuedPrompt,
|
|
220
|
+
};
|
|
221
|
+
// Register session
|
|
222
|
+
ctx.sessions.set(sessionId, session);
|
|
223
|
+
if (state.sessionStartPostId) {
|
|
224
|
+
ctx.registerPost(state.sessionStartPostId, state.threadId);
|
|
225
|
+
}
|
|
226
|
+
// Bind event handlers (use sessionId which is the composite key)
|
|
227
|
+
claude.on('event', (e) => ctx.handleEvent(sessionId, e));
|
|
228
|
+
claude.on('exit', (code) => ctx.handleExit(sessionId, code));
|
|
229
|
+
try {
|
|
230
|
+
claude.start();
|
|
231
|
+
console.log(` 🔄 Resumed session ${shortId}... (@${state.startedBy})`);
|
|
232
|
+
// Post resume message
|
|
233
|
+
await session.platform.createPost(`🔄 **Session resumed** after bot restart (v${pkg.version})\n*Reconnected to Claude session. You can continue where you left off.*`, state.threadId);
|
|
234
|
+
// Update session header
|
|
235
|
+
await ctx.updateSessionHeader(session);
|
|
236
|
+
// Update persistence with new activity time
|
|
237
|
+
ctx.persistSession(session);
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
console.error(` ❌ Failed to resume session ${shortId}...:`, err);
|
|
241
|
+
ctx.sessions.delete(sessionId);
|
|
242
|
+
ctx.sessionStore.remove(sessionId);
|
|
243
|
+
// Try to notify user
|
|
244
|
+
try {
|
|
245
|
+
await session.platform.createPost(`⚠️ **Could not resume previous session.** Starting fresh.\n*Your previous conversation context is preserved, but Claude needs to re-read it.*`, state.threadId);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// Ignore if we can't post
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Session messaging
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
/**
|
|
256
|
+
* Send a follow-up message to an existing session.
|
|
257
|
+
*/
|
|
258
|
+
export async function sendFollowUp(session, message, files, ctx) {
|
|
259
|
+
if (!session.claude.isRunning())
|
|
260
|
+
return;
|
|
261
|
+
const content = await ctx.buildMessageContent(message, session.platform, files);
|
|
262
|
+
session.claude.sendMessage(content);
|
|
263
|
+
session.lastActivityAt = new Date();
|
|
264
|
+
ctx.startTyping(session);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Resume a paused session and send a message to it.
|
|
268
|
+
*/
|
|
269
|
+
export async function resumePausedSession(threadId, message, files, ctx) {
|
|
270
|
+
// Find persisted session by raw threadId
|
|
271
|
+
const persisted = ctx.sessionStore.load();
|
|
272
|
+
const state = findPersistedByThreadId(persisted, threadId);
|
|
273
|
+
if (!state) {
|
|
274
|
+
console.log(` [resume] No persisted session found for ${threadId.substring(0, 8)}...`);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const shortId = threadId.substring(0, 8);
|
|
278
|
+
console.log(` 🔄 Resuming paused session ${shortId}... for new message`);
|
|
279
|
+
// Resume the session
|
|
280
|
+
await resumeSession(state, ctx);
|
|
281
|
+
// Wait a moment for the session to be ready, then send the message
|
|
282
|
+
const session = ctx.findSessionByThreadId(threadId);
|
|
283
|
+
if (session && session.claude.isRunning()) {
|
|
284
|
+
const content = await ctx.buildMessageContent(message, session.platform, files);
|
|
285
|
+
session.claude.sendMessage(content);
|
|
286
|
+
session.lastActivityAt = new Date();
|
|
287
|
+
ctx.startTyping(session);
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
console.log(` ⚠️ Failed to resume session ${shortId}..., could not send message`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Session termination
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
/**
|
|
297
|
+
* Handle Claude CLI exit event.
|
|
298
|
+
*/
|
|
299
|
+
export async function handleExit(sessionId, code, ctx) {
|
|
300
|
+
const session = ctx.sessions.get(sessionId);
|
|
301
|
+
const shortId = sessionId.substring(0, 8);
|
|
302
|
+
console.log(` [exit] handleExit called for ${shortId}... code=${code} isShuttingDown=${ctx.isShuttingDown}`);
|
|
303
|
+
if (!session) {
|
|
304
|
+
console.log(` [exit] Session ${shortId}... not found (already cleaned up)`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// If we're intentionally restarting (e.g., !cd), don't clean up
|
|
308
|
+
if (session.isRestarting) {
|
|
309
|
+
console.log(` [exit] Session ${shortId}... restarting, skipping cleanup`);
|
|
310
|
+
session.isRestarting = false;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
// If bot is shutting down, preserve persistence
|
|
314
|
+
if (ctx.isShuttingDown) {
|
|
315
|
+
console.log(` [exit] Session ${shortId}... bot shutting down, preserving persistence`);
|
|
316
|
+
ctx.stopTyping(session);
|
|
317
|
+
if (session.updateTimer) {
|
|
318
|
+
clearTimeout(session.updateTimer);
|
|
319
|
+
session.updateTimer = null;
|
|
320
|
+
}
|
|
321
|
+
ctx.sessions.delete(session.sessionId);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// If session was interrupted, preserve for resume
|
|
325
|
+
if (session.wasInterrupted) {
|
|
326
|
+
console.log(` [exit] Session ${shortId}... exited after interrupt, preserving for resume`);
|
|
327
|
+
ctx.stopTyping(session);
|
|
328
|
+
if (session.updateTimer) {
|
|
329
|
+
clearTimeout(session.updateTimer);
|
|
330
|
+
session.updateTimer = null;
|
|
331
|
+
}
|
|
332
|
+
ctx.persistSession(session);
|
|
333
|
+
ctx.sessions.delete(session.sessionId);
|
|
334
|
+
// Clean up post index
|
|
335
|
+
for (const [postId, tid] of ctx.postIndex.entries()) {
|
|
336
|
+
if (tid === session.threadId) {
|
|
337
|
+
ctx.postIndex.delete(postId);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Notify user
|
|
341
|
+
try {
|
|
342
|
+
await session.platform.createPost(`ℹ️ Session paused. Send a new message to continue.`, session.threadId);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
// Ignore
|
|
346
|
+
}
|
|
347
|
+
console.log(` ⏸️ Session paused (${shortId}…) — ${ctx.sessions.size} active`);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// For resumed sessions that exit with error, preserve for retry
|
|
351
|
+
if (session.isResumed && code !== 0) {
|
|
352
|
+
console.log(` [exit] Resumed session ${shortId}... failed with code ${code}, preserving for retry`);
|
|
353
|
+
ctx.stopTyping(session);
|
|
354
|
+
if (session.updateTimer) {
|
|
355
|
+
clearTimeout(session.updateTimer);
|
|
356
|
+
session.updateTimer = null;
|
|
357
|
+
}
|
|
358
|
+
ctx.sessions.delete(session.sessionId);
|
|
359
|
+
try {
|
|
360
|
+
await session.platform.createPost(`⚠️ **Session resume failed** (exit code ${code}). The session data is preserved - try restarting the bot.`, session.threadId);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// Ignore
|
|
364
|
+
}
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
// Normal exit cleanup
|
|
368
|
+
console.log(` [exit] Session ${shortId}... normal exit, cleaning up`);
|
|
369
|
+
ctx.stopTyping(session);
|
|
370
|
+
if (session.updateTimer) {
|
|
371
|
+
clearTimeout(session.updateTimer);
|
|
372
|
+
session.updateTimer = null;
|
|
373
|
+
}
|
|
374
|
+
await ctx.flush(session);
|
|
375
|
+
if (code !== 0 && code !== null) {
|
|
376
|
+
await session.platform.createPost(`**[Exited: ${code}]**`, session.threadId);
|
|
377
|
+
}
|
|
378
|
+
// Clean up session from maps
|
|
379
|
+
ctx.sessions.delete(session.sessionId);
|
|
380
|
+
for (const [postId, tid] of ctx.postIndex.entries()) {
|
|
381
|
+
if (tid === session.threadId) {
|
|
382
|
+
ctx.postIndex.delete(postId);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// Only unpersist for normal exits
|
|
386
|
+
if (code === 0 || code === null) {
|
|
387
|
+
ctx.unpersistSession(session.sessionId);
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
console.log(` [exit] Session ${shortId}... non-zero exit, preserving for potential retry`);
|
|
391
|
+
}
|
|
392
|
+
console.log(` ■ Session ended (${shortId}…) — ${ctx.sessions.size} active`);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Kill a specific session.
|
|
396
|
+
*/
|
|
397
|
+
export function killSession(session, unpersist, ctx) {
|
|
398
|
+
const shortId = session.threadId.substring(0, 8);
|
|
399
|
+
// Set restarting flag to prevent handleExit from also unpersisting
|
|
400
|
+
if (!unpersist) {
|
|
401
|
+
session.isRestarting = true;
|
|
402
|
+
}
|
|
403
|
+
ctx.stopTyping(session);
|
|
404
|
+
session.claude.kill();
|
|
405
|
+
// Clean up session from maps
|
|
406
|
+
ctx.sessions.delete(session.sessionId);
|
|
407
|
+
for (const [postId, tid] of ctx.postIndex.entries()) {
|
|
408
|
+
if (tid === session.threadId) {
|
|
409
|
+
ctx.postIndex.delete(postId);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// Explicitly unpersist if requested
|
|
413
|
+
if (unpersist) {
|
|
414
|
+
ctx.unpersistSession(session.threadId);
|
|
415
|
+
}
|
|
416
|
+
console.log(` ✖ Session killed (${shortId}…) — ${ctx.sessions.size} active`);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Kill all active sessions.
|
|
420
|
+
*/
|
|
421
|
+
export function killAllSessions(ctx) {
|
|
422
|
+
for (const session of ctx.sessions.values()) {
|
|
423
|
+
ctx.stopTyping(session);
|
|
424
|
+
session.claude.kill();
|
|
425
|
+
}
|
|
426
|
+
ctx.sessions.clear();
|
|
427
|
+
ctx.postIndex.clear();
|
|
428
|
+
}
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// Cleanup
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
/**
|
|
433
|
+
* Clean up idle sessions that have timed out.
|
|
434
|
+
*/
|
|
435
|
+
export function cleanupIdleSessions(timeoutMs, warningMs, ctx) {
|
|
436
|
+
const now = Date.now();
|
|
437
|
+
for (const [_sessionId, session] of ctx.sessions) {
|
|
438
|
+
const idleMs = now - session.lastActivityAt.getTime();
|
|
439
|
+
const shortId = session.threadId.substring(0, 8);
|
|
440
|
+
// Check for timeout
|
|
441
|
+
if (idleMs > timeoutMs) {
|
|
442
|
+
console.log(` ⏰ Session (${shortId}…) timed out after ${Math.round(idleMs / 60000)}min idle`);
|
|
443
|
+
session.platform.createPost(`⏰ **Session timed out** after ${Math.round(idleMs / 60000)} minutes of inactivity`, session.threadId).catch(() => { });
|
|
444
|
+
// Kill without unpersisting to allow resume
|
|
445
|
+
killSession(session, false, ctx);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
// Check for warning threshold
|
|
449
|
+
if (idleMs > warningMs && !session.timeoutWarningPosted) {
|
|
450
|
+
const remainingMins = Math.round((timeoutMs - idleMs) / 60000);
|
|
451
|
+
session.platform.createPost(`⏰ **Session idle** - will timeout in ~${remainingMins} minutes without activity`, session.threadId).catch(() => { });
|
|
452
|
+
session.timeoutWarningPosted = true;
|
|
453
|
+
console.log(` ⏰ Session (${shortId}…) idle warning posted`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionManager - Orchestrates Claude Code sessions across chat platforms
|
|
3
|
+
*
|
|
4
|
+
* This is the main coordinator that delegates to specialized modules:
|
|
5
|
+
* - lifecycle.ts: Session start, resume, exit
|
|
6
|
+
* - events.ts: Claude event handling
|
|
7
|
+
* - reactions.ts: User reaction handling
|
|
8
|
+
* - commands.ts: User commands (!cd, !invite, etc.)
|
|
9
|
+
* - worktree.ts: Git worktree management
|
|
10
|
+
* - streaming.ts: Message streaming and flushing
|
|
11
|
+
*/
|
|
12
|
+
import type { PlatformClient, PlatformFile } from '../platform/index.js';
|
|
13
|
+
import { PersistedSession } from '../persistence/session-store.js';
|
|
14
|
+
import { WorktreeMode } from '../config.js';
|
|
15
|
+
/**
|
|
16
|
+
* SessionManager - Main orchestrator for Claude Code sessions
|
|
17
|
+
*/
|
|
18
|
+
export declare class SessionManager {
|
|
19
|
+
private platforms;
|
|
20
|
+
private workingDir;
|
|
21
|
+
private skipPermissions;
|
|
22
|
+
private chromeEnabled;
|
|
23
|
+
private worktreeMode;
|
|
24
|
+
private debug;
|
|
25
|
+
private sessions;
|
|
26
|
+
private postIndex;
|
|
27
|
+
private sessionStore;
|
|
28
|
+
private cleanupTimer;
|
|
29
|
+
private isShuttingDown;
|
|
30
|
+
constructor(workingDir: string, skipPermissions?: boolean, chromeEnabled?: boolean, worktreeMode?: WorktreeMode);
|
|
31
|
+
addPlatform(platformId: string, client: PlatformClient): void;
|
|
32
|
+
removePlatform(platformId: string): void;
|
|
33
|
+
private getLifecycleContext;
|
|
34
|
+
private getEventContext;
|
|
35
|
+
private getReactionContext;
|
|
36
|
+
private getCommandContext;
|
|
37
|
+
private getSessionId;
|
|
38
|
+
private registerPost;
|
|
39
|
+
private getSessionByPost;
|
|
40
|
+
private handleMessage;
|
|
41
|
+
private handleReaction;
|
|
42
|
+
private handleSessionReaction;
|
|
43
|
+
private handleEvent;
|
|
44
|
+
private handleExit;
|
|
45
|
+
private appendContent;
|
|
46
|
+
private flush;
|
|
47
|
+
private startTyping;
|
|
48
|
+
private stopTyping;
|
|
49
|
+
private buildMessageContent;
|
|
50
|
+
private shouldPromptForWorktree;
|
|
51
|
+
private hasOtherSessionInRepo;
|
|
52
|
+
private postWorktreePrompt;
|
|
53
|
+
private persistSession;
|
|
54
|
+
private unpersistSession;
|
|
55
|
+
private updateSessionHeader;
|
|
56
|
+
initialize(): Promise<void>;
|
|
57
|
+
startSession(options: {
|
|
58
|
+
prompt: string;
|
|
59
|
+
files?: PlatformFile[];
|
|
60
|
+
}, username: string, replyToPostId?: string, platformId?: string): Promise<void>;
|
|
61
|
+
private findSessionByThreadId;
|
|
62
|
+
private findPersistedByThreadId;
|
|
63
|
+
sendFollowUp(threadId: string, message: string, files?: PlatformFile[]): Promise<void>;
|
|
64
|
+
isSessionActive(): boolean;
|
|
65
|
+
isInSessionThread(threadRoot: string): boolean;
|
|
66
|
+
hasPausedSession(threadId: string): boolean;
|
|
67
|
+
resumePausedSession(threadId: string, message: string, files?: PlatformFile[]): Promise<void>;
|
|
68
|
+
getPersistedSession(threadId: string): PersistedSession | undefined;
|
|
69
|
+
killSession(threadId: string, unpersist?: boolean): void;
|
|
70
|
+
killAllSessions(): void;
|
|
71
|
+
cancelSession(threadId: string, username: string): Promise<void>;
|
|
72
|
+
interruptSession(threadId: string, username: string): Promise<void>;
|
|
73
|
+
changeDirectory(threadId: string, newDir: string, username: string): Promise<void>;
|
|
74
|
+
inviteUser(threadId: string, invitedUser: string, invitedBy: string): Promise<void>;
|
|
75
|
+
kickUser(threadId: string, kickedUser: string, kickedBy: string): Promise<void>;
|
|
76
|
+
enableInteractivePermissions(threadId: string, username: string): Promise<void>;
|
|
77
|
+
isSessionInteractive(threadId: string): boolean;
|
|
78
|
+
requestMessageApproval(threadId: string, username: string, message: string): Promise<void>;
|
|
79
|
+
handleWorktreeBranchResponse(threadId: string, branchName: string, username: string): Promise<boolean>;
|
|
80
|
+
handleWorktreeSkip(threadId: string, username: string): Promise<void>;
|
|
81
|
+
createAndSwitchToWorktree(threadId: string, branch: string, username: string): Promise<void>;
|
|
82
|
+
switchToWorktree(threadId: string, branchOrPath: string, username: string): Promise<void>;
|
|
83
|
+
listWorktreesCommand(threadId: string, _username: string): Promise<void>;
|
|
84
|
+
removeWorktreeCommand(threadId: string, branchOrPath: string, username: string): Promise<void>;
|
|
85
|
+
disableWorktreePrompt(threadId: string, username: string): Promise<void>;
|
|
86
|
+
hasPendingWorktreePrompt(threadId: string): boolean;
|
|
87
|
+
getActiveThreadIds(): string[];
|
|
88
|
+
killAllSessionsAndUnpersist(): void;
|
|
89
|
+
isUserAllowedInSession(threadId: string, username: string): boolean;
|
|
90
|
+
startSessionWithWorktree(options: {
|
|
91
|
+
prompt: string;
|
|
92
|
+
files?: PlatformFile[];
|
|
93
|
+
}, branch: string, username: string, replyToPostId?: string, platformId?: string): Promise<void>;
|
|
94
|
+
setShuttingDown(): void;
|
|
95
|
+
shutdown(message?: string): Promise<void>;
|
|
96
|
+
}
|