claude-threads 1.36.1 → 1.37.1

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.
@@ -171,6 +171,7 @@ What happens: every `audio/*` attachment (or a file with an audio extension when
171
171
  | `outboundFiles` | No | `send_file` settings: `{ enabled, maxBytes }` (defaults: enabled `true`, `maxBytes` 100 MB) |
172
172
  | `sessionHeader` | No | Per-thread header visibility: `full` (default) / `minimal` (status bar only) / `hidden` (no header post) |
173
173
  | `stickyMessage` | No | Channel sticky visibility: `full` (default) / `minimal` (status bar only) / `hidden` (no sticky, no bumping) |
174
+ | `lifecycle` | No | Session status posts — idle warning, timeout, pause, resume, and the shutdown notice a deploy leaves in each open thread: `full` (default, all of them) / `minimal` (drops the idle warning, which predicts a timeout the next message would undo anyway) / `hidden` (none). Editing a status post the thread already has is not suppressed at any level: it adds no post and no notification, and leaving a stale "session idle" up across a restart would be worse. ⚠️ An **abnormal exit** (`[Exited: <code>]`, non-zero only) posts at every level including `hidden`: it is a failure report, and silencing it would make a session that died look exactly like one that finished. Note that `hidden` also removes the post a 🔄 reaction resumes from — sending a message in the thread still resumes it, and the channel sticky says so. |
174
175
  | `directChannelMode` | No | Direct channel mode: the whole channel is one session, and the bot replies with top-level channel posts instead of thread replies. `true` for defaults, or an options object (`respondTo`). See [Direct Channel Mode](#direct-channel-mode). |
175
176
  | `approvals` | No | Who may answer tool-permission prompts and other reaction gates: `owner` (session participants) or `all_users` (everyone on `allowedUsers`). Unset keeps the historical default per mode — `all_users` for thread sessions, `owner` for direct channel mode. See [Approvals](#approvals). |
176
177
  | `ackReaction` | No | Read receipt: react to every accepted message (session start, follow-up, resume) the instant it is accepted, before Claude produces output. `true` uses 👀 (`eyes`), a string names a custom emoji. Persistent, unlike the typing indicator — useful in busy channels and for messages queued behind an in-flight session start. The receipt means *accepted*, not *delivered*: a later failure (capacity limit, Claude not coming up) is still reported by its own post. `!commands` are not acked — they have their own immediate feedback, and neither are messages accepted through the message-approval flow (an authorized user approving a non-participant's message) — there the approval reaction is already the visible signal. Note: in direct channel mode this is one reaction API call per accepted message. Default off. |
@@ -195,6 +196,7 @@ What happens: every `audio/*` attachment (or a file with an audio extension when
195
196
  | `outboundFiles` | No | `send_file` settings: `{ enabled, maxBytes }` (defaults: enabled `true`, `maxBytes` 100 MB) |
196
197
  | `sessionHeader` | No | Per-thread header visibility: `full` (default) / `minimal` (status bar only) / `hidden` (no header post) |
197
198
  | `stickyMessage` | No | Channel sticky visibility: `full` (default) / `minimal` (status bar only) / `hidden` (no sticky, no bumping) |
199
+ | `lifecycle` | No | Session status posts — idle warning, timeout, pause, resume, and the shutdown notice a deploy leaves in each open thread: `full` (default, all of them) / `minimal` (drops the idle warning, which predicts a timeout the next message would undo anyway) / `hidden` (none). Editing a status post the thread already has is not suppressed at any level: it adds no post and no notification, and leaving a stale "session idle" up across a restart would be worse. ⚠️ An **abnormal exit** (`[Exited: <code>]`, non-zero only) posts at every level including `hidden`: it is a failure report, and silencing it would make a session that died look exactly like one that finished. Note that `hidden` also removes the post a 🔄 reaction resumes from — sending a message in the thread still resumes it, and the channel sticky says so. |
198
200
  | `directChannelMode` | No | Direct channel mode: the whole channel is one session, and the bot replies with top-level channel posts instead of thread replies. `true` for defaults, or an options object (`respondTo`). See [Direct Channel Mode](#direct-channel-mode). |
199
201
  | `approvals` | No | Who may answer tool-permission prompts and other reaction gates: `owner` (session participants) or `all_users` (everyone on `allowedUsers`). Unset keeps the historical default per mode — `all_users` for thread sessions, `owner` for direct channel mode. See [Approvals](#approvals). |
200
202
  | `ackReaction` | No | Read receipt: react to every accepted message (session start, follow-up, resume) the instant it is accepted, before Claude produces output. `true` uses 👀 (`eyes`), a string names a custom emoji. Persistent, unlike the typing indicator — useful in busy channels and for messages queued behind an in-flight session start. The receipt means *accepted*, not *delivered*: a later failure (capacity limit, Claude not coming up) is still reported by its own post. `!commands` are not acked — they have their own immediate feedback, and neither are messages accepted through the message-approval flow (an authorized user approving a non-participant's message) — there the approval reaction is already the visible signal. Note: in direct channel mode this is one reaction API call per accepted message. Default off. |
@@ -369,6 +371,30 @@ platforms:
369
371
 
370
372
  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.
371
373
 
374
+ #### End-of-turn marker
375
+
376
+ 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:
377
+
378
+ ```yaml
379
+ platforms:
380
+ - id: slack-main
381
+ type: slack
382
+ turnMarker: metadata # reaction | metadata | off (default)
383
+ # turnMarkerEmoji: checkered_flag # reaction only; this is the default
384
+ ```
385
+
386
+ | `turnMarker` | What happens | Who sees it |
387
+ |---|---|---|
388
+ | `metadata` | the last reply post gets Slack message metadata `event_type: claude_threads_turn_complete`, `event_payload: { v, session, turn, ok }` | integrations reading `conversations.history` with `include_all_metadata=true`; invisible in the UI. Slack only |
389
+ | `reaction` | the bot reacts on the last reply post with `turnMarkerEmoji` | everyone. Slack and Mattermost |
390
+ | `off` | nothing | |
391
+
392
+ **Format stability.** The marker is a published integration contract, not an internal detail. Within a 1.x release line, `event_type` will not change, and `event_payload` will only gain fields — never lose one or change the meaning of one. `v` is the payload version; a reader should ignore fields it does not recognize and treat an unknown `v` as "marker present, payload unread". `session` is `platformId:threadId` and is stable across bot restarts and Claude respawns. `turn` counts turns within one bot process: it restarts at 1 after a bot restart and is an ordering hint within a single run, never a unique key. Readers that need exactly-once should dedupe by post id.
393
+
394
+ `ok` is false when the turn ended with an error. A turn with no reply post marks nothing. A marker failure never touches the reply.
395
+
396
+ Each marked turn costs one extra API call (a `chat.update` or `reactions.add`) beyond the streaming writes.
397
+
372
398
  ### Memory (`memory`, default: fully enabled)
373
399
 
374
400
  Each platform instance (≈ one channel) can carry persistent memory, modeled on
@@ -663,6 +689,7 @@ Exactly one of `home` or `apiKey` should be set per account. Persisted sessions
663
689
  | `CLAUDE_PATH` | Path to the `claude` binary. Overrides the PATH lookup and the common install locations. | `claude` (from PATH) |
664
690
  | `DECISION_BRIDGE_TIMEOUT_MS` | How long the MCP permission server waits for a plan approval or question answer routed through the decision bridge (the bot's reaction UI) before falling back to the legacy behavior (generic prompt for plans, auto-allow for questions). | `3600000` (1 h) |
665
691
  | `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` | Strip `ANTHROPIC_*`, `AWS_*_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`, `GOOGLE_APPLICATION_CREDENTIALS`, and similar from Bash, hook, and stdio-MCP subprocesses Claude spawns. Bot-specific vars like `PLATFORM_TOKEN` pass through. **Also forces permission mode to `default`**; `--dangerously-skip-permissions` will be rejected. Requires Claude CLI 2.1.83+. | - |
692
+ | `CLAUDE_THREADS_HOME` | Root under which the bot keeps its state (`<root>/.config/claude-threads`, `<root>/.claude-threads`): config, sessions, logs, audit, memory, worktrees, update state. Lets two bots run under the same user without sharing any of it. One process per root; a second start against the same root is refused. | `$HOME` |
666
693
  | `CLAUDE_THREADS_SESSIONS_PATH` | Override the path to the persisted sessions file (default `~/.config/claude-threads/sessions.json`). | - |
667
694
  | `CLAUDE_THREADS_GITHUB_EMAILS_PATH` | Override the path to the GitHub-emails store used for commit attribution. | - |
668
695
  | `CLAUDE_THREADS_MEMORY_DIR` | Override the root of the persistent memory storage (default `~/.config/claude-threads/memory/`). | - |
@@ -37,6 +37,9 @@ ExecStart=/usr/bin/env claude-threads-daemon
37
37
  # This is a fallback for unexpected crashes
38
38
  Restart=on-failure
39
39
  RestartSec=10
40
+ # Exit 3 = another instance holds the state directory (see CLAUDE_THREADS_HOME);
41
+ # restarting would only collide again.
42
+ RestartPreventExitStatus=3
40
43
 
41
44
  # Resource limits (adjust as needed)
42
45
  # LimitNOFILE=65536
@@ -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.1",
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",
@@ -77,7 +77,7 @@
77
77
  "commander": "^14.0.2",
78
78
  "diff": "^8.0.3",
79
79
  "express-rate-limit": "^8.3.0",
80
- "hono": "4.13.5",
80
+ "hono": "4.13.7",
81
81
  "ink": "^6.6.0",
82
82
  "ink-scroll-view": "^0.3.5",
83
83
  "js-yaml": "^4.3.1",
@@ -100,10 +100,10 @@
100
100
  "@types/ws": "^8.18.0",
101
101
  "eslint": "^10.7.0",
102
102
  "husky": "^9.1.7",
103
- "knip": "6.34.0",
103
+ "knip": "6.35.1",
104
104
  "lint-staged": "^17.0.4",
105
105
  "prettier": "^3.9.5",
106
- "react-devtools-core": "^7.0.1",
106
+ "react-devtools-core": "^8.0.0",
107
107
  "typescript": "^6.0.2",
108
108
  "typescript-eslint": "^8.64.0"
109
109
  },