claude-threads 1.31.0 β†’ 1.31.2

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,25 @@ 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.31.2] - 2026-08-30
11
+
12
+ ### Added
13
+ - **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.
14
+
15
+ ### Security
16
+ - **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.
17
+
18
+ ## [1.31.1] - 2026-08-29
19
+
20
+ ### Security
21
+ - **Regression guard for the cross-platform resume scoping (1.31.0).** The `resumePausedSession` sink is now covered by a test that fails if the `platformId` scope is removed β€” a message on one platform must never resume a session persisted under another platform whose thread id collides. The fix shipped correct in 1.31.0 but without this guard.
22
+
23
+ ### Fixed
24
+ - **A rejected branch name can no longer break its own error message.** An invalid `!worktree <name>` whose name contains a backtick or newline is sanitized for display, so it stays inside its markdown code span in the error post.
25
+ - **A downgraded "βœ… Invite to session" reaction is no longer silent.** When a non-owner participant's βœ… is downgraded to a one-shot allow (only the owner may grant standing membership), the bot now says so, instead of leaving the reactor to assume the invite succeeded.
26
+
8
27
  ## [1.31.0] - 2026-08-29
9
28
 
10
29
  ### Added
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
@@ -62633,6 +62633,7 @@ class MessageApprovalExecutor extends BaseExecutor {
62633
62633
  const isInviteAuthorized = pending.sessionOwner !== undefined && user === pending.sessionOwner || ctx.platform.isUserAllowed(user);
62634
62634
  if (!isInviteAuthorized) {
62635
62635
  ctx.logger.info(`Message approval invite (βœ…) from @${user} downgraded to allow-once: only the session owner may invite`);
62636
+ await ctx.createPost(`ℹ️ Only the session owner can invite ${ctx.formatter.formatUserMention(pending.fromUser)} to the session β€” allowing this one message instead.`, { type: "system" });
62636
62637
  const handled2 = await this.handleMessageApprovalResponse(postId, "allow", user, ctx);
62637
62638
  ctx.logger.debug(`MessageApprovalExecutor: outcome=allow (invite downgraded), handled=${handled2}`);
62638
62639
  return handled2;
@@ -65226,6 +65227,9 @@ async function postRoutineConfirmation(session, ctx, parsed, requestedBy, opts =
65226
65227
  });
65227
65228
  sessionLog4(session).info(`\uD83D\uDD58 Routine proposal posted for @${requestedBy}${opts.proposedByAgent ? " (agent-proposed)" : ""}: "${parsed.name}"`);
65228
65229
  }
65230
+ function postureMarker(item) {
65231
+ return item.requireApproval === false ? " Β· βœ… autonomous" : " Β· \uD83D\uDC4D approvals";
65232
+ }
65229
65233
  async function manageListItems(session, args, username, flavor) {
65230
65234
  const formatter = session.platform.getFormatter();
65231
65235
  const trimmed = args?.trim();
@@ -65243,13 +65247,32 @@ async function manageListItems(session, args, username, flavor) {
65243
65247
  ` + `${lines.join(`
65244
65248
  `)}
65245
65249
 
65246
- ` + `${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`"}.`)}`);
65247
65251
  session.threadLogger?.logCommand(flavor.command, "list", username);
65248
65252
  return;
65249
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
+ }
65250
65273
  const match = trimmed.match(new RegExp(`^(${flavor.actions})\\s+(\\d+)$`, "i"));
65251
65274
  if (!match) {
65252
- 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`)}`);
65253
65276
  return;
65254
65277
  }
65255
65278
  const [, action, indexArg] = match;
@@ -65301,7 +65324,7 @@ async function manageRoutines(session, args, username, ctx) {
65301
65324
  describe: (r, formatter) => {
65302
65325
  const status = r.enabled ? "" : " β€” ⏸️ paused";
65303
65326
  const last = r.lastRunAt ? ` Β· last run ${formatIsoMinute(r.lastRunAt)} (${r.lastRunStatus})` : "";
65304
- 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}`;
65305
65328
  },
65306
65329
  list: () => ctx.state.routinesStore.list(platformId),
65307
65330
  update: (id, patch) => ctx.state.routinesStore.update(platformId, id, patch),
@@ -65387,7 +65410,7 @@ async function manageWatches(session, args, username, ctx) {
65387
65410
  describe: (w, formatter) => {
65388
65411
  const status = w.enabled ? "" : " β€” ⏸️ paused";
65389
65412
  const last = w.lastFiredAt ? ` Β· last fired ${formatIsoMinute(w.lastFiredAt)} (${w.lastFireStatus})` : "";
65390
- 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}`;
65391
65414
  },
65392
65415
  list: () => ctx.state.watchesStore.list(platformId),
65393
65416
  update: (id, patch) => ctx.state.watchesStore.update(platformId, id, patch),
@@ -65482,6 +65505,9 @@ import { randomUUID as randomUUID5 } from "crypto";
65482
65505
  init_logger();
65483
65506
  var log24 = createLogger("worktree");
65484
65507
  var sessionLog5 = createSessionLog(log24);
65508
+ function displayBranchName(name) {
65509
+ return name.replace(/[`\r\n]/g, "").slice(0, 100);
65510
+ }
65485
65511
  function parseWorktreeError(error) {
65486
65512
  const message = error instanceof Error ? error.message : String(error);
65487
65513
  const lowerMessage = message.toLowerCase();
@@ -65652,7 +65678,7 @@ async function handleWorktreeBranchResponse(session, branchName, username, respo
65652
65678
  return false;
65653
65679
  }
65654
65680
  if (!isValidBranchName(branchName)) {
65655
- await postError(session, `Invalid branch name: \`${branchName}\`. Please provide a valid git branch name.`);
65681
+ await postError(session, `Invalid branch name: \`${displayBranchName(branchName)}\`. Please provide a valid git branch name.`);
65656
65682
  sessionLog5(session).warn(`\uD83C\uDF3F Invalid branch name: ${branchName}`);
65657
65683
  return true;
65658
65684
  }
@@ -65696,7 +65722,7 @@ async function createAndSwitchToWorktree(session, branch, username, options) {
65696
65722
  if (!await requireSessionOwner(session, username, "manage worktrees"))
65697
65723
  return;
65698
65724
  if (!isValidBranchName(branch)) {
65699
- await postError(session, `Invalid branch name: \`${branch}\`. Please provide a valid git branch name.`);
65725
+ await postError(session, `Invalid branch name: \`${displayBranchName(branch)}\`. Please provide a valid git branch name.`);
65700
65726
  sessionLog5(session).warn(`\uD83C\uDF3F Rejected invalid branch name: ${branch}`);
65701
65727
  return;
65702
65728
  }
@@ -67884,7 +67910,10 @@ class SessionRegistry {
67884
67910
  find(platformId, threadId) {
67885
67911
  return this.sessions.get(this.getSessionId(platformId, threadId));
67886
67912
  }
67887
- findByThreadId(threadId) {
67913
+ findByThreadId(threadId, platformId) {
67914
+ if (platformId !== undefined) {
67915
+ return this.find(platformId, threadId);
67916
+ }
67888
67917
  for (const session of this.sessions.values()) {
67889
67918
  if (session.threadId === threadId) {
67890
67919
  return session;
@@ -75427,7 +75456,7 @@ class SessionManager extends EventEmitter4 {
75427
75456
  return session !== undefined && session.claude.isRunning();
75428
75457
  }
75429
75458
  hasPausedSession(threadId, platformId) {
75430
- if (this.registry.findByThreadId(threadId))
75459
+ if (this.registry.findByThreadId(threadId, platformId))
75431
75460
  return false;
75432
75461
  return this.registry.getPersistedByThreadId(threadId, platformId) !== undefined;
75433
75462
  }
@@ -75739,7 +75768,7 @@ Mention me to start a session in this worktree.`, threadId);
75739
75768
  }
75740
75769
  }
75741
75770
  isUserAllowedInSession(threadId, username, platformId) {
75742
- const session = this.findSessionByThreadId(threadId);
75771
+ const session = this.registry.find(platformId, threadId);
75743
75772
  if (!session) {
75744
75773
  const persisted = this.getPersistedSession(threadId, platformId);
75745
75774
  if (persisted) {
@@ -85464,7 +85493,7 @@ async function handleMessage(client, session, post2, user, options) {
85464
85493
  await onKill?.(username);
85465
85494
  return;
85466
85495
  }
85467
- const activeSession = session.registry.findByThreadId(threadRoot);
85496
+ const activeSession = session.registry.findByThreadId(threadRoot, platformId);
85468
85497
  if (activeSession) {
85469
85498
  const sideMentionActive = leadingOtherUserMention(client, message);
85470
85499
  if (sideMentionActive) {
@@ -51310,6 +51310,7 @@ class MessageApprovalExecutor extends BaseExecutor {
51310
51310
  const isInviteAuthorized = pending.sessionOwner !== undefined && user === pending.sessionOwner || ctx.platform.isUserAllowed(user);
51311
51311
  if (!isInviteAuthorized) {
51312
51312
  ctx.logger.info(`Message approval invite (βœ…) from @${user} downgraded to allow-once: only the session owner may invite`);
51313
+ await ctx.createPost(`ℹ️ Only the session owner can invite ${ctx.formatter.formatUserMention(pending.fromUser)} to the session β€” allowing this one message instead.`, { type: "system" });
51313
51314
  const handled2 = await this.handleMessageApprovalResponse(postId, "allow", user, ctx);
51314
51315
  ctx.logger.debug(`MessageApprovalExecutor: outcome=allow (invite downgraded), handled=${handled2}`);
51315
51316
  return handled2;
@@ -58100,7 +58101,10 @@ class SessionRegistry {
58100
58101
  find(platformId, threadId) {
58101
58102
  return this.sessions.get(this.getSessionId(platformId, threadId));
58102
58103
  }
58103
- findByThreadId(threadId) {
58104
+ findByThreadId(threadId, platformId) {
58105
+ if (platformId !== undefined) {
58106
+ return this.find(platformId, threadId);
58107
+ }
58104
58108
  for (const session of this.sessions.values()) {
58105
58109
  if (session.threadId === threadId) {
58106
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.0",
3
+ "version": "1.31.2",
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",