typeclaw 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +42 -5
  4. package/src/agent/llm-replay-sanitizer.ts +120 -0
  5. package/src/agent/loop-guard.ts +34 -0
  6. package/src/agent/multimodal/look-at.ts +1 -1
  7. package/src/agent/plugin-tools.ts +90 -12
  8. package/src/agent/session-origin.ts +58 -5
  9. package/src/agent/subagent-completion-reminder.ts +39 -1
  10. package/src/agent/subagents.ts +31 -2
  11. package/src/agent/system-prompt.ts +1 -1
  12. package/src/agent/tool-not-found-nudge.ts +8 -1
  13. package/src/agent/tools/channel-react.ts +11 -4
  14. package/src/agent/tools/channel-reply.ts +3 -3
  15. package/src/agent/tools/curl-impersonate.ts +2 -2
  16. package/src/agent/tools/spawn-subagent.ts +19 -2
  17. package/src/agent/tools/subagent-access.ts +40 -5
  18. package/src/agent/tools/subagent-cancel.ts +3 -1
  19. package/src/agent/tools/subagent-output.ts +6 -2
  20. package/src/agent/tools/webfetch/fetch.ts +18 -18
  21. package/src/agent/tools/webfetch/index.ts +1 -1
  22. package/src/agent/tools/webfetch/tool.ts +13 -13
  23. package/src/agent/tools/webfetch/types.ts +1 -1
  24. package/src/agent/tools/websearch.ts +6 -6
  25. package/src/bundled-plugins/backup/index.ts +40 -37
  26. package/src/bundled-plugins/backup/runner.ts +22 -1
  27. package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
  28. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
  29. package/src/bundled-plugins/memory/README.md +11 -11
  30. package/src/bundled-plugins/memory/dreaming.ts +5 -0
  31. package/src/bundled-plugins/memory/search-tool.ts +98 -1
  32. package/src/bundled-plugins/operator/operator.ts +5 -1
  33. package/src/bundled-plugins/reviewer/reviewer.ts +18 -9
  34. package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
  35. package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
  36. package/src/bundled-plugins/scout/scout.ts +7 -7
  37. package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
  38. package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
  39. package/src/bundled-plugins/tool-result-cap/README.md +1 -1
  40. package/src/channels/adapters/discord-bot-classify.ts +3 -0
  41. package/src/channels/adapters/discord-bot-reactions.ts +164 -0
  42. package/src/channels/adapters/discord-bot.ts +23 -0
  43. package/src/channels/adapters/github/inbound.ts +19 -4
  44. package/src/channels/adapters/github/webhook-register.ts +32 -27
  45. package/src/channels/adapters/slack-bot-classify.ts +2 -0
  46. package/src/channels/adapters/slack-bot-reactions.ts +167 -0
  47. package/src/channels/adapters/slack-bot.ts +24 -0
  48. package/src/channels/router.ts +63 -23
  49. package/src/channels/schema.ts +43 -1
  50. package/src/channels/subagent-completion-bridge.ts +18 -18
  51. package/src/channels/types.ts +1 -1
  52. package/src/cli/inspect-controller.ts +130 -38
  53. package/src/config/config.ts +43 -2
  54. package/src/container/start.ts +7 -1
  55. package/src/git/mutex.ts +22 -0
  56. package/src/git/reconcile-ignored.ts +214 -0
  57. package/src/hostd/daemon.ts +26 -1
  58. package/src/hostd/portbroker-manager.ts +7 -0
  59. package/src/init/dockerfile.ts +1 -1
  60. package/src/init/gitignore.ts +25 -16
  61. package/src/init/index.ts +3 -3
  62. package/src/inspect/index.ts +31 -4
  63. package/src/inspect/loop.ts +16 -12
  64. package/src/plugin/define.ts +2 -2
  65. package/src/plugin/index.ts +2 -2
  66. package/src/portbroker/hostd-client.ts +36 -13
  67. package/src/run/index.ts +14 -0
  68. package/src/sandbox/build.ts +10 -0
  69. package/src/sandbox/index.ts +9 -1
  70. package/src/sandbox/policy.ts +12 -0
  71. package/src/sandbox/session-tmp.ts +43 -0
  72. package/src/sandbox/writable-zones.ts +103 -3
  73. package/src/server/command-runner.ts +1 -1
  74. package/src/server/index.ts +8 -0
  75. package/src/skills/typeclaw-channel-github/SKILL.md +38 -11
  76. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  77. package/src/tui/format.ts +11 -11
  78. package/typeclaw.schema.json +1 -0
@@ -100,7 +100,7 @@ export function classifyUrl(rawUrl: string): SsrfClassification {
100
100
 
101
101
  export function checkSsrfGuard(options: { tool: string; args: Record<string, unknown> }): SecurityBlock | undefined {
102
102
  const { tool, args } = options
103
- if (tool !== 'webfetch') return undefined
103
+ if (tool !== 'web_fetch') return undefined
104
104
  const url = args.url
105
105
  if (typeof url !== 'string') return undefined
106
106
  if (isGuardAcknowledged(args, GUARD_SSRF)) return undefined
@@ -111,9 +111,9 @@ export function checkSsrfGuard(options: { tool: string; args: Record<string, unk
111
111
  return {
112
112
  block: true,
113
113
  reason: [
114
- `Guard \`${GUARD_SSRF}\` blocked webfetch to a non-public destination (${result.category ?? 'unknown'}): ${result.reason ?? 'classified as internal'}.`,
114
+ `Guard \`${GUARD_SSRF}\` blocked web_fetch to a non-public destination (${result.category ?? 'unknown'}): ${result.reason ?? 'classified as internal'}.`,
115
115
  'This protects against SSRF, cloud metadata exfiltration, and accidental fetches against internal services.',
116
- `If this is genuinely intentional and you trust the URL, retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_SSRF}: true\` in the webfetch arguments.`,
116
+ `If this is genuinely intentional and you trust the URL, retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_SSRF}: true\` in the web_fetch arguments.`,
117
117
  ].join(' '),
118
118
  }
119
119
  }
@@ -9,7 +9,7 @@ This plugin is **auto-loaded** by every TypeClaw agent. There is no `plugins[]`
9
9
  `pi-coding-agent`'s built-in tools occasionally return very large payloads that the model only needed once. Two empirically observed cases:
10
10
 
11
11
  1. **`read` on an image file** returns the base64-encoded image inline (e.g. `{type:"image", data:"<3.2MB of base64>"}`). The model uses it on the turn it was asked for, then sees the same 3.2MB of base64 as conversation context on every subsequent prompt — until compaction fires (which is token-driven, not byte-driven, so a single fat blob may sit in context for many turns before compaction is triggered).
12
- 2. **`webfetch` on a binary URL** (PNG, ZIP, etc.) receives the raw response body, treats it as text, and stores raw binary as a JSON-encoded string. Same effect: 100KB+ of mojibake sits in the transcript permanently.
12
+ 2. **`web_fetch` on a binary URL** (PNG, ZIP, etc.) receives the raw response body, treats it as text, and stores raw binary as a JSON-encoded string. Same effect: 100KB+ of mojibake sits in the transcript permanently.
13
13
 
14
14
  The result is a session JSONL file that's tens of megabytes on disk but mostly one or two giant tool results, plus 3-minute first-prompt latencies after container restart because the full transcript gets re-shipped to the LLM as context.
15
15
 
@@ -8,6 +8,8 @@ import type {
8
8
  import type { ChannelAdapterConfig } from '@/channels/schema'
9
9
  import type { InboundAttachment, InboundMessage } from '@/channels/types'
10
10
 
11
+ import { encodeDiscordReactionRef } from './discord-bot-reactions'
12
+
11
13
  export type InboundDropReason =
12
14
  | 'self_author' // event.author.id === botUserId; we never route our own messages back to ourselves
13
15
  | 'empty_content' // SDK delivered content: '' — usually missing MessageContent intent
@@ -87,6 +89,7 @@ export function classifyInbound(
87
89
  text,
88
90
  ...(attachments.length > 0 ? { attachments } : {}),
89
91
  externalMessageId: event.id,
92
+ reactionRef: encodeDiscordReactionRef({ channel: event.channel_id, message: event.id }),
90
93
  authorId: event.author.id,
91
94
  // Discord's post-2023 username system allows pure-numeric handles (e.g.
92
95
  // "1411531"); the human-facing display name lives on `global_name`. The
@@ -0,0 +1,164 @@
1
+ import type { DiscordBotClient } from 'agent-messenger/discordbot'
2
+
3
+ import type {
4
+ ReactionCallback,
5
+ ReactionErrorCode,
6
+ ReactionRef,
7
+ ReactionResult,
8
+ RemoveReactionCallback,
9
+ } from '@/channels/types'
10
+
11
+ // The reactable target on Discord: a message is addressed by its channel id
12
+ // plus the message id. The classifier stamps this because both values are on
13
+ // the gateway event but `chat`/`externalMessageId` are the only ones that
14
+ // survive into the routed payload — keeping them paired in an opaque ref means
15
+ // the router/tool never have to reassemble Discord's addressing.
16
+ export type DiscordReactionTarget = { channel: string; message: string }
17
+
18
+ // `RemoveReactionRequest` carries no emoji (the router only round-trips the
19
+ // success ref), so removal needs the resolved unicode folded into the ref:
20
+ // Discord's DELETE is keyed by (channel, message, emoji). Mirrors Slack's
21
+ // removal-ref pattern; GitHub instead carries a per-reaction id.
22
+ export type DiscordReactionRemovalTarget = { channel: string; message: string; emoji: string }
23
+
24
+ export function encodeDiscordReactionRef(target: DiscordReactionTarget): ReactionRef {
25
+ return { adapter: 'discord-bot', value: JSON.stringify(target) }
26
+ }
27
+
28
+ export function decodeDiscordReactionRef(ref: ReactionRef): DiscordReactionTarget | null {
29
+ if (ref.adapter !== 'discord-bot') return null
30
+ const parsed = parseRecord(ref.value)
31
+ if (parsed === null || parsed.op !== undefined) return null
32
+ const channel = typeof parsed.channel === 'string' ? parsed.channel : null
33
+ const message = typeof parsed.message === 'string' ? parsed.message : null
34
+ if (channel === null || message === null) return null
35
+ return { channel, message }
36
+ }
37
+
38
+ export function encodeDiscordRemovalRef(target: DiscordReactionRemovalTarget): ReactionRef {
39
+ return { adapter: 'discord-bot', value: JSON.stringify({ op: 'remove', ...target }) }
40
+ }
41
+
42
+ export function decodeDiscordRemovalRef(ref: ReactionRef): DiscordReactionRemovalTarget | null {
43
+ if (ref.adapter !== 'discord-bot') return null
44
+ const parsed = parseRecord(ref.value)
45
+ if (parsed === null || parsed.op !== 'remove') return null
46
+ const channel = typeof parsed.channel === 'string' ? parsed.channel : null
47
+ const message = typeof parsed.message === 'string' ? parsed.message : null
48
+ const emoji = typeof parsed.emoji === 'string' ? parsed.emoji : null
49
+ if (channel === null || message === null || emoji === null) return null
50
+ return { channel, message, emoji }
51
+ }
52
+
53
+ // Discord's reaction endpoint takes the literal unicode emoji (URL-encoded by
54
+ // the SDK), NOT a `:name:` shortcode — the agent passes adapter-generic bare
55
+ // names (`+1`, `rocket`), so we translate here. The set mirrors GitHub's fixed
56
+ // reaction vocabulary so the same `channel_react({ emoji })` call works on both
57
+ // platforms, plus a few extra chat-native acks. An unmapped name is reported as
58
+ // `unsupported` rather than forwarded, so a typo gets a clear signal instead of
59
+ // Discord's opaque `10014 Unknown Emoji`.
60
+ const EMOJI_UNICODE: Record<string, string> = {
61
+ eyes: '👀',
62
+ '+1': '👍',
63
+ thumbsup: '👍',
64
+ '-1': '👎',
65
+ thumbsdown: '👎',
66
+ laugh: '😄',
67
+ hooray: '🎉',
68
+ tada: '🎉',
69
+ confused: '😕',
70
+ heart: '❤️',
71
+ rocket: '🚀',
72
+ white_check_mark: '✅',
73
+ 'white-check-mark': '✅',
74
+ check: '✅',
75
+ fire: '🔥',
76
+ eye: '👁️',
77
+ raised_hands: '🙌',
78
+ }
79
+
80
+ function resolveEmoji(emoji: string): string | null {
81
+ const name = emoji.replace(/^:|:$/g, '')
82
+ return EMOJI_UNICODE[name] ?? null
83
+ }
84
+
85
+ export function createDiscordReactionCallback(deps: {
86
+ client: Pick<DiscordBotClient, 'addReaction'>
87
+ }): ReactionCallback {
88
+ return async (req): Promise<ReactionResult> => {
89
+ if (req.adapter !== 'discord-bot') {
90
+ return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
91
+ }
92
+ const unicode = resolveEmoji(req.emoji)
93
+ if (unicode === null) {
94
+ return { ok: false, error: `discord does not support reaction "${req.emoji}"`, code: 'unsupported' }
95
+ }
96
+ const target = decodeDiscordReactionRef(req.reactionRef)
97
+ if (target === null) return { ok: false, error: 'unparseable discord reaction ref', code: 'unsupported' }
98
+ try {
99
+ await deps.client.addReaction(target.channel, target.message, unicode)
100
+ } catch (err) {
101
+ return { ok: false, error: describe(err), code: classifyDiscordError(err) }
102
+ }
103
+ return { ok: true, reactionRef: encodeDiscordRemovalRef({ ...target, emoji: unicode }) }
104
+ }
105
+ }
106
+
107
+ export function createDiscordRemoveReactionCallback(deps: {
108
+ client: Pick<DiscordBotClient, 'removeReaction'>
109
+ }): RemoveReactionCallback {
110
+ return async (req): Promise<ReactionResult> => {
111
+ if (req.adapter !== 'discord-bot') {
112
+ return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
113
+ }
114
+ const target = decodeDiscordRemovalRef(req.reactionRef)
115
+ if (target === null) return { ok: false, error: 'unparseable discord reaction removal ref', code: 'unsupported' }
116
+ try {
117
+ await deps.client.removeReaction(target.channel, target.message, target.emoji)
118
+ } catch (err) {
119
+ return { ok: false, error: describe(err), code: classifyDiscordError(err) }
120
+ }
121
+ return { ok: true }
122
+ }
123
+ }
124
+
125
+ // DiscordBotError exposes the Discord error code on `.code` as a string. We
126
+ // read it structurally so a wrapped/re-thrown error still classifies. The
127
+ // numeric codes are Discord's documented JSON error codes; `http_4xx` is the
128
+ // SDK's fallback when no JSON code is present.
129
+ function classifyDiscordError(err: unknown): ReactionErrorCode {
130
+ const code = typeof err === 'object' && err !== null && 'code' in err ? String((err as { code: unknown }).code) : ''
131
+ switch (code) {
132
+ case '10003': // Unknown Channel
133
+ case '10008': // Unknown Message
134
+ case 'http_404':
135
+ return 'not-found'
136
+ case '50001': // Missing Access
137
+ case '50013': // Missing Permissions
138
+ case 'http_403':
139
+ case 'http_401':
140
+ return 'permission-denied'
141
+ case '10014': // Unknown Emoji
142
+ return 'unsupported'
143
+ case 'http_429':
144
+ return 'rate-limited'
145
+ default:
146
+ return 'transient'
147
+ }
148
+ }
149
+
150
+ function parseRecord(value: string): Record<string, unknown> | null {
151
+ let parsed: unknown
152
+ try {
153
+ parsed = JSON.parse(value)
154
+ } catch {
155
+ return null
156
+ }
157
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
158
+ ? (parsed as Record<string, unknown>)
159
+ : null
160
+ }
161
+
162
+ function describe(err: unknown): string {
163
+ return err instanceof Error ? err.message : String(err)
164
+ }
@@ -39,6 +39,7 @@ import {
39
39
  type InboundDropReason,
40
40
  renderPlaceholder,
41
41
  } from './discord-bot-classify'
42
+ import { createDiscordReactionCallback, createDiscordRemoveReactionCallback } from './discord-bot-reactions'
42
43
  import { enrichDiscordMessageReferences } from './discord-bot-reference'
43
44
  import {
44
45
  ackInteraction,
@@ -863,6 +864,9 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
863
864
 
864
865
  const fetchAttachmentCallback = createFetchAttachmentCallback({ token: options.token, logger })
865
866
 
867
+ const reactionCallback = createDiscordReactionCallback({ client })
868
+ const removeReactionCallback = createDiscordRemoveReactionCallback({ client })
869
+
866
870
  const interactionHandler = createInteractionHandler({
867
871
  router: options.router,
868
872
  knownCommandNames: SLASH_COMMAND_NAMES,
@@ -999,6 +1003,8 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
999
1003
  })
1000
1004
 
1001
1005
  options.router.registerOutbound('discord-bot', outboundCallback)
1006
+ options.router.registerReaction('discord-bot', reactionCallback)
1007
+ options.router.registerRemoveReaction('discord-bot', removeReactionCallback)
1002
1008
  options.router.registerTyping('discord-bot', typingCallback)
1003
1009
  options.router.registerChannelNameResolver('discord-bot', channelResolver)
1004
1010
  options.router.registerSelfIdentity('discord-bot', selfIdentityResolver)
@@ -1009,6 +1015,21 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
1009
1015
  try {
1010
1016
  await listener.start()
1011
1017
  } catch (err) {
1018
+ // Listener failed after registration — roll back every callback so a
1019
+ // failed start leaves no router state behind (stop() returns early on
1020
+ // !started and would otherwise skip cleanup), mirroring the github
1021
+ // adapter's rollback path.
1022
+ options.router.unregisterOutbound('discord-bot', outboundCallback)
1023
+ options.router.unregisterReaction('discord-bot', reactionCallback)
1024
+ options.router.unregisterRemoveReaction('discord-bot', removeReactionCallback)
1025
+ options.router.unregisterTyping('discord-bot', typingCallback)
1026
+ options.router.unregisterChannelNameResolver('discord-bot', channelResolver)
1027
+ options.router.unregisterSelfIdentity('discord-bot', selfIdentityResolver)
1028
+ options.router.unregisterHistory('discord-bot', historyCallback)
1029
+ options.router.unregisterFetchAttachment('discord-bot', fetchAttachmentCallback)
1030
+ options.router.unregisterMembership('discord-bot', membershipResolver)
1031
+ listener = null
1032
+ botUserId = null
1012
1033
  started = false
1013
1034
  logger.error(`[discord-bot] listener start failed: ${describe(err)}`)
1014
1035
  throw err
@@ -1019,6 +1040,8 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
1019
1040
  if (!started) return
1020
1041
  started = false
1021
1042
  options.router.unregisterOutbound('discord-bot', outboundCallback)
1043
+ options.router.unregisterReaction('discord-bot', reactionCallback)
1044
+ options.router.unregisterRemoveReaction('discord-bot', removeReactionCallback)
1022
1045
  options.router.unregisterTyping('discord-bot', typingCallback)
1023
1046
  options.router.unregisterChannelNameResolver('discord-bot', channelResolver)
1024
1047
  options.router.unregisterSelfIdentity('discord-bot', selfIdentityResolver)
@@ -301,7 +301,12 @@ export function classifyGithubInbound(
301
301
  teamIsBotMember: options?.teamIsBotMember,
302
302
  })
303
303
  }
304
- if (action === 'opened' && reviewOn === 'opened') {
304
+ // `ready_for_review` (draft→ready) is a fresh review opportunity, so it
305
+ // reuses the `opened` paths: same review trigger, same title-only awareness
306
+ // text. The draft skip in classifyOpenedReviewTrigger is a no-op here since
307
+ // the PR is non-draft once ready — preserving "review when no longer draft".
308
+ const isOpenLike = action === 'opened' || action === 'ready_for_review'
309
+ if (isOpenLike && reviewOn === 'opened') {
305
310
  const trigger = classifyOpenedReviewTrigger({
306
311
  payload,
307
312
  pr,
@@ -314,8 +319,7 @@ export function classifyGithubInbound(
314
319
  }
315
320
  const opener = readUser(pr.user)
316
321
  const hasBody = readString(pr, 'body')?.trim() ? true : false
317
- const prText =
318
- action === 'opened' ? bodyOrOpenedTitle(pr.body, opener, 'PR', number, readString(pr, 'title')) : pr.body
322
+ const prText = isOpenLike ? bodyOrOpenedTitle(pr.body, opener, 'PR', number, readString(pr, 'title')) : pr.body
319
323
  return buildInbound(
320
324
  { ...base, chat: `pr:${number}`, thread: null },
321
325
  prText,
@@ -324,7 +328,7 @@ export function classifyGithubInbound(
324
328
  selfLogin,
325
329
  pr.created_at,
326
330
  { kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
327
- action === 'opened' && !hasBody,
331
+ isOpenLike && !hasBody,
328
332
  )
329
333
  }
330
334
 
@@ -494,6 +498,12 @@ function classifyOpenedReviewTrigger(input: OpenedReviewTriggerInput): InboundMe
494
498
  const decoyLogin = resolveDecoyReviewerLogin(selfLogin, authType)
495
499
  if (sender.login === selfLogin || (decoyLogin !== null && sender.login === decoyLogin)) return null
496
500
 
501
+ // A draft PR is work-in-progress, so the automatic `opened` path skips it: null
502
+ // here drops to awareness-only context (like a non-`opened` reviewOn) instead of
503
+ // waking a review. An explicit `review_requested` still triggers on a draft via
504
+ // classifyReviewRequest, preserving "skip until explicitly requested".
505
+ if (readBoolean(pr, 'draft') === true) return null
506
+
497
507
  const title = readString(pr, 'title') ?? `#${number}`
498
508
  const head = readString(readRecord(pr.head), 'ref')
499
509
  const baseRef = readString(readRecord(pr.base), 'ref')
@@ -738,6 +748,11 @@ function readNumber(obj: Record<string, unknown> | null, key: string): number |
738
748
  return typeof value === 'number' && Number.isFinite(value) ? value : null
739
749
  }
740
750
 
751
+ function readBoolean(obj: Record<string, unknown> | null, key: string): boolean | null {
752
+ const value = obj?.[key]
753
+ return typeof value === 'boolean' ? value : null
754
+ }
755
+
741
756
  function ok(): Response {
742
757
  return new Response('ok', { status: 200 })
743
758
  }
@@ -54,17 +54,25 @@ export async function registerGithubWebhooks(
54
54
  options: RegisterGithubWebhooksOptions,
55
55
  ): Promise<WebhookRegistrationResult> {
56
56
  const fetchImpl = options.fetchImpl ?? fetch
57
- const repos: WebhookRepoResult[] = []
58
- for (const repo of options.repos) {
59
- let token: string
60
- try {
61
- token = await options.token(repo)
62
- } catch (err) {
63
- repos.push({ repo, action: 'failed', error: describe(err) })
64
- continue
65
- }
66
- repos.push(await registerOne(fetchImpl, token, repo, options))
67
- }
57
+ // Dedupe before fanning out: the serial loop self-corrected on a repeated
58
+ // repo (the second pass saw the first pass's hook and updated it), but
59
+ // concurrent passes would both list an empty set and each POST a hook,
60
+ // creating a duplicate. Collapsing to distinct slugs restores convergence.
61
+ const distinctRepos = [...new Set(options.repos)]
62
+ // Repos are independent (own installation token, own hooks), so register them
63
+ // concurrently. Every task resolves to a result (failures are caught into a
64
+ // `failed` entry, never thrown), so the batch never rejects and order is kept.
65
+ const repos = await Promise.all(
66
+ distinctRepos.map(async (repo): Promise<WebhookRepoResult> => {
67
+ let token: string
68
+ try {
69
+ token = await options.token(repo)
70
+ } catch (err) {
71
+ return { repo, action: 'failed', error: describe(err) }
72
+ }
73
+ return registerOne(fetchImpl, token, repo, options)
74
+ }),
75
+ )
68
76
  return { repos }
69
77
  }
70
78
 
@@ -82,17 +90,17 @@ export async function deregisterGithubWebhooks(
82
90
  options: DeregisterGithubWebhooksOptions,
83
91
  ): Promise<WebhookDeregistrationResult> {
84
92
  const fetchImpl = options.fetchImpl ?? fetch
85
- const hooks: WebhookDeregistrationResult['hooks'] = []
86
- for (const hook of options.hooks) {
87
- let token: string
88
- try {
89
- token = await options.token(hook.repo)
90
- } catch (err) {
91
- hooks.push({ ...hook, action: 'failed', error: describe(err) })
92
- continue
93
- }
94
- hooks.push(await deleteOne(fetchImpl, token, hook))
95
- }
93
+ const hooks = await Promise.all(
94
+ options.hooks.map(async (hook): Promise<WebhookDeregistrationResult['hooks'][number]> => {
95
+ let token: string
96
+ try {
97
+ token = await options.token(hook.repo)
98
+ } catch (err) {
99
+ return { ...hook, action: 'failed', error: describe(err) }
100
+ }
101
+ return deleteOne(fetchImpl, token, hook)
102
+ }),
103
+ )
96
104
  return { hooks }
97
105
  }
98
106
 
@@ -125,11 +133,8 @@ async function registerOne(
125
133
  // inspecting the repo's webhook list.
126
134
  const [keep, ...stale] = owned.slice().sort((a, b) => a - b)
127
135
  await updateHook(fetchImpl, token, parsed, keep!, options)
128
- let stalePruned = 0
129
- for (const id of stale) {
130
- const ok = await tryDeleteHook(fetchImpl, token, parsed, id)
131
- if (ok) stalePruned++
132
- }
136
+ const pruned = await Promise.all(stale.map((id) => tryDeleteHook(fetchImpl, token, parsed, id)))
137
+ const stalePruned = pruned.filter(Boolean).length
133
138
  return { repo, action: 'updated', hookId: keep!, stalePruned }
134
139
  } catch (err) {
135
140
  return { repo, action: 'failed', error: describe(err) }
@@ -4,6 +4,7 @@ import { matchesAnyAlias } from '@/channels/engagement'
4
4
  import type { ChannelAdapterConfig } from '@/channels/schema'
5
5
  import type { InboundAttachment, InboundMessage } from '@/channels/types'
6
6
 
7
+ import { encodeSlackReactionRef } from './slack-bot-reactions'
7
8
  import { hasSlackMessageShareAttachments } from './slack-bot-reference'
8
9
  import { slackTsToMillis } from './slack-bot-time'
9
10
 
@@ -141,6 +142,7 @@ export function classifyInbound(
141
142
  text,
142
143
  ...(attachments.length > 0 ? { attachments } : {}),
143
144
  externalMessageId: event.ts,
145
+ reactionRef: encodeSlackReactionRef({ channel: event.channel, ts: event.ts }),
144
146
  authorId: event.user,
145
147
  authorName: event.user,
146
148
  authorIsBot,
@@ -0,0 +1,167 @@
1
+ import type { SlackBotClient } from 'agent-messenger/slackbot'
2
+
3
+ import type {
4
+ ReactionCallback,
5
+ ReactionErrorCode,
6
+ ReactionRef,
7
+ ReactionResult,
8
+ RemoveReactionCallback,
9
+ } from '@/channels/types'
10
+
11
+ // The reactable target on Slack: a message is addressed by its channel id plus
12
+ // the message `ts`. The classifier stamps this because `ts` is the inbound's
13
+ // own message timestamp — the same value that becomes `externalMessageId`
14
+ // downstream, but kept in an opaque ref so the router/tool never have to know
15
+ // Slack's addressing. Mirrors the GitHub `GithubReactionTarget` precedent.
16
+ export type SlackReactionTarget = { channel: string; ts: string }
17
+
18
+ // Removal needs the emoji name too: Slack's `reactions.remove` is keyed by
19
+ // (channel, ts, name), unlike GitHub's per-reaction id. We fold the emoji that
20
+ // was added into the removal ref so `RemoveReactionCallback` can reconstruct
21
+ // the exact call without the caller tracking it.
22
+ export type SlackReactionRemovalTarget = { channel: string; ts: string; emoji: string }
23
+
24
+ export function encodeSlackReactionRef(target: SlackReactionTarget): ReactionRef {
25
+ return { adapter: 'slack-bot', value: JSON.stringify(target) }
26
+ }
27
+
28
+ export function decodeSlackReactionRef(ref: ReactionRef): SlackReactionTarget | null {
29
+ if (ref.adapter !== 'slack-bot') return null
30
+ const parsed = parseRecord(ref.value)
31
+ if (parsed === null) return null
32
+ if (parsed.op !== undefined) return null
33
+ const channel = typeof parsed.channel === 'string' ? parsed.channel : null
34
+ const ts = typeof parsed.ts === 'string' ? parsed.ts : null
35
+ if (channel === null || ts === null) return null
36
+ return { channel, ts }
37
+ }
38
+
39
+ export function encodeSlackRemovalRef(target: SlackReactionRemovalTarget): ReactionRef {
40
+ return { adapter: 'slack-bot', value: JSON.stringify({ op: 'remove', ...target }) }
41
+ }
42
+
43
+ export function decodeSlackRemovalRef(ref: ReactionRef): SlackReactionRemovalTarget | null {
44
+ if (ref.adapter !== 'slack-bot') return null
45
+ const parsed = parseRecord(ref.value)
46
+ if (parsed === null || parsed.op !== 'remove') return null
47
+ const channel = typeof parsed.channel === 'string' ? parsed.channel : null
48
+ const ts = typeof parsed.ts === 'string' ? parsed.ts : null
49
+ const emoji = typeof parsed.emoji === 'string' ? parsed.emoji : null
50
+ if (channel === null || ts === null || emoji === null) return null
51
+ return { channel, ts, emoji }
52
+ }
53
+
54
+ // Slack accepts any custom-emoji name the workspace has, so unlike GitHub there
55
+ // is no fixed allow-list to validate against up front — an unknown name comes
56
+ // back as `invalid_name` from the API, which we map to `unsupported`. We only
57
+ // strip surrounding colons here; the SDK does the same, but normalizing first
58
+ // keeps the removal ref's stored name canonical.
59
+ function normalizeEmoji(emoji: string): string {
60
+ return emoji.replace(/^:|:$/g, '')
61
+ }
62
+
63
+ export function createSlackReactionCallback(deps: { client: Pick<SlackBotClient, 'addReaction'> }): ReactionCallback {
64
+ return async (req): Promise<ReactionResult> => {
65
+ if (req.adapter !== 'slack-bot') {
66
+ return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
67
+ }
68
+ const target = decodeSlackReactionRef(req.reactionRef)
69
+ if (target === null) return { ok: false, error: 'unparseable slack reaction ref', code: 'unsupported' }
70
+ const emoji = normalizeEmoji(req.emoji)
71
+ try {
72
+ await deps.client.addReaction(target.channel, target.ts, emoji)
73
+ } catch (err) {
74
+ // `already_reacted` is the desired end state, not a failure: a duplicate
75
+ // engage (or a retried tool call) that re-adds the same emoji must read
76
+ // as success so the model/runtime don't surface a spurious error.
77
+ const code = slackErrorCode(err)
78
+ if (code === 'already_reacted') {
79
+ return { ok: true, reactionRef: encodeSlackRemovalRef({ ...target, emoji }) }
80
+ }
81
+ return { ok: false, error: withScopeHint(code, describe(err)), code: classifySlackError(code) }
82
+ }
83
+ return { ok: true, reactionRef: encodeSlackRemovalRef({ ...target, emoji }) }
84
+ }
85
+ }
86
+
87
+ export function createSlackRemoveReactionCallback(deps: {
88
+ client: Pick<SlackBotClient, 'removeReaction'>
89
+ }): RemoveReactionCallback {
90
+ return async (req): Promise<ReactionResult> => {
91
+ if (req.adapter !== 'slack-bot') {
92
+ return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
93
+ }
94
+ const target = decodeSlackRemovalRef(req.reactionRef)
95
+ if (target === null) return { ok: false, error: 'unparseable slack reaction removal ref', code: 'unsupported' }
96
+ try {
97
+ await deps.client.removeReaction(target.channel, target.ts, target.emoji)
98
+ } catch (err) {
99
+ // `no_reaction` means the reaction is already gone — the desired end
100
+ // state for a removal, so treat it as success (idempotent), mirroring the
101
+ // `already_reacted` handling on the add path.
102
+ const code = slackErrorCode(err)
103
+ if (code === 'no_reaction') return { ok: true }
104
+ return { ok: false, error: describe(err), code: classifySlackError(code) }
105
+ }
106
+ return { ok: true }
107
+ }
108
+ }
109
+
110
+ // SlackBotError carries the raw Slack API error string on `.code`. We read it
111
+ // structurally (not by instanceof) so a re-thrown or wrapped error still maps
112
+ // correctly, falling back to the message when no code is present.
113
+ function slackErrorCode(err: unknown): string | null {
114
+ if (typeof err === 'object' && err !== null && 'code' in err) {
115
+ const code = (err as { code: unknown }).code
116
+ if (typeof code === 'string') return code
117
+ }
118
+ return null
119
+ }
120
+
121
+ // `reactions:write` is the scope the bot token needs for both add and remove.
122
+ // On `missing_scope` the bare Slack error is uninformative, so append the
123
+ // concrete operator fix — mirroring GitHub's permission-guidance precedent —
124
+ // since autoReactOnEngage surfaces this to host logs on every engaged inbound
125
+ // until the scope is granted.
126
+ function withScopeHint(code: string | null, error: string): string {
127
+ if (code !== 'missing_scope') return error
128
+ return `${error} (Slack bot token needs the \`reactions:write\` scope; reinstall/reauthorize the app with that scope.)`
129
+ }
130
+
131
+ function classifySlackError(code: string | null): ReactionErrorCode {
132
+ switch (code) {
133
+ case 'invalid_name':
134
+ case 'no_item_specified':
135
+ return 'unsupported'
136
+ case 'missing_scope':
137
+ case 'not_in_channel':
138
+ case 'is_archived':
139
+ case 'not_authed':
140
+ case 'invalid_auth':
141
+ return 'permission-denied'
142
+ case 'message_not_found':
143
+ case 'channel_not_found':
144
+ return 'not-found'
145
+ case 'ratelimited':
146
+ case 'rate_limited':
147
+ return 'rate-limited'
148
+ default:
149
+ return 'transient'
150
+ }
151
+ }
152
+
153
+ function parseRecord(value: string): Record<string, unknown> | null {
154
+ let parsed: unknown
155
+ try {
156
+ parsed = JSON.parse(value)
157
+ } catch {
158
+ return null
159
+ }
160
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
161
+ ? (parsed as Record<string, unknown>)
162
+ : null
163
+ }
164
+
165
+ function describe(err: unknown): string {
166
+ return err instanceof Error ? err.message : String(err)
167
+ }
@@ -43,6 +43,7 @@ import {
43
43
  type SlackInboundMessageEvent,
44
44
  } from './slack-bot-classify'
45
45
  import { createSlackDedupe } from './slack-bot-dedupe'
46
+ import { createSlackReactionCallback, createSlackRemoveReactionCallback } from './slack-bot-reactions'
46
47
  import { enrichSlackReferenceContext } from './slack-bot-reference'
47
48
  import {
48
49
  buildSlashAckPayload,
@@ -997,6 +998,9 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
997
998
 
998
999
  const fetchAttachmentCallback = createFetchAttachmentCallback({ client, logger })
999
1000
 
1001
+ const reactionCallback = createSlackReactionCallback({ client })
1002
+ const removeReactionCallback = createSlackRemoveReactionCallback({ client })
1003
+
1000
1004
  const dedupe = createSlackDedupe()
1001
1005
 
1002
1006
  const handleSlashCommand = createSlashCommandHandler({
@@ -1206,6 +1210,8 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1206
1210
  })
1207
1211
 
1208
1212
  options.router.registerOutbound('slack-bot', outboundCallback)
1213
+ options.router.registerReaction('slack-bot', reactionCallback)
1214
+ options.router.registerRemoveReaction('slack-bot', removeReactionCallback)
1209
1215
  options.router.registerTyping('slack-bot', typingCallback)
1210
1216
  options.router.registerChannelNameResolver('slack-bot', channelResolver)
1211
1217
  options.router.registerSelfIdentity('slack-bot', selfIdentityResolver)
@@ -1216,6 +1222,22 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1216
1222
  try {
1217
1223
  await listener.start()
1218
1224
  } catch (err) {
1225
+ // Listener failed after registration — roll back every callback so a
1226
+ // failed start leaves no router state behind (stop() returns early on
1227
+ // !started and would otherwise skip cleanup), mirroring the github
1228
+ // adapter's rollback path.
1229
+ options.router.unregisterOutbound('slack-bot', outboundCallback)
1230
+ options.router.unregisterReaction('slack-bot', reactionCallback)
1231
+ options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
1232
+ options.router.unregisterTyping('slack-bot', typingCallback)
1233
+ options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
1234
+ options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
1235
+ options.router.unregisterHistory('slack-bot', historyCallback)
1236
+ options.router.unregisterFetchAttachment('slack-bot', fetchAttachmentCallback)
1237
+ options.router.unregisterMembership('slack-bot', membershipResolver)
1238
+ listener = null
1239
+ botUserId = null
1240
+ teamId = null
1219
1241
  started = false
1220
1242
  logger.error(`[slack-bot] listener start failed: ${describe(err)}`)
1221
1243
  throw err
@@ -1226,6 +1248,8 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1226
1248
  if (!started) return
1227
1249
  started = false
1228
1250
  options.router.unregisterOutbound('slack-bot', outboundCallback)
1251
+ options.router.unregisterReaction('slack-bot', reactionCallback)
1252
+ options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
1229
1253
  options.router.unregisterTyping('slack-bot', typingCallback)
1230
1254
  options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
1231
1255
  options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)