claude-threads 1.15.2 → 1.16.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
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.16.1] - 2026-05-22
11
+
12
+ ### Security
13
+ - **Fail-closed authorization on the user-driven paths that invoke Claude.** A confirmed bypass let an unauthorized Slack user (not in `allowedUsers`, non-empty allowlist) reach Claude: the bot posted both the "not authorized" warning and a real answer in the same thread. Rather than chase the one leaking caller, authorization is now deny-by-default at the functions that spawn or message Claude on a user's behalf: `startSession`, `sendFollowUp`, and `resumePausedSession`, plus the resume-from-reaction path. A single helper, `isAuthorizedForSession`, encodes every tier (global allowlist, empty-allowlist allow-all, per-session owner and invited users) and refuses a missing, empty, or `unknown` username. Every user-facing path now routes through that one helper instead of duplicating the check. The biggest gap was message-driven resume, which ran purely from persisted state with no identity check; it now takes a `username` and verifies it against the persisted session allowlist before resuming. The two system-triggered resume paths (bulk restore after a bot restart) stay ungated by design, since they carry no user identity. Legitimate username-less follow-ups (passthrough slash commands like `/context`, already authorized upstream) pass an explicit `system` flag so they are not caught. The mid-session approval flow is untouched: it still adds approved users to the session allowlist, so the check passes once approval is granted. (#388)
14
+ - **hono 4.12.18 → 4.12.21** picks up four upstream security fixes: GHSA-2gcr-mfcq-wcc3 (`app.mount()` stripped the mount prefix from the raw, undecoded pathname, so percent-encoded paths could be cut at the wrong offset and reach a sub-app with the wrong path), GHSA-xrhx-7g5j-rcj5 (`hono/ip-restriction` compared IPs by string equality, so non-canonical IPv6 forms such as compressed or hex IPv4-mapped addresses slipped past static deny rules), GHSA-3hrh-pfw6-9m5x (the cookie helper didn't sanitize `sameSite`/`priority`, allowing Set-Cookie injection via `;`, `\r`, `\n`), and GHSA-f577-qrjj-4474 (`hono/jwt` accepted any two-part Authorization header regardless of scheme, not just `Bearer`). claude-threads reaches hono through `@hono/node-server` on the inbound webhook surface, so the mount-prefix and Set-Cookie fixes are the relevant ones. (#386)
15
+
16
+ ### Changed
17
+ - **Production deps** bumped: `@hono/node-server` 2.0.2 → 2.0.3 (preserves headers mutated after raw Response construction), `express-rate-limit` 8.5.1 → 8.5.2 (simplified IPv6 key generation). Lockfile-only. (#386)
18
+ - **Dev deps** bumped: `@types/bun` 1.3.13 → 1.3.14, `@types/node` 25.7.0 → 25.9.1, `@types/react` 19.2.14 → 19.2.15, `eslint` 10.3.0 → 10.4.0 (adds `includeIgnoreFile()` and a `for-direction` sequence-expression check, both additive), `lint-staged` 17.0.4 → 17.0.5, `typescript-eslint` 8.59.3 → 8.59.4 (fixes a `no-floating-promises` stack overflow on recursive types). Lockfile-only. (#385)
19
+
20
+ ### Fixed
21
+ - **Multiple pasted screenshots no longer silently vanish.** When you paste several clipboard images into one chat message, the platforms hand them all the same filename (`image.png`). The save path wrote each one with the `wx` (`O_EXCL`) flag, so the second and later files hit `EEXIST`, got caught, and were reported as a download failure, so they never reached Claude. Saves now dedupe filenames within a single message, inserting a numeric suffix before the extension (`image.png`, `image_1.png`, `image_2.png`); files without an extension get `report`, `report_1`, and so on. The unique name is resolved before the write, so the `wx` flag still does its job against symlink races. (#387)
22
+ - **Inbound attachments are no longer capped at 100 MB.** A hard 100 MB ceiling on incoming files meant anything larger was skipped with a "too large" warning, which surprised people sharing big assets. The cap is gone; an attachment of any reported size is downloaded and written to disk. One tradeoff worth knowing: `downloadFile` still buffers the whole file in memory via `arrayBuffer()`, so a very large attachment is held in RAM while it's written. Streaming that download is a separate change. (#387)
23
+
24
+ ## [1.16.0] - 2026-05-14
25
+
26
+ ### Added
27
+ - **Per-platform channel-verbosity controls.** New `sessionHeader` and `stickyMessage` settings, both `full` (default) / `minimal` (one-line status bar) / `hidden` (no post). Reachable three ways: setup wizard ("How verbose should the bot be in this channel?"), CLI flags (`--session-header`, `--sticky-message`, applied to every platform), or per-platform YAML for split values. `hidden` for the header means Claude's reply is the first message in the thread; `hidden` for the sticky stops the `channel_post` bump entirely. Update notices still ride along in `minimal`. Session-header mode persists per session — resume preserves the user's choice. Old `sessions.json` defaults to `full`. The pre-existing top-level `stickyMessage: { description, footer }` block is unchanged. (#384, closes #383)
28
+
29
+ ### Security
30
+ - **`ws` 8.20.0 → 8.20.1** picks up a fix for an uninitialized memory disclosure in `websocket.close()`. Triggered when a `TypedArray` (e.g. `Float32Array`) is passed as the `reason` argument instead of a string or `Buffer` — uninitialized memory was leaked to the remote peer. claude-threads doesn't pass typed arrays to `close()` anywhere we control, but the Mattermost client and inbound webhook surface use `ws` as a transitive dependency. (#382)
31
+
32
+ ### Changed
33
+ - **Production deps** bumped: `@hono/node-server` 2.0.1 → 2.0.2 (serve-static fixes), `react` 19.2.5 → 19.2.6 (RSC type hardening), `semver` 7.7.4 → 7.8.0 (new `truncate` function), `ink-scroll-view` 0.3.6 → 0.3.7. Lockfile-only. (#382)
34
+ - **Dev deps** bumped: `@types/node` 25.6.0 → 25.7.0, `lint-staged` 16.4.0 → 17.0.4, `typescript-eslint` 8.59.2 → 8.59.3. lint-staged 17 drops Node 20 support, but it's a dev dep — the bot's runtime floor is unchanged. Lockfile-only. (#381)
35
+
10
36
  ## [1.15.2] - 2026-05-06
11
37
 
12
38
  ### Security
package/dist/index.js CHANGED
@@ -48344,6 +48344,18 @@ var jsYaml = {
48344
48344
  };
48345
48345
 
48346
48346
  // src/config/types.ts
48347
+ var OVERHEAD_VISIBILITY_VALUES = ["full", "minimal", "hidden"];
48348
+ var DEFAULT_OVERHEAD_VISIBILITY = "full";
48349
+ function isOverheadVisibility(value) {
48350
+ return typeof value === "string" && OVERHEAD_VISIBILITY_VALUES.includes(value);
48351
+ }
48352
+ function resolveOverheadVisibility(value, fieldPath) {
48353
+ if (value === undefined || value === null)
48354
+ return DEFAULT_OVERHEAD_VISIBILITY;
48355
+ if (isOverheadVisibility(value))
48356
+ return value;
48357
+ throw new Error(`Invalid ${fieldPath}: expected one of ${OVERHEAD_VISIBILITY_VALUES.join(", ")}, got ${JSON.stringify(value)}`);
48358
+ }
48347
48359
  var LIMITS_DEFAULTS = {
48348
48360
  maxSessions: 5,
48349
48361
  sessionTimeoutMinutes: 30,
@@ -48648,6 +48660,26 @@ var PERMISSION_MODE_CHOICES = [
48648
48660
  function permissionModeChoiceIndex(mode) {
48649
48661
  return PERMISSION_MODE_CHOICES.findIndex((c) => c.value === mode);
48650
48662
  }
48663
+ var OVERHEAD_VISIBILITY_CHOICES = [
48664
+ {
48665
+ title: "Full (default)",
48666
+ value: "full",
48667
+ description: "Per-thread session header + channel sticky with active-sessions list"
48668
+ },
48669
+ {
48670
+ title: "Minimal",
48671
+ value: "minimal",
48672
+ description: "One-line status bar only — drops the table and sessions list"
48673
+ },
48674
+ {
48675
+ title: "Hidden",
48676
+ value: "hidden",
48677
+ description: "No header post, no sticky — Claude's reply is the first message in the thread"
48678
+ }
48679
+ ];
48680
+ function overheadVisibilityChoiceIndex(mode) {
48681
+ return OVERHEAD_VISIBILITY_CHOICES.findIndex((c) => c.value === mode);
48682
+ }
48651
48683
  var __dirname2 = dirname2(fileURLToPath(import.meta.url));
48652
48684
  var SLACK_MANIFEST_PATH = join2(__dirname2, "..", "docs", "slack-app-manifest.yaml");
48653
48685
  var onCancel = () => {
@@ -49417,6 +49449,10 @@ async function setupMattermostPlatform(id, existing) {
49417
49449
  permissionMode: existingMattermost.permissionMode,
49418
49450
  skipPermissions: existingMattermost.skipPermissions
49419
49451
  }) : "auto";
49452
+ const existingHeader = existingMattermost?.sessionHeader;
49453
+ const existingSticky = existingMattermost?.stickyMessage;
49454
+ const hasSplitVerbosity = existingHeader !== undefined && existingSticky !== undefined && existingHeader !== existingSticky;
49455
+ let lastChannelVerbosity = existingHeader ?? existingSticky ?? DEFAULT_OVERHEAD_VISIBILITY;
49420
49456
  while (true) {
49421
49457
  console.log("");
49422
49458
  console.log(dim(" Now enter your Mattermost credentials:"));
@@ -49521,6 +49557,20 @@ async function setupMattermostPlatform(id, existing) {
49521
49557
  choices: PERMISSION_MODE_CHOICES,
49522
49558
  initial: permissionModeChoiceIndex(lastPermissionMode)
49523
49559
  }, { onCancel });
49560
+ let channelVerbosity = lastChannelVerbosity;
49561
+ if (hasSplitVerbosity) {
49562
+ console.log("");
49563
+ console.log(dim(` Channel verbosity: keeping split values from current config ` + `(sessionHeader=${existingHeader}, stickyMessage=${existingSticky}). ` + `Edit YAML directly to change.`));
49564
+ } else {
49565
+ const result = await import_prompts.default({
49566
+ type: "select",
49567
+ name: "channelVerbosity",
49568
+ message: "How verbose should the bot be in this channel?",
49569
+ choices: OVERHEAD_VISIBILITY_CHOICES,
49570
+ initial: overheadVisibilityChoiceIndex(lastChannelVerbosity)
49571
+ }, { onCancel });
49572
+ channelVerbosity = result.channelVerbosity;
49573
+ }
49524
49574
  lastUrl = basicSettings.url;
49525
49575
  lastDisplayName = basicSettings.displayName;
49526
49576
  lastToken = finalToken;
@@ -49528,6 +49578,7 @@ async function setupMattermostPlatform(id, existing) {
49528
49578
  lastBotName = basicSettings.botName;
49529
49579
  lastAllowedUsers = allowedUsers.join(",");
49530
49580
  lastPermissionMode = permissionMode;
49581
+ lastChannelVerbosity = channelVerbosity;
49531
49582
  console.log("");
49532
49583
  console.log(dim(" Validating credentials..."));
49533
49584
  const validationResult = await validateMattermostCredentials(basicSettings.url, finalToken, basicSettings.channelId);
@@ -49586,7 +49637,8 @@ async function setupMattermostPlatform(id, existing) {
49586
49637
  channelId: basicSettings.channelId,
49587
49638
  botName: basicSettings.botName,
49588
49639
  allowedUsers,
49589
- permissionMode: lastPermissionMode
49640
+ permissionMode: lastPermissionMode,
49641
+ ...hasSplitVerbosity ? { sessionHeader: existingHeader, stickyMessage: existingSticky } : lastChannelVerbosity !== DEFAULT_OVERHEAD_VISIBILITY ? { sessionHeader: lastChannelVerbosity, stickyMessage: lastChannelVerbosity } : {}
49590
49642
  };
49591
49643
  }
49592
49644
  }
@@ -49703,6 +49755,10 @@ async function setupSlackPlatform(id, existing) {
49703
49755
  permissionMode: existingSlack.permissionMode,
49704
49756
  skipPermissions: existingSlack.skipPermissions
49705
49757
  }) : "auto";
49758
+ const existingHeader = existingSlack?.sessionHeader;
49759
+ const existingSticky = existingSlack?.stickyMessage;
49760
+ const hasSplitVerbosity = existingHeader !== undefined && existingSticky !== undefined && existingHeader !== existingSticky;
49761
+ let lastChannelVerbosity = existingHeader ?? existingSticky ?? DEFAULT_OVERHEAD_VISIBILITY;
49706
49762
  while (true) {
49707
49763
  console.log("");
49708
49764
  console.log(dim(" Now enter your Slack credentials:"));
@@ -49818,6 +49874,20 @@ async function setupSlackPlatform(id, existing) {
49818
49874
  choices: PERMISSION_MODE_CHOICES,
49819
49875
  initial: permissionModeChoiceIndex(lastPermissionMode)
49820
49876
  }, { onCancel });
49877
+ let channelVerbosity = lastChannelVerbosity;
49878
+ if (hasSplitVerbosity) {
49879
+ console.log("");
49880
+ console.log(dim(` Channel verbosity: keeping split values from current config ` + `(sessionHeader=${existingHeader}, stickyMessage=${existingSticky}). ` + `Edit YAML directly to change.`));
49881
+ } else {
49882
+ const result = await import_prompts.default({
49883
+ type: "select",
49884
+ name: "channelVerbosity",
49885
+ message: "How verbose should the bot be in this channel?",
49886
+ choices: OVERHEAD_VISIBILITY_CHOICES,
49887
+ initial: overheadVisibilityChoiceIndex(lastChannelVerbosity)
49888
+ }, { onCancel });
49889
+ channelVerbosity = result.channelVerbosity;
49890
+ }
49821
49891
  lastDisplayName = basicSettings.displayName;
49822
49892
  lastBotToken = finalBotToken;
49823
49893
  lastAppToken = finalAppToken;
@@ -49825,6 +49895,7 @@ async function setupSlackPlatform(id, existing) {
49825
49895
  lastBotName = basicSettings.botName;
49826
49896
  lastAllowedUsers = allowedUsers.join(",");
49827
49897
  lastPermissionMode = permissionMode;
49898
+ lastChannelVerbosity = channelVerbosity;
49828
49899
  console.log("");
49829
49900
  console.log(dim(" Validating credentials..."));
49830
49901
  const validationResult = await validateSlackCredentials(finalBotToken, finalAppToken, basicSettings.channelId);
@@ -49889,7 +49960,8 @@ async function setupSlackPlatform(id, existing) {
49889
49960
  channelId: basicSettings.channelId,
49890
49961
  botName: basicSettings.botName,
49891
49962
  allowedUsers,
49892
- permissionMode: lastPermissionMode
49963
+ permissionMode: lastPermissionMode,
49964
+ ...hasSplitVerbosity ? { sessionHeader: existingHeader, stickyMessage: existingSticky } : lastChannelVerbosity !== DEFAULT_OVERHEAD_VISIBILITY ? { sessionHeader: lastChannelVerbosity, stickyMessage: lastChannelVerbosity } : {}
49893
49965
  };
49894
49966
  }
49895
49967
  }
@@ -50283,6 +50355,24 @@ function sanitizeFilename(name) {
50283
50355
  }
50284
50356
  return cleaned;
50285
50357
  }
50358
+ function dedupeFilename(name, used) {
50359
+ if (!used.has(name)) {
50360
+ used.add(name);
50361
+ return name;
50362
+ }
50363
+ const lastDot = name.lastIndexOf(".");
50364
+ const hasExt = lastDot > 0;
50365
+ const stem = hasExt ? name.slice(0, lastDot) : name;
50366
+ const ext = hasExt ? name.slice(lastDot) : "";
50367
+ let counter = 1;
50368
+ let candidate = `${stem}_${counter}${ext}`;
50369
+ while (used.has(candidate)) {
50370
+ counter += 1;
50371
+ candidate = `${stem}_${counter}${ext}`;
50372
+ }
50373
+ used.add(candidate);
50374
+ return candidate;
50375
+ }
50286
50376
  function formatBytes(bytes) {
50287
50377
  if (bytes < 1024)
50288
50378
  return `${bytes} B`;
@@ -53513,6 +53603,18 @@ function getSessionStatus(session) {
53513
53603
  return "idle";
53514
53604
  }
53515
53605
 
53606
+ // src/session/authorization.ts
53607
+ function isAuthorizedForSession(check) {
53608
+ const { username, platform, sessionAllowedUsers } = check;
53609
+ if (!username || username === "unknown") {
53610
+ return false;
53611
+ }
53612
+ if (platform.isUserAllowed(username)) {
53613
+ return true;
53614
+ }
53615
+ return sessionAllowedUsers?.has(username) ?? false;
53616
+ }
53617
+
53516
53618
  // src/claude/cli.ts
53517
53619
  init_spawn();
53518
53620
  init_logger();
@@ -54746,7 +54848,7 @@ function createPassthroughHandler(slashCommand) {
54746
54848
  return { handled: false };
54747
54849
  }
54748
54850
  if (ctx.isAllowed) {
54749
- await ctx.sessionManager.sendFollowUp(ctx.threadId, `/${slashCommand}`);
54851
+ await ctx.sessionManager.sendFollowUp(ctx.threadId, `/${slashCommand}`, undefined, undefined, undefined, { system: true });
54750
54852
  }
54751
54853
  return { handled: true };
54752
54854
  };
@@ -54794,7 +54896,7 @@ async function handleDynamicSlashCommand(command, args, ctx) {
54794
54896
  }
54795
54897
  if (ctx.isAllowed) {
54796
54898
  const fullCommand = args ? `/${command} ${args}` : `/${command}`;
54797
- await ctx.sessionManager.sendFollowUp(ctx.threadId, fullCommand);
54899
+ await ctx.sessionManager.sendFollowUp(ctx.threadId, fullCommand, undefined, undefined, undefined, { system: true });
54798
54900
  }
54799
54901
  return { handled: true };
54800
54902
  }
@@ -55315,7 +55417,6 @@ import { lstat, mkdir as mkdir2, mkdtemp, rm as rm2, writeFile as writeFile2 } f
55315
55417
  import { tmpdir as tmpdir2 } from "os";
55316
55418
  import { join as join9 } from "path";
55317
55419
  var log18 = createLogger("streaming");
55318
- var MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
55319
55420
  var UPLOAD_ROOT_DIR = "claude-threads-uploads";
55320
55421
  function safeIdSegment(id) {
55321
55422
  return id.replace(/[^A-Za-z0-9._-]/g, "_");
@@ -55355,18 +55456,11 @@ async function saveFilesToUploadDir(platform, uploadDir, files, debug = false) {
55355
55456
  return { saved, skipped };
55356
55457
  }
55357
55458
  const messageDir = await mkdtemp(join9(uploadDir, `${Date.now().toString(36)}-`));
55459
+ const usedNames = new Set;
55358
55460
  for (const file of files) {
55359
- if (file.size > MAX_UPLOAD_SIZE) {
55360
- skipped.push({
55361
- name: file.name,
55362
- reason: `File too large (${formatBytes(file.size)} > ${formatBytes(MAX_UPLOAD_SIZE)} limit)`,
55363
- suggestion: "Split the file or share it via an external link"
55364
- });
55365
- continue;
55366
- }
55367
55461
  try {
55368
55462
  const buffer = await platform.downloadFile(file.id);
55369
- const safeName = sanitizeFilename(file.name);
55463
+ const safeName = dedupeFilename(sanitizeFilename(file.name), usedNames);
55370
55464
  const absolutePath = join9(messageDir, safeName);
55371
55465
  await writeFile2(absolutePath, buffer, { mode: 384, flag: "wx" });
55372
55466
  saved.push({
@@ -63980,6 +64074,13 @@ async function buildStickyMessage(sessions, platformId, config, formatter, getTh
63980
64074
  const otherPlatformSessions = allSessions.filter((s) => s.platformId !== platformId);
63981
64075
  const totalCount = allSessions.length;
63982
64076
  const statusBar = await buildStatusBar(totalCount, config, formatter, platformId);
64077
+ if (config.overhead === "minimal") {
64078
+ return [
64079
+ formatter.formatHorizontalRule(),
64080
+ statusBar
64081
+ ].join(`
64082
+ `);
64083
+ }
63983
64084
  const activeSessionIds = new Set(sessions.keys());
63984
64085
  const historySessions = sessionStore ? sessionStore.getHistory(platformId, activeSessionIds).slice(0, 5) : [];
63985
64086
  if (totalCount === 0) {
@@ -64119,7 +64220,35 @@ async function validateLastMessageIds(platform, sessions) {
64119
64220
  });
64120
64221
  await Promise.all(validationPromises);
64121
64222
  }
64223
+ var hiddenCleanupDone = new Set;
64224
+ function clearHiddenCleanupTracking(platformId) {
64225
+ hiddenCleanupDone.delete(platformId);
64226
+ }
64122
64227
  async function updateStickyMessageImpl(platform, sessions, config) {
64228
+ if (config.overhead === "hidden") {
64229
+ if (!hiddenCleanupDone.has(platform.platformId)) {
64230
+ hiddenCleanupDone.add(platform.platformId);
64231
+ const existing = stickyPostIds.get(platform.platformId);
64232
+ if (existing) {
64233
+ log21.info(`sticky[${platform.platformId}] hidden mode: removing leftover ${formatShortId(existing)}`);
64234
+ try {
64235
+ await platform.unpinPost(existing);
64236
+ } catch {}
64237
+ try {
64238
+ await platform.deletePost(existing);
64239
+ } catch {}
64240
+ stickyPostIds.delete(platform.platformId);
64241
+ if (sessionStore) {
64242
+ sessionStore.removeStickyPostId(platform.platformId);
64243
+ }
64244
+ }
64245
+ try {
64246
+ const botUser = await platform.getBotUser();
64247
+ cleanupOldStickyMessages(platform, botUser.id, false, new Set).catch(() => {});
64248
+ } catch {}
64249
+ }
64250
+ return;
64251
+ }
64123
64252
  const platformSessions = [...sessions.values()].filter((s) => s.platformId === platform.platformId);
64124
64253
  log21.debug(`updateStickyMessage for ${platform.platformId}, ${platformSessions.length} sessions`);
64125
64254
  for (const s of platformSessions) {
@@ -64199,8 +64328,11 @@ async function updateStickyMessageImpl(platform, sessions, config) {
64199
64328
  log21.error(`Failed to update sticky message for ${platform.platformId}`, err instanceof Error ? err : undefined);
64200
64329
  }
64201
64330
  }
64202
- async function updateAllStickyMessages(platforms, sessions, config) {
64203
- const updates = [...platforms.values()].map((platform) => updateStickyMessage(platform, sessions, config));
64331
+ async function updateAllStickyMessages(platforms, sessions, config, overheadByPlatform) {
64332
+ const updates = [...platforms.values()].map((platform) => {
64333
+ const overhead = overheadByPlatform?.get(platform.platformId) ?? config.overhead;
64334
+ return updateStickyMessage(platform, sessions, { ...config, overhead });
64335
+ });
64204
64336
  await Promise.all(updates);
64205
64337
  }
64206
64338
  function markNeedsBump(platformId) {
@@ -65986,49 +66118,62 @@ async function requestMessageApproval(session, username, message, ctx) {
65986
66118
  fromUser: username
65987
66119
  });
65988
66120
  }
65989
- async function updateSessionHeader(session, ctx) {
65990
- if (!session.sessionStartPostId)
65991
- return;
66121
+ async function buildSessionHeaderStatusBar(session, ctx) {
65992
66122
  const formatter = session.platform.getFormatter();
65993
- const worktreeContext = session.worktreeInfo ? { path: session.worktreeInfo.worktreePath, branch: session.worktreeInfo.branch } : undefined;
65994
- const shortDir = shortenPath(session.workingDir, undefined, worktreeContext);
65995
66123
  const effectiveMode = effectivePermissionMode({
65996
66124
  override: session.permissionModeOverride,
65997
66125
  sessionHasInteractiveOverride: session.forceInteractivePermissions,
65998
66126
  botWideMode: ctx.config.permissionMode
65999
66127
  });
66000
66128
  const permMode = permissionModeDisplay(effectiveMode).chip;
66001
- const otherParticipants = [...session.sessionAllowedUsers].filter((u) => u !== session.startedBy).map((u) => formatter.formatUserMention(u)).join(", ");
66002
- const statusItems = [];
66003
- const versionStr = formatVersionString();
66004
- statusItems.push(formatter.formatCode(versionStr));
66129
+ const items = [];
66130
+ items.push(formatter.formatCode(formatVersionString()));
66005
66131
  if (session.usageStats) {
66006
66132
  const stats = session.usageStats;
66007
- statusItems.push(formatter.formatCode(`\uD83E\uDD16 ${stats.modelDisplayName}`));
66133
+ items.push(formatter.formatCode(`\uD83E\uDD16 ${stats.modelDisplayName}`));
66008
66134
  const contextPercent = Math.round(stats.contextTokens / stats.contextWindowSize * 100);
66009
- const contextBar = formatContextBar(contextPercent);
66010
- statusItems.push(formatter.formatCode(`${contextBar} ${contextPercent}%`));
66011
- statusItems.push(formatter.formatCode(`\uD83D\uDCB0 $${stats.totalCostUSD.toFixed(2)}`));
66135
+ items.push(formatter.formatCode(`${formatContextBar(contextPercent)} ${contextPercent}%`));
66136
+ items.push(formatter.formatCode(`\uD83D\uDCB0 $${stats.totalCostUSD.toFixed(2)}`));
66012
66137
  }
66013
- statusItems.push(formatter.formatCode(permMode));
66138
+ items.push(formatter.formatCode(permMode));
66014
66139
  if (session.messageManager?.getPendingApproval()?.type === "plan") {
66015
- statusItems.push(formatter.formatCode("\uD83D\uDCCB Plan pending"));
66140
+ items.push(formatter.formatCode("\uD83D\uDCCB Plan pending"));
66016
66141
  } else if (session.planApproved) {
66017
- statusItems.push(formatter.formatCode("\uD83D\uDD28 Implementing"));
66142
+ items.push(formatter.formatCode("\uD83D\uDD28 Implementing"));
66018
66143
  }
66019
66144
  if (ctx.config.chromeEnabled) {
66020
- statusItems.push(formatter.formatCode("\uD83C\uDF10 Chrome"));
66145
+ items.push(formatter.formatCode("\uD83C\uDF10 Chrome"));
66021
66146
  }
66022
66147
  if (keepAlive.isActive()) {
66023
- statusItems.push(formatter.formatCode("\uD83D\uDC93 Keep-alive"));
66148
+ items.push(formatter.formatCode("\uD83D\uDC93 Keep-alive"));
66024
66149
  }
66025
66150
  const battery = await formatBatteryStatus();
66026
66151
  if (battery) {
66027
- statusItems.push(formatter.formatCode(battery));
66152
+ items.push(formatter.formatCode(battery));
66028
66153
  }
66029
- const uptime = formatUptime(session.startedAt);
66030
- statusItems.push(formatter.formatCode(`⏱️ ${uptime}`));
66031
- const statusBar = statusItems.join(" · ");
66154
+ items.push(formatter.formatCode(`⏱️ ${formatUptime(session.startedAt)}`));
66155
+ return items.join(" · ");
66156
+ }
66157
+ async function updateSessionHeader(session, ctx) {
66158
+ if (session.sessionHeaderMode === "hidden")
66159
+ return;
66160
+ if (!session.sessionStartPostId)
66161
+ return;
66162
+ const formatter = session.platform.getFormatter();
66163
+ const statusBar = await buildSessionHeaderStatusBar(session, ctx);
66164
+ const updateInfo = getUpdateInfo();
66165
+ const updateNotice = updateInfo ? `> ⚠️ ${formatter.formatBold("Update available:")} v${updateInfo.current} → v${updateInfo.latest} - Run ${formatter.formatCode("bun install -g claude-threads")}
66166
+
66167
+ ` : undefined;
66168
+ if (session.sessionHeaderMode === "minimal") {
66169
+ const msg2 = [updateNotice, statusBar].filter(Boolean).join(`
66170
+ `);
66171
+ await updatePost(session, session.sessionStartPostId, msg2);
66172
+ return;
66173
+ }
66174
+ const worktreeContext = session.worktreeInfo ? { path: session.worktreeInfo.worktreePath, branch: session.worktreeInfo.branch } : undefined;
66175
+ const shortDir = shortenPath(session.workingDir, undefined, worktreeContext);
66176
+ const otherParticipants = [...session.sessionAllowedUsers].filter((u) => u !== session.startedBy).map((u) => formatter.formatUserMention(u)).join(", ");
66032
66177
  const items = [];
66033
66178
  if (session.sessionTitle) {
66034
66179
  items.push(["\uD83D\uDCDD", "Topic", session.sessionTitle]);
@@ -66074,10 +66219,6 @@ async function updateSessionHeader(session, ctx) {
66074
66219
  const logPath = getLogFilePath(session.platform.platformId, session.claudeSessionId);
66075
66220
  const shortLogPath = logPath.replace(process.env.HOME || "", "~");
66076
66221
  items.push(["\uD83D\uDCCB", "Log File", formatter.formatCode(shortLogPath)]);
66077
- const updateInfo = getUpdateInfo();
66078
- const updateNotice = updateInfo ? `> ⚠️ ${formatter.formatBold("Update available:")} v${updateInfo.current} → v${updateInfo.latest} - Run ${formatter.formatCode("bun install -g claude-threads")}
66079
-
66080
- ` : undefined;
66081
66222
  const msg = [
66082
66223
  updateNotice,
66083
66224
  statusBar,
@@ -66085,8 +66226,7 @@ async function updateSessionHeader(session, ctx) {
66085
66226
  formatter.formatKeyValueList(items)
66086
66227
  ].filter((item) => item !== null && item !== undefined).join(`
66087
66228
  `);
66088
- const postId = session.sessionStartPostId;
66089
- await updatePost(session, postId, msg);
66229
+ await updatePost(session, session.sessionStartPostId, msg);
66090
66230
  }
66091
66231
  async function showUpdateStatus(session, updateManager, ctx) {
66092
66232
  const formatter = session.platform.getFormatter();
@@ -66586,6 +66726,17 @@ function maybeInjectMetadataReminder(message, session, ctx, fullSession) {
66586
66726
  }
66587
66727
  return message;
66588
66728
  }
66729
+ function resumeSessionHeaderMode(persisted, platformConfigured) {
66730
+ return persisted ?? platformConfigured ?? DEFAULT_OVERHEAD_VISIBILITY;
66731
+ }
66732
+ function resolveSessionHeaderMode(configured, replyToPostId, platformId) {
66733
+ const mode = configured ?? DEFAULT_OVERHEAD_VISIBILITY;
66734
+ if (mode === "hidden" && !replyToPostId) {
66735
+ log30.error(`sessionHeader: hidden requires a replyToPostId for ${platformId}; ` + `downgrading this session to 'minimal' so the header post is still short.`);
66736
+ return "minimal";
66737
+ }
66738
+ return mode;
66739
+ }
66589
66740
  async function startSession(options, username, displayName, replyToPostId, platformId, ctx, triggeringPostId, initialOptions) {
66590
66741
  const threadId = replyToPostId || "";
66591
66742
  const existingSessionId = ctx.ops.getSessionId(platformId, threadId);
@@ -66599,6 +66750,10 @@ async function startSession(options, username, displayName, replyToPostId, platf
66599
66750
  if (!platform) {
66600
66751
  throw new Error(`Platform '${platformId}' not found. Call addPlatform() first.`);
66601
66752
  }
66753
+ if (!isAuthorizedForSession({ username, platform, sessionAllowedUsers: undefined })) {
66754
+ log30.warn(`auth.denied.startSession: @${username || "unknown"} not authorized to start session in ${threadId.substring(0, 8)}...`);
66755
+ return;
66756
+ }
66602
66757
  const activeOrPending = ctx.state.sessions.size + pendingStartsCount;
66603
66758
  if (activeOrPending >= ctx.config.maxSessions) {
66604
66759
  const formatter2 = platform.getFormatter();
@@ -66611,13 +66766,18 @@ async function startSession(options, username, displayName, replyToPostId, platf
66611
66766
  return;
66612
66767
  }
66613
66768
  pendingStartsCount++;
66769
+ const sessionHeaderMode = resolveSessionHeaderMode(ctx.ops.getPlatformOverhead(platformId).sessionHeader, replyToPostId, platformId);
66614
66770
  const startFormatter = platform.getFormatter();
66615
- const startPost = await withErrorHandling(() => platform.createPost(startFormatter.formatItalic("Claude Threads session starting..."), replyToPostId), { action: "Create session post" });
66616
- if (!startPost) {
66617
- releasePendingStart();
66618
- return;
66771
+ const skipHeaderPost = sessionHeaderMode === "hidden";
66772
+ let startPost;
66773
+ if (!skipHeaderPost) {
66774
+ startPost = await withErrorHandling(() => platform.createPost(startFormatter.formatItalic("Claude Threads session starting..."), replyToPostId), { action: "Create session post" });
66775
+ if (!startPost) {
66776
+ releasePendingStart();
66777
+ return;
66778
+ }
66619
66779
  }
66620
- const actualThreadId = replyToPostId || startPost.id;
66780
+ const actualThreadId = replyToPostId || (startPost ? startPost.id : "");
66621
66781
  const sessionId = ctx.ops.getSessionId(platformId, actualThreadId);
66622
66782
  platform.sendTyping(actualThreadId);
66623
66783
  const claudeSessionId = randomUUID4();
@@ -66631,13 +66791,23 @@ async function startSession(options, username, displayName, replyToPostId, platf
66631
66791
  const requestedDir = initialOptions.workingDir.startsWith("~") ? initialOptions.workingDir.replace("~", process.env.HOME || "") : initialOptions.workingDir;
66632
66792
  const resolvedDir = resolve6(requestedDir);
66633
66793
  if (!existsSync12(resolvedDir)) {
66634
- await platform.updatePost(startPost.id, `❌ Directory does not exist: ${formatter.formatCode(initialOptions.workingDir)}`);
66794
+ const msg = `❌ Directory does not exist: ${formatter.formatCode(initialOptions.workingDir)}`;
66795
+ if (startPost) {
66796
+ await platform.updatePost(startPost.id, msg);
66797
+ } else {
66798
+ await platform.createPost(msg, replyToPostId);
66799
+ }
66635
66800
  releasePendingStart();
66636
66801
  return;
66637
66802
  }
66638
66803
  const { statSync: statSync4 } = await import("fs");
66639
66804
  if (!statSync4(resolvedDir).isDirectory()) {
66640
- await platform.updatePost(startPost.id, `❌ Not a directory: ${formatter.formatCode(initialOptions.workingDir)}`);
66805
+ const msg = `❌ Not a directory: ${formatter.formatCode(initialOptions.workingDir)}`;
66806
+ if (startPost) {
66807
+ await platform.updatePost(startPost.id, msg);
66808
+ } else {
66809
+ await platform.createPost(msg, replyToPostId);
66810
+ }
66641
66811
  releasePendingStart();
66642
66812
  return;
66643
66813
  }
@@ -66695,7 +66865,8 @@ async function startSession(options, username, displayName, replyToPostId, platf
66695
66865
  sessionAllowedUsers: new Set([username]),
66696
66866
  forceInteractivePermissions,
66697
66867
  permissionModeOverride: sessionPermissionModeOverride,
66698
- sessionStartPostId: startPost.id,
66868
+ sessionStartPostId: startPost ? startPost.id : null,
66869
+ sessionHeaderMode,
66699
66870
  timers: createSessionTimers(),
66700
66871
  lifecycle: createSessionLifecycle(),
66701
66872
  timeoutWarningPosted: false,
@@ -66714,7 +66885,9 @@ async function startSession(options, username, displayName, replyToPostId, platf
66714
66885
  });
66715
66886
  mutableSessions(ctx).set(sessionId, session);
66716
66887
  releasePendingStart();
66717
- ctx.ops.registerPost(startPost.id, actualThreadId);
66888
+ if (startPost) {
66889
+ ctx.ops.registerPost(startPost.id, actualThreadId);
66890
+ }
66718
66891
  ctx.ops.emitSessionAdd(session);
66719
66892
  sessionLog6(session).info(`▶ Session started by @${username}`);
66720
66893
  fireMetadataSuggestions(session, options.prompt, ctx);
@@ -66847,6 +67020,7 @@ Please start a new session.`), { action: "Post resume failure notification" });
66847
67020
  sessionAllowedUsers: new Set(state.sessionAllowedUsers),
66848
67021
  forceInteractivePermissions: state.forceInteractivePermissions ?? false,
66849
67022
  sessionStartPostId: state.sessionStartPostId ?? null,
67023
+ sessionHeaderMode: resumeSessionHeaderMode(state.sessionHeaderMode, ctx.ops.getPlatformOverhead(platformId).sessionHeader),
66850
67024
  timers: createSessionTimers(),
66851
67025
  lifecycle: createResumedLifecycle(state.resumeFailCount ?? 0),
66852
67026
  timeoutWarningPosted: false,
@@ -66947,9 +67121,15 @@ ${failFormatter.formatItalic("Your previous conversation context is preserved, b
66947
67121
  await ctx.ops.updateStickyMessage();
66948
67122
  }
66949
67123
  }
66950
- async function sendFollowUp(session, message, files, ctx, username, displayName) {
67124
+ async function sendFollowUp(session, message, files, ctx, username, displayName, options) {
66951
67125
  if (!session.claude.isRunning())
66952
67126
  return;
67127
+ if (!options?.system) {
67128
+ if (!isAuthorizedForSession({ username, platform: session.platform, sessionAllowedUsers: session.sessionAllowedUsers })) {
67129
+ sessionLog6(session).warn(`auth.denied.sendFollowUp: @${username || "unknown"} not authorized`);
67130
+ return;
67131
+ }
67132
+ }
66953
67133
  if (session.needsContextPromptOnNextMessage) {
66954
67134
  session.needsContextPromptOnNextMessage = false;
66955
67135
  await session.messageManager?.prepareForUserMessage();
@@ -66972,7 +67152,7 @@ async function sendFollowUp(session, message, files, ctx, username, displayName)
66972
67152
  session.messageCount++;
66973
67153
  await session.messageManager.handleUserMessage(messageToSend, files, username, displayName);
66974
67154
  }
66975
- async function resumePausedSession(threadId, message, files, ctx) {
67155
+ async function resumePausedSession(threadId, message, files, ctx, username) {
66976
67156
  const persisted = ctx.state.sessionStore.load();
66977
67157
  const state = findPersistedByThreadId(persisted, threadId);
66978
67158
  if (!state) {
@@ -66980,6 +67160,16 @@ async function resumePausedSession(threadId, message, files, ctx) {
66980
67160
  return;
66981
67161
  }
66982
67162
  const shortId = threadId.substring(0, 8);
67163
+ const platform = ctx.state.platforms.get(state.platformId);
67164
+ if (!platform) {
67165
+ log30.warn(`auth.denied.resume: platform '${state.platformId}' not found for ${shortId}...`);
67166
+ return;
67167
+ }
67168
+ const sessionAllowedUsers = new Set(state.sessionAllowedUsers || [state.startedBy].filter(Boolean));
67169
+ if (!isAuthorizedForSession({ username, platform, sessionAllowedUsers })) {
67170
+ log30.warn(`auth.denied.resume: @${username || "unknown"} not authorized to resume ${shortId}...`);
67171
+ return;
67172
+ }
66983
67173
  log30.info(`\uD83D\uDD04 Resuming paused session ${shortId}... for new message`);
66984
67174
  await resumeSession(state, ctx);
66985
67175
  const session = ctx.ops.findSessionByThreadId(threadId);
@@ -67497,9 +67687,9 @@ async function tryResumeFromReaction(deps, platformId, postId, username) {
67497
67687
  const sessionId = `${platformId}:${persistedSession.threadId}`;
67498
67688
  if (deps.registry.hasById(sessionId))
67499
67689
  return false;
67500
- const allowedUsers = new Set(persistedSession.sessionAllowedUsers);
67501
67690
  const platform = deps.platforms.get(platformId);
67502
- if (!allowedUsers.has(username) && !platform?.isUserAllowed(username)) {
67691
+ const sessionAllowedUsers = new Set(persistedSession.sessionAllowedUsers || [persistedSession.startedBy].filter(Boolean));
67692
+ if (!platform || !isAuthorizedForSession({ username, platform, sessionAllowedUsers })) {
67503
67693
  if (platform) {
67504
67694
  await platform.createPost(`⚠️ @${username} is not authorized to resume this session`, persistedSession.threadId);
67505
67695
  }
@@ -67581,6 +67771,7 @@ class SessionManager extends EventEmitter4 {
67581
67771
  isShuttingDown = false;
67582
67772
  customDescription;
67583
67773
  customFooter;
67774
+ platformOverhead = new Map;
67584
67775
  autoUpdateManager = null;
67585
67776
  accountPool;
67586
67777
  constructor(workingDir, permissionModeOrSkipFlag = "default", chromeEnabled = false, worktreeMode = "prompt", sessionsPath, threadLogsEnabled = true, threadLogsRetentionDays = 30, limits, claudeAccounts) {
@@ -67612,8 +67803,12 @@ class SessionManager extends EventEmitter4 {
67612
67803
  cleanupWorktrees: this.limits.cleanupWorktrees
67613
67804
  });
67614
67805
  }
67615
- addPlatform(platformId, client) {
67806
+ addPlatform(platformId, client, overhead) {
67616
67807
  this.platforms.set(platformId, client);
67808
+ this.platformOverhead.set(platformId, {
67809
+ sessionHeader: overhead?.sessionHeader ?? DEFAULT_OVERHEAD_VISIBILITY,
67810
+ stickyMessage: overhead?.stickyMessage ?? DEFAULT_OVERHEAD_VISIBILITY
67811
+ });
67617
67812
  client.on("message", (post2, user) => this.handleMessage(platformId, post2, user));
67618
67813
  client.on("reaction", (reaction, user) => {
67619
67814
  if (user) {
@@ -67626,6 +67821,9 @@ class SessionManager extends EventEmitter4 {
67626
67821
  }
67627
67822
  });
67628
67823
  client.on("channel_post", () => {
67824
+ if (this.platformOverhead.get(platformId)?.stickyMessage === "hidden") {
67825
+ return;
67826
+ }
67629
67827
  markNeedsBump(platformId);
67630
67828
  this.updateStickyMessage();
67631
67829
  });
@@ -67633,6 +67831,8 @@ class SessionManager extends EventEmitter4 {
67633
67831
  }
67634
67832
  removePlatform(platformId) {
67635
67833
  this.platforms.delete(platformId);
67834
+ this.platformOverhead.delete(platformId);
67835
+ clearHiddenCleanupTracking(platformId);
67636
67836
  }
67637
67837
  setAutoUpdateManager(manager) {
67638
67838
  this.autoUpdateManager = manager;
@@ -67718,7 +67918,11 @@ class SessionManager extends EventEmitter4 {
67718
67918
  getClaudeAccount: (id) => this.accountPool.get(id),
67719
67919
  releaseClaudeAccount: (id) => this.accountPool.release(id),
67720
67920
  markClaudeAccountCooling: (id, untilMs) => this.accountPool.markCooling(id, untilMs),
67721
- getClaudeAccountPoolStatus: () => this.accountPool.status()
67921
+ getClaudeAccountPoolStatus: () => this.accountPool.status(),
67922
+ getPlatformOverhead: (pid) => this.platformOverhead.get(pid) ?? {
67923
+ sessionHeader: DEFAULT_OVERHEAD_VISIBILITY,
67924
+ stickyMessage: DEFAULT_OVERHEAD_VISIBILITY
67925
+ }
67722
67926
  };
67723
67927
  return createSessionContext(config, state, ops);
67724
67928
  }
@@ -67878,7 +68082,8 @@ class SessionManager extends EventEmitter4 {
67878
68082
  pullRequestUrl: session.pullRequestUrl,
67879
68083
  messageCount: session.messageCount,
67880
68084
  resumeFailCount: session.lifecycle.resumeFailCount,
67881
- claudeAccountId: session.claudeAccountId
68085
+ claudeAccountId: session.claudeAccountId,
68086
+ sessionHeaderMode: session.sessionHeaderMode
67882
68087
  };
67883
68088
  this.sessionStore.save(session.sessionId, state);
67884
68089
  }
@@ -67895,6 +68100,10 @@ class SessionManager extends EventEmitter4 {
67895
68100
  });
67896
68101
  }
67897
68102
  async updateStickyMessage() {
68103
+ const overheadByPlatform = new Map;
68104
+ for (const [platformId, overhead] of this.platformOverhead) {
68105
+ overheadByPlatform.set(platformId, overhead.stickyMessage);
68106
+ }
67898
68107
  await updateAllStickyMessages(this.platforms, this.registry.getSessions(), {
67899
68108
  maxSessions: this.limits.maxSessions,
67900
68109
  chromeEnabled: this.chromeEnabled,
@@ -67905,7 +68114,7 @@ class SessionManager extends EventEmitter4 {
67905
68114
  description: this.customDescription,
67906
68115
  footer: this.customFooter,
67907
68116
  accountPoolStatus: this.accountPool.isEmpty ? undefined : this.accountPool.status()
67908
- });
68117
+ }, overheadByPlatform);
67909
68118
  }
67910
68119
  async updateAllStickyMessages() {
67911
68120
  await this.updateStickyMessage();
@@ -68066,11 +68275,11 @@ class SessionManager extends EventEmitter4 {
68066
68275
  }
68067
68276
  return;
68068
68277
  }
68069
- async sendFollowUp(threadId, message, files, username, displayName) {
68278
+ async sendFollowUp(threadId, message, files, username, displayName, options) {
68070
68279
  const session = this.findSessionByThreadId(threadId);
68071
68280
  if (!session || !session.claude.isRunning())
68072
68281
  return;
68073
- await sendFollowUp(session, message, files, this.getContext(), username, displayName);
68282
+ await sendFollowUp(session, message, files, this.getContext(), username, displayName, options);
68074
68283
  }
68075
68284
  isSessionActive() {
68076
68285
  return this.registry.size > 0;
@@ -68084,8 +68293,8 @@ class SessionManager extends EventEmitter4 {
68084
68293
  return false;
68085
68294
  return this.registry.getPersistedByThreadId(threadId) !== undefined;
68086
68295
  }
68087
- async resumePausedSession(threadId, message, files) {
68088
- await resumePausedSession(threadId, message, files, this.getContext());
68296
+ async resumePausedSession(threadId, message, files, username) {
68297
+ await resumePausedSession(threadId, message, files, this.getContext(), username);
68089
68298
  }
68090
68299
  getPersistedSession(threadId) {
68091
68300
  return this.registry.getPersistedByThreadId(threadId);
@@ -78138,7 +78347,7 @@ async function handleMessage(client, session, post2, user, options) {
78138
78347
  }
78139
78348
  const files2 = post2.metadata?.files;
78140
78349
  if (content || files2?.length) {
78141
- await session.resumePausedSession(threadRoot, content, files2);
78350
+ await session.resumePausedSession(threadRoot, content, files2, username);
78142
78351
  }
78143
78352
  return;
78144
78353
  }
@@ -79149,7 +79358,7 @@ function wirePlatformEvents(platformId, client, session, ui) {
79149
79358
  ui.addLog({ level: "error", component: platformId, message });
79150
79359
  });
79151
79360
  }
79152
- program.name("claude-threads").version(VERSION).description("Share Claude Code sessions in Mattermost").option("--url <url>", "Mattermost server URL").option("--token <token>", "Mattermost bot token").option("--channel <id>", "Mattermost channel ID").option("--bot-name <name>", "Bot mention name (default: claude-code)").option("--allowed-users <users>", "Comma-separated allowed usernames").option("--permission-mode <mode>", "Permission mode: default | auto | bypass (default: from config)").option("--skip-permissions", "[deprecated] Alias for --permission-mode bypass").option("--no-skip-permissions", "[deprecated] Alias for --permission-mode default").option("--chrome", "Enable Claude in Chrome integration").option("--no-chrome", "Disable Claude in Chrome integration").option("--worktree-mode <mode>", "Git worktree mode: off, prompt, require (default: prompt)").option("--keep-alive", "Enable system sleep prevention (default: enabled)").option("--no-keep-alive", "Disable system sleep prevention").option("--setup", "Run interactive setup wizard (reconfigure existing settings)").option("--debug", "Enable debug logging").option("--skip-version-check", "Skip Claude CLI version compatibility check").option("--auto-restart", "Enable auto-restart on updates (default when autoUpdate enabled)").option("--no-auto-restart", "Disable auto-restart on updates").option("--headless", "Run without interactive UI (logs to stdout)").parse();
79361
+ program.name("claude-threads").version(VERSION).description("Share Claude Code sessions in Mattermost").option("--url <url>", "Mattermost server URL").option("--token <token>", "Mattermost bot token").option("--channel <id>", "Mattermost channel ID").option("--bot-name <name>", "Bot mention name (default: claude-code)").option("--allowed-users <users>", "Comma-separated allowed usernames").option("--permission-mode <mode>", "Permission mode: default | auto | bypass (default: from config)").option("--skip-permissions", "[deprecated] Alias for --permission-mode bypass").option("--no-skip-permissions", "[deprecated] Alias for --permission-mode default").option("--chrome", "Enable Claude in Chrome integration").option("--no-chrome", "Disable Claude in Chrome integration").option("--worktree-mode <mode>", "Git worktree mode: off, prompt, require (default: prompt)").option("--session-header <mode>", "Per-thread session header: full | minimal | hidden. Overrides per-platform config.").option("--sticky-message <mode>", "Channel sticky message: full | minimal | hidden. Overrides per-platform config.").option("--keep-alive", "Enable system sleep prevention (default: enabled)").option("--no-keep-alive", "Disable system sleep prevention").option("--setup", "Run interactive setup wizard (reconfigure existing settings)").option("--debug", "Enable debug logging").option("--skip-version-check", "Skip Claude CLI version compatibility check").option("--auto-restart", "Enable auto-restart on updates (default when autoUpdate enabled)").option("--no-auto-restart", "Disable auto-restart on updates").option("--headless", "Run without interactive UI (logs to stdout)").parse();
79153
79362
  var opts = program.opts();
79154
79363
  var forcedInteractive = !!process.env.CLAUDE_THREADS_INTERACTIVE;
79155
79364
  var isHeadless = opts.headless || !forcedInteractive && (!process.stdout.isTTY || !process.stdin.isTTY);
@@ -79237,6 +79446,14 @@ async function startWithoutDaemon() {
79237
79446
  console.error(red(` ❌ Invalid --permission-mode: "${opts.permissionMode}". Must be one of: default, auto, bypass.`));
79238
79447
  process.exit(1);
79239
79448
  }
79449
+ if (opts.sessionHeader !== undefined && !isOverheadVisibility(opts.sessionHeader)) {
79450
+ console.error(red(` ❌ Invalid --session-header: "${opts.sessionHeader}". Must be one of: ${OVERHEAD_VISIBILITY_VALUES.join(", ")}.`));
79451
+ process.exit(1);
79452
+ }
79453
+ if (opts.stickyMessage !== undefined && !isOverheadVisibility(opts.stickyMessage)) {
79454
+ console.error(red(` ❌ Invalid --sticky-message: "${opts.stickyMessage}". Must be one of: ${OVERHEAD_VISIBILITY_VALUES.join(", ")}.`));
79455
+ process.exit(1);
79456
+ }
79240
79457
  const cliArgs = {
79241
79458
  url: opts.url,
79242
79459
  token: opts.token,
@@ -79247,7 +79464,9 @@ async function startWithoutDaemon() {
79247
79464
  permissionMode: opts.permissionMode,
79248
79465
  chrome: opts.chrome,
79249
79466
  worktreeMode: opts.worktreeMode,
79250
- keepAlive: opts.keepAlive
79467
+ keepAlive: opts.keepAlive,
79468
+ sessionHeader: opts.sessionHeader,
79469
+ stickyMessage: opts.stickyMessage
79251
79470
  };
79252
79471
  if (opts.setup) {
79253
79472
  await runOnboarding(true);
@@ -79268,6 +79487,16 @@ async function startWithoutDaemon() {
79268
79487
  if (cliArgs.keepAlive !== undefined) {
79269
79488
  newConfig.keepAlive = cliArgs.keepAlive;
79270
79489
  }
79490
+ if (cliArgs.sessionHeader !== undefined) {
79491
+ for (const p of newConfig.platforms) {
79492
+ p.sessionHeader = cliArgs.sessionHeader;
79493
+ }
79494
+ }
79495
+ if (cliArgs.stickyMessage !== undefined) {
79496
+ for (const p of newConfig.platforms) {
79497
+ p.stickyMessage = cliArgs.stickyMessage;
79498
+ }
79499
+ }
79271
79500
  const keepAliveEnabled = newConfig.keepAlive !== false;
79272
79501
  if (!newConfig.platforms || newConfig.platforms.length === 0) {
79273
79502
  throw new Error("No platforms configured. Run with --setup to configure.");
@@ -79441,7 +79670,10 @@ async function startWithoutDaemon() {
79441
79670
  });
79442
79671
  const client = createPlatformClient(platformConfig);
79443
79672
  platforms.set(platformConfig.id, client);
79444
- session.addPlatform(platformConfig.id, client);
79673
+ session.addPlatform(platformConfig.id, client, {
79674
+ sessionHeader: resolveOverheadVisibility(platformConfig.sessionHeader, `platforms[${platformConfig.id}].sessionHeader`),
79675
+ stickyMessage: resolveOverheadVisibility(platformConfig.stickyMessage, `platforms[${platformConfig.id}].stickyMessage`)
79676
+ });
79445
79677
  wirePlatformEvents(platformConfig.id, client, session, ui);
79446
79678
  }
79447
79679
  const enabledPlatforms = Array.from(platforms.entries()).filter(([id]) => platformEnabledState.get(id) ?? true);
@@ -3035,6 +3035,9 @@ var require_data = __commonJS((exports, module) => {
3035
3035
  var require_utils = __commonJS((exports, module) => {
3036
3036
  var isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu);
3037
3037
  var isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u);
3038
+ var isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu);
3039
+ var isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu);
3040
+ var isPathCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/]$/iu);
3038
3041
  function stringArrayToHexStripped(input) {
3039
3042
  let acc = "";
3040
3043
  let code = 0;
@@ -3228,27 +3231,77 @@ var require_utils = __commonJS((exports, module) => {
3228
3231
  }
3229
3232
  return output.join("");
3230
3233
  }
3231
- function normalizeComponentEncoding(component, esc2) {
3232
- const func = esc2 !== true ? escape : unescape;
3233
- if (component.scheme !== undefined) {
3234
- component.scheme = func(component.scheme);
3235
- }
3236
- if (component.userinfo !== undefined) {
3237
- component.userinfo = func(component.userinfo);
3238
- }
3239
- if (component.host !== undefined) {
3240
- component.host = func(component.host);
3234
+ var HOST_DELIMS = { "@": "%40", "/": "%2F", "?": "%3F", "#": "%23", ":": "%3A" };
3235
+ var HOST_DELIM_RE = /[@/?#:]/g;
3236
+ var HOST_DELIM_NO_COLON_RE = /[@/?#]/g;
3237
+ function reescapeHostDelimiters(host, isIP) {
3238
+ const re = isIP ? HOST_DELIM_NO_COLON_RE : HOST_DELIM_RE;
3239
+ re.lastIndex = 0;
3240
+ return host.replace(re, (ch) => HOST_DELIMS[ch]);
3241
+ }
3242
+ function normalizePercentEncoding(input, decodeUnreserved = false) {
3243
+ if (input.indexOf("%") === -1) {
3244
+ return input;
3241
3245
  }
3242
- if (component.path !== undefined) {
3243
- component.path = func(component.path);
3246
+ let output = "";
3247
+ for (let i = 0;i < input.length; i++) {
3248
+ if (input[i] === "%" && i + 2 < input.length) {
3249
+ const hex = input.slice(i + 1, i + 3);
3250
+ if (isHexPair(hex)) {
3251
+ const normalizedHex = hex.toUpperCase();
3252
+ const decoded = String.fromCharCode(parseInt(normalizedHex, 16));
3253
+ if (decodeUnreserved && isUnreserved(decoded)) {
3254
+ output += decoded;
3255
+ } else {
3256
+ output += "%" + normalizedHex;
3257
+ }
3258
+ i += 2;
3259
+ continue;
3260
+ }
3261
+ }
3262
+ output += input[i];
3244
3263
  }
3245
- if (component.query !== undefined) {
3246
- component.query = func(component.query);
3264
+ return output;
3265
+ }
3266
+ function normalizePathEncoding(input) {
3267
+ let output = "";
3268
+ for (let i = 0;i < input.length; i++) {
3269
+ if (input[i] === "%" && i + 2 < input.length) {
3270
+ const hex = input.slice(i + 1, i + 3);
3271
+ if (isHexPair(hex)) {
3272
+ const normalizedHex = hex.toUpperCase();
3273
+ const decoded = String.fromCharCode(parseInt(normalizedHex, 16));
3274
+ if (decoded !== "." && isUnreserved(decoded)) {
3275
+ output += decoded;
3276
+ } else {
3277
+ output += "%" + normalizedHex;
3278
+ }
3279
+ i += 2;
3280
+ continue;
3281
+ }
3282
+ }
3283
+ if (isPathCharacter(input[i])) {
3284
+ output += input[i];
3285
+ } else {
3286
+ output += escape(input[i]);
3287
+ }
3247
3288
  }
3248
- if (component.fragment !== undefined) {
3249
- component.fragment = func(component.fragment);
3289
+ return output;
3290
+ }
3291
+ function escapePreservingEscapes(input) {
3292
+ let output = "";
3293
+ for (let i = 0;i < input.length; i++) {
3294
+ if (input[i] === "%" && i + 2 < input.length) {
3295
+ const hex = input.slice(i + 1, i + 3);
3296
+ if (isHexPair(hex)) {
3297
+ output += "%" + hex.toUpperCase();
3298
+ i += 2;
3299
+ continue;
3300
+ }
3301
+ }
3302
+ output += escape(input[i]);
3250
3303
  }
3251
- return component;
3304
+ return output;
3252
3305
  }
3253
3306
  function recomposeAuthority(component) {
3254
3307
  const uriTokens = [];
@@ -3263,7 +3316,7 @@ var require_utils = __commonJS((exports, module) => {
3263
3316
  if (ipV6res.isIPV6 === true) {
3264
3317
  host = `[${ipV6res.escapedHost}]`;
3265
3318
  } else {
3266
- host = component.host;
3319
+ host = reescapeHostDelimiters(host, false);
3267
3320
  }
3268
3321
  }
3269
3322
  uriTokens.push(host);
@@ -3277,7 +3330,10 @@ var require_utils = __commonJS((exports, module) => {
3277
3330
  module.exports = {
3278
3331
  nonSimpleDomain,
3279
3332
  recomposeAuthority,
3280
- normalizeComponentEncoding,
3333
+ reescapeHostDelimiters,
3334
+ normalizePercentEncoding,
3335
+ normalizePathEncoding,
3336
+ escapePreservingEscapes,
3281
3337
  removeDotSegments,
3282
3338
  isIPv4,
3283
3339
  isUUID,
@@ -3462,11 +3518,11 @@ var require_schemes = __commonJS((exports, module) => {
3462
3518
 
3463
3519
  // node_modules/fast-uri/index.js
3464
3520
  var require_fast_uri = __commonJS((exports, module) => {
3465
- var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizeComponentEncoding, isIPv4, nonSimpleDomain } = require_utils();
3521
+ var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, escapePreservingEscapes, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require_utils();
3466
3522
  var { SCHEMES, getSchemeHandler } = require_schemes();
3467
3523
  function normalize(uri, options) {
3468
3524
  if (typeof uri === "string") {
3469
- uri = serialize(parse6(uri, options), options);
3525
+ uri = normalizeString(uri, options);
3470
3526
  } else if (typeof uri === "object") {
3471
3527
  uri = parse6(serialize(uri, options), options);
3472
3528
  }
@@ -3532,19 +3588,9 @@ var require_fast_uri = __commonJS((exports, module) => {
3532
3588
  return target;
3533
3589
  }
3534
3590
  function equal(uriA, uriB, options) {
3535
- if (typeof uriA === "string") {
3536
- uriA = unescape(uriA);
3537
- uriA = serialize(normalizeComponentEncoding(parse6(uriA, options), true), { ...options, skipEscape: true });
3538
- } else if (typeof uriA === "object") {
3539
- uriA = serialize(normalizeComponentEncoding(uriA, true), { ...options, skipEscape: true });
3540
- }
3541
- if (typeof uriB === "string") {
3542
- uriB = unescape(uriB);
3543
- uriB = serialize(normalizeComponentEncoding(parse6(uriB, options), true), { ...options, skipEscape: true });
3544
- } else if (typeof uriB === "object") {
3545
- uriB = serialize(normalizeComponentEncoding(uriB, true), { ...options, skipEscape: true });
3546
- }
3547
- return uriA.toLowerCase() === uriB.toLowerCase();
3591
+ const normalizedA = normalizeComparableURI(uriA, options);
3592
+ const normalizedB = normalizeComparableURI(uriB, options);
3593
+ return normalizedA !== undefined && normalizedB !== undefined && normalizedA.toLowerCase() === normalizedB.toLowerCase();
3548
3594
  }
3549
3595
  function serialize(cmpts, opts) {
3550
3596
  const component = {
@@ -3570,12 +3616,12 @@ var require_fast_uri = __commonJS((exports, module) => {
3570
3616
  schemeHandler.serialize(component, options);
3571
3617
  if (component.path !== undefined) {
3572
3618
  if (!options.skipEscape) {
3573
- component.path = escape(component.path);
3619
+ component.path = escapePreservingEscapes(component.path);
3574
3620
  if (component.scheme !== undefined) {
3575
3621
  component.path = component.path.split("%3A").join(":");
3576
3622
  }
3577
3623
  } else {
3578
- component.path = unescape(component.path);
3624
+ component.path = normalizePercentEncoding(component.path);
3579
3625
  }
3580
3626
  }
3581
3627
  if (options.reference !== "suffix" && component.scheme) {
@@ -3610,7 +3656,16 @@ var require_fast_uri = __commonJS((exports, module) => {
3610
3656
  return uriTokens.join("");
3611
3657
  }
3612
3658
  var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
3613
- function parse6(uri, opts) {
3659
+ function getParseError(parsed, matches) {
3660
+ if (matches[2] !== undefined && parsed.path && parsed.path[0] !== "/") {
3661
+ return 'URI path must start with "/" when authority is present.';
3662
+ }
3663
+ if (typeof parsed.port === "number" && (parsed.port < 0 || parsed.port > 65535)) {
3664
+ return "URI port is malformed.";
3665
+ }
3666
+ return;
3667
+ }
3668
+ function parseWithStatus(uri, opts) {
3614
3669
  const options = Object.assign({}, opts);
3615
3670
  const parsed = {
3616
3671
  scheme: undefined,
@@ -3621,6 +3676,7 @@ var require_fast_uri = __commonJS((exports, module) => {
3621
3676
  query: undefined,
3622
3677
  fragment: undefined
3623
3678
  };
3679
+ let malformedAuthorityOrPort = false;
3624
3680
  let isIP = false;
3625
3681
  if (options.reference === "suffix") {
3626
3682
  if (options.scheme) {
@@ -3641,6 +3697,11 @@ var require_fast_uri = __commonJS((exports, module) => {
3641
3697
  if (isNaN(parsed.port)) {
3642
3698
  parsed.port = matches[5];
3643
3699
  }
3700
+ const parseError = getParseError(parsed, matches);
3701
+ if (parseError !== undefined) {
3702
+ parsed.error = parsed.error || parseError;
3703
+ malformedAuthorityOrPort = true;
3704
+ }
3644
3705
  if (parsed.host) {
3645
3706
  const ipv4result = isIPv4(parsed.host);
3646
3707
  if (ipv4result === false) {
@@ -3679,14 +3740,18 @@ var require_fast_uri = __commonJS((exports, module) => {
3679
3740
  parsed.scheme = unescape(parsed.scheme);
3680
3741
  }
3681
3742
  if (parsed.host !== undefined) {
3682
- parsed.host = unescape(parsed.host);
3743
+ parsed.host = reescapeHostDelimiters(unescape(parsed.host), isIP);
3683
3744
  }
3684
3745
  }
3685
3746
  if (parsed.path) {
3686
- parsed.path = escape(unescape(parsed.path));
3747
+ parsed.path = normalizePathEncoding(parsed.path);
3687
3748
  }
3688
3749
  if (parsed.fragment) {
3689
- parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment));
3750
+ try {
3751
+ parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment));
3752
+ } catch {
3753
+ parsed.error = parsed.error || "URI malformed";
3754
+ }
3690
3755
  }
3691
3756
  }
3692
3757
  if (schemeHandler && schemeHandler.parse) {
@@ -3695,7 +3760,29 @@ var require_fast_uri = __commonJS((exports, module) => {
3695
3760
  } else {
3696
3761
  parsed.error = parsed.error || "URI can not be parsed.";
3697
3762
  }
3698
- return parsed;
3763
+ return { parsed, malformedAuthorityOrPort };
3764
+ }
3765
+ function parse6(uri, opts) {
3766
+ return parseWithStatus(uri, opts).parsed;
3767
+ }
3768
+ function normalizeString(uri, opts) {
3769
+ return normalizeStringWithStatus(uri, opts).normalized;
3770
+ }
3771
+ function normalizeStringWithStatus(uri, opts) {
3772
+ const { parsed, malformedAuthorityOrPort } = parseWithStatus(uri, opts);
3773
+ return {
3774
+ normalized: malformedAuthorityOrPort ? uri : serialize(parsed, opts),
3775
+ malformedAuthorityOrPort
3776
+ };
3777
+ }
3778
+ function normalizeComparableURI(uri, opts) {
3779
+ if (typeof uri === "string") {
3780
+ const { normalized, malformedAuthorityOrPort } = normalizeStringWithStatus(uri, opts);
3781
+ return malformedAuthorityOrPort ? undefined : normalized;
3782
+ }
3783
+ if (typeof uri === "object") {
3784
+ return serialize(uri, opts);
3785
+ }
3699
3786
  }
3700
3787
  var fastUri = {
3701
3788
  SCHEMES,
@@ -50956,7 +51043,6 @@ function formatBytes(bytes) {
50956
51043
 
50957
51044
  // src/operations/streaming/handler.ts
50958
51045
  var log2 = createLogger("streaming");
50959
- var MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
50960
51046
  async function postSkippedFilesFeedback(platform, threadId, skipped) {
50961
51047
  if (skipped.length === 0)
50962
51048
  return;
@@ -54449,6 +54535,7 @@ var pausedPlatforms = new Map;
54449
54535
  var lastCleanupTime = new Map;
54450
54536
  var CLEANUP_THROTTLE_MS = 5 * 60 * 1000;
54451
54537
  var CLEANUP_MAX_AGE_MS = 60 * 60 * 1000;
54538
+ var hiddenCleanupDone = new Set;
54452
54539
  // node_modules/@redactpii/node/lib/index.mjs
54453
54540
  class Redactor {
54454
54541
  apiKey;
@@ -55722,7 +55809,7 @@ function createPassthroughHandler(slashCommand) {
55722
55809
  return { handled: false };
55723
55810
  }
55724
55811
  if (ctx.isAllowed) {
55725
- await ctx.sessionManager.sendFollowUp(ctx.threadId, `/${slashCommand}`);
55812
+ await ctx.sessionManager.sendFollowUp(ctx.threadId, `/${slashCommand}`, undefined, undefined, undefined, { system: true });
55726
55813
  }
55727
55814
  return { handled: true };
55728
55815
  };
@@ -57,6 +57,8 @@ platforms:
57
57
  | `botName` | No | Mention name (default: `claude-code`) |
58
58
  | `allowedUsers` | No | List of usernames who can use the bot |
59
59
  | `skipPermissions` | No | Auto-approve actions (default: `false`) |
60
+ | `sessionHeader` | No | Per-thread header visibility: `full` (default) / `minimal` (status bar only) / `hidden` (no header post) |
61
+ | `stickyMessage` | No | Channel sticky visibility: `full` (default) / `minimal` (status bar only) / `hidden` (no sticky, no bumping) |
60
62
 
61
63
  ### Slack
62
64
 
@@ -71,6 +73,23 @@ platforms:
71
73
  | `botName` | No | Mention name (default: `claude`) |
72
74
  | `allowedUsers` | No | List of Slack usernames |
73
75
  | `skipPermissions` | No | Auto-approve actions (default: `false`) |
76
+ | `sessionHeader` | No | Per-thread header visibility: `full` (default) / `minimal` (status bar only) / `hidden` (no header post) |
77
+ | `stickyMessage` | No | Channel sticky visibility: `full` (default) / `minimal` (status bar only) / `hidden` (no sticky, no bumping) |
78
+
79
+ ### Quieting the bot's overhead messages
80
+
81
+ Both the per-thread session header and the channel sticky message default to `full` for backward compatibility. To strip them down on a noisy channel, set the per-platform fields in `config.yaml`:
82
+
83
+ ```yaml
84
+ platforms:
85
+ - id: mattermost-main
86
+ type: mattermost
87
+ # ... credentials ...
88
+ sessionHeader: hidden # no header post — Claude's reply is the first message in the thread
89
+ stickyMessage: minimal # one-line status bar at the channel bottom, no sessions list
90
+ ```
91
+
92
+ Note: the per-platform `stickyMessage: <mode>` field is distinct from the top-level `Config.stickyMessage: { description, footer }` block, which still customizes the full sticky for platforms not in `hidden` mode.
74
93
 
75
94
  ## Claude Accounts (optional, multi-account mode)
76
95
 
@@ -139,6 +158,8 @@ Options:
139
158
  --chrome Enable Chrome integration
140
159
  --no-chrome Disable Chrome integration
141
160
  --worktree-mode <mode> Git worktree mode: off, prompt, require
161
+ --session-header <mode> Per-thread header: full | minimal | hidden (overrides per-platform config)
162
+ --sticky-message <mode> Channel sticky: full | minimal | hidden (overrides per-platform config)
142
163
  --setup Re-run setup wizard
143
164
  --debug Enable debug logging
144
165
  --version Show version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "1.15.2",
3
+ "version": "1.16.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",
@@ -71,7 +71,7 @@
71
71
  "commander": "^14.0.2",
72
72
  "diff": "^8.0.3",
73
73
  "express-rate-limit": "^8.3.0",
74
- "hono": "^4.12.5",
74
+ "hono": "^4.12.18",
75
75
  "ink": "^6.6.0",
76
76
  "ink-scroll-view": "^0.3.5",
77
77
  "js-yaml": "^4.1.1",
@@ -94,7 +94,7 @@
94
94
  "@types/ws": "^8.18.0",
95
95
  "eslint": "^10.1.0",
96
96
  "husky": "^9.1.7",
97
- "lint-staged": "^16.2.7",
97
+ "lint-staged": "^17.0.4",
98
98
  "prettier": "^3.4.2",
99
99
  "typescript": "^6.0.2",
100
100
  "typescript-eslint": "^8.57.2"
@@ -115,7 +115,8 @@
115
115
  "express-rate-limit": "$express-rate-limit",
116
116
  "flatted": ">=3.4.0",
117
117
  "picomatch": ">=2.3.2",
118
- "path-to-regexp": ">=8.4.0"
118
+ "path-to-regexp": ">=8.4.0",
119
+ "fast-uri": ">=3.1.2"
119
120
  },
120
121
  "resolutions": {
121
122
  "hono": "$hono",
@@ -123,6 +124,7 @@
123
124
  "express-rate-limit": "$express-rate-limit",
124
125
  "flatted": ">=3.4.0",
125
126
  "picomatch": ">=2.3.2",
126
- "path-to-regexp": ">=8.4.0"
127
+ "path-to-regexp": ">=8.4.0",
128
+ "fast-uri": ">=3.1.2"
127
129
  }
128
130
  }