typeclaw 0.20.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -0,0 +1,101 @@
1
+ import { basename } from 'node:path'
2
+
3
+ import { writeRestartHandoff } from '@/agent/restart-handoff'
4
+ import { send, sendHttp } from '@/hostd/client'
5
+ import { containerSocketPath } from '@/hostd/paths'
6
+ import type { Stream } from '@/stream'
7
+
8
+ const ACK_TIMEOUT_MS = 5_000
9
+
10
+ export type ContainerRestartingBroadcast = {
11
+ kind: 'container-restarting'
12
+ restartedAt: string
13
+ originatingSessionId: string
14
+ }
15
+
16
+ export type RequestContainerRestartOptions = {
17
+ containerName: string
18
+ build?: boolean
19
+ socketPath?: string
20
+ hostdUrl?: string
21
+ hostdToken?: string
22
+ ackTimeoutMs?: number
23
+ // When present together with originatingSessionId, the post-ACK
24
+ // container-restarting broadcast is published here so every live session's
25
+ // subscribeRestartNotice fans out the restart notice (originator gets
26
+ // typeclaw.restart-self, siblings get typeclaw.restart). Both the tool and
27
+ // the server /restart path route through this so the broadcast->handoff
28
+ // ordering lives in one place.
29
+ stream?: Stream
30
+ agentDir?: string
31
+ originatingSessionId?: string
32
+ originatingSessionFile?: string
33
+ restartedAt?: string
34
+ }
35
+
36
+ export type RequestContainerRestartResult =
37
+ | { ok: true; containerName: string; restartedAt: string }
38
+ | { ok: false; containerName: string; reason: string }
39
+
40
+ export async function requestContainerRestart({
41
+ containerName,
42
+ build,
43
+ socketPath,
44
+ hostdUrl,
45
+ hostdToken,
46
+ ackTimeoutMs,
47
+ stream,
48
+ agentDir,
49
+ originatingSessionId,
50
+ originatingSessionFile,
51
+ restartedAt,
52
+ }: RequestContainerRestartOptions): Promise<RequestContainerRestartResult> {
53
+ const request = { kind: 'restart' as const, containerName, build: build === true }
54
+ const httpUrl = hostdUrl ?? process.env.TYPECLAW_HOSTD_URL
55
+ const httpToken = hostdToken ?? process.env.TYPECLAW_HOSTD_TOKEN
56
+ const ackBudget = ackTimeoutMs ?? ACK_TIMEOUT_MS
57
+ const reply =
58
+ httpUrl && httpToken
59
+ ? await sendHttp(request, { timeoutMs: ackBudget, url: httpUrl, token: httpToken })
60
+ : await send(request, { timeoutMs: ackBudget, socket: socketPath ?? containerSocketPath() })
61
+
62
+ if (!reply.ok) return { ok: false, containerName, reason: reply.reason }
63
+
64
+ const restartTimestamp = restartedAt ?? new Date().toISOString()
65
+
66
+ // Fan out the restart notice to every live session BEFORE writing the handoff.
67
+ // The originating session's subscribeRestartNotice appends the
68
+ // typeclaw.restart-self entry synchronously (broker delivery + the JSONL
69
+ // append are both synchronous), so the handoff below points at a JSONL that
70
+ // already carries the "I'm back" instruction the rebooted container hydrates.
71
+ // Only after an accepted ACK, never on a failed/timed-out restart.
72
+ if (stream !== undefined && originatingSessionId !== undefined) {
73
+ const broadcast: ContainerRestartingBroadcast = {
74
+ kind: 'container-restarting',
75
+ restartedAt: restartTimestamp,
76
+ originatingSessionId,
77
+ }
78
+ stream.publish({ target: { kind: 'broadcast' }, payload: broadcast })
79
+ }
80
+
81
+ // Post-ACK: hostd has committed the restart, so a handoff-write failure must
82
+ // never demote it to a failure — that would render a false error in the TUI
83
+ // and swallow the accepted response. The handoff is a best-effort resume hint
84
+ // only; a missing one just cold-starts the rebooted container without the
85
+ // "I'm back" greeting. writeRestartHandoff swallows its own errors today, but
86
+ // guard here too so this contract survives the writer being changed later.
87
+ if (agentDir !== undefined && originatingSessionId !== undefined && originatingSessionFile !== undefined) {
88
+ try {
89
+ await writeRestartHandoff(agentDir, {
90
+ schemaVersion: 1,
91
+ restartedAt: restartTimestamp,
92
+ originatingSessionId,
93
+ originatingSessionFile: basename(originatingSessionFile),
94
+ })
95
+ } catch {
96
+ // intentional swallow — see the post-ACK rationale above
97
+ }
98
+ }
99
+
100
+ return { ok: true, containerName, restartedAt: restartTimestamp }
101
+ }
@@ -1,14 +1,9 @@
1
- import { basename } from 'node:path'
2
-
3
1
  import { Type } from '@mariozechner/pi-ai'
4
2
  import { defineTool } from '@mariozechner/pi-coding-agent'
5
3
 
6
- import { writeRestartHandoff } from '@/agent/restart-handoff'
7
- import { send, sendHttp } from '@/hostd/client'
8
- import { containerSocketPath } from '@/hostd/paths'
4
+ import { requestContainerRestart } from '@/agent/restart'
9
5
  import type { Stream } from '@/stream'
10
6
 
11
- const ACK_TIMEOUT_MS = 5_000
12
7
  const EXIT_DELAY_MS = 500
13
8
 
14
9
  export type CreateRestartToolOptions = {
@@ -61,11 +56,7 @@ export type CreateRestartToolOptions = {
61
56
 
62
57
  export type RestartToolDetails = { ok: boolean; containerName: string; reason?: string }
63
58
 
64
- export type ContainerRestartingBroadcast = {
65
- kind: 'container-restarting'
66
- restartedAt: string
67
- originatingSessionId: string
68
- }
59
+ export type { ContainerRestartingBroadcast } from '@/agent/restart'
69
60
 
70
61
  export function createRestartTool({
71
62
  containerName,
@@ -80,9 +71,6 @@ export function createRestartTool({
80
71
  originatingSessionFile,
81
72
  }: CreateRestartToolOptions) {
82
73
  const doExit = exit ?? ((code: number) => process.exit(code))
83
- const httpUrl = hostdUrl ?? process.env.TYPECLAW_HOSTD_URL
84
- const ackBudget = ackTimeoutMs ?? ACK_TIMEOUT_MS
85
- const httpToken = hostdToken ?? process.env.TYPECLAW_HOSTD_TOKEN
86
74
 
87
75
  return defineTool({
88
76
  name: 'restart',
@@ -109,49 +97,32 @@ export function createRestartTool({
109
97
  }),
110
98
  async execute(_toolCallId, params) {
111
99
  const build = params.build === true
112
- const request = { kind: 'restart' as const, containerName, build }
113
- const reply =
114
- httpUrl && httpToken
115
- ? await sendHttp(request, { timeoutMs: ackBudget, url: httpUrl, token: httpToken })
116
- : await send(request, { timeoutMs: ackBudget, socket: socketPath ?? containerSocketPath() })
117
- if (!reply.ok) {
118
- const details: RestartToolDetails = { ok: false, containerName, reason: reply.reason }
100
+ // requestContainerRestart owns the post-ACK broadcast->handoff ordering:
101
+ // on a successful ACK it publishes the container-restarting notice (which
102
+ // every live session's subscribeRestartNotice turns into a transcript
103
+ // entry) and then writes the handoff. Handoff fields are gated to TUI
104
+ // origins by the caller passing originatingSessionFile only for those
105
+ // see issue #291's scoping concerns.
106
+ const result = await requestContainerRestart({
107
+ containerName,
108
+ build,
109
+ originatingSessionId,
110
+ ...(socketPath !== undefined ? { socketPath } : {}),
111
+ ...(hostdUrl !== undefined ? { hostdUrl } : {}),
112
+ ...(hostdToken !== undefined ? { hostdToken } : {}),
113
+ ...(ackTimeoutMs !== undefined ? { ackTimeoutMs } : {}),
114
+ ...(stream !== undefined ? { stream } : {}),
115
+ ...(agentDir !== undefined ? { agentDir } : {}),
116
+ ...(originatingSessionFile !== undefined ? { originatingSessionFile } : {}),
117
+ })
118
+ if (!result.ok) {
119
+ const details: RestartToolDetails = { ok: false, containerName, reason: result.reason }
119
120
  return {
120
- content: [{ type: 'text' as const, text: `restart denied: ${reply.reason}` }],
121
+ content: [{ type: 'text' as const, text: `restart denied: ${result.reason}` }],
121
122
  details,
122
123
  }
123
124
  }
124
125
 
125
- // Hostd ACK == restart is committed. Fan out the notice to every live
126
- // session BEFORE arming the exit timer. Stream broker delivery is
127
- // synchronous (broker.ts deliver()) and SessionManager.appendCustomMessageEntry
128
- // does a synchronous JSONL write, so the fan-out completes inside this
129
- // tick — well before the EXIT_DELAY_MS timer fires.
130
- const restartedAt = new Date().toISOString()
131
- const broadcast: ContainerRestartingBroadcast = {
132
- kind: 'container-restarting',
133
- restartedAt,
134
- originatingSessionId,
135
- }
136
- stream?.publish({ target: { kind: 'broadcast' }, payload: broadcast })
137
-
138
- // Write the cross-restart handoff AFTER the broadcast has run so the
139
- // originating session's JSONL already contains the `typeclaw.restart-self`
140
- // custom message entry that the next container will hydrate on
141
- // `SessionManager.open`. Without that ordering, the new container could
142
- // theoretically open the JSONL before the entry was flushed and miss
143
- // the model-instruction the entry carries. Gated on agentDir +
144
- // originatingSessionFile so non-TUI origins (channel/cron/subagent)
145
- // skip the file write — see issue #291's scoping concerns.
146
- if (agentDir !== undefined && originatingSessionFile !== undefined) {
147
- await writeRestartHandoff(agentDir, {
148
- schemaVersion: 1,
149
- restartedAt,
150
- originatingSessionId,
151
- originatingSessionFile: basename(originatingSessionFile),
152
- })
153
- }
154
-
155
126
  // Schedule the exit on the next tick so the tool result is delivered to
156
127
  // the model before the process dies. The host daemon polls for the
157
128
  // container's removal before re-running `start`, so a small delay here
@@ -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,
@@ -487,6 +495,9 @@ type DiscordRawHistoryMessage = {
487
495
  content: string
488
496
  timestamp: string
489
497
  message_reference?: { message_id?: string }
498
+ attachments?: DiscordFile[]
499
+ embeds?: DiscordGatewayEmbed[]
500
+ sticker_items?: DiscordGatewayStickerItem[]
490
501
  }
491
502
 
492
503
  // Discord treats threads as separate channels with their own snowflake ids,
@@ -551,14 +562,28 @@ export function createDiscordHistoryCallback(deps: {
551
562
  function mapDiscordMessage(msg: DiscordRawHistoryMessage, botUserId: string | null): ChannelHistoryMessage {
552
563
  const isBot = msg.author.bot === true || (botUserId !== null && msg.author.id === botUserId)
553
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')}`
554
578
  return {
555
579
  externalMessageId: msg.id,
556
580
  authorId: msg.author.id,
557
581
  authorName: msg.author.global_name ?? msg.author.username ?? msg.author.id,
558
- text: msg.content,
582
+ text,
559
583
  ts: Number.isFinite(ts) ? ts : 0,
560
584
  isBot,
561
585
  replyToBotMessageId: msg.message_reference?.message_id ?? null,
586
+ ...(attachments.length > 0 ? { attachments } : {}),
562
587
  }
563
588
  }
564
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,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'
@@ -23,6 +25,13 @@ export type GithubWebhookHandlerOptions = {
23
25
  // omitted, team-reviewer requests are silently dropped (the v1 fallback
24
26
  // behavior). The adapter wires this in production; tests inject a fake.
25
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
26
35
  }
27
36
 
28
37
  export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions): (req: Request) => Promise<Response> {
@@ -51,6 +60,7 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
51
60
  const selfLogin = options.selfLogin()
52
61
  const author = readAuthor(event, payload)
53
62
  if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
63
+ maybeScheduleDecoyReviewerDrop({ event, action, payload, selfLogin, options })
54
64
  options.logger.info(
55
65
  `[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
56
66
  )
@@ -70,6 +80,65 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
70
80
  }
71
81
  }
72
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
+
73
142
  export async function verifySignature(body: string, secret: string, sigHeader: string): Promise<boolean> {
74
143
  const expected = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
75
144
  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,
@@ -145,6 +150,8 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
145
150
  selfLogin: () => selfLogin,
146
151
  authType: () => options.secrets.auth.type,
147
152
  isBotInTeam,
153
+ authToken,
154
+ fetchImpl,
148
155
  logger,
149
156
  route: (message) => {
150
157
  rememberWorkspace(message.workspace, message.chat)
@@ -168,6 +175,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
168
175
  // is fully wired before any webhook can arrive.
169
176
  options.router.registerOutbound('github', outbound)
170
177
  options.router.registerReaction('github', reaction)
178
+ options.router.registerRemoveReaction('github', removeReaction)
171
179
  options.router.registerTyping('github', typing)
172
180
  options.router.registerHistory('github', history)
173
181
  options.router.registerMembership('github', membership)
@@ -180,6 +188,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
180
188
  // and the manager can report the failure cleanly.
181
189
  options.router.unregisterOutbound('github', outbound)
182
190
  options.router.unregisterReaction('github', reaction)
191
+ options.router.unregisterRemoveReaction('github', removeReaction)
183
192
  options.router.unregisterTyping('github', typing)
184
193
  options.router.unregisterHistory('github', history)
185
194
  options.router.unregisterMembership('github', membership)
@@ -301,6 +310,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
301
310
  started = false
302
311
  options.router.unregisterOutbound('github', outbound)
303
312
  options.router.unregisterReaction('github', reaction)
313
+ options.router.unregisterRemoveReaction('github', removeReaction)
304
314
  options.router.unregisterTyping('github', typing)
305
315
  options.router.unregisterHistory('github', history)
306
316
  options.router.unregisterMembership('github', membership)