claude-threads 1.16.0 → 1.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.16.2] - 2026-05-31
11
+
12
+ ### Fixed
13
+ - **Sturdier retry budget for the Mattermost post-save race.** Under load Mattermost can return a burst of 500s on `POST /posts` (the threads write race: duplicate key on `threads_pkey` / `app.post.save.app_error`) when several posts stream to the same thread. The retry budget was 3 attempts with plain exponential backoff; a heavy burst exhausted it and dropped a post, which surfaced as flaky task-list / sticky / context-prompt behavior. The budget is now 6 attempts with a capped (2s), equal-jittered backoff. The cap keeps the total wait bounded so a long retry chain can't itself stall things, and the jitter decorrelates concurrent posts that would otherwise re-collide on the same row lock every round. (#394)
14
+ - **Two remaining memory leaks after #351.** First, `MessageManager.dispose()` cleared the post tracker but never called `this.events.removeAllListeners()` on the per-session emitter. Each session attaches a handful of listeners (`question:complete`, `task:update`, `approval:complete`, and the rest), and their closures kept session state reachable after the session ended, so the heap grew with every session. `dispose()` now removes the listeners before resetting. Second, React 19 enables user timing when both `console.timeStamp` and `performance.measure` exist (the case on Node.js 25+), so every component re-render calls `performance.measure()` with a structured-clone'd prop-diff detail that Node buffers indefinitely (~50-205 KB per entry, ~2 GB after a long uptime). Nothing in the bot reads those entries, so a guarded `setInterval` clears them every 60 seconds with `.unref()` so it never blocks a clean exit. (#394)
15
+
16
+ ## [1.16.1] - 2026-05-22
17
+
18
+ ### Security
19
+ - **Fail-closed authorization on the user-driven paths that invoke Claude.** A confirmed bypass let an unauthorized Slack user (not in `allowedUsers`, non-empty allowlist) reach Claude: the bot posted both the "not authorized" warning and a real answer in the same thread. Rather than chase the one leaking caller, authorization is now deny-by-default at the functions that spawn or message Claude on a user's behalf: `startSession`, `sendFollowUp`, and `resumePausedSession`, plus the resume-from-reaction path. A single helper, `isAuthorizedForSession`, encodes every tier (global allowlist, empty-allowlist allow-all, per-session owner and invited users) and refuses a missing, empty, or `unknown` username. Every user-facing path now routes through that one helper instead of duplicating the check. The biggest gap was message-driven resume, which ran purely from persisted state with no identity check; it now takes a `username` and verifies it against the persisted session allowlist before resuming. The two system-triggered resume paths (bulk restore after a bot restart) stay ungated by design, since they carry no user identity. Legitimate username-less follow-ups (passthrough slash commands like `/context`, already authorized upstream) pass an explicit `system` flag so they are not caught. The mid-session approval flow is untouched: it still adds approved users to the session allowlist, so the check passes once approval is granted. (#388)
20
+ - **hono 4.12.18 → 4.12.21** picks up four upstream security fixes: GHSA-2gcr-mfcq-wcc3 (`app.mount()` stripped the mount prefix from the raw, undecoded pathname, so percent-encoded paths could be cut at the wrong offset and reach a sub-app with the wrong path), GHSA-xrhx-7g5j-rcj5 (`hono/ip-restriction` compared IPs by string equality, so non-canonical IPv6 forms such as compressed or hex IPv4-mapped addresses slipped past static deny rules), GHSA-3hrh-pfw6-9m5x (the cookie helper didn't sanitize `sameSite`/`priority`, allowing Set-Cookie injection via `;`, `\r`, `\n`), and GHSA-f577-qrjj-4474 (`hono/jwt` accepted any two-part Authorization header regardless of scheme, not just `Bearer`). claude-threads reaches hono through `@hono/node-server` on the inbound webhook surface, so the mount-prefix and Set-Cookie fixes are the relevant ones. (#386)
21
+
22
+ ### Changed
23
+ - **Production deps** bumped: `@hono/node-server` 2.0.2 → 2.0.3 (preserves headers mutated after raw Response construction), `express-rate-limit` 8.5.1 → 8.5.2 (simplified IPv6 key generation). Lockfile-only. (#386)
24
+ - **Dev deps** bumped: `@types/bun` 1.3.13 → 1.3.14, `@types/node` 25.7.0 → 25.9.1, `@types/react` 19.2.14 → 19.2.15, `eslint` 10.3.0 → 10.4.0 (adds `includeIgnoreFile()` and a `for-direction` sequence-expression check, both additive), `lint-staged` 17.0.4 → 17.0.5, `typescript-eslint` 8.59.3 → 8.59.4 (fixes a `no-floating-promises` stack overflow on recursive types). Lockfile-only. (#385)
25
+
26
+ ### Fixed
27
+ - **Multiple pasted screenshots no longer silently vanish.** When you paste several clipboard images into one chat message, the platforms hand them all the same filename (`image.png`). The save path wrote each one with the `wx` (`O_EXCL`) flag, so the second and later files hit `EEXIST`, got caught, and were reported as a download failure, so they never reached Claude. Saves now dedupe filenames within a single message, inserting a numeric suffix before the extension (`image.png`, `image_1.png`, `image_2.png`); files without an extension get `report`, `report_1`, and so on. The unique name is resolved before the write, so the `wx` flag still does its job against symlink races. (#387)
28
+ - **Inbound attachments are no longer capped at 100 MB.** A hard 100 MB ceiling on incoming files meant anything larger was skipped with a "too large" warning, which surprised people sharing big assets. The cap is gone; an attachment of any reported size is downloaded and written to disk. One tradeoff worth knowing: `downloadFile` still buffers the whole file in memory via `arrayBuffer()`, so a very large attachment is held in RAM while it's written. Streaming that download is a separate change. (#387)
29
+
10
30
  ## [1.16.0] - 2026-05-14
11
31
 
12
32
  ### Added
package/dist/index.js CHANGED
@@ -50355,6 +50355,24 @@ function sanitizeFilename(name) {
50355
50355
  }
50356
50356
  return cleaned;
50357
50357
  }
50358
+ function dedupeFilename(name, used) {
50359
+ if (!used.has(name)) {
50360
+ used.add(name);
50361
+ return name;
50362
+ }
50363
+ const lastDot = name.lastIndexOf(".");
50364
+ const hasExt = lastDot > 0;
50365
+ const stem = hasExt ? name.slice(0, lastDot) : name;
50366
+ const ext = hasExt ? name.slice(lastDot) : "";
50367
+ let counter = 1;
50368
+ let candidate = `${stem}_${counter}${ext}`;
50369
+ while (used.has(candidate)) {
50370
+ counter += 1;
50371
+ candidate = `${stem}_${counter}${ext}`;
50372
+ }
50373
+ used.add(candidate);
50374
+ return candidate;
50375
+ }
50358
50376
  function formatBytes(bytes) {
50359
50377
  if (bytes < 1024)
50360
50378
  return `${bytes} B`;
@@ -50589,8 +50607,9 @@ class MattermostClient extends BasePlatformClient {
50589
50607
  this.emit("channel_post", normalizedPost, user);
50590
50608
  }
50591
50609
  }
50592
- MAX_RETRIES = 3;
50610
+ MAX_RETRIES = 6;
50593
50611
  RETRY_DELAY_MS = 500;
50612
+ RETRY_DELAY_CAP_MS = 2000;
50594
50613
  async api(method, path, body, retryCount = 0, options) {
50595
50614
  const url = `${this.url}/api/v4${path}`;
50596
50615
  log3.debug(`API ${method} ${path}`);
@@ -50605,7 +50624,7 @@ class MattermostClient extends BasePlatformClient {
50605
50624
  if (!response.ok) {
50606
50625
  const text = await response.text();
50607
50626
  if (response.status === 500 && retryCount < this.MAX_RETRIES) {
50608
- const delay = this.RETRY_DELAY_MS * Math.pow(2, retryCount);
50627
+ const delay = this.retryDelayMs(retryCount);
50609
50628
  log3.warn(`API ${method} ${path} failed with 500, retrying in ${delay}ms (attempt ${retryCount + 1}/${this.MAX_RETRIES})`);
50610
50629
  await new Promise((resolve3) => setTimeout(resolve3, delay));
50611
50630
  return this.api(method, path, body, retryCount + 1, options);
@@ -50621,6 +50640,11 @@ class MattermostClient extends BasePlatformClient {
50621
50640
  log3.debug(`API ${method} ${path} → ${response.status}`);
50622
50641
  return response.json();
50623
50642
  }
50643
+ retryDelayMs(retryCount) {
50644
+ const ceil = Math.min(this.RETRY_DELAY_MS * Math.pow(2, retryCount), this.RETRY_DELAY_CAP_MS);
50645
+ const half = ceil / 2;
50646
+ return Math.floor(half + Math.random() * half);
50647
+ }
50624
50648
  async getBotUser() {
50625
50649
  const user = await this.api("GET", "/users/me");
50626
50650
  this.botUserId = user.id;
@@ -53585,6 +53609,18 @@ function getSessionStatus(session) {
53585
53609
  return "idle";
53586
53610
  }
53587
53611
 
53612
+ // src/session/authorization.ts
53613
+ function isAuthorizedForSession(check) {
53614
+ const { username, platform, sessionAllowedUsers } = check;
53615
+ if (!username || username === "unknown") {
53616
+ return false;
53617
+ }
53618
+ if (platform.isUserAllowed(username)) {
53619
+ return true;
53620
+ }
53621
+ return sessionAllowedUsers?.has(username) ?? false;
53622
+ }
53623
+
53588
53624
  // src/claude/cli.ts
53589
53625
  init_spawn();
53590
53626
  init_logger();
@@ -54818,7 +54854,7 @@ function createPassthroughHandler(slashCommand) {
54818
54854
  return { handled: false };
54819
54855
  }
54820
54856
  if (ctx.isAllowed) {
54821
- await ctx.sessionManager.sendFollowUp(ctx.threadId, `/${slashCommand}`);
54857
+ await ctx.sessionManager.sendFollowUp(ctx.threadId, `/${slashCommand}`, undefined, undefined, undefined, { system: true });
54822
54858
  }
54823
54859
  return { handled: true };
54824
54860
  };
@@ -54866,7 +54902,7 @@ async function handleDynamicSlashCommand(command, args, ctx) {
54866
54902
  }
54867
54903
  if (ctx.isAllowed) {
54868
54904
  const fullCommand = args ? `/${command} ${args}` : `/${command}`;
54869
- await ctx.sessionManager.sendFollowUp(ctx.threadId, fullCommand);
54905
+ await ctx.sessionManager.sendFollowUp(ctx.threadId, fullCommand, undefined, undefined, undefined, { system: true });
54870
54906
  }
54871
54907
  return { handled: true };
54872
54908
  }
@@ -55387,7 +55423,6 @@ import { lstat, mkdir as mkdir2, mkdtemp, rm as rm2, writeFile as writeFile2 } f
55387
55423
  import { tmpdir as tmpdir2 } from "os";
55388
55424
  import { join as join9 } from "path";
55389
55425
  var log18 = createLogger("streaming");
55390
- var MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
55391
55426
  var UPLOAD_ROOT_DIR = "claude-threads-uploads";
55392
55427
  function safeIdSegment(id) {
55393
55428
  return id.replace(/[^A-Za-z0-9._-]/g, "_");
@@ -55427,18 +55462,11 @@ async function saveFilesToUploadDir(platform, uploadDir, files, debug = false) {
55427
55462
  return { saved, skipped };
55428
55463
  }
55429
55464
  const messageDir = await mkdtemp(join9(uploadDir, `${Date.now().toString(36)}-`));
55465
+ const usedNames = new Set;
55430
55466
  for (const file of files) {
55431
- if (file.size > MAX_UPLOAD_SIZE) {
55432
- skipped.push({
55433
- name: file.name,
55434
- reason: `File too large (${formatBytes(file.size)} > ${formatBytes(MAX_UPLOAD_SIZE)} limit)`,
55435
- suggestion: "Split the file or share it via an external link"
55436
- });
55437
- continue;
55438
- }
55439
55467
  try {
55440
55468
  const buffer = await platform.downloadFile(file.id);
55441
- const safeName = sanitizeFilename(file.name);
55469
+ const safeName = dedupeFilename(sanitizeFilename(file.name), usedNames);
55442
55470
  const absolutePath = join9(messageDir, safeName);
55443
55471
  await writeFile2(absolutePath, buffer, { mode: 384, flag: "wx" });
55444
55472
  saved.push({
@@ -63774,6 +63802,7 @@ class MessageManager {
63774
63802
  dispose() {
63775
63803
  this.cancelScheduledFlush();
63776
63804
  this.postTracker.clearSession(this.sessionId);
63805
+ this.events.removeAllListeners();
63777
63806
  this.reset();
63778
63807
  }
63779
63808
  }
@@ -66728,6 +66757,10 @@ async function startSession(options, username, displayName, replyToPostId, platf
66728
66757
  if (!platform) {
66729
66758
  throw new Error(`Platform '${platformId}' not found. Call addPlatform() first.`);
66730
66759
  }
66760
+ if (!isAuthorizedForSession({ username, platform, sessionAllowedUsers: undefined })) {
66761
+ log30.warn(`auth.denied.startSession: @${username || "unknown"} not authorized to start session in ${threadId.substring(0, 8)}...`);
66762
+ return;
66763
+ }
66731
66764
  const activeOrPending = ctx.state.sessions.size + pendingStartsCount;
66732
66765
  if (activeOrPending >= ctx.config.maxSessions) {
66733
66766
  const formatter2 = platform.getFormatter();
@@ -67095,9 +67128,15 @@ ${failFormatter.formatItalic("Your previous conversation context is preserved, b
67095
67128
  await ctx.ops.updateStickyMessage();
67096
67129
  }
67097
67130
  }
67098
- async function sendFollowUp(session, message, files, ctx, username, displayName) {
67131
+ async function sendFollowUp(session, message, files, ctx, username, displayName, options) {
67099
67132
  if (!session.claude.isRunning())
67100
67133
  return;
67134
+ if (!options?.system) {
67135
+ if (!isAuthorizedForSession({ username, platform: session.platform, sessionAllowedUsers: session.sessionAllowedUsers })) {
67136
+ sessionLog6(session).warn(`auth.denied.sendFollowUp: @${username || "unknown"} not authorized`);
67137
+ return;
67138
+ }
67139
+ }
67101
67140
  if (session.needsContextPromptOnNextMessage) {
67102
67141
  session.needsContextPromptOnNextMessage = false;
67103
67142
  await session.messageManager?.prepareForUserMessage();
@@ -67120,7 +67159,7 @@ async function sendFollowUp(session, message, files, ctx, username, displayName)
67120
67159
  session.messageCount++;
67121
67160
  await session.messageManager.handleUserMessage(messageToSend, files, username, displayName);
67122
67161
  }
67123
- async function resumePausedSession(threadId, message, files, ctx) {
67162
+ async function resumePausedSession(threadId, message, files, ctx, username) {
67124
67163
  const persisted = ctx.state.sessionStore.load();
67125
67164
  const state = findPersistedByThreadId(persisted, threadId);
67126
67165
  if (!state) {
@@ -67128,6 +67167,16 @@ async function resumePausedSession(threadId, message, files, ctx) {
67128
67167
  return;
67129
67168
  }
67130
67169
  const shortId = threadId.substring(0, 8);
67170
+ const platform = ctx.state.platforms.get(state.platformId);
67171
+ if (!platform) {
67172
+ log30.warn(`auth.denied.resume: platform '${state.platformId}' not found for ${shortId}...`);
67173
+ return;
67174
+ }
67175
+ const sessionAllowedUsers = new Set(state.sessionAllowedUsers || [state.startedBy].filter(Boolean));
67176
+ if (!isAuthorizedForSession({ username, platform, sessionAllowedUsers })) {
67177
+ log30.warn(`auth.denied.resume: @${username || "unknown"} not authorized to resume ${shortId}...`);
67178
+ return;
67179
+ }
67131
67180
  log30.info(`\uD83D\uDD04 Resuming paused session ${shortId}... for new message`);
67132
67181
  await resumeSession(state, ctx);
67133
67182
  const session = ctx.ops.findSessionByThreadId(threadId);
@@ -67645,9 +67694,9 @@ async function tryResumeFromReaction(deps, platformId, postId, username) {
67645
67694
  const sessionId = `${platformId}:${persistedSession.threadId}`;
67646
67695
  if (deps.registry.hasById(sessionId))
67647
67696
  return false;
67648
- const allowedUsers = new Set(persistedSession.sessionAllowedUsers);
67649
67697
  const platform = deps.platforms.get(platformId);
67650
- if (!allowedUsers.has(username) && !platform?.isUserAllowed(username)) {
67698
+ const sessionAllowedUsers = new Set(persistedSession.sessionAllowedUsers || [persistedSession.startedBy].filter(Boolean));
67699
+ if (!platform || !isAuthorizedForSession({ username, platform, sessionAllowedUsers })) {
67651
67700
  if (platform) {
67652
67701
  await platform.createPost(`⚠️ @${username} is not authorized to resume this session`, persistedSession.threadId);
67653
67702
  }
@@ -68233,11 +68282,11 @@ class SessionManager extends EventEmitter4 {
68233
68282
  }
68234
68283
  return;
68235
68284
  }
68236
- async sendFollowUp(threadId, message, files, username, displayName) {
68285
+ async sendFollowUp(threadId, message, files, username, displayName, options) {
68237
68286
  const session = this.findSessionByThreadId(threadId);
68238
68287
  if (!session || !session.claude.isRunning())
68239
68288
  return;
68240
- await sendFollowUp(session, message, files, this.getContext(), username, displayName);
68289
+ await sendFollowUp(session, message, files, this.getContext(), username, displayName, options);
68241
68290
  }
68242
68291
  isSessionActive() {
68243
68292
  return this.registry.size > 0;
@@ -68251,8 +68300,8 @@ class SessionManager extends EventEmitter4 {
68251
68300
  return false;
68252
68301
  return this.registry.getPersistedByThreadId(threadId) !== undefined;
68253
68302
  }
68254
- async resumePausedSession(threadId, message, files) {
68255
- await resumePausedSession(threadId, message, files, this.getContext());
68303
+ async resumePausedSession(threadId, message, files, username) {
68304
+ await resumePausedSession(threadId, message, files, this.getContext(), username);
68256
68305
  }
68257
68306
  getPersistedSession(threadId) {
68258
68307
  return this.registry.getPersistedByThreadId(threadId);
@@ -68691,6 +68740,17 @@ Mention me to start a session in this worktree.`, threadId);
68691
68740
  this.registry.clear();
68692
68741
  }
68693
68742
  }
68743
+ // src/utils/perf-cleanup.ts
68744
+ function startReactMeasureCleanup(intervalMs = 60000) {
68745
+ if (typeof performance === "undefined" || typeof performance.clearMeasures !== "function") {
68746
+ return null;
68747
+ }
68748
+ const timer = setInterval(() => {
68749
+ performance.clearMeasures();
68750
+ }, intervalMs);
68751
+ timer.unref?.();
68752
+ return timer;
68753
+ }
68694
68754
  // src/ui/providers/ink-provider.ts
68695
68755
  var import_react62 = __toESM(require_react(), 1);
68696
68756
 
@@ -78305,7 +78365,7 @@ async function handleMessage(client, session, post2, user, options) {
78305
78365
  }
78306
78366
  const files2 = post2.metadata?.files;
78307
78367
  if (content || files2?.length) {
78308
- await session.resumePausedSession(threadRoot, content, files2);
78368
+ await session.resumePausedSession(threadRoot, content, files2, username);
78309
78369
  }
78310
78370
  return;
78311
78371
  }
@@ -79483,6 +79543,7 @@ async function startWithoutDaemon() {
79483
79543
  }
79484
79544
  }
79485
79545
  let triggerShutdown = null;
79546
+ startReactMeasureCleanup();
79486
79547
  const updateState = loadUpdateState();
79487
79548
  const restoredSettings = updateState.justUpdated ? updateState.runtimeSettings : undefined;
79488
79549
  if (restoredSettings) {
@@ -51043,7 +51043,6 @@ function formatBytes(bytes) {
51043
51043
 
51044
51044
  // src/operations/streaming/handler.ts
51045
51045
  var log2 = createLogger("streaming");
51046
- var MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
51047
51046
  async function postSkippedFilesFeedback(platform, threadId, skipped) {
51048
51047
  if (skipped.length === 0)
51049
51048
  return;
@@ -51554,6 +51553,7 @@ class MessageManager {
51554
51553
  dispose() {
51555
51554
  this.cancelScheduledFlush();
51556
51555
  this.postTracker.clearSession(this.sessionId);
51556
+ this.events.removeAllListeners();
51557
51557
  this.reset();
51558
51558
  }
51559
51559
  }
@@ -55810,7 +55810,7 @@ function createPassthroughHandler(slashCommand) {
55810
55810
  return { handled: false };
55811
55811
  }
55812
55812
  if (ctx.isAllowed) {
55813
- await ctx.sessionManager.sendFollowUp(ctx.threadId, `/${slashCommand}`);
55813
+ await ctx.sessionManager.sendFollowUp(ctx.threadId, `/${slashCommand}`, undefined, undefined, undefined, { system: true });
55814
55814
  }
55815
55815
  return { handled: true };
55816
55816
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "1.16.0",
3
+ "version": "1.16.2",
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",