mattermost-claude-code 0.9.0 โ 0.9.2
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 +22 -0
- package/dist/claude/session.d.ts +1 -0
- package/dist/claude/session.js +60 -13
- package/dist/index.js +115 -97
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.9.2] - 2025-12-28
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Fix session persistence** - sessions were being incorrectly cleaned as "stale" on startup
|
|
12
|
+
- The `cleanStale()` call was removing sessions older than 30 minutes before attempting to resume
|
|
13
|
+
- Now sessions survive bot restarts regardless of how long the bot was down
|
|
14
|
+
- Added debug logging (`DEBUG=1`) to trace persistence operations
|
|
15
|
+
- **Fix crash on Mattermost API errors** - bot no longer crashes when posts fail
|
|
16
|
+
- Added try-catch around message handler to prevent unhandled exceptions
|
|
17
|
+
- Added try-catch around reaction handler
|
|
18
|
+
- Graceful error handling when session start post fails (e.g., deleted thread)
|
|
19
|
+
|
|
20
|
+
## [0.9.1] - 2025-12-28
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- Resume message now shows version: "Session resumed after bot restart (v0.9.1)"
|
|
24
|
+
- Session header is updated with new version after resume
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- Fix duplicate "Bot shutting down" messages when stopping bot
|
|
28
|
+
- Fix "[Exited: null]" message appearing during graceful shutdown
|
|
29
|
+
|
|
8
30
|
## [0.9.0] - 2025-12-28
|
|
9
31
|
|
|
10
32
|
### Added
|
package/dist/claude/session.d.ts
CHANGED
|
@@ -67,6 +67,7 @@ export declare class SessionManager {
|
|
|
67
67
|
private postIndex;
|
|
68
68
|
private sessionStore;
|
|
69
69
|
private cleanupTimer;
|
|
70
|
+
private isShuttingDown;
|
|
70
71
|
constructor(mattermost: MattermostClient, workingDir: string, skipPermissions?: boolean);
|
|
71
72
|
/**
|
|
72
73
|
* Initialize session manager by resuming any persisted sessions.
|
package/dist/claude/session.js
CHANGED
|
@@ -37,13 +37,20 @@ export class SessionManager {
|
|
|
37
37
|
sessionStore = new SessionStore();
|
|
38
38
|
// Cleanup timer
|
|
39
39
|
cleanupTimer = null;
|
|
40
|
+
// Shutdown flag to suppress exit messages during graceful shutdown
|
|
41
|
+
isShuttingDown = false;
|
|
40
42
|
constructor(mattermost, workingDir, skipPermissions = false) {
|
|
41
43
|
this.mattermost = mattermost;
|
|
42
44
|
this.workingDir = workingDir;
|
|
43
45
|
this.skipPermissions = skipPermissions;
|
|
44
46
|
// Listen for reactions to answer questions
|
|
45
|
-
this.mattermost.on('reaction', (reaction, user) => {
|
|
46
|
-
|
|
47
|
+
this.mattermost.on('reaction', async (reaction, user) => {
|
|
48
|
+
try {
|
|
49
|
+
await this.handleReaction(reaction.post_id, reaction.emoji_name, user?.username || 'unknown');
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
console.error(' โ Error handling reaction:', err);
|
|
53
|
+
}
|
|
47
54
|
});
|
|
48
55
|
// Start periodic cleanup of idle sessions
|
|
49
56
|
this.cleanupTimer = setInterval(() => this.cleanupIdleSessions(), 60000);
|
|
@@ -56,13 +63,22 @@ export class SessionManager {
|
|
|
56
63
|
* Should be called before starting to listen for new messages.
|
|
57
64
|
*/
|
|
58
65
|
async initialize() {
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
if
|
|
62
|
-
console.log(` ๐งน Cleaned ${staleIds.length} stale session(s)`);
|
|
63
|
-
}
|
|
64
|
-
// Load persisted sessions
|
|
66
|
+
// Load persisted sessions FIRST (before cleaning stale ones)
|
|
67
|
+
// This way we can resume sessions that were active when the bot stopped,
|
|
68
|
+
// even if the bot was down for longer than SESSION_TIMEOUT_MS
|
|
65
69
|
const persisted = this.sessionStore.load();
|
|
70
|
+
if (this.debug) {
|
|
71
|
+
console.log(` [persist] Found ${persisted.size} persisted session(s)`);
|
|
72
|
+
for (const [threadId, state] of persisted) {
|
|
73
|
+
const age = Date.now() - new Date(state.lastActivityAt).getTime();
|
|
74
|
+
const ageMins = Math.round(age / 60000);
|
|
75
|
+
console.log(` [persist] - ${threadId.substring(0, 8)}... by @${state.startedBy}, age: ${ageMins}m`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Note: We intentionally do NOT clean stale sessions on startup anymore.
|
|
79
|
+
// Sessions are cleaned during normal operation by cleanupIdleSessions().
|
|
80
|
+
// This allows sessions to survive bot restarts even if the bot was down
|
|
81
|
+
// for longer than SESSION_TIMEOUT_MS.
|
|
66
82
|
if (persisted.size === 0) {
|
|
67
83
|
if (this.debug)
|
|
68
84
|
console.log(' [resume] No sessions to resume');
|
|
@@ -141,7 +157,7 @@ export class SessionManager {
|
|
|
141
157
|
claude.start();
|
|
142
158
|
console.log(` ๐ Resumed session ${shortId}... (@${state.startedBy})`);
|
|
143
159
|
// Post resume message
|
|
144
|
-
await this.mattermost.createPost(`๐ **Session resumed** after bot restart\n*Reconnected to Claude session. You can continue where you left off.*`, state.threadId);
|
|
160
|
+
await this.mattermost.createPost(`๐ **Session resumed** after bot restart (v${pkg.version})\n*Reconnected to Claude session. You can continue where you left off.*`, state.threadId);
|
|
145
161
|
// Update session header
|
|
146
162
|
await this.updateSessionHeader(session);
|
|
147
163
|
// Update persistence with new activity time
|
|
@@ -179,11 +195,19 @@ export class SessionManager {
|
|
|
179
195
|
planApproved: session.planApproved,
|
|
180
196
|
};
|
|
181
197
|
this.sessionStore.save(session.threadId, state);
|
|
198
|
+
if (this.debug) {
|
|
199
|
+
const shortId = session.threadId.substring(0, 8);
|
|
200
|
+
console.log(` [persist] Saved session ${shortId}... (claudeId: ${session.claudeSessionId.substring(0, 8)}...)`);
|
|
201
|
+
}
|
|
182
202
|
}
|
|
183
203
|
/**
|
|
184
204
|
* Remove a session from persistence
|
|
185
205
|
*/
|
|
186
206
|
unpersistSession(threadId) {
|
|
207
|
+
if (this.debug) {
|
|
208
|
+
const shortId = threadId.substring(0, 8);
|
|
209
|
+
console.log(` [persist] Removing session ${shortId}...`);
|
|
210
|
+
}
|
|
187
211
|
this.sessionStore.remove(threadId);
|
|
188
212
|
}
|
|
189
213
|
// ---------------------------------------------------------------------------
|
|
@@ -246,7 +270,15 @@ export class SessionManager {
|
|
|
246
270
|
return;
|
|
247
271
|
}
|
|
248
272
|
// Post initial session message (will be updated by updateSessionHeader)
|
|
249
|
-
|
|
273
|
+
let post;
|
|
274
|
+
try {
|
|
275
|
+
post = await this.mattermost.createPost(`### ๐ค mm-claude \`v${pkg.version}\`\n\n*Starting session...*`, replyToPostId);
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
console.error(` โ Failed to create session post:`, err);
|
|
279
|
+
// If we can't post to the thread, we can't start a session
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
250
282
|
const actualThreadId = replyToPostId || post.id;
|
|
251
283
|
// Generate a unique session ID for this Claude session
|
|
252
284
|
const claudeSessionId = randomUUID();
|
|
@@ -914,20 +946,34 @@ export class SessionManager {
|
|
|
914
946
|
}
|
|
915
947
|
async handleExit(threadId, code) {
|
|
916
948
|
const session = this.sessions.get(threadId);
|
|
917
|
-
|
|
949
|
+
const shortId = threadId.substring(0, 8);
|
|
950
|
+
if (!session) {
|
|
951
|
+
if (this.debug)
|
|
952
|
+
console.log(` [exit] Session ${shortId}... not found (already cleaned up)`);
|
|
918
953
|
return;
|
|
954
|
+
}
|
|
919
955
|
// If we're intentionally restarting (e.g., !cd), don't clean up or post exit message
|
|
920
956
|
if (session.isRestarting) {
|
|
957
|
+
if (this.debug)
|
|
958
|
+
console.log(` [exit] Session ${shortId}... restarting, skipping cleanup`);
|
|
921
959
|
session.isRestarting = false; // Reset flag here, after the exit event fires
|
|
922
960
|
return;
|
|
923
961
|
}
|
|
962
|
+
// If bot is shutting down, suppress exit messages (shutdown message already sent)
|
|
963
|
+
if (this.isShuttingDown) {
|
|
964
|
+
if (this.debug)
|
|
965
|
+
console.log(` [exit] Session ${shortId}... bot shutting down, preserving persistence`);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (this.debug)
|
|
969
|
+
console.log(` [exit] Session ${shortId}... exited with code ${code}, cleaning up`);
|
|
924
970
|
this.stopTyping(session);
|
|
925
971
|
if (session.updateTimer) {
|
|
926
972
|
clearTimeout(session.updateTimer);
|
|
927
973
|
session.updateTimer = null;
|
|
928
974
|
}
|
|
929
975
|
await this.flush(session);
|
|
930
|
-
if (code !== 0) {
|
|
976
|
+
if (code !== 0 && code !== null) {
|
|
931
977
|
await this.mattermost.createPost(`**[Exited: ${code}]**`, session.threadId);
|
|
932
978
|
}
|
|
933
979
|
// Clean up session from maps
|
|
@@ -940,7 +986,6 @@ export class SessionManager {
|
|
|
940
986
|
}
|
|
941
987
|
// Remove from persistence when session ends normally
|
|
942
988
|
this.unpersistSession(threadId);
|
|
943
|
-
const shortId = threadId.substring(0, 8);
|
|
944
989
|
console.log(` โ Session ended (${shortId}โฆ) โ ${this.sessions.size} active`);
|
|
945
990
|
}
|
|
946
991
|
// ---------------------------------------------------------------------------
|
|
@@ -1229,6 +1274,8 @@ export class SessionManager {
|
|
|
1229
1274
|
}
|
|
1230
1275
|
/** Kill all active sessions (for graceful shutdown) */
|
|
1231
1276
|
killAllSessions() {
|
|
1277
|
+
// Set shutdown flag to suppress exit messages
|
|
1278
|
+
this.isShuttingDown = true;
|
|
1232
1279
|
const count = this.sessions.size;
|
|
1233
1280
|
for (const [, session] of this.sessions.entries()) {
|
|
1234
1281
|
this.stopTyping(session);
|
package/dist/index.js
CHANGED
|
@@ -72,116 +72,129 @@ async function main() {
|
|
|
72
72
|
const mattermost = new MattermostClient(config);
|
|
73
73
|
const session = new SessionManager(mattermost, workingDir, config.skipPermissions);
|
|
74
74
|
mattermost.on('message', async (post, user) => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const content = mattermost.isBotMentioned(message)
|
|
87
|
-
? mattermost.extractPrompt(message)
|
|
88
|
-
: message.trim();
|
|
89
|
-
const lowerContent = content.toLowerCase();
|
|
90
|
-
// Check for stop/cancel commands (only from allowed users)
|
|
91
|
-
// Note: Using ! prefix instead of / to avoid Mattermost slash command interception
|
|
92
|
-
if (lowerContent === '!stop' || lowerContent === 'stop' ||
|
|
93
|
-
lowerContent === '!cancel' || lowerContent === 'cancel') {
|
|
94
|
-
if (session.isUserAllowedInSession(threadRoot, username)) {
|
|
95
|
-
await session.cancelSession(threadRoot, username);
|
|
75
|
+
try {
|
|
76
|
+
const username = user?.username || 'unknown';
|
|
77
|
+
const message = post.message;
|
|
78
|
+
const threadRoot = post.root_id || post.id;
|
|
79
|
+
// Follow-up in active thread
|
|
80
|
+
if (session.isInSessionThread(threadRoot)) {
|
|
81
|
+
// If message starts with @mention to someone else, ignore it (side conversation)
|
|
82
|
+
// Note: Mattermost usernames can contain letters, numbers, hyphens, periods, and underscores
|
|
83
|
+
const mentionMatch = message.trim().match(/^@([\w.-]+)/);
|
|
84
|
+
if (mentionMatch && mentionMatch[1].toLowerCase() !== mattermost.getBotName().toLowerCase()) {
|
|
85
|
+
return; // Side conversation, don't interrupt
|
|
96
86
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
`| \`!permissions interactive\` | Enable interactive permissions |\n` +
|
|
110
|
-
`| \`!stop\` | Stop this session |\n\n` +
|
|
111
|
-
`**Reactions:**\n` +
|
|
112
|
-
`- ๐ Approve action ยท โ
Approve all ยท ๐ Deny\n` +
|
|
113
|
-
`- โ or ๐ on any message to stop session`, threadRoot);
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
// Check for !release-notes command
|
|
117
|
-
if (lowerContent === '!release-notes' || lowerContent === '!changelog') {
|
|
118
|
-
const notes = getReleaseNotes(pkg.version);
|
|
119
|
-
if (notes) {
|
|
120
|
-
await mattermost.createPost(formatReleaseNotes(notes), threadRoot);
|
|
87
|
+
const content = mattermost.isBotMentioned(message)
|
|
88
|
+
? mattermost.extractPrompt(message)
|
|
89
|
+
: message.trim();
|
|
90
|
+
const lowerContent = content.toLowerCase();
|
|
91
|
+
// Check for stop/cancel commands (only from allowed users)
|
|
92
|
+
// Note: Using ! prefix instead of / to avoid Mattermost slash command interception
|
|
93
|
+
if (lowerContent === '!stop' || lowerContent === 'stop' ||
|
|
94
|
+
lowerContent === '!cancel' || lowerContent === 'cancel') {
|
|
95
|
+
if (session.isUserAllowedInSession(threadRoot, username)) {
|
|
96
|
+
await session.cancelSession(threadRoot, username);
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
121
99
|
}
|
|
122
|
-
|
|
123
|
-
|
|
100
|
+
// Check for !help command
|
|
101
|
+
if (lowerContent === '!help' || lowerContent === 'help') {
|
|
102
|
+
await mattermost.createPost(`**Available commands:**\n\n` +
|
|
103
|
+
`| Command | Description |\n` +
|
|
104
|
+
`|:--------|:------------|\n` +
|
|
105
|
+
`| \`!help\` | Show this help message |\n` +
|
|
106
|
+
`| \`!release-notes\` | Show release notes for current version |\n` +
|
|
107
|
+
`| \`!cd <path>\` | Change working directory (restarts Claude) |\n` +
|
|
108
|
+
`| \`!invite @user\` | Invite a user to this session |\n` +
|
|
109
|
+
`| \`!kick @user\` | Remove an invited user |\n` +
|
|
110
|
+
`| \`!permissions interactive\` | Enable interactive permissions |\n` +
|
|
111
|
+
`| \`!stop\` | Stop this session |\n\n` +
|
|
112
|
+
`**Reactions:**\n` +
|
|
113
|
+
`- ๐ Approve action ยท โ
Approve all ยท ๐ Deny\n` +
|
|
114
|
+
`- โ or ๐ on any message to stop session`, threadRoot);
|
|
115
|
+
return;
|
|
124
116
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
117
|
+
// Check for !release-notes command
|
|
118
|
+
if (lowerContent === '!release-notes' || lowerContent === '!changelog') {
|
|
119
|
+
const notes = getReleaseNotes(pkg.version);
|
|
120
|
+
if (notes) {
|
|
121
|
+
await mattermost.createPost(formatReleaseNotes(notes), threadRoot);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
await mattermost.createPost(`๐ **mm-claude v${pkg.version}**\n\nRelease notes not available. See [GitHub releases](https://github.com/anneschuth/mattermost-claude-code/releases).`, threadRoot);
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Check for !invite command
|
|
129
|
+
const inviteMatch = content.match(/^!invite\s+@?([\w.-]+)/i);
|
|
130
|
+
if (inviteMatch) {
|
|
131
|
+
await session.inviteUser(threadRoot, inviteMatch[1], username);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// Check for !kick command
|
|
135
|
+
const kickMatch = content.match(/^!kick\s+@?([\w.-]+)/i);
|
|
136
|
+
if (kickMatch) {
|
|
137
|
+
await session.kickUser(threadRoot, kickMatch[1], username);
|
|
138
|
+
return;
|
|
145
139
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
140
|
+
// Check for !permissions command
|
|
141
|
+
const permMatch = content.match(/^!permissions?\s+(interactive|auto)/i);
|
|
142
|
+
if (permMatch) {
|
|
143
|
+
const mode = permMatch[1].toLowerCase();
|
|
144
|
+
if (mode === 'interactive') {
|
|
145
|
+
await session.enableInteractivePermissions(threadRoot, username);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
// Can't upgrade to auto - that would be less secure
|
|
149
|
+
await mattermost.createPost(`โ ๏ธ Cannot upgrade to auto permissions - can only downgrade to interactive`, threadRoot);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
149
152
|
}
|
|
153
|
+
// Check for !cd command
|
|
154
|
+
const cdMatch = content.match(/^!cd\s+(.+)/i);
|
|
155
|
+
if (cdMatch) {
|
|
156
|
+
await session.changeDirectory(threadRoot, cdMatch[1].trim(), username);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Check if user is allowed in this session
|
|
160
|
+
if (!session.isUserAllowedInSession(threadRoot, username)) {
|
|
161
|
+
// Request approval for their message
|
|
162
|
+
if (content)
|
|
163
|
+
await session.requestMessageApproval(threadRoot, username, content);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Get any attached files (images)
|
|
167
|
+
const files = post.metadata?.files;
|
|
168
|
+
if (content || files?.length)
|
|
169
|
+
await session.sendFollowUp(threadRoot, content, files);
|
|
150
170
|
return;
|
|
151
171
|
}
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
if (cdMatch) {
|
|
155
|
-
await session.changeDirectory(threadRoot, cdMatch[1].trim(), username);
|
|
172
|
+
// New session requires @mention
|
|
173
|
+
if (!mattermost.isBotMentioned(message))
|
|
156
174
|
return;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (!session.isUserAllowedInSession(threadRoot, username)) {
|
|
160
|
-
// Request approval for their message
|
|
161
|
-
if (content)
|
|
162
|
-
await session.requestMessageApproval(threadRoot, username, content);
|
|
175
|
+
if (!mattermost.isUserAllowed(username)) {
|
|
176
|
+
await mattermost.createPost(`โ ๏ธ @${username} is not authorized`, threadRoot);
|
|
163
177
|
return;
|
|
164
178
|
}
|
|
165
|
-
|
|
179
|
+
const prompt = mattermost.extractPrompt(message);
|
|
166
180
|
const files = post.metadata?.files;
|
|
167
|
-
if (
|
|
168
|
-
await
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (!mattermost.isBotMentioned(message))
|
|
173
|
-
return;
|
|
174
|
-
if (!mattermost.isUserAllowed(username)) {
|
|
175
|
-
await mattermost.createPost(`โ ๏ธ @${username} is not authorized`, threadRoot);
|
|
176
|
-
return;
|
|
181
|
+
if (!prompt && !files?.length) {
|
|
182
|
+
await mattermost.createPost(`Mention me with your request`, threadRoot);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
await session.startSession({ prompt, files }, username, threadRoot);
|
|
177
186
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
187
|
+
catch (err) {
|
|
188
|
+
console.error(' โ Error handling message:', err);
|
|
189
|
+
// Try to notify user if possible
|
|
190
|
+
try {
|
|
191
|
+
const threadRoot = post.root_id || post.id;
|
|
192
|
+
await mattermost.createPost(`โ ๏ธ An error occurred. Please try again.`, threadRoot);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// Ignore if we can't post the error message
|
|
196
|
+
}
|
|
183
197
|
}
|
|
184
|
-
await session.startSession({ prompt, files }, username, threadRoot);
|
|
185
198
|
});
|
|
186
199
|
mattermost.on('connected', () => { });
|
|
187
200
|
mattermost.on('error', (e) => console.error(' โ Error:', e));
|
|
@@ -190,7 +203,12 @@ async function main() {
|
|
|
190
203
|
await session.initialize();
|
|
191
204
|
console.log(` โ
${bold('Ready!')} Waiting for @${config.mattermost.botName} mentions...`);
|
|
192
205
|
console.log('');
|
|
206
|
+
let isShuttingDown = false;
|
|
193
207
|
const shutdown = async () => {
|
|
208
|
+
// Guard against multiple shutdown calls (SIGINT + SIGTERM)
|
|
209
|
+
if (isShuttingDown)
|
|
210
|
+
return;
|
|
211
|
+
isShuttingDown = true;
|
|
194
212
|
console.log('');
|
|
195
213
|
console.log(` ๐ ${dim('Shutting down...')}`);
|
|
196
214
|
// Post shutdown message to active sessions
|