claude-threads 0.41.0 → 0.42.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 CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.42.0] - 2026-01-07
11
+
12
+ ### Fixed
13
+ - **Slack message visibility for long sessions** - Add platform-specific message size limits (Slack: 12K, Mattermost: 16K) with error recovery when `updatePost` fails - automatically creates new message instead of silently losing content
14
+ - **ExitPlanMode approval on Slack** - Fix emoji reaction handling by normalizing `thumbsup` → `+1` across platforms
15
+
16
+ ### Added
17
+ - **`!approve` / `!yes` commands** - Text-based alternative to 👍 reaction for plan approval
18
+ - **Plan mode status in session header** - Shows 📋 Plan pending or 🔨 Implementing status
19
+
20
+ ### Changed
21
+ - **User follow-up message handling** - Reset `currentPostId` on user follow-up messages so Claude's responses start in fresh messages with proper code block closure
22
+
10
23
  ## [0.41.0] - 2026-01-07
11
24
 
12
25
  ### Changed
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![npm version](https://img.shields.io/npm/v/claude-threads.svg)](https://www.npmjs.com/package/claude-threads)
10
10
  [![npm downloads](https://img.shields.io/npm/dm/claude-threads.svg)](https://www.npmjs.com/package/claude-threads)
11
11
  [![CI](https://github.com/anneschuth/claude-threads/actions/workflows/ci.yml/badge.svg)](https://github.com/anneschuth/claude-threads/actions/workflows/ci.yml)
12
- [![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/anneschuth/9ba67f04c8c04ae40e11b7f226d40fcb/raw/coverage-badge.json)](https://github.com/anneschuth/claude-threads/actions/workflows/ci.yml)
12
+ [![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/anneschuth/4951f9235658e276208942986092e5ab/raw/coverage-badge.json)](https://github.com/anneschuth/claude-threads/actions/workflows/ci.yml)
13
13
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
14
14
  [![Bun](https://img.shields.io/badge/Bun-%3E%3D1.2.21-black.svg)](https://bun.sh/)
15
15
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.7-blue.svg)](https://www.typescriptlang.org/)
package/dist/index.js CHANGED
@@ -42768,6 +42768,9 @@ class MattermostClient extends EventEmitter {
42768
42768
  const response = await this.api("GET", `/channels/${this.channelId}/pinned`);
42769
42769
  return response.order || [];
42770
42770
  }
42771
+ getMessageLimits() {
42772
+ return { maxLength: 16000, hardThreshold: 14000 };
42773
+ }
42771
42774
  async getThreadHistory(threadId, options) {
42772
42775
  try {
42773
42776
  const response = await this.api("GET", `/posts/${threadId}/thread`);
@@ -43065,6 +43068,30 @@ function getPlatformIcon(platformType) {
43065
43068
  return "\uD83D\uDCAC";
43066
43069
  }
43067
43070
  }
43071
+ function normalizeEmojiName(emojiName) {
43072
+ const name = emojiName.replace(/^:|:$/g, "");
43073
+ const aliases = {
43074
+ thumbsup: "+1",
43075
+ thumbs_up: "+1",
43076
+ thumbsdown: "-1",
43077
+ thumbs_down: "-1",
43078
+ heavy_check_mark: "white_check_mark",
43079
+ x: "x",
43080
+ cross_mark: "x",
43081
+ heavy_multiplication_x: "x",
43082
+ pause_button: "pause",
43083
+ double_vertical_bar: "pause",
43084
+ play_button: "arrow_forward",
43085
+ stop_button: "stop",
43086
+ octagonal_sign: "stop",
43087
+ "1": "one",
43088
+ "2": "two",
43089
+ "3": "three",
43090
+ "4": "four",
43091
+ "5": "five"
43092
+ };
43093
+ return aliases[name.toLowerCase()] ?? name;
43094
+ }
43068
43095
  function convertMarkdownToSlack(content) {
43069
43096
  const codeBlocks = [];
43070
43097
  const CODE_BLOCK_PLACEHOLDER = "\x00CODE_BLOCK_";
@@ -43809,6 +43836,9 @@ class SlackClient extends EventEmitter2 {
43809
43836
  const response = await this.api("GET", `pins.list?channel=${this.channelId}`);
43810
43837
  return (response.items || []).filter((item) => !!item.message).map((item) => item.message.ts);
43811
43838
  }
43839
+ getMessageLimits() {
43840
+ return { maxLength: 12000, hardThreshold: 1e4 };
43841
+ }
43812
43842
  async getThreadHistory(threadId, options) {
43813
43843
  try {
43814
43844
  const limit = options?.limit || 100;
@@ -44538,8 +44568,8 @@ var APPROVAL_EMOJIS = ["+1", "thumbsup"];
44538
44568
  var DENIAL_EMOJIS = ["-1", "thumbsdown"];
44539
44569
  var ALLOW_ALL_EMOJIS = ["white_check_mark", "heavy_check_mark"];
44540
44570
  var NUMBER_EMOJIS = ["one", "two", "three", "four"];
44541
- var CANCEL_EMOJIS = ["x", "octagonal_sign", "stop_sign"];
44542
- var ESCAPE_EMOJIS = ["double_vertical_bar", "pause_button"];
44571
+ var CANCEL_EMOJIS = ["x", "octagonal_sign", "stop_sign", "stop"];
44572
+ var ESCAPE_EMOJIS = ["double_vertical_bar", "pause_button", "pause"];
44543
44573
  var RESUME_EMOJIS = ["arrows_counterclockwise", "arrow_forward", "repeat"];
44544
44574
  var TASK_TOGGLE_EMOJIS = ["arrow_down_small", "small_red_triangle_down"];
44545
44575
  function isApprovalEmoji(emoji) {
@@ -45193,8 +45223,7 @@ async function flush(session, registerPost) {
45193
45223
  }
45194
45224
  const formatter = session.platform.getFormatter();
45195
45225
  let content = formatter.formatMarkdown(session.pendingContent).trim();
45196
- const MAX_POST_LENGTH = 16000;
45197
- const HARD_CONTINUATION_THRESHOLD = 14000;
45226
+ const { maxLength: MAX_POST_LENGTH, hardThreshold: HARD_CONTINUATION_THRESHOLD } = session.platform.getMessageLimits();
45198
45227
  const shouldBreakEarly = session.currentPostId && content.length > MIN_BREAK_THRESHOLD && shouldFlushEarly(content);
45199
45228
  if (session.currentPostId && (content.length > HARD_CONTINUATION_THRESHOLD || shouldBreakEarly)) {
45200
45229
  let breakPoint;
@@ -45228,7 +45257,13 @@ async function flush(session, registerPost) {
45228
45257
  if (breakInfo && breakInfo.position < content.length) {
45229
45258
  breakPoint = breakInfo.position;
45230
45259
  } else {
45231
- await withErrorHandling(() => session.platform.updatePost(session.currentPostId, content), { action: "Update post (no breakpoint)", session });
45260
+ const result = await withErrorHandling(() => session.platform.updatePost(session.currentPostId, content), { action: "Update post (no breakpoint)", session });
45261
+ if (result === undefined) {
45262
+ sessionLog(session).warn("Update failed (no breakpoint), starting new message");
45263
+ session.currentPostId = null;
45264
+ session.currentPostContent = "";
45265
+ return flush(session, registerPost);
45266
+ }
45232
45267
  return;
45233
45268
  }
45234
45269
  }
@@ -45243,14 +45278,20 @@ async function flush(session, registerPost) {
45243
45278
  const firstPartWithMarker = remainder ? firstPart + `
45244
45279
 
45245
45280
  ` + formatter2.formatItalic("... (continued below)") : firstPart;
45246
- await withErrorHandling(() => session.platform.updatePost(session.currentPostId, firstPartWithMarker), { action: "Update post with first part", session });
45281
+ const updateResult = await withErrorHandling(() => session.platform.updatePost(session.currentPostId, firstPartWithMarker), { action: "Update post with first part", session });
45282
+ if (updateResult === undefined) {
45283
+ sessionLog(session).warn("Update failed during split, including content in new message");
45284
+ session.pendingContent = content;
45285
+ } else {
45286
+ session.pendingContent = remainder;
45287
+ }
45247
45288
  session.currentPostId = null;
45248
- session.pendingContent = remainder;
45249
- if (remainder) {
45250
- const continuationMarker = formatter2.formatItalic("(continued)");
45251
- const continuationContent = continuationMarker + `
45289
+ const contentToPost = session.pendingContent;
45290
+ if (contentToPost) {
45291
+ const continuationMarker = updateResult !== undefined ? formatter2.formatItalic("(continued)") + `
45252
45292
 
45253
- ` + remainder;
45293
+ ` : "";
45294
+ const continuationContent = continuationMarker + contentToPost;
45254
45295
  const hasActiveTasks = session.tasksPostId && session.lastTasksContent && !session.tasksCompleted;
45255
45296
  if (hasActiveTasks) {
45256
45297
  const postId = await bumpTasksToBottomWithContent(session, continuationContent, registerPost);
@@ -45274,7 +45315,13 @@ async function flush(session, registerPost) {
45274
45315
  ` + formatter2.formatItalic("... (truncated)");
45275
45316
  }
45276
45317
  if (session.currentPostId) {
45277
- await withErrorHandling(() => session.platform.updatePost(session.currentPostId, content), { action: "Update current post", session });
45318
+ const result = await withErrorHandling(() => session.platform.updatePost(session.currentPostId, content), { action: "Update current post", session });
45319
+ if (result === undefined) {
45320
+ sessionLog(session).warn("Update failed, starting new message");
45321
+ session.currentPostId = null;
45322
+ session.currentPostContent = "";
45323
+ return flush(session, registerPost);
45324
+ }
45278
45325
  } else {
45279
45326
  const hasActiveTasks = session.tasksPostId && session.lastTasksContent && !session.tasksCompleted;
45280
45327
  if (hasActiveTasks) {
@@ -51153,6 +51200,24 @@ async function interruptSession(session, username) {
51153
51200
  await postInterrupt(session, `${formatter.formatBold("Interrupted")} by ${formatter.formatUserMention(username)}`);
51154
51201
  }
51155
51202
  }
51203
+ async function approvePendingPlan(session, username, ctx) {
51204
+ if (!session.pendingApproval || session.pendingApproval.type !== "plan") {
51205
+ await postInfo(session, `No pending plan to approve`);
51206
+ sessionLog4(session).debug(`Approve requested but no pending plan`);
51207
+ return;
51208
+ }
51209
+ const { postId } = session.pendingApproval;
51210
+ sessionLog4(session).info(`\u2705 Plan approved by @${username} via command`);
51211
+ const formatter = session.platform.getFormatter();
51212
+ const statusMessage = `\u2705 ${formatter.formatBold("Plan approved")} by ${formatter.formatUserMention(username)} - starting implementation...`;
51213
+ await withErrorHandling(() => session.platform.updatePost(postId, statusMessage), { action: "Update approval post", session });
51214
+ session.pendingApproval = null;
51215
+ session.planApproved = true;
51216
+ if (session.claude.isRunning()) {
51217
+ session.claude.sendMessage("Plan approved! Please proceed with the implementation.");
51218
+ ctx.ops.startTyping(session);
51219
+ }
51220
+ }
51156
51221
  async function changeDirectory(session, newDir, username, ctx) {
51157
51222
  if (!await requireSessionOwner(session, username, "change the working directory")) {
51158
51223
  return;
@@ -51318,6 +51383,11 @@ async function updateSessionHeader(session, ctx) {
51318
51383
  statusItems.push(formatter.formatCode(`\uD83D\uDCB0 $${stats.totalCostUSD.toFixed(2)}`));
51319
51384
  }
51320
51385
  statusItems.push(formatter.formatCode(permMode));
51386
+ if (session.pendingApproval?.type === "plan") {
51387
+ statusItems.push(formatter.formatCode("\uD83D\uDCCB Plan pending"));
51388
+ } else if (session.planApproved) {
51389
+ statusItems.push(formatter.formatCode("\uD83D\uDD28 Implementing"));
51390
+ }
51321
51391
  if (ctx.config.chromeEnabled) {
51322
51392
  statusItems.push(formatter.formatCode("\uD83C\uDF10 Chrome"));
51323
51393
  }
@@ -51448,11 +51518,13 @@ You are running inside a chat platform (like Mattermost or Slack). Users interac
51448
51518
  - Permission requests (file writes, commands, etc.) appear as messages with emoji options
51449
51519
  - Users approve with \uD83D\uDC4D or deny with \uD83D\uDC4E by reacting to the message
51450
51520
  - Plan approvals and questions also use emoji reactions (\uD83D\uDC4D/\uD83D\uDC4E for plans, number emoji for choices)
51521
+ - Users can also type \`!approve\` or \`!yes\` to approve pending plans
51451
51522
 
51452
51523
  ## User Commands
51453
51524
  Users can control sessions with these commands:
51454
51525
  - \`!stop\` or \u274C reaction: End the current operation
51455
51526
  - \`!escape\` or \u23F8\uFE0F reaction: Interrupt without ending the session
51527
+ - \`!approve\` or \uD83D\uDC4D reaction: Approve pending plan
51456
51528
  - \`!invite @user\`: Allow another user to send messages in this session
51457
51529
  - \`!kick @user\`: Remove a user from the session
51458
51530
  - \`!cd /path\`: Change working directory (restarts the session)
@@ -51552,6 +51624,7 @@ ${startFormatter.formatItalic("Starting session...")}`, replyToPostId), { action
51552
51624
  workingDir: ctx.config.workingDir,
51553
51625
  claude,
51554
51626
  currentPostId: null,
51627
+ currentPostContent: "",
51555
51628
  pendingContent: "",
51556
51629
  pendingApproval: null,
51557
51630
  pendingQuestionSet: null,
@@ -51690,6 +51763,7 @@ Please start a new session.`, state.threadId), { action: "Post resume failure no
51690
51763
  workingDir: state.workingDir,
51691
51764
  claude,
51692
51765
  currentPostId: null,
51766
+ currentPostContent: "",
51693
51767
  pendingContent: "",
51694
51768
  pendingApproval: null,
51695
51769
  pendingQuestionSet: null,
@@ -51774,6 +51848,9 @@ ${failFormatter.formatItalic("Your previous conversation context is preserved, b
51774
51848
  async function sendFollowUp(session, message, files, ctx) {
51775
51849
  if (!session.claude.isRunning())
51776
51850
  return;
51851
+ await ctx.ops.flush(session);
51852
+ session.currentPostId = null;
51853
+ session.currentPostContent = "";
51777
51854
  await ctx.ops.bumpTasksToBottom(session);
51778
51855
  const content = await ctx.ops.buildMessageContent(message, session.platform, files);
51779
51856
  const messageText = typeof content === "string" ? content : message;
@@ -53209,7 +53286,8 @@ class SessionManager extends EventEmitter4 {
53209
53286
  }
53210
53287
  async handleMessage(_platformId, _post, _user) {}
53211
53288
  async handleReaction(platformId, postId, emojiName, username, action) {
53212
- if (action === "added" && isResumeEmoji(emojiName)) {
53289
+ const normalizedEmoji = normalizeEmojiName(emojiName);
53290
+ if (action === "added" && isResumeEmoji(normalizedEmoji)) {
53213
53291
  const resumed = await this.tryResumeFromReaction(platformId, postId, username);
53214
53292
  if (resumed)
53215
53293
  return;
@@ -53222,7 +53300,7 @@ class SessionManager extends EventEmitter4 {
53222
53300
  if (!session.sessionAllowedUsers.has(username) && !session.platform.isUserAllowed(username)) {
53223
53301
  return;
53224
53302
  }
53225
- await this.handleSessionReaction(session, postId, emojiName, username, action);
53303
+ await this.handleSessionReaction(session, postId, normalizedEmoji, username, action);
53226
53304
  }
53227
53305
  async tryResumeFromReaction(platformId, postId, username) {
53228
53306
  const persistedSession = this.sessionStore.findByPostId(platformId, postId);
@@ -53681,6 +53759,12 @@ class SessionManager extends EventEmitter4 {
53681
53759
  return;
53682
53760
  await interruptSession(session, username);
53683
53761
  }
53762
+ async approvePendingPlan(threadId, username) {
53763
+ const session = this.findSessionByThreadId(threadId);
53764
+ if (!session)
53765
+ return;
53766
+ await approvePendingPlan(session, username, this.getContext());
53767
+ }
53684
53768
  async changeDirectory(threadId, newDir, username) {
53685
53769
  const session = this.findSessionByThreadId(threadId);
53686
53770
  if (!session)
@@ -63276,6 +63360,12 @@ async function handleMessage(client, session, post, user, options) {
63276
63360
  }
63277
63361
  return;
63278
63362
  }
63363
+ if (lowerContent === "!approve" || lowerContent === "!yes") {
63364
+ if (session.isUserAllowedInSession(threadRoot, username)) {
63365
+ await session.approvePendingPlan(threadRoot, username);
63366
+ }
63367
+ return;
63368
+ }
63279
63369
  if (lowerContent === "!help") {
63280
63370
  const code = formatter.formatCode.bind(formatter);
63281
63371
  const commandTable = formatter.formatTable(["Command", "Description"], [
@@ -63289,6 +63379,7 @@ async function handleMessage(client, session, post, user, options) {
63289
63379
  [code("!invite @user"), "Invite a user to this session"],
63290
63380
  [code("!kick @user"), "Remove an invited user"],
63291
63381
  [code("!permissions interactive"), "Enable interactive permissions"],
63382
+ [code("!approve"), "Approve pending plan (alternative to \uD83D\uDC4D reaction)"],
63292
63383
  [code("!escape"), "Interrupt current task (session stays active)"],
63293
63384
  [code("!stop"), "Stop this session"],
63294
63385
  [code("!kill"), "Emergency shutdown (kills ALL sessions, exits bot)"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "0.41.0",
3
+ "version": "0.42.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",