claude-threads 1.9.3 → 1.10.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
@@ -5,6 +5,19 @@ 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
+ ## [1.10.0] - 2026-04-29
9
+
10
+ ### Added
11
+ - **Thread permalink in Claude session context.** The system-prompt context block now includes `**Thread:** <permalink>` alongside the existing platform and working-directory entries. When Claude opens a PR, files a ticket, or otherwise produces an artifact for someone to review later, it can paste the link back into the description so reviewers can trace the change to the discussion it came from. Mattermost and Slack both already exposed `getThreadLink()`; pure plumbing change. Also restores the platform/working-directory context on the worktree-restart path, which had been silently dropped pre-existing. (#352)
12
+
13
+ ### Fixed
14
+ - **Sticky-by-thread Claude account binding (multi-account mode).** In multi-account configurations the `claudeAccountId` written to `sessions.json` and the `$HOME` Claude was actually spawned under could drift apart under concurrent acquisitions, leaving sessions unresumable after a bot restart with `Detected permanent failure: The conversation history for this session no longer exists`. Real-world incident: 14 sessions soft-deleted in one restart cycle, 3 of them genuine victims of this drift. `AccountPool.acquire()` now binds threads to accounts deterministically via `accounts[hash(threadId) % n]` (FNV-1a, dependency-free), so the spawn-time `$HOME` and the persisted id both derive from the same hash and cannot drift. `preferredId` still wins over the sticky binding (resume invariant: OAuth history can't move), and the sticky path falls through to round-robin when the chosen account is in cooldown. (#350)
15
+
16
+ ## [1.9.4] - 2026-04-29
17
+
18
+ ### Fixed
19
+ - **Memory leak across session lifecycles.** `PostTracker` registered every post created during a session but its `clearSession(sessionId)` was only invoked on full bot shutdown. Across many sessions the in-memory map grew without bound and contributed to V8 mark-compact becoming ineffective and an eventual OOM abort (observed at ~14 h uptime). `MessageManager.dispose()` now clears its session's `PostTracker` bucket, and dispose is wired into all five session-removal paths in `src/session/lifecycle.ts`: normal exit, kill, idle timeout, pause, shutdown, early exit, resume-fail, plus the start-failure and resume-failure error branches that bypass the shared cleanup helpers. (#356, fixes #351)
20
+
8
21
  ## [1.9.3] - 2026-04-24
9
22
 
10
23
  ### Internals
package/dist/index.js CHANGED
@@ -53655,6 +53655,14 @@ class SessionStore {
53655
53655
  // src/claude/account-pool.ts
53656
53656
  init_logger();
53657
53657
  var log5 = createLogger("account-pool");
53658
+ function hashThreadId(threadId) {
53659
+ let h = 2166136261;
53660
+ for (let i2 = 0;i2 < threadId.length; i2++) {
53661
+ h ^= threadId.charCodeAt(i2);
53662
+ h = Math.imul(h, 16777619);
53663
+ }
53664
+ return h >>> 0;
53665
+ }
53658
53666
 
53659
53667
  class AccountPool {
53660
53668
  accounts;
@@ -53686,7 +53694,7 @@ class AccountPool {
53686
53694
  get size() {
53687
53695
  return this.accounts.length;
53688
53696
  }
53689
- acquire(preferredId) {
53697
+ acquire(preferredId, threadId) {
53690
53698
  if (this.isEmpty)
53691
53699
  return null;
53692
53700
  if (preferredId) {
@@ -53699,6 +53707,14 @@ class AccountPool {
53699
53707
  }
53700
53708
  const now = Date.now();
53701
53709
  const n = this.accounts.length;
53710
+ if (threadId) {
53711
+ const sticky = this.accounts[hashThreadId(threadId) % n];
53712
+ const cooling = this.coolingUntil.get(sticky.id) ?? 0;
53713
+ if (cooling <= now) {
53714
+ this.incrementActive(sticky.id);
53715
+ return sticky;
53716
+ }
53717
+ }
53702
53718
  for (let i2 = 0;i2 < n; i2++) {
53703
53719
  const idx = (this.roundRobinIndex + i2) % n;
53704
53720
  const candidate = this.accounts[idx];
@@ -55582,9 +55598,10 @@ function formatClaudeCommand(cmd) {
55582
55598
  }
55583
55599
  return line;
55584
55600
  }
55585
- function buildSessionContext(platform, workingDir) {
55601
+ function buildSessionContext(platform, workingDir, threadId) {
55586
55602
  const platformName = platform.platformType.charAt(0).toUpperCase() + platform.platformType.slice(1);
55587
- return `**Platform:** ${platformName} (${platform.displayName}) | **Working Directory:** ${workingDir}`;
55603
+ const threadUrl = platform.getThreadLink(threadId);
55604
+ return `**Platform:** ${platformName} (${platform.displayName}) | **Working Directory:** ${workingDir} | **Thread:** ${threadUrl}`;
55588
55605
  }
55589
55606
  function generateChatPlatformPrompt() {
55590
55607
  const userCommands = COMMAND_REGISTRY.filter((cmd) => cmd.category !== "passthrough" && ["stop", "escape", "approve", "invite", "kick", "cd", "permissions", "update"].includes(cmd.command));
@@ -60991,6 +61008,7 @@ class MessageManager {
60991
61008
  }
60992
61009
  dispose() {
60993
61010
  this.cancelScheduledFlush();
61011
+ this.postTracker.clearSession(this.sessionId);
60994
61012
  this.reset();
60995
61013
  }
60996
61014
  }
@@ -65911,7 +65929,7 @@ async function changeDirectory(session, newDir, username, ctx) {
65911
65929
  session.workingDir = absoluteDir;
65912
65930
  const newSessionId = randomUUID2();
65913
65931
  session.claudeSessionId = newSessionId;
65914
- const sessionContext = buildSessionContext(session.platform, absoluteDir);
65932
+ const sessionContext = buildSessionContext(session.platform, absoluteDir, session.threadId);
65915
65933
  const appendSystemPrompt = `${sessionContext}
65916
65934
 
65917
65935
  ${CHAT_PLATFORM_PROMPT}`;
@@ -66614,6 +66632,7 @@ async function createAndSwitchToWorktree(session, branch, username, options2) {
66614
66632
  const newSessionId = randomUUID3();
66615
66633
  session.claudeSessionId = newSessionId;
66616
66634
  const needsTitlePrompt = !session.sessionTitle;
66635
+ const sessionContext = needsTitlePrompt ? buildSessionContext(session.platform, existing.path, session.threadId) : null;
66617
66636
  const cliOptions = {
66618
66637
  workingDir: existing.path,
66619
66638
  threadId: session.threadId,
@@ -66626,7 +66645,9 @@ async function createAndSwitchToWorktree(session, branch, username, options2) {
66626
66645
  resume: false,
66627
66646
  chrome: options2.chromeEnabled,
66628
66647
  platformConfig: session.platform.getMcpConfig(),
66629
- appendSystemPrompt: needsTitlePrompt ? options2.appendSystemPrompt : undefined,
66648
+ appendSystemPrompt: sessionContext ? `${sessionContext}
66649
+
66650
+ ${options2.appendSystemPrompt}` : undefined,
66630
66651
  logSessionId: session.sessionId,
66631
66652
  permissionTimeoutMs: options2.permissionTimeoutMs
66632
66653
  };
@@ -66707,6 +66728,7 @@ ${fmt.formatItalic("Claude Code restarted in the worktree")}`);
66707
66728
  const newSessionId = randomUUID3();
66708
66729
  session.claudeSessionId = newSessionId;
66709
66730
  const needsTitlePrompt = !session.sessionTitle;
66731
+ const sessionContext = needsTitlePrompt ? buildSessionContext(session.platform, worktreePath, session.threadId) : null;
66710
66732
  const cliOptions = {
66711
66733
  workingDir: worktreePath,
66712
66734
  threadId: session.threadId,
@@ -66719,7 +66741,9 @@ ${fmt.formatItalic("Claude Code restarted in the worktree")}`);
66719
66741
  resume: false,
66720
66742
  chrome: options2.chromeEnabled,
66721
66743
  platformConfig: session.platform.getMcpConfig(),
66722
- appendSystemPrompt: needsTitlePrompt ? options2.appendSystemPrompt : undefined,
66744
+ appendSystemPrompt: sessionContext ? `${sessionContext}
66745
+
66746
+ ${options2.appendSystemPrompt}` : undefined,
66723
66747
  logSessionId: session.sessionId,
66724
66748
  permissionTimeoutMs: options2.permissionTimeoutMs
66725
66749
  };
@@ -67485,6 +67509,7 @@ async function cleanupSession(session, ctx, options2 = {}) {
67485
67509
  if (doCloseLogger) {
67486
67510
  await closeThreadLogger(session, action, details);
67487
67511
  }
67512
+ session.messageManager?.dispose();
67488
67513
  ctx.ops.emitSessionRemove(session.sessionId);
67489
67514
  mutableSessions(ctx).delete(session.sessionId);
67490
67515
  if (doCleanupPostIndex) {
@@ -67500,6 +67525,7 @@ function releaseAccountIfHeld(session, ctx) {
67500
67525
  }
67501
67526
  }
67502
67527
  function removeFromRegistry(session, ctx) {
67528
+ session.messageManager?.dispose();
67503
67529
  ctx.ops.emitSessionRemove(session.sessionId);
67504
67530
  mutableSessions(ctx).delete(session.sessionId);
67505
67531
  cleanupPostIndex(ctx, session.threadId);
@@ -67835,12 +67861,12 @@ async function startSession(options2, username, displayName, replyToPostId, plat
67835
67861
  permissionMode = "default";
67836
67862
  log26.info(`Starting session with interactive permissions (from !permissions command)`);
67837
67863
  }
67838
- const sessionContext = buildSessionContext(platform, workingDir);
67864
+ const sessionContext = buildSessionContext(platform, workingDir, actualThreadId);
67839
67865
  const systemPrompt = `${sessionContext}
67840
67866
 
67841
67867
  ${CHAT_PLATFORM_PROMPT}`;
67842
67868
  const platformMcpConfig = platform.getMcpConfig();
67843
- const claudeAccount = ctx.ops.acquireClaudeAccount();
67869
+ const claudeAccount = ctx.ops.acquireClaudeAccount(undefined, actualThreadId);
67844
67870
  if (claudeAccount) {
67845
67871
  log26.info(`Session ${sessionId.substring(0, 20)} reserved Claude account "${claudeAccount.id}"`);
67846
67872
  }
@@ -67911,6 +67937,7 @@ ${CHAT_PLATFORM_PROMPT}`;
67911
67937
  } catch (err) {
67912
67938
  await logAndNotify(err, { action: "Start Claude", session });
67913
67939
  ctx.ops.stopTyping(session);
67940
+ session.messageManager?.dispose();
67914
67941
  ctx.ops.emitSessionRemove(session.sessionId);
67915
67942
  mutableSessions(ctx).delete(session.sessionId);
67916
67943
  releaseAccountIfHeld(session, ctx);
@@ -67986,11 +68013,11 @@ Please start a new session.`), { action: "Post resume failure notification" });
67986
68013
  const sessionId = ctx.ops.getSessionId(platformId, state.threadId);
67987
68014
  const resumePermissionMode = state.forceInteractivePermissions ? "default" : ctx.config.permissionMode;
67988
68015
  const platformMcpConfig = platform.getMcpConfig();
67989
- const sessionContext = buildSessionContext(platform, state.workingDir);
68016
+ const sessionContext = buildSessionContext(platform, state.workingDir, state.threadId);
67990
68017
  const appendSystemPrompt = `${sessionContext}
67991
68018
 
67992
68019
  ${CHAT_PLATFORM_PROMPT}`;
67993
- const claudeAccount = ctx.ops.acquireClaudeAccount(state.claudeAccountId);
68020
+ const claudeAccount = ctx.ops.acquireClaudeAccount(state.claudeAccountId, state.threadId);
67994
68021
  if (state.claudeAccountId && !claudeAccount) {
67995
68022
  log26.warn(`Persisted session referenced Claude account "${state.claudeAccountId}" ` + `which is no longer configured — resuming under default env`);
67996
68023
  }
@@ -68114,6 +68141,7 @@ ${sessionFormatter.formatItalic("Reconnected to Claude session. You can continue
68114
68141
  ctx.ops.persistSession(session);
68115
68142
  } catch (err) {
68116
68143
  log26.error(`Failed to resume session ${shortId}`, err instanceof Error ? err : undefined);
68144
+ session.messageManager?.dispose();
68117
68145
  ctx.ops.emitSessionRemove(sessionId);
68118
68146
  mutableSessions(ctx).delete(sessionId);
68119
68147
  ctx.state.sessionStore.remove(sessionId);
@@ -68888,7 +68916,7 @@ class SessionManager extends EventEmitter4 {
68888
68916
  emitSessionAdd: (s) => this.emitSessionAdd(s),
68889
68917
  emitSessionUpdate: (sid, u) => this.emitSessionUpdate(sid, u),
68890
68918
  emitSessionRemove: (sid) => this.emitSessionRemove(sid),
68891
- acquireClaudeAccount: (preferredId) => this.accountPool.acquire(preferredId),
68919
+ acquireClaudeAccount: (preferredId, threadId) => this.accountPool.acquire(preferredId, threadId),
68892
68920
  getClaudeAccount: (id) => this.accountPool.get(id),
68893
68921
  releaseClaudeAccount: (id) => this.accountPool.release(id),
68894
68922
  markClaudeAccountCooling: (id, untilMs) => this.accountPool.markCooling(id, untilMs),
@@ -52899,6 +52899,7 @@ class MessageManager {
52899
52899
  }
52900
52900
  dispose() {
52901
52901
  this.cancelScheduledFlush();
52902
+ this.postTracker.clearSession(this.sessionId);
52902
52903
  this.reset();
52903
52904
  }
52904
52905
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "1.9.3",
3
+ "version": "1.10.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",