claude-threads 1.31.1 → 1.32.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,21 @@ 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
+ ## [Unreleased]
9
+
10
+ ## [1.32.0] - 2026-09-01
11
+
12
+ ### Added
13
+ - **Slack: shared event source — one Socket Mode connection per app** (#502, thanks @kaza). Slack round-robins Socket Mode envelopes across all of an app's open connections, so a second `SlackClient` on the same app token silently steals events from the first. Now exactly one client (the parent) owns the socket; other clients register as secondaries and receive their channels' events injected by the parent — Web API calls stay independent per instance. The parent mirrors connection state onto secondaries (idempotently re-armed across disconnects), and on reconnect, missed-message recovery runs for the parent and every registered secondary. Zero behavior change for existing single-channel configs; this is the mechanism that unblocks DM auto-discovery on Slack and other multi-channel consumers.
14
+
15
+ ## [1.31.2] - 2026-08-30
16
+
17
+ ### Added
18
+ - **The approval posture of a routine or watch is now visible and changeable after creation.** `!routines` / `!watches` listings show each item's posture inline (👍 approvals · ✅ autonomous), and a new owner-gated `!routines approval <n> on|off` / `!watches approval <n> on|off` flips it — `off` makes an item run autonomously, `on` restores per-action approval. Turning a watch autonomous is the same sensitive choice the creation card gates behind the owner and an explicit ✅, so the flip command is owner-gated too; the safe approvals-required posture stays the default for older data.
19
+
20
+ ### Security
21
+ - **Active-session thread-id lookups are now scoped by `platformId`**, completing the cross-platform privacy boundary that 1.31.0/1.31.1 established for the persisted store. `SessionRegistry.findByThreadId` takes an optional `platformId` and resolves O(1) against the composite key when given; the message router (`handleMessage`) and the in-session authorization check (`isUserAllowedInSession`) now pass it, so a thread id that collides across platforms can no longer resolve to — or authorize a user against — another platform's *active* session, and the router and auth check always agree on which session a message belongs to. Defense-in-depth: real Mattermost (26-char) and Slack (dotted-ts) ids don't collide today.
22
+
8
23
  ## [1.31.1] - 2026-08-29
9
24
 
10
25
  ### Security
package/README.md CHANGED
@@ -126,9 +126,9 @@ Type `!help` in any session thread:
126
126
  | `!remember <text>` | Save a note to the channel's shared memory |
127
127
  | `!memory` | Show channel memory (`forget <n\|text>` removes entries) |
128
128
  | `!routine <schedule, task>` | Schedule a recurring routine in natural language (confirmed with 👍) |
129
- | `!routines` | List routines (`pause\|resume\|delete\|run <n>` to manage) |
129
+ | `!routines` | List routines (`pause\|resume\|delete\|run <n>`, `approval <n> on\|off` to manage) |
130
130
  | `!watch <when ..., task>` | Create an event trigger in natural language (confirmed with 👍) |
131
- | `!watches` | List event triggers (`pause\|resume\|delete <n>` to manage) |
131
+ | `!watches` | List event triggers (`pause\|resume\|delete <n>`, `approval <n> on\|off` to manage) |
132
132
  | `!update` | Show auto-update status (`!update now` / `!update defer`) |
133
133
  | `!bug <desc>` | Report a bug with context (creates a GitHub issue) |
134
134
  | `!approve` | Approve pending plan (alternative to 👍; also `!yes`) |
package/dist/index.js CHANGED
@@ -65227,6 +65227,9 @@ async function postRoutineConfirmation(session, ctx, parsed, requestedBy, opts =
65227
65227
  });
65228
65228
  sessionLog4(session).info(`\uD83D\uDD58 Routine proposal posted for @${requestedBy}${opts.proposedByAgent ? " (agent-proposed)" : ""}: "${parsed.name}"`);
65229
65229
  }
65230
+ function postureMarker(item) {
65231
+ return item.requireApproval === false ? " · ✅ autonomous" : " · \uD83D\uDC4D approvals";
65232
+ }
65230
65233
  async function manageListItems(session, args, username, flavor) {
65231
65234
  const formatter = session.platform.getFormatter();
65232
65235
  const trimmed = args?.trim();
@@ -65244,13 +65247,32 @@ async function manageListItems(session, args, username, flavor) {
65244
65247
  ` + `${lines.join(`
65245
65248
  `)}
65246
65249
 
65247
- ` + `${formatter.formatItalic(`Manage with ${"`" + cmd + " " + flavor.actions + " <n>`"}.`)}`);
65250
+ ` + `${formatter.formatItalic(`Manage with ${"`" + cmd + " " + flavor.actions + " <n>`"}; set approval posture with ${"`" + cmd + " approval <n> on|off`"}.`)}`);
65248
65251
  session.threadLogger?.logCommand(flavor.command, "list", username);
65249
65252
  return;
65250
65253
  }
65254
+ const approvalMatch = trimmed.match(/^approval\s+(\d+)\s+(on|off)$/i);
65255
+ if (approvalMatch) {
65256
+ const [, indexArg2, mode] = approvalMatch;
65257
+ const item2 = flavor.list()[parseInt(indexArg2, 10) - 1];
65258
+ if (!item2) {
65259
+ await post(session, "warning", `${flavor.emoji} No ${flavor.noun.toLowerCase()} ${indexArg2}. See ${formatter.formatCode(cmd)}.`);
65260
+ return;
65261
+ }
65262
+ if (!await requireSessionOwner(session, username, `manage ${flavor.command}`)) {
65263
+ return;
65264
+ }
65265
+ const requireApproval = mode.toLowerCase() === "on";
65266
+ await flavor.update(item2.id, { requireApproval });
65267
+ await post(session, "success", requireApproval ? `\uD83D\uDC4D ${flavor.noun} ${formatter.formatBold(item2.name)} will ask for approval before each action.` : `✅ ${flavor.noun} ${formatter.formatBold(item2.name)} will run autonomously — no approval prompts. Only leave this on for triggers you fully trust.`);
65268
+ sessionLog4(session).info(`${flavor.emoji} @${username}: ${cmd} approval ${indexArg2} ${mode.toLowerCase()} ("${item2.name}")`);
65269
+ auditCommand(session, flavor.command, `approval ${indexArg2} ${mode.toLowerCase()}`, username);
65270
+ session.threadLogger?.logCommand(flavor.command, `approval ${indexArg2} ${mode.toLowerCase()}`, username);
65271
+ return;
65272
+ }
65251
65273
  const match = trimmed.match(new RegExp(`^(${flavor.actions})\\s+(\\d+)$`, "i"));
65252
65274
  if (!match) {
65253
- await post(session, "warning", `${flavor.emoji} Usage: ${formatter.formatCode(cmd)} or ${formatter.formatCode(`${cmd} ${flavor.actions} <n>`)}`);
65275
+ await post(session, "warning", `${flavor.emoji} Usage: ${formatter.formatCode(cmd)}, ${formatter.formatCode(`${cmd} ${flavor.actions} <n>`)} or ${formatter.formatCode(`${cmd} approval <n> on|off`)}`);
65254
65276
  return;
65255
65277
  }
65256
65278
  const [, action, indexArg] = match;
@@ -65302,7 +65324,7 @@ async function manageRoutines(session, args, username, ctx) {
65302
65324
  describe: (r, formatter) => {
65303
65325
  const status = r.enabled ? "" : " — ⏸️ paused";
65304
65326
  const last = r.lastRunAt ? ` · last run ${formatIsoMinute(r.lastRunAt)} (${r.lastRunStatus})` : "";
65305
- return `${formatter.formatBold(r.name)} — ${describeSchedule(r.schedule)} · by ${formatter.formatCode("@" + r.createdBy)}${status}${last}`;
65327
+ return `${formatter.formatBold(r.name)} — ${describeSchedule(r.schedule)} · by ${formatter.formatCode("@" + r.createdBy)}${postureMarker(r)}${status}${last}`;
65306
65328
  },
65307
65329
  list: () => ctx.state.routinesStore.list(platformId),
65308
65330
  update: (id, patch) => ctx.state.routinesStore.update(platformId, id, patch),
@@ -65388,7 +65410,7 @@ async function manageWatches(session, args, username, ctx) {
65388
65410
  describe: (w, formatter) => {
65389
65411
  const status = w.enabled ? "" : " — ⏸️ paused";
65390
65412
  const last = w.lastFiredAt ? ` · last fired ${formatIsoMinute(w.lastFiredAt)} (${w.lastFireStatus})` : "";
65391
- return `${formatter.formatBold(w.name)} — fires when ${w.condition} · by ${formatter.formatCode("@" + w.createdBy)}${status}${last}`;
65413
+ return `${formatter.formatBold(w.name)} — fires when ${w.condition} · by ${formatter.formatCode("@" + w.createdBy)}${postureMarker(w)}${status}${last}`;
65392
65414
  },
65393
65415
  list: () => ctx.state.watchesStore.list(platformId),
65394
65416
  update: (id, patch) => ctx.state.watchesStore.update(platformId, id, patch),
@@ -67888,7 +67910,10 @@ class SessionRegistry {
67888
67910
  find(platformId, threadId) {
67889
67911
  return this.sessions.get(this.getSessionId(platformId, threadId));
67890
67912
  }
67891
- findByThreadId(threadId) {
67913
+ findByThreadId(threadId, platformId) {
67914
+ if (platformId !== undefined) {
67915
+ return this.find(platformId, threadId);
67916
+ }
67892
67917
  for (const session of this.sessions.values()) {
67893
67918
  if (session.threadId === threadId) {
67894
67919
  return session;
@@ -71916,8 +71941,13 @@ class SlackClient extends BasePlatformClient {
71916
71941
  rateLimitRetryAfter = 0;
71917
71942
  outboundFiles;
71918
71943
  formatter = new SlackFormatter;
71919
- constructor(platformConfig) {
71944
+ channelClients = new Map;
71945
+ sharedEventSource;
71946
+ socketConnected = false;
71947
+ constructor(platformConfig, sharedEventSource) {
71920
71948
  super();
71949
+ this.sharedEventSource = sharedEventSource;
71950
+ this.installStateMirror();
71921
71951
  this.platformId = platformConfig.id;
71922
71952
  this.displayName = platformConfig.displayName;
71923
71953
  this.botToken = platformConfig.botToken;
@@ -71931,6 +71961,55 @@ class SlackClient extends BasePlatformClient {
71931
71961
  this.approvals = platformConfig.approvals;
71932
71962
  this.ackReaction = normalizeAckReaction(platformConfig.ackReaction, `platforms[${platformConfig.id}].ackReaction`);
71933
71963
  }
71964
+ stateMirrors = ["connected", "disconnected", "reconnecting"].map((state) => ({
71965
+ state,
71966
+ handler: (...args) => {
71967
+ for (const secondary of this.channelClients.values()) {
71968
+ secondary.emit(state, ...args);
71969
+ }
71970
+ }
71971
+ }));
71972
+ installStateMirror() {
71973
+ for (const { state, handler: handler2 } of this.stateMirrors) {
71974
+ this.off(state, handler2);
71975
+ this.on(state, handler2);
71976
+ }
71977
+ }
71978
+ onConnectionEstablished() {
71979
+ const wasReconnecting = this.isReconnecting;
71980
+ super.onConnectionEstablished();
71981
+ if (!wasReconnecting)
71982
+ return;
71983
+ for (const secondary of this.channelClients.values()) {
71984
+ secondary.recoverMissedMessages().catch((err) => {
71985
+ log40.warn(`Failed to recover missed messages for ${secondary.platformId}: ${err}`);
71986
+ });
71987
+ }
71988
+ }
71989
+ registerChannelClient(channelId, client) {
71990
+ if (channelId === this.channelId) {
71991
+ throw new Error(`registerChannelClient: ${channelId} is the parent's own channel — a secondary there can never receive events`);
71992
+ }
71993
+ this.channelClients.set(channelId, client);
71994
+ }
71995
+ unregisterChannelClient(channelId, client) {
71996
+ if (client && this.channelClients.get(channelId) !== client)
71997
+ return;
71998
+ this.channelClients.delete(channelId);
71999
+ }
72000
+ _injectSlackEvent(event) {
72001
+ this.handleSlackEvent(event);
72002
+ }
72003
+ disconnect() {
72004
+ if (this.sharedEventSource) {
72005
+ this.sharedEventSource.unregisterChannelClient(this.channelId, this);
72006
+ }
72007
+ try {
72008
+ return super.disconnect();
72009
+ } finally {
72010
+ this.installStateMirror();
72011
+ }
72012
+ }
71934
72013
  normalizePlatformUser(slackUser) {
71935
72014
  const displayName = slackUser.profile?.display_name || slackUser.profile?.real_name || slackUser.real_name || slackUser.name;
71936
72015
  return {
@@ -72034,6 +72113,16 @@ class SlackClient extends BasePlatformClient {
72034
72113
  return data;
72035
72114
  }
72036
72115
  async connect() {
72116
+ if (this.sharedEventSource) {
72117
+ await this.fetchBotUser();
72118
+ if (this.isIntentionalDisconnect)
72119
+ return;
72120
+ this.sharedEventSource.registerChannelClient(this.channelId, this);
72121
+ if (this.sharedEventSource.socketConnected) {
72122
+ this.emit("connected");
72123
+ }
72124
+ return;
72125
+ }
72037
72126
  await this.fetchBotUser();
72038
72127
  wsLogger.debug(`Slack bot user ID: ${this.botUserId}`);
72039
72128
  const response = await this.appApi("POST", "apps.connections.open");
@@ -72073,12 +72162,8 @@ class SlackClient extends BasePlatformClient {
72073
72162
  this.handleSocketModeEvent(envelope);
72074
72163
  if (envelope.type === "hello") {
72075
72164
  clearTimeout(connectionTimeout);
72165
+ this.socketConnected = true;
72076
72166
  this.onConnectionEstablished();
72077
- if (this.isReconnecting && this.lastProcessedTs) {
72078
- this.recoverMissedMessages().catch((err) => {
72079
- log40.warn(`Failed to recover missed messages: ${err}`);
72080
- });
72081
- }
72082
72167
  doResolve();
72083
72168
  }
72084
72169
  } catch (err) {
@@ -72086,6 +72171,7 @@ class SlackClient extends BasePlatformClient {
72086
72171
  }
72087
72172
  };
72088
72173
  this.ws.onclose = (event) => {
72174
+ this.socketConnected = false;
72089
72175
  clearTimeout(connectionTimeout);
72090
72176
  wsLogger.info(`Socket Mode: WebSocket disconnected (code: ${event.code}, reason: ${event.reason || "none"}, clean: ${event.wasClean})`);
72091
72177
  if (!settled) {
@@ -72137,6 +72223,14 @@ class SlackClient extends BasePlatformClient {
72137
72223
  }
72138
72224
  }
72139
72225
  handleSlackEvent(event) {
72226
+ const eventChannel = event.channel || event.item?.channel;
72227
+ if (eventChannel && eventChannel !== this.channelId) {
72228
+ const secondary = this.channelClients.get(eventChannel);
72229
+ if (secondary) {
72230
+ secondary._injectSlackEvent(event);
72231
+ return;
72232
+ }
72233
+ }
72140
72234
  if (event.type === "message" && (!event.subtype || event.subtype === "file_share")) {
72141
72235
  if (event.user === this.botUserId || event.bot_id) {
72142
72236
  return;
@@ -75431,7 +75525,7 @@ class SessionManager extends EventEmitter4 {
75431
75525
  return session !== undefined && session.claude.isRunning();
75432
75526
  }
75433
75527
  hasPausedSession(threadId, platformId) {
75434
- if (this.registry.findByThreadId(threadId))
75528
+ if (this.registry.findByThreadId(threadId, platformId))
75435
75529
  return false;
75436
75530
  return this.registry.getPersistedByThreadId(threadId, platformId) !== undefined;
75437
75531
  }
@@ -75743,7 +75837,7 @@ Mention me to start a session in this worktree.`, threadId);
75743
75837
  }
75744
75838
  }
75745
75839
  isUserAllowedInSession(threadId, username, platformId) {
75746
- const session = this.findSessionByThreadId(threadId);
75840
+ const session = this.registry.find(platformId, threadId);
75747
75841
  if (!session) {
75748
75842
  const persisted = this.getPersistedSession(threadId, platformId);
75749
75843
  if (persisted) {
@@ -85468,7 +85562,7 @@ async function handleMessage(client, session, post2, user, options) {
85468
85562
  await onKill?.(username);
85469
85563
  return;
85470
85564
  }
85471
- const activeSession = session.registry.findByThreadId(threadRoot);
85565
+ const activeSession = session.registry.findByThreadId(threadRoot, platformId);
85472
85566
  if (activeSession) {
85473
85567
  const sideMentionActive = leadingOtherUserMention(client, message);
85474
85568
  if (sideMentionActive) {
@@ -58101,7 +58101,10 @@ class SessionRegistry {
58101
58101
  find(platformId, threadId) {
58102
58102
  return this.sessions.get(this.getSessionId(platformId, threadId));
58103
58103
  }
58104
- findByThreadId(threadId) {
58104
+ findByThreadId(threadId, platformId) {
58105
+ if (platformId !== undefined) {
58106
+ return this.find(platformId, threadId);
58107
+ }
58105
58108
  for (const session of this.sessions.values()) {
58106
58109
  if (session.threadId === threadId) {
58107
58110
  return session;
@@ -382,8 +382,12 @@ confirmation says so.
382
382
 
383
383
  **Managing:**
384
384
 
385
- - `!routines` — numbered list with schedule, creator, and last-run status
385
+ - `!routines` — numbered list with schedule, creator, last-run status, and
386
+ the approval posture (👍 approvals · ✅ autonomous)
386
387
  - `!routines pause|resume|delete <n>` — owner-gated
388
+ - `!routines approval <n> on|off` — owner-gated; flip the approval posture
389
+ after creation. `on` restores per-action approval, `off` makes each run
390
+ autonomous (no approval prompts, even on a `skipPermissions` platform)
387
391
  - `!routines run <n>` — fire now, outside the schedule (platform-allowed
388
392
  users only — not temporarily `!invite`d guests; does not consume the
389
393
  period's scheduled fire)
@@ -452,8 +456,12 @@ all three, and **nothing is saved until someone reacts 👍**.
452
456
 
453
457
  **Managing:**
454
458
 
455
- - `!watches` — numbered list with condition, creator, and last-fire status
459
+ - `!watches` — numbered list with condition, creator, last-fire status, and
460
+ the approval posture (👍 approvals · ✅ autonomous)
456
461
  - `!watches pause|resume|delete <n>` — owner-gated
462
+ - `!watches approval <n> on|off` — owner-gated; flip the approval posture
463
+ after creation. `off` makes each fire autonomous — reserve it for triggers
464
+ you fully trust, since a watch fires on channel content anyone can post
457
465
  - (No manual `run` — watches are event-driven; use `!routines run` for
458
466
  on-demand work.)
459
467
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "1.31.1",
3
+ "version": "1.32.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",