typeclaw 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -123,14 +123,20 @@ export const PARTICIPANTS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
123
123
  type PlatformInfo = {
124
124
  displayName: string
125
125
  mentionMode: 'angle-id' | 'at-username' | 'alias'
126
+ // Whether this adapter registers a ReactionCallback, i.e. whether
127
+ // `channel_react` actually does anything here. Gates the proactive-reaction
128
+ // prompt guidance so we never tell a KakaoTalk/Telegram agent to react when
129
+ // the call would no-op. Keep in sync with the adapters that call
130
+ // `router.registerReaction` (github, slack-bot, discord-bot today).
131
+ supportsReactions: boolean
126
132
  }
127
133
 
128
134
  const PLATFORM_INFO: Record<AdapterId, PlatformInfo> = {
129
- 'slack-bot': { displayName: 'Slack', mentionMode: 'angle-id' },
130
- 'discord-bot': { displayName: 'Discord', mentionMode: 'angle-id' },
131
- github: { displayName: 'GitHub', mentionMode: 'at-username' },
132
- 'telegram-bot': { displayName: 'Telegram', mentionMode: 'at-username' },
133
- kakaotalk: { displayName: 'KakaoTalk', mentionMode: 'alias' },
135
+ 'slack-bot': { displayName: 'Slack', mentionMode: 'angle-id', supportsReactions: true },
136
+ 'discord-bot': { displayName: 'Discord', mentionMode: 'angle-id', supportsReactions: true },
137
+ github: { displayName: 'GitHub', mentionMode: 'at-username', supportsReactions: true },
138
+ 'telegram-bot': { displayName: 'Telegram', mentionMode: 'at-username', supportsReactions: false },
139
+ kakaotalk: { displayName: 'KakaoTalk', mentionMode: 'alias', supportsReactions: false },
134
140
  }
135
141
 
136
142
  function getPlatformInfo(adapter: AdapterId): PlatformInfo {
@@ -348,6 +354,23 @@ function renderChannelOrigin(
348
354
  const conversationLine = renderConversationLine(origin)
349
355
  if (conversationLine !== null) lines.push('', conversationLine)
350
356
 
357
+ if (platformInfo.supportsReactions) {
358
+ lines.push(
359
+ '',
360
+ '**React like a teammate would.** You can drop an emoji on the message that',
361
+ 'triggered this turn with `channel_react({ emoji })` — it posts no comment,',
362
+ 'just a reaction. Read the message and pick what genuinely fits its tone:',
363
+ '`+1` to agree or approve, `rocket` for something shipping or exciting,',
364
+ '`tada` to celebrate, `heart` to show appreciation, `laugh` for something',
365
+ 'funny, `eyes` to signal you are looking. Reach for it when a reaction adds',
366
+ 'real warmth or signal — not on every message, and not just because you can.',
367
+ 'A reaction does NOT satisfy the reply obligation below: when the message',
368
+ 'needs a substantive answer, still send it via `channel_reply`. Think of',
369
+ 'reactions as the lightweight, human layer on top of your words, not a',
370
+ 'replacement for them.',
371
+ )
372
+ }
373
+
351
374
  lines.push(
352
375
  '',
353
376
  '**For every user message in this session, you MUST call `channel_reply`',
@@ -16,6 +16,7 @@ export type CompletionReminderArgs = {
16
16
  durationMs: number
17
17
  error?: string
18
18
  channel?: boolean
19
+ adapter?: string
19
20
  }
20
21
 
21
22
  const CHANNEL_REPLY_NUDGE =
@@ -28,9 +29,23 @@ const CHANNEL_REPLY_NUDGE =
28
29
  'can see why the post-completion turn was silent. `NO_REPLY` is the legacy fallback only when ' +
29
30
  '`skip_response` is unavailable.'
30
31
 
32
+ // Conditional carve-out for github channel sessions. The base nudge above
33
+ // steers EVERY completion to `channel_reply`, which on github is a plain PR
34
+ // comment — fine for most subagents, but wrong for a finished `reviewer`:
35
+ // a verdict delivered as a comment leaves the PR "awaiting review" with no
36
+ // formal approval. This reminder cannot tell a review turn from any other
37
+ // completion, so the carve-out is phrased conditionally ("if this was a PR
38
+ // review") to redirect only the review case without misleading the rest.
39
+ const GITHUB_REVIEW_NUDGE =
40
+ 'If this was a PR review, the verdict is a formal review, not a `channel_reply`: ' +
41
+ 'post it via `gh api -X POST /repos/owner/repo/pulls/<N>/reviews` (APPROVE/REQUEST_CHANGES/COMMENT) ' +
42
+ 'and end the turn with `skip_response`. A `channel_reply` that merely says "Approved" posts a ' +
43
+ 'comment and leaves the PR awaiting review.'
44
+
31
45
  export function renderSubagentCompletionReminder(args: CompletionReminderArgs): string {
32
46
  const durationStr = formatReminderDuration(args.durationMs)
33
- const channelTail = args.channel === true ? ` ${CHANNEL_REPLY_NUDGE}` : ''
47
+ const githubTail = args.channel === true && args.adapter === 'github' ? ` ${GITHUB_REVIEW_NUDGE}` : ''
48
+ const channelTail = args.channel === true ? ` ${CHANNEL_REPLY_NUDGE}${githubTail}` : ''
34
49
  if (args.ok) {
35
50
  return (
36
51
  `<system-reminder>\n` +
@@ -31,12 +31,19 @@ export function createChannelReactTool({
31
31
  name: 'channel_react',
32
32
  label: 'Channel React',
33
33
  description:
34
- 'Add an emoji reaction to the message that triggered this turn a lightweight acknowledgment that does not post a comment. ' +
35
- 'On GitHub this reacts to the triggering issue/PR/comment (e.g. :eyes: to signal "I am looking at this"). ' +
36
- 'Use this instead of a textual "on it" reply when a reaction is enough. Pass the bare emoji name, no colons.',
34
+ 'React to the message that triggered this turn with an emoji that fits its content or tone — a lightweight, ' +
35
+ 'human touch that posts no comment. Works on GitHub (issue/PR/comment), Slack, and Discord. ' +
36
+ 'Pick the reaction a thoughtful teammate would leave: :+1: to agree or approve, :rocket: for something ' +
37
+ 'shipping or exciting, :tada: to celebrate, :heart: to show appreciation, :eyes: to signal "I am looking at this", ' +
38
+ ':laugh: for something funny. Use it when a reaction adds genuine social signal — not on every message. ' +
39
+ 'A reaction does NOT replace `channel_reply`: when the message needs a substantive answer, still send one. ' +
40
+ 'If a reaction alone is the whole response, call `skip_response` afterward so the turn ends cleanly. ' +
41
+ 'Pass the bare emoji name, no colons.',
37
42
  parameters: Type.Object({
38
43
  emoji: Type.String({
39
- description: 'Bare emoji name, no surrounding colons. e.g. "eyes", "+1", "rocket", "heart".',
44
+ description:
45
+ 'Bare emoji name, no surrounding colons. Choose one that matches the message: ' +
46
+ 'e.g. "+1", "rocket", "tada", "heart", "eyes", "laugh".',
40
47
  minLength: 1,
41
48
  }),
42
49
  }),
@@ -8,6 +8,8 @@ import type {
8
8
  import type { ChannelAdapterConfig } from '@/channels/schema'
9
9
  import type { InboundAttachment, InboundMessage } from '@/channels/types'
10
10
 
11
+ import { encodeDiscordReactionRef } from './discord-bot-reactions'
12
+
11
13
  export type InboundDropReason =
12
14
  | 'self_author' // event.author.id === botUserId; we never route our own messages back to ourselves
13
15
  | 'empty_content' // SDK delivered content: '' — usually missing MessageContent intent
@@ -87,6 +89,7 @@ export function classifyInbound(
87
89
  text,
88
90
  ...(attachments.length > 0 ? { attachments } : {}),
89
91
  externalMessageId: event.id,
92
+ reactionRef: encodeDiscordReactionRef({ channel: event.channel_id, message: event.id }),
90
93
  authorId: event.author.id,
91
94
  // Discord's post-2023 username system allows pure-numeric handles (e.g.
92
95
  // "1411531"); the human-facing display name lives on `global_name`. The
@@ -0,0 +1,164 @@
1
+ import type { DiscordBotClient } from 'agent-messenger/discordbot'
2
+
3
+ import type {
4
+ ReactionCallback,
5
+ ReactionErrorCode,
6
+ ReactionRef,
7
+ ReactionResult,
8
+ RemoveReactionCallback,
9
+ } from '@/channels/types'
10
+
11
+ // The reactable target on Discord: a message is addressed by its channel id
12
+ // plus the message id. The classifier stamps this because both values are on
13
+ // the gateway event but `chat`/`externalMessageId` are the only ones that
14
+ // survive into the routed payload — keeping them paired in an opaque ref means
15
+ // the router/tool never have to reassemble Discord's addressing.
16
+ export type DiscordReactionTarget = { channel: string; message: string }
17
+
18
+ // `RemoveReactionRequest` carries no emoji (the router only round-trips the
19
+ // success ref), so removal needs the resolved unicode folded into the ref:
20
+ // Discord's DELETE is keyed by (channel, message, emoji). Mirrors Slack's
21
+ // removal-ref pattern; GitHub instead carries a per-reaction id.
22
+ export type DiscordReactionRemovalTarget = { channel: string; message: string; emoji: string }
23
+
24
+ export function encodeDiscordReactionRef(target: DiscordReactionTarget): ReactionRef {
25
+ return { adapter: 'discord-bot', value: JSON.stringify(target) }
26
+ }
27
+
28
+ export function decodeDiscordReactionRef(ref: ReactionRef): DiscordReactionTarget | null {
29
+ if (ref.adapter !== 'discord-bot') return null
30
+ const parsed = parseRecord(ref.value)
31
+ if (parsed === null || parsed.op !== undefined) return null
32
+ const channel = typeof parsed.channel === 'string' ? parsed.channel : null
33
+ const message = typeof parsed.message === 'string' ? parsed.message : null
34
+ if (channel === null || message === null) return null
35
+ return { channel, message }
36
+ }
37
+
38
+ export function encodeDiscordRemovalRef(target: DiscordReactionRemovalTarget): ReactionRef {
39
+ return { adapter: 'discord-bot', value: JSON.stringify({ op: 'remove', ...target }) }
40
+ }
41
+
42
+ export function decodeDiscordRemovalRef(ref: ReactionRef): DiscordReactionRemovalTarget | null {
43
+ if (ref.adapter !== 'discord-bot') return null
44
+ const parsed = parseRecord(ref.value)
45
+ if (parsed === null || parsed.op !== 'remove') return null
46
+ const channel = typeof parsed.channel === 'string' ? parsed.channel : null
47
+ const message = typeof parsed.message === 'string' ? parsed.message : null
48
+ const emoji = typeof parsed.emoji === 'string' ? parsed.emoji : null
49
+ if (channel === null || message === null || emoji === null) return null
50
+ return { channel, message, emoji }
51
+ }
52
+
53
+ // Discord's reaction endpoint takes the literal unicode emoji (URL-encoded by
54
+ // the SDK), NOT a `:name:` shortcode — the agent passes adapter-generic bare
55
+ // names (`+1`, `rocket`), so we translate here. The set mirrors GitHub's fixed
56
+ // reaction vocabulary so the same `channel_react({ emoji })` call works on both
57
+ // platforms, plus a few extra chat-native acks. An unmapped name is reported as
58
+ // `unsupported` rather than forwarded, so a typo gets a clear signal instead of
59
+ // Discord's opaque `10014 Unknown Emoji`.
60
+ const EMOJI_UNICODE: Record<string, string> = {
61
+ eyes: '👀',
62
+ '+1': '👍',
63
+ thumbsup: '👍',
64
+ '-1': '👎',
65
+ thumbsdown: '👎',
66
+ laugh: '😄',
67
+ hooray: '🎉',
68
+ tada: '🎉',
69
+ confused: '😕',
70
+ heart: '❤️',
71
+ rocket: '🚀',
72
+ white_check_mark: '✅',
73
+ 'white-check-mark': '✅',
74
+ check: '✅',
75
+ fire: '🔥',
76
+ eye: '👁️',
77
+ raised_hands: '🙌',
78
+ }
79
+
80
+ function resolveEmoji(emoji: string): string | null {
81
+ const name = emoji.replace(/^:|:$/g, '')
82
+ return EMOJI_UNICODE[name] ?? null
83
+ }
84
+
85
+ export function createDiscordReactionCallback(deps: {
86
+ client: Pick<DiscordBotClient, 'addReaction'>
87
+ }): ReactionCallback {
88
+ return async (req): Promise<ReactionResult> => {
89
+ if (req.adapter !== 'discord-bot') {
90
+ return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
91
+ }
92
+ const unicode = resolveEmoji(req.emoji)
93
+ if (unicode === null) {
94
+ return { ok: false, error: `discord does not support reaction "${req.emoji}"`, code: 'unsupported' }
95
+ }
96
+ const target = decodeDiscordReactionRef(req.reactionRef)
97
+ if (target === null) return { ok: false, error: 'unparseable discord reaction ref', code: 'unsupported' }
98
+ try {
99
+ await deps.client.addReaction(target.channel, target.message, unicode)
100
+ } catch (err) {
101
+ return { ok: false, error: describe(err), code: classifyDiscordError(err) }
102
+ }
103
+ return { ok: true, reactionRef: encodeDiscordRemovalRef({ ...target, emoji: unicode }) }
104
+ }
105
+ }
106
+
107
+ export function createDiscordRemoveReactionCallback(deps: {
108
+ client: Pick<DiscordBotClient, 'removeReaction'>
109
+ }): RemoveReactionCallback {
110
+ return async (req): Promise<ReactionResult> => {
111
+ if (req.adapter !== 'discord-bot') {
112
+ return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
113
+ }
114
+ const target = decodeDiscordRemovalRef(req.reactionRef)
115
+ if (target === null) return { ok: false, error: 'unparseable discord reaction removal ref', code: 'unsupported' }
116
+ try {
117
+ await deps.client.removeReaction(target.channel, target.message, target.emoji)
118
+ } catch (err) {
119
+ return { ok: false, error: describe(err), code: classifyDiscordError(err) }
120
+ }
121
+ return { ok: true }
122
+ }
123
+ }
124
+
125
+ // DiscordBotError exposes the Discord error code on `.code` as a string. We
126
+ // read it structurally so a wrapped/re-thrown error still classifies. The
127
+ // numeric codes are Discord's documented JSON error codes; `http_4xx` is the
128
+ // SDK's fallback when no JSON code is present.
129
+ function classifyDiscordError(err: unknown): ReactionErrorCode {
130
+ const code = typeof err === 'object' && err !== null && 'code' in err ? String((err as { code: unknown }).code) : ''
131
+ switch (code) {
132
+ case '10003': // Unknown Channel
133
+ case '10008': // Unknown Message
134
+ case 'http_404':
135
+ return 'not-found'
136
+ case '50001': // Missing Access
137
+ case '50013': // Missing Permissions
138
+ case 'http_403':
139
+ case 'http_401':
140
+ return 'permission-denied'
141
+ case '10014': // Unknown Emoji
142
+ return 'unsupported'
143
+ case 'http_429':
144
+ return 'rate-limited'
145
+ default:
146
+ return 'transient'
147
+ }
148
+ }
149
+
150
+ function parseRecord(value: string): Record<string, unknown> | null {
151
+ let parsed: unknown
152
+ try {
153
+ parsed = JSON.parse(value)
154
+ } catch {
155
+ return null
156
+ }
157
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
158
+ ? (parsed as Record<string, unknown>)
159
+ : null
160
+ }
161
+
162
+ function describe(err: unknown): string {
163
+ return err instanceof Error ? err.message : String(err)
164
+ }
@@ -39,6 +39,7 @@ import {
39
39
  type InboundDropReason,
40
40
  renderPlaceholder,
41
41
  } from './discord-bot-classify'
42
+ import { createDiscordReactionCallback, createDiscordRemoveReactionCallback } from './discord-bot-reactions'
42
43
  import { enrichDiscordMessageReferences } from './discord-bot-reference'
43
44
  import {
44
45
  ackInteraction,
@@ -863,6 +864,9 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
863
864
 
864
865
  const fetchAttachmentCallback = createFetchAttachmentCallback({ token: options.token, logger })
865
866
 
867
+ const reactionCallback = createDiscordReactionCallback({ client })
868
+ const removeReactionCallback = createDiscordRemoveReactionCallback({ client })
869
+
866
870
  const interactionHandler = createInteractionHandler({
867
871
  router: options.router,
868
872
  knownCommandNames: SLASH_COMMAND_NAMES,
@@ -999,6 +1003,8 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
999
1003
  })
1000
1004
 
1001
1005
  options.router.registerOutbound('discord-bot', outboundCallback)
1006
+ options.router.registerReaction('discord-bot', reactionCallback)
1007
+ options.router.registerRemoveReaction('discord-bot', removeReactionCallback)
1002
1008
  options.router.registerTyping('discord-bot', typingCallback)
1003
1009
  options.router.registerChannelNameResolver('discord-bot', channelResolver)
1004
1010
  options.router.registerSelfIdentity('discord-bot', selfIdentityResolver)
@@ -1009,6 +1015,21 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
1009
1015
  try {
1010
1016
  await listener.start()
1011
1017
  } catch (err) {
1018
+ // Listener failed after registration — roll back every callback so a
1019
+ // failed start leaves no router state behind (stop() returns early on
1020
+ // !started and would otherwise skip cleanup), mirroring the github
1021
+ // adapter's rollback path.
1022
+ options.router.unregisterOutbound('discord-bot', outboundCallback)
1023
+ options.router.unregisterReaction('discord-bot', reactionCallback)
1024
+ options.router.unregisterRemoveReaction('discord-bot', removeReactionCallback)
1025
+ options.router.unregisterTyping('discord-bot', typingCallback)
1026
+ options.router.unregisterChannelNameResolver('discord-bot', channelResolver)
1027
+ options.router.unregisterSelfIdentity('discord-bot', selfIdentityResolver)
1028
+ options.router.unregisterHistory('discord-bot', historyCallback)
1029
+ options.router.unregisterFetchAttachment('discord-bot', fetchAttachmentCallback)
1030
+ options.router.unregisterMembership('discord-bot', membershipResolver)
1031
+ listener = null
1032
+ botUserId = null
1012
1033
  started = false
1013
1034
  logger.error(`[discord-bot] listener start failed: ${describe(err)}`)
1014
1035
  throw err
@@ -1019,6 +1040,8 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
1019
1040
  if (!started) return
1020
1041
  started = false
1021
1042
  options.router.unregisterOutbound('discord-bot', outboundCallback)
1043
+ options.router.unregisterReaction('discord-bot', reactionCallback)
1044
+ options.router.unregisterRemoveReaction('discord-bot', removeReactionCallback)
1022
1045
  options.router.unregisterTyping('discord-bot', typingCallback)
1023
1046
  options.router.unregisterChannelNameResolver('discord-bot', channelResolver)
1024
1047
  options.router.unregisterSelfIdentity('discord-bot', selfIdentityResolver)
@@ -301,7 +301,12 @@ export function classifyGithubInbound(
301
301
  teamIsBotMember: options?.teamIsBotMember,
302
302
  })
303
303
  }
304
- if (action === 'opened' && reviewOn === 'opened') {
304
+ // `ready_for_review` (draft→ready) is a fresh review opportunity, so it
305
+ // reuses the `opened` paths: same review trigger, same title-only awareness
306
+ // text. The draft skip in classifyOpenedReviewTrigger is a no-op here since
307
+ // the PR is non-draft once ready — preserving "review when no longer draft".
308
+ const isOpenLike = action === 'opened' || action === 'ready_for_review'
309
+ if (isOpenLike && reviewOn === 'opened') {
305
310
  const trigger = classifyOpenedReviewTrigger({
306
311
  payload,
307
312
  pr,
@@ -314,8 +319,7 @@ export function classifyGithubInbound(
314
319
  }
315
320
  const opener = readUser(pr.user)
316
321
  const hasBody = readString(pr, 'body')?.trim() ? true : false
317
- const prText =
318
- action === 'opened' ? bodyOrOpenedTitle(pr.body, opener, 'PR', number, readString(pr, 'title')) : pr.body
322
+ const prText = isOpenLike ? bodyOrOpenedTitle(pr.body, opener, 'PR', number, readString(pr, 'title')) : pr.body
319
323
  return buildInbound(
320
324
  { ...base, chat: `pr:${number}`, thread: null },
321
325
  prText,
@@ -324,7 +328,7 @@ export function classifyGithubInbound(
324
328
  selfLogin,
325
329
  pr.created_at,
326
330
  { kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
327
- action === 'opened' && !hasBody,
331
+ isOpenLike && !hasBody,
328
332
  )
329
333
  }
330
334
 
@@ -4,6 +4,7 @@ import { matchesAnyAlias } from '@/channels/engagement'
4
4
  import type { ChannelAdapterConfig } from '@/channels/schema'
5
5
  import type { InboundAttachment, InboundMessage } from '@/channels/types'
6
6
 
7
+ import { encodeSlackReactionRef } from './slack-bot-reactions'
7
8
  import { hasSlackMessageShareAttachments } from './slack-bot-reference'
8
9
  import { slackTsToMillis } from './slack-bot-time'
9
10
 
@@ -141,6 +142,7 @@ export function classifyInbound(
141
142
  text,
142
143
  ...(attachments.length > 0 ? { attachments } : {}),
143
144
  externalMessageId: event.ts,
145
+ reactionRef: encodeSlackReactionRef({ channel: event.channel, ts: event.ts }),
144
146
  authorId: event.user,
145
147
  authorName: event.user,
146
148
  authorIsBot,
@@ -0,0 +1,167 @@
1
+ import type { SlackBotClient } from 'agent-messenger/slackbot'
2
+
3
+ import type {
4
+ ReactionCallback,
5
+ ReactionErrorCode,
6
+ ReactionRef,
7
+ ReactionResult,
8
+ RemoveReactionCallback,
9
+ } from '@/channels/types'
10
+
11
+ // The reactable target on Slack: a message is addressed by its channel id plus
12
+ // the message `ts`. The classifier stamps this because `ts` is the inbound's
13
+ // own message timestamp — the same value that becomes `externalMessageId`
14
+ // downstream, but kept in an opaque ref so the router/tool never have to know
15
+ // Slack's addressing. Mirrors the GitHub `GithubReactionTarget` precedent.
16
+ export type SlackReactionTarget = { channel: string; ts: string }
17
+
18
+ // Removal needs the emoji name too: Slack's `reactions.remove` is keyed by
19
+ // (channel, ts, name), unlike GitHub's per-reaction id. We fold the emoji that
20
+ // was added into the removal ref so `RemoveReactionCallback` can reconstruct
21
+ // the exact call without the caller tracking it.
22
+ export type SlackReactionRemovalTarget = { channel: string; ts: string; emoji: string }
23
+
24
+ export function encodeSlackReactionRef(target: SlackReactionTarget): ReactionRef {
25
+ return { adapter: 'slack-bot', value: JSON.stringify(target) }
26
+ }
27
+
28
+ export function decodeSlackReactionRef(ref: ReactionRef): SlackReactionTarget | null {
29
+ if (ref.adapter !== 'slack-bot') return null
30
+ const parsed = parseRecord(ref.value)
31
+ if (parsed === null) return null
32
+ if (parsed.op !== undefined) return null
33
+ const channel = typeof parsed.channel === 'string' ? parsed.channel : null
34
+ const ts = typeof parsed.ts === 'string' ? parsed.ts : null
35
+ if (channel === null || ts === null) return null
36
+ return { channel, ts }
37
+ }
38
+
39
+ export function encodeSlackRemovalRef(target: SlackReactionRemovalTarget): ReactionRef {
40
+ return { adapter: 'slack-bot', value: JSON.stringify({ op: 'remove', ...target }) }
41
+ }
42
+
43
+ export function decodeSlackRemovalRef(ref: ReactionRef): SlackReactionRemovalTarget | null {
44
+ if (ref.adapter !== 'slack-bot') return null
45
+ const parsed = parseRecord(ref.value)
46
+ if (parsed === null || parsed.op !== 'remove') return null
47
+ const channel = typeof parsed.channel === 'string' ? parsed.channel : null
48
+ const ts = typeof parsed.ts === 'string' ? parsed.ts : null
49
+ const emoji = typeof parsed.emoji === 'string' ? parsed.emoji : null
50
+ if (channel === null || ts === null || emoji === null) return null
51
+ return { channel, ts, emoji }
52
+ }
53
+
54
+ // Slack accepts any custom-emoji name the workspace has, so unlike GitHub there
55
+ // is no fixed allow-list to validate against up front — an unknown name comes
56
+ // back as `invalid_name` from the API, which we map to `unsupported`. We only
57
+ // strip surrounding colons here; the SDK does the same, but normalizing first
58
+ // keeps the removal ref's stored name canonical.
59
+ function normalizeEmoji(emoji: string): string {
60
+ return emoji.replace(/^:|:$/g, '')
61
+ }
62
+
63
+ export function createSlackReactionCallback(deps: { client: Pick<SlackBotClient, 'addReaction'> }): ReactionCallback {
64
+ return async (req): Promise<ReactionResult> => {
65
+ if (req.adapter !== 'slack-bot') {
66
+ return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
67
+ }
68
+ const target = decodeSlackReactionRef(req.reactionRef)
69
+ if (target === null) return { ok: false, error: 'unparseable slack reaction ref', code: 'unsupported' }
70
+ const emoji = normalizeEmoji(req.emoji)
71
+ try {
72
+ await deps.client.addReaction(target.channel, target.ts, emoji)
73
+ } catch (err) {
74
+ // `already_reacted` is the desired end state, not a failure: a duplicate
75
+ // engage (or a retried tool call) that re-adds the same emoji must read
76
+ // as success so the model/runtime don't surface a spurious error.
77
+ const code = slackErrorCode(err)
78
+ if (code === 'already_reacted') {
79
+ return { ok: true, reactionRef: encodeSlackRemovalRef({ ...target, emoji }) }
80
+ }
81
+ return { ok: false, error: withScopeHint(code, describe(err)), code: classifySlackError(code) }
82
+ }
83
+ return { ok: true, reactionRef: encodeSlackRemovalRef({ ...target, emoji }) }
84
+ }
85
+ }
86
+
87
+ export function createSlackRemoveReactionCallback(deps: {
88
+ client: Pick<SlackBotClient, 'removeReaction'>
89
+ }): RemoveReactionCallback {
90
+ return async (req): Promise<ReactionResult> => {
91
+ if (req.adapter !== 'slack-bot') {
92
+ return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
93
+ }
94
+ const target = decodeSlackRemovalRef(req.reactionRef)
95
+ if (target === null) return { ok: false, error: 'unparseable slack reaction removal ref', code: 'unsupported' }
96
+ try {
97
+ await deps.client.removeReaction(target.channel, target.ts, target.emoji)
98
+ } catch (err) {
99
+ // `no_reaction` means the reaction is already gone — the desired end
100
+ // state for a removal, so treat it as success (idempotent), mirroring the
101
+ // `already_reacted` handling on the add path.
102
+ const code = slackErrorCode(err)
103
+ if (code === 'no_reaction') return { ok: true }
104
+ return { ok: false, error: describe(err), code: classifySlackError(code) }
105
+ }
106
+ return { ok: true }
107
+ }
108
+ }
109
+
110
+ // SlackBotError carries the raw Slack API error string on `.code`. We read it
111
+ // structurally (not by instanceof) so a re-thrown or wrapped error still maps
112
+ // correctly, falling back to the message when no code is present.
113
+ function slackErrorCode(err: unknown): string | null {
114
+ if (typeof err === 'object' && err !== null && 'code' in err) {
115
+ const code = (err as { code: unknown }).code
116
+ if (typeof code === 'string') return code
117
+ }
118
+ return null
119
+ }
120
+
121
+ // `reactions:write` is the scope the bot token needs for both add and remove.
122
+ // On `missing_scope` the bare Slack error is uninformative, so append the
123
+ // concrete operator fix — mirroring GitHub's permission-guidance precedent —
124
+ // since autoReactOnEngage surfaces this to host logs on every engaged inbound
125
+ // until the scope is granted.
126
+ function withScopeHint(code: string | null, error: string): string {
127
+ if (code !== 'missing_scope') return error
128
+ return `${error} (Slack bot token needs the \`reactions:write\` scope; reinstall/reauthorize the app with that scope.)`
129
+ }
130
+
131
+ function classifySlackError(code: string | null): ReactionErrorCode {
132
+ switch (code) {
133
+ case 'invalid_name':
134
+ case 'no_item_specified':
135
+ return 'unsupported'
136
+ case 'missing_scope':
137
+ case 'not_in_channel':
138
+ case 'is_archived':
139
+ case 'not_authed':
140
+ case 'invalid_auth':
141
+ return 'permission-denied'
142
+ case 'message_not_found':
143
+ case 'channel_not_found':
144
+ return 'not-found'
145
+ case 'ratelimited':
146
+ case 'rate_limited':
147
+ return 'rate-limited'
148
+ default:
149
+ return 'transient'
150
+ }
151
+ }
152
+
153
+ function parseRecord(value: string): Record<string, unknown> | null {
154
+ let parsed: unknown
155
+ try {
156
+ parsed = JSON.parse(value)
157
+ } catch {
158
+ return null
159
+ }
160
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
161
+ ? (parsed as Record<string, unknown>)
162
+ : null
163
+ }
164
+
165
+ function describe(err: unknown): string {
166
+ return err instanceof Error ? err.message : String(err)
167
+ }
@@ -43,6 +43,7 @@ import {
43
43
  type SlackInboundMessageEvent,
44
44
  } from './slack-bot-classify'
45
45
  import { createSlackDedupe } from './slack-bot-dedupe'
46
+ import { createSlackReactionCallback, createSlackRemoveReactionCallback } from './slack-bot-reactions'
46
47
  import { enrichSlackReferenceContext } from './slack-bot-reference'
47
48
  import {
48
49
  buildSlashAckPayload,
@@ -997,6 +998,9 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
997
998
 
998
999
  const fetchAttachmentCallback = createFetchAttachmentCallback({ client, logger })
999
1000
 
1001
+ const reactionCallback = createSlackReactionCallback({ client })
1002
+ const removeReactionCallback = createSlackRemoveReactionCallback({ client })
1003
+
1000
1004
  const dedupe = createSlackDedupe()
1001
1005
 
1002
1006
  const handleSlashCommand = createSlashCommandHandler({
@@ -1206,6 +1210,8 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1206
1210
  })
1207
1211
 
1208
1212
  options.router.registerOutbound('slack-bot', outboundCallback)
1213
+ options.router.registerReaction('slack-bot', reactionCallback)
1214
+ options.router.registerRemoveReaction('slack-bot', removeReactionCallback)
1209
1215
  options.router.registerTyping('slack-bot', typingCallback)
1210
1216
  options.router.registerChannelNameResolver('slack-bot', channelResolver)
1211
1217
  options.router.registerSelfIdentity('slack-bot', selfIdentityResolver)
@@ -1216,6 +1222,22 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1216
1222
  try {
1217
1223
  await listener.start()
1218
1224
  } catch (err) {
1225
+ // Listener failed after registration — roll back every callback so a
1226
+ // failed start leaves no router state behind (stop() returns early on
1227
+ // !started and would otherwise skip cleanup), mirroring the github
1228
+ // adapter's rollback path.
1229
+ options.router.unregisterOutbound('slack-bot', outboundCallback)
1230
+ options.router.unregisterReaction('slack-bot', reactionCallback)
1231
+ options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
1232
+ options.router.unregisterTyping('slack-bot', typingCallback)
1233
+ options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
1234
+ options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
1235
+ options.router.unregisterHistory('slack-bot', historyCallback)
1236
+ options.router.unregisterFetchAttachment('slack-bot', fetchAttachmentCallback)
1237
+ options.router.unregisterMembership('slack-bot', membershipResolver)
1238
+ listener = null
1239
+ botUserId = null
1240
+ teamId = null
1219
1241
  started = false
1220
1242
  logger.error(`[slack-bot] listener start failed: ${describe(err)}`)
1221
1243
  throw err
@@ -1226,6 +1248,8 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1226
1248
  if (!started) return
1227
1249
  started = false
1228
1250
  options.router.unregisterOutbound('slack-bot', outboundCallback)
1251
+ options.router.unregisterReaction('slack-bot', reactionCallback)
1252
+ options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
1229
1253
  options.router.unregisterTyping('slack-bot', typingCallback)
1230
1254
  options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
1231
1255
  options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
@@ -3233,6 +3233,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3233
3233
  error?: string
3234
3234
  },
3235
3235
  ): { kind: 'delivered'; keyId: string } => {
3236
+ const adapter = live.keyId.split(':', 1)[0] ?? ''
3236
3237
  const text = renderSubagentCompletionReminder({
3237
3238
  subagent: args.subagent,
3238
3239
  taskId: args.taskId,
@@ -3240,6 +3241,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3240
3241
  durationMs: args.durationMs,
3241
3242
  ...(args.error !== undefined ? { error: args.error } : {}),
3242
3243
  channel: true,
3244
+ adapter,
3243
3245
  })
3244
3246
  live.pendingSystemReminders.push(text)
3245
3247
  // The reminder tells the agent to fetch this result now; clear the
@@ -125,12 +125,53 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
125
125
  'discussion_comment.created',
126
126
  'issues.opened',
127
127
  'pull_request.opened',
128
+ 'pull_request.ready_for_review',
128
129
  'pull_request.review_requested',
129
130
  'pull_request.review_request_removed',
130
131
  'discussion.created',
131
132
  'pull_request_review.submitted',
132
133
  ] as const
133
134
 
135
+ // Prior values of DEFAULT_GITHUB_EVENT_ALLOWLIST that shipped in releases and
136
+ // were seeded verbatim into typeclaw.json. Kept as historical record so the
137
+ // migration can recognize and unfreeze configs created by those versions.
138
+ // NEVER edit these in place — they are snapshots of what was on disk.
139
+ // - v1: 7-event default, shipped 0.5.1–0.10.0 (commit fe4f3a8)
140
+ const GITHUB_EVENT_ALLOWLIST_V1 = [
141
+ 'issue_comment.created',
142
+ 'pull_request_review_comment.created',
143
+ 'discussion_comment.created',
144
+ 'issues.opened',
145
+ 'pull_request.opened',
146
+ 'discussion.created',
147
+ 'pull_request_review.submitted',
148
+ ] as const
149
+ // - v2: added review_requested + review_request_removed, shipped 0.11.0+ (commit 4f365ce)
150
+ const GITHUB_EVENT_ALLOWLIST_V2 = [
151
+ 'issue_comment.created',
152
+ 'pull_request_review_comment.created',
153
+ 'discussion_comment.created',
154
+ 'issues.opened',
155
+ 'pull_request.opened',
156
+ 'pull_request.review_requested',
157
+ 'pull_request.review_request_removed',
158
+ 'discussion.created',
159
+ 'pull_request_review.submitted',
160
+ ] as const
161
+
162
+ // Every event-allowlist that `channel add` / `init` has ever seeded verbatim
163
+ // into typeclaw.json, oldest first, current default last. The legacy-shape
164
+ // migration uses this to tell a seeded default (safe to strip so the config
165
+ // re-tracks the shipped default) from a user's deliberate customization (must
166
+ // be preserved). Append the prior array here — never edit in place — whenever
167
+ // DEFAULT_GITHUB_EVENT_ALLOWLIST changes, or configs from the old version stay
168
+ // frozen and the migration starts eating user edits.
169
+ export const SEEDED_GITHUB_EVENT_ALLOWLISTS: readonly (readonly string[])[] = [
170
+ GITHUB_EVENT_ALLOWLIST_V1,
171
+ GITHUB_EVENT_ALLOWLIST_V2,
172
+ DEFAULT_GITHUB_EVENT_ALLOWLIST,
173
+ ]
174
+
134
175
  // Which pull_request webhook action triggers an agent code review. The two
135
176
  // event values are GitHub's bare PR action names (the `pull_request.` event
136
177
  // prefix is implied by this field living under the review config); `off` is the
@@ -5,7 +5,7 @@ import { isAbsolute, join, resolve } from 'node:path'
5
5
  import type { Model } from '@mariozechner/pi-ai'
6
6
  import { z } from 'zod'
7
7
 
8
- import { channelsSchema } from '@/channels/schema'
8
+ import { channelsSchema, SEEDED_GITHUB_EVENT_ALLOWLISTS } from '@/channels/schema'
9
9
  import { commitSystemFileSync } from '@/git/system-commit'
10
10
  import { rolesConfigSchema } from '@/permissions/schema'
11
11
  import { secretFieldSchema } from '@/secrets/resolve'
@@ -810,6 +810,7 @@ export type MigrationStep =
810
810
  | { kind: 'strip-permissions-gate-channel-respond' }
811
811
  | { kind: 'model-to-models'; ref: string }
812
812
  | { kind: 'drop-stale-model'; ref: string }
813
+ | { kind: 'drop-github-seeded-event-allowlist' }
813
814
 
814
815
  export type MigrationResult = { json: unknown; changed: boolean; applied: MigrationStep[] }
815
816
 
@@ -830,13 +831,15 @@ export function migrateLegacyConfigShape(json: unknown): MigrationResult {
830
831
  // silently — same precedence rule as the dockerfile/gitignore migrations.
831
832
  const hasLegacyModel = 'model' in obj && !('models' in obj) && typeof obj.model === 'string'
832
833
  const hasStaleModelAlongsideModels = 'model' in obj && 'models' in obj
834
+ const hasSeededGithubEventAllowlist = isSeededGithubEventAllowlist(obj)
833
835
  if (
834
836
  !hasLegacyDockerfile &&
835
837
  !hasLegacyGitignore &&
836
838
  !channelsAllowMigration.found &&
837
839
  !hasLegacyGateChannelRespond &&
838
840
  !hasLegacyModel &&
839
- !hasStaleModelAlongsideModels
841
+ !hasStaleModelAlongsideModels &&
842
+ !hasSeededGithubEventAllowlist
840
843
  ) {
841
844
  return { json, changed: false, applied: [] }
842
845
  }
@@ -897,9 +900,43 @@ export function migrateLegacyConfigShape(json: unknown): MigrationResult {
897
900
  delete next.model
898
901
  applied.push({ kind: 'drop-stale-model', ref })
899
902
  }
903
+ if (hasSeededGithubEventAllowlist) {
904
+ dropSeededGithubEventAllowlist(next)
905
+ applied.push({ kind: 'drop-github-seeded-event-allowlist' })
906
+ }
900
907
  return { json: next, changed: true, applied }
901
908
  }
902
909
 
910
+ // True when channels.github.eventAllowlist deep-equals an allowlist that
911
+ // `channel add` / `init` has previously seeded verbatim. Such a value is
912
+ // indistinguishable from "the default at that time", so stripping it lets the
913
+ // config re-track the shipped default. A user who hand-edited to any other set
914
+ // (added/removed/reordered an event) fails this check and is preserved.
915
+ function isSeededGithubEventAllowlist(obj: Record<string, unknown>): boolean {
916
+ const github = isPlainObject(obj.channels) ? obj.channels.github : undefined
917
+ if (!isPlainObject(github)) return false
918
+ const list = github.eventAllowlist
919
+ if (!Array.isArray(list)) return false
920
+ return SEEDED_GITHUB_EVENT_ALLOWLISTS.some((seeded) => arraysEqual(list, seeded))
921
+ }
922
+
923
+ function dropSeededGithubEventAllowlist(next: Record<string, unknown>): void {
924
+ const channels = next.channels
925
+ if (!isPlainObject(channels)) return
926
+ const github = channels.github
927
+ if (!isPlainObject(github)) return
928
+ const { eventAllowlist: _dropped, ...rest } = github
929
+ next.channels = { ...channels, github: rest }
930
+ }
931
+
932
+ function arraysEqual(a: readonly unknown[], b: readonly unknown[]): boolean {
933
+ if (a.length !== b.length) return false
934
+ for (let i = 0; i < a.length; i++) {
935
+ if (a[i] !== b[i]) return false
936
+ }
937
+ return true
938
+ }
939
+
903
940
  // Builds a meaningful one-line git commit subject for a typeclaw.json
904
941
  // migration. Single-step migrations get a specific subject; multi-step ones
905
942
  // fall back to a stable summary subject with the count. The body (after the
@@ -949,6 +986,8 @@ function shortStepLabel(step: MigrationStep): string {
949
986
  return 'lift model → models.default'
950
987
  case 'drop-stale-model':
951
988
  return 'drop stale legacy model alongside models'
989
+ case 'drop-github-seeded-event-allowlist':
990
+ return 'drop seeded channels.github.eventAllowlist'
952
991
  }
953
992
  }
954
993
 
@@ -972,6 +1011,8 @@ function describeStep(step: MigrationStep): string {
972
1011
  return step.ref !== ''
973
1012
  ? `drop stale top-level model (${step.ref}) — models block takes precedence`
974
1013
  : 'drop stale top-level model — models block takes precedence'
1014
+ case 'drop-github-seeded-event-allowlist':
1015
+ return 'drop seeded channels.github.eventAllowlist so it re-tracks the shipped default'
975
1016
  }
976
1017
  }
977
1018
 
package/src/init/index.ts CHANGED
@@ -3,7 +3,6 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import { basename, dirname, join, relative, resolve } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
 
6
- import { DEFAULT_GITHUB_EVENT_ALLOWLIST } from '@/channels/schema'
7
6
  import { config, configSchema, migrateLegacyConfigShape, type Config } from '@/config'
8
7
  import {
9
8
  DEFAULT_MODEL_REF,
@@ -1167,10 +1166,12 @@ async function mergeChannelIntoConfig(cwd: string, options: AddChannelOptions):
1167
1166
  }
1168
1167
 
1169
1168
  function buildGithubChannelConfig(options: Extract<AddChannelOptions, { channel: 'github' }>): Record<string, unknown> {
1169
+ // Do NOT write eventAllowlist: the schema defaults it at parse time, so
1170
+ // omitting it lets the user's config track the shipped default across
1171
+ // releases instead of freezing the list captured at `channel add` time.
1170
1172
  return {
1171
1173
  ...(options.webhookUrl !== undefined ? { webhookUrl: options.webhookUrl } : {}),
1172
1174
  webhookPort: options.webhookPort ?? 8975,
1173
- eventAllowlist: [...DEFAULT_GITHUB_EVENT_ALLOWLIST],
1174
1175
  repos: options.repos,
1175
1176
  }
1176
1177
  }
@@ -1246,7 +1247,6 @@ async function writeGithubChannelForInit(cwd: string, credentials: GithubInitCre
1246
1247
  existingChannels.github = {
1247
1248
  ...(credentials.webhookUrl !== undefined ? { webhookUrl: credentials.webhookUrl } : {}),
1248
1249
  webhookPort: credentials.webhookPort ?? 8975,
1249
- eventAllowlist: [...DEFAULT_GITHUB_EVENT_ALLOWLIST],
1250
1250
  repos: credentials.repos,
1251
1251
  }
1252
1252
  parsed.channels = existingChannels
@@ -189,7 +189,7 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
189
189
 
190
190
  A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`. The inline-review post in step 4 applies whenever the actionable count is **at least one**. When the reviewer returns **exactly zero** actionable findings (only `praise`, or none), there is nothing to anchor inline — handle by verdict:
191
191
 
192
- - `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array). **If the operator approval policy above disabled approval, submit a `COMMENT` review instead — same `<summary>` as the review body, `event: "COMMENT"`, no `comments[]` array. Keep it a formal review, not a top-level issue comment, so the review metadata and flow are preserved.** (Re-review caveat: a `COMMENT` review does **not** clear a sticky `CHANGES_REQUESTED` block. If this is a re-review under approval-disabled policy, follow the step-4 re-review branch — dismiss your prior review — instead of relying on this `COMMENT`.)
192
+ - `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array). **This is still a formal review via `POST /pulls/<N>/reviews`, NOT a `channel_reply`.** A zero-findings approval is the single most common place this goes wrong: with nothing to anchor inline, the model is tempted to just `channel_reply({ text: "Approved …" })` and end the turn. That posts a plain PR comment and leaves the PR **"awaiting review"** with no approval — the verdict never reaches GitHub's review API. Never start a top-level `channel_reply` on a `pr:N` with "Approved" / "LGTM" / "Request changes": those are verdicts, and verdicts are always formal reviews. Submit the `APPROVE` via `gh api`, confirm it landed (step 5), then `skip_response`. **If the operator approval policy above disabled approval, submit a `COMMENT` review instead — same `<summary>` as the review body, `event: "COMMENT"`, no `comments[]` array. Keep it a formal review, not a top-level issue comment, so the review metadata and flow are preserved.** (Re-review caveat: a `COMMENT` review does **not** clear a sticky `CHANGES_REQUESTED` block. If this is a re-review under approval-disabled policy, follow the step-4 re-review branch — dismiss your prior review — instead of relying on this `COMMENT`.)
193
193
  - `comment` → post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments` instead of submitting an empty review. **Exception — re-reviews:** if this is a re-review (you have an unresolved blocking obligation — a formal `CHANGES_REQUESTED` **or** an unretracted flat-comment blocker), a top-level comment discharges neither. Do not use this branch; resolve it via the step-4 re-review branch (`APPROVE` if resolved and approval is enabled, the dismissal endpoint if a formal block is resolved but approval is disabled, `REQUEST_CHANGES` if not resolved).
194
194
  - `request-changes` → submit `REQUEST_CHANGES` with the `<summary>` as the review body and no `comments[]` array. This combination is rare (the reviewer's contract says `request-changes` requires at least one blocker or load-bearing concern); if it happens, faithfully encode the verdict and trust the reviewer's reasoning is in the summary.
195
195
 
@@ -529,6 +529,7 @@
529
529
  "discussion_comment.created",
530
530
  "issues.opened",
531
531
  "pull_request.opened",
532
+ "pull_request.ready_for_review",
532
533
  "pull_request.review_requested",
533
534
  "pull_request.review_request_removed",
534
535
  "discussion.created",