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.
- package/CHANGELOG.md +18 -0
- package/README.md +1 -1
- package/bin/claude-threads-daemon +7 -6
- package/dist/index.js +1014 -74
- package/dist/mcp/mcp-server.js +473 -23
- package/docs/CONFIGURATION.md +26 -0
- package/docs/quiet-tools-spec.md +232 -0
- package/package.json +5 -3
|
@@ -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.
|
|
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.
|
|
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.
|
|
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",
|