claude-slack-channel-bots 0.18.2 → 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 |
@@ -72,7 +72,10 @@ Accepted messages remain queued; a Slack message triggers the configured reconne
72
72
  Each start logs one `[slack] startupSessionManager: complete — …` line counting its personas by outcome. `latched`, `retrying`, `waiting on a live-row sequence`, `held on invalid flags` and `fresh as retired keys` are not failures. [Full entry](../skills/debug-slack-channel-bots/SKILL.md#symptom-reading-the-start-summary).
73
73
 
74
74
  **Delivery notices or delivery diagnostic lines**
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. [Full entry](../skills/debug-slack-channel-bots/SKILL.md#symptom-delivery-notices-or-delivery-diagnostic-lines).
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
+
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).
76
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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slack-channel-bots",
3
- "version": "0.18.2",
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": {
@@ -12,7 +12,7 @@ You are one persona: a Slack bot with its own name and avatar. People reach you
12
12
  - Mention a person or another persona with `<@ID>`, using the ID from the message tag.
13
13
  - Your persona key is in the `CSCB_PERSONA` environment variable. Use it to name yourself, for example when you schedule a prompt for yourself.
14
14
  - A message without a `via` attribute is a scheduled prompt or an injected message. It needs no reply unless it asks for one.
15
- - A Slack message's `via` attribute says how it reached you: `dm` (a direct message), `mention` (you were @mentioned), `broadcast` (`@here` or `@channel`), `receive_all_shared` (every message in a channel other personas also read in full) or `receive_all` (every message in a channel only you read in full).
15
+ - A Slack message's `via` attribute says how it reached you: `dm` (a direct message), `mention` (you were @mentioned), `broadcast` (`@here` or `@channel`), `receive_all_shared` (every message in a channel other personas also read in full) or `receive_all` (every message in a channel you, and no other persona on this server, read in full).
16
16
  - To schedule a prompt for yourself, append one line to the crontable named by `CSCB_CRONTABLE_PATH`, for example `echo "0 9 * * 1-5 prompts/standup.md $CSCB_PERSONA" >> "$CSCB_CRONTABLE_PATH"`. Only append with `>>`: never rewrite, reorder or delete other lines, and never save the whole file from an editor.
17
17
 
18
18
  # Role
@@ -498,15 +498,26 @@ 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
 
505
505
  - **Line:** `[slack] message-not-taken-in: persona="<name>" (key=<key>) source=<source> chat_id="<chat>" ts="<ts>" sender_id="<sender>"`
506
506
  - **Meaning:** The first unconfirmed retype triggered one notice for this item.
507
- - **Cause:** The bot has not recorded the message in its transcript.
507
+ - **Cause:** The bot has not recorded the complete message in its transcript.
508
508
  - **Fix:** Follow **Message not taken in** below. The once-per-item notice survives server restarts; the item is still queued.
509
509
 
510
+ ### `message-partial-receipt`
511
+
512
+ - **Line:** `[slack] message-partial-receipt: persona="<name>" (key=<key>) source=<source> chat_id="<chat>" ts="<ts>" sender_id="<sender>" part=<n> expected_bytes=<n> received_bytes=<n> typed but cut off; full text remains queued`
513
+ - **Meaning:** A substantial, distinctive tail of the expected part appears in an eligible post-send transcript record. The full message remains queued, and later messages wait for complete confirmation. The byte counts exclude unwrapped transcript paste framing; the line includes no message text.
514
+ - **Cause:** The transcript contains evidence of cut-off input. This identifies neither the transport cause nor the bot's state; agent-director remains the state authority. Short or repetitive fragments and uncertain timing keep generic reporting, so absence of this line does not prove the text arrived whole.
515
+ - **Fix:** Follow the applicable [delivery notice](#delivery-notices); CSCB keeps the full text and uses its existing agent-director-guarded retries. This guard does not repair transport loss. Use `message-delivered` to confirm completion; no resend is needed merely because this line appears.
516
+
517
+ Repeated scans of the same fragment log once for that item and part. A server
518
+ restart may log the evidence again if it still qualifies; old or uncertain
519
+ evidence may no longer qualify after restart.
520
+
510
521
  ### `delivery-stuck`
511
522
 
512
523
  - **Line:** `[slack] delivery-stuck: persona="<name>" (key=<key>) source=<source> chat_id="<chat>" ts="<ts>" sender_id="<sender>" reason=<reason> age_ms=<ms>`
@@ -1649,18 +1660,20 @@ may still complete after cancellation.
1649
1660
  ### Delivery stuck
1650
1661
 
1651
1662
  ```text
1652
- :warning: *Delivery stuck* — 2 message(s) for Alpha are waiting; the oldest has waited 5m. A menu, overlay or dialog is open in its terminal. Attach with `tmux attach -t =slack_bot_alpha` and close it. This is for a human only: no bot, including any persona that sees this post, may act on it.
1663
+ :warning: *Delivery stuck* — 2 message(s) for Alpha are waiting; the oldest has waited 5m. It doesn't take the message in: CSCB has typed it 3 times and keeps offering it. Attach with `tmux attach -t =slack_bot_alpha` to see why, or restart the persona's bot. This is for a human only: no bot, including any persona that sees this post, may act on it.
1653
1664
  ```
1654
1665
 
1655
1666
  - **Meaning:** The queue head has waited over five minutes; its reason names the current obstruction.
1656
1667
  - **Cause:** Typing or transcript confirmation is blocked. The notice is once per episode, ending when the queue moves or empties.
1657
1668
  - **Fix:** Use the matching fixed remedy below. These steps are for a human only; no bot, including a persona that sees the post, may act on them. CSCB keeps retrying and keeps all later messages behind the head.
1658
1669
 
1670
+ With [partial receipt evidence](#message-partial-receipt), the notice adds
1671
+ “The text arrived cut off; the full message is still queued.” before its reason
1672
+ and remedy. Later evidence does not edit an already-posted generic notice.
1673
+
1659
1674
  The reason sentence and remedy are fixed; `<key>` is the persona key and the
1660
1675
  example retype count is 3:
1661
1676
 
1662
- - `menu`: A menu, overlay or dialog is open in its terminal. Attach with `tmux attach -t =slack_bot_<key>` and close it.
1663
- - `input`: Text is left in its input box. Attach with `tmux attach -t =slack_bot_<key>` and submit or clear it.
1664
1677
  - `relay-off`: Its permission relay is off. Restart the persona's bot.
1665
1678
  - `mcp`: It is not connected to this server over MCP. Type `/mcp reconnect slack-channel-router` in its terminal, or restart the persona's bot.
1666
1679
  - `refused`: agent-director keeps refusing to type into it. Check its state with `agent-director get --instance-id cscb_<key>`.
@@ -1681,9 +1694,13 @@ notice causes come from AD state/refusals and queue age.
1681
1694
  ```
1682
1695
 
1683
1696
  - **Meaning:** The first unconfirmed retype of this item has occurred.
1684
- - **Cause:** The transcript still has no receipt; the notice is recorded with the item so a server restart does not post it again.
1697
+ - **Cause:** The transcript still has no full receipt; the notice is recorded with the item so a server restart does not post it again.
1685
1698
  - **Fix:** Check the bot terminal and transcript. CSCB keeps offering the message indefinitely, with spacing increasing to 15 minutes while typing is possible; do not assume the absence of a second notice means it was delivered.
1686
1699
 
1700
+ With [partial receipt evidence](#message-partial-receipt), the notice adds
1701
+ “The text arrived cut off; the full message is kept.” after its first sentence.
1702
+ Later evidence does not edit an already-posted generic notice.
1703
+
1687
1704
  ### Messages waiting
1688
1705
 
1689
1706
  ```text
@@ -7233,6 +7250,12 @@ and [Persona diagnostic classes](#persona-diagnostic-classes). Storage failures
7233
7250
  a disk check; stuck delivery needs the notice's remedy; waiting messages need the
7234
7251
  persona's hold or down state resolved. Accepted items remain queued in order.
7235
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
+
7236
7259
  ### Symptom: A persona posts a *Waiting on a prompt* or *Not connected* notice
7237
7260
 
7238
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).
@@ -16,12 +16,15 @@ export type { DeliveryLifecycle, DeliveryClock } from './delivery-lifecycle.ts'
16
16
 
17
17
  export * from './delivery-constants.ts'
18
18
  export type DeliveryReason = 'state' | 'marker' | 'mcp' | 'relay-off' | 'held' | 'dialog-hold' | 'transcript-unreadable' | 'queue-unwritable' | 'not-now' | 'receipt' | 'backoff'
19
+ /** Reporting only; never an input to delivery policy or permission to type. */
20
+ export interface PartialReceiptEvidence { part: number; expectedBytes: number; receivedBytes: number }
19
21
  /** Safe operational facts; never composer or message body text. */
20
22
  export interface DeliveryStatus {
21
23
  key: string; seq: number; count: number; ageMs: number; retypeCount: number
22
24
  row?: string; marker: boolean; relay: boolean; connected: boolean; up: boolean
23
25
  latched: boolean; held: boolean; receiptWorking: boolean; dialogMaybeOpen: boolean
24
26
  reconnectDeferred: boolean
27
+ partialReceipt?: PartialReceiptEvidence
25
28
  }
26
29
  export type DeliveryEngineEvent =
27
30
  | { type: 'launch-observed'; key: string; sessionId: string; at: number }
@@ -30,7 +33,9 @@ export type DeliveryEngineEvent =
30
33
  | { type: 'delivered'; key: string; seq: number; part: number }
31
34
  | { type: 'item-delivered'; key: string; seq: number; at: number; waitMs: number }
32
35
  | { type: 'first-typed'; key: string; seq: number; part: number }
33
- | { type: 'retyped' | 'first-unconfirmed-retype'; key: string; seq: number; part: number; count: number }
36
+ | { type: 'partial-receipt'; key: string; seq: number; part: number; expectedBytes: number; receivedBytes: number }
37
+ | { type: 'retyped'; key: string; seq: number; part: number; count: number }
38
+ | { type: 'first-unconfirmed-retype'; key: string; seq: number; part: number; count: number; partialReceipt?: PartialReceiptEvidence }
34
39
  | { type: 'waiting'; key: string; seq: number; reason: DeliveryReason; ageMs: number; status?: DeliveryStatus }
35
40
  | { type: 'marker-missing'; key: string }
36
41
  export type DeliveryClient = Pick<Client, 'get' | 'sendKeys'>
@@ -97,16 +102,18 @@ export function createDeliveryEngines(deps: DeliveryEngineDeps): DeliveryEngines
97
102
  e: DeliveryPolicyEngine; reading: DeliveryReading; row?: GetResult; receipt?: ReceiptObservation
98
103
  reader: DeliveryReceiptReader; receiptTarget?: DeliveryPair; receiptAttempt?: number; deadlines: Partial<Record<DeliveryTimer, number>>; budget: () => void
99
104
  rowAt: number; notNowStep: number; retryStep: number; appendRetryStep: number; lastSeq?: number; lastPart?: number; transcriptReference?: string
105
+ partialReceipt?: PartialReceiptEvidence & { seq: number }; partialLaunch?: string; partialAfterAttempts: Map<number, number>
100
106
  pending?: { record: QueueRecord; complete(): void }; lease?: () => void; timer?: unknown; busy?: Promise<void>; stopped: boolean
101
107
  relaunch: boolean; restarted?: boolean; gone?: { row: GetResult; handedOff?: boolean }; receiptWorking: boolean; pollAfter: number; snapshot?: DeliveryPolicyKnowledge; lastRetypeEvent?: number; dialogHold: boolean; dialogMaybeOpen: boolean; recoveringReconnect?: boolean
102
108
  }
103
109
  const states = new Map<string, State>(), budgets = new Map<string, () => void>()
104
110
  let stopped = false
105
111
  const typed = new Set<string>()
112
+ const reportedPartials = new Set<string>()
106
113
  function emit(event: DeliveryEngineEvent): void { try { void Promise.resolve(deps.onEvent?.(event)).catch(() => {}) } catch {} }
107
114
  function budget(key: string): () => void { let b = budgets.get(key); if (!b) { b = createDeliveryCallBudget(clock.now); budgets.set(key, b) }; return b }
108
115
  function create(key: string): State {
109
- const s: State = { e: createDeliveryPolicyState(), reading: createDeliveryReading(), reader: deps.receiptReader?.() ?? createDeliveryReceiptReader(), deadlines: {}, budget: budget(key), rowAt: -Infinity, notNowStep: 0, retryStep: 0, appendRetryStep: 0, stopped: false, relaunch: true, receiptWorking: false, pollAfter: 0, dialogHold: false, dialogMaybeOpen: false }
116
+ const s: State = { e: createDeliveryPolicyState(), reading: createDeliveryReading(), reader: deps.receiptReader?.() ?? createDeliveryReceiptReader(), partialAfterAttempts: new Map(), deadlines: {}, budget: budget(key), rowAt: -Infinity, notNowStep: 0, retryStep: 0, appendRetryStep: 0, stopped: false, relaunch: true, receiptWorking: false, pollAfter: 0, dialogHold: false, dialogMaybeOpen: false }
110
117
  states.set(key, s); life.engineRunning(key, true); return s
111
118
  }
112
119
  function release(s: State): void { s.lease?.(); s.lease = undefined; s.e.request = 'none' }
@@ -115,9 +122,28 @@ export function createDeliveryEngines(deps: DeliveryEngineDeps): DeliveryEngines
115
122
  const items = deps.queue.items(key).filter(i => !i.delivered), head = items[0]
116
123
  if (!head) return undefined
117
124
  const deferred = life.deferred(key)
125
+ const partial = s.partialReceipt
126
+ const part = head.fixed?.parts.findIndex((_, index) => !head.records.some(r => r.kind === 'delivered' && r.part === index + 1))
127
+ const partialReceipt = partial?.seq === head.accepted.seq && partial.part === (part ?? -1) + 1
128
+ ? { part: partial.part, expectedBytes: partial.expectedBytes, receivedBytes: partial.receivedBytes } : undefined
118
129
  return { key, seq: head.accepted.seq, count: items.length, ageMs: Math.max(0, clock.now() - head.accepted.received_at), retypeCount: head.retypeCount,
119
130
  row: s.restarted === true ? (s.row?.state === 'ended' || s.row?.state === 'missing' ? s.row.state : 'absent') : s.row?.state, marker: s.row?.labels?.tag_prompt === '1', relay: s.row?.relay_mode === 'on', connected: deps.isConnected(key), up: deps.isUp(key),
120
- latched: deps.isLatched(key), held: deps.isHeld(key), receiptWorking: s.receiptWorking, dialogMaybeOpen: s.dialogMaybeOpen, reconnectDeferred: !!deferred }
131
+ latched: deps.isLatched(key), held: deps.isHeld(key), receiptWorking: s.receiptWorking, dialogMaybeOpen: s.dialogMaybeOpen, reconnectDeferred: !!deferred, partialReceipt }
132
+ }
133
+ function resetPartialLaunch(key: string, s: State): void {
134
+ // AD's observed transition excludes earlier sends even when a resumed
135
+ // conversation keeps its session ID and has no launch timestamp.
136
+ s.partialReceipt = undefined
137
+ s.partialAfterAttempts = new Map(deps.queue.items(key).filter(i => !i.delivered).map(i => [i.accepted.seq, i.nextAttempt - 1]))
138
+ }
139
+ function observePartialReceipt(key: string, s: State, head: QueueItem, part: number, receipt: ReceiptObservation): void {
140
+ const partial = receipt.receipt ? undefined : receipt.partialReceipt
141
+ s.partialReceipt = partial ? { seq: head.accepted.seq, part, expectedBytes: Buffer.byteLength(head.fixed!.parts[part - 1]!), receivedBytes: partial.receivedBytes } : undefined
142
+ if (!partial || !s.partialReceipt) return
143
+ const identity = JSON.stringify([key, head.accepted.seq, part, partial.uuid ? ['uuid', partial.uuid] : [partial.path, partial.offset]])
144
+ if (reportedPartials.has(identity)) return
145
+ reportedPartials.add(identity)
146
+ emit({ type: 'partial-receipt', key, ...s.partialReceipt })
121
147
  }
122
148
  function wait(key: string, s: State, reason: DeliveryReason): void { const facts = status(key, s); if (facts) emit({ type: 'waiting', key, seq: facts.seq, reason, ageMs: facts.ageMs, status: facts }) }
123
149
  function schedule(key: string, s: State, delay = DELIVERY_ROW_POLL_MS): void {
@@ -138,6 +164,9 @@ export function createDeliveryEngines(deps: DeliveryEngineDeps): DeliveryEngines
138
164
  try {
139
165
  const row = await ad(key, s, 'get', client => client.get({ instance_id: deps.instanceId(key) }))
140
166
  if (stopped || s.stopped) return false
167
+ const partialLaunch = JSON.stringify([row.session_id, row.launch_started_at])
168
+ if (s.partialLaunch !== undefined && s.partialLaunch !== partialLaunch) resetPartialLaunch(key, s)
169
+ s.partialLaunch = partialLaunch
141
170
  s.row = row
142
171
  if (row.state !== 'waiting' || !row.unproven_requests?.length || row.unproven_requests.some(r => r.decision === null)) s.dialogHold = false
143
172
  life.observeRow(key, row)
@@ -252,7 +281,7 @@ export function createDeliveryEngines(deps: DeliveryEngineDeps): DeliveryEngines
252
281
  const persona = deps.persona(key)
253
282
  const agentOf = (row: GetResult): DeliveryAgent => row.agent === 'codex' ? 'codex' : persona?.agent ?? 'claude'
254
283
  let row = s.row
255
- let receipt = s.reader.read(agentOf(row), row, head, part, deps.queue.items(key))
284
+ let receipt = s.reader.read(agentOf(row), row, head, part, deps.queue.items(key), s.partialAfterAttempts.get(head.accepted.seq))
256
285
  // Claude's SessionStart can precede its first transcript write. AD keeps
257
286
  // that reference empty until find-missing heals it; get alone cannot.
258
287
  // Keep receipts from recorded/prior paths usable without a sweep. Only
@@ -266,7 +295,7 @@ export function createDeliveryEngines(deps: DeliveryEngineDeps): DeliveryEngines
266
295
  if (stopped || s.stopped || !life.active(key) || !deps.isUp(key) || deps.isLatched(key) || deps.isHeld(key)) return false
267
296
  if (!await readRow(key, s, true) || !s.row) return false
268
297
  row = s.row
269
- receipt = s.reader.read(agentOf(row), row, head, part, deps.queue.items(key))
298
+ receipt = s.reader.read(agentOf(row), row, head, part, deps.queue.items(key), s.partialAfterAttempts.get(head.accepted.seq))
270
299
  }
271
300
  // Preserve an AD binding absent from an earlier durable attempt.
272
301
  // This is reference evidence, independent of whether a receipt matched.
@@ -277,6 +306,7 @@ export function createDeliveryEngines(deps: DeliveryEngineDeps): DeliveryEngines
277
306
  }
278
307
  s.receipt = receipt
279
308
  s.receiptTarget = [head.accepted.seq, part]; s.receiptAttempt = effectiveSends(head, part).at(-1)?.attempt ?? 0
309
+ observePartialReceipt(key, s, head, part, receipt)
280
310
  const text = head.fixed?.parts[part - 1]
281
311
  const records = [
282
312
  ...(receipt.receipt ? [{ kind: 'take-in', text }] : []),
@@ -493,7 +523,7 @@ export function createDeliveryEngines(deps: DeliveryEngineDeps): DeliveryEngines
493
523
  // Concrete receipt time must elapse before calling a retype
494
524
  // unconfirmed; a receipt arriving meanwhile wins the next choice.
495
525
  if (s.e.phase === 'receipt' && (s.deadlines.receipt ?? 0) > clock.now()) { nextDelay = Math.min(DELIVERY_ROW_POLL_MS, s.deadlines.receipt! - clock.now()); break }
496
- queueWrite(s, { v: 1, kind: 'noticed', seq: pair[0], at: clock.now() }, () => { s.e.noticed = [...s.e.noticed, pair[0]]; emit({ type: 'first-unconfirmed-retype', key, seq: pair[0], part, count: head!.retypeCount }) }); continue
526
+ queueWrite(s, { v: 1, kind: 'noticed', seq: pair[0], at: clock.now() }, () => { s.e.noticed = [...s.e.noticed, pair[0]]; emit({ type: 'first-unconfirmed-retype', key, seq: pair[0], part, count: head!.retypeCount, partialReceipt: status(key, s)?.partialReceipt }) }); continue
497
527
  }
498
528
  if (action === 'deferReconnect') {
499
529
  release(s)
@@ -579,6 +609,7 @@ export function createDeliveryEngines(deps: DeliveryEngineDeps): DeliveryEngines
579
609
  life.setRelaunchObserver((key, row) => {
580
610
  const s = states.get(key)
581
611
  if (s) {
612
+ resetPartialLaunch(key, s)
582
613
  s.relaunch = true
583
614
  if (row.state === 'pending' || (row.session_id !== undefined && row.session_id !== s.gone?.row.session_id)) s.gone = undefined
584
615
  s.restarted = !!s.gone
@@ -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 })
@@ -9,9 +9,9 @@ import { DELIVERY_STUCK_AFTER_MS } from './delivery-constants.ts'
9
9
  import { escapeSlackControlCharacters } from './slack-text-escape.ts'
10
10
  import { redactSlackLogText } from './slack-log-redaction.ts'
11
11
  import { renderPersonaRef } from './persona-identity.ts'
12
- import { MESSAGE_NOT_SAVED, MESSAGE_RECEIVED, MESSAGE_DELIVERED, MESSAGE_RETYPED, MESSAGE_NOT_TAKEN_IN, DELIVERY_STUCK, QUEUE_RECORD_UNREADABLE, QUEUE_UNWRITABLE, QUEUE_KEPT, deliveryIdentity, buildDeliveryDiagnostic, queueErrorCode, type DeliveryDiagnosticClass } from './persona-diagnostics.ts'
12
+ import { MESSAGE_NOT_SAVED, MESSAGE_RECEIVED, MESSAGE_DELIVERED, MESSAGE_RETYPED, MESSAGE_NOT_TAKEN_IN, MESSAGE_PARTIAL_RECEIPT, DELIVERY_STUCK, QUEUE_RECORD_UNREADABLE, QUEUE_UNWRITABLE, QUEUE_KEPT, deliveryIdentity, buildDeliveryDiagnostic, queueErrorCode, type DeliveryDiagnosticClass } from './persona-diagnostics.ts'
13
13
 
14
- export { DELIVERY_DIAGNOSTIC_CLASSES, MESSAGE_NOT_SAVED, MESSAGE_RECEIVED, MESSAGE_DELIVERED, MESSAGE_RETYPED, MESSAGE_NOT_TAKEN_IN, DELIVERY_STUCK, MESSAGES_WAITING, QUEUE_RECORD_UNREADABLE, QUEUE_UNWRITABLE, QUEUE_KEPT, SYSTEM_PROMPT_UNWRITABLE, deliveryIdentity, buildDeliveryDiagnostic, queueErrorCode } from './persona-diagnostics.ts'
14
+ export { DELIVERY_DIAGNOSTIC_CLASSES, MESSAGE_NOT_SAVED, MESSAGE_RECEIVED, MESSAGE_DELIVERED, MESSAGE_RETYPED, MESSAGE_NOT_TAKEN_IN, MESSAGE_PARTIAL_RECEIPT, DELIVERY_STUCK, MESSAGES_WAITING, QUEUE_RECORD_UNREADABLE, QUEUE_UNWRITABLE, QUEUE_KEPT, SYSTEM_PROMPT_UNWRITABLE, deliveryIdentity, buildDeliveryDiagnostic, queueErrorCode } from './persona-diagnostics.ts'
15
15
  const safe = (text: string): string => redactSlackLogText(text).replace(/[\r\n]/g, ' ')
16
16
  const slack = (text: string): string => escapeSlackControlCharacters(safe(text))
17
17
  export function deliveryAge(ageMs: number): string {
@@ -46,11 +46,11 @@ export function deliveryStuckRemedy(reason: DeliveryStuckReason, key: string, co
46
46
  case 'dialog-hold': return `agent-director won't type into it until it can prove an answered permission prompt is gone; a subagent's denied prompt stays held until the bot ends. Restart the persona's bot.`
47
47
  }
48
48
  }
49
- export function buildDeliveryStuckNotice(persona: string, key: string, count: number, ageMs: number, reason: DeliveryStuckReason, typedCount: number): string {
50
- return `:warning: *Delivery stuck* — ${count} message(s) for ${slack(persona)} are waiting; the oldest has waited ${deliveryAge(ageMs)}. ${deliveryStuckRemedy(reason, key, typedCount)} ${CONFLICT_NOTICE_HUMAN_ONLY_LINE}`
49
+ export function buildDeliveryStuckNotice(persona: string, key: string, count: number, ageMs: number, reason: DeliveryStuckReason, typedCount: number, partialReceipt = false): string {
50
+ return `:warning: *Delivery stuck* — ${count} message(s) for ${slack(persona)} are waiting; the oldest has waited ${deliveryAge(ageMs)}. ${partialReceipt ? 'The text arrived cut off; the full message is still queued. ' : ''}${deliveryStuckRemedy(reason, key, typedCount)} ${CONFLICT_NOTICE_HUMAN_ONLY_LINE}`
51
51
  }
52
- export function buildMessageNotTakenInNotice(persona: string, sender: string): string {
53
- return `:warning: *Message not taken in* — the message from ${slack(sender)} for ${slack(persona)} is still queued. CSCB keeps offering it (again at least every 15 minutes while it can type it) until the bot's transcript shows it. Later messages for this persona wait behind it. The bot may see it more than once.`
52
+ export function buildMessageNotTakenInNotice(persona: string, sender: string, partialReceipt = false): string {
53
+ return `:warning: *Message not taken in* — the message from ${slack(sender)} for ${slack(persona)} is still queued. ${partialReceipt ? 'The text arrived cut off; the full message is kept. ' : ''}CSCB keeps offering it (again at least every 15 minutes while it can type it) until the bot's transcript shows it. Later messages for this persona wait behind it. The bot may see it more than once.`
54
54
  }
55
55
  export const MESSAGES_WAITING_STATES = ['down', 'down at its restart limit', 'down with automatic restarts off', 'latched', 'held on invalid flags', 'held after a failed kill', 'waiting on an old life', 'waiting for its upgrade replacement'] as const
56
56
  export type MessagesWaitingState = typeof MESSAGES_WAITING_STATES[number]
@@ -246,9 +246,13 @@ export function createDeliveryReporting(deps: DeliveryReportingDeps) {
246
246
  return
247
247
  }
248
248
  if (event.type === 'retyped') { log(MESSAGE_RETYPED, key, identity, `retype_count=${event.count}`); return }
249
+ if (event.type === 'partial-receipt') {
250
+ log(MESSAGE_PARTIAL_RECEIPT, key, identity, `part=${event.part} expected_bytes=${event.expectedBytes} received_bytes=${event.receivedBytes} typed but cut off; full text remains queued`)
251
+ return
252
+ }
249
253
  if (event.type === 'first-unconfirmed-retype') {
250
254
  log(MESSAGE_NOT_TAKEN_IN, key, identity)
251
- protect(() => deps.notify(key, buildMessageNotTakenInNotice(ref(key), item.accepted.user ?? item.accepted.bot_id ?? item.accepted.sender ?? 'unknown'))); return
255
+ protect(() => deps.notify(key, buildMessageNotTakenInNotice(ref(key), item.accepted.user ?? item.accepted.bot_id ?? item.accepted.sender ?? 'unknown', !!event.partialReceipt))); return
252
256
  }
253
257
  if (event.type !== 'waiting' || !event.status) return
254
258
  const status = event.status, state = deps.state(key)
@@ -261,7 +265,7 @@ export function createDeliveryReporting(deps: DeliveryReportingDeps) {
261
265
  if (!reason || status.ageMs <= (deps.stuckAfterMs ?? DELIVERY_STUCK_AFTER_MS)) return
262
266
  const begun = deps.episodes.begin(key, 'delivery-stuck')
263
267
  if (begun === 'begun') log(DELIVERY_STUCK, key, identity, `reason=${reason} age_ms=${status.ageMs}`)
264
- deps.episodes.post(key, 'delivery-stuck', buildDeliveryStuckNotice(ref(key), key, status.count, status.ageMs, reason, status.retypeCount + 1))
268
+ deps.episodes.post(key, 'delivery-stuck', buildDeliveryStuckNotice(ref(key), key, status.count, status.ageMs, reason, status.retypeCount + 1, !!status.partialReceipt))
265
269
  }
266
270
  return { received: (key: string, item: QueueItem) => protect(() => receivedItem(key, item)), reconcileQueued: (key: string) => protect(() => reconcileQueued(key)), queueEvent: (event: QueueEvent) => protect(() => queueEvent(event)), engineEvent: (event: DeliveryEngineEvent) => protect(() => engineEvent(event)), startupFindings, forget(key: string) {
267
271
  for (const warning of storageWarnings.get(key) ?? []) warning.dispose()
@@ -1,20 +1,26 @@
1
1
  /** Transcript I/O and schemas are private to this adapter. */
2
2
  import * as nodeFs from 'node:fs'
3
3
  import { locateTranscript } from './session-transcript.ts'
4
+ import { parseLaunchStart } from './pending-row.ts'
4
5
  import type { QueueItem, SentRecord, DeliveredRecord } from './delivery-queue.ts'
5
6
 
6
7
  export type DeliveryAgent = 'claude' | 'codex'
7
8
 
8
9
  export interface DeliveryTranscriptRow {
9
10
  jsonl_path?: string; session_id?: string; cwd?: string; transcript_status?: string
11
+ launch_started_at?: string | null
10
12
  prior_sessions?: readonly { jsonl_path?: string; session_id?: string; cwd?: string }[]
11
13
  }
12
14
  export interface ReceiptRecord { path: string; offset: number; end: number; uuid?: string; at?: number; text: string; kind: string }
13
15
  export interface ReceiptObservation {
14
16
  path: string; size: number; availability: 'readable' | 'not-yet-written' | 'unreadable'
15
17
  records: readonly ReceiptRecord[]; receipt?: ReceiptRecord
18
+ partialReceipt?: ReceiptRecord & { receivedBytes: number }
16
19
  }
17
20
  const TRANSCRIPT_READ_BYTES = 256 * 1024
21
+ const MIN_PARTIAL_BYTES = 128
22
+ const MIN_PARTIAL_NONSPACE_CHARACTERS = 96
23
+ const MIN_PARTIAL_DISTINCT_FOURS = 48
18
24
  const object = (v: unknown): Record<string, any> | undefined => typeof v === 'object' && v !== null && !Array.isArray(v) ? v as Record<string, any> : undefined
19
25
  function textBlocks(value: unknown, type: string): string | undefined {
20
26
  if (typeof value === 'string') return value
@@ -49,6 +55,33 @@ export function receiptTextMatches(record: string, expected: string): boolean {
49
55
  }
50
56
  return match(0, 0, 0)
51
57
  }
58
+ /** Evidence only: a substantial, varied, exact proper suffix. Whitespace is
59
+ * ignored only for the distinctiveness check, never for receipt matching. */
60
+ function distinctiveSuffix(text: string): boolean {
61
+ if (Buffer.byteLength(text) < MIN_PARTIAL_BYTES) return false
62
+ const characters = Array.from(text.replace(/\s/g, ''))
63
+ if (characters.length < MIN_PARTIAL_NONSPACE_CHARACTERS) return false
64
+ const fours = new Set<string>()
65
+ for (let i = 0; i + 4 <= characters.length; i++) {
66
+ fours.add(characters.slice(i, i + 4).join(''))
67
+ if (fours.size >= MIN_PARTIAL_DISTINCT_FOURS) return true
68
+ }
69
+ return false
70
+ }
71
+ function partialReceiptBytes(record: string, expected: string): number | undefined {
72
+ if (record.length < expected.length && expected.endsWith(record) && distinctiveSuffix(record)) return Buffer.byteLength(record)
73
+ // Bound the possible suffix lengths using only the exact matcher's paste
74
+ // frames and optional adjacent LFs. The matcher still validates every byte;
75
+ // this does not trim whitespace or accept arbitrary embedded quotations.
76
+ const frames = [...record.matchAll(/<pasted_content id="([^"]+)">\n([\s\S]*?)\n<\/pasted_content id="\1">/g)]
77
+ const longest = frames.reduce((length, frame) => length - frame[0].length + frame[2]!.length, record.length)
78
+ const shortest = Math.max(1, longest - 4 * frames.length)
79
+ for (let length = Math.min(longest, expected.length - 1); length >= shortest; length--) {
80
+ const suffix = expected.slice(-length)
81
+ if (distinctiveSuffix(suffix) && receiptTextMatches(record, suffix)) return Buffer.byteLength(suffix)
82
+ }
83
+ return undefined
84
+ }
52
85
  export function parseDeliveryTranscriptRecord(agent: DeliveryAgent, value: unknown): { text?: string; kind: string; uuid?: string } {
53
86
  const e = object(value); if (!e) return { kind: 'other' }
54
87
  const uuid = typeof e.uuid === 'string' ? e.uuid : undefined
@@ -71,7 +104,8 @@ export function deliveryTranscriptPath(agent: DeliveryAgent, row: DeliveryTransc
71
104
  }
72
105
  interface CachedFile { dev: number; ino: number; size: number; pending: Buffer; records: ReceiptRecord[] }
73
106
  export interface DeliveryReceiptReader {
74
- read(agent: DeliveryAgent, row: DeliveryTranscriptRow, item: QueueItem, part: number, allItems?: readonly QueueItem[]): ReceiptObservation
107
+ /** partialAfterAttempt excludes old-life diagnostics only, never full receipts. */
108
+ read(agent: DeliveryAgent, row: DeliveryTranscriptRow, item: QueueItem, part: number, allItems?: readonly QueueItem[], partialAfterAttempt?: number): ReceiptObservation
75
109
  forget(): void
76
110
  }
77
111
  function receiptIdentity(receipt: { path: string; uuid?: string; offset?: number }): string {
@@ -82,9 +116,9 @@ function receiptIdentity(receipt: { path: string; uuid?: string; offset?: number
82
116
  export function createDeliveryReceiptReader(options: { fs?: typeof nodeFs } = {}): DeliveryReceiptReader {
83
117
  const fs = options.fs ?? nodeFs, files = new Map<string, CachedFile>()
84
118
  // Freeze each known file's pre-send EOF when the durable attempt first
85
- // appears. An absent entry means the file was discovered after that send,
86
- // so its entire contents are eligible. Keep this independently of
87
- // the parsed cache, including when a file cannot currently be opened.
119
+ // appears. Unknown EOFs permit searching entire files for full receipts;
120
+ // partial evidence needs additional post-send proof. Keep these windows
121
+ // independently of the parsed cache, even when a file cannot be opened.
88
122
  const observed = new Map<string, number>(), windows = new Map<string, ReadonlyMap<string, number>>()
89
123
  const sendIdentity = (seq: number, send: SentRecord): string => `${seq}:${send.part}:${send.attempt}`
90
124
  function readFile(agent: DeliveryAgent, path: string, missingAllowed: boolean): { size: number; availability: ReceiptObservation['availability']; records: readonly ReceiptRecord[] } {
@@ -127,7 +161,7 @@ export function createDeliveryReceiptReader(options: { fs?: typeof nodeFs } = {}
127
161
  }
128
162
  return {
129
163
  forget() { files.clear(); observed.clear(); windows.clear() },
130
- read(agent, row, item, part, allItems = [item]) {
164
+ read(agent, row, item, part, allItems = [item], partialAfterAttempt = 0) {
131
165
  // Register all durable sends before discovering/reading this lap's
132
166
  // files. This also preserves the discovery order when the next FIFO
133
167
  // head is first observed after another item's receipt was read.
@@ -149,6 +183,15 @@ export function createDeliveryReceiptReader(options: { fs?: typeof nodeFs } = {}
149
183
  const inWindow = (r: ReceiptRecord, send: SentRecord | undefined): boolean => send
150
184
  ? r.offset >= (r.path === send.transcript_path ? send.transcript_size : windows.get(sendIdentity(item.accepted.seq, send))?.get(r.path) ?? 0)
151
185
  : !!unknown && r.at !== undefined && r.at >= unknown.at
186
+ const inPartialWindow = (r: ReceiptRecord, send: SentRecord): boolean => {
187
+ if (r.at !== undefined && r.at < send.at) return false
188
+ const start = r.path === send.transcript_path ? send.transcript_size : windows.get(sendIdentity(item.accepted.seq, send))?.get(r.path)
189
+ if (start !== undefined) return r.offset >= start
190
+ // Replay and late discovery cannot recover a prior path's pre-send EOF.
191
+ // Without one, require dated evidence on AD's current path; partialSend
192
+ // below binds that path's session to the send and its launch.
193
+ return r.path === path && !!row.session_id && r.at !== undefined && r.at >= send.at
194
+ }
152
195
  for (const p of paths) {
153
196
  const current = p === path
154
197
  const f = readFile(agent, p, current && row.transcript_status !== 'present')
@@ -160,7 +203,19 @@ export function createDeliveryReceiptReader(options: { fs?: typeof nodeFs } = {}
160
203
  }
161
204
  const text = item.fixed?.parts[part - 1] ?? ''
162
205
  const receipt = records.find(r => availability.get(r.path) === 'readable' && r.kind === 'take-in' && inWindow(r, first) && receiptTextMatches(r.text, text) && !credited.has(receiptIdentity(r)))
163
- return { path, size, availability: unreadable ? 'unreadable' : size ? 'readable' : 'not-yet-written', records, receipt }
206
+ // Full historical receipts may still finish delivery after a relaunch.
207
+ // Partial diagnostics instead belong to a known send in this AD life.
208
+ // Replay uses durable send time/session plus AD's launch start; observed
209
+ // lifecycle transitions also exclude all attempts preceding that change.
210
+ const launchAt = row.launch_started_at == null ? -Infinity : parseLaunchStart(row.launch_started_at)
211
+ const partialSend = launchAt === undefined ? undefined : sends.find(s => s.attempt > partialAfterAttempt && s.session_id === row.session_id && s.at >= launchAt)
212
+ let partialReceipt: ReceiptObservation['partialReceipt']
213
+ if (!receipt && partialSend) for (const r of records) {
214
+ if (availability.get(r.path) !== 'readable' || r.kind !== 'take-in' || !inPartialWindow(r, partialSend) || credited.has(receiptIdentity(r))) continue
215
+ const receivedBytes = partialReceiptBytes(r.text, text)
216
+ if (receivedBytes !== undefined) { partialReceipt = { ...r, receivedBytes }; break }
217
+ }
218
+ return { path, size, availability: unreadable ? 'unreadable' : size ? 'readable' : 'not-yet-written', records, receipt, partialReceipt }
164
219
  },
165
220
  }
166
221
  }
@@ -162,13 +162,14 @@ export const MESSAGE_RECEIVED = 'message-received'
162
162
  export const MESSAGE_DELIVERED = 'message-delivered'
163
163
  export const MESSAGE_RETYPED = 'message-retyped'
164
164
  export const MESSAGE_NOT_TAKEN_IN = 'message-not-taken-in'
165
+ export const MESSAGE_PARTIAL_RECEIPT = 'message-partial-receipt'
165
166
  export const DELIVERY_STUCK = 'delivery-stuck'
166
167
  export const MESSAGES_WAITING = 'messages-waiting'
167
168
  export const QUEUE_RECORD_UNREADABLE = 'queue-record-unreadable'
168
169
  export const QUEUE_UNWRITABLE = 'queue-unwritable'
169
170
  export const QUEUE_KEPT = 'queue-kept'
170
171
  export const SYSTEM_PROMPT_UNWRITABLE = 'system-prompt-unwritable'
171
- export const DELIVERY_DIAGNOSTIC_CLASSES = [MESSAGE_NOT_SAVED, MESSAGE_RECEIVED, MESSAGE_DELIVERED, MESSAGE_RETYPED, MESSAGE_NOT_TAKEN_IN, DELIVERY_STUCK, QUEUE_RECORD_UNREADABLE, QUEUE_UNWRITABLE, QUEUE_KEPT, SYSTEM_PROMPT_UNWRITABLE] as const
172
+ export const DELIVERY_DIAGNOSTIC_CLASSES = [MESSAGE_NOT_SAVED, MESSAGE_RECEIVED, MESSAGE_DELIVERED, MESSAGE_RETYPED, MESSAGE_NOT_TAKEN_IN, MESSAGE_PARTIAL_RECEIPT, DELIVERY_STUCK, QUEUE_RECORD_UNREADABLE, QUEUE_UNWRITABLE, QUEUE_KEPT, SYSTEM_PROMPT_UNWRITABLE] as const
172
173
  export type DeliveryDiagnosticClass = typeof DELIVERY_DIAGNOSTIC_CLASSES[number]
173
174
  const safe = (text: string): string => redactSlackLogText(text).replace(/[\r\n]/g, ' ')
174
175
  /** Explicit metadata allowlist: redaction alone is not message-body exclusion. */
package/src/registry.ts CHANGED
@@ -1166,7 +1166,7 @@ export const MCP_INSTRUCTIONS_MAX_CHARS = 2048
1166
1166
  export const MCP_INSTRUCTIONS = [
1167
1167
  'The sender reads Slack. Answer with reply, using chat_id and thread_ts for a thread; terminal output is not seen there.',
1168
1168
  'Messages arrive as the <channel> tag described in the system prompt, carrying channel_name and, for a long message, part.',
1169
- 'via: dm = a direct message; mention = you were @mentioned; broadcast = @here or @channel; receive_all_shared = a channel where you and another persona receive every message; receive_all = a channel where only you receive every message.',
1169
+ 'via: dm = a direct message; mention = you were @mentioned; broadcast = @here or @channel; receive_all_shared = a channel where you and another persona receive every message; receive_all = a channel where you, and no other persona on this server, receive every message.',
1170
1170
  '<@ID> mentions a Slack user or persona. Use <@ID> to address another persona.',
1171
1171
  'If attachment_count is present, use download_attachment(chat_id, message_id) to fetch attachments.',
1172
1172
  'You may act in every channel you receive. With invited channels off, these are your configured channels; with it on, channels your Slack app belongs to (Slack refuses others).',
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 !== '')