claude-threads 1.7.0 → 1.7.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
@@ -5,6 +5,14 @@ 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.7.1] - 2026-04-21
9
+
10
+ ### Fixed
11
+ - **`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)
12
+ - **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)
13
+ - **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)
14
+ - **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)
15
+
8
16
  ## [1.7.0] - 2026-04-21
9
17
 
10
18
  ### 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
@@ -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;
@@ -54272,7 +54277,7 @@ class ClaudeCli extends EventEmitter2 {
54272
54277
  statusFilePath = null;
54273
54278
  lastStatusData = null;
54274
54279
  stderrBuffer = "";
54275
- rateLimitEmitted = false;
54280
+ lastEmittedRateLimitDeadline = 0;
54276
54281
  log;
54277
54282
  constructor(options2) {
54278
54283
  super();
@@ -54324,7 +54329,7 @@ class ClaudeCli extends EventEmitter2 {
54324
54329
  if (this.process)
54325
54330
  throw new Error("Already running");
54326
54331
  this.stderrBuffer = "";
54327
- this.rateLimitEmitted = false;
54332
+ this.lastEmittedRateLimitDeadline = 0;
54328
54333
  cleanupBrowserBridgeSockets();
54329
54334
  const claudePath = getClaudePath();
54330
54335
  const args = [
@@ -54479,9 +54484,11 @@ class ClaudeCli extends EventEmitter2 {
54479
54484
  const hit = detectRateLimit(text);
54480
54485
  if (!hit.detected)
54481
54486
  return;
54482
- if (this.rateLimitEmitted)
54487
+ const newDeadline = cooldownDeadline(hit);
54488
+ const MIN_ADVANCE_MS = 60000;
54489
+ if (newDeadline - this.lastEmittedRateLimitDeadline < MIN_ADVANCE_MS)
54483
54490
  return;
54484
- this.rateLimitEmitted = true;
54491
+ this.lastEmittedRateLimitDeadline = newDeadline;
54485
54492
  this.log.warn(`Rate limit detected: ${hit.matched ?? "(no match text)"}`);
54486
54493
  this.emit("rate-limit", hit);
54487
54494
  }
@@ -67216,6 +67223,11 @@ var sessionLog6 = createSessionLog(log26);
67216
67223
  function mutableSessions(ctx) {
67217
67224
  return ctx.state.sessions;
67218
67225
  }
67226
+ var pendingStartsCount = 0;
67227
+ function releasePendingStart() {
67228
+ if (pendingStartsCount > 0)
67229
+ pendingStartsCount--;
67230
+ }
67219
67231
  function mutablePostIndex(ctx) {
67220
67232
  return ctx.state.postIndex;
67221
67233
  }
@@ -67548,20 +67560,24 @@ async function startSession(options2, username, displayName, replyToPostId, plat
67548
67560
  if (!platform) {
67549
67561
  throw new Error(`Platform '${platformId}' not found. Call addPlatform() first.`);
67550
67562
  }
67551
- if (ctx.state.sessions.size >= ctx.config.maxSessions) {
67563
+ const activeOrPending = ctx.state.sessions.size + pendingStartsCount;
67564
+ if (activeOrPending >= ctx.config.maxSessions) {
67552
67565
  const formatter2 = platform.getFormatter();
67553
67566
  const tempSession = {
67554
67567
  platform,
67555
67568
  threadId: replyToPostId || "",
67556
67569
  sessionId: "temp"
67557
67570
  };
67558
- await post(tempSession, "warning", `${formatter2.formatBold("Too busy")} - ${ctx.state.sessions.size} sessions active. Please try again later.`);
67571
+ await post(tempSession, "warning", `${formatter2.formatBold("Too busy")} - ${activeOrPending} sessions active. Please try again later.`);
67559
67572
  return;
67560
67573
  }
67574
+ pendingStartsCount++;
67561
67575
  const startFormatter = platform.getFormatter();
67562
67576
  const startPost = await withErrorHandling(() => platform.createPost(startFormatter.formatItalic("Claude Threads session starting..."), replyToPostId), { action: "Create session post" });
67563
- if (!startPost)
67577
+ if (!startPost) {
67578
+ releasePendingStart();
67564
67579
  return;
67580
+ }
67565
67581
  const actualThreadId = replyToPostId || startPost.id;
67566
67582
  const sessionId = ctx.ops.getSessionId(platformId, actualThreadId);
67567
67583
  platform.sendTyping(actualThreadId);
@@ -67576,11 +67592,13 @@ async function startSession(options2, username, displayName, replyToPostId, plat
67576
67592
  const resolvedDir = resolve6(requestedDir);
67577
67593
  if (!existsSync11(resolvedDir)) {
67578
67594
  await platform.updatePost(startPost.id, `❌ Directory does not exist: ${formatter.formatCode(initialOptions.workingDir)}`);
67595
+ releasePendingStart();
67579
67596
  return;
67580
67597
  }
67581
67598
  const { statSync: statSync4 } = await import("fs");
67582
67599
  if (!statSync4(resolvedDir).isDirectory()) {
67583
67600
  await platform.updatePost(startPost.id, `❌ Not a directory: ${formatter.formatCode(initialOptions.workingDir)}`);
67601
+ releasePendingStart();
67584
67602
  return;
67585
67603
  }
67586
67604
  workingDir = resolvedDir;
@@ -67649,6 +67667,7 @@ ${CHAT_PLATFORM_PROMPT}`;
67649
67667
  workingDir: ctx.config.workingDir
67650
67668
  });
67651
67669
  mutableSessions(ctx).set(sessionId, session);
67670
+ releasePendingStart();
67652
67671
  ctx.ops.registerPost(startPost.id, actualThreadId);
67653
67672
  ctx.ops.emitSessionAdd(session);
67654
67673
  sessionLog6(session).info(`▶ Session started by @${username}`);
@@ -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;
@@ -53596,7 +53601,7 @@ class ClaudeCli extends EventEmitter2 {
53596
53601
  statusFilePath = null;
53597
53602
  lastStatusData = null;
53598
53603
  stderrBuffer = "";
53599
- rateLimitEmitted = false;
53604
+ lastEmittedRateLimitDeadline = 0;
53600
53605
  log;
53601
53606
  constructor(options2) {
53602
53607
  super();
@@ -53648,7 +53653,7 @@ class ClaudeCli extends EventEmitter2 {
53648
53653
  if (this.process)
53649
53654
  throw new Error("Already running");
53650
53655
  this.stderrBuffer = "";
53651
- this.rateLimitEmitted = false;
53656
+ this.lastEmittedRateLimitDeadline = 0;
53652
53657
  cleanupBrowserBridgeSockets();
53653
53658
  const claudePath = getClaudePath();
53654
53659
  const args = [
@@ -53803,9 +53808,11 @@ class ClaudeCli extends EventEmitter2 {
53803
53808
  const hit = detectRateLimit(text);
53804
53809
  if (!hit.detected)
53805
53810
  return;
53806
- if (this.rateLimitEmitted)
53811
+ const newDeadline = cooldownDeadline(hit);
53812
+ const MIN_ADVANCE_MS = 60000;
53813
+ if (newDeadline - this.lastEmittedRateLimitDeadline < MIN_ADVANCE_MS)
53807
53814
  return;
53808
- this.rateLimitEmitted = true;
53815
+ this.lastEmittedRateLimitDeadline = newDeadline;
53809
53816
  this.log.warn(`Rate limit detected: ${hit.matched ?? "(no match text)"}`);
53810
53817
  this.emit("rate-limit", hit);
53811
53818
  }
@@ -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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "1.7.0",
3
+ "version": "1.7.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",