claude-threads 1.7.0 → 1.8.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,24 @@ 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.8.0] - 2026-04-21
9
+
10
+ ### Added
11
+ - **Pass `MCP_CONNECTION_NONBLOCKING=true` to the Claude child** — caps `--mcp-config` connects at 5s so a slow MCP server never delays session startup. Requires Claude CLI 2.1.89+. Set it explicitly in the bot's own env to override.
12
+ - **Pass `ENABLE_PROMPT_CACHING_1H=true` to the Claude child** — opts into the 1-hour prompt cache TTL, meaningfully reducing re-caching cost on long-lived threads that idle past the default 5-minute window. Requires Claude CLI 2.1.108+. Set it explicitly in the bot's own env to override.
13
+ - **Documented `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1`** as an opt-in hardening flag. When set on the bot's env it passes through to Claude, which strips the specific credential env vars `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_BEARER_TOKEN_BEDROCK`, and `GOOGLE_APPLICATION_CREDENTIALS` from any Bash, hook, or stdio MCP subprocess it spawns (empirically verified on CLI 2.1.116). Bot-specific vars like `PLATFORM_TOKEN` / `MATTERMOST_TOKEN` / `SLACK_BOT_TOKEN` pass through untouched. **Side effect:** the flag also forces Claude's permission mode to `default` and rejects `--dangerously-skip-permissions`; the bot now warns at startup if the flag is set alongside any platform configured with `skipPermissions: true`. Requires Claude CLI 2.1.83+.
14
+
15
+ ### Fixed
16
+ - **Stale Claude CLI version range in `CLAUDE.md`** — the docs said `>=2.0.74 <=2.0.76`, but the actual pin in `version-check.ts` has been `>=2.0.74 <2.2.0` since v1.x. Also bumped the "install a compatible version" hint in the runtime error from `@2.1.1` to `@2.1.116`.
17
+
18
+ ## [1.7.1] - 2026-04-21
19
+
20
+ ### Fixed
21
+ - **`maxSessions` cap could be exceeded under concurrent session starts** — `startSession()` passed the cap check synchronously but then awaited `createPost()` before committing the session to the map, so concurrent starts all read the same stale count and over-admitted. The integration test `"should reject new session when at capacity"` flaked at ~40% for weeks because of this. Pending starts are now tracked alongside committed sessions in the cap check. (#331)
22
+ - **Reject Claude accounts with both `home` and `apiKey` set** — the two are documented as mutually exclusive, but `AccountPool` silently preferred `home` when both were configured. Now the account is dropped with a warning so misconfiguration surfaces. (#330, thanks @shaders)
23
+ - **Tighten `reset_at` epoch regex in rate-limit detection** — the old pattern matched `"preset": N` and (more importantly) `reset_after=N`, a *relative* retry-after hint that would have been misread as an absolute epoch and pushed cooldown decades into the future. Added word-boundary anchors and regression tests. (#330)
24
+ - **Rate-limit emit guard now allows deadline extensions** — the boolean latch in `ClaudeCli` fired at most once per process, so a second rate-limit hit with a longer reset time couldn't widen `AccountPool.markCooling`'s extend-only window. Replaced with a numeric last-deadline tracker: repeat hits at the same severity still dedupe, but a later deadline re-emits. (#330)
25
+
8
26
  ## [1.7.0] - 2026-04-21
9
27
 
10
28
  ### Added
package/README.md CHANGED
@@ -34,6 +34,7 @@
34
34
  - **Git worktrees** - Isolate changes in separate branches
35
35
  - **File attachments** - Attach images, PDFs, and files for Claude to analyze
36
36
  - **Chrome automation** - Control Chrome browser for web tasks
37
+ - **Multi-account Claude (opt-in)** - Round-robin sessions across multiple Claude subscriptions or API keys with automatic rate-limit cooldown — see [Configuration](docs/CONFIGURATION.md#claude-accounts-optional-multi-account-mode)
37
38
 
38
39
  ## Quick Start
39
40
 
package/dist/index.js CHANGED
@@ -50038,7 +50038,7 @@ function validateClaudeCli() {
50038
50038
  version: result.version,
50039
50039
  compatible: false,
50040
50040
  message: `Claude CLI version ${result.version} is not compatible. Required: ${CLAUDE_CLI_VERSION_RANGE}
50041
- ` + `Install a compatible version: npm install -g @anthropic-ai/claude-code@2.1.1`,
50041
+ ` + `Install a compatible version: npm install -g @anthropic-ai/claude-code@2.1.116`,
50042
50042
  rawOutput: result.rawOutput ?? undefined
50043
50043
  };
50044
50044
  }
@@ -53561,8 +53561,13 @@ class AccountPool {
53561
53561
  const hasAuth = !!acc.home || !!acc.apiKey;
53562
53562
  if (!hasAuth) {
53563
53563
  log6.warn(`Claude account ${acc.id} has neither home nor apiKey — ignoring`);
53564
+ return false;
53565
+ }
53566
+ if (acc.home && acc.apiKey) {
53567
+ log6.warn(`Claude account ${acc.id} has both home and apiKey set — must choose one; ignoring`);
53568
+ return false;
53564
53569
  }
53565
- return hasAuth;
53570
+ return true;
53566
53571
  });
53567
53572
  this.byId = new Map(this.accounts.map((acc) => [acc.id, acc]));
53568
53573
  for (const acc of this.accounts) {
@@ -54215,7 +54220,7 @@ function extractResetAt(text, now) {
54215
54220
  };
54216
54221
  return now + value * unitMs[unit];
54217
54222
  }
54218
- const unix = text.match(/["']?reset(?:_at)?["']?\s*[:=]\s*(\d{10,13})/);
54223
+ const unix = text.match(/\breset(?:_at)?\b\s*["']?\s*[:=]\s*(\d{10,13})/);
54219
54224
  if (unix) {
54220
54225
  const raw = parseInt(unix[1], 10);
54221
54226
  return unix[1].length === 13 ? raw : raw * 1000;
@@ -54255,6 +54260,25 @@ function cleanupBrowserBridgeSockets() {
54255
54260
  log10.debug(`Browser bridge cleanup failed: ${err}`);
54256
54261
  }
54257
54262
  }
54263
+ function buildClaudeChildEnv(parentEnv, account) {
54264
+ const env = { ...parentEnv };
54265
+ if (env.MCP_CONNECTION_NONBLOCKING === undefined) {
54266
+ env.MCP_CONNECTION_NONBLOCKING = "true";
54267
+ }
54268
+ if (env.ENABLE_PROMPT_CACHING_1H === undefined) {
54269
+ env.ENABLE_PROMPT_CACHING_1H = "true";
54270
+ }
54271
+ if (account?.home) {
54272
+ env.HOME = account.home;
54273
+ env.USERPROFILE = account.home;
54274
+ delete env.ANTHROPIC_API_KEY;
54275
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
54276
+ } else if (account?.apiKey) {
54277
+ env.ANTHROPIC_API_KEY = account.apiKey;
54278
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
54279
+ }
54280
+ return env;
54281
+ }
54258
54282
  function isErrorResultEvent(event) {
54259
54283
  const ev = event;
54260
54284
  if (typeof ev.subtype === "string" && ev.subtype.startsWith("error"))
@@ -54272,7 +54296,7 @@ class ClaudeCli extends EventEmitter2 {
54272
54296
  statusFilePath = null;
54273
54297
  lastStatusData = null;
54274
54298
  stderrBuffer = "";
54275
- rateLimitEmitted = false;
54299
+ lastEmittedRateLimitDeadline = 0;
54276
54300
  log;
54277
54301
  constructor(options2) {
54278
54302
  super();
@@ -54324,7 +54348,7 @@ class ClaudeCli extends EventEmitter2 {
54324
54348
  if (this.process)
54325
54349
  throw new Error("Already running");
54326
54350
  this.stderrBuffer = "";
54327
- this.rateLimitEmitted = false;
54351
+ this.lastEmittedRateLimitDeadline = 0;
54328
54352
  cleanupBrowserBridgeSockets();
54329
54353
  const claudePath = getClaudePath();
54330
54354
  const args = [
@@ -54479,9 +54503,11 @@ class ClaudeCli extends EventEmitter2 {
54479
54503
  const hit = detectRateLimit(text);
54480
54504
  if (!hit.detected)
54481
54505
  return;
54482
- if (this.rateLimitEmitted)
54506
+ const newDeadline = cooldownDeadline(hit);
54507
+ const MIN_ADVANCE_MS = 60000;
54508
+ if (newDeadline - this.lastEmittedRateLimitDeadline < MIN_ADVANCE_MS)
54483
54509
  return;
54484
- this.rateLimitEmitted = true;
54510
+ this.lastEmittedRateLimitDeadline = newDeadline;
54485
54511
  this.log.warn(`Rate limit detected: ${hit.matched ?? "(no match text)"}`);
54486
54512
  this.emit("rate-limit", hit);
54487
54513
  }
@@ -54554,20 +54580,7 @@ class ClaudeCli extends EventEmitter2 {
54554
54580
  return true;
54555
54581
  }
54556
54582
  buildChildEnv() {
54557
- const account = this.options.account;
54558
- if (!account)
54559
- return process.env;
54560
- const env = { ...process.env };
54561
- if (account.home) {
54562
- env.HOME = account.home;
54563
- env.USERPROFILE = account.home;
54564
- delete env.ANTHROPIC_API_KEY;
54565
- delete env.CLAUDE_CODE_OAUTH_TOKEN;
54566
- } else if (account.apiKey) {
54567
- env.ANTHROPIC_API_KEY = account.apiKey;
54568
- delete env.CLAUDE_CODE_OAUTH_TOKEN;
54569
- }
54570
- return env;
54583
+ return buildClaudeChildEnv(process.env, this.options.account);
54571
54584
  }
54572
54585
  getMcpServerPath() {
54573
54586
  const __filename2 = fileURLToPath3(import.meta.url);
@@ -67216,6 +67229,11 @@ var sessionLog6 = createSessionLog(log26);
67216
67229
  function mutableSessions(ctx) {
67217
67230
  return ctx.state.sessions;
67218
67231
  }
67232
+ var pendingStartsCount = 0;
67233
+ function releasePendingStart() {
67234
+ if (pendingStartsCount > 0)
67235
+ pendingStartsCount--;
67236
+ }
67219
67237
  function mutablePostIndex(ctx) {
67220
67238
  return ctx.state.postIndex;
67221
67239
  }
@@ -67548,20 +67566,24 @@ async function startSession(options2, username, displayName, replyToPostId, plat
67548
67566
  if (!platform) {
67549
67567
  throw new Error(`Platform '${platformId}' not found. Call addPlatform() first.`);
67550
67568
  }
67551
- if (ctx.state.sessions.size >= ctx.config.maxSessions) {
67569
+ const activeOrPending = ctx.state.sessions.size + pendingStartsCount;
67570
+ if (activeOrPending >= ctx.config.maxSessions) {
67552
67571
  const formatter2 = platform.getFormatter();
67553
67572
  const tempSession = {
67554
67573
  platform,
67555
67574
  threadId: replyToPostId || "",
67556
67575
  sessionId: "temp"
67557
67576
  };
67558
- await post(tempSession, "warning", `${formatter2.formatBold("Too busy")} - ${ctx.state.sessions.size} sessions active. Please try again later.`);
67577
+ await post(tempSession, "warning", `${formatter2.formatBold("Too busy")} - ${activeOrPending} sessions active. Please try again later.`);
67559
67578
  return;
67560
67579
  }
67580
+ pendingStartsCount++;
67561
67581
  const startFormatter = platform.getFormatter();
67562
67582
  const startPost = await withErrorHandling(() => platform.createPost(startFormatter.formatItalic("Claude Threads session starting..."), replyToPostId), { action: "Create session post" });
67563
- if (!startPost)
67583
+ if (!startPost) {
67584
+ releasePendingStart();
67564
67585
  return;
67586
+ }
67565
67587
  const actualThreadId = replyToPostId || startPost.id;
67566
67588
  const sessionId = ctx.ops.getSessionId(platformId, actualThreadId);
67567
67589
  platform.sendTyping(actualThreadId);
@@ -67576,11 +67598,13 @@ async function startSession(options2, username, displayName, replyToPostId, plat
67576
67598
  const resolvedDir = resolve6(requestedDir);
67577
67599
  if (!existsSync11(resolvedDir)) {
67578
67600
  await platform.updatePost(startPost.id, `❌ Directory does not exist: ${formatter.formatCode(initialOptions.workingDir)}`);
67601
+ releasePendingStart();
67579
67602
  return;
67580
67603
  }
67581
67604
  const { statSync: statSync4 } = await import("fs");
67582
67605
  if (!statSync4(resolvedDir).isDirectory()) {
67583
67606
  await platform.updatePost(startPost.id, `❌ Not a directory: ${formatter.formatCode(initialOptions.workingDir)}`);
67607
+ releasePendingStart();
67584
67608
  return;
67585
67609
  }
67586
67610
  workingDir = resolvedDir;
@@ -67649,6 +67673,7 @@ ${CHAT_PLATFORM_PROMPT}`;
67649
67673
  workingDir: ctx.config.workingDir
67650
67674
  });
67651
67675
  mutableSessions(ctx).set(sessionId, session);
67676
+ releasePendingStart();
67652
67677
  ctx.ops.registerPost(startPost.id, actualThreadId);
67653
67678
  ctx.ops.emitSessionAdd(session);
67654
67679
  sessionLog6(session).info(`▶ Session started by @${username}`);
@@ -79986,6 +80011,15 @@ async function startWithoutDaemon() {
79986
80011
  console.error("");
79987
80012
  process.exit(1);
79988
80013
  }
80014
+ if (process.env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB === "1") {
80015
+ const hasSkipPermissionPlatform = config.platforms.some((p) => p.skipPermissions === true);
80016
+ if (hasSkipPermissionPlatform) {
80017
+ console.error(red(" ⚠️ CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 is set but a platform has skipPermissions: true."));
80018
+ console.error(dim(" Claude CLI will force permissionMode: default and reject --dangerously-skip-permissions."));
80019
+ console.error(dim(" Either unset the env var or set skipPermissions: false on all platforms."));
80020
+ console.error("");
80021
+ }
80022
+ }
79989
80023
  let triggerShutdown = null;
79990
80024
  const updateState = loadUpdateState();
79991
80025
  const restoredSettings = updateState.justUpdated ? updateState.runtimeSettings : undefined;
@@ -53526,6 +53526,11 @@ function detectRateLimit(text, now = Date.now()) {
53526
53526
  const resetAtEpochMs = extractResetAt(text, now);
53527
53527
  return { detected: true, matched, resetAtEpochMs };
53528
53528
  }
53529
+ function cooldownDeadline(hit, now = Date.now()) {
53530
+ if (!hit.detected)
53531
+ return now;
53532
+ return hit.resetAtEpochMs ?? now + DEFAULT_COOLDOWN_MS;
53533
+ }
53529
53534
  function extractResetAt(text, now) {
53530
53535
  const relative = text.match(/(?:retry[_\s-]?after|resets?\s+in)\s+(\d+)\s*(second|minute|hour|day)s?/i);
53531
53536
  if (relative) {
@@ -53539,7 +53544,7 @@ function extractResetAt(text, now) {
53539
53544
  };
53540
53545
  return now + value * unitMs[unit];
53541
53546
  }
53542
- const unix = text.match(/["']?reset(?:_at)?["']?\s*[:=]\s*(\d{10,13})/);
53547
+ const unix = text.match(/\breset(?:_at)?\b\s*["']?\s*[:=]\s*(\d{10,13})/);
53543
53548
  if (unix) {
53544
53549
  const raw = parseInt(unix[1], 10);
53545
53550
  return unix[1].length === 13 ? raw : raw * 1000;
@@ -53579,6 +53584,25 @@ function cleanupBrowserBridgeSockets() {
53579
53584
  log7.debug(`Browser bridge cleanup failed: ${err}`);
53580
53585
  }
53581
53586
  }
53587
+ function buildClaudeChildEnv(parentEnv, account) {
53588
+ const env = { ...parentEnv };
53589
+ if (env.MCP_CONNECTION_NONBLOCKING === undefined) {
53590
+ env.MCP_CONNECTION_NONBLOCKING = "true";
53591
+ }
53592
+ if (env.ENABLE_PROMPT_CACHING_1H === undefined) {
53593
+ env.ENABLE_PROMPT_CACHING_1H = "true";
53594
+ }
53595
+ if (account?.home) {
53596
+ env.HOME = account.home;
53597
+ env.USERPROFILE = account.home;
53598
+ delete env.ANTHROPIC_API_KEY;
53599
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
53600
+ } else if (account?.apiKey) {
53601
+ env.ANTHROPIC_API_KEY = account.apiKey;
53602
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
53603
+ }
53604
+ return env;
53605
+ }
53582
53606
  function isErrorResultEvent(event) {
53583
53607
  const ev = event;
53584
53608
  if (typeof ev.subtype === "string" && ev.subtype.startsWith("error"))
@@ -53596,7 +53620,7 @@ class ClaudeCli extends EventEmitter2 {
53596
53620
  statusFilePath = null;
53597
53621
  lastStatusData = null;
53598
53622
  stderrBuffer = "";
53599
- rateLimitEmitted = false;
53623
+ lastEmittedRateLimitDeadline = 0;
53600
53624
  log;
53601
53625
  constructor(options2) {
53602
53626
  super();
@@ -53648,7 +53672,7 @@ class ClaudeCli extends EventEmitter2 {
53648
53672
  if (this.process)
53649
53673
  throw new Error("Already running");
53650
53674
  this.stderrBuffer = "";
53651
- this.rateLimitEmitted = false;
53675
+ this.lastEmittedRateLimitDeadline = 0;
53652
53676
  cleanupBrowserBridgeSockets();
53653
53677
  const claudePath = getClaudePath();
53654
53678
  const args = [
@@ -53803,9 +53827,11 @@ class ClaudeCli extends EventEmitter2 {
53803
53827
  const hit = detectRateLimit(text);
53804
53828
  if (!hit.detected)
53805
53829
  return;
53806
- if (this.rateLimitEmitted)
53830
+ const newDeadline = cooldownDeadline(hit);
53831
+ const MIN_ADVANCE_MS = 60000;
53832
+ if (newDeadline - this.lastEmittedRateLimitDeadline < MIN_ADVANCE_MS)
53807
53833
  return;
53808
- this.rateLimitEmitted = true;
53834
+ this.lastEmittedRateLimitDeadline = newDeadline;
53809
53835
  this.log.warn(`Rate limit detected: ${hit.matched ?? "(no match text)"}`);
53810
53836
  this.emit("rate-limit", hit);
53811
53837
  }
@@ -53878,20 +53904,7 @@ class ClaudeCli extends EventEmitter2 {
53878
53904
  return true;
53879
53905
  }
53880
53906
  buildChildEnv() {
53881
- const account = this.options.account;
53882
- if (!account)
53883
- return process.env;
53884
- const env = { ...process.env };
53885
- if (account.home) {
53886
- env.HOME = account.home;
53887
- env.USERPROFILE = account.home;
53888
- delete env.ANTHROPIC_API_KEY;
53889
- delete env.CLAUDE_CODE_OAUTH_TOKEN;
53890
- } else if (account.apiKey) {
53891
- env.ANTHROPIC_API_KEY = account.apiKey;
53892
- delete env.CLAUDE_CODE_OAUTH_TOKEN;
53893
- }
53894
- return env;
53907
+ return buildClaudeChildEnv(process.env, this.options.account);
53895
53908
  }
53896
53909
  getMcpServerPath() {
53897
53910
  const __filename2 = fileURLToPath3(import.meta.url);
@@ -72,6 +72,33 @@ platforms:
72
72
  | `allowedUsers` | No | List of Slack usernames |
73
73
  | `skipPermissions` | No | Auto-approve actions (default: `false`) |
74
74
 
75
+ ## Claude Accounts (optional, multi-account mode)
76
+
77
+ By default every session spawns `claude` with the bot's own `process.env`, so they all share one subscription's token budget. Add a `claudeAccounts` block to spread load across multiple accounts — the bot round-robins new sessions across the pool and automatically skips accounts in rate-limit cooldown. Omit the block entirely to stay in single-account mode (unchanged behavior).
78
+
79
+ ```yaml
80
+ claudeAccounts:
81
+ # OAuth accounts — prepare each HOME first with `HOME=<path> claude login`
82
+ - id: primary
83
+ home: /home/bot/.claude-accounts/primary
84
+ - id: backup
85
+ displayName: Backup (Pro)
86
+ home: /home/bot/.claude-accounts/backup
87
+
88
+ # API-key billed
89
+ - id: shared-api
90
+ apiKey: sk-ant-api03-xxxxxxxx...
91
+ ```
92
+
93
+ | Setting | Required | Description |
94
+ |---------|----------|-------------|
95
+ | `id` | Yes | Stable identifier used in logs, UI, and persisted session state |
96
+ | `home` | One of | Alternate `$HOME` containing `.claude/.credentials.json` from a prior `HOME=<path> claude login`. For OAuth Pro/Max subscriptions. Session history also lives here, so resumed sessions pick the same account. |
97
+ | `apiKey` | One of | Anthropic API key. Billed against that key; session history stays under the bot's default `HOME`. |
98
+ | `displayName` | No | Human-readable label in UI (defaults to `id`) |
99
+
100
+ Exactly one of `home` or `apiKey` should be set per account. Persisted sessions record which account they ran under and resume on the same one.
101
+
75
102
  ## Environment Variables
76
103
 
77
104
  | Variable | Description | Default |
@@ -80,6 +107,19 @@ platforms:
80
107
  | `SESSION_TIMEOUT_MS` | Idle timeout in milliseconds | `1800000` (30 min) |
81
108
  | `NO_UPDATE_NOTIFIER` | Disable update checks | - |
82
109
  | `DEBUG` | Enable verbose logging | - |
110
+ | `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` | Strip `ANTHROPIC_*` / `AWS_*_TOKEN` / `CLAUDE_CODE_OAUTH_TOKEN` / `GOOGLE_APPLICATION_CREDENTIALS` etc. from Bash, hook, and stdio-MCP subprocesses Claude spawns. Bot-specific vars like `PLATFORM_TOKEN` pass through. **Also forces permission mode to `default`** — `--dangerously-skip-permissions` will be rejected. Requires Claude CLI 2.1.83+. | - |
111
+
112
+ ### Forwarded to Claude CLI automatically
113
+
114
+ The bot sets two tuning flags on the Claude child process when they aren't
115
+ already present in the bot's environment:
116
+
117
+ | Variable | Effect | Requires |
118
+ |----------|--------|----------|
119
+ | `MCP_CONNECTION_NONBLOCKING=true` | Caps `--mcp-config` connects at 5s so a slow MCP server never delays startup | Claude CLI 2.1.89+ |
120
+ | `ENABLE_PROMPT_CACHING_1H=true` | Opts into 1-hour prompt cache TTL, cutting re-caching cost on long-lived threads | Claude CLI 2.1.108+ |
121
+
122
+ Export either with a different value in the bot's own env to disable.
83
123
 
84
124
  ## CLI Options
85
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "1.7.0",
3
+ "version": "1.8.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",