typeclaw 0.19.0 → 0.21.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 (38) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +7 -0
  3. package/src/agent/live-subagents.ts +4 -0
  4. package/src/agent/restart/index.ts +101 -0
  5. package/src/agent/session-origin.ts +32 -10
  6. package/src/agent/tools/channel-react.ts +79 -0
  7. package/src/agent/tools/restart.ts +23 -52
  8. package/src/agent/tools/spawn-subagent.ts +1 -0
  9. package/src/agent/tools/subagent-access.ts +67 -0
  10. package/src/agent/tools/subagent-cancel.ts +11 -6
  11. package/src/agent/tools/subagent-output.ts +10 -2
  12. package/src/channels/adapters/discord-bot-classify.ts +8 -2
  13. package/src/channels/adapters/discord-bot.ts +265 -22
  14. package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
  15. package/src/channels/adapters/github/inbound.ts +79 -0
  16. package/src/channels/adapters/github/index.ts +19 -0
  17. package/src/channels/adapters/github/permission-guidance.ts +20 -1
  18. package/src/channels/adapters/github/reactions.ts +276 -0
  19. package/src/channels/adapters/slack-bot-classify.ts +2 -2
  20. package/src/channels/adapters/slack-bot.ts +25 -2
  21. package/src/channels/engagement.ts +81 -44
  22. package/src/channels/router.ts +255 -18
  23. package/src/channels/types.ts +57 -0
  24. package/src/cli/builtins.ts +1 -0
  25. package/src/cli/dreams.ts +147 -0
  26. package/src/cli/index.ts +1 -0
  27. package/src/cli/inspect.ts +3 -0
  28. package/src/dreams/git.ts +85 -0
  29. package/src/dreams/index.ts +134 -0
  30. package/src/dreams/parse.ts +224 -0
  31. package/src/dreams/render.ts +155 -0
  32. package/src/dreams/types.ts +50 -0
  33. package/src/inspect/loop.ts +12 -1
  34. package/src/permissions/permissions.ts +24 -0
  35. package/src/server/index.ts +49 -0
  36. package/src/shared/protocol.ts +2 -0
  37. package/src/skills/typeclaw-channel-github/SKILL.md +6 -2
  38. package/src/tui/index.ts +70 -18
@@ -1,8 +1,11 @@
1
1
  import { DiscordBotClient, DiscordBotListener } from 'agent-messenger/discordbot'
2
2
  import {
3
3
  DiscordIntent,
4
+ type DiscordFile,
5
+ type DiscordGatewayEmbed,
4
6
  type DiscordGatewayInteractionEvent,
5
7
  type DiscordGatewayMessageCreateEvent,
8
+ type DiscordGatewayStickerItem,
6
9
  } from 'agent-messenger/discordbot'
7
10
 
8
11
  import {
@@ -29,7 +32,12 @@ import type {
29
32
  } from '@/channels/types'
30
33
 
31
34
  import { createDiscordChannelResolver } from './discord-bot-channel-resolver'
32
- import { classifyInbound, type InboundDropReason } from './discord-bot-classify'
35
+ import {
36
+ classifyInbound,
37
+ describeDiscordMedia,
38
+ type InboundDropReason,
39
+ renderPlaceholder,
40
+ } from './discord-bot-classify'
33
41
  import {
34
42
  ackInteraction,
35
43
  parseInteractionAsCommand,
@@ -148,6 +156,116 @@ type DiscordGuildPreview = {
148
156
 
149
157
  type DiscordGuildMember = {
150
158
  user?: { id?: string; bot?: boolean }
159
+ roles?: string[]
160
+ }
161
+
162
+ type DiscordPermissionOverwrite = {
163
+ id?: string
164
+ type?: number
165
+ allow?: string
166
+ deny?: string
167
+ }
168
+
169
+ // Discord channel `type` values that are threads. A thread's own
170
+ // permission_overwrites are empty and its `parent_id` points at its parent
171
+ // channel (not a category), so the normal "compute from this channel's
172
+ // overwrites" path would treat a thread as fully public. We refuse to count
173
+ // threads channel-scoped and fall back instead — fail-closed.
174
+ const DISCORD_THREAD_CHANNEL_TYPES: ReadonlySet<number> = new Set([10, 11, 12])
175
+
176
+ type DiscordChannelObject = {
177
+ type?: number
178
+ permission_overwrites?: DiscordPermissionOverwrite[]
179
+ }
180
+
181
+ type DiscordRole = {
182
+ id?: string
183
+ permissions?: string
184
+ }
185
+
186
+ type DiscordGuildObject = {
187
+ owner_id?: string
188
+ roles?: DiscordRole[]
189
+ }
190
+
191
+ // Discord permission bits. Discord serialises permission bitsets as decimal
192
+ // STRINGS that can exceed Number.MAX_SAFE_INTEGER, so all bit math is done in
193
+ // BigInt. Only the two bits this resolver needs are named.
194
+ const DISCORD_PERMISSION_VIEW_CHANNEL = 0x400n
195
+ const DISCORD_PERMISSION_ADMINISTRATOR = 0x8n
196
+
197
+ function parsePermissionBits(raw: string | undefined): bigint {
198
+ if (raw === undefined || raw === '') return 0n
199
+ try {
200
+ return BigInt(raw)
201
+ } catch {
202
+ return 0n
203
+ }
204
+ }
205
+
206
+ // Computes whether a single member can VIEW_CHANNEL on a normal guild channel,
207
+ // following Discord's documented resolution order:
208
+ // base = @everyone role perms OR all of the member's role perms
209
+ // → owner or ADMINISTRATOR short-circuits to visible
210
+ // → @everyone channel overwrite (clear deny bits, then set allow bits)
211
+ // → all matching role overwrites combined (OR every deny, OR every allow;
212
+ // apply denies then allows)
213
+ // → member-specific overwrite (clear deny, set allow)
214
+ // Returns null when the data is insufficient to decide (a member references a
215
+ // role absent from the guild role map), so the caller can fail closed rather
216
+ // than guess.
217
+ function memberCanViewChannel(args: {
218
+ guildId: string
219
+ ownerId: string | undefined
220
+ rolePermissions: ReadonlyMap<string, bigint>
221
+ overwrites: ReadonlyMap<string, { allow: bigint; deny: bigint }>
222
+ memberId: string | undefined
223
+ memberRoleIds: readonly string[]
224
+ }): boolean | null {
225
+ const { guildId, ownerId, rolePermissions, overwrites, memberId, memberRoleIds } = args
226
+
227
+ if (memberId !== undefined && ownerId !== undefined && memberId === ownerId) return true
228
+
229
+ // Discord's `@everyone` role id always equals the guild id, so the guild id
230
+ // keys both the base @everyone permissions and the @everyone overwrite.
231
+ let base = rolePermissions.get(guildId) ?? 0n
232
+ for (const roleId of memberRoleIds) {
233
+ const perms = rolePermissions.get(roleId)
234
+ if (perms === undefined) return null
235
+ base |= perms
236
+ }
237
+
238
+ if ((base & DISCORD_PERMISSION_ADMINISTRATOR) !== 0n) return true
239
+
240
+ let perms = base
241
+
242
+ const everyoneOverwrite = overwrites.get(guildId)
243
+ if (everyoneOverwrite !== undefined) {
244
+ perms &= ~everyoneOverwrite.deny
245
+ perms |= everyoneOverwrite.allow
246
+ }
247
+
248
+ let roleDeny = 0n
249
+ let roleAllow = 0n
250
+ for (const roleId of memberRoleIds) {
251
+ const overwrite = overwrites.get(roleId)
252
+ if (overwrite !== undefined) {
253
+ roleDeny |= overwrite.deny
254
+ roleAllow |= overwrite.allow
255
+ }
256
+ }
257
+ perms &= ~roleDeny
258
+ perms |= roleAllow
259
+
260
+ if (memberId !== undefined) {
261
+ const memberOverwrite = overwrites.get(memberId)
262
+ if (memberOverwrite !== undefined) {
263
+ perms &= ~memberOverwrite.deny
264
+ perms |= memberOverwrite.allow
265
+ }
266
+ }
267
+
268
+ return (perms & DISCORD_PERMISSION_VIEW_CHANNEL) !== 0n
151
269
  }
152
270
 
153
271
  export function createDiscordMembershipResolver(deps: {
@@ -207,28 +325,136 @@ export function createDiscordMembershipResolver(deps: {
207
325
  return members.failure
208
326
  }
209
327
 
210
- let bots = 0
211
- let humans = 0
212
- const humanMemberIds: string[] = []
213
- let everyHumanIdentified = true
214
- for (const member of members.value) {
215
- if (member.user?.bot === true) {
216
- bots++
217
- continue
218
- }
219
- humans++
220
- const userId = member.user?.id
221
- if (userId === undefined) everyHumanIdentified = false
222
- else humanMemberIds.push(userId)
328
+ // Guild `/members` returns the whole guild, not the channel. A bot or
329
+ // human that cannot VIEW_CHANNEL the target channel is not in the room and
330
+ // not a prompt-injection surface, so it must not be counted — otherwise a
331
+ // private channel of "operator + agent bot" inside a multi-bot guild reads
332
+ // as bots>1 and the grant_role relaxation (provesOnlyAgentBotPresent)
333
+ // false-refuses. Resolve channel visibility for each member; on ANY
334
+ // inability to decide, fall back rather than over- or under-count.
335
+ // `key.thread ?? key.chat` mirrors the history callback's channel-id
336
+ // resolution: today Discord stores a thread's own snowflake in `chat` with
337
+ // `thread` null, but a future caller that passes `chat=parent,
338
+ // thread=threadId` must scope visibility to the thread, not the parent.
339
+ const scoped = await scopeMembersToChannel({
340
+ fetchFn,
341
+ token: deps.token,
342
+ logger: deps.logger,
343
+ guildId: key.workspace,
344
+ channelId: key.thread ?? key.chat,
345
+ members: members.value,
346
+ })
347
+ if (scoped === 'fallback') return await fallback()
348
+
349
+ return scoped.everyHumanIdentified
350
+ ? {
351
+ humans: scoped.humans,
352
+ bots: scoped.bots,
353
+ fetchedAt: now(),
354
+ truncated: false,
355
+ humanMemberIds: scoped.humanMemberIds,
356
+ }
357
+ : { humans: scoped.humans, bots: scoped.bots, fetchedAt: now(), truncated: false }
358
+ }
359
+ }
360
+
361
+ type ScopedMembership = {
362
+ humans: number
363
+ bots: number
364
+ humanMemberIds: string[]
365
+ everyHumanIdentified: boolean
366
+ }
367
+
368
+ // Filters a guild member list down to those who can VIEW_CHANNEL the target
369
+ // channel, then counts humans/bots over that visible set. Returns 'fallback'
370
+ // when channel visibility cannot be computed completely (channel/guild fetch
371
+ // failure, a member referencing an unknown role, or a thread channel whose
372
+ // visibility this resolver does not model) — the caller then derives from
373
+ // history (truncated:true), keeping every security consumer fail-closed.
374
+ async function scopeMembersToChannel(args: {
375
+ fetchFn: typeof fetch
376
+ token: string
377
+ logger: DiscordBotAdapterLogger
378
+ guildId: string
379
+ channelId: string
380
+ members: readonly DiscordGuildMember[]
381
+ }): Promise<ScopedMembership | 'fallback'> {
382
+ const { fetchFn, token, logger, guildId, channelId, members } = args
383
+
384
+ const [channel, guild] = await Promise.all([
385
+ fetchDiscordJson<DiscordChannelObject>(fetchFn, `${DISCORD_API_BASE}/channels/${channelId}`, token),
386
+ fetchDiscordJson<DiscordGuildObject>(fetchFn, `${DISCORD_API_BASE}/guilds/${guildId}`, token),
387
+ ])
388
+ if (!channel.ok) {
389
+ logger.warn(
390
+ `[discord-bot] membership channel=${channelId} fetch failed: ${channel.reason}; deriving from recent message authors`,
391
+ )
392
+ return 'fallback'
393
+ }
394
+ if (!guild.ok) {
395
+ logger.warn(
396
+ `[discord-bot] membership guild=${guildId} fetch failed: ${guild.reason}; deriving from recent message authors`,
397
+ )
398
+ return 'fallback'
399
+ }
400
+
401
+ if (channel.value.type !== undefined && DISCORD_THREAD_CHANNEL_TYPES.has(channel.value.type)) {
402
+ // A thread inherits visibility from its parent channel and (for private
403
+ // threads) an explicit member list; we do not model either here. Fall
404
+ // back rather than treat the thread as world-visible.
405
+ logger.warn(
406
+ `[discord-bot] membership channel=${channelId} is a thread; deriving from recent message authors instead of channel-scoped enumeration`,
407
+ )
408
+ return 'fallback'
409
+ }
410
+
411
+ const rolePermissions = new Map<string, bigint>()
412
+ for (const role of guild.value.roles ?? []) {
413
+ if (role.id !== undefined) rolePermissions.set(role.id, parsePermissionBits(role.permissions))
414
+ }
415
+
416
+ const overwrites = new Map<string, { allow: bigint; deny: bigint }>()
417
+ for (const overwrite of channel.value.permission_overwrites ?? []) {
418
+ if (overwrite.id === undefined) continue
419
+ overwrites.set(overwrite.id, {
420
+ allow: parsePermissionBits(overwrite.allow),
421
+ deny: parsePermissionBits(overwrite.deny),
422
+ })
423
+ }
424
+
425
+ let humans = 0
426
+ let bots = 0
427
+ const humanMemberIds: string[] = []
428
+ let everyHumanIdentified = true
429
+
430
+ for (const member of members) {
431
+ const visible = memberCanViewChannel({
432
+ guildId,
433
+ ownerId: guild.value.owner_id,
434
+ rolePermissions,
435
+ overwrites,
436
+ memberId: member.user?.id,
437
+ memberRoleIds: member.roles ?? [],
438
+ })
439
+ if (visible === null) {
440
+ logger.warn(
441
+ `[discord-bot] membership channel=${channelId} member references unknown role; deriving from recent message authors`,
442
+ )
443
+ return 'fallback'
223
444
  }
224
- // Only attach identities when every human was identifiable; an
225
- // unidentifiable human must not be silently dropped, or a consumer proving
226
- // "all humans trusted" would skip an unaccounted member. Falling back to
227
- // counts-only keeps that consumer fail-closed.
228
- return everyHumanIdentified
229
- ? { humans, bots, fetchedAt: now(), truncated: false, humanMemberIds }
230
- : { humans, bots, fetchedAt: now(), truncated: false }
445
+ if (!visible) continue
446
+
447
+ if (member.user?.bot === true) {
448
+ bots++
449
+ continue
450
+ }
451
+ humans++
452
+ const userId = member.user?.id
453
+ if (userId === undefined) everyHumanIdentified = false
454
+ else humanMemberIds.push(userId)
231
455
  }
456
+
457
+ return { humans, bots, humanMemberIds, everyHumanIdentified }
232
458
  }
233
459
 
234
460
  type DiscordFetchResult<T> =
@@ -269,6 +495,9 @@ type DiscordRawHistoryMessage = {
269
495
  content: string
270
496
  timestamp: string
271
497
  message_reference?: { message_id?: string }
498
+ attachments?: DiscordFile[]
499
+ embeds?: DiscordGatewayEmbed[]
500
+ sticker_items?: DiscordGatewayStickerItem[]
272
501
  }
273
502
 
274
503
  // Discord treats threads as separate channels with their own snowflake ids,
@@ -333,14 +562,28 @@ export function createDiscordHistoryCallback(deps: {
333
562
  function mapDiscordMessage(msg: DiscordRawHistoryMessage, botUserId: string | null): ChannelHistoryMessage {
334
563
  const isBot = msg.author.bot === true || (botUserId !== null && msg.author.id === botUserId)
335
564
  const ts = Date.parse(msg.timestamp)
565
+ // The REST history fetch bypasses the inbound classifier, so attachments,
566
+ // embeds, and stickers on already-posted messages (e.g. an image on a thread
567
+ // root the agent is later @-mentioned under) must be mapped here too —
568
+ // otherwise they are silently dropped and look_at_channel_attachment can
569
+ // never resolve them. Mirror the classifier's splitInbound: bake placeholders
570
+ // into text and carry the structured attachments so the router can resolve ids.
571
+ const attachments = describeDiscordMedia(msg)
572
+ const text =
573
+ attachments.length === 0
574
+ ? msg.content
575
+ : msg.content === ''
576
+ ? attachments.map(renderPlaceholder).join('\n')
577
+ : `${msg.content}\n${attachments.map(renderPlaceholder).join('\n')}`
336
578
  return {
337
579
  externalMessageId: msg.id,
338
580
  authorId: msg.author.id,
339
581
  authorName: msg.author.global_name ?? msg.author.username ?? msg.author.id,
340
- text: msg.content,
582
+ text,
341
583
  ts: Number.isFinite(ts) ? ts : 0,
342
584
  isBot,
343
585
  replyToBotMessageId: msg.message_reference?.message_id ?? null,
586
+ ...(attachments.length > 0 ? { attachments } : {}),
344
587
  }
345
588
  }
346
589
 
@@ -0,0 +1,43 @@
1
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
2
+
3
+ // `absent` separates "GitHub says the reviewer is already not requested"
4
+ // (404/422 — never on the list, already removed, or invalid for the repo) from
5
+ // `failed` ("couldn't reach GitHub"), so callers warn only on the latter.
6
+ export type RemoveRequestedReviewerResult =
7
+ | { kind: 'removed'; status: number }
8
+ | { kind: 'absent'; status: number; message: string }
9
+ | { kind: 'failed'; status?: number; reason: string }
10
+
11
+ export async function removeRequestedReviewer(params: {
12
+ fetchImpl: typeof fetch
13
+ token: string
14
+ owner: string
15
+ repo: string
16
+ pullNumber: number
17
+ reviewerLogin: string
18
+ }): Promise<RemoveRequestedReviewerResult> {
19
+ const url = `${GITHUB_API_BASE}/repos/${params.owner}/${params.repo}/pulls/${params.pullNumber}/requested_reviewers`
20
+ try {
21
+ const response = await params.fetchImpl(url, {
22
+ method: 'DELETE',
23
+ headers: githubJsonHeaders(params.token),
24
+ body: JSON.stringify({ reviewers: [params.reviewerLogin] }),
25
+ })
26
+ if (response.ok) return { kind: 'removed', status: response.status }
27
+ const message = await response.text().catch(() => '')
28
+ // 404 (PR/reviewer not found) and 422 (reviewer not currently requested,
29
+ // or not a valid reviewer for this repo) mean there is nothing to remove —
30
+ // the desired end state already holds. Everything else (401/403 auth,
31
+ // 429 rate, 5xx) is a real failure worth surfacing.
32
+ if (response.status === 404 || response.status === 422) {
33
+ return { kind: 'absent', status: response.status, message }
34
+ }
35
+ return {
36
+ kind: 'failed',
37
+ status: response.status,
38
+ reason: `GitHub API ${response.status}${message !== '' ? `: ${message}` : ''}`,
39
+ }
40
+ } catch (err) {
41
+ return { kind: 'failed', reason: err instanceof Error ? err.message : String(err) }
42
+ }
43
+ }
@@ -2,8 +2,11 @@ import { createHmac, timingSafeEqual } from 'node:crypto'
2
2
 
3
3
  import type { InboundMessage } from '@/channels/types'
4
4
 
5
+ import type { GithubAuthContext } from './auth'
6
+ import { removeRequestedReviewer } from './decoy-reviewer'
5
7
  import type { DeliveryDedup } from './dedup'
6
8
  import { isGithubEventAllowed } from './event-allowlist'
9
+ import { encodeGithubReactionRef, type GithubReactionTarget } from './reactions'
7
10
 
8
11
  export type GithubInboundLogger = { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
9
12
 
@@ -22,6 +25,13 @@ export type GithubWebhookHandlerOptions = {
22
25
  // omitted, team-reviewer requests are silently dropped (the v1 fallback
23
26
  // behavior). The adapter wires this in production; tests inject a fake.
24
27
  isBotInTeam?: (input: { org: string; slug: string; login: string }) => Promise<boolean>
28
+ // App-auth only: mints a repo-scoped token used to drop the decoy reviewer
29
+ // once the bot's own review lands. Omitted under PAT auth (no decoy exists).
30
+ authToken?: (context?: GithubAuthContext) => Promise<string>
31
+ // Schedules the decoy-drop off the webhook ACK path so the 200 stays fast.
32
+ // Defaults to fire-and-forget; tests inject a recorder to await the task.
33
+ scheduleBackgroundTask?: (task: () => Promise<void>) => void
34
+ fetchImpl?: typeof fetch
25
35
  }
26
36
 
27
37
  export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions): (req: Request) => Promise<Response> {
@@ -50,6 +60,7 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
50
60
  const selfLogin = options.selfLogin()
51
61
  const author = readAuthor(event, payload)
52
62
  if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
63
+ maybeScheduleDecoyReviewerDrop({ event, action, payload, selfLogin, options })
53
64
  options.logger.info(
54
65
  `[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
55
66
  )
@@ -69,6 +80,65 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
69
80
  }
70
81
  }
71
82
 
83
+ // GitHub auto-records the App as a reviewer the moment its review posts, but
84
+ // leaves the decoy user pinned as a perpetual "review requested". When the bot
85
+ // drops its own review (the self-authored event we're about to discard), fire a
86
+ // background DELETE to remove the decoy. The DELETE is authenticated as the App,
87
+ // so the resulting review_request_removed webhook has the bot actor as sender
88
+ // and is dropped by classifyReviewRequest's self-loop guard — no fresh session.
89
+ function maybeScheduleDecoyReviewerDrop(input: {
90
+ event: string
91
+ action: string | null
92
+ payload: Record<string, unknown>
93
+ selfLogin: string | null
94
+ options: GithubWebhookHandlerOptions
95
+ }): void {
96
+ const { event, action, payload, selfLogin, options } = input
97
+ if (event !== 'pull_request_review' || action !== 'submitted') return
98
+ if (selfLogin === null) return
99
+ const authToken = options.authToken
100
+ if (authToken === undefined) return
101
+ if ((options.authType?.() ?? 'pat') !== 'app') return
102
+ const decoyLogin = resolveDecoyReviewerLogin(selfLogin, 'app')
103
+ if (decoyLogin === null) return
104
+
105
+ const repository = readRepository(payload)
106
+ const pr = readRecord(payload.pull_request)
107
+ const pullNumber = readNumber(pr, 'number')
108
+ if (repository === null || pullNumber === null) return
109
+
110
+ const fetchImpl = options.fetchImpl ?? fetch
111
+ const schedule = options.scheduleBackgroundTask ?? defaultScheduleBackgroundTask
112
+ const target = `${repository.owner}/${repository.name}#${pullNumber}`
113
+ schedule(async () => {
114
+ // authToken can throw (installation lookup / token mint), and a thrown
115
+ // failure must still warn — the default scheduler swallows rejections, so
116
+ // catching here is the only place the failure is observable.
117
+ try {
118
+ const token = await authToken({ repoSlug: `${repository.owner}/${repository.name}` })
119
+ const result = await removeRequestedReviewer({
120
+ fetchImpl,
121
+ token,
122
+ owner: repository.owner,
123
+ repo: repository.name,
124
+ pullNumber,
125
+ reviewerLogin: decoyLogin,
126
+ })
127
+ if (result.kind === 'failed') {
128
+ options.logger.warn(`[github] failed to drop decoy reviewer @${decoyLogin} from ${target}: ${result.reason}`)
129
+ }
130
+ } catch (err) {
131
+ options.logger.warn(
132
+ `[github] failed to drop decoy reviewer @${decoyLogin} from ${target}: ${err instanceof Error ? err.message : String(err)}`,
133
+ )
134
+ }
135
+ })
136
+ }
137
+
138
+ function defaultScheduleBackgroundTask(task: () => Promise<void>): void {
139
+ void task().catch(() => {})
140
+ }
141
+
72
142
  export async function verifySignature(body: string, secret: string, sigHeader: string): Promise<boolean> {
73
143
  const expected = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
74
144
  const a = Buffer.from(expected)
@@ -109,6 +179,7 @@ export function classifyGithubInbound(
109
179
  user,
110
180
  selfLogin,
111
181
  comment.created_at,
182
+ { kind: 'issue-comment', owner: repository.owner, repo: repository.name, commentId: id },
112
183
  )
113
184
  }
114
185
 
@@ -127,6 +198,7 @@ export function classifyGithubInbound(
127
198
  readUser(comment.user),
128
199
  selfLogin,
129
200
  comment.created_at,
201
+ { kind: 'pr-review-comment', owner: repository.owner, repo: repository.name, commentId: id },
130
202
  )
131
203
  }
132
204
 
@@ -144,6 +216,7 @@ export function classifyGithubInbound(
144
216
  readUser(comment.user),
145
217
  selfLogin,
146
218
  comment.created_at,
219
+ null,
147
220
  )
148
221
  }
149
222
 
@@ -160,6 +233,7 @@ export function classifyGithubInbound(
160
233
  readUser(issue.user),
161
234
  selfLogin,
162
235
  issue.created_at,
236
+ { kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
163
237
  )
164
238
  }
165
239
 
@@ -189,6 +263,7 @@ export function classifyGithubInbound(
189
263
  readUser(pr.user),
190
264
  selfLogin,
191
265
  pr.created_at,
266
+ { kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
192
267
  )
193
268
  }
194
269
 
@@ -206,6 +281,7 @@ export function classifyGithubInbound(
206
281
  readUser(review.user),
207
282
  selfLogin,
208
283
  review.submitted_at,
284
+ null,
209
285
  )
210
286
  }
211
287
 
@@ -222,6 +298,7 @@ export function classifyGithubInbound(
222
298
  readUser(discussion.user),
223
299
  selfLogin,
224
300
  discussion.created_at,
301
+ null,
225
302
  )
226
303
  }
227
304
 
@@ -340,6 +417,7 @@ function buildInbound(
340
417
  user: GithubUser | null,
341
418
  selfLogin: string | null,
342
419
  rawTs: unknown,
420
+ reactionTarget: GithubReactionTarget | null,
343
421
  ): InboundMessage | null {
344
422
  if (user === null) return null
345
423
  const text = typeof rawText === 'string' ? rawText : ''
@@ -347,6 +425,7 @@ function buildInbound(
347
425
  ...key,
348
426
  text,
349
427
  externalMessageId: String(id),
428
+ ...(reactionTarget !== null ? { reactionRef: encodeGithubReactionRef(reactionTarget) } : {}),
350
429
  authorId: String(user.id),
351
430
  authorName: user.login,
352
431
  authorIsBot: user.type === 'Bot',
@@ -19,6 +19,7 @@ import {
19
19
  buildPermissionGuidance,
20
20
  parseListHooksPermissionStatus,
21
21
  } from './permission-guidance'
22
+ import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
22
23
  import { createTeamMembershipChecker } from './team-membership'
23
24
  import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
24
25
 
@@ -119,6 +120,16 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
119
120
  logger,
120
121
  fetchImpl,
121
122
  })
123
+ const reaction = createGithubReactionCallback({
124
+ token: authToken,
125
+ authType: options.secrets.auth.type,
126
+ fetchImpl,
127
+ })
128
+ const removeReaction = createGithubRemoveReactionCallback({
129
+ token: authToken,
130
+ authType: options.secrets.auth.type,
131
+ fetchImpl,
132
+ })
122
133
  const history = createGithubHistoryCallback({
123
134
  token: authToken,
124
135
  fetchImpl,
@@ -139,6 +150,8 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
139
150
  selfLogin: () => selfLogin,
140
151
  authType: () => options.secrets.auth.type,
141
152
  isBotInTeam,
153
+ authToken,
154
+ fetchImpl,
142
155
  logger,
143
156
  route: (message) => {
144
157
  rememberWorkspace(message.workspace, message.chat)
@@ -161,6 +174,8 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
161
174
  // Register all callbacks before binding the HTTP listener so the router
162
175
  // is fully wired before any webhook can arrive.
163
176
  options.router.registerOutbound('github', outbound)
177
+ options.router.registerReaction('github', reaction)
178
+ options.router.registerRemoveReaction('github', removeReaction)
164
179
  options.router.registerTyping('github', typing)
165
180
  options.router.registerHistory('github', history)
166
181
  options.router.registerMembership('github', membership)
@@ -172,6 +187,8 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
172
187
  // Listener failed — roll back all registrations so stop() is a no-op
173
188
  // and the manager can report the failure cleanly.
174
189
  options.router.unregisterOutbound('github', outbound)
190
+ options.router.unregisterReaction('github', reaction)
191
+ options.router.unregisterRemoveReaction('github', removeReaction)
175
192
  options.router.unregisterTyping('github', typing)
176
193
  options.router.unregisterHistory('github', history)
177
194
  options.router.unregisterMembership('github', membership)
@@ -292,6 +309,8 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
292
309
  if (!started) return
293
310
  started = false
294
311
  options.router.unregisterOutbound('github', outbound)
312
+ options.router.unregisterReaction('github', reaction)
313
+ options.router.unregisterRemoveReaction('github', removeReaction)
295
314
  options.router.unregisterTyping('github', typing)
296
315
  options.router.unregisterHistory('github', history)
297
316
  options.router.unregisterMembership('github', membership)
@@ -6,7 +6,12 @@ export type GithubAuthType = 'pat' | 'app'
6
6
  // permission-failure response. Each value maps to a distinct GitHub App
7
7
  // permission family (and, for PATs, a distinct scope), so each surfaces a
8
8
  // different remediation message.
9
- export type OutboundEndpointKind = 'issue-comment' | 'pr-review-reply' | 'discussion-comment'
9
+ export type OutboundEndpointKind =
10
+ | 'issue-comment'
11
+ | 'pr-review-reply'
12
+ | 'discussion-comment'
13
+ | 'issue-reaction'
14
+ | 'pr-review-comment-reaction'
10
15
 
11
16
  // Parses webhook-register errors of the shape `list hooks failed: <status> <body>`.
12
17
  // Returns the status code when it matches the two shapes GitHub emits for
@@ -127,6 +132,20 @@ const OUTBOUND_PERMISSION_FOR_KIND: Record<
127
132
  patScope: 'repo',
128
133
  patFineGrained: 'Discussions',
129
134
  },
135
+ // Reactions on an issue/PR body or an issue comment go through the Issues
136
+ // permission family; reactions on a PR review comment go through Pull requests.
137
+ 'issue-reaction': {
138
+ label: 'Issues',
139
+ level: 'Read and write',
140
+ patScope: 'repo (or public_repo for public repos)',
141
+ patFineGrained: 'Issues',
142
+ },
143
+ 'pr-review-comment-reaction': {
144
+ label: 'Pull requests',
145
+ level: 'Read and write',
146
+ patScope: 'repo',
147
+ patFineGrained: 'Pull requests',
148
+ },
130
149
  }
131
150
 
132
151
  // Decorate an outbound-API failure with the precise github.com permission a