typeclaw 0.25.0 → 0.27.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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/agent/session-origin.ts +36 -5
  3. package/src/agent/subagent-completion-reminder.ts +16 -1
  4. package/src/agent/tools/channel-react.ts +11 -4
  5. package/src/bundled-plugins/reviewer/skills/code-review.ts +3 -1
  6. package/src/channels/adapters/discord-bot-classify.ts +3 -0
  7. package/src/channels/adapters/discord-bot-reactions.ts +164 -0
  8. package/src/channels/adapters/discord-bot.ts +23 -0
  9. package/src/channels/adapters/github/inbound.ts +60 -13
  10. package/src/channels/adapters/github/review-thread-resolver.ts +28 -3
  11. package/src/channels/adapters/slack-bot-classify.ts +2 -0
  12. package/src/channels/adapters/slack-bot-reactions.ts +167 -0
  13. package/src/channels/adapters/slack-bot.ts +24 -0
  14. package/src/channels/router.ts +191 -7
  15. package/src/channels/schema.ts +41 -0
  16. package/src/cli/inspect.ts +216 -36
  17. package/src/cli/logs.ts +15 -0
  18. package/src/cli/tui.ts +33 -39
  19. package/src/compose/logs.ts +1 -1
  20. package/src/config/config.ts +43 -2
  21. package/src/container/logs.ts +70 -22
  22. package/src/init/index.ts +3 -3
  23. package/src/inspect/index.ts +128 -42
  24. package/src/inspect/item-list.ts +44 -0
  25. package/src/inspect/item.ts +17 -0
  26. package/src/inspect/label.ts +1 -1
  27. package/src/inspect/logs-item.ts +79 -0
  28. package/src/inspect/loop.ts +74 -3
  29. package/src/inspect/open-item.ts +100 -0
  30. package/src/inspect/preview.ts +106 -0
  31. package/src/inspect/session-list.ts +15 -3
  32. package/src/inspect/transcript-view.ts +182 -0
  33. package/src/inspect/tui-item.ts +97 -0
  34. package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
  35. package/src/tui/index.ts +72 -32
  36. package/typeclaw.schema.json +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.25.0",
3
+ "version": "0.27.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 {
@@ -291,6 +297,7 @@ function renderChannelOrigin(
291
297
  chat: string
292
298
  chatName?: string
293
299
  thread: string | null
300
+ reactionRef?: ReactionRef
294
301
  participants?: readonly ChannelParticipant[]
295
302
  membership?: MembershipCount
296
303
  self?: ChannelSelfIdentity
@@ -348,6 +355,30 @@ function renderChannelOrigin(
348
355
  const conversationLine = renderConversationLine(origin)
349
356
  if (conversationLine !== null) lines.push('', conversationLine)
350
357
 
358
+ // Gate on `reactionRef`, not just the static `supportsReactions` platform
359
+ // fact: a turn only has a message to react to when the triggering inbound
360
+ // carried one. Reminder-only turns (restart-resume, subagent-completion,
361
+ // idle/todo continuation) wake the session with no inbound, so
362
+ // `buildLiveOrigin` omits `reactionRef`. Prompting "react like a teammate"
363
+ // there made the model call `channel_react`, which then denied with "this
364
+ // conversation has no message to react to".
365
+ if (platformInfo.supportsReactions && origin.reactionRef !== undefined) {
366
+ lines.push(
367
+ '',
368
+ '**React like a teammate would.** You can drop an emoji on the message that',
369
+ 'triggered this turn with `channel_react({ emoji })` — it posts no comment,',
370
+ 'just a reaction. Read the message and pick what genuinely fits its tone:',
371
+ '`+1` to agree or approve, `rocket` for something shipping or exciting,',
372
+ '`tada` to celebrate, `heart` to show appreciation, `laugh` for something',
373
+ 'funny, `eyes` to signal you are looking. Reach for it when a reaction adds',
374
+ 'real warmth or signal — not on every message, and not just because you can.',
375
+ 'A reaction does NOT satisfy the reply obligation below: when the message',
376
+ 'needs a substantive answer, still send it via `channel_reply`. Think of',
377
+ 'reactions as the lightweight, human layer on top of your words, not a',
378
+ 'replacement for them.',
379
+ )
380
+ }
381
+
351
382
  lines.push(
352
383
  '',
353
384
  '**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
  }),
@@ -66,7 +66,9 @@ Prioritize in this order:
66
66
 
67
67
  ### Re-reviews must re-decide, not observe
68
68
 
69
- When the payload tells you this is a **re-review** — you (or this agent) previously requested changes on this PR and the author has pushed fixes and asked again — your verdict's whole purpose is to **re-decide the blocking state**, so:
69
+ When the payload tells you this is a **re-review** — you (or this agent) previously requested changes on this PR and the author has pushed fixes — your verdict's whole purpose is to **re-decide the blocking state**, so:
70
+
71
+ This includes payloads where the parent says the author **addressed your prior blocking feedback** — "fixed both issues", "addressed your review", "pushed a fix" — even when the inbound was phrased conversationally rather than as an explicit "review again". An author responding to the blocker you raised IS the re-review trigger; the absence of the words "review again" does not downgrade it to a \`comment\`. Re-decide:
70
72
 
71
73
  - Return **approve** if the blockers that drove the prior \`request-changes\` are resolved (leftover nits do not block — \`approve\` with inline nits is correct).
72
74
  - Return **request-changes** if any blocker remains or a new one appeared.
@@ -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)
@@ -187,6 +187,7 @@ export function classifyGithubInbound(
187
187
  ): InboundMessage | null {
188
188
  const repository = readRepository(payload)
189
189
  if (repository === null) return null
190
+ const mention = resolveBotMentionLogins(selfLogin, options?.authType ?? 'pat')
190
191
  const base = {
191
192
  adapter: 'github' as const,
192
193
  workspace: `${repository.owner}/${repository.name}`,
@@ -209,7 +210,7 @@ export function classifyGithubInbound(
209
210
  comment.body,
210
211
  id,
211
212
  user,
212
- selfLogin,
213
+ mention,
213
214
  comment.created_at,
214
215
  { kind: 'issue-comment', owner: repository.owner, repo: repository.name, commentId: id },
215
216
  )
@@ -228,7 +229,7 @@ export function classifyGithubInbound(
228
229
  comment.body,
229
230
  id,
230
231
  readUser(comment.user),
231
- selfLogin,
232
+ mention,
232
233
  comment.created_at,
233
234
  { kind: 'pr-review-comment', owner: repository.owner, repo: repository.name, commentId: id },
234
235
  )
@@ -246,7 +247,7 @@ export function classifyGithubInbound(
246
247
  comment.body,
247
248
  id,
248
249
  readUser(comment.user),
249
- selfLogin,
250
+ mention,
250
251
  comment.created_at,
251
252
  null,
252
253
  )
@@ -270,7 +271,7 @@ export function classifyGithubInbound(
270
271
  text,
271
272
  id,
272
273
  opener,
273
- selfLogin,
274
+ mention,
274
275
  issue.created_at,
275
276
  { kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
276
277
  action === 'opened' && !hasBody,
@@ -301,7 +302,12 @@ export function classifyGithubInbound(
301
302
  teamIsBotMember: options?.teamIsBotMember,
302
303
  })
303
304
  }
304
- if (action === 'opened' && reviewOn === 'opened') {
305
+ // `ready_for_review` (draft→ready) is a fresh review opportunity, so it
306
+ // reuses the `opened` paths: same review trigger, same title-only awareness
307
+ // text. The draft skip in classifyOpenedReviewTrigger is a no-op here since
308
+ // the PR is non-draft once ready — preserving "review when no longer draft".
309
+ const isOpenLike = action === 'opened' || action === 'ready_for_review'
310
+ if (isOpenLike && reviewOn === 'opened') {
305
311
  const trigger = classifyOpenedReviewTrigger({
306
312
  payload,
307
313
  pr,
@@ -314,17 +320,16 @@ export function classifyGithubInbound(
314
320
  }
315
321
  const opener = readUser(pr.user)
316
322
  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
323
+ const prText = isOpenLike ? bodyOrOpenedTitle(pr.body, opener, 'PR', number, readString(pr, 'title')) : pr.body
319
324
  return buildInbound(
320
325
  { ...base, chat: `pr:${number}`, thread: null },
321
326
  prText,
322
327
  id,
323
328
  opener,
324
- selfLogin,
329
+ mention,
325
330
  pr.created_at,
326
331
  { kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
327
- action === 'opened' && !hasBody,
332
+ isOpenLike && !hasBody,
328
333
  )
329
334
  }
330
335
 
@@ -348,7 +353,7 @@ export function classifyGithubInbound(
348
353
  text,
349
354
  id,
350
355
  reviewer,
351
- selfLogin,
356
+ mention,
352
357
  review.submitted_at,
353
358
  null,
354
359
  !hasBody,
@@ -373,7 +378,7 @@ export function classifyGithubInbound(
373
378
  text,
374
379
  id,
375
380
  opener,
376
- selfLogin,
381
+ mention,
377
382
  discussion.created_at,
378
383
  null,
379
384
  action === 'created' && !hasBody,
@@ -413,6 +418,48 @@ function resolveDecoyReviewerLogin(selfLogin: string, authType: 'pat' | 'app'):
413
418
  return slug !== '' ? slug : null
414
419
  }
415
420
 
421
+ // The @-handles that count as "addressed to us" in inbound body text. Under
422
+ // App auth `selfLogin` is the actor login `slug[bot]`, but GitHub renders a
423
+ // human's mention of the App as `@slug` (the bare slug — the decoy account's
424
+ // login), with no `[bot]` suffix and no way to type one. Matching only against
425
+ // `selfLogin` therefore never sees `@typeey` for a `typeey[bot]` actor, so a
426
+ // direct "@typeey review again" lands with isBotMention=false and falls through
427
+ // the engagement mention gate. Include the decoy slug so the bare-slug mention
428
+ // is recognized. Under PAT auth the bot IS a real user, so there is no decoy
429
+ // and only `selfLogin` applies.
430
+ export type BotMentionLogins = readonly string[]
431
+
432
+ export function resolveBotMentionLogins(selfLogin: string | null, authType: 'pat' | 'app'): BotMentionLogins {
433
+ if (selfLogin === null) return []
434
+ const decoyLogin = resolveDecoyReviewerLogin(selfLogin, authType)
435
+ return decoyLogin !== null ? [selfLogin, decoyLogin] : [selfLogin]
436
+ }
437
+
438
+ // GitHub login chars are ASCII letters, digits, and hyphen. A `@login` token is
439
+ // a real mention of `login` only when the char right after it is not one of
440
+ // these — otherwise `@${login}` is a prefix of a longer, different login. This
441
+ // matters for the App decoy slug: `resolveBotMentionLogins('typeclaw[bot]')`
442
+ // yields the bare slug `typeclaw`, and a naive substring check would treat
443
+ // `@typeclaw-bot` (a different user) as a self-mention. The trailing `[` of
444
+ // `@typeclaw[bot]` is not a login char, so the full actor handle still matches.
445
+ const LOGIN_CHAR = /[A-Za-z0-9-]/
446
+
447
+ function textMentionsBot(text: string, mentionLogins: BotMentionLogins): boolean {
448
+ return mentionLogins.some((login) => mentionsLogin(text, login))
449
+ }
450
+
451
+ function mentionsLogin(text: string, login: string): boolean {
452
+ const token = `@${login}`
453
+ let from = 0
454
+ for (;;) {
455
+ const at = text.indexOf(token, from)
456
+ if (at === -1) return false
457
+ const next = text[at + token.length]
458
+ if (next === undefined || !LOGIN_CHAR.test(next)) return true
459
+ from = at + 1
460
+ }
461
+ }
462
+
416
463
  function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null {
417
464
  const { action, payload, pr, number, base, selfLogin, authType, teamIsBotMember } = input
418
465
  if (selfLogin === null) return null
@@ -546,7 +593,7 @@ function buildInbound(
546
593
  rawText: unknown,
547
594
  id: number,
548
595
  user: GithubUser | null,
549
- selfLogin: string | null,
596
+ mention: BotMentionLogins,
550
597
  rawTs: unknown,
551
598
  reactionTarget: GithubReactionTarget | null,
552
599
  synthesizedAwareness = false,
@@ -563,7 +610,7 @@ function buildInbound(
563
610
  // Synthesized awareness lines carry an `@author` prefix describing who acted;
564
611
  // that handle is the author, never a third-party mention of the bot, so the
565
612
  // body-text mention heuristic must not fire on it.
566
- const isBotMention = !synthesizedAwareness && selfLogin !== null && text.includes(`@${selfLogin}`)
613
+ const isBotMention = !synthesizedAwareness && textMentionsBot(text, mention)
567
614
  return {
568
615
  ...key,
569
616
  text,
@@ -9,7 +9,7 @@ const GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`
9
9
  // carry more, so the resolver paginates until it matches the root comment id
10
10
  // or exhausts the pages — stopping early on a 404-equivalent (thread absent)
11
11
  // rather than fabricating a node id.
12
- const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:1){nodes{databaseId author{login}}}}}}}}`
12
+ const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:1){nodes{databaseId author{__typename login}}}}}}}}`
13
13
 
14
14
  const RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}`
15
15
 
@@ -18,6 +18,7 @@ type ReviewThreadNode = {
18
18
  isResolved: boolean
19
19
  rootCommentId: number | null
20
20
  rootAuthorLogin: string | null
21
+ rootAuthorIsBot: boolean
21
22
  }
22
23
 
23
24
  type ThreadLookup =
@@ -66,7 +67,7 @@ export function createGithubReviewThreadResolver(deps: {
66
67
  const thread = lookup.thread
67
68
  // The load-bearing guard: only the bot may resolve the bot's own thread.
68
69
  // Resolving a human reviewer's thread would erase their open question.
69
- if (thread.rootAuthorLogin !== selfLogin) {
70
+ if (!isSelfAuthor(thread, selfLogin)) {
70
71
  return {
71
72
  ok: false,
72
73
  error: `refusing to resolve thread authored by @${thread.rootAuthorLogin ?? 'unknown'} (not @${selfLogin})`,
@@ -79,6 +80,29 @@ export function createGithubReviewThreadResolver(deps: {
79
80
  }
80
81
  }
81
82
 
83
+ // A GitHub App's own login differs across the two APIs this guard straddles:
84
+ // REST `getSelf` returns `slug[bot]` (selfLogin) but GraphQL's `Bot` author node
85
+ // returns the bare `slug` (rootAuthorLogin). Strict `===` thus refused the App's
86
+ // OWN thread (production: "refusing to resolve thread authored by @typeey (not
87
+ // @typeey[bot])"). The bare-slug match is gated on the GraphQL author actually
88
+ // being a `Bot`: a human `User` can legitimately own the bare slug as a login
89
+ // (e.g. the user `typeey` exists alongside the App `typeey[bot]`), so a User
90
+ // author must still match `selfLogin` exactly — otherwise the suffix-strip would
91
+ // let the bot close a human reviewer's thread, defeating the guard above.
92
+ const BOT_LOGIN_SUFFIX = '[bot]'
93
+
94
+ function isSelfAuthor(thread: ReviewThreadNode, selfLogin: string): boolean {
95
+ if (thread.rootAuthorLogin === null) return false
96
+ if (thread.rootAuthorIsBot) {
97
+ return normalizeBotLogin(thread.rootAuthorLogin) === normalizeBotLogin(selfLogin)
98
+ }
99
+ return thread.rootAuthorLogin === selfLogin
100
+ }
101
+
102
+ function normalizeBotLogin(login: string): string {
103
+ return login.endsWith(BOT_LOGIN_SUFFIX) ? login.slice(0, -BOT_LOGIN_SUFFIX.length) : login
104
+ }
105
+
82
106
  type ResolveTarget = { owner: string; repo: string; prNumber: number; rootCommentId: number }
83
107
 
84
108
  function parseTarget(req: ReviewThreadResolveRequest): ResolveTarget | null {
@@ -175,6 +199,7 @@ async function parseThreadsPage(response: Response): Promise<ThreadsPage> {
175
199
  isResolved: n.isResolved,
176
200
  rootCommentId: root?.databaseId ?? null,
177
201
  rootAuthorLogin: root?.author?.login ?? null,
202
+ rootAuthorIsBot: root?.author?.__typename === 'Bot',
178
203
  }
179
204
  })
180
205
  return { kind: 'ok', nodes, hasNextPage: connection.pageInfo.hasNextPage, endCursor: connection.pageInfo.endCursor }
@@ -231,7 +256,7 @@ type GraphqlThreadsResponse = {
231
256
  nodes: Array<{
232
257
  id: string
233
258
  isResolved: boolean
234
- comments: { nodes: Array<{ databaseId?: number; author?: { login?: string } }> }
259
+ comments: { nodes: Array<{ databaseId?: number; author?: { __typename?: string; login?: string } }> }
235
260
  }>
236
261
  }
237
262
  }
@@ -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,