typeclaw 0.23.0 → 0.25.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 (90) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +133 -27
  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 +122 -8
  8. package/src/agent/restart/index.ts +15 -3
  9. package/src/agent/restart-handoff/index.ts +110 -12
  10. package/src/agent/session-origin.ts +30 -0
  11. package/src/agent/subagent-completion-reminder.ts +26 -1
  12. package/src/agent/subagents.ts +75 -3
  13. package/src/agent/system-prompt.ts +5 -1
  14. package/src/agent/todo/continuation-policy.ts +242 -0
  15. package/src/agent/todo/continuation-state.ts +87 -0
  16. package/src/agent/todo/continuation-wiring.ts +113 -0
  17. package/src/agent/todo/continuation.ts +71 -0
  18. package/src/agent/todo/scope.ts +77 -0
  19. package/src/agent/todo/store.ts +98 -0
  20. package/src/agent/tool-not-found-nudge.ts +126 -0
  21. package/src/agent/tools/channel-reply.ts +51 -0
  22. package/src/agent/tools/curl-impersonate.ts +2 -2
  23. package/src/agent/tools/restart.ts +11 -4
  24. package/src/agent/tools/spawn-subagent.ts +19 -2
  25. package/src/agent/tools/subagent-access.ts +40 -5
  26. package/src/agent/tools/subagent-cancel.ts +3 -1
  27. package/src/agent/tools/subagent-output.ts +6 -2
  28. package/src/agent/tools/todo/index.ts +119 -0
  29. package/src/agent/tools/webfetch/fetch.ts +18 -18
  30. package/src/agent/tools/webfetch/index.ts +1 -1
  31. package/src/agent/tools/webfetch/tool.ts +13 -13
  32. package/src/agent/tools/webfetch/types.ts +1 -1
  33. package/src/agent/tools/websearch.ts +6 -6
  34. package/src/bundled-plugins/backup/index.ts +40 -37
  35. package/src/bundled-plugins/backup/runner.ts +23 -2
  36. package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
  37. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
  38. package/src/bundled-plugins/memory/README.md +11 -11
  39. package/src/bundled-plugins/memory/dreaming.ts +5 -0
  40. package/src/bundled-plugins/memory/search-tool.ts +98 -1
  41. package/src/bundled-plugins/operator/operator.ts +5 -1
  42. package/src/bundled-plugins/reviewer/reviewer.ts +32 -9
  43. package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
  44. package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
  45. package/src/bundled-plugins/scout/scout.ts +7 -7
  46. package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
  47. package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
  48. package/src/bundled-plugins/tool-result-cap/README.md +1 -1
  49. package/src/channels/adapters/discord-bot-reference.ts +78 -0
  50. package/src/channels/adapters/discord-bot.ts +25 -3
  51. package/src/channels/adapters/github/inbound.ts +172 -10
  52. package/src/channels/adapters/github/index.ts +10 -0
  53. package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
  54. package/src/channels/adapters/github/webhook-register.ts +32 -27
  55. package/src/channels/adapters/kakaotalk-classify.ts +67 -6
  56. package/src/channels/adapters/slack-bot-classify.ts +9 -1
  57. package/src/channels/adapters/slack-bot-reference.ts +129 -0
  58. package/src/channels/adapters/slack-bot.ts +67 -8
  59. package/src/channels/manager.ts +8 -2
  60. package/src/channels/router.ts +506 -45
  61. package/src/channels/schema.ts +21 -4
  62. package/src/channels/subagent-completion-bridge.ts +18 -18
  63. package/src/channels/types.ts +69 -1
  64. package/src/cli/inspect-controller.ts +132 -33
  65. package/src/cli/inspect.ts +2 -1
  66. package/src/commands/index.ts +9 -0
  67. package/src/container/start.ts +7 -1
  68. package/src/git/mutex.ts +22 -0
  69. package/src/git/reconcile-ignored.ts +214 -0
  70. package/src/hostd/daemon.ts +26 -1
  71. package/src/hostd/portbroker-manager.ts +7 -0
  72. package/src/init/dockerfile.ts +1 -1
  73. package/src/init/gitignore.ts +28 -16
  74. package/src/inspect/index.ts +53 -4
  75. package/src/inspect/loop.ts +16 -12
  76. package/src/plugin/define.ts +2 -2
  77. package/src/plugin/index.ts +2 -2
  78. package/src/portbroker/hostd-client.ts +36 -13
  79. package/src/run/index.ts +74 -5
  80. package/src/sandbox/build.ts +20 -0
  81. package/src/sandbox/index.ts +10 -0
  82. package/src/sandbox/policy.ts +22 -0
  83. package/src/sandbox/session-tmp.ts +43 -0
  84. package/src/sandbox/writable-zones.ts +178 -0
  85. package/src/server/command-runner.ts +1 -1
  86. package/src/server/index.ts +126 -4
  87. package/src/skills/typeclaw-channel-github/SKILL.md +71 -17
  88. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  89. package/src/tui/format.ts +11 -11
  90. package/typeclaw.schema.json +10 -0
@@ -1,8 +1,8 @@
1
- import type { KakaoTalkPushMessageEvent } from 'agent-messenger/kakaotalk'
1
+ import { KAKAO_MESSAGE_TYPE, type KakaoTalkPushMessageEvent } from 'agent-messenger/kakaotalk'
2
2
 
3
3
  import { matchesAnyAlias } from '@/channels/engagement'
4
4
  import type { ChannelAdapterConfig } from '@/channels/schema'
5
- import type { InboundAttachment, InboundMessage } from '@/channels/types'
5
+ import type { InboundAttachment, InboundMessage, InboundReferenceContext } from '@/channels/types'
6
6
 
7
7
  export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'pre_connect' | 'bot_message'
8
8
 
@@ -49,7 +49,9 @@ export function classifyInbound(
49
49
  return { kind: 'drop', reason: 'bot_message' }
50
50
  }
51
51
 
52
- const text = event.message ?? ''
52
+ const rawText = event.message ?? ''
53
+ const replyContext = parseReplyContext(event, context.selfUserId)
54
+ const text = rawText
53
55
  if (text === '') return { kind: 'drop', reason: 'empty_text' }
54
56
 
55
57
  const chatInfo = context.lookupChat(event.chat_id)
@@ -64,7 +66,7 @@ export function classifyInbound(
64
66
  // mention (see engagement.ts: alias is unconditional and ranks alongside
65
67
  // explicit triggers). Without aliases configured, only `reply` and `dm`
66
68
  // triggers can fire on KakaoTalk.
67
- const aliasMatched = matchesAnyAlias(text, context.selfAliases ?? [])
69
+ const aliasMatched = matchesAnyAlias(rawText, context.selfAliases ?? [])
68
70
 
69
71
  return {
70
72
  kind: 'route',
@@ -74,6 +76,7 @@ export function classifyInbound(
74
76
  chat: event.chat_id,
75
77
  thread: null,
76
78
  text,
79
+ ...referenceContextPayload(replyContext),
77
80
  ...(context.attachments !== undefined && context.attachments.length > 0
78
81
  ? { attachments: context.attachments }
79
82
  : {}),
@@ -82,9 +85,9 @@ export function classifyInbound(
82
85
  authorName: event.author_name ?? String(event.author_id),
83
86
  authorIsBot: false,
84
87
  isBotMention: aliasMatched,
85
- replyToBotMessageId: null,
88
+ replyToBotMessageId: replyContext?.target === 'bot' ? replyContext.logId : null,
86
89
  mentionsOthers: false,
87
- replyToOtherMessageId: null,
90
+ replyToOtherMessageId: replyContext?.target === 'other' ? replyContext.logId : null,
88
91
  isDm: chatInfo.isDm,
89
92
  // SDK delivers `sent_at` in Unix seconds (LOCO `sendAt`); contract
90
93
  // wants ms (see `src/channels/types.ts`). Without `* 1000`, ms-based
@@ -93,3 +96,61 @@ export function classifyInbound(
93
96
  },
94
97
  }
95
98
  }
99
+
100
+ type ParsedReplyContext = {
101
+ logId: string
102
+ authorId: string
103
+ authorName: string
104
+ quotedText: string | null
105
+ target: 'bot' | 'other'
106
+ }
107
+
108
+ function parseReplyContext(event: KakaoTalkPushMessageEvent, selfUserId: string): ParsedReplyContext | null {
109
+ if (event.message_type !== KAKAO_MESSAGE_TYPE.REPLY) return null
110
+ if (event.attachment === null) return null
111
+
112
+ const logId = stringField(event.attachment, 'src_logId')
113
+ const sourceUserId = numericField(event.attachment, 'src_userId')
114
+ if (logId === null || sourceUserId === null) return null
115
+
116
+ const sourceAuthorId = String(sourceUserId)
117
+ const quotedText = stringField(event.attachment, 'src_message')
118
+ return {
119
+ logId,
120
+ authorId: sourceAuthorId,
121
+ // classifyInbound is synchronous and has no cheap author resolver access;
122
+ // fall back to Kakao's stable source user id rather than fetching.
123
+ authorName: sourceAuthorId,
124
+ quotedText,
125
+ target: sourceAuthorId === selfUserId ? 'bot' : 'other',
126
+ }
127
+ }
128
+
129
+ function referenceContextPayload(
130
+ replyContext: ParsedReplyContext | null,
131
+ ): { referenceContext: InboundReferenceContext } | Record<string, never> {
132
+ if (replyContext === null || replyContext.quotedText === null || replyContext.quotedText.trim() === '') return {}
133
+ return {
134
+ referenceContext: {
135
+ kind: 'reply',
136
+ sources: [
137
+ {
138
+ adapter: 'kakaotalk',
139
+ authorId: replyContext.authorId,
140
+ authorName: replyContext.authorName,
141
+ text: replyContext.quotedText,
142
+ },
143
+ ],
144
+ },
145
+ }
146
+ }
147
+
148
+ function stringField(record: Record<string, unknown>, key: string): string | null {
149
+ const value = record[key]
150
+ return typeof value === 'string' && value.length > 0 ? value : null
151
+ }
152
+
153
+ function numericField(record: Record<string, unknown>, key: string): number | null {
154
+ const value = record[key]
155
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
156
+ }
@@ -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 { hasSlackMessageShareAttachments } from './slack-bot-reference'
7
8
  import { slackTsToMillis } from './slack-bot-time'
8
9
 
9
10
  export type SlackInboundMessageEvent = SlackSocketModeMessageEvent
@@ -62,7 +63,8 @@ export function classifyInbound(
62
63
 
63
64
  const rawText = event.text ?? ''
64
65
  const { text, attachments } = splitInbound(event)
65
- if (text === '') return { kind: 'drop', reason: 'empty_text' }
66
+ const slackAttachments = Array.isArray(event.attachments) ? event.attachments : undefined
67
+ if (text === '' && !hasSlackMessageShareAttachments(slackAttachments)) return { kind: 'drop', reason: 'empty_text' }
66
68
 
67
69
  const isDm = event.channel_type === 'im'
68
70
  const workspace = isDm ? '@dm' : context.teamId
@@ -92,6 +94,11 @@ export function classifyInbound(
92
94
  // found, keeping the cost negligible in mention-heavy channels.
93
95
  const aliasMatched = !isBotMention && matchesAnyAlias(rawText, context.selfAliases ?? [])
94
96
  const thread = event.thread_ts ?? (!isDm && (isBotMention || aliasMatched) ? event.ts : null)
97
+ // DMs stay flat (`thread` null), but Slack's typing status still needs a real
98
+ // message ts; anchor it on the inbound so the indicator renders without
99
+ // threading the reply. `assistant.threads.setStatus` accepts any live channel
100
+ // message ts, confirmed against the API.
101
+ const typingThread = isDm && thread === null ? event.ts : undefined
95
102
 
96
103
  // A reply is "to the bot" only when the thread parent was authored by the
97
104
  // bot. Slack surfaces the parent author via `parent_user_id` on every
@@ -130,6 +137,7 @@ export function classifyInbound(
130
137
  workspace,
131
138
  chat: event.channel,
132
139
  thread,
140
+ ...(typingThread !== undefined ? { typingThread } : {}),
133
141
  text,
134
142
  ...(attachments.length > 0 ? { attachments } : {}),
135
143
  externalMessageId: event.ts,
@@ -0,0 +1,129 @@
1
+ import type { InboundReferenceContext, QuoteAnchorSource } from '@/channels/types'
2
+
3
+ export type SlackResolvedReference = {
4
+ authorId: string
5
+ authorName: string
6
+ text: string
7
+ }
8
+
9
+ export type SlackReferenceFetch = (channelId: string, messageTs: string) => Promise<SlackResolvedReference | null>
10
+
11
+ export type SlackMessagePointer = {
12
+ channelId: string
13
+ messageTs: string
14
+ }
15
+
16
+ export async function enrichSlackReferenceContext(args: {
17
+ text: string
18
+ channelId: string
19
+ threadTs?: string
20
+ messageTs: string
21
+ attachments?: readonly unknown[]
22
+ fetchMessage: SlackReferenceFetch
23
+ linkLimit?: number
24
+ }): Promise<{ text: string; referenceContext?: InboundReferenceContext }> {
25
+ const sources: QuoteAnchorSource[] = []
26
+ let kind: InboundReferenceContext['kind'] = 'link'
27
+
28
+ if (args.threadTs !== undefined && args.threadTs !== args.messageTs) {
29
+ const parent = await fetchSafely(args.fetchMessage, { channelId: args.channelId, messageTs: args.threadTs })
30
+ if (parent !== null) {
31
+ sources.push(toSource(parent))
32
+ kind = 'reply'
33
+ }
34
+ }
35
+
36
+ for (const source of extractSlackShareSources(args.attachments ?? [])) {
37
+ sources.push(source)
38
+ if (kind !== 'reply') kind = 'quote'
39
+ }
40
+
41
+ const links = extractSlackMessageLinks(args.text).slice(0, args.linkLimit ?? 3)
42
+ const seen = new Set(sources.map((source) => `${source.authorId}\x00${source.text}`))
43
+ for (const link of links) {
44
+ const message = await fetchSafely(args.fetchMessage, link)
45
+ if (message === null) continue
46
+ const source = toSource(message)
47
+ const key = `${source.authorId}\x00${source.text}`
48
+ if (seen.has(key)) continue
49
+ seen.add(key)
50
+ sources.push(source)
51
+ }
52
+
53
+ if (sources.length === 0) return { text: args.text }
54
+ return { text: args.text, referenceContext: { kind, sources } }
55
+ }
56
+
57
+ export function hasSlackMessageShareAttachments(attachments: readonly unknown[] | undefined): boolean {
58
+ return extractSlackShareSources(attachments ?? []).length > 0
59
+ }
60
+
61
+ const SLACK_ARCHIVE_LINK = /https?:\/\/[^\s/]+\.slack\.com\/archives\/([^/\s]+)\/p(\d{10})(\d{6})/g
62
+
63
+ function extractSlackMessageLinks(text: string): SlackMessagePointer[] {
64
+ const seen = new Set<string>()
65
+ const links: SlackMessagePointer[] = []
66
+ for (const match of text.matchAll(SLACK_ARCHIVE_LINK)) {
67
+ const channelId = match[1]
68
+ const seconds = match[2]
69
+ const micros = match[3]
70
+ if (channelId === undefined || seconds === undefined || micros === undefined) continue
71
+ const messageTs = `${seconds}.${micros}`
72
+ const key = `${channelId}:${messageTs}`
73
+ if (seen.has(key)) continue
74
+ seen.add(key)
75
+ links.push({ channelId, messageTs })
76
+ }
77
+ return links
78
+ }
79
+
80
+ // A stable author id is required because the quote renders as `<@id>`
81
+ // downstream; a name-only source would emit `<@unknown>`, worse than omitting
82
+ // the context. Forwarded message-shares put it under `author_id`, classic
83
+ // shares under `user`/`user_id`. `author_subname` is Slack's display-name
84
+ // fallback on forwarded messages — used for the name only, never the id.
85
+ function extractSlackShareSources(attachments: readonly unknown[]): QuoteAnchorSource[] {
86
+ const sources: QuoteAnchorSource[] = []
87
+ for (const attachment of attachments) {
88
+ const record = recordValue(attachment)
89
+ if (record === null) continue
90
+ const text = stringField(record, 'text') ?? stringField(record, 'fallback')
91
+ if (text === null || text.trim() === '') continue
92
+ const authorId = stringField(record, 'author_id') ?? stringField(record, 'user') ?? stringField(record, 'user_id')
93
+ if (authorId === null) continue
94
+ const authorName = stringField(record, 'author_name') ?? stringField(record, 'author_subname') ?? authorId
95
+ sources.push({
96
+ adapter: 'slack-bot',
97
+ authorId,
98
+ authorName,
99
+ text,
100
+ })
101
+ }
102
+ return sources
103
+ }
104
+
105
+ async function fetchSafely(
106
+ fetchMessage: SlackReferenceFetch,
107
+ pointer: SlackMessagePointer,
108
+ ): Promise<SlackResolvedReference | null> {
109
+ try {
110
+ return await fetchMessage(pointer.channelId, pointer.messageTs)
111
+ } catch {
112
+ return null
113
+ }
114
+ }
115
+
116
+ function toSource(message: SlackResolvedReference): QuoteAnchorSource {
117
+ return { adapter: 'slack-bot', authorId: message.authorId, authorName: message.authorName, text: message.text }
118
+ }
119
+
120
+ function recordValue(value: unknown): Record<string, unknown> | null {
121
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
122
+ ? (value as Record<string, unknown>)
123
+ : null
124
+ }
125
+
126
+ function stringField(record: Record<string, unknown>, key: string): string | null {
127
+ const value = record[key]
128
+ return typeof value === 'string' && value.length > 0 ? value : null
129
+ }
@@ -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 { enrichSlackReferenceContext } from './slack-bot-reference'
46
47
  import {
47
48
  buildSlashAckPayload,
48
49
  commandResultReply,
@@ -261,6 +262,7 @@ export type SlackBotAdapterOptions = {
261
262
  // classifier behaves as before (no alias-driven thread anchoring), so
262
263
  // tests and ad-hoc adapter constructions stay backwards-compatible.
263
264
  selfAliasesRef?: () => readonly string[]
265
+ fetchImpl?: typeof fetch
264
266
  }
265
267
 
266
268
  export type SlackBotAdapter = {
@@ -351,18 +353,23 @@ export function createTypingCallback(deps: {
351
353
  const { typingTracker, logger, formatChannelTag } = deps
352
354
  return async (target: TypingTarget): Promise<void> => {
353
355
  if (target.adapter !== 'slack-bot') return
356
+ // DMs are flat (thread null) but setStatus still needs a real message ts;
357
+ // `typingThread` carries the inbound ts for exactly that case. Real channel
358
+ // threads keep using `thread`. Either way the status is keyed on one ts.
359
+ const statusThread =
360
+ target.typingThread !== undefined && target.typingThread !== '' ? target.typingThread : target.thread
354
361
  const tag = formatChannelTag
355
- ? await formatChannelTag(target.workspace, target.thread ?? target.chat)
356
- : `channel=${target.thread ?? target.chat}`
357
- if (target.thread === undefined || target.thread === null || target.thread === '') {
362
+ ? await formatChannelTag(target.workspace, statusThread ?? target.chat)
363
+ : `channel=${statusThread ?? target.chat}`
364
+ if (statusThread === undefined || statusThread === null || statusThread === '') {
358
365
  if (target.phase === 'tick') logger.info(`[slack-bot] typing (no-op, top-level chat) ${tag}`)
359
366
  return
360
367
  }
361
368
  if (target.phase === 'stop') {
362
- await typingTracker.clearAfterSend(target.chat, target.thread)
369
+ await typingTracker.clearAfterSend(target.chat, statusThread)
363
370
  return
364
371
  }
365
- await typingTracker.setStatus(target.chat, target.thread, 'is typing...')
372
+ await typingTracker.setStatus(target.chat, statusThread, 'is typing...')
366
373
  }
367
374
  }
368
375
 
@@ -823,7 +830,7 @@ export function createOutboundCallback(deps: {
823
830
  // top-level posts.
824
831
  if (threadTs === null && chunks.length > 1) threadTs = sent.ts
825
832
  }
826
- if (typingTracker) await typingTracker.clearAfterSend(msg.chat, msg.thread)
833
+ if (typingTracker) await typingTracker.clearAfterSend(msg.chat, msg.typingThread ?? msg.thread)
827
834
  return { ok: true }
828
835
  } catch (err) {
829
836
  const message = err instanceof Error ? err.message : String(err)
@@ -856,7 +863,7 @@ export function createOutboundCallback(deps: {
856
863
  return { ok: false, error: `uploadFile failed: ${message}` }
857
864
  }
858
865
  }
859
- if (typingTracker) await typingTracker.clearAfterSend(msg.chat, msg.thread)
866
+ if (typingTracker) await typingTracker.clearAfterSend(msg.chat, msg.typingThread ?? msg.thread)
860
867
  return { ok: true }
861
868
  }
862
869
  }
@@ -897,9 +904,46 @@ export function createFetchAttachmentCallback(deps: {
897
904
  }
898
905
  }
899
906
 
907
+ function createSlackReferenceFetch(deps: { token: string; fetchImpl: typeof fetch }) {
908
+ return async (channelId: string, messageTs: string) => {
909
+ const url = new URL('https://slack.com/api/conversations.replies')
910
+ url.searchParams.set('channel', channelId)
911
+ url.searchParams.set('ts', messageTs)
912
+ url.searchParams.set('limit', '1')
913
+ const response = await deps.fetchImpl(url, { headers: { Authorization: `Bearer ${deps.token}` } })
914
+ if (!response.ok) return null
915
+ const body = recordValue(await response.json())
916
+ if (body === null || body.ok !== true) return null
917
+ const messages = arrayField(body, 'messages')
918
+ const first = recordValue(messages[0])
919
+ if (first === null) return null
920
+ const authorId = stringField(first, 'user') ?? stringField(first, 'bot_id')
921
+ const text = stringField(first, 'text')
922
+ if (authorId === null || text === null) return null
923
+ return { authorId, authorName: authorId, text }
924
+ }
925
+ }
926
+
927
+ function recordValue(value: unknown): Record<string, unknown> | null {
928
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
929
+ ? (value as Record<string, unknown>)
930
+ : null
931
+ }
932
+
933
+ function arrayField(record: Record<string, unknown>, key: string): readonly unknown[] {
934
+ const value = record[key]
935
+ return Array.isArray(value) ? value : []
936
+ }
937
+
938
+ function stringField(record: Record<string, unknown>, key: string): string | null {
939
+ const value = record[key]
940
+ return typeof value === 'string' && value.length > 0 ? value : null
941
+ }
942
+
900
943
  export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBotAdapter {
901
944
  const logger = options.logger ?? consoleLogger
902
945
  const client = new SlackBotClient()
946
+ const fetchImpl = options.fetchImpl ?? fetch
903
947
  let listener: SlackBotListener | null = null
904
948
  let botUserId: string | null = null
905
949
  let teamId: string | null = null
@@ -1053,7 +1097,22 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1053
1097
  }
1054
1098
 
1055
1099
  dedupe.mark(event)
1056
- const enriched = { ...verdict.payload, authorName: resolvedUserName }
1100
+ const slackAttachments = Array.isArray(event.attachments) ? event.attachments : undefined
1101
+ const referenceResult = await enrichSlackReferenceContext({
1102
+ text: verdict.payload.text,
1103
+ channelId: event.channel,
1104
+ ...(event.thread_ts !== undefined ? { threadTs: event.thread_ts } : {}),
1105
+ messageTs: event.ts,
1106
+ ...(slackAttachments !== undefined ? { attachments: slackAttachments } : {}),
1107
+ fetchMessage: createSlackReferenceFetch({ token: options.token, fetchImpl }),
1108
+ })
1109
+ const enriched = {
1110
+ ...verdict.payload,
1111
+ authorName: resolvedUserName,
1112
+ ...(referenceResult.referenceContext !== undefined
1113
+ ? { referenceContext: referenceResult.referenceContext }
1114
+ : {}),
1115
+ }
1057
1116
  const routedTag = await formatChannelTag(enriched.workspace, enriched.chat)
1058
1117
  logger.info(
1059
1118
  `[slack-bot] routed ts=${event.ts} ${routedTag} mention=${enriched.isBotMention} reply=${enriched.replyToBotMessageId !== null}`,
@@ -13,7 +13,13 @@ import { createKakaotalkAdapter, type KakaotalkAdapter } from './adapters/kakaot
13
13
  import { createSlackBotAdapter, type SlackBotAdapter } from './adapters/slack-bot'
14
14
  import { createTelegramBotAdapter, type TelegramBotAdapter } from './adapters/telegram-bot'
15
15
  import type { GithubTokenBridge } from './github-token-bridge'
16
- import { createChannelRouter, type ChannelRouter, type ClaimHandler, type CreateSessionForChannel } from './router'
16
+ import {
17
+ createChannelRouter,
18
+ type ChannelRouter,
19
+ type ClaimHandler,
20
+ type CreateSessionForChannel,
21
+ type RestartCommandContext,
22
+ } from './router'
17
23
  import {
18
24
  ADAPTER_IDS,
19
25
  type AdapterId,
@@ -94,7 +100,7 @@ export type ChannelManagerOptions = {
94
100
  // container-restart bindings; tests omit them so the commands stay
95
101
  // unregistered. See CreateChannelRouterOptions.onReload/onRestart.
96
102
  onReload?: () => Promise<string>
97
- onRestart?: () => Promise<string>
103
+ onRestart?: (ctx?: RestartCommandContext) => Promise<string>
98
104
  }
99
105
 
100
106
  export type ChannelManager = {