claude-threads 1.37.2 → 1.38.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,7 +5,14 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [1.38.0] - 2026-09-24
9
+
10
+ ### Added
11
+ - **`mode: assistant` names the replies-only shape** (#590, building on #505). A platform entry already carried three fields deciding how much of the bot's scaffolding a channel sees (`sessionHeader`, `stickyMessage`, `lifecycle`), and two deployments described the same combination of all three in nearly the same words. `mode` names it: `full` (default) is the current behaviour, `assistant` hides all three so Claude's reply is the whole thread. A field set explicitly still wins, so `mode: assistant` with `sessionHeader: full` means "replies only, but keep the header". The preset expands when the config loads, so everything downstream reads the same concrete values as before and omitting `mode` behaves identically to today. `turnMarker` stays outside the preset: it is a signal for integrations reading the channel, not something a human sees. Startup warns when a preset is countermanded by an explicit field, because the setup wizard writes `sessionHeader` and `stickyMessage` whenever your answer differed from the default: adding `mode: assistant` to a wizard-made config would otherwise move `lifecycle` alone while looking like it moved everything.
12
+
13
+ ### Fixed
14
+ - **A failed auto-update no longer retries every second forever** (#601). When the first scheduling check already met the restart condition (quiet or scheduled mode with idle sessions, ask mode with no threads), the countdown started before the 10-second check interval existed, so that interval survived and started a second countdown. The first countdown's interval was orphaned: at zero it cleared the new one instead of itself and kept firing "ready" once a second. A successful install exits the process, so this only showed when the install failed, as two posts per second (installing, failed) for as long as the bot ran. The check interval is now armed before the first check, a running countdown is never restarted, and a countdown clears its own interval at zero.
15
+ - **Deferring, cancelling or forcing an update now controls a running countdown** (#601). `!update defer` during the 60-second countdown left the countdown running, so the update installed anyway, and afterwards nothing re-armed the scheduler: a one-hour deferral silently became "until the next release". A deferral now stops the countdown, holds off every trigger (including late ask votes) for the window, and resumes checking when the window ends, asking again in ask mode. `!update now` during a countdown no longer installs a second time when the countdown reaches zero, and cancelling the schedule stops the countdown too.
9
16
 
10
17
  ## [1.37.2] - 2026-09-18
11
18
 
package/dist/index.js CHANGED
@@ -2232,6 +2232,35 @@ function resolveOverheadVisibility(value, fieldPath) {
2232
2232
  return value;
2233
2233
  throw new Error(`Invalid ${fieldPath}: expected one of ${OVERHEAD_VISIBILITY_VALUES.join(", ")}, got ${JSON.stringify(value)}`);
2234
2234
  }
2235
+ function isPresentationMode(value) {
2236
+ return typeof value === "string" && PRESENTATION_MODE_VALUES.includes(value);
2237
+ }
2238
+ function resolvePresentationMode(value, fieldPath) {
2239
+ if (value === undefined || value === null)
2240
+ return "full";
2241
+ if (isPresentationMode(value))
2242
+ return value;
2243
+ throw new Error(`Invalid ${fieldPath}: expected one of ${PRESENTATION_MODE_VALUES.join(", ")}, got ${JSON.stringify(value)}`);
2244
+ }
2245
+ function resolvePresentationOverhead(entry, fieldPath) {
2246
+ const preset = PRESENTATION_MODE_EXPANSIONS[resolvePresentationMode(entry.mode, `${fieldPath}.mode`)];
2247
+ const override = (value, field, fallback) => value === undefined || value === null ? fallback : resolveOverheadVisibility(value, `${fieldPath}.${field}`);
2248
+ return {
2249
+ sessionHeader: override(entry.sessionHeader, "sessionHeader", preset.sessionHeader),
2250
+ stickyMessage: override(entry.stickyMessage, "stickyMessage", preset.stickyMessage),
2251
+ lifecycle: override(entry.lifecycle, "lifecycle", preset.lifecycle)
2252
+ };
2253
+ }
2254
+ function presentationOverridesAgainstPreset(entry) {
2255
+ if (!isPresentationMode(entry.mode) || entry.mode === "full")
2256
+ return [];
2257
+ const preset = PRESENTATION_MODE_EXPANSIONS[entry.mode];
2258
+ const fields = ["sessionHeader", "stickyMessage", "lifecycle"];
2259
+ return fields.filter((field) => {
2260
+ const value = entry[field];
2261
+ return value !== undefined && value !== null && value !== preset[field];
2262
+ });
2263
+ }
2235
2264
  function resolveTurnMarker(mode, emoji, platformType, fieldPath) {
2236
2265
  const m = mode === undefined || mode === null ? "off" : mode;
2237
2266
  if (!TURN_MARKER_VALUES.includes(m)) {
@@ -2423,10 +2452,19 @@ function effectivePermissionMode(input) {
2423
2452
  return "default";
2424
2453
  return input.botWideMode;
2425
2454
  }
2426
- var OVERHEAD_VISIBILITY_VALUES, DEFAULT_OVERHEAD_VISIBILITY = "full", RECONNECT_POLICY_VALUES, DEFAULT_RECONNECT_POLICY = "retry", TURN_MARKER_VALUES, DEFAULT_TURN_MARKER, DEFAULT_TURN_MARKER_EMOJI = "checkered_flag", TURN_COMPLETE_EVENT_TYPE = "claude_threads_turn_complete", TURN_COMPLETE_PAYLOAD_VERSION = 1, DEFAULT_MEMORY_CONFIG, MEMORY_DISABLED, BOT_MCP_SERVER_NAME = "claude-threads-mcp", STDIO_KEYS, REMOTE_KEYS, LIMITS_DEFAULTS, MODE_INFO;
2455
+ var OVERHEAD_VISIBILITY_VALUES, DEFAULT_OVERHEAD_VISIBILITY = "full", RECONNECT_POLICY_VALUES, DEFAULT_RECONNECT_POLICY = "retry", PRESENTATION_MODE_VALUES, PRESENTATION_MODE_EXPANSIONS, TURN_MARKER_VALUES, DEFAULT_TURN_MARKER, DEFAULT_TURN_MARKER_EMOJI = "checkered_flag", TURN_COMPLETE_EVENT_TYPE = "claude_threads_turn_complete", TURN_COMPLETE_PAYLOAD_VERSION = 1, DEFAULT_MEMORY_CONFIG, MEMORY_DISABLED, BOT_MCP_SERVER_NAME = "claude-threads-mcp", STDIO_KEYS, REMOTE_KEYS, LIMITS_DEFAULTS, MODE_INFO;
2427
2456
  var init_types = __esm(() => {
2428
2457
  OVERHEAD_VISIBILITY_VALUES = ["full", "minimal", "hidden"];
2429
2458
  RECONNECT_POLICY_VALUES = ["retry", "exit"];
2459
+ PRESENTATION_MODE_VALUES = ["full", "assistant"];
2460
+ PRESENTATION_MODE_EXPANSIONS = {
2461
+ full: {
2462
+ sessionHeader: DEFAULT_OVERHEAD_VISIBILITY,
2463
+ stickyMessage: DEFAULT_OVERHEAD_VISIBILITY,
2464
+ lifecycle: DEFAULT_OVERHEAD_VISIBILITY
2465
+ },
2466
+ assistant: { sessionHeader: "hidden", stickyMessage: "hidden", lifecycle: "hidden" }
2467
+ };
2430
2468
  TURN_MARKER_VALUES = ["reaction", "metadata", "off"];
2431
2469
  DEFAULT_TURN_MARKER = { mode: "off" };
2432
2470
  DEFAULT_MEMORY_CONFIG = {
@@ -72234,10 +72272,15 @@ async function handleReaction(deps, platformId, postId, emojiName, username, act
72234
72272
  }
72235
72273
  let session = deps.registry.findByPost(postId);
72236
72274
  if (!session) {
72275
+ const hadInFlight = deps.registry.hasInFlightInteractivePost();
72237
72276
  await deps.registry.awaitPendingPost(postId, UNKNOWN_POST_GRACE_MS);
72238
72277
  session = deps.registry.findByPost(postId);
72239
- if (!session)
72278
+ if (!session) {
72279
+ if (hadInFlight) {
72280
+ log52.debug(`Reaction :${normalizedEmoji}: by @${username} on unknown post ${postId} ` + `dropped after the in-flight grace window (${UNKNOWN_POST_GRACE_MS}ms)`);
72281
+ }
72240
72282
  return;
72283
+ }
72241
72284
  }
72242
72285
  if (session.platformId !== platformId)
72243
72286
  return;
@@ -85754,6 +85797,7 @@ class UpdateScheduler extends EventEmitter8 {
85754
85797
  pendingUpdate = null;
85755
85798
  checkTimer = null;
85756
85799
  countdownTimer = null;
85800
+ deferTimer = null;
85757
85801
  idleStartTime = null;
85758
85802
  scheduledRestartAt = null;
85759
85803
  askApprovals = new Map;
@@ -85776,6 +85820,8 @@ class UpdateScheduler extends EventEmitter8 {
85776
85820
  }
85777
85821
  cancelSchedule() {
85778
85822
  this.stopChecking();
85823
+ this.stopCountdown();
85824
+ this.stopDeferTimer();
85779
85825
  this.pendingUpdate = null;
85780
85826
  this.idleStartTime = null;
85781
85827
  this.scheduledRestartAt = null;
@@ -85784,9 +85830,21 @@ class UpdateScheduler extends EventEmitter8 {
85784
85830
  log55.debug("Update schedule cancelled");
85785
85831
  }
85786
85832
  deferUpdate(minutes) {
85787
- const deferUntil = new Date(Date.now() + minutes * 60 * 1000);
85833
+ const deferMs = minutes * 60 * 1000;
85834
+ const deferUntil = new Date(Date.now() + deferMs);
85835
+ this.stopCountdown();
85836
+ this.stopChecking();
85837
+ this.stopDeferTimer();
85788
85838
  this.scheduledRestartAt = null;
85789
85839
  this.idleStartTime = null;
85840
+ this.askApprovals.clear();
85841
+ this.askStartTime = null;
85842
+ if (this.pendingUpdate) {
85843
+ this.deferTimer = setTimeout(() => {
85844
+ this.deferTimer = null;
85845
+ this.startChecking();
85846
+ }, deferMs);
85847
+ }
85790
85848
  this.emit("deferred", deferUntil);
85791
85849
  log55.info(`Update deferred until ${deferUntil.toLocaleTimeString()}`);
85792
85850
  return deferUntil;
@@ -85808,12 +85866,13 @@ class UpdateScheduler extends EventEmitter8 {
85808
85866
  stop() {
85809
85867
  this.stopChecking();
85810
85868
  this.stopCountdown();
85869
+ this.stopDeferTimer();
85811
85870
  }
85812
85871
  startChecking() {
85813
85872
  if (this.checkTimer)
85814
85873
  return;
85815
- this.checkCondition();
85816
85874
  this.checkTimer = setInterval(() => this.checkCondition(), 1e4);
85875
+ this.checkCondition();
85817
85876
  log55.debug(`Started checking for ${this.config.autoRestartMode} condition`);
85818
85877
  }
85819
85878
  stopChecking() {
@@ -85943,23 +86002,35 @@ class UpdateScheduler extends EventEmitter8 {
85943
86002
  if (!this.pendingUpdate)
85944
86003
  return;
85945
86004
  this.stopChecking();
86005
+ if (this.countdownTimer || this.deferTimer)
86006
+ return;
85946
86007
  this.scheduledRestartAt = new Date(Date.now() + 60000);
85947
86008
  let secondsRemaining = 60;
85948
86009
  this.emit("countdown", secondsRemaining);
85949
- this.countdownTimer = setInterval(() => {
86010
+ const timer = setInterval(() => {
85950
86011
  secondsRemaining--;
85951
86012
  this.emit("countdown", secondsRemaining);
85952
86013
  if (secondsRemaining <= 0) {
85953
- this.stopCountdown();
86014
+ clearInterval(timer);
86015
+ if (this.countdownTimer === timer)
86016
+ this.countdownTimer = null;
85954
86017
  this.emit("ready", this.pendingUpdate);
85955
86018
  }
85956
86019
  }, 1000);
86020
+ this.countdownTimer = timer;
85957
86021
  log55.info("Update countdown started (60 seconds)");
85958
86022
  }
85959
86023
  stopCountdown() {
85960
86024
  if (this.countdownTimer) {
85961
86025
  clearInterval(this.countdownTimer);
85962
86026
  this.countdownTimer = null;
86027
+ this.scheduledRestartAt = null;
86028
+ }
86029
+ }
86030
+ stopDeferTimer() {
86031
+ if (this.deferTimer) {
86032
+ clearTimeout(this.deferTimer);
86033
+ this.deferTimer = null;
85963
86034
  }
85964
86035
  }
85965
86036
  }
@@ -86346,6 +86417,7 @@ class AutoUpdateManager extends EventEmitter9 {
86346
86417
  return;
86347
86418
  }
86348
86419
  log58.info("Forcing immediate update");
86420
+ this.scheduler.stop();
86349
86421
  await this.performUpdate(updateInfo);
86350
86422
  }
86351
86423
  deferUpdate(minutes = 60) {
@@ -86731,6 +86803,15 @@ async function startWithoutDaemon() {
86731
86803
  if (cliArgs.keepAlive !== undefined) {
86732
86804
  newConfig.keepAlive = cliArgs.keepAlive;
86733
86805
  }
86806
+ const presetOverrideWarnings = newConfig.platforms.flatMap((platformConfig) => {
86807
+ const overridden = presentationOverridesAgainstPreset(platformConfig);
86808
+ if (overridden.length === 0)
86809
+ return [];
86810
+ const one = overridden.length === 1;
86811
+ return [
86812
+ ` ⚠️ platforms[${platformConfig.id}]: mode: ${String(platformConfig.mode)} is overridden for ${overridden.join(", ")} by ${one ? "an explicit field" : "explicit fields"}. Remove ${one ? "it" : "them"} to let the preset apply.`
86813
+ ];
86814
+ });
86734
86815
  if (cliArgs.sessionHeader !== undefined) {
86735
86816
  for (const p of newConfig.platforms) {
86736
86817
  p.sessionHeader = cliArgs.sessionHeader;
@@ -86745,14 +86826,15 @@ async function startWithoutDaemon() {
86745
86826
  if (!newConfig.platforms || newConfig.platforms.length === 0) {
86746
86827
  throw new Error("No platforms configured. Run with --setup to configure.");
86747
86828
  }
86748
- let mcpPostureWarnings = [];
86829
+ let startupWarnings = [];
86749
86830
  try {
86750
- mcpPostureWarnings = resolvePlatformMcpPosture(newConfig.platforms, newConfig.mcpServers).warnings;
86831
+ startupWarnings = resolvePlatformMcpPosture(newConfig.platforms, newConfig.mcpServers).warnings;
86751
86832
  } catch (err) {
86752
86833
  console.error(red(` ❌ ${err instanceof Error ? err.message : String(err)}`));
86753
86834
  process.exit(1);
86754
86835
  }
86755
- for (const w of mcpPostureWarnings)
86836
+ startupWarnings.push(...presetOverrideWarnings);
86837
+ for (const w of startupWarnings)
86756
86838
  console.warn(w);
86757
86839
  const config = newConfig;
86758
86840
  const firstPlatformConfig = config.platforms[0];
@@ -86914,7 +86996,7 @@ async function startWithoutDaemon() {
86914
86996
  }
86915
86997
  }
86916
86998
  });
86917
- for (const w of mcpPostureWarnings) {
86999
+ for (const w of startupWarnings) {
86918
87000
  ui.addLog({ level: "warn", component: "config", message: w });
86919
87001
  }
86920
87002
  setLogHandler((level, component, message, sessionId) => {
@@ -86963,9 +87045,7 @@ async function startWithoutDaemon() {
86963
87045
  configureAuditLog(platformConfig.id, resolveAuditLogEnabled(platformConfig.auditLog, `platforms[${platformConfig.id}].auditLog`));
86964
87046
  session.addPlatform(platformConfig.id, client, {
86965
87047
  overhead: {
86966
- sessionHeader: resolveOverheadVisibility(platformConfig.sessionHeader, `platforms[${platformConfig.id}].sessionHeader`),
86967
- stickyMessage: resolveOverheadVisibility(platformConfig.stickyMessage, `platforms[${platformConfig.id}].stickyMessage`),
86968
- lifecycle: resolveOverheadVisibility(platformConfig.lifecycle, `platforms[${platformConfig.id}].lifecycle`),
87048
+ ...resolvePresentationOverhead(platformConfig, `platforms[${platformConfig.id}]`),
86969
87049
  turnMarker: resolveTurnMarker(platformConfig.turnMarker, platformConfig.turnMarkerEmoji, platformConfig.type, `platforms[${platformConfig.id}]`)
86970
87050
  },
86971
87051
  memory: resolveMemoryConfig(platformConfig.memory, `platforms[${platformConfig.id}].memory`),
@@ -86992,9 +87072,8 @@ async function startWithoutDaemon() {
86992
87072
  configureAuditLog(dmConfig.id, resolveAuditLogEnabled(dmConfig.auditLog, `dm[${dmConfig.id}].auditLog`));
86993
87073
  session.addPlatform(dmConfig.id, dmClient, {
86994
87074
  overhead: {
86995
- sessionHeader: resolveOverheadVisibility(dmConfig.sessionHeader, `dm[${dmConfig.id}].sessionHeader`),
87075
+ ...resolvePresentationOverhead(dmConfig, `dm[${dmConfig.id}]`),
86996
87076
  stickyMessage: "hidden",
86997
- lifecycle: resolveOverheadVisibility(dmConfig.lifecycle, `dm[${dmConfig.id}].lifecycle`),
86998
87077
  turnMarker: resolveTurnMarker(dmConfig.turnMarker, dmConfig.turnMarkerEmoji, dmConfig.type, `dm[${dmConfig.id}]`)
86999
87078
  },
87000
87079
  memory: resolveMemoryConfig(dmConfig.memory, `dm[${dmConfig.id}].memory`),
@@ -169,6 +169,7 @@ What happens: every `audio/*` attachment (or a file with an audio extension when
169
169
  | `permissionMode` | No | How tool-use is gated: `default` / `auto` / `bypass` (default: `default`). See [Permission Modes](#permission-modes). |
170
170
  | `skipPermissions` | No | **Deprecated.** Use `permissionMode`. `true` maps to `bypass`, `false` to `default`. `permissionMode` wins when both are set. |
171
171
  | `outboundFiles` | No | `send_file` settings: `{ enabled, maxBytes }` (defaults: enabled `true`, `maxBytes` 100 MB) |
172
+ | `mode` | No | Presentation preset: the baseline for `sessionHeader`, `stickyMessage` and `lifecycle`. `full` (default) is the behaviour described below; `assistant` hides all three, so Claude's reply is the whole thread. Any of those three fields set explicitly wins over the preset, so `mode: assistant` with `sessionHeader: full` is valid. `turnMarker` is machine-facing and deliberately not part of the preset. |
172
173
  | `sessionHeader` | No | Per-thread header visibility: `full` (default) / `minimal` (status bar only) / `hidden` (no header post) |
173
174
  | `stickyMessage` | No | Channel sticky visibility: `full` (default) / `minimal` (status bar only) / `hidden` (no sticky, no bumping) |
174
175
  | `lifecycle` | No | Session status posts — idle warning, timeout, pause, resume, and the shutdown notice a deploy leaves in each open thread: `full` (default, all of them) / `minimal` (drops the idle warning, which predicts a timeout the next message would undo anyway) / `hidden` (none). Editing a status post the thread already has is not suppressed at any level: it adds no post and no notification, and leaving a stale "session idle" up across a restart would be worse. ⚠️ An **abnormal exit** (`[Exited: <code>]`, non-zero only) posts at every level including `hidden`: it is a failure report, and silencing it would make a session that died look exactly like one that finished. Note that `hidden` also removes the post a 🔄 reaction resumes from — sending a message in the thread still resumes it, and the channel sticky says so. |
@@ -194,6 +195,7 @@ What happens: every `audio/*` attachment (or a file with an audio extension when
194
195
  | `permissionMode` | No | How tool-use is gated: `default` / `auto` / `bypass` (default: `default`). See [Permission Modes](#permission-modes). |
195
196
  | `skipPermissions` | No | **Deprecated.** Use `permissionMode`. `true` maps to `bypass`, `false` to `default`. `permissionMode` wins when both are set. |
196
197
  | `outboundFiles` | No | `send_file` settings: `{ enabled, maxBytes }` (defaults: enabled `true`, `maxBytes` 100 MB) |
198
+ | `mode` | No | Presentation preset: the baseline for `sessionHeader`, `stickyMessage` and `lifecycle`. `full` (default) is the behaviour described below; `assistant` hides all three, so Claude's reply is the whole thread. Any of those three fields set explicitly wins over the preset, so `mode: assistant` with `sessionHeader: full` is valid. `turnMarker` is machine-facing and deliberately not part of the preset. |
197
199
  | `sessionHeader` | No | Per-thread header visibility: `full` (default) / `minimal` (status bar only) / `hidden` (no header post) |
198
200
  | `stickyMessage` | No | Channel sticky visibility: `full` (default) / `minimal` (status bar only) / `hidden` (no sticky, no bumping) |
199
201
  | `lifecycle` | No | Session status posts — idle warning, timeout, pause, resume, and the shutdown notice a deploy leaves in each open thread: `full` (default, all of them) / `minimal` (drops the idle warning, which predicts a timeout the next message would undo anyway) / `hidden` (none). Editing a status post the thread already has is not suppressed at any level: it adds no post and no notification, and leaving a stale "session idle" up across a restart would be worse. ⚠️ An **abnormal exit** (`[Exited: <code>]`, non-zero only) posts at every level including `hidden`: it is a failure report, and silencing it would make a session that died look exactly like one that finished. Note that `hidden` also removes the post a 🔄 reaction resumes from — sending a message in the thread still resumes it, and the channel sticky says so. |
@@ -203,6 +205,56 @@ What happens: every `audio/*` attachment (or a file with an audio extension when
203
205
  | `reconnectPolicy` | No | What happens when reconnection attempts run out: `retry` (default — log, cool down 60s, reset the counter and keep trying; recovers with no supervisor) or `exit` (leave through the graceful shutdown path and exit non-zero, for `Restart=always` deployments). The bot never stays alive with a dead socket either way. |
204
206
  | `auditLog` | No | Append-only audit trail of what the bot executed for this platform — tool calls (incl. subagents), session lifecycle, security-relevant commands, plan approvals. One JSONL stream per platform under `~/.claude-threads/audit/` (override: `CLAUDE_THREADS_AUDIT_DIR`), files `0600`. The bot never deletes it — rotation/retention is the operator's job (logrotate, SIEM ingestion). See [Audit log](#audit-log). Default off. |
205
207
 
208
+ ### Presentation presets (`mode`)
209
+
210
+ A platform entry carries three fields that decide how much of the bot's own
211
+ scaffolding a channel sees: `sessionHeader`, `stickyMessage` and `lifecycle`.
212
+ They are useful separately, but almost nobody wants to tune them one by one.
213
+ Two deployments asked for the same combination in nearly the same words: the
214
+ reply, and nothing else.
215
+
216
+ `mode` names that combination.
217
+
218
+ ```yaml
219
+ platforms:
220
+ - id: assistant-channel
221
+ type: slack
222
+ mode: assistant # no header, no sticky, no lifecycle notices
223
+ ```
224
+
225
+ | Preset | `sessionHeader` | `stickyMessage` | `lifecycle` |
226
+ |--------|-----------------|-----------------|-------------|
227
+ | `full` (default) | `full` | `full` | `full` |
228
+ | `assistant` | `hidden` | `hidden` | `hidden` |
229
+
230
+ A field set explicitly always wins over the preset, so you can start from a
231
+ preset and keep one thing:
232
+
233
+ ```yaml
234
+ mode: assistant
235
+ sessionHeader: full # replies only, but keep the per-thread header
236
+ ```
237
+
238
+ One caveat worth knowing if you ran the setup wizard. It writes `sessionHeader`
239
+ and `stickyMessage` into your config whenever your answer differed from the
240
+ default, and those are explicit fields, so they win. Adding `mode: assistant`
241
+ to such a config moves `lifecycle` and leaves the other two where the wizard
242
+ put them. Startup warns and names the fields when that happens; remove them to
243
+ let the preset apply.
244
+
245
+ Two more things worth knowing:
246
+
247
+ - The preset is expanded when the config loads, so everything downstream reads
248
+ the same three concrete values it always did. Omitting `mode` resolves to
249
+ exactly the old defaults, which means an existing config behaves identically.
250
+ - `turnMarker` is **not** part of any preset. It is a signal for integrations
251
+ reading the channel rather than something a human sees, so it is not
252
+ something you would switch off because you want a quieter channel.
253
+
254
+ An abnormal exit is still reported under `mode: assistant`, for the reason
255
+ given in the `lifecycle` row above: a session that died must not look like one
256
+ that finished.
257
+
206
258
  ### Direct Channel Mode
207
259
 
208
260
  `directChannelMode: true` turns the configured channel into a single, always-on conversation with the bot:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "1.37.2",
3
+ "version": "1.38.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",