claude-slack-channel-bots 0.18.3 → 0.18.4

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/docs/bots.md CHANGED
@@ -141,6 +141,8 @@ A message from Slack carries these tag attributes:
141
141
  | `thread_ts` | The thread's timestamp, for a thread reply. |
142
142
  | `attachment_count`, `attachments` | The number of attached files and each file's name, type and size, when files are attached. |
143
143
 
144
+ A long `attachments` or `user` value can be cut short, ending in `…`; on a macOS host this happens sooner, and a long `channel_name` can be cut too. `chat_id` and `attachment_count` are always whole.
145
+
144
146
  Each way a message reaches a persona:
145
147
 
146
148
  | Kind | How it reaches the persona | Tag attributes | `via` | Reminder |
@@ -74,6 +74,9 @@ Each start logs one `[slack] startupSessionManager: complete — …` line count
74
74
  **Delivery notices or delivery diagnostic lines**
75
75
  For *Message not saved*, check the server disk; for *Delivery stuck* or *Message not taken in*, follow the notice's remedy; for *Messages waiting*, resolve the persona's state; for *Queue record unreadable*, preserve its file and ask the senders to resend. Accepted messages stay queued in order, including when [`message-partial-receipt`](../skills/debug-slack-channel-bots/SKILL.md#message-partial-receipt) reports cut-off text. [Full entry](../skills/debug-slack-channel-bots/SKILL.md#symptom-delivery-notices-or-delivery-diagnostic-lines).
76
76
 
77
+ **A persona on a Mac keeps retyping a long message**
78
+ On a macOS host CSCB types a long message in parts of at most about 880 bytes each. A message queued before an upgrade can keep larger parts, and a restart does not split it again: CSCB keeps offering it until the bot takes it in, while later messages wait. [Full entry](../skills/debug-slack-channel-bots/SKILL.md#symptom-a-persona-on-a-mac-keeps-retyping-a-long-message).
79
+
77
80
  **A persona posts a *Waiting on a prompt* or *Not connected* notice**
78
81
  The session runs but is disconnected; accepted messages are kept until it reconnects. A human attaches with `tmux attach -t =slack_bot_<key>`, deals with anything on screen and, if it stays disconnected, types `/mcp reconnect slack-channel-router`, or restarts the server. [Full entry](../skills/debug-slack-channel-bots/SKILL.md#symptom-a-persona-posts-a-waiting-on-a-prompt-or-not-connected-notice).
79
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slack-channel-bots",
3
- "version": "0.18.3",
3
+ "version": "0.18.4",
4
4
  "description": "Run several Claude Code personas, each its own Slack app, from one server via Socket Mode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -498,7 +498,7 @@ no recovery outcome. See [Message saved after retry](#message-saved-after-retry)
498
498
  - **Line:** `[slack] message-retyped: persona="<name>" (key=<key>) source=<source> chat_id="<chat>" ts="<ts>" sender_id="<sender>" retype_count=<n>`
499
499
  - **Meaning:** CSCB offered the queued message again; it may reach the bot more than once.
500
500
  - **Cause:** The transcript has not confirmed the earlier copy.
501
- - **Fix:** Check **Message not taken in** and **Delivery stuck** below if it persists. Retries continue indefinitely; later messages wait behind it.
501
+ - **Fix:** Check **Message not taken in** and **Delivery stuck** below if it persists. Retries continue indefinitely; later messages wait behind it. For a long message on a Mac host, see [A persona on a Mac keeps retyping a long message](#symptom-a-persona-on-a-mac-keeps-retyping-a-long-message).
502
502
 
503
503
  ### `message-not-taken-in`
504
504
 
@@ -7250,6 +7250,12 @@ and [Persona diagnostic classes](#persona-diagnostic-classes). Storage failures
7250
7250
  a disk check; stuck delivery needs the notice's remedy; waiting messages need the
7251
7251
  persona's hold or down state resolved. Accepted items remain queued in order.
7252
7252
 
7253
+ ### Symptom: A persona on a Mac keeps retyping a long message
7254
+
7255
+ Claude Code on macOS loses typed input sent as one large burst, so on a macOS host CSCB types a long message as several parts of at most about 880 bytes each, tag included; on other hosts a part can reach about 1,900 bytes. On a Mac, long attachment lists, sender names and channel names in the tag are shortened sooner, each ending in `…`; the channel ID and the attachment count are kept whole.
7256
+
7257
+ CSCB sets a message's parts once, shortly after it is queued, and never changes them; restarting the bot or the server does not split it again. A message queued before an upgrade can therefore keep its larger parts, go on logging `message-retyped` and raise a *Message not taken in* or *Delivery stuck* notice while later messages wait behind it. CSCB keeps offering it until the bot takes it in. [Delivery notices](#delivery-notices) explains each notice and how to attach to the bot's terminal to see what it received.
7258
+
7253
7259
  ### Symptom: A persona posts a *Waiting on a prompt* or *Not connected* notice
7254
7260
 
7255
7261
  The persona's session runs but is disconnected. Messages sent to it are kept and delivered once it reconnects. What each notice means and what to do: the notice table under [A persona's instance runs but isn't connected](#a-personas-instance-runs-but-isnt-connected).
@@ -1,7 +1,7 @@
1
1
  /** Post-accept immutable fixation shared by fresh and recovered work. */
2
2
  import type { WebClient } from '@slack/web-api'
3
3
  import type { DeliveryQueue, QueueAccept, QueueItem, QueueEvent } from './delivery-queue.ts'
4
- import { buildTagParts, NAME_LOOKUP_TIMEOUT_MS, type TagMessage } from './tag-wrapper.ts'
4
+ import { buildTagParts, NAME_LOOKUP_TIMEOUT_MS, TAG_MAX_BYTES, type TagMessage } from './tag-wrapper.ts'
5
5
  import { sanitizeFilename } from './lib.ts'
6
6
  import { RETYPE_BACKOFF_MS } from './delivery-constants.ts'
7
7
  export interface DeliveryPreparationClock {
@@ -20,6 +20,8 @@ export interface DeliveryPreparationDeps {
20
20
  /** Called only from post-ack/answer preparation or startup recovery. */
21
21
  onReceived?(key: string, item: QueueItem): void
22
22
  lookupTimeoutMs?: number
23
+ /** Byte limit for each fixed part; production binds the host's `tagMaxBytesFor`. */
24
+ tagMaxBytes?: number
23
25
  clock?: DeliveryPreparationClock
24
26
  retryBackoffMs?: readonly number[]
25
27
  }
@@ -40,6 +42,7 @@ export function createDeliveryPreparation(deps: DeliveryPreparationDeps): Delive
40
42
  const fixedParts = new Map<string, string[]>()
41
43
  let stopped = false
42
44
  const timeout = deps.lookupTimeoutMs ?? NAME_LOOKUP_TIMEOUT_MS
45
+ const tagMaxBytes = deps.tagMaxBytes ?? TAG_MAX_BYTES
43
46
  const clock: DeliveryPreparationClock = deps.clock ?? {
44
47
  setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
45
48
  clearTimeout: handle => globalThis.clearTimeout(handle as ReturnType<typeof globalThis.setTimeout>),
@@ -91,7 +94,7 @@ export function createDeliveryPreparation(deps: DeliveryPreparationDeps): Delive
91
94
  message.attachments = a.files.map(f => `${sanitizeFilename(f.name || 'unnamed')} (${f.mimetype || 'unknown'}, ${f.size || '?'} bytes)`).join('; ')
92
95
  }
93
96
  }
94
- parts = buildTagParts(message)
97
+ parts = buildTagParts(message, tagMaxBytes)
95
98
  fixedParts.set(id, parts)
96
99
  }
97
100
  await deps.queue.append(key, { v: 1, kind: 'fixed', seq: a.seq, parts })
package/src/server.ts CHANGED
@@ -208,6 +208,7 @@ import { createPersonaRouting } from './persona-routing.ts'
208
208
  import { createDeliveryQueue, type ReplyReminderQueue, type QueueEvent } from './delivery-queue.ts'
209
209
  import { createReplyReminders, type ReplyReminders } from './reply-reminder.ts'
210
210
  import { createDeliveryPreparation, type DeliveryPreparation } from './delivery-intake.ts'
211
+ import { tagMaxBytesFor } from './tag-wrapper.ts'
211
212
  import {
212
213
  createPersonaConnectionManager,
213
214
  SYSTEM_PERSONA_CONNECTION_CLOCK,
@@ -2717,6 +2718,7 @@ export async function main(): Promise<void> {
2717
2718
  clientFor,
2718
2719
  wake: key => deliveryQueueWake.wake(key),
2719
2720
  onPending: key => deliveryEngines?.wake(key),
2721
+ tagMaxBytes: tagMaxBytesFor(process.platform),
2720
2722
  })
2721
2723
 
2722
2724
  // the retired-key record (retired-keys.json in the
@@ -1,5 +1,7 @@
1
1
  /** Deterministic typed tags, with no I/O or module state. */
2
2
  export const TAG_MAX_BYTES = 1900
3
+ /** Claude Code on macOS loses a typed burst over about 900 bytes, wrapper included. */
4
+ export const TAG_MAX_BYTES_MACOS = 880
3
5
  export const TAG_MIN_BODY_BYTES = 400
4
6
  export const NAME_LOOKUP_TIMEOUT_MS = 2000
5
7
  export const TAG_SOURCE = 'slack-channel-router'
@@ -44,6 +46,8 @@ export function escapeBody(value: string): string {
44
46
  return out.replace(/[\u0301\u0308\u2028]/g, c => `&#${c.codePointAt(0)};`)
45
47
  }
46
48
  export function tagByteLength(value: string): number { return new TextEncoder().encode(value).byteLength }
49
+ /** The typed part limit for the host the agent runs on, which is the server's own host. */
50
+ export function tagMaxBytesFor(platform: string): number { return platform === 'darwin' ? TAG_MAX_BYTES_MACOS : TAG_MAX_BYTES }
47
51
  function render(attrs: TagAttributes, body: string, part?: string): string {
48
52
  const values = { ...attrs, part }
49
53
  const attributes = TAG_ATTRIBUTE_ORDER.flatMap(key => values[key] === undefined || values[key] === '' ? [] : [`${key}="${escapeAttr(values[key])}"`])
@@ -51,10 +55,11 @@ function render(attrs: TagAttributes, body: string, part?: string): string {
51
55
  }
52
56
  /** Unsized reference formatter, useful for wrapper.py parity. */
53
57
  export function buildWrapper(message: TagMessage): string { return render(message, escapeBody(message.text ?? ''), message.part) }
54
- function boundedAttrs(input: TagAttributes, part?: string): TagAttributes {
58
+ function boundedAttrs(input: TagAttributes, maxBytes: number, part?: string): TagAttributes {
55
59
  const attrs = { ...input }
56
- for (const key of ['attachments', 'user'] as const) {
57
- if (tagByteLength(render(attrs, '', part)) <= TAG_MAX_BYTES - TAG_MIN_BODY_BYTES) break
60
+ const allowance = maxBytes - TAG_MIN_BODY_BYTES
61
+ for (const key of ['attachments', 'user', 'channel_name'] as const) {
62
+ if (tagByteLength(render(attrs, '', part)) <= allowance) break
58
63
  const original = attrs[key]
59
64
  if (original === undefined || original === '') continue
60
65
  const chars = Array.from(replaceLoneSurrogates(original))
@@ -62,12 +67,12 @@ function boundedAttrs(input: TagAttributes, part?: string): TagAttributes {
62
67
  while (low < high) {
63
68
  const mid = Math.ceil((low + high) / 2)
64
69
  attrs[key] = chars.slice(0, mid).join('') + '…'
65
- if (tagByteLength(render(attrs, '', part)) <= TAG_MAX_BYTES - TAG_MIN_BODY_BYTES) low = mid
70
+ if (tagByteLength(render(attrs, '', part)) <= allowance) low = mid
66
71
  else high = mid - 1
67
72
  }
68
73
  attrs[key] = chars.slice(0, low).join('') + '…'
69
74
  }
70
- if (tagByteLength(render(attrs, '', part)) > TAG_MAX_BYTES - TAG_MIN_BODY_BYTES) throw new Error('Tag attributes exceed the minimum body allowance')
75
+ if (tagByteLength(render(attrs, '', part)) > allowance) throw new Error('Tag attributes exceed the minimum body allowance')
71
76
  return attrs
72
77
  }
73
78
  function cutBody(body: string, budget: number): [string, string] {
@@ -82,15 +87,15 @@ function cutBody(body: string, budget: number): [string, string] {
82
87
  return [body.slice(0, cut), body.slice(cut)]
83
88
  }
84
89
  /** Escape once, then split losslessly, accounting for the widest part index. */
85
- export function buildTagParts(message: TagMessage): string[] {
90
+ export function buildTagParts(message: TagMessage, maxBytes: number = TAG_MAX_BYTES): string[] {
86
91
  const body = escapeBody(message.text ?? '')
87
- const singleAttrs = boundedAttrs(message)
88
- if (tagByteLength(render(singleAttrs, body)) <= TAG_MAX_BYTES) return [render(singleAttrs, body)]
92
+ const singleAttrs = boundedAttrs(message, maxBytes)
93
+ if (tagByteLength(render(singleAttrs, body)) <= maxBytes) return [render(singleAttrs, body)]
89
94
  let count = 2
90
95
  for (;;) {
91
96
  const widest = `${count}/${count}`
92
- const attrs = boundedAttrs(message, widest)
93
- const budget = TAG_MAX_BYTES - tagByteLength(render(attrs, '', widest))
97
+ const attrs = boundedAttrs(message, maxBytes, widest)
98
+ const budget = maxBytes - tagByteLength(render(attrs, '', widest))
94
99
  const bodies: string[] = []
95
100
  let remaining = body
96
101
  do { const [part, rest] = cutBody(remaining, budget); bodies.push(part); remaining = rest } while (remaining !== '')