claude-threads 1.38.1 → 1.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ 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
+ ## [1.39.0] - 2026-09-25
9
+
10
+ ### Added
11
+ - **Per-platform `toolActivity`: `summary` or `hidden` instead of the tool stream** (#534, thanks @kaza). `summary` replaces the inline `Bash …` / `↳ ✓` lines with one live line at the top of the turn's post — `🔧 12 tools · 40 s · Bash`, naming the tool most recently started so the line stays a liveness signal; `hidden` says nothing about tools at all. `toolDetails: thread` keeps the full rendering in a thread under the reply, `none` drops it. Defaults are unchanged (`full` / `none`). Permission prompts, plan approvals, questions, task lists and errors are untouched in every mode. Part of #505. Hardened before release in #616: the turn marker keeps the summary line, details in a thread session arrive after the whole reply, and long or rate-limited turns lose no tool lines.
12
+
13
+ ### Fixed
14
+ - **Long replies are no longer cut off at the platform limit** (#616). A reply that reached the chat in one large burst, with no post of its own yet, was truncated past the limit (about 12K characters on Slack, 16K on Mattermost), with "(truncated)" at the end. It is now split over several posts; a code block cut in two is closed and reopened with its language. Normal streaming replies are unchanged.
15
+ - **A refused post no longer loses part of a long reply** (#616). When the platform refused one post of a reply split over several (a rate limit, for instance), the text of that post was dropped once a later one went through, and anything Claude streamed meanwhile could be wiped. The refused part is now kept and posted on the next flush.
16
+
17
+ ### Changed
18
+ - `hono` 4.13.7 → 4.13.8 (#608).
19
+
8
20
  ## [1.38.1] - 2026-09-24
9
21
 
10
22
  ### Fixed
package/dist/index.js CHANGED
@@ -2278,6 +2278,27 @@ function resolveTurnMarker(mode, emoji, platformType, fieldPath) {
2278
2278
  return { mode: "reaction", emoji: emoji ?? DEFAULT_TURN_MARKER_EMOJI };
2279
2279
  return { mode: m };
2280
2280
  }
2281
+ function resolveToolActivity(activity, details, fieldPath) {
2282
+ const act = activity === undefined || activity === null ? "full" : activity;
2283
+ if (!TOOL_ACTIVITY_VALUES.includes(act)) {
2284
+ throw new Error(`Invalid ${fieldPath}.toolActivity: expected one of ${TOOL_ACTIVITY_VALUES.join(", ")}, got ${JSON.stringify(activity)}`);
2285
+ }
2286
+ const mode = act;
2287
+ if (details !== undefined && details !== null && !TOOL_DETAILS_VALUES.includes(details)) {
2288
+ throw new Error(`Invalid ${fieldPath}.toolDetails: expected one of ${TOOL_DETAILS_VALUES.join(", ")}, got ${JSON.stringify(details)}`);
2289
+ }
2290
+ const det = details;
2291
+ if (mode === "full") {
2292
+ if (det !== undefined && det !== null) {
2293
+ throw new Error(`Invalid ${fieldPath}.toolDetails: only meaningful with toolActivity summary or hidden`);
2294
+ }
2295
+ return { activity: "full", details: "none" };
2296
+ }
2297
+ if (mode === "hidden" && det === "thread") {
2298
+ throw new Error(`Invalid ${fieldPath}.toolDetails: hidden has no post of its own to thread under; use summary, or none`);
2299
+ }
2300
+ return { activity: mode, details: det ?? (mode === "summary" ? "thread" : "none") };
2301
+ }
2281
2302
  function resolveMemoryConfig(value, fieldPath) {
2282
2303
  if (value === undefined || value === null || value === true)
2283
2304
  return DEFAULT_MEMORY_CONFIG;
@@ -2452,7 +2473,7 @@ function effectivePermissionMode(input) {
2452
2473
  return "default";
2453
2474
  return input.botWideMode;
2454
2475
  }
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;
2476
+ 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, TOOL_ACTIVITY_VALUES, TOOL_DETAILS_VALUES, DEFAULT_TOOL_ACTIVITY, DEFAULT_MEMORY_CONFIG, MEMORY_DISABLED, BOT_MCP_SERVER_NAME = "claude-threads-mcp", STDIO_KEYS, REMOTE_KEYS, LIMITS_DEFAULTS, MODE_INFO;
2456
2477
  var init_types = __esm(() => {
2457
2478
  OVERHEAD_VISIBILITY_VALUES = ["full", "minimal", "hidden"];
2458
2479
  RECONNECT_POLICY_VALUES = ["retry", "exit"];
@@ -2467,6 +2488,9 @@ var init_types = __esm(() => {
2467
2488
  };
2468
2489
  TURN_MARKER_VALUES = ["reaction", "metadata", "off"];
2469
2490
  DEFAULT_TURN_MARKER = { mode: "off" };
2491
+ TOOL_ACTIVITY_VALUES = ["full", "summary", "hidden"];
2492
+ TOOL_DETAILS_VALUES = ["thread", "none"];
2493
+ DEFAULT_TOOL_ACTIVITY = { activity: "full", details: "none" };
2470
2494
  DEFAULT_MEMORY_CONFIG = {
2471
2495
  enabled: true,
2472
2496
  repoLayer: true,
@@ -59501,6 +59525,9 @@ toolFormatterRegistry.register(playwrightToolsFormatter);
59501
59525
  toolFormatterRegistry.register(figmaToolsFormatter);
59502
59526
  toolFormatterRegistry.register(context7ToolsFormatter);
59503
59527
  // src/operations/types.ts
59528
+ function isToolActivityOp(op) {
59529
+ return op.type === "tool_activity";
59530
+ }
59504
59531
  function isContentOp(op) {
59505
59532
  return op.type === "append_content";
59506
59533
  }
@@ -59537,6 +59564,9 @@ function createAppendContentOp(sessionId, content, isToolOutput) {
59537
59564
  isToolOutput
59538
59565
  };
59539
59566
  }
59567
+ function createToolActivityOp(sessionId, detail) {
59568
+ return { type: "tool_activity", sessionId, timestamp: Date.now(), ...detail };
59569
+ }
59540
59570
  function createFlushOp(sessionId, reason, resultOk) {
59541
59571
  return {
59542
59572
  type: "flush",
@@ -59599,6 +59629,9 @@ function createStatusUpdateOp(sessionId, options) {
59599
59629
  }
59600
59630
  // src/operations/transformer.ts
59601
59631
  init_cli();
59632
+ function toolsInline(ctx) {
59633
+ return (ctx.toolActivity ?? "full") === "full";
59634
+ }
59602
59635
  function transformEvent(event, ctx) {
59603
59636
  if (event.parent_tool_use_id) {
59604
59637
  return [];
@@ -59648,7 +59681,16 @@ function transformAssistant(event, ctx) {
59648
59681
  });
59649
59682
  if (result.display && !result.hidden) {
59650
59683
  flushTextBuffer();
59651
- operations.push(createAppendContentOp(ctx.sessionId, result.display, true));
59684
+ if (toolsInline(ctx)) {
59685
+ operations.push(createAppendContentOp(ctx.sessionId, result.display, true));
59686
+ } else {
59687
+ operations.push(createToolActivityOp(ctx.sessionId, {
59688
+ kind: "start",
59689
+ toolUseId: block.id || "",
59690
+ name: block.name,
59691
+ display: result.display
59692
+ }));
59693
+ }
59652
59694
  if (block.id) {
59653
59695
  ctx.toolStartTimes.set(block.id, Date.now());
59654
59696
  }
@@ -59662,7 +59704,14 @@ function transformAssistant(event, ctx) {
59662
59704
  } else if (block.type === "server_tool_use" && block.name) {
59663
59705
  flushTextBuffer();
59664
59706
  const inputStr = block.input ? JSON.stringify(block.input).substring(0, 50) : "";
59665
- operations.push(createAppendContentOp(ctx.sessionId, `\uD83C\uDF10 ${ctx.formatter.formatBold(block.name)} ${inputStr}`, true));
59707
+ const display = `\uD83C\uDF10 ${ctx.formatter.formatBold(block.name)} ${inputStr}`;
59708
+ if (toolsInline(ctx)) {
59709
+ operations.push(createAppendContentOp(ctx.sessionId, display, true));
59710
+ } else {
59711
+ const toolUseId = block.id || "";
59712
+ operations.push(createToolActivityOp(ctx.sessionId, { kind: "start", toolUseId, name: block.name, display }));
59713
+ operations.push(createToolActivityOp(ctx.sessionId, { kind: "end", toolUseId, ok: true, elapsedMs: 0, display: " ↳ ✓" }));
59714
+ }
59666
59715
  }
59667
59716
  }
59668
59717
  flushTextBuffer();
@@ -59691,7 +59740,7 @@ function transformUser(event, ctx) {
59691
59740
  }
59692
59741
  }
59693
59742
  if (ctx.toolStartTimes.has(block.tool_use_id)) {
59694
- operations.push(createResultIndicatorOp(block.tool_use_id, block.is_error === true, ctx));
59743
+ operations.push(toolsInline(ctx) ? createResultIndicatorOp(block.tool_use_id, block.is_error === true, ctx) : createToolEndOp(block.tool_use_id, block.is_error === true, ctx));
59695
59744
  indicatorCount++;
59696
59745
  }
59697
59746
  }
@@ -59730,6 +59779,12 @@ function createResultIndicatorOp(toolUseId, isError, ctx) {
59730
59779
  const errorNote = isError ? " Error" : "";
59731
59780
  return createAppendContentOp(ctx.sessionId, ` ↳ ${icon}${errorNote}${elapsed}`, true);
59732
59781
  }
59782
+ function createToolEndOp(toolUseId, isError, ctx) {
59783
+ const startTime = ctx.toolStartTimes.get(toolUseId);
59784
+ const elapsedMs = startTime ? Date.now() - startTime : 0;
59785
+ const indicator = createResultIndicatorOp(toolUseId, isError, ctx);
59786
+ return createToolActivityOp(ctx.sessionId, { kind: "end", toolUseId, ok: !isError, elapsedMs, display: indicator.content });
59787
+ }
59733
59788
  function transformToolUse(event, ctx) {
59734
59789
  const tool = event.tool_use;
59735
59790
  if (tool.id) {
@@ -59745,7 +59800,9 @@ function transformToolUse(event, ctx) {
59745
59800
  worktreeInfo: ctx.worktreeInfo
59746
59801
  });
59747
59802
  if (result.display && !result.hidden) {
59748
- return [createAppendContentOp(ctx.sessionId, result.display, true)];
59803
+ return [
59804
+ toolsInline(ctx) ? createAppendContentOp(ctx.sessionId, result.display, true) : createToolActivityOp(ctx.sessionId, { kind: "start", toolUseId: tool.id || "", name: tool.name, display: result.display })
59805
+ ];
59749
59806
  }
59750
59807
  return [];
59751
59808
  }
@@ -59754,13 +59811,18 @@ function transformToolResult(event, ctx) {
59754
59811
  return [];
59755
59812
  }
59756
59813
  const result = event.tool_result;
59814
+ const toolUseId = result.tool_use_id || "";
59815
+ const isError = result.is_error === true;
59757
59816
  return [
59758
- createResultIndicatorOp(result.tool_use_id || "", result.is_error === true, ctx),
59817
+ toolsInline(ctx) ? createResultIndicatorOp(toolUseId, isError, ctx) : createToolEndOp(toolUseId, isError, ctx),
59759
59818
  createFlushOp(ctx.sessionId, "tool_complete")
59760
59819
  ];
59761
59820
  }
59762
59821
  function transformResult(event, ctx) {
59763
59822
  const operations = [];
59823
+ if (!toolsInline(ctx)) {
59824
+ operations.push(createToolActivityOp(ctx.sessionId, { kind: "turn_end" }));
59825
+ }
59764
59826
  operations.push(createFlushOp(ctx.sessionId, "result", !isErrorResultEvent(event)));
59765
59827
  const result = event;
59766
59828
  const r = result.result;
@@ -59844,6 +59906,210 @@ function truncateAtWord2(text, maxLength) {
59844
59906
  }
59845
59907
  return truncated + "...";
59846
59908
  }
59909
+ // src/operations/executors/tool-activity.ts
59910
+ var fresh = () => ({ started: 0, finished: 0, failed: 0, firstStartAt: null, lastEndAt: null, lastTool: null });
59911
+ function shortToolName(name) {
59912
+ return parseMcpToolName(name)?.tool || name;
59913
+ }
59914
+ function renderToolSummary(stats, now, link, formatter) {
59915
+ const running = stats.started > stats.finished;
59916
+ const until = running || stats.lastEndAt === null ? now : stats.lastEndAt;
59917
+ const seconds = stats.firstStartAt === null ? 0 : Math.max(0, Math.round((until - stats.firstStartAt) / 1000));
59918
+ const parts = [`\uD83D\uDD27 ${stats.started} ${stats.started === 1 ? "tool" : "tools"}`, `${seconds} s${running ? "…" : ""}`];
59919
+ if (stats.lastTool)
59920
+ parts.push(shortToolName(stats.lastTool));
59921
+ if (stats.failed > 0)
59922
+ parts.push(`${stats.failed} ❌`);
59923
+ if (link)
59924
+ parts.push(formatter.formatLink("details", link));
59925
+ return parts.join(" · ");
59926
+ }
59927
+
59928
+ class ToolActivityExecutor {
59929
+ options;
59930
+ stats = fresh();
59931
+ open = new Set;
59932
+ constructor(options) {
59933
+ this.options = options;
59934
+ }
59935
+ getStats() {
59936
+ return this.stats;
59937
+ }
59938
+ async execute(op, ctx) {
59939
+ const now = this.options.now?.() ?? Date.now();
59940
+ if (op.kind === "start") {
59941
+ this.stats.started++;
59942
+ this.open.add(op.toolUseId);
59943
+ this.stats.firstStartAt ??= now;
59944
+ this.stats.lastTool = op.name;
59945
+ this.renderHeader(now, ctx);
59946
+ await this.options.sink.append(op, ctx);
59947
+ } else if (op.kind === "end") {
59948
+ if (!this.open.delete(op.toolUseId))
59949
+ return;
59950
+ this.stats.finished++;
59951
+ if (!op.ok)
59952
+ this.stats.failed++;
59953
+ this.stats.lastEndAt = now;
59954
+ this.renderHeader(now, ctx);
59955
+ await this.options.sink.append(op, ctx);
59956
+ } else if (this.stats.started > 0) {
59957
+ if (this.stats.started > this.stats.finished) {
59958
+ this.stats.finished = this.stats.started;
59959
+ this.stats.lastEndAt = now;
59960
+ }
59961
+ this.open.clear();
59962
+ this.renderHeader(now, ctx);
59963
+ }
59964
+ }
59965
+ async afterResultFlush(ctx) {
59966
+ if (this.stats.started === 0)
59967
+ return;
59968
+ this.stats = fresh();
59969
+ this.open.clear();
59970
+ try {
59971
+ await this.options.sink.turnEnded(ctx);
59972
+ } catch (err) {
59973
+ ctx.logger.warn(`tool details: delivering the turn failed: ${err.message ?? err}`);
59974
+ }
59975
+ }
59976
+ reset() {
59977
+ if (this.stats.started === 0)
59978
+ return;
59979
+ this.stats = fresh();
59980
+ this.open.clear();
59981
+ this.options.sink.reset();
59982
+ }
59983
+ wake() {
59984
+ this.options.sink.wake?.();
59985
+ }
59986
+ renderHeader(now, ctx) {
59987
+ if (this.options.mode !== "summary")
59988
+ return;
59989
+ this.options.onHeader(renderToolSummary(this.stats, now, this.options.sink.link(), ctx.formatter));
59990
+ }
59991
+ }
59992
+
59993
+ // src/operations/tool-details/thread.ts
59994
+ function createThreadSink(deps) {
59995
+ const flushDelayMs = deps.flushDelayMs ?? 500;
59996
+ const newTurn = () => ({ queued: [], executor: null, ctx: null, timer: null, chain: Promise.resolve(), dead: false });
59997
+ let turn = newTurn();
59998
+ function enqueue(t, work) {
59999
+ const run = t.chain.then(() => t.dead ? undefined : work());
60000
+ t.chain = run.catch(() => {
60001
+ return;
60002
+ });
60003
+ return run;
60004
+ }
60005
+ async function drain(t) {
60006
+ if (t.queued.length === 0)
60007
+ return;
60008
+ t.ctx ??= deps.contextFor();
60009
+ if (!t.ctx)
60010
+ return;
60011
+ t.executor ??= deps.makeExecutor();
60012
+ const lines = t.queued;
60013
+ t.queued = [];
60014
+ const chunk = Math.floor(t.ctx.platform.getMessageLimits().hardThreshold / 2);
60015
+ let nextFlushAt = chunk;
60016
+ for (const line of lines) {
60017
+ await t.executor.executeAppend(createAppendContentOp(t.ctx.sessionId, line, true), t.ctx);
60018
+ if (t.dead)
60019
+ return;
60020
+ const pending = t.executor.getState().pendingContent.length;
60021
+ if (pending >= nextFlushAt) {
60022
+ await t.executor.executeFlush(createFlushOp(t.ctx.sessionId, "soft_threshold"), t.ctx);
60023
+ if (t.dead)
60024
+ return;
60025
+ const left = t.executor.getState().pendingContent.length;
60026
+ nextFlushAt = left >= pending ? pending * 2 : chunk;
60027
+ }
60028
+ }
60029
+ }
60030
+ function scheduleFlush(t) {
60031
+ if (t.timer || t.dead)
60032
+ return;
60033
+ t.timer = setTimeout(() => {
60034
+ t.timer = null;
60035
+ enqueue(t, async () => {
60036
+ if (t.executor && t.ctx)
60037
+ await t.executor.executeFlush(createFlushOp(t.ctx.sessionId, "soft_threshold"), t.ctx);
60038
+ });
60039
+ }, flushDelayMs);
60040
+ }
60041
+ function stopTimer(t) {
60042
+ if (t.timer) {
60043
+ clearTimeout(t.timer);
60044
+ t.timer = null;
60045
+ }
60046
+ }
60047
+ return {
60048
+ async append(op) {
60049
+ const t = turn;
60050
+ t.queued.push(op.display);
60051
+ if (deps.holdUntilTurnEnd)
60052
+ return;
60053
+ await enqueue(t, async () => {
60054
+ await drain(t);
60055
+ if (t.executor)
60056
+ scheduleFlush(t);
60057
+ });
60058
+ },
60059
+ async turnEnded(ctx) {
60060
+ const t = turn;
60061
+ turn = newTurn();
60062
+ stopTimer(t);
60063
+ t.ctx ??= deps.contextFor();
60064
+ await enqueue(t, async () => {
60065
+ await drain(t);
60066
+ if (t.executor && t.ctx) {
60067
+ await t.executor.executeFlush(createFlushOp(t.ctx.sessionId, "result"), t.ctx);
60068
+ t.executor.closeCurrentPost(t.ctx);
60069
+ }
60070
+ if (t.queued.length > 0) {
60071
+ ctx.logger.warn(`tool details: ${t.queued.length} line(s) dropped, the turn has no post to thread under`);
60072
+ t.queued = [];
60073
+ }
60074
+ });
60075
+ },
60076
+ link: () => null,
60077
+ wake() {
60078
+ const t = turn;
60079
+ if (deps.holdUntilTurnEnd || t.ctx || t.queued.length === 0)
60080
+ return;
60081
+ enqueue(t, async () => {
60082
+ await drain(t);
60083
+ if (t.executor)
60084
+ scheduleFlush(t);
60085
+ }).catch(() => {
60086
+ return;
60087
+ });
60088
+ },
60089
+ reset() {
60090
+ const t = turn;
60091
+ t.dead = true;
60092
+ stopTimer(t);
60093
+ t.executor?.reset();
60094
+ turn = newTurn();
60095
+ }
60096
+ };
60097
+ }
60098
+
60099
+ // src/operations/tool-details/types.ts
60100
+ var noneSink = {
60101
+ append: async () => {
60102
+ return;
60103
+ },
60104
+ turnEnded: async () => {
60105
+ return;
60106
+ },
60107
+ link: () => null,
60108
+ reset: () => {
60109
+ return;
60110
+ }
60111
+ };
60112
+
59847
60113
  // src/operations/message-manager.ts
59848
60114
  init_types();
59849
60115
 
@@ -60009,9 +60275,66 @@ class ContentExecutor extends BaseExecutor {
60009
60275
  currentPostId: null,
60010
60276
  currentPostContent: "",
60011
60277
  pendingContent: "",
60012
- updateTimer: null
60278
+ updateTimer: null,
60279
+ header: null,
60280
+ headerDirty: false,
60281
+ headerPostId: null,
60282
+ headerBody: "",
60283
+ turnOpen: false,
60284
+ paragraphBreak: false
60013
60285
  };
60014
60286
  }
60287
+ setHeader(line) {
60288
+ if (!this.state.turnOpen) {
60289
+ this.state.turnOpen = true;
60290
+ this.state.headerPostId = this.state.currentPostId;
60291
+ this.state.headerBody = this.state.currentPostContent;
60292
+ }
60293
+ this.state.header = line;
60294
+ this.state.headerDirty = true;
60295
+ }
60296
+ getHeaderPostId() {
60297
+ return this.state.headerPostId;
60298
+ }
60299
+ abandonHeaderTurn() {
60300
+ this.state.header = null;
60301
+ this.state.headerDirty = false;
60302
+ this.state.headerPostId = null;
60303
+ this.state.headerBody = "";
60304
+ this.state.turnOpen = false;
60305
+ }
60306
+ getRenderedCurrentPost() {
60307
+ const postId = this.state.currentPostId;
60308
+ if (!postId)
60309
+ return null;
60310
+ return { postId, text: this.renderFor(postId, this.state.currentPostContent) };
60311
+ }
60312
+ renderFor(postId, body) {
60313
+ if (this.state.header && postId !== null && postId === this.state.headerPostId) {
60314
+ return body ? `${this.state.header}
60315
+
60316
+ ${body}` : this.state.header;
60317
+ }
60318
+ return body;
60319
+ }
60320
+ headerReserve(postId) {
60321
+ const header = this.state.header;
60322
+ if (!header)
60323
+ return 0;
60324
+ const applies = postId === this.state.headerPostId || postId === null && this.state.headerPostId === null && this.state.turnOpen;
60325
+ return applies ? header.length + 2 : 0;
60326
+ }
60327
+ async renderHeaderOnly(ctx) {
60328
+ this.state.headerDirty = false;
60329
+ if (this.state.headerPostId) {
60330
+ const postId = this.state.headerPostId;
60331
+ await this.tryUpdatePost(ctx, postId, this.state.headerBody, "header", { reason: "header_update", headerLength: this.state.header?.length ?? 0 }, { reason: "header_update_failed" }, () => {}, () => {});
60332
+ return;
60333
+ }
60334
+ if (this.state.header && this.state.turnOpen) {
60335
+ await this.createNewPost(ctx, "", "");
60336
+ }
60337
+ }
60015
60338
  getInitialState() {
60016
60339
  return ContentExecutor.createInitialState();
60017
60340
  }
@@ -60023,7 +60346,11 @@ class ContentExecutor extends BaseExecutor {
60023
60346
  }
60024
60347
  async tryUpdatePost(ctx, postId, content, logTag, successDetails, failureDetails, onSuccess, onFailure) {
60025
60348
  try {
60026
- await ctx.platform.updatePost(postId, content);
60349
+ await ctx.platform.updatePost(postId, this.renderFor(postId, content));
60350
+ if (postId === this.state.headerPostId) {
60351
+ this.state.headerBody = content;
60352
+ this.state.headerDirty = false;
60353
+ }
60027
60354
  onSuccess();
60028
60355
  ctx.threadLogger?.logExecutor("content", "update", postId, successDetails, logTag);
60029
60356
  } catch (err) {
@@ -60045,8 +60372,14 @@ class ContentExecutor extends BaseExecutor {
60045
60372
  }, "closeCurrentPost");
60046
60373
  }
60047
60374
  }
60375
+ markParagraphBreak() {
60376
+ this.state.paragraphBreak = true;
60377
+ }
60048
60378
  async executeAppend(op, _ctx) {
60049
- if (op.isToolOutput && this.state.pendingContent.length > 0) {
60379
+ const breakBefore = this.state.paragraphBreak && !op.isToolOutput;
60380
+ if (!op.isToolOutput)
60381
+ this.state.paragraphBreak = false;
60382
+ if ((op.isToolOutput || breakBefore) && this.state.pendingContent.length > 0) {
60050
60383
  if (!this.state.pendingContent.endsWith(`
60051
60384
 
60052
60385
  `)) {
@@ -60079,10 +60412,21 @@ class ContentExecutor extends BaseExecutor {
60079
60412
  this.flush(ctx, "soft_threshold");
60080
60413
  }, delayMs);
60081
60414
  }
60082
- async flush(ctx, _reason) {
60415
+ async flush(ctx, reason) {
60083
60416
  if (!this.state.pendingContent.trim()) {
60417
+ if (this.state.headerDirty)
60418
+ await this.renderHeaderOnly(ctx);
60419
+ if (reason === "result")
60420
+ this.state.turnOpen = false;
60084
60421
  return;
60085
60422
  }
60423
+ await this.flushPending(ctx);
60424
+ if (this.state.headerDirty)
60425
+ await this.renderHeaderOnly(ctx);
60426
+ if (reason === "result")
60427
+ this.state.turnOpen = false;
60428
+ }
60429
+ async flushPending(ctx) {
60086
60430
  const pendingAtFlushStart = this.state.pendingContent;
60087
60431
  let content = ctx.formatter.formatMarkdown(pendingAtFlushStart).trim();
60088
60432
  const { maxLength: MAX_POST_LENGTH, hardThreshold: HARD_CONTINUATION_THRESHOLD } = ctx.platform.getMessageLimits();
@@ -60098,13 +60442,14 @@ class ContentExecutor extends BaseExecutor {
60098
60442
  combinedContent = content;
60099
60443
  }
60100
60444
  const shouldBreakEarly = this.state.currentPostId && combinedContent.length > MIN_BREAK_THRESHOLD && ctx.contentBreaker.shouldFlushEarly(combinedContent);
60101
- if (this.state.currentPostId && (combinedContent.length > HARD_CONTINUATION_THRESHOLD || shouldBreakEarly)) {
60445
+ const reserve = this.headerReserve(this.state.currentPostId);
60446
+ if (this.state.currentPostId && (combinedContent.length + reserve > HARD_CONTINUATION_THRESHOLD || shouldBreakEarly)) {
60102
60447
  await this.handleSplit(ctx, combinedContent, pendingAtFlushStart, HARD_CONTINUATION_THRESHOLD);
60103
60448
  return;
60104
60449
  }
60105
- if (content.length > MAX_POST_LENGTH) {
60450
+ if (this.state.currentPostId && content.length + reserve > MAX_POST_LENGTH) {
60106
60451
  ctx.logger.warn(`Content too long (${content.length}), truncating`);
60107
- content = truncateMessageSafely(content, MAX_POST_LENGTH, ctx.formatter.formatItalic("... (truncated)"));
60452
+ content = truncateMessageSafely(content, MAX_POST_LENGTH - reserve, ctx.formatter.formatItalic("... (truncated)"));
60108
60453
  }
60109
60454
  if (this.state.currentPostId) {
60110
60455
  const postId = this.state.currentPostId;
@@ -60119,7 +60464,7 @@ class ContentExecutor extends BaseExecutor {
60119
60464
  } else {
60120
60465
  combinedContent = content;
60121
60466
  }
60122
- if (combinedContent.length > MAX_POST_LENGTH) {
60467
+ if (combinedContent.length + reserve > MAX_POST_LENGTH) {
60123
60468
  ctx.logger.debug(`Combined content (${combinedContent.length}) would exceed max (${MAX_POST_LENGTH}), creating continuation post`);
60124
60469
  ctx.threadLogger?.logExecutor("content", "create_start", "none", {
60125
60470
  contentLength: content.length,
@@ -60139,14 +60484,28 @@ class ContentExecutor extends BaseExecutor {
60139
60484
  this.state.currentPostContent = "";
60140
60485
  });
60141
60486
  } else {
60142
- const chunks = splitContentForHeight(content, ctx.contentBreaker);
60487
+ const chunks = splitContentForHeight(content, ctx.contentBreaker).flatMap((chunk) => chunk.length + reserve > MAX_POST_LENGTH ? splitByLength(chunk, HARD_CONTINUATION_THRESHOLD - reserve) : [chunk]);
60143
60488
  ctx.threadLogger?.logExecutor("content", "create_start", "none", {
60144
60489
  contentLength: content.length,
60145
60490
  chunkCount: chunks.length,
60146
60491
  reason: "no_currentPostId"
60147
60492
  }, "flush");
60493
+ let lastPosted = null;
60148
60494
  for (let i = 0;i < chunks.length; i++) {
60149
- await this.createNewPost(ctx, chunks[i], pendingAtFlushStart);
60495
+ const created = await this.createNewPost(ctx, chunks[i], lastPosted ? "" : pendingAtFlushStart);
60496
+ if (!created) {
60497
+ if (lastPosted) {
60498
+ this.state.pendingContent = chunks.slice(i).join(`
60499
+
60500
+ `) + (this.state.pendingContent ? `
60501
+
60502
+ ${this.state.pendingContent}` : "");
60503
+ }
60504
+ this.state.currentPostId = null;
60505
+ this.state.currentPostContent = "";
60506
+ break;
60507
+ }
60508
+ lastPosted = { id: this.state.currentPostId, content: this.state.currentPostContent };
60150
60509
  if (i < chunks.length - 1) {
60151
60510
  this.state.currentPostId = null;
60152
60511
  this.state.currentPostContent = "";
@@ -60245,7 +60604,11 @@ class ContentExecutor extends BaseExecutor {
60245
60604
  const remainder = content.substring(breakPoint).trim();
60246
60605
  if (this.state.currentPostId) {
60247
60606
  const postId = this.state.currentPostId;
60248
- await this.tryUpdatePost(ctx, postId, firstPart, "handleSplit", { reason: "split_first_part", firstPartLength: firstPart.length, remainderLength: remainder.length }, { reason: "split_first_part_failed" }, () => {}, () => {});
60607
+ await this.tryUpdatePost(ctx, postId, firstPart, "handleSplit", { reason: "split_first_part", firstPartLength: firstPart.length, remainderLength: remainder.length }, { reason: "split_first_part_failed" }, () => {}, () => {
60608
+ if (postId === this.state.headerPostId) {
60609
+ this.state.headerBody = firstPart;
60610
+ }
60611
+ });
60249
60612
  }
60250
60613
  this.state.currentPostId = null;
60251
60614
  this.state.currentPostContent = "";
@@ -60254,9 +60617,22 @@ class ContentExecutor extends BaseExecutor {
60254
60617
  }
60255
60618
  }
60256
60619
  async createNewPost(ctx, content, pendingAtFlushStart) {
60620
+ const header = this.state.header;
60621
+ const becomesHeaderPost = this.state.turnOpen && this.state.headerPostId === null && header !== null;
60622
+ const rendered = becomesHeaderPost && header !== null ? content ? `${header}
60623
+
60624
+ ${content}` : header : content;
60625
+ const adoptAsHeaderPost = (postId) => {
60626
+ if (becomesHeaderPost) {
60627
+ this.state.headerPostId = postId;
60628
+ this.state.headerBody = content;
60629
+ this.state.headerDirty = false;
60630
+ }
60631
+ };
60257
60632
  if (this.onBumpTaskList) {
60258
- const bumpedPostId = await this.onBumpTaskList(content, ctx);
60633
+ const bumpedPostId = await this.onBumpTaskList(rendered, ctx);
60259
60634
  if (bumpedPostId) {
60635
+ adoptAsHeaderPost(bumpedPostId);
60260
60636
  this.state.currentPostId = bumpedPostId;
60261
60637
  this.state.currentPostContent = content;
60262
60638
  this.clearFlushedContent(pendingAtFlushStart);
@@ -60267,11 +60643,12 @@ class ContentExecutor extends BaseExecutor {
60267
60643
  if (this.onBumpTaskListToBottom) {
60268
60644
  await this.onBumpTaskListToBottom();
60269
60645
  }
60270
- return;
60646
+ return true;
60271
60647
  }
60272
60648
  }
60273
60649
  try {
60274
- const post = await ctx.createPost(content, { type: "content" });
60650
+ const post = await ctx.createPost(rendered, { type: "content" });
60651
+ adoptAsHeaderPost(post.id);
60275
60652
  this.state.currentPostId = post.id;
60276
60653
  this.state.currentPostContent = content;
60277
60654
  this.clearFlushedContent(pendingAtFlushStart);
@@ -60283,8 +60660,10 @@ class ContentExecutor extends BaseExecutor {
60283
60660
  if (this.onBumpTaskListToBottom) {
60284
60661
  await this.onBumpTaskListToBottom();
60285
60662
  }
60663
+ return true;
60286
60664
  } catch (err) {
60287
60665
  ctx.logger.error(`Failed to create post: ${err}`);
60666
+ return false;
60288
60667
  }
60289
60668
  }
60290
60669
  clearFlushedContent(flushedContent) {
@@ -60295,6 +60674,40 @@ class ContentExecutor extends BaseExecutor {
60295
60674
  }
60296
60675
  }
60297
60676
  }
60677
+ var FENCE_LINE = /^```.*$/gm;
60678
+ var FENCE_CLOSE = "\n```";
60679
+ function splitByLength(text, max) {
60680
+ const room = max - FENCE_CLOSE.length;
60681
+ if (room < 1)
60682
+ return [text];
60683
+ const pieces = [];
60684
+ let rest = text;
60685
+ while (rest.length > max) {
60686
+ let cut = rest.lastIndexOf(`
60687
+ `, room);
60688
+ if (cut < room * 0.5)
60689
+ cut = room;
60690
+ let piece = rest.slice(0, cut);
60691
+ let next = rest.slice(cut).replace(/^\n+/, "");
60692
+ const fences = piece.match(FENCE_LINE) ?? [];
60693
+ const opener = fences[fences.length - 1];
60694
+ if (fences.length % 2 === 1 && opener !== undefined && `${opener}
60695
+ ${next}`.length < rest.length) {
60696
+ piece += FENCE_CLOSE;
60697
+ next = `${opener}
60698
+ ${next}`;
60699
+ }
60700
+ pieces.push(piece);
60701
+ if (next.length >= rest.length) {
60702
+ rest = next;
60703
+ break;
60704
+ }
60705
+ rest = next;
60706
+ }
60707
+ if (rest)
60708
+ pieces.push(rest);
60709
+ return pieces;
60710
+ }
60298
60711
  // src/operations/executors/task-list.ts
60299
60712
  init_emoji();
60300
60713
  class TaskListExecutor extends BaseExecutor {
@@ -61806,6 +62219,8 @@ class MessageManager {
61806
62219
  platform;
61807
62220
  postTracker;
61808
62221
  contentBreaker;
62222
+ toolActivity;
62223
+ toolActivityExecutor = null;
61809
62224
  turnMarker;
61810
62225
  flushInFlight = null;
61811
62226
  turn = 0;
@@ -61897,6 +62312,39 @@ class MessageManager {
61897
62312
  updateLastMessage: options.updateLastMessage,
61898
62313
  events: this.events
61899
62314
  });
62315
+ this.toolActivity = options.toolActivity ?? { activity: "full", details: "none" };
62316
+ if (this.toolActivity.activity !== "full") {
62317
+ const sink = this.toolActivity.details === "thread" ? createThreadSink({
62318
+ contextFor: () => this.toolDetailsContext(),
62319
+ holdUntilTurnEnd: !isDcmThreadId(this.threadId),
62320
+ makeExecutor: () => new ContentExecutor({ registerPost: options.registerPost, updateLastMessage: () => {
62321
+ return;
62322
+ } })
62323
+ }) : noneSink;
62324
+ this.toolActivityExecutor = new ToolActivityExecutor({
62325
+ mode: this.toolActivity.activity,
62326
+ sink,
62327
+ onHeader: (line) => {
62328
+ this.contentExecutor.setHeader(line);
62329
+ this.scheduleFlush(this.getExecutorContext());
62330
+ }
62331
+ });
62332
+ }
62333
+ }
62334
+ toolDetailsContext() {
62335
+ const replyPost = this.contentExecutor.getHeaderPostId();
62336
+ if (!replyPost)
62337
+ return null;
62338
+ const root = isDcmThreadId(this.threadId) ? replyPost : this.threadId;
62339
+ const base = this.getExecutorContext();
62340
+ return {
62341
+ ...base,
62342
+ createPost: async (content, options) => {
62343
+ const post = await this.platform.createPost(content, root);
62344
+ this.registerPost(post.id, { ...options, type: "tool_details" });
62345
+ return post;
62346
+ }
62347
+ };
61900
62348
  }
61901
62349
  async handleEvent(event) {
61902
62350
  const logger = log16.forSession(this.sessionId);
@@ -61906,6 +62354,7 @@ class MessageManager {
61906
62354
  toolStartTimes: this.toolStartTimes,
61907
62355
  taskTracker: this.taskTracker,
61908
62356
  detailed: true,
62357
+ toolActivity: this.toolActivity.activity,
61909
62358
  worktreeInfo: this.worktreePath && this.worktreeBranch ? { path: this.worktreePath, branch: this.worktreeBranch } : undefined
61910
62359
  };
61911
62360
  const ops = transformEvent(event, transformCtx);
@@ -61958,6 +62407,10 @@ class MessageManager {
61958
62407
  try {
61959
62408
  if (isContentOp(op)) {
61960
62409
  await this.handleContentOp(op, ctx);
62410
+ } else if (isToolActivityOp(op)) {
62411
+ if (op.kind === "start")
62412
+ this.contentExecutor.markParagraphBreak();
62413
+ await this.toolActivityExecutor?.execute(op, ctx);
61961
62414
  } else if (isFlushOp(op)) {
61962
62415
  await this.handleFlushOp(op, ctx);
61963
62416
  } else if (isTaskListOp(op)) {
@@ -61997,25 +62450,26 @@ class MessageManager {
61997
62450
  await this.flushInFlight.catch(() => {
61998
62451
  return;
61999
62452
  });
62000
- if (op.reason === "result")
62001
- this.turn++;
62453
+ const turn = op.reason === "result" ? ++this.turn : this.turn;
62002
62454
  await this.runTrackedFlush(op, ctx);
62003
62455
  if (op.reason === "result") {
62004
- await this.markTurnComplete(ctx, op.resultOk !== false);
62456
+ await this.toolActivityExecutor?.afterResultFlush(ctx);
62457
+ await this.markTurnComplete(ctx, op.resultOk !== false, turn);
62005
62458
  }
62006
62459
  }
62007
- async markTurnComplete(ctx, ok) {
62460
+ async markTurnComplete(ctx, ok, turn) {
62008
62461
  if (this.turnMarker.mode === "off")
62009
62462
  return;
62010
- const { currentPostId, currentPostContent } = this.contentExecutor.getState();
62011
- if (!currentPostId)
62463
+ const current = this.contentExecutor.getRenderedCurrentPost();
62464
+ if (!current)
62012
62465
  return;
62466
+ const { postId: currentPostId, text: currentPostText } = current;
62013
62467
  try {
62014
62468
  if (this.turnMarker.mode === "metadata") {
62015
- await this.platform.updatePost(currentPostId, currentPostContent, {
62469
+ await this.platform.updatePost(currentPostId, currentPostText, {
62016
62470
  metadata: {
62017
62471
  event_type: TURN_COMPLETE_EVENT_TYPE,
62018
- event_payload: { v: TURN_COMPLETE_PAYLOAD_VERSION, session: this.sessionId, turn: this.turn, ok }
62472
+ event_payload: { v: TURN_COMPLETE_PAYLOAD_VERSION, session: this.sessionId, turn, ok }
62019
62473
  }
62020
62474
  });
62021
62475
  } else {
@@ -62035,6 +62489,7 @@ class MessageManager {
62035
62489
  });
62036
62490
  this.flushInFlight = running;
62037
62491
  await running;
62492
+ this.toolActivityExecutor?.wake();
62038
62493
  }
62039
62494
  scheduleFlush(ctx) {
62040
62495
  if (this.flushTimer)
@@ -62431,15 +62886,21 @@ class MessageManager {
62431
62886
  this.pendingBridgeQuestion = null;
62432
62887
  }
62433
62888
  clearClaudeSessionState() {
62889
+ this.clearTurnState();
62434
62890
  this.toolStartTimes.clear();
62435
62891
  this.taskTracker.clear();
62436
62892
  this.denyPendingBridgeRequests("Claude was restarted before a decision was made");
62437
62893
  }
62894
+ clearTurnState() {
62895
+ this.toolActivityExecutor?.reset();
62896
+ this.contentExecutor.abandonHeaderTurn();
62897
+ }
62438
62898
  reset() {
62439
62899
  this.cancelScheduledFlush();
62440
62900
  this.toolStartTimes.clear();
62441
62901
  this.taskTracker.clear();
62442
62902
  this.contentExecutor.reset();
62903
+ this.toolActivityExecutor?.reset();
62443
62904
  this.taskListExecutor.reset();
62444
62905
  this.questionApprovalExecutor.reset();
62445
62906
  this.messageApprovalExecutor.reset();
@@ -65782,6 +66243,7 @@ async function restartClaudeSession(session, cliOptions, ctx, actionName) {
65782
66243
  await session.claude.kill();
65783
66244
  await ctx.ops.flush(session);
65784
66245
  session.messageManager?.denyPendingBridgeRequests("Claude was restarted before a decision was made");
66246
+ session.messageManager?.clearTurnState();
65785
66247
  if (!cliOptions.resume) {
65786
66248
  session.messageManager?.clearClaudeSessionState();
65787
66249
  }
@@ -67301,6 +67763,7 @@ function createMessageManager(session, ctx) {
67301
67763
  sessionId: session.sessionId,
67302
67764
  worktreePath: session.worktreeInfo?.worktreePath,
67303
67765
  worktreeBranch: session.worktreeInfo?.branch,
67766
+ toolActivity: ctx.ops.getPlatformOverhead(session.platformId).tools,
67304
67767
  turnMarker: ctx.ops.getPlatformOverhead(session.platformId).turnMarker,
67305
67768
  registerPost: (postId, options) => {
67306
67769
  ctx.ops.registerPost(postId, session.threadId);
@@ -72954,7 +73417,8 @@ class SessionManager extends EventEmitter4 {
72954
73417
  sessionHeader: options?.overhead?.sessionHeader ?? DEFAULT_OVERHEAD_VISIBILITY,
72955
73418
  stickyMessage: options?.overhead?.stickyMessage ?? DEFAULT_OVERHEAD_VISIBILITY,
72956
73419
  lifecycle: options?.overhead?.lifecycle ?? DEFAULT_OVERHEAD_VISIBILITY,
72957
- turnMarker: options?.overhead?.turnMarker ?? DEFAULT_TURN_MARKER
73420
+ turnMarker: options?.overhead?.turnMarker ?? DEFAULT_TURN_MARKER,
73421
+ tools: options?.overhead?.tools ?? DEFAULT_TOOL_ACTIVITY
72958
73422
  });
72959
73423
  this.platformMemory.set(platformId, options?.memory ?? DEFAULT_MEMORY_CONFIG);
72960
73424
  this.platformRoutines.set(platformId, options?.routinesEnabled ?? true);
@@ -73094,7 +73558,8 @@ class SessionManager extends EventEmitter4 {
73094
73558
  sessionHeader: DEFAULT_OVERHEAD_VISIBILITY,
73095
73559
  stickyMessage: DEFAULT_OVERHEAD_VISIBILITY,
73096
73560
  lifecycle: DEFAULT_OVERHEAD_VISIBILITY,
73097
- turnMarker: DEFAULT_TURN_MARKER
73561
+ turnMarker: DEFAULT_TURN_MARKER,
73562
+ tools: DEFAULT_TOOL_ACTIVITY
73098
73563
  },
73099
73564
  getPlatformMemoryConfig: (pid) => this.platformMemory.get(pid) ?? DEFAULT_MEMORY_CONFIG,
73100
73565
  isRoutinesEnabled: (pid) => this.platformRoutines.get(pid) ?? true,
@@ -87519,7 +87984,8 @@ async function startWithoutDaemon() {
87519
87984
  session.addPlatform(platformConfig.id, client, {
87520
87985
  overhead: {
87521
87986
  ...resolvePresentationOverhead(platformConfig, `platforms[${platformConfig.id}]`),
87522
- turnMarker: resolveTurnMarker(platformConfig.turnMarker, platformConfig.turnMarkerEmoji, platformConfig.type, `platforms[${platformConfig.id}]`)
87987
+ turnMarker: resolveTurnMarker(platformConfig.turnMarker, platformConfig.turnMarkerEmoji, platformConfig.type, `platforms[${platformConfig.id}]`),
87988
+ tools: resolveToolActivity(platformConfig.toolActivity, platformConfig.toolDetails, `platforms[${platformConfig.id}]`)
87523
87989
  },
87524
87990
  memory: resolveMemoryConfig(platformConfig.memory, `platforms[${platformConfig.id}].memory`),
87525
87991
  routinesEnabled: resolveRoutinesEnabled(platformConfig.routines, `platforms[${platformConfig.id}].routines`),
@@ -87547,6 +88013,7 @@ async function startWithoutDaemon() {
87547
88013
  overhead: {
87548
88014
  ...resolvePresentationOverhead(dmConfig, `dm[${dmConfig.id}]`),
87549
88015
  stickyMessage: "hidden",
88016
+ tools: resolveToolActivity(dmConfig.toolActivity, dmConfig.toolDetails, `dm[${dmConfig.id}]`),
87550
88017
  turnMarker: resolveTurnMarker(dmConfig.turnMarker, dmConfig.turnMarkerEmoji, dmConfig.type, `dm[${dmConfig.id}]`)
87551
88018
  },
87552
88019
  memory: resolveMemoryConfig(dmConfig.memory, `dm[${dmConfig.id}].memory`),
@@ -34639,11 +34639,6 @@ var METADATA_STORE_PATH = path.join(stateHome(), ".claude-threads", "worktree-me
34639
34639
  var log5 = createLogger("helpers");
34640
34640
  var sessionLog = createSessionLog(log5);
34641
34641
 
34642
- // src/operations/message-manager.ts
34643
- init_types();
34644
-
34645
- // src/operations/task-tracker.ts
34646
- var VALID_STATUSES = new Set(["pending", "in_progress", "completed"]);
34647
34642
  // src/platform/utils.ts
34648
34643
  function formatWebSocketError(err) {
34649
34644
  if (err instanceof Error)
@@ -34764,6 +34759,12 @@ function isDcmThreadId(threadId) {
34764
34759
  function resolvePostThreadId(threadId) {
34765
34760
  return isDcmThreadId(threadId) ? undefined : threadId;
34766
34761
  }
34762
+
34763
+ // src/operations/message-manager.ts
34764
+ init_types();
34765
+
34766
+ // src/operations/task-tracker.ts
34767
+ var VALID_STATUSES = new Set(["pending", "in_progress", "completed"]);
34767
34768
  // src/operations/executors/task-list.ts
34768
34769
  init_emoji();
34769
34770
  // src/operations/executors/subagent.ts
@@ -423,6 +423,32 @@ platforms:
423
423
 
424
424
  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.
425
425
 
426
+ #### Tool activity: a summary line instead of the stream
427
+
428
+ By default every tool Claude uses streams into the reply (`Bash …`, `Read …`, `↳ ✓`). For an assistant-style channel where you want the answer, not the shopping, two per-platform fields change that:
429
+
430
+ ```yaml
431
+ platforms:
432
+ - id: slack-assistant
433
+ type: slack
434
+ # ... credentials ...
435
+ toolActivity: summary # full (default) | summary | hidden
436
+ toolDetails: thread # thread (default for summary) | none
437
+ ```
438
+
439
+ | `toolActivity` | The reply |
440
+ |---|---|
441
+ | `full` | unchanged: every tool inline with its completion indicator |
442
+ | `summary` | one live line at the top of the turn's post, `🔧 12 tools · 40 s · Bash`, updated as tools run; the name is the tool most recently started, so the line shows what the bot is doing; `· 1 ❌` when a tool failed. Claude's text follows |
443
+ | `hidden` | nothing about tools at all |
444
+
445
+ | `toolDetails` | Where the full tool stream goes when `toolActivity` is not `full` |
446
+ |---|---|
447
+ | `thread` | posted as replies in a thread under the turn's post (in a thread-mode session, after the reply in the same thread, once the turn ends: Slack has no nested threads) |
448
+ | `none` | dropped |
449
+
450
+ Permission prompts, plan approvals, questions, task lists and errors post as before in every mode. `toolDetails` with `full`, or `thread` with `hidden`, is a startup config error.
451
+
426
452
  #### End-of-turn marker
427
453
 
428
454
  Integrations that read the channel (a voice front end, a phone bridge, a dashboard) need to know when Claude's answer is complete. The daemon streams by editing one post, so without help they can only guess from the text going quiet. With a marker, the daemon stamps the turn's last reply post the moment the turn ends:
@@ -0,0 +1,232 @@
1
+ # Tool activity: summary line instead of the stream, details one click away
2
+
3
+ Upstream discussion: anneschuth/claude-threads#505 (shape proposed in the
4
+ 2026-09-02 comment; `hidden` was agreed by the maintainer on 2026-08-25).
5
+
6
+ ## What it does
7
+
8
+ Today every tool Claude uses renders into the reply post as it happens
9
+ (`Bash …`, `Read …`, then `↳ ✓ (5s)`), and there is no switch. For a coding
10
+ session that is the point; for an assistant-style channel the answer is
11
+ buried under the commands that produced it.
12
+
13
+ Two per-platform settings, beside `sessionHeader` / `stickyMessage` /
14
+ `lifecycle`:
15
+
16
+ ```yaml
17
+ platforms:
18
+ - id: assistant
19
+ type: slack
20
+ toolActivity: summary # full (default) | summary | hidden
21
+ toolDetails: thread # thread (default with summary) | none
22
+ ```
23
+
24
+ `toolDetails: file`, with `toolDetailsDir` / `toolDetailsUrl`, arrives in
25
+ PR 2 (see **Delivery**); this PR rejects those three at startup.
26
+
27
+ | `toolActivity` | The reply post |
28
+ |---|---|
29
+ | `full` | unchanged: every tool inline with its `↳` indicator |
30
+ | `summary` | one **live line at the top of the turn's post**, `🔧 12 tools · 40 s · Bash`, updated as tools start and finish; the name is the tool most recently started, so the line says what the bot is doing and not only how much it has done (@thejdubb02 in #505); `· 1 ❌` appended when a tool failed; with `toolDetails: file` and a URL (PR 2), the line links to the details page. Claude's text follows as today |
31
+ | `hidden` | nothing about tools at all, and no `↳` orphans. What the maintainer agreed to in #505 |
32
+
33
+ | `toolDetails` | Where the full rendering goes when `toolActivity` is not `full` |
34
+ |---|---|
35
+ | `thread` | posted as replies **in a thread under the turn's post**, streamed the same way the reply is (edit-in-place, split on length). In a thread-mode session the turn's post is already a thread reply and Slack has no nested threads, so the details land in the same thread after the reply; that is `full` with the tools moved below the answer, and documented as such |
36
+ | `file` (PR 2) | appended to `<toolDetailsDir>/<platformId>/<sessionId>/<turn>.html`, one file per turn, plus `index.html` per session listing turns. Serving the directory is the operator's job. With `toolDetailsUrl` the summary line links to `<url>/<platformId>/<sessionId>/<turn>.html` |
37
+ | `none` | the summary only |
38
+
39
+ Default: `full` / `none`, so an existing config behaves exactly as before.
40
+ `summary` without `toolDetails` means `thread`. Config errors, thrown with
41
+ the field path at startup like the other per-platform fields: `toolDetails`
42
+ with `full`; `hidden` with `thread` (hidden has no post of its own to hang
43
+ a thread on; use `summary`, or — from PR 2 — `file`); `toolDetailsDir` /
44
+ `toolDetailsUrl` with anything but `file`.
45
+
46
+ Untouched in every mode: permission prompts, plan approvals, questions, task
47
+ lists, `send_file`, the bug button, session errors. Those are not tool
48
+ rendering; they are interactive or they are failures. A *tool* that fails is
49
+ Claude's business and shows only as the `❌` count.
50
+
51
+ ## Why
52
+
53
+ - Assistant use (#505): replies only, but the receipt stays one click away.
54
+ - Anything that consumes the reply post as an answer — text-to-speech, a
55
+ digest, a downstream bot — has to skip forty tool lines to find it.
56
+ `summary` is what makes the post the answer.
57
+ - `file` (PR 2) over a web server outside the daemon: the daemon writes, the
58
+ operator's existing server serves, nothing new listens. ⚠️ Tool details
59
+ contain command lines and outputs. The directory must be served **behind
60
+ auth**; the spec says so, the README says so, and the default is no URL.
61
+
62
+ Considered and dropped: a git commit per tool call (a `Read` has no diff;
63
+ forty commits a turn would wreck the repo the agent works in) and a
64
+ `!tools` runtime toggle (round 2 if anyone asks; the config is enough to
65
+ start).
66
+
67
+ ## How
68
+
69
+ ### Transformer (`src/operations/transformer.ts`)
70
+
71
+ Tool rendering already produces ops tagged `isToolOutput` (tool_use display,
72
+ `server_tool_use`, and the `↳` result indicator). With `toolActivity !== 'full'`
73
+ the transformer emits, instead of those ops, a `tool_activity` op:
74
+
75
+ ```ts
76
+ { type: 'tool_activity', kind: 'start', toolUseId, name, display } // display: the same rendered line
77
+ { type: 'tool_activity', kind: 'end', toolUseId, ok, elapsedMs, display } // display: the ↳ line
78
+ ```
79
+
80
+ Nothing else in the transformer changes; special tools keep their own ops.
81
+ A `TransformContext.toolActivity` field carries the mode (default `full`).
82
+
83
+ ### Executors
84
+
85
+ - **`ToolActivityExecutor`** (new, `src/operations/executors/tool-activity.ts`)
86
+ owns the per-turn counter
87
+ `{ started, finished, failed, firstStartAt, lastEndAt, lastTool }`
88
+ and renders the summary line. On each op it asks the content executor to
89
+ re-render the post header (debounced through the existing 500 ms flush).
90
+ The transformer emits an explicit `{ kind: 'turn_end' }` op from the
91
+ `result` event (inferring it from a flush reason would couple the executor
92
+ to flush internals); the last render is the final line, the sink's
93
+ `turnEnded()` runs, and the counter resets.
94
+ - **Content executor**: every write to a post goes through one
95
+ `renderPost(postId, body)` path (today `flush`, `handleSplit` and the
96
+ task-list repurpose branch each write directly; Codex plan review), which
97
+ prepends the header when `postId` is the turn's first post. A header
98
+ update with nothing pending still renders: it edits the first post, or
99
+ creates it header-only when the turn has no post yet. Length and height
100
+ decisions for the first post use `header + body`. Gains an optional
101
+ `header` (one line) that belongs to the **first post of the turn only**. The executor remembers
102
+ `turnFirstPostId` and that post's body; a header update re-renders that
103
+ post as `header + body` even after streaming has moved on to a
104
+ continuation post. Continuation posts never carry the header, so a split
105
+ cannot bake a stale receipt into one post and a fresh one into the next
106
+ (Gemini plan review). The header is not part of `pendingContent`, so
107
+ splitting and length checks are unchanged apart from counting its length
108
+ on the first post.
109
+ - **Details sinks** (`src/operations/tool-details/`), one interface:
110
+ `ToolDetailsSink { append(op): void; turnEnded(): Promise<void>; linkFor(turn): string | null }`.
111
+ - `thread`: a second `ContentExecutor` **without** the task-list bump
112
+ callbacks (they would let a details post repurpose the task-list post)
113
+ and with its own `createPost` that registers posts as `tool_details`
114
+ and does **not** touch `updateLastMessage` (a details post must never
115
+ become the session's "latest reply"). Its context posts with
116
+ `rootId = turnFirstPostId` of the main executor in direct-channel mode
117
+ (the reply is a channel post, the details thread hangs under it), and
118
+ `rootId = the session's threadId` in thread mode (Slack has no nested
119
+ threads; `thread_ts` of a reply is rejected with `invalid_thread_ts`), so
120
+ there the details land as peers after the reply. In that mode they are
121
+ held until the turn ends and then posted in one go, because a details
122
+ post made mid-turn would sit above reply posts that are still to come.
123
+ In direct-channel mode they have a thread of their own and stream the
124
+ `display` strings exactly like the reply, starting lazily on the first
125
+ tool of a turn; if the main post does not exist yet, the first header
126
+ render creates it. Each turn's details are written on a serial chain of
127
+ their own, so a streaming flush and the turn-end flush never overlap,
128
+ and the next turn's first tool never lands in the turn being closed.
129
+ - `file`: appends escaped `<pre>` blocks to the turn file (ANSI escape
130
+ sequences stripped first; tool output is full of them) and rewrites
131
+ `index.html`; deterministic path so the link exists from the first tool.
132
+ Writes are sequential per session. A write error is **reported once in
133
+ the channel as a system error and the sink stops for the session**; the
134
+ reply itself keeps streaming. Not silent, not fatal to the answer.
135
+ - `none`: no-op.
136
+
137
+ ### Config (`src/config/types.ts`)
138
+
139
+ `PlatformInstanceConfig.toolActivity?`, `toolDetails?`, `toolDetailsDir?`,
140
+ `toolDetailsUrl?`; resolved next to the overhead fields into
141
+ `PlatformOverhead.tools: { activity, details, dir, url }` with the same
142
+ `resolve…` validation and the same wiring through `src/index.ts` →
143
+ `SessionManager` → `TransformContext` / executor options as `lifecycle`.
144
+
145
+ ### Turn boundaries
146
+
147
+ A turn's counter and its details sink describe **one CLI process**. Every
148
+ respawn — `!cd`, a worktree switch, `!permissions interactive` — kills that
149
+ process mid-turn, so both must start over. `MessageManager.clearTurnState()`
150
+ does exactly that and `restartClaudeSession` calls it unconditionally:
151
+ `clearClaudeSessionState()` is not enough, because a resume restart
152
+ deliberately skips it to keep its task numbering.
153
+
154
+ ### Delivery
155
+
156
+ Two PRs so each stands alone:
157
+
158
+ 1. `pr/quiet-tools`: `full | summary | hidden`, `thread | none`, transformer
159
+ op, executors, config, tests. (#505's tool half.)
160
+ 2. `pr/quiet-tools-file` stacked on it: the `file` sink and `toolDetailsUrl`.
161
+
162
+ ## Tests (first)
163
+
164
+ - transformer: each mode × (tool_use, server_tool_use, tool_result ok/error,
165
+ special tool) → which ops come out; in both quiet modes no
166
+ `append_content` op carries tool text, and the start time is consumed by
167
+ the end op (so `elapsedMs` exists and no `↳` line can orphan).
168
+ - ToolActivityExecutor: counter and line text through start/end/error/turn
169
+ end; the line with and without a link.
170
+ - content executor: header stays on the first post of the turn across a split;
171
+ the continuation post has none; a header update after the split edits the
172
+ first post, not the current one; a final header set after a split still
173
+ reaches the header post when the closing text lands on the continuation.
174
+ - respawn: `restartClaudeSession` clears the turn in progress whether or not
175
+ it resumes, and the next turn's counter starts at one.
176
+ - content executor: header rendered above content, survives a split, counted
177
+ in length checks.
178
+ - thread sink: posts under the main post's id, lazy start, thread-mode
179
+ degrade.
180
+ - file sink: path, escaping, index rewrite, write error propagates.
181
+ - config: defaults, `summary` implies `thread`, `toolDetails` with `full`
182
+ rejected with the config path in the message.
183
+
184
+ ## Decisions
185
+
186
+ | Decision | Why |
187
+ |---|---|
188
+ | Two fields, not one enum | what to show and where to keep the rest are independent; `summary+file` and `hidden+file` are both real |
189
+ | Summary line at the top of the post, not the bottom | it is the receipt for the whole turn and must not move as text streams in |
190
+ | Details as a second content executor | the streaming, splitting and rate behaviour already exist there; a new poster would reimplement them badly |
191
+ | `file` writes HTML, not markdown | the point is a browser; markdown in a browser is raw text |
192
+ | Keep an explicit `turn_end` op rather than finalising from the existing `StatusUpdateOp` (Codex suggested the latter) | `turn_end` is emitted *before* the final flush, so the final summary line lands in the same edit as the last text; the status op comes after the flush and would cost one more edit of the first post per turn |
193
+ | The header post is the post being written when the turn's first tool starts, not retroactively the turn's very first post (Codex code review asked for the latter) | if a long preamble was already split into two posts before the first tool, the receipt sits on the post where the work began and the details thread hangs there too; it never moves after adoption, which is what the reader needs. Chasing the earlier post would mean editing a post the executor has already left behind, for no gain |
194
+ | No HTTP server in the daemon | one more listener, auth, TLS; the operator already has a web server |
195
+ | Flat fields, not a nested `tools:` block (Gemini suggested nesting) | the sibling dials `sessionHeader` / `stickyMessage` / `lifecycle` are flat and `toolActivity` is the name agreed in #505; PR 1 adds two fields, the dir/url pair only comes with PR 2 |
196
+ | Resolved settings ride on the same per-platform record as the other dials (Gemini wanted a separate type) | one map, one wiring path, already threaded through `SessionManager`; a second type for two fields is plumbing for its own sake |
197
+
198
+ ## Open
199
+
200
+ (none)
201
+
202
+ ## Decided during review
203
+
204
+ - Q-001: does the summary line also name the last tool (`· Bash`)? **Yes.**
205
+ @thejdubb02 asked for it in #505 and the maintainer agreed in #534: once
206
+ the stream is hidden this line is the only liveness signal, so it has to
207
+ say what the bot is doing. MCP names are shortened to the tool part —
208
+ `mcp__playwright__browser_navigate` renders as `browser_navigate`.
209
+
210
+ ## Lessons learned
211
+
212
+ - **A reset nobody calls is not a reset.** `ToolActivityExecutor.reset()`
213
+ existed from the first commit and had a test, but its only caller,
214
+ `MessageManager.reset()`, has no production caller — the respawn paths use
215
+ `clearClaudeSessionState()`. Tested and wired are different properties.
216
+ - **A one-line header on a post that a split has already left behind needs
217
+ its own render pass.** Every write clears `headerDirty` only when it wrote
218
+ the header post; a flush that writes the continuation post leaves the
219
+ header running forever. Checking the flag after the flush covers every
220
+ path, which comparing post ids at each call site would not.
221
+ - **Half the turn state lived somewhere else.** Resetting the tool counter
222
+ left `turnOpen` / `headerPostId` set on the content executor, which clear
223
+ only on a `result` flush — and a respawn never produces one. The next
224
+ turn's summary then edited the abandoned reply and its details threaded
225
+ under it: the *same* bug, one layer down. `abandonHeaderTurn()` releases
226
+ the other half.
227
+ - **A retry needs to know what was attempted, not what was confirmed.**
228
+ `headerBody` advanced only on a successful update, so a lost response left
229
+ it stale — and the new header re-render then overwrote the post with the
230
+ older body, deleting text the platform had already accepted. Since an
231
+ update replaces the whole post, recording the attempted body is right
232
+ whether or not it arrived.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "1.38.1",
3
+ "version": "1.39.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",
@@ -78,7 +78,7 @@
78
78
  "cross-spawn": "^7.0.6",
79
79
  "diff": "^8.0.3",
80
80
  "express-rate-limit": "^8.3.0",
81
- "hono": "4.13.7",
81
+ "hono": "4.13.8",
82
82
  "ink": "^6.6.0",
83
83
  "ink-scroll-view": "^0.3.5",
84
84
  "js-yaml": "^4.3.1",
@@ -102,7 +102,7 @@
102
102
  "@types/ws": "^8.18.0",
103
103
  "eslint": "^10.7.0",
104
104
  "husky": "^9.1.7",
105
- "knip": "6.35.1",
105
+ "knip": "6.37.0",
106
106
  "lint-staged": "^17.0.4",
107
107
  "prettier": "^3.9.5",
108
108
  "react-devtools-core": "^8.0.0",