claude-threads 0.51.0 → 0.52.1

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.52.1] - 2026-01-09
11
+
12
+ ### Fixed
13
+ - **Bug report image upload error** - Fixed `downloadFile` method losing `this` context when passed as callback, causing "undefined is not an object" error (#163)
14
+
15
+ ## [0.52.0] - 2026-01-09
16
+
17
+ ### Fixed
18
+ - **Image attachments in bug reports** - Fixed image attachments not appearing in bug reports by uploading to Catbox before generating the report (#158)
19
+ - **Sticky message install command** - Fixed npm/bun string issue in sticky message and added website link (#159)
20
+ - **Paused sessions auto-resuming** - Fixed paused sessions incorrectly auto-resuming on bot restart by persisting paused state (#160)
21
+ - **403 permission errors** - Fixed 403 errors when unpinning/updating stale task posts by handling channel post deletion gracefully (#161)
22
+
10
23
  ## [0.51.0] - 2026-01-09
11
24
 
12
25
  ### Added
package/dist/index.js CHANGED
@@ -42852,9 +42852,42 @@ class MattermostClient extends EventEmitter {
42852
42852
  extension: mattermostFile.extension
42853
42853
  };
42854
42854
  }
42855
+ async processAndEmitPost(post) {
42856
+ const hasFileIds = post.file_ids && post.file_ids.length > 0;
42857
+ const hasFileMetadata = post.metadata?.files && post.metadata.files.length > 0;
42858
+ if (hasFileIds && !hasFileMetadata) {
42859
+ log.debug(`Post ${post.id.substring(0, 8)} has ${post.file_ids.length} file(s), fetching metadata`);
42860
+ try {
42861
+ const files = [];
42862
+ for (const fileId of post.file_ids) {
42863
+ try {
42864
+ const file = await this.api("GET", `/files/${fileId}/info`);
42865
+ files.push(file);
42866
+ } catch (err) {
42867
+ log.warn(`Failed to fetch file info for ${fileId}: ${err}`);
42868
+ }
42869
+ }
42870
+ if (files.length > 0) {
42871
+ post.metadata = {
42872
+ ...post.metadata,
42873
+ files
42874
+ };
42875
+ log.debug(`Enriched post ${post.id.substring(0, 8)} with ${files.length} file(s)`);
42876
+ }
42877
+ } catch (err) {
42878
+ log.warn(`Failed to fetch file metadata for post ${post.id.substring(0, 8)}: ${err}`);
42879
+ }
42880
+ }
42881
+ const user = await this.getUser(post.user_id);
42882
+ const normalizedPost = this.normalizePlatformPost(post);
42883
+ this.emit("message", normalizedPost, user);
42884
+ if (!post.root_id) {
42885
+ this.emit("channel_post", normalizedPost, user);
42886
+ }
42887
+ }
42855
42888
  MAX_RETRIES = 3;
42856
42889
  RETRY_DELAY_MS = 500;
42857
- async api(method, path, body, retryCount = 0) {
42890
+ async api(method, path, body, retryCount = 0, options) {
42858
42891
  const url = `${this.url}/api/v4${path}`;
42859
42892
  log.debug(`API ${method} ${path}`);
42860
42893
  const response = await fetch(url, {
@@ -42871,9 +42904,14 @@ class MattermostClient extends EventEmitter {
42871
42904
  const delay = this.RETRY_DELAY_MS * Math.pow(2, retryCount);
42872
42905
  log.warn(`API ${method} ${path} failed with 500, retrying in ${delay}ms (attempt ${retryCount + 1}/${this.MAX_RETRIES})`);
42873
42906
  await new Promise((resolve2) => setTimeout(resolve2, delay));
42874
- return this.api(method, path, body, retryCount + 1);
42907
+ return this.api(method, path, body, retryCount + 1, options);
42908
+ }
42909
+ const isSilent = options?.silent?.includes(response.status);
42910
+ if (isSilent) {
42911
+ log.debug(`API ${method} ${path} failed: ${response.status} (expected)`);
42912
+ } else {
42913
+ log.warn(`API ${method} ${path} failed: ${response.status} ${text.substring(0, 100)}`);
42875
42914
  }
42876
- log.warn(`API ${method} ${path} failed: ${response.status} ${text.substring(0, 100)}`);
42877
42915
  throw new Error(`Mattermost API error ${response.status}: ${text}`);
42878
42916
  }
42879
42917
  log.debug(`API ${method} ${path} \u2192 ${response.status}`);
@@ -42992,7 +43030,14 @@ class MattermostClient extends EventEmitter {
42992
43030
  }
42993
43031
  async unpinPost(postId) {
42994
43032
  log.debug(`Unpinning post ${postId.substring(0, 8)}`);
42995
- await this.api("POST", `/posts/${postId}/unpin`);
43033
+ try {
43034
+ await this.api("POST", `/posts/${postId}/unpin`, undefined, 0, { silent: [403, 404] });
43035
+ } catch (err) {
43036
+ if (err instanceof Error && (err.message.includes("403") || err.message.includes("404"))) {
43037
+ return;
43038
+ }
43039
+ throw err;
43040
+ }
42996
43041
  }
42997
43042
  async getPinnedPosts() {
42998
43043
  const response = await this.api("GET", `/channels/${this.channelId}/pinned`);
@@ -43120,13 +43165,7 @@ class MattermostClient extends EventEmitter {
43120
43165
  if (post.channel_id !== this.channelId)
43121
43166
  return;
43122
43167
  this.lastProcessedPostId = post.id;
43123
- this.getUser(post.user_id).then((user) => {
43124
- const normalizedPost = this.normalizePlatformPost(post);
43125
- this.emit("message", normalizedPost, user);
43126
- if (!post.root_id) {
43127
- this.emit("channel_post", normalizedPost, user);
43128
- }
43129
- });
43168
+ this.processAndEmitPost(post);
43130
43169
  } catch (err) {
43131
43170
  wsLogger.warn(`Failed to parse post: ${err}`);
43132
43171
  }
@@ -45330,6 +45369,7 @@ function resetSessionActivity(session) {
45330
45369
  session.lastActivityAt = new Date;
45331
45370
  session.timeoutWarningPosted = false;
45332
45371
  session.lifecyclePostId = undefined;
45372
+ session.isPaused = undefined;
45333
45373
  }
45334
45374
  function updateLastMessage(session, post) {
45335
45375
  session.lastMessageId = post.id;
@@ -45545,8 +45585,18 @@ async function bumpTasksToBottomWithContent(session, newContent, registerPost) {
45545
45585
  sessionLog(session).debug(`Could not remove toggle emoji: ${err}`);
45546
45586
  }
45547
45587
  await session.platform.unpinPost(oldTasksPostId).catch(() => {});
45548
- await withErrorHandling(() => session.platform.updatePost(oldTasksPostId, contentToPost), { action: "Repurpose task post", session });
45549
- registerPost(oldTasksPostId, session.threadId);
45588
+ let repurposedPostId = null;
45589
+ try {
45590
+ await session.platform.updatePost(oldTasksPostId, contentToPost);
45591
+ repurposedPostId = oldTasksPostId;
45592
+ registerPost(oldTasksPostId, session.threadId);
45593
+ } catch (err) {
45594
+ sessionLog(session).debug(`Could not repurpose task post (creating new): ${err}`);
45595
+ const newPost = await session.platform.createPost(contentToPost, session.threadId);
45596
+ repurposedPostId = newPost.id;
45597
+ registerPost(newPost.id, session.threadId);
45598
+ updateLastMessage(session, newPost);
45599
+ }
45550
45600
  if (oldTasksContent) {
45551
45601
  const displayContent = getTaskDisplayContent(session);
45552
45602
  const newTasksPost = await session.platform.createInteractivePost(displayContent, [TASK_TOGGLE_EMOJIS[0]], session.threadId);
@@ -45558,7 +45608,7 @@ async function bumpTasksToBottomWithContent(session, newContent, registerPost) {
45558
45608
  } else {
45559
45609
  session.tasksPostId = null;
45560
45610
  }
45561
- return oldTasksPostId;
45611
+ return repurposedPostId || oldTasksPostId;
45562
45612
  } finally {
45563
45613
  releaseLock();
45564
45614
  }
@@ -51548,10 +51598,10 @@ async function reportBug(session, description, username, ctx, errorContext, atta
51548
51598
  const context = await collectBugReportContext(session, errorContext);
51549
51599
  let imageUrls = [];
51550
51600
  let imageErrors = [];
51551
- const downloadFile = session.platform.downloadFile;
51601
+ const downloadFile = session.platform.downloadFile?.bind(session.platform);
51552
51602
  if (attachedFiles && attachedFiles.length > 0 && downloadFile) {
51553
51603
  await postInfo(session, `\uD83D\uDCE4 Uploading ${attachedFiles.length} image(s)...`);
51554
- const uploadResults = await uploadImages(attachedFiles, (fileId) => downloadFile(fileId));
51604
+ const uploadResults = await uploadImages(attachedFiles, downloadFile);
51555
51605
  imageUrls = uploadResults.filter((r) => r.success && typeof r.url === "string").map((r) => r.url);
51556
51606
  imageErrors = uploadResults.filter((r) => !r.success).map((r) => `${r.originalFile.name}: ${r.error}`);
51557
51607
  }
@@ -52377,12 +52427,16 @@ async function handleExitPlanMode(session, toolUseId, ctx) {
52377
52427
  }
52378
52428
  async function cleanupOrphanedTaskPosts(session, currentTaskPostId) {
52379
52429
  try {
52430
+ const botUser = await session.platform.getBotUser();
52431
+ const botUserId = botUser.id;
52380
52432
  const history = await session.platform.getThreadHistory(session.threadId, { limit: 50 });
52381
52433
  const taskPostPattern = /^(?:(?:---|___|\*\*\*|\u2014+)\s*\n)?\uD83D\uDCCB/;
52382
52434
  let cleanedCount = 0;
52383
52435
  for (const msg of history) {
52384
52436
  if (msg.id === currentTaskPostId)
52385
52437
  continue;
52438
+ if (msg.userId !== botUserId)
52439
+ continue;
52386
52440
  if (!taskPostPattern.test(msg.message))
52387
52441
  continue;
52388
52442
  sessionLog4(session).info(`Cleaning up orphaned task post ${msg.id.substring(0, 8)}`);
@@ -53326,6 +53380,7 @@ ${CHAT_PLATFORM_PROMPT}`;
53326
53380
  ${sessionFormatter.formatItalic("Reconnected to Claude session. You can continue where you left off.")}`;
53327
53381
  await withErrorHandling(() => session.platform.updatePost(postId, resumeMsg), { action: "Update timeout/shutdown post for resume", session });
53328
53382
  session.lifecyclePostId = undefined;
53383
+ session.isPaused = undefined;
53329
53384
  } else {
53330
53385
  const restartMsg = `${sessionFormatter.formatBold("Session resumed")} after bot restart (v${VERSION})
53331
53386
  ${sessionFormatter.formatItalic("Reconnected to Claude session. You can continue where you left off.")}`;
@@ -53418,15 +53473,20 @@ async function handleExit(sessionId, code, ctx) {
53418
53473
  sessionLog6(session).debug(`Exited after interrupt, preserving for resume`);
53419
53474
  ctx.ops.stopTyping(session);
53420
53475
  cleanupSessionTimers(session);
53476
+ const message = session.hasClaudeResponded ? `\u2139\uFE0F Session paused. Send a new message to continue.` : `\u2139\uFE0F Session ended before Claude could respond. Send a new message to start fresh.`;
53477
+ const pausePost = await withErrorHandling(() => postInfo(session, message), { action: "Post session pause notification", session });
53421
53478
  if (session.hasClaudeResponded) {
53479
+ session.isPaused = true;
53480
+ if (pausePost) {
53481
+ session.lifecyclePostId = pausePost.id;
53482
+ ctx.ops.registerPost(pausePost.id, session.threadId);
53483
+ }
53422
53484
  ctx.ops.persistSession(session);
53423
53485
  }
53424
53486
  ctx.ops.emitSessionRemove(session.sessionId);
53425
53487
  mutableSessions(ctx).delete(session.sessionId);
53426
53488
  cleanupPostIndex(ctx, session.threadId);
53427
53489
  keepAlive.sessionEnded();
53428
- const message = session.hasClaudeResponded ? `\u2139\uFE0F Session paused. Send a new message to continue.` : `\u2139\uFE0F Session ended before Claude could respond. Send a new message to start fresh.`;
53429
- await withErrorHandling(() => postInfo(session, message), { action: "Post session pause notification", session });
53430
53490
  sessionLog6(session).info(`\u23F8 Session paused`);
53431
53491
  await ctx.ops.updateStickyMessage();
53432
53492
  return;
@@ -53565,6 +53625,7 @@ async function cleanupIdleSessions(timeoutMs, warningMs, ctx) {
53565
53625
  ctx.ops.registerPost(timeoutPost.id, session.threadId);
53566
53626
  }
53567
53627
  }
53628
+ session.isPaused = true;
53568
53629
  ctx.ops.persistSession(session);
53569
53630
  await killSession(session, false, ctx);
53570
53631
  continue;
@@ -54051,7 +54112,7 @@ async function buildStickyMessage(sessions, platformId, config, formatter, getTh
54051
54112
  }
54052
54113
  }
54053
54114
  lines2.push("");
54054
- lines2.push(`${formatter.formatItalic("Mention me to start a session")} \xB7 ${formatter.formatCode("npm i -g claude-threads")}`);
54115
+ lines2.push(`${formatter.formatItalic("Mention me to start a session")} \xB7 ${formatter.formatCode("bun install -g claude-threads")} \xB7 ${formatter.formatLink("claude-threads.run", "https://claude-threads.run/")}`);
54055
54116
  return lines2.join(`
54056
54117
  `);
54057
54118
  }
@@ -54103,7 +54164,7 @@ async function buildStickyMessage(sessions, platformId, config, formatter, getTh
54103
54164
  }
54104
54165
  }
54105
54166
  lines.push("");
54106
- lines.push(`${formatter.formatItalic("Mention me to start a session")} \xB7 ${formatter.formatCode("npm i -g claude-threads")}`);
54167
+ lines.push(`${formatter.formatItalic("Mention me to start a session")} \xB7 ${formatter.formatCode("bun install -g claude-threads")} \xB7 ${formatter.formatLink("claude-threads.run", "https://claude-threads.run/")}`);
54107
54168
  return lines.join(`
54108
54169
  `);
54109
54170
  }
@@ -54651,6 +54712,7 @@ class SessionManager extends EventEmitter4 {
54651
54712
  pendingContextPrompt: persistedContextPrompt,
54652
54713
  needsContextPromptOnNextMessage: session.needsContextPromptOnNextMessage,
54653
54714
  lifecyclePostId: session.lifecyclePostId,
54715
+ isPaused: session.isPaused,
54654
54716
  sessionTitle: session.sessionTitle,
54655
54717
  sessionDescription: session.sessionDescription,
54656
54718
  pullRequestUrl: session.pullRequestUrl,
@@ -54858,9 +54920,23 @@ class SessionManager extends EventEmitter4 {
54858
54920
  const persisted = this.sessionStore.load();
54859
54921
  log18.info(`\uD83D\uDCC2 Loaded ${persisted.size} session(s) from persistence`);
54860
54922
  if (persisted.size > 0) {
54861
- log18.info(`\uD83D\uDD04 Attempting to resume ${persisted.size} persisted session(s)...`);
54923
+ const activeToResume = [];
54924
+ const pausedToSkip = [];
54862
54925
  for (const state of persisted.values()) {
54863
- await resumeSession(state, this.getContext());
54926
+ if (state.isPaused) {
54927
+ pausedToSkip.push(state);
54928
+ } else {
54929
+ activeToResume.push(state);
54930
+ }
54931
+ }
54932
+ if (pausedToSkip.length > 0) {
54933
+ log18.info(`\u23F8\uFE0F ${pausedToSkip.length} session(s) remain paused (waiting for user message)`);
54934
+ }
54935
+ if (activeToResume.length > 0) {
54936
+ log18.info(`\uD83D\uDD04 Attempting to resume ${activeToResume.length} active session(s)...`);
54937
+ for (const state of activeToResume) {
54938
+ await resumeSession(state, this.getContext());
54939
+ }
54864
54940
  }
54865
54941
  }
54866
54942
  await this.updateStickyMessage();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "0.51.0",
3
+ "version": "0.52.1",
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",