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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "1.36.1",
3
+ "version": "1.37.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",