claude-threads 0.17.0 → 0.18.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 +29 -1
- package/dist/index.js +296 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,35 @@ 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
|
-
## [
|
|
8
|
+
## [0.18.0] - 2026-01-01
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Keep-alive support** - Prevents system sleep while Claude sessions are active
|
|
12
|
+
- Automatically starts when first session begins, stops when all sessions end
|
|
13
|
+
- Cross-platform: macOS (`caffeinate`), Linux (`systemd-inhibit`), Windows (`SetThreadExecutionState`)
|
|
14
|
+
- Enabled by default, disable with `--no-keep-alive` CLI flag or `keepAlive: false` in config
|
|
15
|
+
- Shows `☕ Keep-alive enabled` in startup output
|
|
16
|
+
- **Resume timed-out sessions via emoji reaction** - React with 🔄 to the timeout message or session header to resume a timed-out session
|
|
17
|
+
- Timeout message now shows resume hint: "💡 React with 🔄 to resume, or send a new message to continue."
|
|
18
|
+
- Resume also works by sending a new message in the thread (existing behavior)
|
|
19
|
+
- Session header now displays truncated session ID for reference
|
|
20
|
+
- Supports multiple resume emojis: 🔄 (arrows_counterclockwise), ▶️ (arrow_forward), 🔁 (repeat)
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **Sticky task list**: Task list now correctly stops being sticky when all tasks are completed
|
|
24
|
+
- Previously, the task list stayed at the bottom even after all tasks had `status: 'completed'`
|
|
25
|
+
- Now properly detects when all tasks are done using `todos.every(t => t.status === 'completed')`
|
|
26
|
+
|
|
27
|
+
## [0.17.1] - 2025-12-31
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- **Sticky task list optimization**: Completed task lists no longer move to the bottom
|
|
31
|
+
- Once all tasks are done, the "~~Tasks~~ *(completed)*" message stays in place
|
|
32
|
+
- Reduces unnecessary message deletions and recreations
|
|
33
|
+
- Added `tasksCompleted` flag to session state for explicit tracking
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
- **Task list visual separator**: Added horizontal rule (`---`) above task list for better visibility
|
|
9
37
|
|
|
10
38
|
## [0.17.0] - 2025-12-31
|
|
11
39
|
|
package/dist/index.js
CHANGED
|
@@ -13694,6 +13694,22 @@ class SessionStore {
|
|
|
13694
13694
|
console.log(" [persist] Cleared all sessions");
|
|
13695
13695
|
}
|
|
13696
13696
|
}
|
|
13697
|
+
findByThread(platformId, threadId) {
|
|
13698
|
+
const sessionId = `${platformId}:${threadId}`;
|
|
13699
|
+
const data = this.loadRaw();
|
|
13700
|
+
return data.sessions[sessionId];
|
|
13701
|
+
}
|
|
13702
|
+
findByPostId(platformId, postId) {
|
|
13703
|
+
const data = this.loadRaw();
|
|
13704
|
+
for (const session of Object.values(data.sessions)) {
|
|
13705
|
+
if (session.platformId !== platformId)
|
|
13706
|
+
continue;
|
|
13707
|
+
if (session.timeoutPostId === postId || session.sessionStartPostId === postId) {
|
|
13708
|
+
return session;
|
|
13709
|
+
}
|
|
13710
|
+
}
|
|
13711
|
+
return;
|
|
13712
|
+
}
|
|
13697
13713
|
loadRaw() {
|
|
13698
13714
|
if (!existsSync3(SESSIONS_FILE)) {
|
|
13699
13715
|
return { version: STORE_VERSION, sessions: {} };
|
|
@@ -13718,6 +13734,7 @@ var ALLOW_ALL_EMOJIS = ["white_check_mark", "heavy_check_mark"];
|
|
|
13718
13734
|
var NUMBER_EMOJIS = ["one", "two", "three", "four"];
|
|
13719
13735
|
var CANCEL_EMOJIS = ["x", "octagonal_sign", "stop_sign"];
|
|
13720
13736
|
var ESCAPE_EMOJIS = ["double_vertical_bar", "pause_button"];
|
|
13737
|
+
var RESUME_EMOJIS = ["arrows_counterclockwise", "arrow_forward", "repeat"];
|
|
13721
13738
|
function isApprovalEmoji(emoji) {
|
|
13722
13739
|
return APPROVAL_EMOJIS.includes(emoji);
|
|
13723
13740
|
}
|
|
@@ -13733,6 +13750,9 @@ function isCancelEmoji(emoji) {
|
|
|
13733
13750
|
function isEscapeEmoji(emoji) {
|
|
13734
13751
|
return ESCAPE_EMOJIS.includes(emoji);
|
|
13735
13752
|
}
|
|
13753
|
+
function isResumeEmoji(emoji) {
|
|
13754
|
+
return RESUME_EMOJIS.includes(emoji);
|
|
13755
|
+
}
|
|
13736
13756
|
var UNICODE_NUMBER_EMOJIS = {
|
|
13737
13757
|
"1\uFE0F\u20E3": 0,
|
|
13738
13758
|
"2\uFE0F\u20E3": 1,
|
|
@@ -13823,6 +13843,9 @@ async function bumpTasksToBottom(session) {
|
|
|
13823
13843
|
if (!session.tasksPostId || !session.lastTasksContent) {
|
|
13824
13844
|
return;
|
|
13825
13845
|
}
|
|
13846
|
+
if (session.tasksCompleted) {
|
|
13847
|
+
return;
|
|
13848
|
+
}
|
|
13826
13849
|
try {
|
|
13827
13850
|
await session.platform.deletePost(session.tasksPostId);
|
|
13828
13851
|
const newPost = await session.platform.createPost(session.lastTasksContent, session.threadId);
|
|
@@ -13853,7 +13876,8 @@ async function flush(session, registerPost) {
|
|
|
13853
13876
|
session.currentPostId = null;
|
|
13854
13877
|
session.pendingContent = remainder;
|
|
13855
13878
|
if (remainder) {
|
|
13856
|
-
|
|
13879
|
+
const hasActiveTasks = session.tasksPostId && session.lastTasksContent && !session.tasksCompleted;
|
|
13880
|
+
if (hasActiveTasks) {
|
|
13857
13881
|
const postId = await bumpTasksToBottomWithContent(session, `*(continued)*
|
|
13858
13882
|
|
|
13859
13883
|
` + remainder, registerPost);
|
|
@@ -13876,7 +13900,8 @@ async function flush(session, registerPost) {
|
|
|
13876
13900
|
if (session.currentPostId) {
|
|
13877
13901
|
await session.platform.updatePost(session.currentPostId, content);
|
|
13878
13902
|
} else {
|
|
13879
|
-
|
|
13903
|
+
const hasActiveTasks = session.tasksPostId && session.lastTasksContent && !session.tasksCompleted;
|
|
13904
|
+
if (hasActiveTasks) {
|
|
13880
13905
|
const postId = await bumpTasksToBottomWithContent(session, content, registerPost);
|
|
13881
13906
|
session.currentPostId = postId;
|
|
13882
13907
|
} else {
|
|
@@ -14843,9 +14868,11 @@ async function handleExitPlanMode(session, toolUseId, ctx) {
|
|
|
14843
14868
|
async function handleTodoWrite(session, input) {
|
|
14844
14869
|
const todos = input.todos;
|
|
14845
14870
|
if (!todos || todos.length === 0) {
|
|
14871
|
+
session.tasksCompleted = true;
|
|
14846
14872
|
if (session.tasksPostId) {
|
|
14847
14873
|
try {
|
|
14848
|
-
const completedMsg =
|
|
14874
|
+
const completedMsg = `---
|
|
14875
|
+
\uD83D\uDCCB ~~Tasks~~ *(completed)*`;
|
|
14849
14876
|
await session.platform.updatePost(session.tasksPostId, completedMsg);
|
|
14850
14877
|
session.lastTasksContent = completedMsg;
|
|
14851
14878
|
} catch (err) {
|
|
@@ -14854,6 +14881,8 @@ async function handleTodoWrite(session, input) {
|
|
|
14854
14881
|
}
|
|
14855
14882
|
return;
|
|
14856
14883
|
}
|
|
14884
|
+
const allCompleted = todos.every((t) => t.status === "completed");
|
|
14885
|
+
session.tasksCompleted = allCompleted;
|
|
14857
14886
|
const completed = todos.filter((t) => t.status === "completed").length;
|
|
14858
14887
|
const total = todos.length;
|
|
14859
14888
|
const pct = Math.round(completed / total * 100);
|
|
@@ -14863,7 +14892,8 @@ async function handleTodoWrite(session, input) {
|
|
|
14863
14892
|
} else if (!hasInProgress) {
|
|
14864
14893
|
session.inProgressTaskStart = null;
|
|
14865
14894
|
}
|
|
14866
|
-
let message =
|
|
14895
|
+
let message = `---
|
|
14896
|
+
\uD83D\uDCCB **Tasks** (${completed}/${total} \xB7 ${pct}%)
|
|
14867
14897
|
|
|
14868
14898
|
`;
|
|
14869
14899
|
for (const todo of todos) {
|
|
@@ -18939,6 +18969,7 @@ async function updateSessionHeader(session, ctx) {
|
|
|
18939
18969
|
rows.push(`| \uD83D\uDC65 **Participants** | ${otherParticipants} |`);
|
|
18940
18970
|
}
|
|
18941
18971
|
rows.push(`| \uD83D\uDD22 **Session** | #${session.sessionNumber} of ${ctx.maxSessions} max |`);
|
|
18972
|
+
rows.push(`| \uD83C\uDD94 **Session ID** | \`${session.claudeSessionId.substring(0, 8)}\` |`);
|
|
18942
18973
|
rows.push(`| ${permMode.split(" ")[0]} **Permissions** | ${permMode.split(" ")[1]} |`);
|
|
18943
18974
|
if (ctx.chromeEnabled) {
|
|
18944
18975
|
rows.push(`| \uD83C\uDF10 **Chrome** | Enabled |`);
|
|
@@ -18971,6 +19002,196 @@ async function updateSessionHeader(session, ctx) {
|
|
|
18971
19002
|
// src/session/lifecycle.ts
|
|
18972
19003
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
18973
19004
|
import { existsSync as existsSync7 } from "fs";
|
|
19005
|
+
|
|
19006
|
+
// src/utils/keep-alive.ts
|
|
19007
|
+
import { spawn as spawn3 } from "child_process";
|
|
19008
|
+
var logger = createLogger("[keep-alive]");
|
|
19009
|
+
|
|
19010
|
+
class KeepAliveManager {
|
|
19011
|
+
activeSessionCount = 0;
|
|
19012
|
+
keepAliveProcess = null;
|
|
19013
|
+
enabled = true;
|
|
19014
|
+
platform;
|
|
19015
|
+
constructor() {
|
|
19016
|
+
this.platform = process.platform;
|
|
19017
|
+
}
|
|
19018
|
+
setEnabled(enabled) {
|
|
19019
|
+
this.enabled = enabled;
|
|
19020
|
+
if (!enabled && this.keepAliveProcess) {
|
|
19021
|
+
this.stopKeepAlive();
|
|
19022
|
+
}
|
|
19023
|
+
logger.debug(`Keep-alive ${enabled ? "enabled" : "disabled"}`);
|
|
19024
|
+
}
|
|
19025
|
+
isEnabled() {
|
|
19026
|
+
return this.enabled;
|
|
19027
|
+
}
|
|
19028
|
+
isActive() {
|
|
19029
|
+
return this.keepAliveProcess !== null;
|
|
19030
|
+
}
|
|
19031
|
+
sessionStarted() {
|
|
19032
|
+
this.activeSessionCount++;
|
|
19033
|
+
logger.debug(`Session started (${this.activeSessionCount} active)`);
|
|
19034
|
+
if (this.activeSessionCount === 1) {
|
|
19035
|
+
this.startKeepAlive();
|
|
19036
|
+
}
|
|
19037
|
+
}
|
|
19038
|
+
sessionEnded() {
|
|
19039
|
+
if (this.activeSessionCount > 0) {
|
|
19040
|
+
this.activeSessionCount--;
|
|
19041
|
+
}
|
|
19042
|
+
logger.debug(`Session ended (${this.activeSessionCount} active)`);
|
|
19043
|
+
if (this.activeSessionCount === 0) {
|
|
19044
|
+
this.stopKeepAlive();
|
|
19045
|
+
}
|
|
19046
|
+
}
|
|
19047
|
+
forceStop() {
|
|
19048
|
+
this.stopKeepAlive();
|
|
19049
|
+
this.activeSessionCount = 0;
|
|
19050
|
+
}
|
|
19051
|
+
getSessionCount() {
|
|
19052
|
+
return this.activeSessionCount;
|
|
19053
|
+
}
|
|
19054
|
+
startKeepAlive() {
|
|
19055
|
+
if (!this.enabled) {
|
|
19056
|
+
logger.debug("Keep-alive disabled, skipping");
|
|
19057
|
+
return;
|
|
19058
|
+
}
|
|
19059
|
+
if (this.keepAliveProcess) {
|
|
19060
|
+
logger.debug("Keep-alive already running");
|
|
19061
|
+
return;
|
|
19062
|
+
}
|
|
19063
|
+
switch (this.platform) {
|
|
19064
|
+
case "darwin":
|
|
19065
|
+
this.startMacOSKeepAlive();
|
|
19066
|
+
break;
|
|
19067
|
+
case "linux":
|
|
19068
|
+
this.startLinuxKeepAlive();
|
|
19069
|
+
break;
|
|
19070
|
+
case "win32":
|
|
19071
|
+
this.startWindowsKeepAlive();
|
|
19072
|
+
break;
|
|
19073
|
+
default:
|
|
19074
|
+
logger.info(`Keep-alive not supported on ${this.platform}`);
|
|
19075
|
+
}
|
|
19076
|
+
}
|
|
19077
|
+
stopKeepAlive() {
|
|
19078
|
+
if (this.keepAliveProcess) {
|
|
19079
|
+
logger.debug("Stopping keep-alive");
|
|
19080
|
+
this.keepAliveProcess.kill();
|
|
19081
|
+
this.keepAliveProcess = null;
|
|
19082
|
+
}
|
|
19083
|
+
}
|
|
19084
|
+
startMacOSKeepAlive() {
|
|
19085
|
+
try {
|
|
19086
|
+
this.keepAliveProcess = spawn3("caffeinate", ["-s", "-i"], {
|
|
19087
|
+
stdio: "ignore",
|
|
19088
|
+
detached: false
|
|
19089
|
+
});
|
|
19090
|
+
this.keepAliveProcess.on("error", (err) => {
|
|
19091
|
+
logger.error(`Failed to start caffeinate: ${err.message}`);
|
|
19092
|
+
this.keepAliveProcess = null;
|
|
19093
|
+
});
|
|
19094
|
+
this.keepAliveProcess.on("exit", (code) => {
|
|
19095
|
+
if (code !== null && code !== 0 && this.activeSessionCount > 0) {
|
|
19096
|
+
logger.debug(`caffeinate exited with code ${code}`);
|
|
19097
|
+
}
|
|
19098
|
+
this.keepAliveProcess = null;
|
|
19099
|
+
});
|
|
19100
|
+
logger.info("System sleep prevention started (caffeinate)");
|
|
19101
|
+
} catch (err) {
|
|
19102
|
+
logger.error(`Failed to start caffeinate: ${err}`);
|
|
19103
|
+
}
|
|
19104
|
+
}
|
|
19105
|
+
startLinuxKeepAlive() {
|
|
19106
|
+
try {
|
|
19107
|
+
this.keepAliveProcess = spawn3("systemd-inhibit", [
|
|
19108
|
+
"--what=sleep:idle:handle-lid-switch",
|
|
19109
|
+
"--why=Claude Code session active",
|
|
19110
|
+
"--mode=block",
|
|
19111
|
+
"sleep",
|
|
19112
|
+
"infinity"
|
|
19113
|
+
], {
|
|
19114
|
+
stdio: "ignore",
|
|
19115
|
+
detached: false
|
|
19116
|
+
});
|
|
19117
|
+
this.keepAliveProcess.on("error", (err) => {
|
|
19118
|
+
logger.debug(`systemd-inhibit not available: ${err.message}`);
|
|
19119
|
+
this.keepAliveProcess = null;
|
|
19120
|
+
this.startLinuxKeepAliveFallback();
|
|
19121
|
+
});
|
|
19122
|
+
this.keepAliveProcess.on("exit", (code) => {
|
|
19123
|
+
if (code !== null && code !== 0 && this.activeSessionCount > 0) {
|
|
19124
|
+
logger.debug(`systemd-inhibit exited with code ${code}`);
|
|
19125
|
+
}
|
|
19126
|
+
this.keepAliveProcess = null;
|
|
19127
|
+
});
|
|
19128
|
+
logger.info("System sleep prevention started (systemd-inhibit)");
|
|
19129
|
+
} catch (err) {
|
|
19130
|
+
logger.debug(`Failed to start systemd-inhibit: ${err}`);
|
|
19131
|
+
this.startLinuxKeepAliveFallback();
|
|
19132
|
+
}
|
|
19133
|
+
}
|
|
19134
|
+
startLinuxKeepAliveFallback() {
|
|
19135
|
+
try {
|
|
19136
|
+
this.keepAliveProcess = spawn3("bash", [
|
|
19137
|
+
"-c",
|
|
19138
|
+
`while true; do xdg-screensaver reset 2>/dev/null || true; sleep 60; done`
|
|
19139
|
+
], {
|
|
19140
|
+
stdio: "ignore",
|
|
19141
|
+
detached: false
|
|
19142
|
+
});
|
|
19143
|
+
this.keepAliveProcess.on("error", (err) => {
|
|
19144
|
+
logger.info(`Linux keep-alive fallback not available: ${err.message}`);
|
|
19145
|
+
this.keepAliveProcess = null;
|
|
19146
|
+
});
|
|
19147
|
+
this.keepAliveProcess.on("exit", () => {
|
|
19148
|
+
this.keepAliveProcess = null;
|
|
19149
|
+
});
|
|
19150
|
+
logger.info("System sleep prevention started (xdg-screensaver reset loop)");
|
|
19151
|
+
} catch (err) {
|
|
19152
|
+
logger.info(`Linux keep-alive not available: ${err}`);
|
|
19153
|
+
}
|
|
19154
|
+
}
|
|
19155
|
+
startWindowsKeepAlive() {
|
|
19156
|
+
try {
|
|
19157
|
+
const script = `
|
|
19158
|
+
Add-Type -TypeDefinition @"
|
|
19159
|
+
using System;
|
|
19160
|
+
using System.Runtime.InteropServices;
|
|
19161
|
+
public class PowerState {
|
|
19162
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
|
19163
|
+
public static extern uint SetThreadExecutionState(uint esFlags);
|
|
19164
|
+
}
|
|
19165
|
+
"@
|
|
19166
|
+
# ES_CONTINUOUS | ES_SYSTEM_REQUIRED
|
|
19167
|
+
[PowerState]::SetThreadExecutionState(0x80000001) | Out-Null
|
|
19168
|
+
# Keep running until killed
|
|
19169
|
+
while ($true) { Start-Sleep -Seconds 60 }
|
|
19170
|
+
`;
|
|
19171
|
+
this.keepAliveProcess = spawn3("powershell", ["-NoProfile", "-Command", script], {
|
|
19172
|
+
stdio: "ignore",
|
|
19173
|
+
detached: false,
|
|
19174
|
+
windowsHide: true
|
|
19175
|
+
});
|
|
19176
|
+
this.keepAliveProcess.on("error", (err) => {
|
|
19177
|
+
logger.info(`Windows keep-alive not available: ${err.message}`);
|
|
19178
|
+
this.keepAliveProcess = null;
|
|
19179
|
+
});
|
|
19180
|
+
this.keepAliveProcess.on("exit", (code) => {
|
|
19181
|
+
if (code !== null && code !== 0 && this.activeSessionCount > 0) {
|
|
19182
|
+
logger.debug(`PowerShell keep-alive exited with code ${code}`);
|
|
19183
|
+
}
|
|
19184
|
+
this.keepAliveProcess = null;
|
|
19185
|
+
});
|
|
19186
|
+
logger.info("System sleep prevention started (SetThreadExecutionState)");
|
|
19187
|
+
} catch (err) {
|
|
19188
|
+
logger.info(`Windows keep-alive not available: ${err}`);
|
|
19189
|
+
}
|
|
19190
|
+
}
|
|
19191
|
+
}
|
|
19192
|
+
var keepAlive = new KeepAliveManager;
|
|
19193
|
+
|
|
19194
|
+
// src/session/lifecycle.ts
|
|
18974
19195
|
function findPersistedByThreadId(persisted, threadId) {
|
|
18975
19196
|
for (const session of persisted.values()) {
|
|
18976
19197
|
if (session.threadId === threadId) {
|
|
@@ -19041,6 +19262,7 @@ async function startSession(options, username, replyToPostId, platformId, ctx) {
|
|
|
19041
19262
|
sessionStartPostId: post.id,
|
|
19042
19263
|
tasksPostId: null,
|
|
19043
19264
|
lastTasksContent: null,
|
|
19265
|
+
tasksCompleted: false,
|
|
19044
19266
|
activeSubagents: new Map,
|
|
19045
19267
|
updateTimer: null,
|
|
19046
19268
|
typingTimer: null,
|
|
@@ -19055,6 +19277,7 @@ async function startSession(options, username, replyToPostId, platformId, ctx) {
|
|
|
19055
19277
|
ctx.registerPost(post.id, actualThreadId);
|
|
19056
19278
|
const shortId = actualThreadId.substring(0, 8);
|
|
19057
19279
|
console.log(` \u25B6 Session #${ctx.sessions.size} started (${shortId}\u2026) by @${username}`);
|
|
19280
|
+
keepAlive.sessionStarted();
|
|
19058
19281
|
await ctx.updateSessionHeader(session);
|
|
19059
19282
|
ctx.startTyping(session);
|
|
19060
19283
|
claude.on("event", (e) => ctx.handleEvent(sessionId, e));
|
|
@@ -19153,6 +19376,7 @@ Please start a new session.`, state.threadId);
|
|
|
19153
19376
|
sessionStartPostId: state.sessionStartPostId,
|
|
19154
19377
|
tasksPostId: state.tasksPostId,
|
|
19155
19378
|
lastTasksContent: state.lastTasksContent ?? null,
|
|
19379
|
+
tasksCompleted: state.tasksCompleted ?? false,
|
|
19156
19380
|
activeSubagents: new Map,
|
|
19157
19381
|
updateTimer: null,
|
|
19158
19382
|
typingTimer: null,
|
|
@@ -19173,6 +19397,7 @@ Please start a new session.`, state.threadId);
|
|
|
19173
19397
|
if (state.sessionStartPostId) {
|
|
19174
19398
|
ctx.registerPost(state.sessionStartPostId, state.threadId);
|
|
19175
19399
|
}
|
|
19400
|
+
keepAlive.sessionStarted();
|
|
19176
19401
|
claude.on("event", (e) => ctx.handleEvent(sessionId, e));
|
|
19177
19402
|
claude.on("exit", (code) => ctx.handleExit(sessionId, code));
|
|
19178
19403
|
try {
|
|
@@ -19251,6 +19476,7 @@ async function handleExit(sessionId, code, ctx) {
|
|
|
19251
19476
|
session.updateTimer = null;
|
|
19252
19477
|
}
|
|
19253
19478
|
ctx.sessions.delete(session.sessionId);
|
|
19479
|
+
keepAlive.sessionEnded();
|
|
19254
19480
|
return;
|
|
19255
19481
|
}
|
|
19256
19482
|
if (session.wasInterrupted) {
|
|
@@ -19267,6 +19493,7 @@ async function handleExit(sessionId, code, ctx) {
|
|
|
19267
19493
|
ctx.postIndex.delete(postId);
|
|
19268
19494
|
}
|
|
19269
19495
|
}
|
|
19496
|
+
keepAlive.sessionEnded();
|
|
19270
19497
|
try {
|
|
19271
19498
|
await session.platform.createPost(`\u2139\uFE0F Session paused. Send a new message to continue.`, session.threadId);
|
|
19272
19499
|
} catch {}
|
|
@@ -19281,6 +19508,7 @@ async function handleExit(sessionId, code, ctx) {
|
|
|
19281
19508
|
session.updateTimer = null;
|
|
19282
19509
|
}
|
|
19283
19510
|
ctx.sessions.delete(session.sessionId);
|
|
19511
|
+
keepAlive.sessionEnded();
|
|
19284
19512
|
try {
|
|
19285
19513
|
await session.platform.createPost(`\u26A0\uFE0F **Session resume failed** (exit code ${code}). The session data is preserved - try restarting the bot.`, session.threadId);
|
|
19286
19514
|
} catch {}
|
|
@@ -19302,6 +19530,7 @@ async function handleExit(sessionId, code, ctx) {
|
|
|
19302
19530
|
ctx.postIndex.delete(postId);
|
|
19303
19531
|
}
|
|
19304
19532
|
}
|
|
19533
|
+
keepAlive.sessionEnded();
|
|
19305
19534
|
if (code === 0 || code === null) {
|
|
19306
19535
|
ctx.unpersistSession(session.sessionId);
|
|
19307
19536
|
} else {
|
|
@@ -19322,6 +19551,7 @@ function killSession(session, unpersist, ctx) {
|
|
|
19322
19551
|
ctx.postIndex.delete(postId);
|
|
19323
19552
|
}
|
|
19324
19553
|
}
|
|
19554
|
+
keepAlive.sessionEnded();
|
|
19325
19555
|
if (unpersist) {
|
|
19326
19556
|
ctx.unpersistSession(session.threadId);
|
|
19327
19557
|
}
|
|
@@ -19334,15 +19564,23 @@ function killAllSessions(ctx) {
|
|
|
19334
19564
|
}
|
|
19335
19565
|
ctx.sessions.clear();
|
|
19336
19566
|
ctx.postIndex.clear();
|
|
19567
|
+
keepAlive.forceStop();
|
|
19337
19568
|
}
|
|
19338
|
-
function cleanupIdleSessions(timeoutMs, warningMs, ctx) {
|
|
19569
|
+
async function cleanupIdleSessions(timeoutMs, warningMs, ctx) {
|
|
19339
19570
|
const now = Date.now();
|
|
19340
19571
|
for (const [_sessionId, session] of ctx.sessions) {
|
|
19341
19572
|
const idleMs = now - session.lastActivityAt.getTime();
|
|
19342
19573
|
const shortId = session.threadId.substring(0, 8);
|
|
19343
19574
|
if (idleMs > timeoutMs) {
|
|
19344
19575
|
console.log(` \u23F0 Session (${shortId}\u2026) timed out after ${Math.round(idleMs / 60000)}min idle`);
|
|
19345
|
-
|
|
19576
|
+
try {
|
|
19577
|
+
const timeoutPost = await session.platform.createPost(`\u23F0 **Session timed out** after ${Math.round(idleMs / 60000)} minutes of inactivity
|
|
19578
|
+
|
|
19579
|
+
` + `\uD83D\uDCA1 React with \uD83D\uDD04 to resume, or send a new message to continue.`, session.threadId);
|
|
19580
|
+
session.timeoutPostId = timeoutPost.id;
|
|
19581
|
+
ctx.persistSession(session);
|
|
19582
|
+
ctx.registerPost(timeoutPost.id, session.threadId);
|
|
19583
|
+
} catch {}
|
|
19346
19584
|
killSession(session, false, ctx);
|
|
19347
19585
|
continue;
|
|
19348
19586
|
}
|
|
@@ -19357,13 +19595,13 @@ function cleanupIdleSessions(timeoutMs, warningMs, ctx) {
|
|
|
19357
19595
|
}
|
|
19358
19596
|
|
|
19359
19597
|
// src/git/worktree.ts
|
|
19360
|
-
import { spawn as
|
|
19598
|
+
import { spawn as spawn4 } from "child_process";
|
|
19361
19599
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
19362
19600
|
import * as path9 from "path";
|
|
19363
19601
|
import * as fs5 from "fs/promises";
|
|
19364
19602
|
async function execGit(args, cwd) {
|
|
19365
19603
|
return new Promise((resolve6, reject) => {
|
|
19366
|
-
const proc =
|
|
19604
|
+
const proc = spawn4("git", args, { cwd });
|
|
19367
19605
|
let stdout = "";
|
|
19368
19606
|
let stderr = "";
|
|
19369
19607
|
proc.stdout.on("data", (data) => {
|
|
@@ -19898,7 +20136,7 @@ class SessionManager {
|
|
|
19898
20136
|
this.chromeEnabled = chromeEnabled;
|
|
19899
20137
|
this.worktreeMode = worktreeMode;
|
|
19900
20138
|
this.cleanupTimer = setInterval(() => {
|
|
19901
|
-
cleanupIdleSessions(SESSION_TIMEOUT_MS, SESSION_WARNING_MS, this.getLifecycleContext());
|
|
20139
|
+
cleanupIdleSessions(SESSION_TIMEOUT_MS, SESSION_WARNING_MS, this.getLifecycleContext()).catch((err) => console.error(" [cleanup] Error during idle session cleanup:", err));
|
|
19902
20140
|
}, 60000);
|
|
19903
20141
|
}
|
|
19904
20142
|
addPlatform(platformId, client) {
|
|
@@ -19993,6 +20231,11 @@ class SessionManager {
|
|
|
19993
20231
|
}
|
|
19994
20232
|
async handleMessage(_platformId, _post, _user) {}
|
|
19995
20233
|
async handleReaction(platformId, postId, emojiName, username) {
|
|
20234
|
+
if (isResumeEmoji(emojiName)) {
|
|
20235
|
+
const resumed = await this.tryResumeFromReaction(platformId, postId, username);
|
|
20236
|
+
if (resumed)
|
|
20237
|
+
return;
|
|
20238
|
+
}
|
|
19996
20239
|
const session = this.getSessionByPost(postId);
|
|
19997
20240
|
if (!session)
|
|
19998
20241
|
return;
|
|
@@ -20003,6 +20246,36 @@ class SessionManager {
|
|
|
20003
20246
|
}
|
|
20004
20247
|
await this.handleSessionReaction(session, postId, emojiName, username);
|
|
20005
20248
|
}
|
|
20249
|
+
async tryResumeFromReaction(platformId, postId, username) {
|
|
20250
|
+
const persistedSession = this.sessionStore.findByPostId(platformId, postId);
|
|
20251
|
+
if (!persistedSession)
|
|
20252
|
+
return false;
|
|
20253
|
+
const sessionId = `${platformId}:${persistedSession.threadId}`;
|
|
20254
|
+
if (this.sessions.has(sessionId)) {
|
|
20255
|
+
if (this.debug) {
|
|
20256
|
+
console.log(` [resume] Session already active for ${persistedSession.threadId.substring(0, 8)}...`);
|
|
20257
|
+
}
|
|
20258
|
+
return false;
|
|
20259
|
+
}
|
|
20260
|
+
const allowedUsers = new Set(persistedSession.sessionAllowedUsers);
|
|
20261
|
+
const platform = this.platforms.get(platformId);
|
|
20262
|
+
if (!allowedUsers.has(username) && !platform?.isUserAllowed(username)) {
|
|
20263
|
+
if (platform) {
|
|
20264
|
+
await platform.createPost(`\u26A0\uFE0F @${username} is not authorized to resume this session`, persistedSession.threadId);
|
|
20265
|
+
}
|
|
20266
|
+
return false;
|
|
20267
|
+
}
|
|
20268
|
+
if (this.sessions.size >= MAX_SESSIONS) {
|
|
20269
|
+
if (platform) {
|
|
20270
|
+
await platform.createPost(`\u26A0\uFE0F **Too busy** - ${this.sessions.size} sessions active. Please try again later.`, persistedSession.threadId);
|
|
20271
|
+
}
|
|
20272
|
+
return false;
|
|
20273
|
+
}
|
|
20274
|
+
const shortId = persistedSession.threadId.substring(0, 8);
|
|
20275
|
+
console.log(` \uD83D\uDD04 Resuming session ${shortId}... via emoji reaction by @${username}`);
|
|
20276
|
+
await resumeSession(persistedSession, this.getLifecycleContext());
|
|
20277
|
+
return true;
|
|
20278
|
+
}
|
|
20006
20279
|
async handleSessionReaction(session, postId, emojiName, username) {
|
|
20007
20280
|
if (session.worktreePromptPostId === postId && emojiName === "x") {
|
|
20008
20281
|
await handleWorktreeSkip(session, username, (s) => this.persistSession(s), (s, q) => this.offerContextPrompt(s, q));
|
|
@@ -20178,13 +20451,15 @@ class SessionManager {
|
|
|
20178
20451
|
sessionStartPostId: session.sessionStartPostId,
|
|
20179
20452
|
tasksPostId: session.tasksPostId,
|
|
20180
20453
|
lastTasksContent: session.lastTasksContent,
|
|
20454
|
+
tasksCompleted: session.tasksCompleted,
|
|
20181
20455
|
worktreeInfo: session.worktreeInfo,
|
|
20182
20456
|
pendingWorktreePrompt: session.pendingWorktreePrompt,
|
|
20183
20457
|
worktreePromptDisabled: session.worktreePromptDisabled,
|
|
20184
20458
|
queuedPrompt: session.queuedPrompt,
|
|
20185
20459
|
firstPrompt: session.firstPrompt,
|
|
20186
20460
|
pendingContextPrompt: persistedContextPrompt,
|
|
20187
|
-
needsContextPromptOnNextMessage: session.needsContextPromptOnNextMessage
|
|
20461
|
+
needsContextPromptOnNextMessage: session.needsContextPromptOnNextMessage,
|
|
20462
|
+
timeoutPostId: session.timeoutPostId
|
|
20188
20463
|
};
|
|
20189
20464
|
this.sessionStore.save(session.sessionId, state);
|
|
20190
20465
|
}
|
|
@@ -20426,7 +20701,7 @@ class SessionManager {
|
|
|
20426
20701
|
var dim2 = (s) => `\x1B[2m${s}\x1B[0m`;
|
|
20427
20702
|
var bold2 = (s) => `\x1B[1m${s}\x1B[0m`;
|
|
20428
20703
|
var cyan = (s) => `\x1B[36m${s}\x1B[0m`;
|
|
20429
|
-
program.name("claude-threads").version(VERSION).description("Share Claude Code sessions in Mattermost").option("--url <url>", "Mattermost server URL").option("--token <token>", "Mattermost bot token").option("--channel <id>", "Mattermost channel ID").option("--bot-name <name>", "Bot mention name (default: claude-code)").option("--allowed-users <users>", "Comma-separated allowed usernames").option("--skip-permissions", "Skip interactive permission prompts").option("--no-skip-permissions", "Enable interactive permission prompts (override env)").option("--chrome", "Enable Claude in Chrome integration").option("--no-chrome", "Disable Claude in Chrome integration").option("--worktree-mode <mode>", "Git worktree mode: off, prompt, require (default: prompt)").option("--setup", "Run interactive setup wizard (reconfigure existing settings)").option("--debug", "Enable debug logging").parse();
|
|
20704
|
+
program.name("claude-threads").version(VERSION).description("Share Claude Code sessions in Mattermost").option("--url <url>", "Mattermost server URL").option("--token <token>", "Mattermost bot token").option("--channel <id>", "Mattermost channel ID").option("--bot-name <name>", "Bot mention name (default: claude-code)").option("--allowed-users <users>", "Comma-separated allowed usernames").option("--skip-permissions", "Skip interactive permission prompts").option("--no-skip-permissions", "Enable interactive permission prompts (override env)").option("--chrome", "Enable Claude in Chrome integration").option("--no-chrome", "Disable Claude in Chrome integration").option("--worktree-mode <mode>", "Git worktree mode: off, prompt, require (default: prompt)").option("--keep-alive", "Enable system sleep prevention (default: enabled)").option("--no-keep-alive", "Disable system sleep prevention").option("--setup", "Run interactive setup wizard (reconfigure existing settings)").option("--debug", "Enable debug logging").parse();
|
|
20430
20705
|
var opts = program.opts();
|
|
20431
20706
|
function hasRequiredCliArgs(args) {
|
|
20432
20707
|
return !!(args.url && args.token && args.channel);
|
|
@@ -20444,7 +20719,8 @@ async function main() {
|
|
|
20444
20719
|
allowedUsers: opts.allowedUsers,
|
|
20445
20720
|
skipPermissions: opts.skipPermissions,
|
|
20446
20721
|
chrome: opts.chrome,
|
|
20447
|
-
worktreeMode: opts.worktreeMode
|
|
20722
|
+
worktreeMode: opts.worktreeMode,
|
|
20723
|
+
keepAlive: opts.keepAlive
|
|
20448
20724
|
};
|
|
20449
20725
|
if (opts.setup) {
|
|
20450
20726
|
await runOnboarding(true);
|
|
@@ -20462,6 +20738,11 @@ async function main() {
|
|
|
20462
20738
|
if (cliArgs.worktreeMode !== undefined) {
|
|
20463
20739
|
newConfig.worktreeMode = cliArgs.worktreeMode;
|
|
20464
20740
|
}
|
|
20741
|
+
if (cliArgs.keepAlive !== undefined) {
|
|
20742
|
+
newConfig.keepAlive = cliArgs.keepAlive;
|
|
20743
|
+
}
|
|
20744
|
+
const keepAliveEnabled = newConfig.keepAlive !== false;
|
|
20745
|
+
keepAlive.setEnabled(keepAliveEnabled);
|
|
20465
20746
|
const platformConfig = newConfig.platforms.find((p) => p.type === "mattermost");
|
|
20466
20747
|
if (!platformConfig) {
|
|
20467
20748
|
throw new Error("No Mattermost platform configured.");
|
|
@@ -20481,6 +20762,9 @@ async function main() {
|
|
|
20481
20762
|
if (config.chrome) {
|
|
20482
20763
|
console.log(` \uD83C\uDF10 ${dim2("Chrome integration enabled")}`);
|
|
20483
20764
|
}
|
|
20765
|
+
if (keepAliveEnabled) {
|
|
20766
|
+
console.log(` \u2615 ${dim2("Keep-alive enabled")}`);
|
|
20767
|
+
}
|
|
20484
20768
|
console.log("");
|
|
20485
20769
|
const mattermost = new MattermostClient(platformConfig);
|
|
20486
20770
|
const session = new SessionManager(workingDir, platformConfig.skipPermissions, config.chrome, config.worktreeMode);
|