claude-threads 1.26.0 → 1.27.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.
@@ -47754,7 +47754,11 @@ function truncateWithEllipsis(str, maxLength) {
47754
47754
  return str.substring(0, maxLength) + "...";
47755
47755
  }
47756
47756
  function escapeCodeBlockContent(content) {
47757
- return content.replace(/```/g, "` ``");
47757
+ let result = content;
47758
+ while (result.includes("```")) {
47759
+ result = result.replace(/```/g, "` ``");
47760
+ }
47761
+ return result;
47758
47762
  }
47759
47763
  // src/operations/tool-formatters/registry.ts
47760
47764
  class ToolFormatterRegistry {
@@ -48553,6 +48557,15 @@ var fileToolsFormatter = {
48553
48557
  }
48554
48558
  };
48555
48559
  // src/operations/tool-formatters/bash-tools.ts
48560
+ var PERMISSION_COMMAND_MAX = 1500;
48561
+ function truncateAtCodePoint(text, max) {
48562
+ if (text.length <= max)
48563
+ return { text, truncated: false };
48564
+ const points = Array.from(text);
48565
+ if (points.length <= max)
48566
+ return { text, truncated: false };
48567
+ return { text: points.slice(0, max).join(""), truncated: true };
48568
+ }
48556
48569
  var bashToolFormatter = {
48557
48570
  toolNames: ["Bash"],
48558
48571
  format(toolName, input, options) {
@@ -48565,9 +48578,13 @@ var bashToolFormatter = {
48565
48578
  }
48566
48579
  const truncated = cmd.length > maxCommandLength;
48567
48580
  const displayCmd = cmd.substring(0, maxCommandLength);
48581
+ const permission = truncateAtCodePoint(cmd, PERMISSION_COMMAND_MAX);
48582
+ const permissionCmd = escapeCodeBlockContent(permission.text) + (permission.truncated ? `
48583
+ [... truncated]` : "");
48568
48584
  return {
48569
48585
  display: `\uD83D\uDCBB ${formatter.formatBold("Bash")} ${formatter.formatCode(displayCmd + (truncated ? "..." : ""))}`,
48570
- permissionText: `\uD83D\uDCBB ${formatter.formatBold("Bash")} ${formatter.formatCode(cmd.substring(0, 100) + (cmd.length >= 100 ? "..." : ""))}`,
48586
+ permissionText: `\uD83D\uDCBB ${formatter.formatBold("Bash")}
48587
+ ${formatter.formatCodeBlock(permissionCmd, "bash")}`,
48571
48588
  isDestructive: true
48572
48589
  };
48573
48590
  }
@@ -49632,6 +49649,13 @@ function convertMarkdownTablesToSlack(content) {
49632
49649
  `);
49633
49650
  });
49634
49651
  }
49652
+ var DCM_THREAD_PREFIX = "dcm:";
49653
+ function isDcmThreadId(threadId) {
49654
+ return !!threadId && threadId.startsWith(DCM_THREAD_PREFIX);
49655
+ }
49656
+ function resolvePostThreadId(threadId) {
49657
+ return isDcmThreadId(threadId) ? undefined : threadId;
49658
+ }
49635
49659
 
49636
49660
  // src/version.ts
49637
49661
  import { readFileSync, existsSync } from "fs";
@@ -57732,6 +57756,7 @@ var log18 = createLogger("memory");
57732
57756
  // src/session/lifecycle.ts
57733
57757
  var log19 = createLogger("lifecycle");
57734
57758
  var sessionLog3 = createSessionLog(log19);
57759
+ var _inFlightSessionStarts = new Map;
57735
57760
  var CHAT_PLATFORM_PROMPT = generateChatPlatformPrompt();
57736
57761
  // src/update-notifier.ts
57737
57762
  var import_semver2 = __toESM(require_semver2(), 1);
@@ -58050,7 +58075,7 @@ async function uploadFileMattermost(args) {
58050
58075
  const postBody = {
58051
58076
  channel_id: channelId,
58052
58077
  message: caption ?? "",
58053
- root_id: threadId,
58078
+ root_id: resolvePostThreadId(threadId),
58054
58079
  file_ids: [fileInfo.id]
58055
58080
  };
58056
58081
  log28.debug(`POST /posts (file_ids=[${fileInfo.id}])`);
@@ -58106,7 +58131,7 @@ async function createPost(config3, channelId, message, rootId) {
58106
58131
  return mattermostApi(config3, "POST", "/posts", {
58107
58132
  channel_id: channelId,
58108
58133
  message,
58109
- root_id: rootId
58134
+ root_id: resolvePostThreadId(rootId)
58110
58135
  });
58111
58136
  }
58112
58137
  async function updatePostRaw(config3, postId, message) {
@@ -58588,7 +58613,7 @@ async function uploadFileSlack(args) {
58588
58613
  const step3Body = {
58589
58614
  files: [{ id: fileId, title: caption ?? filename }],
58590
58615
  channel_id: channelId,
58591
- thread_ts: threadTs
58616
+ thread_ts: resolvePostThreadId(threadTs)
58592
58617
  };
58593
58618
  if (caption !== undefined) {
58594
58619
  step3Body.initial_comment = caption;
@@ -58687,7 +58712,7 @@ class SlackMcpPlatformApi {
58687
58712
  const response = await slackApi("chat.postMessage", this.config.botToken, {
58688
58713
  channel: this.config.channelId,
58689
58714
  text: message,
58690
- thread_ts: threadTs || this.config.threadTs,
58715
+ thread_ts: resolvePostThreadId(threadTs || this.config.threadTs),
58691
58716
  mrkdwn: true
58692
58717
  });
58693
58718
  const messageTs = response.ts;
@@ -59631,6 +59656,12 @@ async function resolveLatestThreadPost(cfg) {
59631
59656
  if (!cfg.sessionThreadId) {
59632
59657
  return { ok: false, reason: "no session thread to react in — pass a permalink URL instead" };
59633
59658
  }
59659
+ if (isDcmThreadId(cfg.sessionThreadId)) {
59660
+ return {
59661
+ ok: false,
59662
+ reason: "this session runs in direct channel mode (no thread of its own) — pass a permalink URL to the target message instead"
59663
+ };
59664
+ }
59634
59665
  let thread;
59635
59666
  try {
59636
59667
  thread = await cfg.api.readThread(cfg.sessionThreadId);
@@ -59732,6 +59763,12 @@ async function handleListThreadWith(args, cfg) {
59732
59763
  reason: "no session thread to read — pass a permalink URL instead"
59733
59764
  };
59734
59765
  }
59766
+ if (isDcmThreadId(cfg.sessionThreadId)) {
59767
+ return {
59768
+ ok: false,
59769
+ reason: "this session runs in direct channel mode (no thread of its own) — use read_channel_history, or pass a permalink URL"
59770
+ };
59771
+ }
59735
59772
  rootId = cfg.sessionThreadId;
59736
59773
  }
59737
59774
  const limit = clampThreadLimit(args.max_messages);
@@ -129,6 +129,9 @@ stickyMessage:
129
129
  | `outboundFiles` | No | `send_file` settings: `{ enabled, maxBytes }` (defaults: enabled `true`, `maxBytes` 100 MB) |
130
130
  | `sessionHeader` | No | Per-thread header visibility: `full` (default) / `minimal` (status bar only) / `hidden` (no header post) |
131
131
  | `stickyMessage` | No | Channel sticky visibility: `full` (default) / `minimal` (status bar only) / `hidden` (no sticky, no bumping) |
132
+ | `directChannelMode` | No | Direct channel mode: the whole channel is one session, and the bot replies with top-level channel posts instead of thread replies. `true` for defaults, or an options object (`respondTo`). See [Direct Channel Mode](#direct-channel-mode). |
133
+ | `approvals` | No | Who may answer tool-permission prompts and other reaction gates: `owner` (session participants) or `all_users` (everyone on `allowedUsers`). Unset keeps the historical default per mode — `all_users` for thread sessions, `owner` for direct channel mode. See [Approvals](#approvals). |
134
+ | `directMessages` | No | Mattermost only: DM auto-discovery. A direct message from a user on `allowedUsers` spawns a derived direct-channel-mode instance for that DM conversation — no per-DM entry needed. See [DM auto-discovery](#dm-auto-discovery). |
132
135
 
133
136
  ### Slack
134
137
 
@@ -147,6 +150,88 @@ stickyMessage:
147
150
  | `outboundFiles` | No | `send_file` settings: `{ enabled, maxBytes }` (defaults: enabled `true`, `maxBytes` 100 MB) |
148
151
  | `sessionHeader` | No | Per-thread header visibility: `full` (default) / `minimal` (status bar only) / `hidden` (no header post) |
149
152
  | `stickyMessage` | No | Channel sticky visibility: `full` (default) / `minimal` (status bar only) / `hidden` (no sticky, no bumping) |
153
+ | `directChannelMode` | No | Direct channel mode: the whole channel is one session, and the bot replies with top-level channel posts instead of thread replies. `true` for defaults, or an options object (`respondTo`). See [Direct Channel Mode](#direct-channel-mode). |
154
+ | `approvals` | No | Who may answer tool-permission prompts and other reaction gates: `owner` (session participants) or `all_users` (everyone on `allowedUsers`). Unset keeps the historical default per mode — `all_users` for thread sessions, `owner` for direct channel mode. See [Approvals](#approvals). |
155
+
156
+ ### Direct Channel Mode
157
+
158
+ `directChannelMode: true` turns the configured channel into a single, always-on conversation with the bot:
159
+
160
+ - Every message in the channel reaches the bot — no `@mention` required (messages starting with `@someone-else` are still treated as side conversations and ignored).
161
+ - The bot replies with **top-level channel posts** instead of thread replies, so the channel reads like a plain chat.
162
+ - Only **one session** exists per platform instance; internally it is keyed by the synthetic thread id `dcm:<platform id>`, so persistence, resume after bot restarts, emoji permission prompts, and `!commands` all work exactly as in thread sessions.
163
+ - Messages posted inside any thread of the channel are routed to the same session.
164
+
165
+ This is the mode to use for a dedicated channel with the bot (see issue #315). For shared channels where multiple parallel sessions are wanted, keep the default thread-per-session behavior.
166
+
167
+ The long form configures how the shared channel behaves:
168
+
169
+ ```yaml
170
+ directChannelMode:
171
+ respondTo: all_messages # or: mention
172
+ ```
173
+
174
+ | Option | Values | Default | Meaning |
175
+ |--------|--------|---------|---------|
176
+ | `respondTo` | `all_messages` / `mention` | `all_messages` | `all_messages`: every message from an allowed user reaches the bot. `mention`: the bot only reacts to messages that @mention it — useful when several people discuss in the channel and the bot should not join every exchange. Backed by the per-session quiet-mode flag, so `!mentions` toggles it at runtime. |
177
+
178
+ Who may approve tool use in the channel is controlled by the platform-level [`approvals`](#approvals) option (DCM defaults to `owner`).
179
+
180
+ ### Approvals
181
+
182
+ The platform-level `approvals` option controls who may answer tool-permission prompts (👍/✅/👎) and the other reaction gates — plan approvals, question answers, and session resume:
183
+
184
+ - `owner` — the session participants: the starter plus explicitly `!invite`d users.
185
+ - `all_users` — everyone on the platform's `allowedUsers` list.
186
+
187
+ Unset keeps the historical default per mode, so existing setups are unaffected: thread sessions behave as before (`all_users`), direct channel mode defaults to the safer `owner`. Setting the option applies it to every session of that platform entry — including classic thread sessions, where `approvals: owner` is an opt-in hardening.
188
+
189
+ Under effective `owner` mode the scoping is enforced consistently across every path, so the boundary cannot be talked around: the text alternatives (`!approve`, message-based resume) apply the same participant check as their reaction counterparts, and the owner-gated session commands (`!invite`, `!kick`, `!cd`, `!permissions`, …) additionally require the caller to be a session participant — a platform-allowlisted non-participant can neither approve directly nor `!invite` themselves into the approval set.
190
+
191
+ The approval set is fixed when the Claude CLI is spawned; a later `!invite` extends message access immediately but reaches the approval set on the next CLI respawn (e.g. via `!cd` or `!permissions`).
192
+
193
+ ### Direct messages (DM)
194
+
195
+ **Mattermost only.** A Mattermost DM is just a private channel with its own id, so a bot DM conversation is direct channel mode pointed at that id — no separate feature needed. (This recipe does NOT work on Slack: Socket Mode distributes event envelopes across an app's active connections, so a second platform entry sharing the same app credentials can consume and discard events meant for the other entry. Slack DM support needs a single-connection, channel-aware implementation.)
196
+
197
+ ```yaml
198
+ platforms:
199
+ - id: mattermost-dm
200
+ type: mattermost
201
+ url: https://chat.example.com
202
+ token: your-bot-token # same bot token as the main entry
203
+ channelId: <dm-channel-id>
204
+ botName: claude-code
205
+ directChannelMode: true
206
+ stickyMessage: hidden # a sticky makes little sense in a DM
207
+ allowedUsers: [you]
208
+ ```
209
+
210
+ Get the DM channel id with one API call: `POST /api/v4/channels/direct` with `["<bot-user-id>", "<your-user-id>"]` — the returned `id` is stable.
211
+
212
+ #### DM auto-discovery
213
+
214
+ Maintaining one static entry per DM conversation does not scale to a team. With `directMessages: true` on a Mattermost entry, anyone on that entry's `allowedUsers` can simply DM the bot "out of the cold":
215
+
216
+ ```yaml
217
+ platforms:
218
+ - id: mattermost-main
219
+ type: mattermost
220
+ # ... regular entry ...
221
+ directMessages: true
222
+ ```
223
+
224
+ On first contact the bot spawns a derived platform instance for that DM channel — a clone of the parent entry in direct channel mode, sticky hidden, `allowedUsers` scoped to the DM partner (which, with the DCM `approvals` default of `owner`, also scopes tool-permission prompts to that person). DM sessions persist and are reconstructed after a bot restart. Users not on the parent's `allowedUsers` are ignored.
225
+
226
+ Details and caveats:
227
+
228
+ - **Lifecycle**: when a DM session leaves the registry its derived instance and connection are torn down (after a short grace period). After an **idle timeout** the session is persisted — the next DM re-discovers the channel and resumes the conversation. After **`!stop`** the session is deliberately unpersisted — the next DM starts fresh. Instances that never produce a session are reaped after a TTL, so instances do not accumulate over uptime.
229
+ - **Multiple entries, one bot account**: the first entry to discover a DM channel owns it — other `directMessages: true` entries stay out, so the bot never double-replies.
230
+ - **Empty `allowedUsers`**: consistent with the rest of the bot, an empty list means *everyone* — combined with `directMessages: true` that is every user on the server who can DM the bot. Leave it empty only on servers you trust.
231
+ - **Renaming a platform entry** strands its persisted DM sessions (as it does any persisted session referencing the old id); they are skipped with a warning.
232
+ - Mattermost only — see the note above for why the multi-connection approach cannot work on Slack.
233
+
234
+ Limitations: the thread-context prompt ("include previous messages?") is skipped — there is no thread history to offer — and the `list_thread` MCP tool cannot resolve the synthetic session id (use `read_channel_history` instead).
150
235
 
151
236
  ### Permission Modes
152
237
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "1.26.0",
3
+ "version": "1.27.0",
4
4
  "description": "Run Claude Code from Slack or Mattermost. Sessions stream live into threads where your whole team can watch and steer.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -13,9 +13,9 @@
13
13
  "dev": "bun --watch src/index.ts",
14
14
  "build": "bun build src/index.ts --outdir dist --target node && bun build src/mcp/mcp-server.ts --outdir dist/mcp --target node && bun build src/statusline/writer.ts --outdir dist/statusline --target node",
15
15
  "start": "bun dist/index.js",
16
- "test": "bun test src/",
17
- "test:watch": "bun test src/ --watch",
18
- "test:coverage": "bun test src/ --coverage",
16
+ "test": "bun test src/ --timeout 15000",
17
+ "test:watch": "bun test src/ --watch --timeout 15000",
18
+ "test:coverage": "bun test src/ --coverage --timeout 15000",
19
19
  "test:integration:setup": "docker compose -f tests/integration/docker/docker-compose.yml up -d && bun run tests/integration/setup/wait-for-mattermost.ts && bun run tests/integration/setup/setup-mattermost.ts",
20
20
  "test:integration:run": "INTEGRATION_TEST=1 bun test tests/integration/suites --timeout 120000",
21
21
  "test:integration": "bun run test:integration:setup && bun run test:integration:run",
@@ -111,7 +111,7 @@
111
111
  },
112
112
  "lint-staged": {
113
113
  "*.ts": "eslint --fix",
114
- "*.test.ts": "bun test --bail",
114
+ "*.test.ts": "bun test --bail --timeout 15000",
115
115
  "*.{ts,tsx}": "bash -c 'tsc --noEmit'",
116
116
  "website/**/*.{html,css,js}": "prettier --write"
117
117
  },