typeclaw 0.20.0 → 0.22.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 (55) hide show
  1. package/package.json +2 -1
  2. package/src/agent/index.ts +55 -1
  3. package/src/agent/loop-guard.ts +180 -53
  4. package/src/agent/restart/index.ts +101 -0
  5. package/src/agent/tools/restart.ts +23 -52
  6. package/src/bundled-plugins/bun-hygiene/README.md +82 -0
  7. package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
  8. package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
  9. package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
  10. package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
  11. package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
  12. package/src/bundled-plugins/memory/memory-logger.ts +6 -2
  13. package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
  14. package/src/channels/adapters/discord-bot-classify.ts +8 -2
  15. package/src/channels/adapters/discord-bot.ts +29 -2
  16. package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
  17. package/src/channels/adapters/github/inbound.ts +92 -1
  18. package/src/channels/adapters/github/index.ts +12 -1
  19. package/src/channels/adapters/github/reactions.ts +138 -4
  20. package/src/channels/adapters/slack-bot-classify.ts +2 -2
  21. package/src/channels/adapters/slack-bot.ts +129 -7
  22. package/src/channels/engagement.ts +71 -31
  23. package/src/channels/manager.ts +8 -0
  24. package/src/channels/router.ts +180 -25
  25. package/src/channels/schema.ts +18 -0
  26. package/src/channels/types.ts +16 -1
  27. package/src/cli/builtins.ts +1 -0
  28. package/src/cli/dreams.ts +148 -0
  29. package/src/cli/index.ts +1 -0
  30. package/src/cli/inspect.ts +2 -1
  31. package/src/cli/ui.ts +34 -0
  32. package/src/commands/index.ts +5 -2
  33. package/src/config/config.ts +89 -0
  34. package/src/dreams/git.ts +85 -0
  35. package/src/dreams/index.ts +134 -0
  36. package/src/dreams/parse.ts +224 -0
  37. package/src/dreams/render.ts +155 -0
  38. package/src/dreams/types.ts +50 -0
  39. package/src/mcp/catalog.ts +29 -0
  40. package/src/mcp/client.ts +236 -0
  41. package/src/mcp/index.ts +25 -0
  42. package/src/mcp/manager.ts +156 -0
  43. package/src/mcp/tools.ts +190 -0
  44. package/src/permissions/builtins.ts +9 -0
  45. package/src/reload/format.ts +14 -0
  46. package/src/reload/index.ts +1 -0
  47. package/src/run/bundled-plugins.ts +7 -0
  48. package/src/run/channel-session-factory.ts +3 -0
  49. package/src/run/index.ts +38 -1
  50. package/src/server/command-runner.ts +5 -0
  51. package/src/server/index.ts +53 -0
  52. package/src/shared/protocol.ts +2 -0
  53. package/src/skills/typeclaw-channel-github/SKILL.md +86 -22
  54. package/src/tui/index.ts +70 -18
  55. package/typeclaw.schema.json +82 -0
@@ -2,6 +2,7 @@ import { TYPECLAW_INTERNAL_BASH_ENV } from '@/agent/plugin-tools'
2
2
  import { definePlugin } from '@/plugin'
3
3
 
4
4
  import { analyzeGhCommand } from './gh-command'
5
+ import { checkGraphqlAuthNudge } from './graphql-auth-nudge'
5
6
  import { classifyGhToken } from './token-class'
6
7
 
7
8
  export default definePlugin({
@@ -34,8 +35,14 @@ export default definePlugin({
34
35
  // --setenv by the bash wrapper) so the token never enters the command
35
36
  // string, where it could leak through logs or later hooks.
36
37
  event.args[TYPECLAW_INTERNAL_BASH_ENV] = { GH_TOKEN: result.token }
38
+ // graphql consumed `-R/--repo` as a mint hint; `gh api` rejects it, so
39
+ // run the command with the flag stripped (token still rides in env).
40
+ if (decision.rewrittenCommand !== undefined) event.args.command = decision.rewrittenCommand
37
41
  return
38
42
  },
43
+ 'tool.after': async (event) => {
44
+ checkGraphqlAuthNudge({ tool: event.tool, result: event.result })
45
+ },
39
46
  },
40
47
  }
41
48
  },
@@ -81,9 +81,11 @@ Session transcripts are JSONL files where each line is an entry with an \`id\` f
81
81
  Typical flow with a watermark:
82
82
 
83
83
  1. \`find_entry(path=<transcript>, entryId=<watermark>)\` → returns \`line=N, totalLines=T, offset=N+1\`.
84
- 2. \`read(path=<transcript>, offset=N+1)\` → returns the chunk starting AT the first unread entry. Repeat with the next offset until the read tool's continuation notice stops appearing.
84
+ 2. \`read(path=<transcript>, offset=N+1)\` → returns the chunk starting AT the first unread entry. Repeat with the next offset until you reach the end of the file. \`find_entry\` already told you \`totalLines=T\`: once a \`read\` has returned line T (or the read tool reports no continuation), you have reached the end of the transcript. Stop reading.
85
85
  3. As you read, track the most recent \`id\` you see. That is your new watermark value — pass it as \`latestEntryId\` on the final \`append\` call, or to the watermark-advance tool when there are zero fragments.
86
86
 
87
+ **Reading is bounded — a finite transcript takes a finite number of reads.** \`find_entry\` gives you \`totalLines=T\` up front, so you always know the last line. Each \`read\` returns a slice and an offset to continue; advance the offset forward each time. Once you have read line T, or a \`read\` returns no new content (an empty chunk, or the same slice you already saw, or no continuation offset), you are at the end. Do NOT re-read the same offset, and do NOT keep calling \`read\` hoping more will appear — nothing more will. A read that returns nothing new is the end-of-file signal, not a transient error to retry. Re-reading past the end produces no new information and wastes the entire run; treat the first no-new-content read as "done reading" and move to your fragment decision.
88
+
87
89
  Never write the same watermark id you were given as input. If the transcript has no new entries past the watermark, evaluate the entries you can see, then advance the watermark to the latest \`id\` in the transcript (which is on line \`totalLines\` from \`find_entry\`'s reply). The whole point of the watermark is to move forward each run.
88
90
 
89
91
  # Capture philosophy: when in doubt, SKIP
@@ -202,7 +204,9 @@ When you evaluated the transcript but found nothing worth a fragment, call the w
202
204
 
203
205
  # Stopping
204
206
 
205
- When you're done, simply stop. There is no completion message to emit.`
207
+ You are done the moment BOTH are true: (1) you have read to the end of the transcript (reached \`totalLines\` from \`find_entry\`, or a \`read\` returned no new content), and (2) you have either written your fragment(s) with the final \`latestEntryId\`, or advanced the watermark for the zero-fragment case. When both hold, simply stop. There is no completion message to emit.
208
+
209
+ Do not loop. The hard stop is \`totalLines\`: a long transcript may legitimately need many \`read\` chunks to reach it, and that is fine as long as each \`read\` advances the offset toward \`totalLines\`. What is NOT fine is re-reading without progress. If a \`read\` returns no new content, returns the same slice you already saw, or your offset stops advancing, you are at the end — stop reading immediately and proceed to your fragment decision. A transcript has a fixed length; re-reading the same offset cannot surface content that is not there. The single most expensive failure mode for this subagent is re-reading the same file in a cycle instead of recognizing end-of-file and stopping.`
206
210
 
207
211
  function buildInitialPrompt(payload: MemoryLoggerPayload, streamFile: string, watermark: string | null): string {
208
212
  const lines: string[] = [
@@ -64,6 +64,14 @@ Prioritize in this order:
64
64
  - **request-changes** — At least one blocker, OR a load-bearing concern that needs an answer before this lands.
65
65
  - **comment** — Mixed signal: useful observations without a clear approve/reject. Common on large refactors where you reviewed part of the change, or on early-draft PRs where the author asked for direction more than approval.
66
66
 
67
+ ### Re-reviews must re-decide, not observe
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:
70
+
71
+ - 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
+ - Return **request-changes** if any blocker remains or a new one appeared.
73
+ - **Do NOT return \`comment\` on a re-review.** \`comment\` is for ambiguous partial reviews with no accept/reject signal; a re-review is the opposite — it is precisely an accept/reject decision. A \`comment\` verdict here leaves the PR's \`REQUEST_CHANGES\` state stuck (a plain comment does not clear it on GitHub), which is the exact failure a re-review exists to resolve. The only escape hatch is the same one that always applies: if you genuinely cannot reach the diff or the prior context, return one \`blocker\` finding stating what you need and a \`comment\` verdict — but a reachable, reviewable re-review must end in \`approve\` or \`request-changes\`.
74
+
67
75
  ## Line-anchor every finding
68
76
 
69
77
  Code review is line-level work, and your findings are meant to land as **inline comments on the exact lines they describe**. The parent agent posts them that way — it reads the \`location\` on each \`<finding>\` and attaches your \`<issue>\`/\`<evidence>\`/\`<suggestion>\` to that line. A finding with no line anchor cannot be posted inline; the parent can only fold it into a top-level summary, which strips the one thing that made it actionable.
@@ -141,7 +141,13 @@ function splitInbound(event: DiscordGatewayMessageCreateEvent): SplitInbound {
141
141
  return { text, attachments }
142
142
  }
143
143
 
144
- function describeDiscordMedia(event: DiscordGatewayMessageCreateEvent): InboundAttachment[] {
144
+ export type DiscordMediaCarrier = {
145
+ attachments?: DiscordFile[]
146
+ embeds?: DiscordGatewayEmbed[]
147
+ sticker_items?: DiscordGatewayStickerItem[]
148
+ }
149
+
150
+ export function describeDiscordMedia(event: DiscordMediaCarrier): InboundAttachment[] {
145
151
  return [
146
152
  ...(event.attachments ?? []).map(describeAttachment),
147
153
  ...(event.embeds ?? []).map(describeEmbed),
@@ -167,7 +173,7 @@ function describeSticker(sticker: DiscordGatewayStickerItem): Omit<InboundAttach
167
173
  return { kind: 'sticker', ref: '', filename: sticker.name }
168
174
  }
169
175
 
170
- function renderPlaceholder(attachment: InboundAttachment): string {
176
+ export function renderPlaceholder(attachment: InboundAttachment): string {
171
177
  const parts: string[] = [`Discord attachment #${attachment.id}: ${attachment.kind}`]
172
178
  if (attachment.mimetype !== undefined) parts.push(attachment.mimetype)
173
179
  if (attachment.filename !== undefined) parts.push(`name=${attachment.filename}`)
@@ -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,
@@ -44,6 +52,8 @@ import {
44
52
  const SLASH_COMMANDS: readonly DiscordCommandDeclaration[] = [
45
53
  { name: 'help', description: 'List available commands' },
46
54
  { name: 'stop', description: 'Abort the current turn in this channel' },
55
+ { name: 'reload', description: 'Reload typeclaw config and subsystems from disk' },
56
+ { name: 'restart', description: 'Restart the typeclaw container' },
47
57
  ]
48
58
  const SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(SLASH_COMMANDS.map((c) => c.name))
49
59
 
@@ -487,6 +497,9 @@ type DiscordRawHistoryMessage = {
487
497
  content: string
488
498
  timestamp: string
489
499
  message_reference?: { message_id?: string }
500
+ attachments?: DiscordFile[]
501
+ embeds?: DiscordGatewayEmbed[]
502
+ sticker_items?: DiscordGatewayStickerItem[]
490
503
  }
491
504
 
492
505
  // Discord treats threads as separate channels with their own snowflake ids,
@@ -551,14 +564,28 @@ export function createDiscordHistoryCallback(deps: {
551
564
  function mapDiscordMessage(msg: DiscordRawHistoryMessage, botUserId: string | null): ChannelHistoryMessage {
552
565
  const isBot = msg.author.bot === true || (botUserId !== null && msg.author.id === botUserId)
553
566
  const ts = Date.parse(msg.timestamp)
567
+ // The REST history fetch bypasses the inbound classifier, so attachments,
568
+ // embeds, and stickers on already-posted messages (e.g. an image on a thread
569
+ // root the agent is later @-mentioned under) must be mapped here too —
570
+ // otherwise they are silently dropped and look_at_channel_attachment can
571
+ // never resolve them. Mirror the classifier's splitInbound: bake placeholders
572
+ // into text and carry the structured attachments so the router can resolve ids.
573
+ const attachments = describeDiscordMedia(msg)
574
+ const text =
575
+ attachments.length === 0
576
+ ? msg.content
577
+ : msg.content === ''
578
+ ? attachments.map(renderPlaceholder).join('\n')
579
+ : `${msg.content}\n${attachments.map(renderPlaceholder).join('\n')}`
554
580
  return {
555
581
  externalMessageId: msg.id,
556
582
  authorId: msg.author.id,
557
583
  authorName: msg.author.global_name ?? msg.author.username ?? msg.author.id,
558
- text: msg.content,
584
+ text,
559
585
  ts: Number.isFinite(ts) ? ts : 0,
560
586
  isBot,
561
587
  replyToBotMessageId: msg.message_reference?.message_id ?? null,
588
+ ...(attachments.length > 0 ? { attachments } : {}),
562
589
  }
563
590
  }
564
591
 
@@ -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,6 +2,8 @@ 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'
7
9
  import { encodeGithubReactionRef, type GithubReactionTarget } from './reactions'
@@ -17,12 +19,23 @@ export type GithubWebhookHandlerOptions = {
17
19
  // Defaults to 'pat' when omitted. In 'app' mode classifyReviewRequest also
18
20
  // matches the App's decoy reviewer login; see resolveDecoyReviewerLogin.
19
21
  authType?: () => 'pat' | 'app'
22
+ // Defaults to true when omitted. When it returns false, every inbound carries
23
+ // an appended operator-policy note telling the agent not to submit an APPROVE
24
+ // review; the github skill keys off that note to downgrade approve→COMMENT.
25
+ allowApprove?: () => boolean
20
26
  route: (message: InboundMessage) => void
21
27
  logger: GithubInboundLogger
22
28
  // Optional: resolves whether the bot is a member of the given team. When
23
29
  // omitted, team-reviewer requests are silently dropped (the v1 fallback
24
30
  // behavior). The adapter wires this in production; tests inject a fake.
25
31
  isBotInTeam?: (input: { org: string; slug: string; login: string }) => Promise<boolean>
32
+ // App-auth only: mints a repo-scoped token used to drop the decoy reviewer
33
+ // once the bot's own review lands. Omitted under PAT auth (no decoy exists).
34
+ authToken?: (context?: GithubAuthContext) => Promise<string>
35
+ // Schedules the decoy-drop off the webhook ACK path so the 200 stays fast.
36
+ // Defaults to fire-and-forget; tests inject a recorder to await the task.
37
+ scheduleBackgroundTask?: (task: () => Promise<void>) => void
38
+ fetchImpl?: typeof fetch
26
39
  }
27
40
 
28
41
  export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions): (req: Request) => Promise<Response> {
@@ -51,6 +64,7 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
51
64
  const selfLogin = options.selfLogin()
52
65
  const author = readAuthor(event, payload)
53
66
  if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
67
+ maybeScheduleDecoyReviewerDrop({ event, action, payload, selfLogin, options })
54
68
  options.logger.info(
55
69
  `[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
56
70
  )
@@ -65,11 +79,88 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
65
79
  if (classified === null) return ok()
66
80
 
67
81
  if (delivery !== '') options.dedup.add(delivery)
68
- options.route(classified)
82
+ options.route(withApprovalPolicy(classified, options.allowApprove?.() ?? true))
69
83
  return ok()
70
84
  }
71
85
  }
72
86
 
87
+ export const PR_APPROVAL_DISABLED_NOTE =
88
+ 'Operator policy: PR approval is disabled for this agent ' +
89
+ '(`channels.github.review.approve: false`). If you review a PR and the ' +
90
+ 'verdict is `approve`, submit a `COMMENT` review instead of `APPROVE` — post ' +
91
+ 'the findings, but never formally approve.'
92
+
93
+ // Gating PR approval lives here (inbound text), not at the bash layer: the
94
+ // review is posted via `gh api --input <file>`, so the `event: APPROVE` value
95
+ // sits in a temp file the gh-cli-auth command interceptor never inspects. The
96
+ // note rides on every inbound (cheap: one line, only when an operator has
97
+ // opted out) so it reaches the agent for both webhook review requests and
98
+ // plain-language "@bot review this" asks, which arrive on arbitrary inbounds.
99
+ function withApprovalPolicy(message: InboundMessage, allowApprove: boolean): InboundMessage {
100
+ if (allowApprove) return message
101
+ const text = message.text === '' ? PR_APPROVAL_DISABLED_NOTE : `${message.text}\n\n${PR_APPROVAL_DISABLED_NOTE}`
102
+ return { ...message, text }
103
+ }
104
+
105
+ // GitHub auto-records the App as a reviewer the moment its review posts, but
106
+ // leaves the decoy user pinned as a perpetual "review requested". When the bot
107
+ // drops its own review (the self-authored event we're about to discard), fire a
108
+ // background DELETE to remove the decoy. The DELETE is authenticated as the App,
109
+ // so the resulting review_request_removed webhook has the bot actor as sender
110
+ // and is dropped by classifyReviewRequest's self-loop guard — no fresh session.
111
+ function maybeScheduleDecoyReviewerDrop(input: {
112
+ event: string
113
+ action: string | null
114
+ payload: Record<string, unknown>
115
+ selfLogin: string | null
116
+ options: GithubWebhookHandlerOptions
117
+ }): void {
118
+ const { event, action, payload, selfLogin, options } = input
119
+ if (event !== 'pull_request_review' || action !== 'submitted') return
120
+ if (selfLogin === null) return
121
+ const authToken = options.authToken
122
+ if (authToken === undefined) return
123
+ if ((options.authType?.() ?? 'pat') !== 'app') return
124
+ const decoyLogin = resolveDecoyReviewerLogin(selfLogin, 'app')
125
+ if (decoyLogin === null) return
126
+
127
+ const repository = readRepository(payload)
128
+ const pr = readRecord(payload.pull_request)
129
+ const pullNumber = readNumber(pr, 'number')
130
+ if (repository === null || pullNumber === null) return
131
+
132
+ const fetchImpl = options.fetchImpl ?? fetch
133
+ const schedule = options.scheduleBackgroundTask ?? defaultScheduleBackgroundTask
134
+ const target = `${repository.owner}/${repository.name}#${pullNumber}`
135
+ schedule(async () => {
136
+ // authToken can throw (installation lookup / token mint), and a thrown
137
+ // failure must still warn — the default scheduler swallows rejections, so
138
+ // catching here is the only place the failure is observable.
139
+ try {
140
+ const token = await authToken({ repoSlug: `${repository.owner}/${repository.name}` })
141
+ const result = await removeRequestedReviewer({
142
+ fetchImpl,
143
+ token,
144
+ owner: repository.owner,
145
+ repo: repository.name,
146
+ pullNumber,
147
+ reviewerLogin: decoyLogin,
148
+ })
149
+ if (result.kind === 'failed') {
150
+ options.logger.warn(`[github] failed to drop decoy reviewer @${decoyLogin} from ${target}: ${result.reason}`)
151
+ }
152
+ } catch (err) {
153
+ options.logger.warn(
154
+ `[github] failed to drop decoy reviewer @${decoyLogin} from ${target}: ${err instanceof Error ? err.message : String(err)}`,
155
+ )
156
+ }
157
+ })
158
+ }
159
+
160
+ function defaultScheduleBackgroundTask(task: () => Promise<void>): void {
161
+ void task().catch(() => {})
162
+ }
163
+
73
164
  export async function verifySignature(body: string, secret: string, sigHeader: string): Promise<boolean> {
74
165
  const expected = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
75
166
  const a = Buffer.from(expected)
@@ -19,7 +19,7 @@ import {
19
19
  buildPermissionGuidance,
20
20
  parseListHooksPermissionStatus,
21
21
  } from './permission-guidance'
22
- import { createGithubReactionCallback } from './reactions'
22
+ import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
23
23
  import { createTeamMembershipChecker } from './team-membership'
24
24
  import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
25
25
 
@@ -125,6 +125,11 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
125
125
  authType: options.secrets.auth.type,
126
126
  fetchImpl,
127
127
  })
128
+ const removeReaction = createGithubRemoveReactionCallback({
129
+ token: authToken,
130
+ authType: options.secrets.auth.type,
131
+ fetchImpl,
132
+ })
128
133
  const history = createGithubHistoryCallback({
129
134
  token: authToken,
130
135
  fetchImpl,
@@ -144,7 +149,10 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
144
149
  selfId: () => selfId,
145
150
  selfLogin: () => selfLogin,
146
151
  authType: () => options.secrets.auth.type,
152
+ allowApprove: () => options.configRef().review.approve,
147
153
  isBotInTeam,
154
+ authToken,
155
+ fetchImpl,
148
156
  logger,
149
157
  route: (message) => {
150
158
  rememberWorkspace(message.workspace, message.chat)
@@ -168,6 +176,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
168
176
  // is fully wired before any webhook can arrive.
169
177
  options.router.registerOutbound('github', outbound)
170
178
  options.router.registerReaction('github', reaction)
179
+ options.router.registerRemoveReaction('github', removeReaction)
171
180
  options.router.registerTyping('github', typing)
172
181
  options.router.registerHistory('github', history)
173
182
  options.router.registerMembership('github', membership)
@@ -180,6 +189,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
180
189
  // and the manager can report the failure cleanly.
181
190
  options.router.unregisterOutbound('github', outbound)
182
191
  options.router.unregisterReaction('github', reaction)
192
+ options.router.unregisterRemoveReaction('github', removeReaction)
183
193
  options.router.unregisterTyping('github', typing)
184
194
  options.router.unregisterHistory('github', history)
185
195
  options.router.unregisterMembership('github', membership)
@@ -301,6 +311,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
301
311
  started = false
302
312
  options.router.unregisterOutbound('github', outbound)
303
313
  options.router.unregisterReaction('github', reaction)
314
+ options.router.unregisterRemoveReaction('github', removeReaction)
304
315
  options.router.unregisterTyping('github', typing)
305
316
  options.router.unregisterHistory('github', history)
306
317
  options.router.unregisterMembership('github', membership)
@@ -1,4 +1,10 @@
1
- import type { ReactionCallback, ReactionErrorCode, ReactionRef, ReactionResult } from '@/channels/types'
1
+ import type {
2
+ RemoveReactionCallback,
3
+ ReactionCallback,
4
+ ReactionErrorCode,
5
+ ReactionRef,
6
+ ReactionResult,
7
+ } from '@/channels/types'
2
8
 
3
9
  import type { GithubAuthContext } from './auth'
4
10
  import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
@@ -21,6 +27,11 @@ export type GithubReactionTarget =
21
27
  | { kind: 'issue-comment'; owner: string; repo: string; commentId: number }
22
28
  | { kind: 'pr-review-comment'; owner: string; repo: string; commentId: number }
23
29
 
30
+ export type GithubReactionRemovalTarget =
31
+ | { kind: 'issue'; owner: string; repo: string; issueNumber: number; reactionId: number }
32
+ | { kind: 'issue-comment'; owner: string; repo: string; commentId: number; reactionId: number }
33
+ | { kind: 'pr-review-comment'; owner: string; repo: string; commentId: number; reactionId: number }
34
+
24
35
  export function encodeGithubReactionRef(target: GithubReactionTarget): ReactionRef {
25
36
  return { adapter: 'github', value: JSON.stringify(target) }
26
37
  }
@@ -35,6 +46,7 @@ export function decodeGithubReactionRef(ref: ReactionRef): GithubReactionTarget
35
46
  }
36
47
  if (typeof parsed !== 'object' || parsed === null) return null
37
48
  const t = parsed as Record<string, unknown>
49
+ if (t.op !== undefined) return null
38
50
  const owner = typeof t.owner === 'string' ? t.owner : null
39
51
  const repo = typeof t.repo === 'string' ? t.repo : null
40
52
  if (owner === null || repo === null) return null
@@ -47,6 +59,32 @@ export function decodeGithubReactionRef(ref: ReactionRef): GithubReactionTarget
47
59
  return null
48
60
  }
49
61
 
62
+ export function encodeGithubRemovalRef(target: GithubReactionRemovalTarget): ReactionRef {
63
+ return { adapter: 'github', value: JSON.stringify({ op: 'remove', ...target }) }
64
+ }
65
+
66
+ export function decodeGithubRemovalRef(ref: ReactionRef): GithubReactionRemovalTarget | null {
67
+ if (ref.adapter !== 'github') return null
68
+ let parsed: unknown
69
+ try {
70
+ parsed = JSON.parse(ref.value)
71
+ } catch {
72
+ return null
73
+ }
74
+ if (typeof parsed !== 'object' || parsed === null) return null
75
+ const t = parsed as Record<string, unknown>
76
+ const owner = typeof t.owner === 'string' ? t.owner : null
77
+ const repo = typeof t.repo === 'string' ? t.repo : null
78
+ if (t.op !== 'remove' || owner === null || repo === null || typeof t.reactionId !== 'number') return null
79
+ if (t.kind === 'issue' && typeof t.issueNumber === 'number') {
80
+ return { kind: 'issue', owner, repo, issueNumber: t.issueNumber, reactionId: t.reactionId }
81
+ }
82
+ if ((t.kind === 'issue-comment' || t.kind === 'pr-review-comment') && typeof t.commentId === 'number') {
83
+ return { kind: t.kind, owner, repo, commentId: t.commentId, reactionId: t.reactionId }
84
+ }
85
+ return null
86
+ }
87
+
50
88
  // GitHub's Reactions API takes a fixed vocabulary of content strings. Map the
51
89
  // adapter-generic emoji name onto it; anything outside the set is reported as
52
90
  // `unsupported` so the model gets a clear signal rather than a silent 422.
@@ -82,8 +120,35 @@ export function createGithubReactionCallback(deps: {
82
120
  const endpoint = reactionEndpoint(target)
83
121
  const endpointKind: OutboundEndpointKind =
84
122
  target.kind === 'pr-review-comment' ? 'pr-review-comment-reaction' : 'issue-reaction'
85
- return await postReaction(fetchImpl, await deps.token({ repoSlug: `${target.owner}/${target.repo}` }), endpoint, {
86
- content,
123
+ return await postReaction(
124
+ fetchImpl,
125
+ await deps.token({ repoSlug: `${target.owner}/${target.repo}` }),
126
+ endpoint,
127
+ target,
128
+ {
129
+ content,
130
+ authType: deps.authType,
131
+ endpointKind,
132
+ },
133
+ )
134
+ }
135
+ }
136
+
137
+ export function createGithubRemoveReactionCallback(deps: {
138
+ token: (context?: GithubAuthContext) => Promise<string>
139
+ authType: GithubAuthType
140
+ fetchImpl?: typeof fetch
141
+ }): RemoveReactionCallback {
142
+ const fetchImpl = deps.fetchImpl ?? fetch
143
+ return async (req): Promise<ReactionResult> => {
144
+ if (req.adapter !== 'github') return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
145
+ const target = decodeGithubRemovalRef(req.reactionRef)
146
+ if (target === null) return { ok: false, error: 'unparseable github reaction removal ref', code: 'unsupported' }
147
+
148
+ const endpoint = removeReactionEndpoint(target)
149
+ const endpointKind: OutboundEndpointKind =
150
+ target.kind === 'pr-review-comment' ? 'pr-review-comment-reaction' : 'issue-reaction'
151
+ return await deleteReaction(fetchImpl, await deps.token({ repoSlug: `${target.owner}/${target.repo}` }), endpoint, {
87
152
  authType: deps.authType,
88
153
  endpointKind,
89
154
  })
@@ -102,10 +167,23 @@ function reactionEndpoint(target: GithubReactionTarget): string {
102
167
  }
103
168
  }
104
169
 
170
+ function removeReactionEndpoint(target: GithubReactionRemovalTarget): string {
171
+ const base = `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}`
172
+ switch (target.kind) {
173
+ case 'issue':
174
+ return `${base}/issues/${target.issueNumber}/reactions/${target.reactionId}`
175
+ case 'issue-comment':
176
+ return `${base}/issues/comments/${target.commentId}/reactions/${target.reactionId}`
177
+ case 'pr-review-comment':
178
+ return `${base}/pulls/comments/${target.commentId}/reactions/${target.reactionId}`
179
+ }
180
+ }
181
+
105
182
  async function postReaction(
106
183
  fetchImpl: typeof fetch,
107
184
  token: string,
108
185
  url: string,
186
+ target: GithubReactionTarget,
109
187
  options: { content: string; authType: GithubAuthType; endpointKind: OutboundEndpointKind },
110
188
  ): Promise<ReactionResult> {
111
189
  let response: Response
@@ -121,7 +199,11 @@ async function postReaction(
121
199
  // 201 = reaction created, 200 = the actor already left this same reaction.
122
200
  // Both are success: an :eyes: that's already there is the desired end state,
123
201
  // so a duplicate webhook delivery (or a retried engage) must not surface an error.
124
- if (response.status === 200 || response.status === 201) return { ok: true }
202
+ if (response.status === 200 || response.status === 201) {
203
+ const reactionId = await readReactionId(response)
204
+ if (reactionId === null) return { ok: true }
205
+ return { ok: true, reactionRef: encodeGithubRemovalRef(removalTargetFor(target, reactionId)) }
206
+ }
125
207
  const text = await response.text().catch(() => '')
126
208
  const baseError = `GitHub API ${response.status}${text !== '' ? `: ${text}` : ''}`
127
209
  if (isOutboundPermissionDenial(response.status, text)) {
@@ -134,6 +216,58 @@ async function postReaction(
134
216
  return { ok: false, error: baseError, code: classifyStatus(response.status) }
135
217
  }
136
218
 
219
+ async function deleteReaction(
220
+ fetchImpl: typeof fetch,
221
+ token: string,
222
+ url: string,
223
+ options: { authType: GithubAuthType; endpointKind: OutboundEndpointKind },
224
+ ): Promise<ReactionResult> {
225
+ let response: Response
226
+ try {
227
+ response = await fetchImpl(url, {
228
+ method: 'DELETE',
229
+ headers: githubJsonHeaders(token),
230
+ })
231
+ } catch (err) {
232
+ return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
233
+ }
234
+ if (response.status === 204) return { ok: true }
235
+ const text = await response.text().catch(() => '')
236
+ const baseError = `GitHub API ${response.status}${text !== '' ? `: ${text}` : ''}`
237
+ if (isOutboundPermissionDenial(response.status, text)) {
238
+ return {
239
+ ok: false,
240
+ error: `${baseError}${buildOutboundPermissionGuidance({ authType: options.authType, endpointKind: options.endpointKind })}`,
241
+ code: 'permission-denied',
242
+ }
243
+ }
244
+ return { ok: false, error: baseError, code: classifyStatus(response.status) }
245
+ }
246
+
247
+ function removalTargetFor(target: GithubReactionTarget, reactionId: number): GithubReactionRemovalTarget {
248
+ switch (target.kind) {
249
+ case 'issue':
250
+ return { kind: 'issue', owner: target.owner, repo: target.repo, issueNumber: target.issueNumber, reactionId }
251
+ case 'issue-comment':
252
+ return { kind: 'issue-comment', owner: target.owner, repo: target.repo, commentId: target.commentId, reactionId }
253
+ case 'pr-review-comment':
254
+ return {
255
+ kind: 'pr-review-comment',
256
+ owner: target.owner,
257
+ repo: target.repo,
258
+ commentId: target.commentId,
259
+ reactionId,
260
+ }
261
+ }
262
+ }
263
+
264
+ async function readReactionId(response: Response): Promise<number | null> {
265
+ const body = await response.json().catch(() => null)
266
+ if (typeof body !== 'object' || body === null) return null
267
+ const id = (body as Record<string, unknown>).id
268
+ return typeof id === 'number' ? id : null
269
+ }
270
+
137
271
  function classifyStatus(status: number): ReactionErrorCode {
138
272
  if (status === 403) return 'permission-denied'
139
273
  if (status === 404) return 'not-found'
@@ -182,7 +182,7 @@ function describeSlackMedia(event: SlackInboundMessageEvent): InboundAttachment[
182
182
  return (event.files ?? []).map((file, index) => describeSlackFile(file, index + 1))
183
183
  }
184
184
 
185
- function describeSlackFile(file: SlackFile, id: number): InboundAttachment {
185
+ export function describeSlackFile(file: SlackFile, id: number): InboundAttachment {
186
186
  return {
187
187
  id,
188
188
  kind: 'file',
@@ -192,7 +192,7 @@ function describeSlackFile(file: SlackFile, id: number): InboundAttachment {
192
192
  }
193
193
  }
194
194
 
195
- function renderPlaceholder(attachment: InboundAttachment): string {
195
+ export function renderPlaceholder(attachment: InboundAttachment): string {
196
196
  const parts: string[] = [`Slack attachment #${attachment.id}: ${attachment.kind}`]
197
197
  if (attachment.mimetype !== undefined) parts.push(attachment.mimetype)
198
198
  if (attachment.filename !== undefined) parts.push(`name=${attachment.filename}`)