claude-threads 0.19.2 → 0.20.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/index.js +398 -30
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.20.0] - 2026-01-01
11
+
12
+ ### Added
13
+ - **Sticky message improvements** - Enhanced the channel sticky message with active sessions
14
+ - Shows display name in bold (e.g., **Anne**) instead of username
15
+ - Added session description below the title (generated by Claude)
16
+ - Added install hint: `npm i -g claude-threads` in footer
17
+ - Periodic refresh every 60 seconds to keep relative times current
18
+ - Auto-cleanup of old sticky messages from failed runs at startup
19
+
20
+ ### Fixed
21
+ - **Sticky message updates on session end** - Message now updates when sessions are:
22
+ - Canceled via `!stop` or ❌ reaction
23
+ - Paused/interrupted via `!escape` or ⏸️ reaction
24
+ - Killed due to timeout
25
+ - Failed to start or resume
26
+ - **Race condition in sticky updates** - Added mutex to prevent duplicate sticky posts when multiple updates happen concurrently
27
+
10
28
  ## [0.19.2] - 2026-01-01
11
29
 
12
30
  ### Added
package/dist/index.js CHANGED
@@ -13289,9 +13289,11 @@ class MattermostClient extends EventEmitter {
13289
13289
  this.allowedUsers = platformConfig.allowedUsers;
13290
13290
  }
13291
13291
  normalizePlatformUser(mattermostUser) {
13292
+ const displayName = mattermostUser.first_name || mattermostUser.nickname || mattermostUser.username;
13292
13293
  return {
13293
13294
  id: mattermostUser.id,
13294
13295
  username: mattermostUser.username,
13296
+ displayName,
13295
13297
  email: mattermostUser.email
13296
13298
  };
13297
13299
  }
@@ -13425,6 +13427,16 @@ class MattermostClient extends EventEmitter {
13425
13427
  async deletePost(postId) {
13426
13428
  await this.api("DELETE", `/posts/${postId}`);
13427
13429
  }
13430
+ async pinPost(postId) {
13431
+ await this.api("POST", `/posts/${postId}/pin`);
13432
+ }
13433
+ async unpinPost(postId) {
13434
+ await this.api("POST", `/posts/${postId}/unpin`);
13435
+ }
13436
+ async getPinnedPosts() {
13437
+ const response = await this.api("GET", `/channels/${this.channelId}/pinned`);
13438
+ return response.order || [];
13439
+ }
13428
13440
  async getThreadHistory(threadId, options) {
13429
13441
  try {
13430
13442
  const response = await this.api("GET", `/posts/${threadId}/thread`);
@@ -13513,7 +13525,11 @@ class MattermostClient extends EventEmitter {
13513
13525
  if (post.channel_id !== this.channelId)
13514
13526
  return;
13515
13527
  this.getUser(post.user_id).then((user) => {
13516
- this.emit("message", this.normalizePlatformPost(post), user);
13528
+ const normalizedPost = this.normalizePlatformPost(post);
13529
+ this.emit("message", normalizedPost, user);
13530
+ if (!post.root_id) {
13531
+ this.emit("channel_post", normalizedPost, user);
13532
+ }
13517
13533
  });
13518
13534
  } catch (err) {
13519
13535
  wsLogger.debug(`Failed to parse post: ${err}`);
@@ -13716,11 +13732,37 @@ class SessionStore {
13716
13732
  return staleIds;
13717
13733
  }
13718
13734
  clear() {
13719
- this.writeAtomic({ version: STORE_VERSION, sessions: {} });
13735
+ const data = this.loadRaw();
13736
+ this.writeAtomic({ version: STORE_VERSION, sessions: {}, stickyPostIds: data.stickyPostIds });
13720
13737
  if (this.debug) {
13721
13738
  console.log(" [persist] Cleared all sessions");
13722
13739
  }
13723
13740
  }
13741
+ saveStickyPostId(platformId, postId) {
13742
+ const data = this.loadRaw();
13743
+ if (!data.stickyPostIds) {
13744
+ data.stickyPostIds = {};
13745
+ }
13746
+ data.stickyPostIds[platformId] = postId;
13747
+ this.writeAtomic(data);
13748
+ if (this.debug) {
13749
+ console.log(` [persist] Saved sticky post ID for ${platformId}: ${postId.substring(0, 8)}...`);
13750
+ }
13751
+ }
13752
+ getStickyPostIds() {
13753
+ const data = this.loadRaw();
13754
+ return new Map(Object.entries(data.stickyPostIds || {}));
13755
+ }
13756
+ removeStickyPostId(platformId) {
13757
+ const data = this.loadRaw();
13758
+ if (data.stickyPostIds && data.stickyPostIds[platformId]) {
13759
+ delete data.stickyPostIds[platformId];
13760
+ this.writeAtomic(data);
13761
+ if (this.debug) {
13762
+ console.log(` [persist] Removed sticky post ID for ${platformId}`);
13763
+ }
13764
+ }
13765
+ }
13724
13766
  findByThread(platformId, threadId) {
13725
13767
  const sessionId = `${platformId}:${threadId}`;
13726
13768
  const data = this.loadRaw();
@@ -14822,7 +14864,7 @@ function handleEvent(session, event, ctx) {
14822
14864
  handleExitPlanMode(session, block.id, ctx);
14823
14865
  hasSpecialTool = true;
14824
14866
  } else if (block.name === "TodoWrite") {
14825
- handleTodoWrite(session, block.input);
14867
+ handleTodoWrite(session, block.input, ctx);
14826
14868
  } else if (block.name === "Task") {
14827
14869
  handleTaskStart(session, block.id, block.input, ctx);
14828
14870
  } else if (block.name === "AskUserQuestion") {
@@ -14865,7 +14907,27 @@ function formatEvent(session, e, ctx) {
14865
14907
  const parts = [];
14866
14908
  for (const block of msg?.content || []) {
14867
14909
  if (block.type === "text" && block.text) {
14868
- const text = block.text.replace(/<thinking>[\s\S]*?<\/thinking>/g, "").trim();
14910
+ let text = block.text.replace(/<thinking>[\s\S]*?<\/thinking>/g, "").trim();
14911
+ const titleMatch = text.match(/\[SESSION_TITLE:\s*([^\]]+)\]/);
14912
+ if (titleMatch) {
14913
+ const newTitle = titleMatch[1].trim();
14914
+ if (newTitle !== session.sessionTitle) {
14915
+ session.sessionTitle = newTitle;
14916
+ ctx.persistSession(session);
14917
+ ctx.updateStickyMessage().catch(() => {});
14918
+ }
14919
+ text = text.replace(/\[SESSION_TITLE:\s*[^\]]+\]\s*/g, "").trim();
14920
+ }
14921
+ const descMatch = text.match(/\[SESSION_DESCRIPTION:\s*([^\]]+)\]/);
14922
+ if (descMatch) {
14923
+ const newDesc = descMatch[1].trim();
14924
+ if (newDesc !== session.sessionDescription) {
14925
+ session.sessionDescription = newDesc;
14926
+ ctx.persistSession(session);
14927
+ ctx.updateStickyMessage().catch(() => {});
14928
+ }
14929
+ text = text.replace(/\[SESSION_DESCRIPTION:\s*[^\]]+\]\s*/g, "").trim();
14930
+ }
14869
14931
  if (text)
14870
14932
  parts.push(text);
14871
14933
  } else if (block.type === "tool_use" && block.name) {
@@ -14965,7 +15027,7 @@ async function handleExitPlanMode(session, toolUseId, ctx) {
14965
15027
  session.pendingApproval = { postId: post.id, type: "plan", toolUseId };
14966
15028
  ctx.stopTyping(session);
14967
15029
  }
14968
- async function handleTodoWrite(session, input) {
15030
+ async function handleTodoWrite(session, input, ctx) {
14969
15031
  const todos = input.todos;
14970
15032
  if (!todos || todos.length === 0) {
14971
15033
  session.tasksCompleted = true;
@@ -15046,6 +15108,7 @@ async function handleTodoWrite(session, input) {
15046
15108
  const post = await session.platform.createInteractivePost(displayMessage, [TASK_TOGGLE_EMOJIS[0]], session.threadId);
15047
15109
  session.tasksPostId = post.id;
15048
15110
  }
15111
+ ctx.updateStickyMessage().catch(() => {});
15049
15112
  } catch (err) {
15050
15113
  console.error(" \u26A0\uFE0F Failed to update tasks:", err);
15051
15114
  }
@@ -15326,6 +15389,9 @@ class ClaudeCli extends EventEmitter2 {
15326
15389
  if (this.options.chrome) {
15327
15390
  args.push("--chrome");
15328
15391
  }
15392
+ if (this.options.appendSystemPrompt) {
15393
+ args.push("--append-system-prompt", this.options.appendSystemPrompt);
15394
+ }
15329
15395
  if (this.debug) {
15330
15396
  console.log(` [claude] Starting: ${claudePath} ${args.slice(0, 5).join(" ")}...`);
15331
15397
  }
@@ -18917,7 +18983,7 @@ async function cancelSession(session, username, ctx) {
18917
18983
  const shortId = session.threadId.substring(0, 8);
18918
18984
  console.log(` \uD83D\uDED1 Session (${shortId}\u2026) cancelled by @${username}`);
18919
18985
  await session.platform.createPost(`\uD83D\uDED1 **Session cancelled** by @${username}`, session.threadId);
18920
- ctx.killSession(session.threadId);
18986
+ await ctx.killSession(session.threadId);
18921
18987
  }
18922
18988
  async function interruptSession(session, username) {
18923
18989
  if (!session.claude.isRunning()) {
@@ -19336,7 +19402,34 @@ function findPersistedByThreadId(persisted, threadId) {
19336
19402
  }
19337
19403
  return;
19338
19404
  }
19339
- async function startSession(options, username, replyToPostId, platformId, ctx) {
19405
+ var CHAT_PLATFORM_PROMPT = `
19406
+ You are running inside a chat platform (like Mattermost or Slack). Users interact with you through chat messages in a thread.
19407
+
19408
+ SESSION METADATA: At the START of your first response, include metadata about this session:
19409
+
19410
+ [SESSION_TITLE: <short title>]
19411
+ [SESSION_DESCRIPTION: <brief description>]
19412
+
19413
+ Title requirements:
19414
+ - 3-7 words maximum
19415
+ - Descriptive of the main task/topic
19416
+ - Written in imperative form (e.g., "Fix login bug", "Add dark mode")
19417
+ - Do NOT include quotes
19418
+
19419
+ Description requirements:
19420
+ - 1-2 sentences explaining what you're helping with
19421
+ - Summarize the current work or goal
19422
+ - Keep it under 100 characters
19423
+
19424
+ You can update both later if the session focus changes significantly.
19425
+
19426
+ Example: If the user asks "help me debug why the tests are failing", respond with:
19427
+ [SESSION_TITLE: Debug failing tests]
19428
+ [SESSION_DESCRIPTION: Investigating test failures and fixing broken assertions in the test suite.]
19429
+
19430
+ Then continue with your normal response.
19431
+ `.trim();
19432
+ async function startSession(options, username, displayName, replyToPostId, platformId, ctx) {
19340
19433
  const threadId = replyToPostId || "";
19341
19434
  const existingSessionId = ctx.getSessionId(platformId, threadId);
19342
19435
  const existingSession = ctx.sessions.get(existingSessionId);
@@ -19372,7 +19465,8 @@ async function startSession(options, username, replyToPostId, platformId, ctx) {
19372
19465
  sessionId: claudeSessionId,
19373
19466
  resume: false,
19374
19467
  chrome: ctx.chromeEnabled,
19375
- platformConfig: platformMcpConfig
19468
+ platformConfig: platformMcpConfig,
19469
+ appendSystemPrompt: CHAT_PLATFORM_PROMPT
19376
19470
  };
19377
19471
  const claude = new ClaudeCli(cliOptions);
19378
19472
  const session = {
@@ -19382,6 +19476,7 @@ async function startSession(options, username, replyToPostId, platformId, ctx) {
19382
19476
  platform,
19383
19477
  claudeSessionId,
19384
19478
  startedBy: username,
19479
+ startedByDisplayName: displayName,
19385
19480
  startedAt: new Date,
19386
19481
  lastActivityAt: new Date,
19387
19482
  sessionNumber: ctx.sessions.size + 1,
@@ -19408,7 +19503,8 @@ async function startSession(options, username, replyToPostId, platformId, ctx) {
19408
19503
  isResumed: false,
19409
19504
  wasInterrupted: false,
19410
19505
  inProgressTaskStart: null,
19411
- activeToolStarts: new Map
19506
+ activeToolStarts: new Map,
19507
+ firstPrompt: options.prompt
19412
19508
  };
19413
19509
  ctx.sessions.set(sessionId, session);
19414
19510
  ctx.registerPost(post.id, actualThreadId);
@@ -19416,6 +19512,7 @@ async function startSession(options, username, replyToPostId, platformId, ctx) {
19416
19512
  console.log(` \u25B6 Session #${ctx.sessions.size} started (${shortId}\u2026) by @${username}`);
19417
19513
  keepAlive.sessionStarted();
19418
19514
  await ctx.updateSessionHeader(session);
19515
+ await ctx.updateStickyMessage();
19419
19516
  ctx.startTyping(session);
19420
19517
  claude.on("event", (e) => ctx.handleEvent(sessionId, e));
19421
19518
  claude.on("exit", (code) => ctx.handleExit(sessionId, code));
@@ -19426,9 +19523,9 @@ async function startSession(options, username, replyToPostId, platformId, ctx) {
19426
19523
  ctx.stopTyping(session);
19427
19524
  await session.platform.createPost(`\u274C ${err}`, actualThreadId);
19428
19525
  ctx.sessions.delete(session.sessionId);
19526
+ await ctx.updateStickyMessage();
19429
19527
  return;
19430
19528
  }
19431
- session.firstPrompt = options.prompt;
19432
19529
  const shouldPrompt = await ctx.shouldPromptForWorktree(session);
19433
19530
  if (shouldPrompt) {
19434
19531
  session.queuedPrompt = options.prompt;
@@ -19480,6 +19577,7 @@ Please start a new session.`, state.threadId);
19480
19577
  const sessionId = ctx.getSessionId(platformId, state.threadId);
19481
19578
  const skipPerms = ctx.skipPermissions && !state.forceInteractivePermissions;
19482
19579
  const platformMcpConfig = platform.getMcpConfig();
19580
+ const needsTitlePrompt = !state.sessionTitle;
19483
19581
  const cliOptions = {
19484
19582
  workingDir: state.workingDir,
19485
19583
  threadId: state.threadId,
@@ -19487,7 +19585,8 @@ Please start a new session.`, state.threadId);
19487
19585
  sessionId: state.claudeSessionId,
19488
19586
  resume: true,
19489
19587
  chrome: ctx.chromeEnabled,
19490
- platformConfig: platformMcpConfig
19588
+ platformConfig: platformMcpConfig,
19589
+ appendSystemPrompt: needsTitlePrompt ? CHAT_PLATFORM_PROMPT : undefined
19491
19590
  };
19492
19591
  const claude = new ClaudeCli(cliOptions);
19493
19592
  const session = {
@@ -19497,6 +19596,7 @@ Please start a new session.`, state.threadId);
19497
19596
  platform,
19498
19597
  claudeSessionId: state.claudeSessionId,
19499
19598
  startedBy: state.startedBy,
19599
+ startedByDisplayName: state.startedByDisplayName,
19500
19600
  startedAt: new Date(state.startedAt),
19501
19601
  lastActivityAt: new Date,
19502
19602
  sessionNumber: state.sessionNumber,
@@ -19529,7 +19629,9 @@ Please start a new session.`, state.threadId);
19529
19629
  worktreePromptDisabled: state.worktreePromptDisabled,
19530
19630
  queuedPrompt: state.queuedPrompt,
19531
19631
  firstPrompt: state.firstPrompt,
19532
- needsContextPromptOnNextMessage: state.needsContextPromptOnNextMessage
19632
+ needsContextPromptOnNextMessage: state.needsContextPromptOnNextMessage,
19633
+ sessionTitle: state.sessionTitle,
19634
+ sessionDescription: state.sessionDescription
19533
19635
  };
19534
19636
  ctx.sessions.set(sessionId, session);
19535
19637
  if (state.sessionStartPostId) {
@@ -19544,6 +19646,7 @@ Please start a new session.`, state.threadId);
19544
19646
  await session.platform.createPost(`\uD83D\uDD04 **Session resumed** after bot restart (v${VERSION})
19545
19647
  *Reconnected to Claude session. You can continue where you left off.*`, state.threadId);
19546
19648
  await ctx.updateSessionHeader(session);
19649
+ await ctx.updateStickyMessage();
19547
19650
  ctx.persistSession(session);
19548
19651
  } catch (err) {
19549
19652
  console.error(` \u274C Failed to resume session ${shortId}...:`, err);
@@ -19553,6 +19656,7 @@ Please start a new session.`, state.threadId);
19553
19656
  await session.platform.createPost(`\u26A0\uFE0F **Could not resume previous session.** Starting fresh.
19554
19657
  *Your previous conversation context is preserved, but Claude needs to re-read it.*`, state.threadId);
19555
19658
  } catch {}
19659
+ await ctx.updateStickyMessage();
19556
19660
  }
19557
19661
  }
19558
19662
  async function sendFollowUp(session, message, files, ctx) {
@@ -19636,6 +19740,7 @@ async function handleExit(sessionId, code, ctx) {
19636
19740
  await session.platform.createPost(`\u2139\uFE0F Session paused. Send a new message to continue.`, session.threadId);
19637
19741
  } catch {}
19638
19742
  console.log(` \u23F8\uFE0F Session paused (${shortId}\u2026) \u2014 ${ctx.sessions.size} active`);
19743
+ await ctx.updateStickyMessage();
19639
19744
  return;
19640
19745
  }
19641
19746
  if (session.isResumed && code !== 0) {
@@ -19650,6 +19755,7 @@ async function handleExit(sessionId, code, ctx) {
19650
19755
  try {
19651
19756
  await session.platform.createPost(`\u26A0\uFE0F **Session resume failed** (exit code ${code}). The session data is preserved - try restarting the bot.`, session.threadId);
19652
19757
  } catch {}
19758
+ await ctx.updateStickyMessage();
19653
19759
  return;
19654
19760
  }
19655
19761
  console.log(` [exit] Session ${shortId}... normal exit, cleaning up`);
@@ -19675,8 +19781,9 @@ async function handleExit(sessionId, code, ctx) {
19675
19781
  console.log(` [exit] Session ${shortId}... non-zero exit, preserving for potential retry`);
19676
19782
  }
19677
19783
  console.log(` \u25A0 Session ended (${shortId}\u2026) \u2014 ${ctx.sessions.size} active`);
19784
+ await ctx.updateStickyMessage();
19678
19785
  }
19679
- function killSession(session, unpersist, ctx) {
19786
+ async function killSession(session, unpersist, ctx) {
19680
19787
  const shortId = session.threadId.substring(0, 8);
19681
19788
  if (!unpersist) {
19682
19789
  session.isRestarting = true;
@@ -19694,6 +19801,7 @@ function killSession(session, unpersist, ctx) {
19694
19801
  ctx.unpersistSession(session.threadId);
19695
19802
  }
19696
19803
  console.log(` \u2716 Session killed (${shortId}\u2026) \u2014 ${ctx.sessions.size} active`);
19804
+ await ctx.updateStickyMessage();
19697
19805
  }
19698
19806
  function killAllSessions(ctx) {
19699
19807
  for (const session of ctx.sessions.values()) {
@@ -19719,7 +19827,7 @@ async function cleanupIdleSessions(timeoutMs, warningMs, ctx) {
19719
19827
  ctx.persistSession(session);
19720
19828
  ctx.registerPost(timeoutPost.id, session.threadId);
19721
19829
  } catch {}
19722
- killSession(session, false, ctx);
19830
+ await killSession(session, false, ctx);
19723
19831
  continue;
19724
19832
  }
19725
19833
  const warningThresholdMs = timeoutMs - warningMs;
@@ -20250,6 +20358,239 @@ function clearContextPromptTimeout(pending) {
20250
20358
  }
20251
20359
  }
20252
20360
 
20361
+ // src/session/sticky-message.ts
20362
+ var stickyPostIds = new Map;
20363
+ var needsBump = new Map;
20364
+ var updateLocks = new Map;
20365
+ var sessionStore = null;
20366
+ function initialize(store) {
20367
+ sessionStore = store;
20368
+ const persistedIds = store.getStickyPostIds();
20369
+ for (const [platformId, postId] of persistedIds) {
20370
+ stickyPostIds.set(platformId, postId);
20371
+ }
20372
+ if (persistedIds.size > 0) {
20373
+ console.log(` \uD83D\uDCCC Restored ${persistedIds.size} sticky post ID(s) from persistence`);
20374
+ }
20375
+ }
20376
+ function formatRelativeTime(date) {
20377
+ const now = Date.now();
20378
+ const diff = now - date.getTime();
20379
+ const minutes = Math.floor(diff / 60000);
20380
+ const hours = Math.floor(minutes / 60);
20381
+ if (minutes < 1)
20382
+ return "just now";
20383
+ if (minutes < 60)
20384
+ return `${minutes}m ago`;
20385
+ if (hours < 24)
20386
+ return `${hours}h ago`;
20387
+ return `${Math.floor(hours / 24)}d ago`;
20388
+ }
20389
+ function getTaskProgress(session) {
20390
+ if (!session.lastTasksContent)
20391
+ return null;
20392
+ const match = session.lastTasksContent.match(/\((\d+)\/(\d+)/);
20393
+ if (match) {
20394
+ return `${match[1]}/${match[2]}`;
20395
+ }
20396
+ return null;
20397
+ }
20398
+ function getSessionTopic(session) {
20399
+ if (session.sessionTitle) {
20400
+ return session.sessionTitle;
20401
+ }
20402
+ return formatTopicFromPrompt(session.firstPrompt);
20403
+ }
20404
+ function formatTopicFromPrompt(prompt) {
20405
+ if (!prompt)
20406
+ return "_No topic_";
20407
+ let cleaned = prompt.replace(/^@[\w-]+\s*/g, "").trim();
20408
+ if (cleaned.startsWith("!")) {
20409
+ return "_No topic_";
20410
+ }
20411
+ cleaned = cleaned.replace(/\s+/g, " ");
20412
+ if (cleaned.length > 50) {
20413
+ cleaned = cleaned.substring(0, 47) + "\u2026";
20414
+ }
20415
+ return cleaned || "_No topic_";
20416
+ }
20417
+ function buildStickyMessage(sessions, platformId) {
20418
+ const platformSessions = [...sessions.values()].filter((s) => s.platformId === platformId);
20419
+ if (platformSessions.length === 0) {
20420
+ return [
20421
+ "---",
20422
+ "**Active Claude Threads**",
20423
+ "",
20424
+ "_No active sessions_",
20425
+ "",
20426
+ "_Mention me to start a session_ \xB7 `npm i -g claude-threads`"
20427
+ ].join(`
20428
+ `);
20429
+ }
20430
+ platformSessions.sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime());
20431
+ const count = platformSessions.length;
20432
+ const lines = [
20433
+ "---",
20434
+ `**Active Claude Threads** (${count})`,
20435
+ ""
20436
+ ];
20437
+ for (const session of platformSessions) {
20438
+ const topic = getSessionTopic(session);
20439
+ const threadLink = `[${topic}](/_redirect/pl/${session.threadId})`;
20440
+ const displayName = session.startedByDisplayName || session.startedBy;
20441
+ const time = formatRelativeTime(session.startedAt);
20442
+ const taskProgress = getTaskProgress(session);
20443
+ const progressStr = taskProgress ? ` \xB7 ${taskProgress}` : "";
20444
+ lines.push(`\u25B8 ${threadLink} \xB7 **${displayName}**${progressStr} \xB7 ${time}`);
20445
+ if (session.sessionDescription) {
20446
+ lines.push(` _${session.sessionDescription}_`);
20447
+ }
20448
+ }
20449
+ lines.push("");
20450
+ lines.push("_Mention me to start a session_ \xB7 `npm i -g claude-threads`");
20451
+ return lines.join(`
20452
+ `);
20453
+ }
20454
+ var DEBUG = process.env.DEBUG === "1";
20455
+ async function updateStickyMessage(platform, sessions) {
20456
+ const platformId = platform.platformId;
20457
+ const pendingUpdate = updateLocks.get(platformId);
20458
+ if (pendingUpdate) {
20459
+ await pendingUpdate;
20460
+ }
20461
+ let releaseLock;
20462
+ const lock = new Promise((resolve6) => {
20463
+ releaseLock = resolve6;
20464
+ });
20465
+ updateLocks.set(platformId, lock);
20466
+ try {
20467
+ await updateStickyMessageImpl(platform, sessions);
20468
+ } finally {
20469
+ releaseLock();
20470
+ updateLocks.delete(platformId);
20471
+ }
20472
+ }
20473
+ async function updateStickyMessageImpl(platform, sessions) {
20474
+ if (DEBUG) {
20475
+ const platformSessions = [...sessions.values()].filter((s) => s.platformId === platform.platformId);
20476
+ console.log(`[sticky] updateStickyMessage called for ${platform.platformId}, ${platformSessions.length} sessions`);
20477
+ for (const s of platformSessions) {
20478
+ console.log(`[sticky] - ${s.sessionId}: title="${s.sessionTitle}" firstPrompt="${s.firstPrompt?.substring(0, 30)}..."`);
20479
+ }
20480
+ }
20481
+ const content = buildStickyMessage(sessions, platform.platformId);
20482
+ const existingPostId = stickyPostIds.get(platform.platformId);
20483
+ const shouldBump = needsBump.get(platform.platformId) ?? false;
20484
+ if (DEBUG) {
20485
+ console.log(`[sticky] existingPostId: ${existingPostId || "(none)"}, needsBump: ${shouldBump}`);
20486
+ console.log(`[sticky] content preview: ${content.substring(0, 100).replace(/\n/g, "\\n")}...`);
20487
+ }
20488
+ try {
20489
+ if (existingPostId && !shouldBump) {
20490
+ if (DEBUG)
20491
+ console.log(`[sticky] Updating existing post in place...`);
20492
+ try {
20493
+ await platform.updatePost(existingPostId, content);
20494
+ try {
20495
+ await platform.pinPost(existingPostId);
20496
+ if (DEBUG)
20497
+ console.log(`[sticky] Re-pinned post`);
20498
+ } catch (pinErr) {
20499
+ if (DEBUG)
20500
+ console.log(`[sticky] Re-pin failed (might already be pinned):`, pinErr);
20501
+ }
20502
+ if (DEBUG)
20503
+ console.log(`[sticky] Updated successfully`);
20504
+ return;
20505
+ } catch (err) {
20506
+ if (DEBUG)
20507
+ console.log(`[sticky] Update failed, will create new:`, err);
20508
+ }
20509
+ }
20510
+ needsBump.set(platform.platformId, false);
20511
+ if (existingPostId) {
20512
+ if (DEBUG)
20513
+ console.log(`[sticky] Unpinning and deleting existing post ${existingPostId.substring(0, 8)}...`);
20514
+ try {
20515
+ await platform.unpinPost(existingPostId);
20516
+ if (DEBUG)
20517
+ console.log(`[sticky] Unpinned successfully`);
20518
+ } catch (err) {
20519
+ if (DEBUG)
20520
+ console.log(`[sticky] Unpin failed (probably already unpinned):`, err);
20521
+ }
20522
+ try {
20523
+ await platform.deletePost(existingPostId);
20524
+ if (DEBUG)
20525
+ console.log(`[sticky] Deleted successfully`);
20526
+ } catch (err) {
20527
+ if (DEBUG)
20528
+ console.log(`[sticky] Delete failed (probably already deleted):`, err);
20529
+ }
20530
+ stickyPostIds.delete(platform.platformId);
20531
+ }
20532
+ if (DEBUG)
20533
+ console.log(`[sticky] Creating new post...`);
20534
+ const post = await platform.createPost(content);
20535
+ stickyPostIds.set(platform.platformId, post.id);
20536
+ try {
20537
+ await platform.pinPost(post.id);
20538
+ if (DEBUG)
20539
+ console.log(`[sticky] Pinned post successfully`);
20540
+ } catch (err) {
20541
+ if (DEBUG)
20542
+ console.log(`[sticky] Failed to pin post:`, err);
20543
+ }
20544
+ if (sessionStore) {
20545
+ sessionStore.saveStickyPostId(platform.platformId, post.id);
20546
+ }
20547
+ console.log(` \uD83D\uDCCC Created sticky message for ${platform.platformId}: ${post.id.substring(0, 8)}...`);
20548
+ } catch (err) {
20549
+ console.error(` \u26A0\uFE0F Failed to update sticky message for ${platform.platformId}:`, err);
20550
+ }
20551
+ }
20552
+ async function updateAllStickyMessages(platforms, sessions) {
20553
+ const updates = [...platforms.values()].map((platform) => updateStickyMessage(platform, sessions));
20554
+ await Promise.all(updates);
20555
+ }
20556
+ function markNeedsBump(platformId) {
20557
+ needsBump.set(platformId, true);
20558
+ }
20559
+ async function cleanupOldStickyMessages(platform, botUserId) {
20560
+ const currentStickyId = stickyPostIds.get(platform.platformId);
20561
+ try {
20562
+ const pinnedPostIds = await platform.getPinnedPosts();
20563
+ if (DEBUG)
20564
+ console.log(`[sticky] Found ${pinnedPostIds.length} pinned posts, current sticky: ${currentStickyId?.substring(0, 8) || "(none)"}`);
20565
+ for (const postId of pinnedPostIds) {
20566
+ if (postId === currentStickyId)
20567
+ continue;
20568
+ try {
20569
+ const post = await platform.getPost(postId);
20570
+ if (!post)
20571
+ continue;
20572
+ if (post.userId === botUserId) {
20573
+ if (DEBUG)
20574
+ console.log(`[sticky] Cleaning up old sticky: ${postId.substring(0, 8)}...`);
20575
+ try {
20576
+ await platform.unpinPost(postId);
20577
+ await platform.deletePost(postId);
20578
+ console.log(` \uD83E\uDDF9 Cleaned up old sticky message: ${postId.substring(0, 8)}...`);
20579
+ } catch (err) {
20580
+ if (DEBUG)
20581
+ console.log(`[sticky] Failed to cleanup ${postId}:`, err);
20582
+ }
20583
+ }
20584
+ } catch (err) {
20585
+ if (DEBUG)
20586
+ console.log(`[sticky] Could not check post ${postId}:`, err);
20587
+ }
20588
+ }
20589
+ } catch (err) {
20590
+ console.error(` \u26A0\uFE0F Failed to cleanup old sticky messages:`, err);
20591
+ }
20592
+ }
20593
+
20253
20594
  // src/session/types.ts
20254
20595
  var MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "5", 10);
20255
20596
  var SESSION_TIMEOUT_MS = parseInt(process.env.SESSION_TIMEOUT_MS || "1800000", 10);
@@ -20275,6 +20616,9 @@ class SessionManager {
20275
20616
  this.worktreeMode = worktreeMode;
20276
20617
  this.cleanupTimer = setInterval(() => {
20277
20618
  cleanupIdleSessions(SESSION_TIMEOUT_MS, SESSION_WARNING_MS, this.getLifecycleContext()).catch((err) => console.error(" [cleanup] Error during idle session cleanup:", err));
20619
+ if (this.sessions.size > 0) {
20620
+ this.updateStickyMessage().catch((err) => console.error(" [sticky] Error during periodic refresh:", err));
20621
+ }
20278
20622
  }, 60000);
20279
20623
  }
20280
20624
  addPlatform(platformId, client) {
@@ -20285,6 +20629,10 @@ class SessionManager {
20285
20629
  this.handleReaction(platformId, reaction.postId, reaction.emojiName, user.username);
20286
20630
  }
20287
20631
  });
20632
+ client.on("channel_post", () => {
20633
+ markNeedsBump(platformId);
20634
+ this.updateStickyMessage();
20635
+ });
20288
20636
  console.log(` \uD83D\uDCE1 Platform "${platformId}" registered`);
20289
20637
  }
20290
20638
  removePlatform(platformId) {
@@ -20317,7 +20665,8 @@ class SessionManager {
20317
20665
  postWorktreePrompt: (s, r) => this.postWorktreePrompt(s, r),
20318
20666
  buildMessageContent: (t, p, f) => this.buildMessageContent(t, p, f),
20319
20667
  offerContextPrompt: (s, q, e) => this.offerContextPrompt(s, q, e),
20320
- bumpTasksToBottom: (s) => this.bumpTasksToBottom(s)
20668
+ bumpTasksToBottom: (s) => this.bumpTasksToBottom(s),
20669
+ updateStickyMessage: () => this.updateStickyMessage()
20321
20670
  };
20322
20671
  }
20323
20672
  getEventContext() {
@@ -20328,7 +20677,9 @@ class SessionManager {
20328
20677
  startTyping: (s) => this.startTyping(s),
20329
20678
  stopTyping: (s) => this.stopTyping(s),
20330
20679
  appendContent: (s, t) => this.appendContent(s, t),
20331
- bumpTasksToBottom: (s) => this.bumpTasksToBottom(s)
20680
+ bumpTasksToBottom: (s) => this.bumpTasksToBottom(s),
20681
+ updateStickyMessage: () => this.updateStickyMessage(),
20682
+ persistSession: (s) => this.persistSession(s)
20332
20683
  };
20333
20684
  }
20334
20685
  getReactionContext() {
@@ -20584,6 +20935,7 @@ class SessionManager {
20584
20935
  threadId: session.threadId,
20585
20936
  claudeSessionId: session.claudeSessionId,
20586
20937
  startedBy: session.startedBy,
20938
+ startedByDisplayName: session.startedByDisplayName,
20587
20939
  startedAt: session.startedAt.toISOString(),
20588
20940
  lastActivityAt: session.lastActivityAt.toISOString(),
20589
20941
  sessionNumber: session.sessionNumber,
@@ -20603,7 +20955,9 @@ class SessionManager {
20603
20955
  firstPrompt: session.firstPrompt,
20604
20956
  pendingContextPrompt: persistedContextPrompt,
20605
20957
  needsContextPromptOnNextMessage: session.needsContextPromptOnNextMessage,
20606
- timeoutPostId: session.timeoutPostId
20958
+ timeoutPostId: session.timeoutPostId,
20959
+ sessionTitle: session.sessionTitle,
20960
+ sessionDescription: session.sessionDescription
20607
20961
  };
20608
20962
  this.sessionStore.save(session.sessionId, state);
20609
20963
  }
@@ -20613,21 +20967,35 @@ class SessionManager {
20613
20967
  async updateSessionHeader(session) {
20614
20968
  await updateSessionHeader(session, this.getCommandContext());
20615
20969
  }
20970
+ async updateStickyMessage() {
20971
+ await updateAllStickyMessages(this.platforms, this.sessions);
20972
+ }
20616
20973
  async initialize() {
20974
+ initialize(this.sessionStore);
20975
+ for (const platform of this.platforms.values()) {
20976
+ try {
20977
+ const botUser = await platform.getBotUser();
20978
+ await cleanupOldStickyMessages(platform, botUser.id);
20979
+ } catch (err) {
20980
+ console.error(` \u26A0\uFE0F Failed to cleanup old sticky messages for ${platform.platformId}:`, err);
20981
+ }
20982
+ }
20617
20983
  const staleIds = this.sessionStore.cleanStale(SESSION_TIMEOUT_MS * 2);
20618
20984
  if (staleIds.length > 0) {
20619
20985
  console.log(` \uD83E\uDDF9 Cleaned ${staleIds.length} stale session(s) from persistence`);
20620
20986
  }
20621
20987
  const persisted = this.sessionStore.load();
20622
- if (persisted.size === 0)
20623
- return;
20624
- console.log(` \uD83D\uDD04 Found ${persisted.size} persisted session(s), attempting resume...`);
20625
- for (const state of persisted.values()) {
20626
- await resumeSession(state, this.getLifecycleContext());
20988
+ console.log(` [persist] Loaded ${persisted.size} session(s)`);
20989
+ if (persisted.size > 0) {
20990
+ console.log(` \uD83D\uDD04 Attempting to resume ${persisted.size} persisted session(s)...`);
20991
+ for (const state of persisted.values()) {
20992
+ await resumeSession(state, this.getLifecycleContext());
20993
+ }
20627
20994
  }
20995
+ await this.updateStickyMessage();
20628
20996
  }
20629
- async startSession(options, username, replyToPostId, platformId = "default") {
20630
- await startSession(options, username, replyToPostId, platformId, this.getLifecycleContext());
20997
+ async startSession(options, username, replyToPostId, platformId = "default", displayName) {
20998
+ await startSession(options, username, displayName, replyToPostId, platformId, this.getLifecycleContext());
20631
20999
  }
20632
21000
  findSessionByThreadId(threadId) {
20633
21001
  for (const session of this.sessions.values()) {
@@ -20670,11 +21038,11 @@ class SessionManager {
20670
21038
  getPersistedSession(threadId) {
20671
21039
  return this.findPersistedByThreadId(threadId);
20672
21040
  }
20673
- killSession(threadId, unpersist = true) {
21041
+ async killSession(threadId, unpersist = true) {
20674
21042
  const session = this.findSessionByThreadId(threadId);
20675
21043
  if (!session)
20676
21044
  return;
20677
- killSession(session, unpersist, this.getLifecycleContext());
21045
+ await killSession(session, unpersist, this.getLifecycleContext());
20678
21046
  }
20679
21047
  killAllSessions() {
20680
21048
  killAllSessions(this.getLifecycleContext());
@@ -20809,8 +21177,8 @@ class SessionManager {
20809
21177
  }
20810
21178
  return session.sessionAllowedUsers.has(username) || session.platform.isUserAllowed(username);
20811
21179
  }
20812
- async startSessionWithWorktree(options, branch, username, replyToPostId, platformId = "default") {
20813
- await this.startSession(options, username, replyToPostId, platformId);
21180
+ async startSessionWithWorktree(options, branch, username, replyToPostId, platformId = "default", displayName) {
21181
+ await this.startSession(options, username, replyToPostId, platformId, displayName);
20814
21182
  const threadId = replyToPostId || "";
20815
21183
  const session = this.sessions.get(this.getSessionId(platformId, threadId));
20816
21184
  if (session) {
@@ -21105,10 +21473,10 @@ Release notes not available. See [GitHub releases](https://github.com/anneschuth
21105
21473
  if (branchMatch) {
21106
21474
  const branch = branchMatch[1];
21107
21475
  const cleanedPrompt = prompt.replace(/(?:on branch|!worktree)\s+\S+/i, "").trim();
21108
- await session.startSessionWithWorktree({ prompt: cleanedPrompt || prompt, files }, branch, username, threadRoot);
21476
+ await session.startSessionWithWorktree({ prompt: cleanedPrompt || prompt, files }, branch, username, threadRoot, platformConfig.id, user?.displayName);
21109
21477
  return;
21110
21478
  }
21111
- await session.startSession({ prompt, files }, username, threadRoot);
21479
+ await session.startSession({ prompt, files }, username, threadRoot, platformConfig.id, user?.displayName);
21112
21480
  } catch (err) {
21113
21481
  console.error(" \u274C Error handling message:", err);
21114
21482
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "0.19.2",
3
+ "version": "0.20.0",
4
4
  "description": "Share Claude Code sessions live in a Mattermost channel with interactive features",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",