claude-slack-channel-bots 0.7.1 → 0.7.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slack-channel-bots",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Multi-session Slack-to-Claude bridge — run multiple Claude Code bots across Slack channels via Socket Mode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,12 +23,26 @@ import type { WebClient } from '@slack/web-api'
23
23
  import { decideWithToken } from './agent-director-client.ts'
24
24
  import { parsePermissionActionId, type PermissionDecision } from './permission-action-id.ts'
25
25
  import { getLivePermission, markHandled } from './permission-poller.ts'
26
+ import { emitTrail as defaultEmitTrail } from './permission-trail.ts'
27
+ import type {
28
+ AdDecideResponseClass,
29
+ ClosureVerdictTag,
30
+ ParseFailureReason,
31
+ TrailEventBase,
32
+ } from './permission-trail.ts'
26
33
 
27
34
  export interface ClickDeps {
28
35
  /** Returns an AD Client whose `decide` method this handler will invoke. */
29
36
  getClient: () => Pick<Client, 'decide'>
30
37
  web: Pick<WebClient, 'chat'>
31
38
  log?: (...args: unknown[]) => void
39
+ /**
40
+ * Trail emitter hook (SR-V). Defaults to `emitTrail` from
41
+ * `permission-trail.ts`. Tests override with a capture stub.
42
+ */
43
+ emitTrail?: (
44
+ partial: Omit<TrailEventBase, 'ts'> & { [extra: string]: unknown },
45
+ ) => void
32
46
  }
33
47
 
34
48
  function logDeps(deps: ClickDeps, ...args: unknown[]): void {
@@ -36,6 +50,34 @@ function logDeps(deps: ClickDeps, ...args: unknown[]): void {
36
50
  else console.error(...args)
37
51
  }
38
52
 
53
+ /**
54
+ * SR-V-2.7 call-side classification of an agent-director `decide` error.
55
+ * Mirrors the existing branches in `handlePermissionClick`'s catch so the
56
+ * trail's `result_class` is identical to the operational discriminator.
57
+ */
58
+ function classifyAdDecideError(err: unknown): AdDecideResponseClass {
59
+ if (err instanceof ErrAlreadyDecided) return 'ErrAlreadyDecided'
60
+ if (err instanceof AgentDirectorError && err.errName === 'ErrInvalidFlags') return 'ErrInvalidFlags'
61
+ if (err instanceof AgentDirectorError && err.errName === 'ErrAmbiguousRequest') return 'ErrAmbiguousRequest'
62
+ return 'other'
63
+ }
64
+
65
+ /** SR-V-2.5 Slack error class string (mirrors permission-poller.ts). */
66
+ function classifySlackError(err: unknown): string {
67
+ if (err !== null && typeof err === 'object') {
68
+ const data = (err as { data?: unknown }).data
69
+ if (data !== null && typeof data === 'object') {
70
+ const e = (data as { error?: unknown }).error
71
+ if (typeof e === 'string' && e.length > 0) return e
72
+ }
73
+ }
74
+ if (err instanceof Error) {
75
+ if (err.name === 'AbortError') return 'aborted'
76
+ return 'network_error'
77
+ }
78
+ return 'unknown_error'
79
+ }
80
+
39
81
  // Match the poller's SR-2.4 terminal verdict text exactly so the click-handler
40
82
  // render and the next-tick reconciliation render are byte-identical — no
41
83
  // visible flicker.
@@ -51,6 +93,22 @@ function buildDecisionBlocks(decision: PermissionDecision): unknown[] {
51
93
  ]
52
94
  }
53
95
 
96
+ /**
97
+ * Inbound context captured from the Socket Mode interactive payload. Used by
98
+ * the SR-V-2.6 `cscb.click_handler.invoked` event so investigations can
99
+ * pivot by clicking user or by the Slack message being clicked against.
100
+ * Optional so unit-test call sites can omit it; the trail event records the
101
+ * fields as undefined/missing when not supplied (open envelope per SR-V-1).
102
+ */
103
+ export interface ClickInteractionContext {
104
+ /** Inbound Slack channel id from the interactive payload. */
105
+ channel?: string
106
+ /** The clicked-message ts from the interactive payload. */
107
+ messageTs?: string
108
+ /** Clicking user's Slack id from the interactive payload. */
109
+ user?: string
110
+ }
111
+
54
112
  /**
55
113
  * Handle a permission Block Kit click. Returns true when the action_id was a
56
114
  * valid permission decision (the caller has already ack'd); false when the
@@ -59,23 +117,66 @@ function buildDecisionBlocks(decision: PermissionDecision): unknown[] {
59
117
  export async function handlePermissionClick(
60
118
  actionId: string,
61
119
  deps: ClickDeps,
120
+ context: ClickInteractionContext = {},
62
121
  ): Promise<boolean> {
63
122
  const parsed = parsePermissionActionId(actionId)
64
123
  if (parsed === null) return false
65
124
 
66
125
  const { decision, claudeInstanceId, requestToken } = parsed
126
+ const emit = deps.emitTrail ?? defaultEmitTrail
127
+ // SR-V-2.6: live_pending captures the click-time state. Read the entry
128
+ // here so the trail event records the value at handler entry, before any
129
+ // markHandled / drop later in this function (or in the next tick) mutates
130
+ // the map.
131
+ const earlyEntry = getLivePermission(claudeInstanceId, requestToken)
132
+ emit({
133
+ event: 'cscb.click_handler.invoked',
134
+ claude_instance_id: claudeInstanceId,
135
+ request_token: requestToken,
136
+ channel: context.channel ?? earlyEntry?.channelId,
137
+ message_ts: context.messageTs ?? earlyEntry?.messageTs,
138
+ user: context.user,
139
+ raw_action_id: actionId,
140
+ decision,
141
+ live_pending: earlyEntry !== undefined,
142
+ })
67
143
 
68
144
  // SR-4.1, SR-4.2, SR-7.2: AD is the source of truth. Always call decide
69
145
  // with the decoded request_token — including for stale clicks whose
70
146
  // composite key is no longer in the pending map. This is the click's ONLY
71
147
  // AD interaction (SR-4.3 retires the pre-decide get).
148
+ const decideEnvelope = {
149
+ event: 'cscb.ad_decide.attempted',
150
+ claude_instance_id: claudeInstanceId,
151
+ request_token: requestToken,
152
+ decision,
153
+ }
72
154
  try {
73
155
  await decideWithToken(deps.getClient(), {
74
156
  claude_instance_id: claudeInstanceId,
75
157
  decision,
76
158
  request_token: requestToken,
77
159
  })
160
+ // SR-V-2.7 call-side success emission.
161
+ emit({ ...decideEnvelope, result_class: 'ok' satisfies AdDecideResponseClass })
78
162
  } catch (err) {
163
+ // SR-V-2.7 call-side failure emission. Classify against the same AD
164
+ // error names the existing branches discriminate on so the trail's
165
+ // result_class stays consistent with src/agent-director-errors.ts. The
166
+ // existing logDeps operational logging is preserved alongside the
167
+ // canonical trail event — the two serve different purposes (live
168
+ // stderr vs after-the-fact debugging).
169
+ const result_class: AdDecideResponseClass = classifyAdDecideError(err)
170
+ const emission: Omit<TrailEventBase, 'ts'> & { [extra: string]: unknown } = {
171
+ ...decideEnvelope,
172
+ result_class,
173
+ }
174
+ if (result_class === 'other') {
175
+ const raw = err instanceof Error ? err.message : String(err)
176
+ emission['raw_error_message'] = raw
177
+ }
178
+ emit(emission)
179
+
79
180
  // SR-4.4: ErrAlreadyDecided is silently swallowed — the next poller tick
80
181
  // reconciles the operator-visible Slack rendering via SR-2.4 / SR-5.
81
182
  if (err instanceof ErrAlreadyDecided) return true
@@ -104,16 +205,99 @@ export async function handlePermissionClick(
104
205
  const text = decision === 'allow'
105
206
  ? '*Permission* — Allowed'
106
207
  : '*Permission* — Denied by operator'
208
+ const blocks = buildDecisionBlocks(decision)
209
+ const verdictTag: ClosureVerdictTag = decision === 'allow'
210
+ ? 'click_handler_allow'
211
+ : 'click_handler_deny'
212
+ const envelope = {
213
+ event: 'cscb.chat_update.attempted',
214
+ claude_instance_id: claudeInstanceId,
215
+ request_token: requestToken,
216
+ channel: entry.channelId,
217
+ message_ts: entry.messageTs,
218
+ text,
219
+ blocks,
220
+ verdict_tag: verdictTag,
221
+ triggered_by: 'click_handler' as const,
222
+ }
107
223
  try {
108
224
  await deps.web.chat.update({
109
225
  channel: entry.channelId,
110
226
  ts: entry.messageTs,
111
227
  text,
112
- blocks: buildDecisionBlocks(decision) as never,
228
+ blocks: blocks as never,
113
229
  })
114
230
  markHandled(claudeInstanceId, requestToken)
231
+ emit({ ...envelope, ok: true })
115
232
  } catch (err) {
233
+ // b.emk: failures land in BOTH server.log and the trail JSONL.
116
234
  logDeps(deps, `[slack] permission-click: decision chat.update failed for ${claudeInstanceId}:`, err)
235
+ emit({ ...envelope, ok: false, error: classifySlackError(err) })
117
236
  }
118
237
  return true
119
238
  }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // SR-V-2.9 inbound block_actions emission
242
+ // ---------------------------------------------------------------------------
243
+
244
+ /**
245
+ * Classify a `block_actions` action_id for the SR-V-2.9 trail event without
246
+ * mutating state. Exported so `src/server.ts`'s `socket.on('interactive')`
247
+ * handler stays a thin wiring layer and so this branch can be unit-tested
248
+ * in isolation (server.ts has module-load side effects and is not directly
249
+ * importable from tests).
250
+ *
251
+ * The decision split is intentional: a `perm_(allow|deny)_*` prefix
252
+ * indicates a CSCB permission button whose body failed to parse
253
+ * (`malformed_token`); anything else is foreign (`foreign_action_id`). Note
254
+ * that `stale_prompt` is NOT a parse-failure reason here — a stale click
255
+ * decodes fine and shows up as `cscb.click_handler.invoked{live_pending:false}`.
256
+ */
257
+ const PERMISSION_BUTTON_PREFIX_RE = /^perm_(allow|deny)_/
258
+
259
+ export interface BlockActionTrailContext {
260
+ /** Inbound Slack channel id from the interactive payload. */
261
+ channel?: string
262
+ /** Clicked message ts from the interactive payload. */
263
+ messageTs?: string
264
+ /** Clicking user's Slack id. */
265
+ user?: string
266
+ }
267
+
268
+ /**
269
+ * Emit one `cscb.block_action.received` event for an inbound `block_actions`
270
+ * action_id. Called once per action in the payload's `actions` array,
271
+ * regardless of whether the action_id decodes (SR-V-2.9 — the
272
+ * decode-failure case is the diagnostically critical surface for
273
+ * "I clicked Allow and nothing happened").
274
+ */
275
+ export function emitBlockActionReceived(
276
+ actionId: string,
277
+ context: BlockActionTrailContext,
278
+ emit: (
279
+ partial: Omit<TrailEventBase, 'ts'> & { [extra: string]: unknown },
280
+ ) => void = defaultEmitTrail,
281
+ ): void {
282
+ const parsed = parsePermissionActionId(actionId)
283
+ const base = {
284
+ event: 'cscb.block_action.received',
285
+ channel: context.channel,
286
+ message_ts: context.messageTs,
287
+ user: context.user,
288
+ raw_action_id: actionId,
289
+ }
290
+ if (parsed !== null) {
291
+ emit({
292
+ ...base,
293
+ claude_instance_id: parsed.claudeInstanceId,
294
+ request_token: parsed.requestToken,
295
+ decision: parsed.decision,
296
+ })
297
+ return
298
+ }
299
+ const reason: ParseFailureReason = PERMISSION_BUTTON_PREFIX_RE.test(actionId)
300
+ ? 'malformed_token'
301
+ : 'foreign_action_id'
302
+ emit({ ...base, parse_failure_reason: reason })
303
+ }
@@ -51,6 +51,12 @@ import type {
51
51
  GetPermissionResult,
52
52
  } from './agent-director-client.ts'
53
53
  import { encodePermissionActionId } from './permission-action-id.ts'
54
+ import { emitTrail as defaultEmitTrail } from './permission-trail.ts'
55
+ import type {
56
+ ClosureVerdictTag,
57
+ RowDecisionAction,
58
+ TrailEventBase,
59
+ } from './permission-trail.ts'
54
60
 
55
61
  // ---------------------------------------------------------------------------
56
62
  // Wire-shape types (local — mirrors the `^0.6.0` wire)
@@ -136,6 +142,14 @@ export interface PollerDeps {
136
142
  /** Hook factories so tests can stub timer scheduling. */
137
143
  setInterval?: (cb: () => void, ms: number) => ReturnType<typeof setInterval>
138
144
  clearInterval?: (handle: ReturnType<typeof setInterval>) => void
145
+ /**
146
+ * Trail emitter hook (SR-V). Defaults to `emitTrail` from
147
+ * `permission-trail.ts`. Tests override with a capture stub to assert
148
+ * emission shape without touching the on-disk JSONL.
149
+ */
150
+ emitTrail?: (
151
+ partial: Omit<TrailEventBase, 'ts'> & { [extra: string]: unknown },
152
+ ) => void
139
153
  }
140
154
 
141
155
  // ---------------------------------------------------------------------------
@@ -245,6 +259,47 @@ function logViaDeps(deps: PollerDeps, ...args: unknown[]): void {
245
259
  else console.error(...args)
246
260
  }
247
261
 
262
+ /**
263
+ * SR-V-2.3 row-decision emitter. Builds a `cscb.poller.row_decision` event
264
+ * with the canonical envelope fields and the action identifier. `request_token`
265
+ * is omitted (not empty) when absent per SR-V-1.1.
266
+ */
267
+ function emitRowDecision(
268
+ deps: PollerDeps,
269
+ action: RowDecisionAction,
270
+ claudeInstanceId: string,
271
+ requestToken: string | undefined,
272
+ ): void {
273
+ const emit = deps.emitTrail ?? defaultEmitTrail
274
+ const event: Omit<TrailEventBase, 'ts'> & { [extra: string]: unknown } = {
275
+ event: 'cscb.poller.row_decision',
276
+ claude_instance_id: claudeInstanceId,
277
+ action,
278
+ }
279
+ if (requestToken !== undefined) event.request_token = requestToken
280
+ emit(event)
281
+ }
282
+
283
+ /**
284
+ * Map an unknown Slack error to a stable error-class string per SR-V-2.4.
285
+ * Slack platform errors expose `data.error` (e.g. `channel_not_found`); other
286
+ * errors collapse to short class labels.
287
+ */
288
+ function classifySlackError(err: unknown): string {
289
+ if (err !== null && typeof err === 'object') {
290
+ const data = (err as { data?: unknown }).data
291
+ if (data !== null && typeof data === 'object') {
292
+ const e = (data as { error?: unknown }).error
293
+ if (typeof e === 'string' && e.length > 0) return e
294
+ }
295
+ }
296
+ if (err instanceof Error) {
297
+ if (err.name === 'AbortError') return 'aborted'
298
+ return 'network_error'
299
+ }
300
+ return 'unknown_error'
301
+ }
302
+
248
303
  async function runTick(deps: PollerDeps): Promise<void> {
249
304
  if (tickInFlight) {
250
305
  skippedTicks++
@@ -284,13 +339,19 @@ async function runTick(deps: PollerDeps): Promise<void> {
284
339
  if (got.permission_requests === null || got.permission_requests === undefined) {
285
340
  logViaDeps(deps, `[slack] permission-poller: non-conforming open-rows response for ${row.claude_instance_id} — skipping`)
286
341
  nonConformingInstanceIds.add(row.claude_instance_id)
342
+ // SR-V-2.3: request_token omitted (no row was readable) per SR-V-1.1.
343
+ emitRowDecision(deps, 'non_conforming_skipped', row.claude_instance_id, undefined)
287
344
  continue
288
345
  }
289
346
 
290
347
  for (const perm of got.permission_requests) {
291
348
  const key = makeCompositeKey(row.claude_instance_id, perm.request_token)
292
349
  seenComposite.add(key)
293
- if (livePermissions.has(key)) continue
350
+ if (livePermissions.has(key)) {
351
+ emitRowDecision(deps, 'already_tracked', row.claude_instance_id, perm.request_token)
352
+ continue
353
+ }
354
+ emitRowDecision(deps, 'post_attempted', row.claude_instance_id, perm.request_token)
294
355
  await postPermissionPrompt(deps, row, perm)
295
356
  }
296
357
  }
@@ -311,6 +372,7 @@ async function runTick(deps: PollerDeps): Promise<void> {
311
372
  } catch (err) {
312
373
  if (isErrPermissionRequestNotFound(err)) {
313
374
  logViaDeps(deps, `[slack] permission-poller: get-permission not-found for ${entry.claudeInstanceId} token=${entry.requestToken} — generic deny + drop`)
375
+ emitRowDecision(deps, 'not_found_generic_deny', entry.claudeInstanceId, entry.requestToken)
314
376
  await renderClosureUpdate(deps, entry, 'not_found')
315
377
  dropPermission(entry.claudeInstanceId, entry.requestToken)
316
378
  continue
@@ -318,6 +380,7 @@ async function runTick(deps: PollerDeps): Promise<void> {
318
380
  const e = err instanceof AgentDirectorError ? err : null
319
381
  logViaDeps(deps, `[slack] permission-poller: get-permission failed for ${entry.claudeInstanceId} token=${entry.requestToken}: ${e?.errName ?? String(err)}`)
320
382
  // SR-2.4 transient retry: leave entry alive; next tick will retry.
383
+ emitRowDecision(deps, 'transient_retry', entry.claudeInstanceId, entry.requestToken)
321
384
  continue
322
385
  }
323
386
 
@@ -325,6 +388,7 @@ async function runTick(deps: PollerDeps): Promise<void> {
325
388
  if (verdict === 'unknown') {
326
389
  logViaDeps(deps, `[slack] permission-poller: unknown verdict for ${entry.claudeInstanceId} token=${entry.requestToken} decision=${info.decision} decision_reason=${String(info.decision_reason)} — fail-closed generic deny`)
327
390
  }
391
+ emitRowDecision(deps, 'reconciled_closed', entry.claudeInstanceId, entry.requestToken)
328
392
  await renderClosureUpdate(deps, entry, verdict)
329
393
  dropPermission(entry.claudeInstanceId, entry.requestToken)
330
394
  }
@@ -364,13 +428,27 @@ async function postPermissionPrompt(
364
428
  permission.request_token,
365
429
  )
366
430
 
431
+ const text = `🤖🛠️ permission request: ${permission.tool_name}`
432
+ const emit = deps.emitTrail ?? defaultEmitTrail
367
433
  try {
368
434
  const response = await deps.web.chat.postMessage({
369
435
  channel: channelId,
370
- text: `🤖🛠️ permission request: ${permission.tool_name}`,
436
+ text,
371
437
  blocks: blocks as never,
372
438
  })
373
439
  const messageTs = (response as { ts?: string }).ts
440
+ // SR-V-2.4: emit on success (and on the no-ts edge case below) with the
441
+ // Slack-returned ts. Full text + blocks pass through verbatim (SR-V-3.1).
442
+ emit({
443
+ event: 'cscb.chat_post.attempted',
444
+ claude_instance_id: row.claude_instance_id,
445
+ request_token: permission.request_token,
446
+ channel: channelId,
447
+ text,
448
+ blocks,
449
+ ok: true,
450
+ slack_ts: messageTs,
451
+ })
374
452
  if (!messageTs) {
375
453
  logViaDeps(deps, `[slack] permission-poller: chat.postMessage returned no ts for ${row.claude_instance_id}`)
376
454
  return
@@ -384,7 +462,20 @@ async function postPermissionPrompt(
384
462
  handled: false,
385
463
  })
386
464
  } catch (err) {
465
+ // b.emk: failures land in BOTH server.log (real-time visibility) AND the
466
+ // trail JSONL (after-the-fact debugging). Success paths stay trail-only,
467
+ // preserving the SR-V-2.4 asymmetric-behavior fix.
387
468
  logViaDeps(deps, `[slack] permission-poller: chat.postMessage failed for ${row.claude_instance_id}:`, err)
469
+ emit({
470
+ event: 'cscb.chat_post.attempted',
471
+ claude_instance_id: row.claude_instance_id,
472
+ request_token: permission.request_token,
473
+ channel: channelId,
474
+ text,
475
+ blocks,
476
+ ok: false,
477
+ error: classifySlackError(err),
478
+ })
388
479
  }
389
480
  }
390
481
 
@@ -472,6 +563,18 @@ async function renderClosureUpdate(
472
563
  tag: VerdictTag,
473
564
  ): Promise<void> {
474
565
  const { text, blocks } = buildVerdictRendering(tag)
566
+ const emit = deps.emitTrail ?? defaultEmitTrail
567
+ const envelope = {
568
+ event: 'cscb.chat_update.attempted',
569
+ claude_instance_id: entry.claudeInstanceId,
570
+ request_token: entry.requestToken,
571
+ channel: entry.channelId,
572
+ message_ts: entry.messageTs,
573
+ text,
574
+ blocks,
575
+ verdict_tag: tag as ClosureVerdictTag,
576
+ triggered_by: 'poller' as const,
577
+ }
475
578
  try {
476
579
  await deps.web.chat.update({
477
580
  channel: entry.channelId,
@@ -479,8 +582,11 @@ async function renderClosureUpdate(
479
582
  text,
480
583
  blocks: blocks as never,
481
584
  })
585
+ emit({ ...envelope, ok: true })
482
586
  } catch (err) {
587
+ // b.emk: failures land in BOTH server.log and the trail JSONL.
483
588
  logViaDeps(deps, `[slack] permission-poller: closure chat.update failed for ${entry.claudeInstanceId} token=${entry.requestToken} (verdict=${tag}):`, err)
589
+ emit({ ...envelope, ok: false, error: classifySlackError(err) })
484
590
  }
485
591
  }
486
592
 
@@ -0,0 +1,234 @@
1
+ /**
2
+ * permission-trail.ts — Append-only JSONL event store for the CSCB-side
3
+ * visibility trail of the AD↔CSCB tool-permission relay (SRD t1.cdb.4g).
4
+ *
5
+ * One JSON object per newline-terminated line in
6
+ * <SLACK_STATE_DIR>/permission-trail.jsonl
7
+ * (default: ~/.claude/channels/slack/permission-trail.jsonl).
8
+ *
9
+ * Public API:
10
+ * - TrailEvent — enforced line schema
11
+ * - emitTrail(partial) — auto-stamps `ts`, recommended call site API
12
+ * - emitTrailEvent(event) — raw, accepts caller-supplied `ts`
13
+ * - _resetTrailFdForTests() — test-only fd reset
14
+ *
15
+ * The emitter is the single enforcement point for SR-V-1 (correlation
16
+ * envelope), SR-V-3 (no truncation in the canonical store), and SR-V-4.5
17
+ * (RFC 3339 sub-second `ts`, stable hierarchical `event` identifier).
18
+ *
19
+ * SPDX-License-Identifier: MIT
20
+ */
21
+
22
+ import { mkdirSync, openSync, writeSync } from 'node:fs'
23
+ import { homedir } from 'node:os'
24
+ import { dirname, join, resolve } from 'node:path'
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Line schema
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Required envelope fields enforced by the type system on every emitted line.
32
+ * `event` is a stable hierarchical identifier in the `cscb.*` namespace
33
+ * (SR-V-4.5). `ts` is RFC 3339 with at least millisecond precision
34
+ * (SR-V-4.3, SR-V-4.5).
35
+ */
36
+ export interface TrailEventBase {
37
+ ts: string
38
+ event: string
39
+ /** SR-V-1.1: AD-minted UUID, absent for events before token assignment (SR-V-1.2). */
40
+ request_token?: string
41
+ /** SR-V-1.2: present whenever known. */
42
+ claude_instance_id?: string
43
+ /** SR-V-1.3: Slack channel id for events that touch Slack. */
44
+ channel?: string
45
+ /** SR-V-1.3: Slack message ts for events that touch Slack. */
46
+ message_ts?: string
47
+ }
48
+
49
+ /**
50
+ * One emitted JSONL line. Extends the required envelope with an open index
51
+ * signature so per-event-class fields pass through verbatim (no truncation
52
+ * per SR-V-3.1) without a cast at the call site.
53
+ */
54
+ export type TrailEvent = TrailEventBase & { [extra: string]: unknown }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Per-event-class field types
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * The six canonical row-decision identifiers used as the `action` field on
62
+ * `cscb.poller.row_decision` events (SR-V-2.3). Exported for type-narrowing
63
+ * at the emission site.
64
+ *
65
+ * The set is open to extension per SR-V-2.3: the `(string & {})` widening
66
+ * keeps existing literals discoverable in editor completions while still
67
+ * accepting new identifiers without a type change.
68
+ */
69
+ export const ROW_DECISION_ACTIONS = [
70
+ 'post_attempted',
71
+ 'already_tracked',
72
+ 'reconciled_closed',
73
+ 'not_found_generic_deny',
74
+ 'non_conforming_skipped',
75
+ 'transient_retry',
76
+ ] as const
77
+
78
+ export type CanonicalRowDecisionAction = (typeof ROW_DECISION_ACTIONS)[number]
79
+
80
+ /** Open string-literal union per SR-V-2.3 — canonical values plus any new identifier. */
81
+ // eslint-disable-next-line @typescript-eslint/ban-types
82
+ export type RowDecisionAction = CanonicalRowDecisionAction | (string & {})
83
+
84
+ /**
85
+ * The 8 canonical verdict tags carried on `cscb.chat_update.attempted` events
86
+ * (SR-V-2.5). 6 are emitted by the poller's `renderClosureUpdate`; 2 are
87
+ * emitted by the click handler's verdict-render path. Open to extension.
88
+ */
89
+ export const CLOSURE_VERDICT_TAGS = [
90
+ 'operator_allow',
91
+ 'operator_deny',
92
+ 'timeout',
93
+ 'find_missing',
94
+ 'unknown',
95
+ 'not_found',
96
+ 'click_handler_allow',
97
+ 'click_handler_deny',
98
+ ] as const
99
+
100
+ export type CanonicalClosureVerdictTag = (typeof CLOSURE_VERDICT_TAGS)[number]
101
+
102
+ // eslint-disable-next-line @typescript-eslint/ban-types
103
+ export type ClosureVerdictTag = CanonicalClosureVerdictTag | (string & {})
104
+
105
+ /** Which surface triggered a closure `chat.update` (SR-V-2.5). Open to extension. */
106
+ // eslint-disable-next-line @typescript-eslint/ban-types
107
+ export type TriggeredBy = 'poller' | 'click_handler' | (string & {})
108
+
109
+ /**
110
+ * Stable reasons a `cscb.block_action.received` event records when the
111
+ * inbound `action_id` failed `parsePermissionActionId` (SR-V-2.9). Open to
112
+ * extension. `stale_prompt` is intentionally NOT in this set — stale clicks
113
+ * decode fine and show up as `cscb.click_handler.invoked{live_pending:false}`.
114
+ */
115
+ export const PARSE_FAILURE_REASONS = [
116
+ 'foreign_action_id',
117
+ 'malformed_token',
118
+ ] as const
119
+
120
+ export type CanonicalParseFailureReason = (typeof PARSE_FAILURE_REASONS)[number]
121
+
122
+ // eslint-disable-next-line @typescript-eslint/ban-types
123
+ export type ParseFailureReason = CanonicalParseFailureReason | (string & {})
124
+
125
+ /** Decoded permission decision carried on inbound click events (SR-V-2.6 / SR-V-2.9). */
126
+ export type ClickDecision = 'allow' | 'deny'
127
+
128
+ /**
129
+ * Classification of an agent-director `decide` response on the CSCB call
130
+ * side (SR-V-2.7). The four named error classes match the AD error
131
+ * identifiers in `src/agent-director-errors.ts`. `"other"` is the catch-all
132
+ * for any other thrown value; the emit additionally carries a
133
+ * `raw_error_message` string in that case. Open extension allowed.
134
+ */
135
+ export const AD_DECIDE_RESPONSE_CLASSES = [
136
+ 'ok',
137
+ 'ErrAlreadyDecided',
138
+ 'ErrInvalidFlags',
139
+ 'ErrAmbiguousRequest',
140
+ 'other',
141
+ ] as const
142
+
143
+ export type CanonicalAdDecideResponseClass = (typeof AD_DECIDE_RESPONSE_CLASSES)[number]
144
+
145
+ // eslint-disable-next-line @typescript-eslint/ban-types
146
+ export type AdDecideResponseClass = CanonicalAdDecideResponseClass | (string & {})
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // State-dir + path resolution
150
+ // ---------------------------------------------------------------------------
151
+
152
+ const TRAIL_FILENAME = 'permission-trail.jsonl'
153
+
154
+ function resolveStateDir(): string {
155
+ const fromEnv = process.env['SLACK_STATE_DIR']
156
+ return fromEnv ? resolve(fromEnv) : join(homedir(), '.claude', 'channels', 'slack')
157
+ }
158
+
159
+ function resolveTrailPath(): string {
160
+ return join(resolveStateDir(), TRAIL_FILENAME)
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Lazy-open module-scoped fd (mirrors src/logging.ts)
165
+ // ---------------------------------------------------------------------------
166
+
167
+ let fd: number | null = null
168
+
169
+ /**
170
+ * Test-only: reset the module-scoped fd so a subsequent emit reopens the file
171
+ * (typically against a fresh `SLACK_STATE_DIR` temp directory). Mirrors the
172
+ * `resetClientForTests` pattern in `src/agent-director-client.ts`.
173
+ *
174
+ * @internal
175
+ */
176
+ export function _resetTrailFdForTests(): void {
177
+ fd = null
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Emission
182
+ // ---------------------------------------------------------------------------
183
+
184
+ const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3,}Z$/
185
+
186
+ function isValidTs(ts: unknown): ts is string {
187
+ return typeof ts === 'string' && ts.trim() !== '' && ISO_TIMESTAMP_RE.test(ts)
188
+ }
189
+
190
+ /**
191
+ * Append one fully-formed `TrailEvent` to the trail file. Use this when the
192
+ * caller supplies its own `ts` (e.g. test fixtures replaying historical
193
+ * events). Most production call sites should prefer `emitTrail`.
194
+ *
195
+ * Defensive guard: if `ts` is missing, empty, or not RFC 3339 with ms
196
+ * precision, substitute `new Date().toISOString()` and log a `[slack]`
197
+ * warning. Silently corrupting a trail line is worse than logging.
198
+ *
199
+ * Never throws. Write failures are caught and logged to stderr with the
200
+ * `[slack]` prefix per engineering-guide convention.
201
+ */
202
+ export function emitTrailEvent(event: TrailEvent): void {
203
+ let toWrite: TrailEvent = event
204
+ if (!isValidTs(event.ts)) {
205
+ const stamped = new Date().toISOString()
206
+ console.error(
207
+ `[slack] permission-trail: missing/invalid ts on event=${String(event.event)} — substituting ${stamped}`,
208
+ )
209
+ toWrite = { ...event, ts: stamped }
210
+ }
211
+
212
+ try {
213
+ if (fd === null) {
214
+ const path = resolveTrailPath()
215
+ mkdirSync(dirname(path), { recursive: true })
216
+ fd = openSync(path, 'a')
217
+ }
218
+ const line = JSON.stringify(toWrite) + '\n'
219
+ writeSync(fd, line)
220
+ } catch (err) {
221
+ console.error('[slack] permission-trail: write failed', err)
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Recommended call-site API for Epics 2–5: stamps `ts` from
227
+ * `new Date().toISOString()` (RFC 3339 with ms precision) and delegates to
228
+ * `emitTrailEvent`. Callers pass everything except `ts`.
229
+ */
230
+ export function emitTrail(
231
+ partial: Omit<TrailEventBase, 'ts'> & { [extra: string]: unknown },
232
+ ): void {
233
+ emitTrailEvent({ ...partial, ts: new Date().toISOString() })
234
+ }
package/src/server.ts CHANGED
@@ -55,7 +55,10 @@ import {
55
55
  import { cleanSession, getCozempicAvailable } from './cozempic.ts'
56
56
  import { ErrSpawnNotFound } from 'agent-director'
57
57
  import { getClient, closeClient } from './agent-director-client.ts'
58
- import { handlePermissionClick } from './permission-click-handler.ts'
58
+ import {
59
+ emitBlockActionReceived,
60
+ handlePermissionClick,
61
+ } from './permission-click-handler.ts'
59
62
  import { trustBootstrap } from './trust-bootstrap.ts'
60
63
  import { startPermissionPoller, stopPermissionPoller } from './permission-poller.ts'
61
64
  import {
@@ -734,12 +737,28 @@ socket.on('interactive', async (evt) => {
734
737
  const { ack } = evt as { ack: () => Promise<void> }
735
738
  const p = ((evt as any).body ?? (evt as any).payload ?? evt) as Record<string, unknown>
736
739
  const actions = (Array.isArray(p['actions']) ? p['actions'] : []) as Array<{ action_id: string }>
740
+ // SR-V-2.6 / SR-V-2.9 envelope: pull the inbound channel / message ts /
741
+ // clicking user once per payload — all actions in this payload share them.
742
+ const channelId = ((p['channel'] as { id?: string } | undefined)?.id) ?? undefined
743
+ const messageTs = ((p['message'] as { ts?: string } | undefined)?.ts) ?? undefined
744
+ const userId = ((p['user'] as { id?: string } | undefined)?.id) ?? undefined
745
+
737
746
  for (const action of actions) {
738
747
  const actionId = action.action_id
739
- const handled = await handlePermissionClick(actionId, {
740
- getClient,
741
- web,
748
+ // SR-V-2.9: emit cscb.block_action.received for every action regardless
749
+ // of whether handlePermissionClick decides to engage. Decode-failure
750
+ // cases are diagnostically critical for "I clicked and nothing happened".
751
+ emitBlockActionReceived(actionId, {
752
+ channel: channelId,
753
+ messageTs,
754
+ user: userId,
742
755
  })
756
+
757
+ const handled = await handlePermissionClick(
758
+ actionId,
759
+ { getClient, web },
760
+ { channel: channelId, messageTs, user: userId },
761
+ )
743
762
  if (handled) {
744
763
  await ack()
745
764
  return