claude-threads 1.38.0 → 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.
@@ -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.0",
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",
@@ -75,9 +75,10 @@
75
75
  "@redactpii/node": "^1.0.16",
76
76
  "cli-spinners": "^3.3.0",
77
77
  "commander": "^14.0.2",
78
+ "cross-spawn": "^7.0.6",
78
79
  "diff": "^8.0.3",
79
80
  "express-rate-limit": "^8.3.0",
80
- "hono": "4.13.7",
81
+ "hono": "4.13.8",
81
82
  "ink": "^6.6.0",
82
83
  "ink-scroll-view": "^0.3.5",
83
84
  "js-yaml": "^4.3.1",
@@ -91,6 +92,7 @@
91
92
  "devDependencies": {
92
93
  "@eslint/js": "^10.0.1",
93
94
  "@types/bun": "latest",
95
+ "@types/cross-spawn": "^6.0.6",
94
96
  "@types/js-yaml": "^4.0.9",
95
97
  "@types/node": "^26.1.1",
96
98
  "@types/prompts": "^2.4.9",
@@ -100,7 +102,7 @@
100
102
  "@types/ws": "^8.18.0",
101
103
  "eslint": "^10.7.0",
102
104
  "husky": "^9.1.7",
103
- "knip": "6.35.1",
105
+ "knip": "6.37.0",
104
106
  "lint-staged": "^17.0.4",
105
107
  "prettier": "^3.9.5",
106
108
  "react-devtools-core": "^8.0.0",