claude-threads 1.36.1 → 1.37.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 +13 -0
- package/bin/claude-threads-daemon +8 -0
- package/dist/index.js +302 -73
- package/dist/mcp/mcp-server.js +93 -86
- package/docs/CONFIGURATION.md +27 -0
- package/docs/systemd/claude-threads.service +3 -0
- package/docs/turn-marker-spec.md +149 -0
- package/package.json +1 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Turn marker: the daemon says when a turn is over
|
|
2
|
+
|
|
3
|
+
Upstream discussion: anneschuth/claude-threads#528 (the maintainer proposed
|
|
4
|
+
this shape on 2026-09-03).
|
|
5
|
+
|
|
6
|
+
## What it does
|
|
7
|
+
|
|
8
|
+
A per-platform setting:
|
|
9
|
+
|
|
10
|
+
```yaml
|
|
11
|
+
platforms:
|
|
12
|
+
- id: slack-main
|
|
13
|
+
type: slack
|
|
14
|
+
turnMarker: metadata # reaction | metadata | off (default)
|
|
15
|
+
turnMarkerEmoji: checkered_flag # reaction only; default checkered_flag
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
When Claude's turn ends (the CLI's `result` event) the daemon marks **the
|
|
19
|
+
turn's last reply post**:
|
|
20
|
+
|
|
21
|
+
| `turnMarker` | What happens | Who can see it |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `metadata` | the final edit of that post carries Slack message metadata: `event_type: claude_threads_turn_complete`, `event_payload: { v, session, turn, ok }` | integrations reading history with `include_all_metadata`; invisible in the UI. Slack only |
|
|
24
|
+
| `reaction` | the bot adds `turnMarkerEmoji` to that post | everyone, and any integration reading reactions. Slack and Mattermost |
|
|
25
|
+
| `off` | nothing; today's behaviour | |
|
|
26
|
+
|
|
27
|
+
`ok` is false when the result event reports an error. `turn` counts the
|
|
28
|
+
session's turns from 1. A turn that produced no reply post (only a task
|
|
29
|
+
list, a question, an approval) has nothing to mark and marks nothing.
|
|
30
|
+
|
|
31
|
+
Untouched: prompts, questions and approvals (a blocked turn has no `result`
|
|
32
|
+
and is not marked; a `turn_waiting` marker for those is a possible follow-up,
|
|
33
|
+
asked in #528), the streaming itself, every other post.
|
|
34
|
+
|
|
35
|
+
## Why
|
|
36
|
+
|
|
37
|
+
Any integration that reads the channel has to know when the answer is
|
|
38
|
+
complete. The daemon streams by editing one post, so "the text stopped
|
|
39
|
+
changing" is the only signal today, and every integration reinvents the same
|
|
40
|
+
guess (voice-desk: identical on three polls, ~8–12 s late, wrong when a tool
|
|
41
|
+
pauses the turn). The daemon knows the truth to the millisecond.
|
|
42
|
+
|
|
43
|
+
## How
|
|
44
|
+
|
|
45
|
+
- **Config** (`src/config/types.ts`): `turnMarker?: 'reaction' | 'metadata' | 'off'`,
|
|
46
|
+
`turnMarkerEmoji?: string` on `PlatformInstanceConfig`; resolved with the
|
|
47
|
+
other per-platform dials into `PlatformOverhead.turnMarker: { mode, emoji }`
|
|
48
|
+
(`resolveTurnMarker(mode, emoji, platformType, path)`; `metadata` on a
|
|
49
|
+
non-Slack platform is a startup config error with the field path, because
|
|
50
|
+
silently marking nothing would be worse; an emoji with another mode is
|
|
51
|
+
simply ignored — Gemini plan review: YAML anchors and commented-out modes
|
|
52
|
+
make that a common, harmless state). Wired like `sessionHeader` through
|
|
53
|
+
`index.ts` → `SessionManager` → `MessageManager` options.
|
|
54
|
+
- **The result event carries its outcome**: `transformResult` sets
|
|
55
|
+
`resultOk` on the `flush` op it emits (`FlushOp.resultOk?: boolean`, only
|
|
56
|
+
with `reason: 'result'`). No new op.
|
|
57
|
+
- **One marker write after the final flush** (`MessageManager`, in the
|
|
58
|
+
`result` flush branch, after `executeFlush`): the content executor's
|
|
59
|
+
`currentPostId` / `currentPostContent` name the turn's last reply post and
|
|
60
|
+
its exact text (both plan reviews: piggybacking on "the final write" is
|
|
61
|
+
fragile because splits, task-post reuse, empty flushes and failed writes
|
|
62
|
+
all move that write; a dedicated write after the flush is not).
|
|
63
|
+
- `metadata`: `platform.updatePost(postId, currentPostContent, { metadata })`
|
|
64
|
+
— Slack's `chat.update` needs text, so the text is re-sent unchanged.
|
|
65
|
+
The platform's `createPost` / `updatePost` gain an optional
|
|
66
|
+
`{ metadata }` (Mattermost accepts and drops it).
|
|
67
|
+
- `reaction`: `platform.addReaction(postId, emoji)`; Slack's
|
|
68
|
+
`already_reacted` is not an error.
|
|
69
|
+
- No post (the turn produced only a task list, a question, an approval):
|
|
70
|
+
nothing to mark. A marker failure is logged and never touches the reply.
|
|
71
|
+
- **Every flush is tracked, not just the timer's** (`runTrackedFlush`): each
|
|
72
|
+
one records itself in `flushInFlight` for its duration, and the next flush
|
|
73
|
+
waits for it. A `tool_complete` flush and the `result` flush that follows
|
|
74
|
+
are both `handleFlushOp` calls, and `SessionManager.handleEvent` does not
|
|
75
|
+
await its handling — so without this the second starts while the first is
|
|
76
|
+
still writing, two `updatePost` calls race on one post and complete out of
|
|
77
|
+
order, and the marker lands on a post a late write then supersedes.
|
|
78
|
+
- **The turn counter** lives in the `MessageManager` (`turn` increments on
|
|
79
|
+
each `result`), reset with the manager.
|
|
80
|
+
|
|
81
|
+
## Reading it (what voice-desk does, for the record)
|
|
82
|
+
|
|
83
|
+
`conversations.history` with `include_all_metadata=true`; a bot post whose
|
|
84
|
+
`metadata.event_type` is `claude_threads_turn_complete` is delivered at once.
|
|
85
|
+
In reaction mode: a bot post carrying the marker emoji from the bot user.
|
|
86
|
+
Without either, the old quiet rule. No configuration on the reader's side.
|
|
87
|
+
|
|
88
|
+
### `turn` is not durable — only `session` is
|
|
89
|
+
|
|
90
|
+
`turn` counts turns completed **by one MessageManager process**. It is not
|
|
91
|
+
persisted, so a resumed session starts counting from 1 again and will repeat
|
|
92
|
+
`{session, turn}` pairs it already emitted before the restart. It does *not*
|
|
93
|
+
restart on a Claude respawn (`!cd`, a worktree switch,
|
|
94
|
+
`!permissions interactive`): that is the same chat session, so the count
|
|
95
|
+
carries on.
|
|
96
|
+
|
|
97
|
+
The count is of turns that **happened**, not of turns that were successfully
|
|
98
|
+
marked: a turn with no reply post, or one whose flush failed, still advances
|
|
99
|
+
it and emits nothing. So a gap means a turn went missing — but only for a
|
|
100
|
+
reader that has watched an unbroken sequence in this process. A reader that
|
|
101
|
+
connects mid-session sees, say, `turn: 7` and cannot tell it from the seventh
|
|
102
|
+
turn of a second run after a restart; it has no baseline to call anything a
|
|
103
|
+
gap. Within one continuous observation window a gap is a real signal; across
|
|
104
|
+
a reconnect it is not.
|
|
105
|
+
|
|
106
|
+
Treat `turn` as ordering *within one run*, useful for spotting that gap, and
|
|
107
|
+
never as a unique key. `session` is the durable identity; readers that need
|
|
108
|
+
exactly-once should dedupe by post id, which is what voice-desk does. A
|
|
109
|
+
reader that wants durable turn numbers should count marked posts itself.
|
|
110
|
+
|
|
111
|
+
## Tests (first)
|
|
112
|
+
|
|
113
|
+
- config: defaults, `metadata` on Mattermost rejected, emoji with `off`
|
|
114
|
+
ignored, a malformed emoji rejected, custom emoji accepted.
|
|
115
|
+
- transformer: the result flush op carries `resultOk` true/false.
|
|
116
|
+
- message manager: `metadata` re-sends the last post's text with the
|
|
117
|
+
payload after the result flush, once, and only then; `reaction` adds the
|
|
118
|
+
emoji; `off` does nothing; a turn with no post marks nothing; a marker
|
|
119
|
+
failure leaves the reply alone; the turn counter increments; `ok` false
|
|
120
|
+
on an error result; a split turn marks the continuation (the current
|
|
121
|
+
post), not the first part; a soft flush still writing is awaited before
|
|
122
|
+
the result flush (Codex code review: otherwise the marker can land on a
|
|
123
|
+
post that a slower earlier write then supersedes); two event-driven
|
|
124
|
+
flushes (a `tool_complete` and the `result` that follows it) do not
|
|
125
|
+
overlap either — the timer flush was the only tracked writer, so the
|
|
126
|
+
waiter had nothing to wait on and two `updatePost` calls raced on one
|
|
127
|
+
post (maintainer review on #547); the payload carries `v` and exactly
|
|
128
|
+
the published field set.
|
|
129
|
+
- slack client: `chat.update` / `chat.postMessage` body carries `metadata`
|
|
130
|
+
when given, not otherwise.
|
|
131
|
+
|
|
132
|
+
## Decisions
|
|
133
|
+
|
|
134
|
+
| Decision | Why |
|
|
135
|
+
|---|---|
|
|
136
|
+
| One dedicated marker write after the final flush, not piggybacked on it (both plan reviews) | the "final write" moves with splits, task-post reuse, empty flushes and failed writes; one extra `chat.update` per turn is the price of never marking the wrong post |
|
|
137
|
+
| `(session, turn)` is not a cross-restart unique key | the counter lives in the manager and is not persisted; readers dedupe by post id, which is what voice-desk does. Spelled out under **`turn` is not durable** rather than left to be inferred (maintainer review on #547) |
|
|
138
|
+
| A Claude respawn does NOT restart the count | `!cd` and friends replace the CLI process, not the conversation; restarting would re-issue `{session, turn}` pairs the reader has already seen. The old `this.turn = 0` in `reset()` said the opposite and only ever ran from `dispose()`, where the manager is discarded anyway |
|
|
139
|
+
| `reaction` default emoji 🏁 `checkered_flag` | rare in real conversations, reads as "finished" without words |
|
|
140
|
+
| `metadata` refused on Mattermost at config time | rather than silently marking nothing |
|
|
141
|
+
| Payload is small and flat: v, session, turn, ok | Slack caps metadata size; readers need identity and outcome, not the answer |
|
|
142
|
+
| The payload carries a version `v`, and the format is a stated contract | the payload leaves the process and is parsed by code we do not control; once someone builds on it the shape is frozen. `v` is the only escape hatch if it ever has to break, and it costs one field now. The stability statement lives in `docs/CONFIGURATION.md` where integrators read it, not only here (maintainer review on #547) |
|
|
143
|
+
| Emoji names validated lowercase only (Gemini code review suggested allowing uppercase) | Slack and Mattermost create custom emoji names lowercase; an uppercase name in config can only be a typo |
|
|
144
|
+
| One extra `chat.update` per turn is accepted (Gemini code review called it redundant) | see the plan-review decision above: the alternative marks the wrong post under splits and reuse |
|
|
145
|
+
| `ok` from the result event, not from the text | the daemon has the fact; parsing "error" from the reply would be the guess this replaces |
|
|
146
|
+
|
|
147
|
+
## Lessons learned
|
|
148
|
+
|
|
149
|
+
(none yet)
|
package/package.json
CHANGED