typeclaw 0.28.1 → 0.29.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/package.json +1 -1
  2. package/src/agent/index.ts +37 -5
  3. package/src/agent/loop-guard.ts +112 -26
  4. package/src/agent/plugin-tools.ts +102 -41
  5. package/src/agent/session-origin.ts +3 -3
  6. package/src/agent/subagents.ts +7 -0
  7. package/src/agent/system-prompt.ts +29 -4
  8. package/src/agent/tools/channel-reply.ts +1 -0
  9. package/src/agent/tools/channel-send.ts +2 -1
  10. package/src/agent/tools/spawn-subagent.ts +21 -0
  11. package/src/agent/tools/subagent-output.ts +7 -3
  12. package/src/agent/tools/wikipedia.ts +1 -1
  13. package/src/bundled-plugins/explorer/explorer.ts +2 -0
  14. package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +74 -0
  15. package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
  16. package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
  17. package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
  18. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
  19. package/src/bundled-plugins/memory/memory-logger.ts +3 -3
  20. package/src/bundled-plugins/operator/operator.ts +2 -0
  21. package/src/bundled-plugins/planner/index.ts +11 -0
  22. package/src/bundled-plugins/planner/planner.ts +282 -0
  23. package/src/bundled-plugins/planner/skills/general.ts +65 -0
  24. package/src/bundled-plugins/planner/skills/project.ts +69 -0
  25. package/src/bundled-plugins/researcher/index.ts +11 -0
  26. package/src/bundled-plugins/researcher/researcher.ts +226 -0
  27. package/src/bundled-plugins/researcher/skills/general.ts +105 -0
  28. package/src/bundled-plugins/researcher/write-report.ts +107 -0
  29. package/src/bundled-plugins/reviewer/reviewer.ts +29 -11
  30. package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
  31. package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
  32. package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
  33. package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
  34. package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
  35. package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
  36. package/src/bundled-plugins/scout/scout.ts +2 -0
  37. package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
  38. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
  39. package/src/channels/adapters/discord-bot.ts +38 -11
  40. package/src/channels/adapters/github/inbound.ts +74 -9
  41. package/src/channels/adapters/github/index.ts +36 -11
  42. package/src/channels/adapters/github/reconcile-open-prs.ts +306 -0
  43. package/src/channels/adapters/github/review-state.ts +71 -2
  44. package/src/channels/adapters/kakaotalk-classify.ts +2 -2
  45. package/src/channels/adapters/kakaotalk.ts +2 -2
  46. package/src/channels/adapters/slack-bot-classify.ts +1 -1
  47. package/src/channels/adapters/slack-bot.ts +3 -0
  48. package/src/channels/adapters/telegram-bot.ts +3 -0
  49. package/src/channels/engagement.ts +12 -7
  50. package/src/channels/github-rereview-guard.ts +32 -8
  51. package/src/channels/github-review-claim.ts +53 -6
  52. package/src/channels/router.ts +44 -9
  53. package/src/channels/schema.ts +4 -3
  54. package/src/channels/types.ts +17 -6
  55. package/src/cli/init.ts +13 -2
  56. package/src/cli/ui.ts +64 -0
  57. package/src/config/config.ts +21 -15
  58. package/src/container/start.ts +5 -1
  59. package/src/init/dockerfile.ts +19 -56
  60. package/src/init/hatching.ts +1 -1
  61. package/src/init/index.ts +5 -1
  62. package/src/run/bundled-plugins.ts +4 -0
  63. package/src/server/index.ts +24 -5
  64. package/src/shared/host-locale.ts +27 -0
  65. package/src/shared/protocol.ts +1 -1
  66. package/src/shared/wordmark.ts +19 -0
  67. package/src/skills/typeclaw-channel-github/SKILL.md +1 -1
  68. package/src/skills/typeclaw-config/SKILL.md +32 -32
  69. package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
  70. package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
  71. package/src/tui/banner.ts +19 -0
  72. package/src/tui/format.ts +34 -0
  73. package/src/tui/index.ts +121 -22
  74. package/src/tui/theme.ts +26 -1
  75. package/src/tunnels/providers/cloudflare-named.ts +15 -4
  76. package/src/tunnels/providers/cloudflare-quick.ts +15 -4
  77. package/src/tunnels/providers/cloudflared-binary.ts +11 -0
  78. package/typeclaw.schema.json +15 -7
@@ -1,7 +1,7 @@
1
1
  import type { GithubTokenBridge } from '@/channels/github-token-bridge'
2
2
  import type { ChannelRouter } from '@/channels/router'
3
3
  import type { ChannelAdapterConfig, GithubAdapterConfig } from '@/channels/schema'
4
- import type { ChannelSelfIdentityResolver } from '@/channels/types'
4
+ import type { ChannelSelfIdentityResolver, InboundMessage } from '@/channels/types'
5
5
  import { resolveSecret } from '@/secrets/resolve'
6
6
  import type { GithubSecretsBlock } from '@/secrets/schema'
7
7
 
@@ -21,6 +21,7 @@ import {
21
21
  parseListHooksPermissionStatus,
22
22
  } from './permission-guidance'
23
23
  import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
24
+ import { reconcileOpenPrs } from './reconcile-open-prs'
24
25
  import { createGithubReviewStateResolver } from './review-state'
25
26
  import { createGithubReviewThreadResolver } from './review-thread-resolver'
26
27
  import { createTeamMembershipChecker } from './team-membership'
@@ -160,6 +161,19 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
160
161
  const typing = async (): Promise<void> => {}
161
162
  const dedup = createDeliveryDedup()
162
163
  const isBotInTeam = createTeamMembershipChecker({ token: authToken, fetchImpl })
164
+ // Shared inbound entry. Both the live webhook handler and the startup
165
+ // reconciliation pass route through this so a replayed PR takes the exact
166
+ // same path a real delivery would.
167
+ const routeInbound = (message: InboundMessage): void => {
168
+ rememberWorkspace(message.workspace, message.chat)
169
+ // Ack-first: wrap in Promise.resolve so a synchronous throw inside
170
+ // router.route() cannot prevent the 200 response from being returned.
171
+ void Promise.resolve()
172
+ .then(() => options.router.route(message))
173
+ .catch((err: unknown) => {
174
+ logger.error(`[github] route failed: ${err instanceof Error ? err.message : String(err)}`)
175
+ })
176
+ }
163
177
  const handler = createGithubWebhookHandler({
164
178
  webhookSecret,
165
179
  dedup,
@@ -173,16 +187,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
173
187
  authToken,
174
188
  fetchImpl,
175
189
  logger,
176
- route: (message) => {
177
- rememberWorkspace(message.workspace, message.chat)
178
- // Ack-first: wrap in Promise.resolve so a synchronous throw inside
179
- // router.route() cannot prevent the 200 response from being returned.
180
- void Promise.resolve()
181
- .then(() => options.router.route(message))
182
- .catch((err: unknown) => {
183
- logger.error(`[github] route failed: ${err instanceof Error ? err.message : String(err)}`)
184
- })
185
- },
190
+ route: routeInbound,
186
191
  })
187
192
 
188
193
  return {
@@ -330,6 +335,26 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
330
335
  )
331
336
  logRegistrationOutcome(logger, registration, options.secrets.auth.type)
332
337
  }
338
+ // Catch up on PRs whose opened/ready_for_review/review_requested delivery
339
+ // was missed (tunnel-URL churn, dropped delivery, downtime). Best-effort
340
+ // and last so a failure here never blocks the adapter from coming up; the
341
+ // helper swallows per-repo errors internally. Runs on every start(), so a
342
+ // tunnel-driven restart re-checks too. `off` short-circuits inside.
343
+ if (repos.length > 0) {
344
+ await reconcileOpenPrs({
345
+ repos,
346
+ reviewOn: cfg.review.on,
347
+ selfLogin,
348
+ authType: options.secrets.auth.type,
349
+ token: authToken,
350
+ route: routeInbound,
351
+ logger,
352
+ isBotInTeam,
353
+ fetchImpl,
354
+ }).catch((err: unknown) => {
355
+ logger.warn(`[github] reconcile pass failed: ${err instanceof Error ? err.message : String(err)}`)
356
+ })
357
+ }
333
358
  },
334
359
  async stop(): Promise<void> {
335
360
  if (!started) return
@@ -0,0 +1,306 @@
1
+ import type { GithubReviewOn } from '@/channels/schema'
2
+ import type { InboundMessage } from '@/channels/types'
3
+
4
+ import type { GithubAuthContext } from './auth'
5
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
6
+ import type { TeamMembershipChecker } from './team-membership'
7
+
8
+ // Catches up on review work that a live webhook delivery missed. The github
9
+ // adapter only acts on deliveries it receives, so a `pull_request.opened` /
10
+ // `ready_for_review` dropped while the tunnel URL was churning (cloudflare-quick
11
+ // mints a fresh host every restart) leaves an open PR permanently un-reviewed —
12
+ // nothing wakes the bot for it again. This pass runs on every adapter start()
13
+ // (cold boot AND tunnel-driven restart) and replays the PRs that still need a
14
+ // review as synthetic inbounds through the same router path a real webhook uses.
15
+ //
16
+ // It is intentionally NOT a substitute for webhooks: it is a floor, not the
17
+ // primary path. Drift between a missed delivery and the next start() is the
18
+ // reconciliation window; webhooks remain the low-latency path when delivery
19
+ // works.
20
+
21
+ export type ReconcileOpenPrsOptions = {
22
+ repos: readonly string[]
23
+ reviewOn: GithubReviewOn
24
+ selfLogin: string | null
25
+ authType: 'pat' | 'app'
26
+ token: (context?: GithubAuthContext) => Promise<string>
27
+ route: (message: InboundMessage) => void
28
+ logger: { info: (m: string) => void; warn: (m: string) => void }
29
+ // Resolves whether the bot is a member of a requested team, gating
30
+ // team-requested reviews under review.on 'review_requested' (mirrors the
31
+ // live webhook path's isMyTeam check in classifyReviewRequest). Omitted in
32
+ // tests that don't exercise team requests; treated as "not a member".
33
+ isBotInTeam?: TeamMembershipChecker
34
+ fetchImpl?: typeof fetch
35
+ }
36
+
37
+ export type ReconcileOutcome = { repo: string; scanned: number; replayed: number } | { repo: string; error: string }
38
+
39
+ const BOT_LOGIN_SUFFIX = '[bot]'
40
+
41
+ export async function reconcileOpenPrs(options: ReconcileOpenPrsOptions): Promise<ReconcileOutcome[]> {
42
+ // `off` disables code review entirely, so there is nothing to catch up on.
43
+ if (options.reviewOn === 'off') return []
44
+ if (options.selfLogin === null) return []
45
+ const fetchImpl = options.fetchImpl ?? fetch
46
+ const selfLogin = options.selfLogin
47
+ const decoyLogin = resolveDecoyLogin(selfLogin, options.authType)
48
+
49
+ const outcomes: ReconcileOutcome[] = []
50
+ for (const repo of new Set(options.repos)) {
51
+ const target = parseRepo(repo)
52
+ if (target === null) {
53
+ outcomes.push({ repo, error: 'malformed repo slug' })
54
+ continue
55
+ }
56
+ try {
57
+ const outcome = await reconcileRepo(target, options, selfLogin, decoyLogin, fetchImpl)
58
+ outcomes.push(outcome)
59
+ if (outcome.replayed > 0) {
60
+ options.logger.info(`[github] reconcile ${repo}: replayed ${outcome.replayed}/${outcome.scanned} open PR(s)`)
61
+ }
62
+ } catch (err) {
63
+ const message = err instanceof Error ? err.message : String(err)
64
+ options.logger.warn(`[github] reconcile ${repo} failed: ${message}`)
65
+ outcomes.push({ repo, error: message })
66
+ }
67
+ }
68
+ return outcomes
69
+ }
70
+
71
+ async function reconcileRepo(
72
+ target: RepoTarget,
73
+ options: ReconcileOpenPrsOptions,
74
+ selfLogin: string,
75
+ decoyLogin: string | null,
76
+ fetchImpl: typeof fetch,
77
+ ): Promise<{ repo: string; scanned: number; replayed: number }> {
78
+ const repo = `${target.owner}/${target.repo}`
79
+ const token = await options.token({ repoSlug: repo })
80
+ const prs = await fetchOpenPrs(fetchImpl, token, target)
81
+
82
+ let replayed = 0
83
+ for (const pr of prs) {
84
+ const needs = await prNeedsReview({ pr, options, target, token, selfLogin, decoyLogin, fetchImpl })
85
+ if (!needs) continue
86
+ options.route(buildSyntheticInbound(pr, target))
87
+ replayed += 1
88
+ }
89
+ return { repo, scanned: prs.length, replayed }
90
+ }
91
+
92
+ async function prNeedsReview(input: {
93
+ pr: OpenPr
94
+ options: ReconcileOpenPrsOptions
95
+ target: RepoTarget
96
+ token: string
97
+ selfLogin: string
98
+ decoyLogin: string | null
99
+ fetchImpl: typeof fetch
100
+ }): Promise<boolean> {
101
+ const { pr, options, target, token, selfLogin, decoyLogin, fetchImpl } = input
102
+ if (isSelfAuthored(pr, selfLogin, decoyLogin)) return false
103
+
104
+ if (options.reviewOn === 'review_requested') {
105
+ // Only the explicit request wakes a review under this mode. A draft can
106
+ // still carry a request, so draft state is irrelevant here. Mirrors the
107
+ // live path's `isMeAsUser || isMyTeam`: a direct user request, OR a team
108
+ // request the bot is a member of.
109
+ if (isUserReviewRequestedFromSelf(pr, selfLogin, decoyLogin)) return true
110
+ return await isTeamReviewRequestedFromSelf(pr, target, selfLogin, options.isBotInTeam)
111
+ }
112
+
113
+ // reviewOn === 'opened': a non-draft PR the bot has not yet reviewed. Draft
114
+ // PRs wait for the ready_for_review trigger, matching the live-webhook path.
115
+ if (pr.draft) return false
116
+ return !(await botAlreadyReviewed(fetchImpl, token, target, pr.number, selfLogin))
117
+ }
118
+
119
+ async function isTeamReviewRequestedFromSelf(
120
+ pr: OpenPr,
121
+ target: RepoTarget,
122
+ selfLogin: string,
123
+ isBotInTeam: TeamMembershipChecker | undefined,
124
+ ): Promise<boolean> {
125
+ if (isBotInTeam === undefined || pr.requestedTeamSlugs.length === 0) return false
126
+ for (const slug of pr.requestedTeamSlugs) {
127
+ if (await isBotInTeam({ org: target.owner, slug, login: selfLogin })) return true
128
+ }
129
+ return false
130
+ }
131
+
132
+ function buildSyntheticInbound(pr: OpenPr, target: RepoTarget): InboundMessage {
133
+ const branchSegment = pr.headRef !== null && pr.baseRef !== null ? ` Branch: ${pr.headRef} → ${pr.baseRef}.` : ''
134
+ const text =
135
+ `@${pr.authorLogin} opened PR #${pr.number}: "${pr.title}".${branchSegment}` +
136
+ ' Please review the changes line-by-line and post your feedback.'
137
+ // Distinct from the live `pr-<id>-opened-<updatedAt>` id so a replay never
138
+ // collides with a real opened delivery, while repeated reconciles for the
139
+ // same unchanged PR dedupe against each other (same updatedAt).
140
+ const externalMessageId = `pr-${pr.id}-reconcile-${pr.updatedAt}`
141
+ return {
142
+ adapter: 'github',
143
+ workspace: `${target.owner}/${target.repo}`,
144
+ chat: `pr:${pr.number}`,
145
+ thread: null,
146
+ text,
147
+ externalMessageId,
148
+ authorId: String(pr.authorId),
149
+ authorName: pr.authorLogin,
150
+ authorIsBot: pr.authorIsBot,
151
+ isBotMention: true,
152
+ replyToBotMessageId: null,
153
+ mentionsOthers: false,
154
+ replyToOtherMessageId: null,
155
+ isDm: false,
156
+ ts: pr.updatedAt !== '' ? Date.parse(pr.updatedAt) || 0 : 0,
157
+ }
158
+ }
159
+
160
+ type RepoTarget = { owner: string; repo: string }
161
+
162
+ type OpenPr = {
163
+ number: number
164
+ id: number
165
+ title: string
166
+ draft: boolean
167
+ authorLogin: string
168
+ authorId: number
169
+ authorIsBot: boolean
170
+ headRef: string | null
171
+ baseRef: string | null
172
+ updatedAt: string
173
+ requestedReviewerLogins: string[]
174
+ requestedTeamSlugs: string[]
175
+ }
176
+
177
+ function parseRepo(slug: string): RepoTarget | null {
178
+ const [owner, repo, ...rest] = slug.trim().split('/')
179
+ if (owner === undefined || owner === '' || repo === undefined || repo === '' || rest.length > 0) return null
180
+ return { owner, repo }
181
+ }
182
+
183
+ async function fetchOpenPrs(fetchImpl: typeof fetch, token: string, target: RepoTarget): Promise<OpenPr[]> {
184
+ const prs: OpenPr[] = []
185
+ let url: string | null = `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/pulls?state=open&per_page=100`
186
+ while (url !== null) {
187
+ const response = await fetchImpl(url, { headers: githubJsonHeaders(token) })
188
+ if (!response.ok) {
189
+ const body = await response.text().catch(() => '')
190
+ throw new Error(`GitHub pulls ${response.status}${body !== '' ? `: ${body}` : ''}`)
191
+ }
192
+ const page = (await response.json().catch(() => null)) as PrRow[] | null
193
+ if (page === null) throw new Error('GitHub pulls returned non-JSON')
194
+ for (const row of page) {
195
+ const parsed = parsePrRow(row)
196
+ if (parsed !== null) prs.push(parsed)
197
+ }
198
+ url = nextLink(response.headers.get('link'))
199
+ }
200
+ return prs
201
+ }
202
+
203
+ async function botAlreadyReviewed(
204
+ fetchImpl: typeof fetch,
205
+ token: string,
206
+ target: RepoTarget,
207
+ prNumber: number,
208
+ selfLogin: string,
209
+ ): Promise<boolean> {
210
+ let url: string | null =
211
+ `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/pulls/${prNumber}/reviews?per_page=100`
212
+ while (url !== null) {
213
+ const response = await fetchImpl(url, { headers: githubJsonHeaders(token) })
214
+ if (!response.ok) {
215
+ const body = await response.text().catch(() => '')
216
+ throw new Error(`GitHub reviews ${response.status}${body !== '' ? `: ${body}` : ''}`)
217
+ }
218
+ const page = (await response.json().catch(() => null)) as ReviewRow[] | null
219
+ if (page === null) throw new Error('GitHub reviews returned non-JSON')
220
+ for (const row of page) {
221
+ const login = row.user?.login ?? null
222
+ if (login === null) continue
223
+ if (isSelfLogin(login, row.user?.type === 'Bot', selfLogin)) return true
224
+ }
225
+ url = nextLink(response.headers.get('link'))
226
+ }
227
+ return false
228
+ }
229
+
230
+ function isUserReviewRequestedFromSelf(pr: OpenPr, selfLogin: string, decoyLogin: string | null): boolean {
231
+ return pr.requestedReviewerLogins.some(
232
+ (login) => isSelfLogin(login, login.endsWith(BOT_LOGIN_SUFFIX), selfLogin) || login === decoyLogin,
233
+ )
234
+ }
235
+
236
+ function isSelfAuthored(pr: OpenPr, selfLogin: string, decoyLogin: string | null): boolean {
237
+ return isSelfLogin(pr.authorLogin, pr.authorIsBot, selfLogin) || pr.authorLogin === decoyLogin
238
+ }
239
+
240
+ // Mirrors review-state.ts isSelfReviewer: a GitHub App's REST login is
241
+ // `slug[bot]`, so the `[bot]` suffix-strip comparison is gated on the actor
242
+ // actually being a Bot to avoid attributing a human who owns the bare slug.
243
+ function isSelfLogin(login: string, isBot: boolean, selfLogin: string): boolean {
244
+ if (isBot) return normalizeBotLogin(login) === normalizeBotLogin(selfLogin)
245
+ return login === selfLogin
246
+ }
247
+
248
+ function normalizeBotLogin(login: string): string {
249
+ return login.endsWith(BOT_LOGIN_SUFFIX) ? login.slice(0, -BOT_LOGIN_SUFFIX.length) : login
250
+ }
251
+
252
+ // A GitHub App actor's REST login is `slug[bot]`; the decoy account an operator
253
+ // requests for App-auth reviews is the bare slug. PAT auth has no decoy.
254
+ function resolveDecoyLogin(selfLogin: string, authType: 'pat' | 'app'): string | null {
255
+ if (authType !== 'app') return null
256
+ if (!selfLogin.endsWith(BOT_LOGIN_SUFFIX)) return null
257
+ const slug = selfLogin.slice(0, -BOT_LOGIN_SUFFIX.length)
258
+ return slug !== '' ? slug : null
259
+ }
260
+
261
+ function nextLink(linkHeader: string | null): string | null {
262
+ if (linkHeader === null) return null
263
+ for (const part of linkHeader.split(',')) {
264
+ const m = /<([^>]+)>;\s*rel="next"/.exec(part)
265
+ if (m !== null) return m[1] ?? null
266
+ }
267
+ return null
268
+ }
269
+
270
+ type PrRow = {
271
+ number?: unknown
272
+ id?: unknown
273
+ title?: unknown
274
+ draft?: unknown
275
+ updated_at?: unknown
276
+ user?: { login?: unknown; id?: unknown; type?: unknown }
277
+ head?: { ref?: unknown }
278
+ base?: { ref?: unknown }
279
+ requested_reviewers?: Array<{ login?: unknown }>
280
+ requested_teams?: Array<{ slug?: unknown }>
281
+ }
282
+
283
+ type ReviewRow = { user?: { login?: string; type?: string } }
284
+
285
+ function parsePrRow(row: PrRow): OpenPr | null {
286
+ const number = typeof row.number === 'number' ? row.number : null
287
+ const id = typeof row.id === 'number' ? row.id : null
288
+ const authorLogin = typeof row.user?.login === 'string' ? row.user.login : null
289
+ if (number === null || id === null || authorLogin === null) return null
290
+ return {
291
+ number,
292
+ id,
293
+ title: typeof row.title === 'string' ? row.title : `#${number}`,
294
+ draft: row.draft === true,
295
+ authorLogin,
296
+ authorId: typeof row.user?.id === 'number' ? row.user.id : 0,
297
+ authorIsBot: row.user?.type === 'Bot',
298
+ headRef: typeof row.head?.ref === 'string' ? row.head.ref : null,
299
+ baseRef: typeof row.base?.ref === 'string' ? row.base.ref : null,
300
+ updatedAt: typeof row.updated_at === 'string' ? row.updated_at : '',
301
+ requestedReviewerLogins: (row.requested_reviewers ?? []).flatMap((r) =>
302
+ typeof r.login === 'string' ? [r.login] : [],
303
+ ),
304
+ requestedTeamSlugs: (row.requested_teams ?? []).flatMap((t) => (typeof t.slug === 'string' ? [t.slug] : [])),
305
+ }
306
+ }
@@ -31,11 +31,20 @@ export function createGithubReviewStateResolver(deps: {
31
31
  }
32
32
 
33
33
  const token = await deps.token({ repoSlug: `${target.owner}/${target.repo}` })
34
- const reviews = await fetchSelfReviews(fetchImpl, token, target, selfLogin)
34
+ const [reviews, reviewDecision] = await Promise.all([
35
+ fetchSelfReviews(fetchImpl, token, target, selfLogin),
36
+ fetchReviewDecision(fetchImpl, token, target),
37
+ ])
35
38
  if (!reviews.ok) return { ok: false, error: reviews.error, code: reviews.code }
39
+ if (!reviewDecision.ok) return { ok: false, error: reviewDecision.error, code: reviewDecision.code }
36
40
 
37
41
  const lastDecisive = reviews.states.filter(isDecisive).at(-1) ?? null
38
- return { ok: true, selfBlocking: lastDecisive === 'CHANGES_REQUESTED', approve }
42
+ return {
43
+ ok: true,
44
+ selfBlocking: lastDecisive === 'CHANGES_REQUESTED',
45
+ approve,
46
+ ...(reviewDecision.reviewDecision !== null ? { reviewDecision: reviewDecision.reviewDecision } : {}),
47
+ }
39
48
  }
40
49
  }
41
50
 
@@ -55,6 +64,12 @@ type SelfReviewsResult =
55
64
  | { ok: true; states: string[] }
56
65
  | { ok: false; error: string; code: 'not-found' | 'permission-denied' | 'transient' }
57
66
 
67
+ type ReviewDecision = 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED'
68
+
69
+ type ReviewDecisionResult =
70
+ | { ok: true; reviewDecision: ReviewDecision | null }
71
+ | { ok: false; error: string; code: 'not-found' | 'permission-denied' | 'transient' }
72
+
58
73
  async function fetchSelfReviews(
59
74
  fetchImpl: typeof fetch,
60
75
  token: string,
@@ -94,6 +109,47 @@ async function fetchSelfReviews(
94
109
  return { ok: true, states }
95
110
  }
96
111
 
112
+ async function fetchReviewDecision(
113
+ fetchImpl: typeof fetch,
114
+ token: string,
115
+ target: Target,
116
+ ): Promise<ReviewDecisionResult> {
117
+ let response: Response
118
+ try {
119
+ response = await fetchImpl(`${GITHUB_API_BASE}/graphql`, {
120
+ method: 'POST',
121
+ headers: githubJsonHeaders(token),
122
+ body: JSON.stringify({
123
+ query:
124
+ 'query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}',
125
+ variables: { owner: target.owner, repo: target.repo, number: target.prNumber },
126
+ }),
127
+ })
128
+ } catch (err) {
129
+ return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
130
+ }
131
+ if (!response.ok) {
132
+ const text = await response.text().catch(() => '')
133
+ return {
134
+ ok: false,
135
+ error: `GitHub reviewDecision ${response.status}${text !== '' ? `: ${text}` : ''}`,
136
+ code: classifyStatus(response.status),
137
+ }
138
+ }
139
+ const raw = (await response.json().catch(() => null)) as ReviewDecisionResponse | null
140
+ if (raw === null) return { ok: false, error: 'GitHub reviewDecision returned non-JSON', code: 'transient' }
141
+ if (Array.isArray(raw.errors) && raw.errors.length > 0) {
142
+ return {
143
+ ok: false,
144
+ error: `GitHub reviewDecision errors: ${raw.errors.map(describeGraphqlError).join('; ')}`,
145
+ code: 'transient',
146
+ }
147
+ }
148
+ const value = raw.data?.repository?.pullRequest?.reviewDecision ?? null
149
+ if (value === null || isReviewDecision(value)) return { ok: true, reviewDecision: value }
150
+ return { ok: false, error: `GitHub reviewDecision returned unknown value: ${String(value)}`, code: 'transient' }
151
+ }
152
+
97
153
  // A formal CHANGES_REQUESTED is sticky until a later APPROVED/DISMISSED; only
98
154
  // these three states decide the block. COMMENTED and PENDING are non-deciding
99
155
  // noise that must NOT shadow an earlier CHANGES_REQUESTED.
@@ -135,3 +191,16 @@ function classifyStatus(status: number): 'not-found' | 'permission-denied' | 'tr
135
191
  }
136
192
 
137
193
  type ReviewRow = { id?: number; state?: unknown; user?: { login?: string; type?: string } }
194
+
195
+ type ReviewDecisionResponse = {
196
+ data?: { repository?: { pullRequest?: { reviewDecision?: unknown } | null } | null }
197
+ errors?: Array<{ message?: unknown }>
198
+ }
199
+
200
+ function isReviewDecision(value: unknown): value is ReviewDecision {
201
+ return value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED'
202
+ }
203
+
204
+ function describeGraphqlError(error: { message?: unknown }): string {
205
+ return typeof error.message === 'string' ? error.message : JSON.stringify(error)
206
+ }
@@ -7,8 +7,8 @@ import type { InboundAttachment, InboundMessage, InboundReferenceContext } from
7
7
  export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'pre_connect' | 'bot_message'
8
8
 
9
9
  // LOCO message_type 71 is KakaoTalk's notification/feed channel — official
10
- // accounts like "카카오 고객센터" and "카카오계정" (login alerts, security
11
- // notices, system messages). These arrive in @kakao-group buckets because
10
+ // accounts like "KakaoTalk Customer Center" and "Kakao Account" (login alerts,
11
+ // security notices, system messages). These arrive in @kakao-group buckets because
12
12
  // they aren't normal user chats, but they are not human conversation and
13
13
  // the agent should never reply to them. Not enumerated in
14
14
  // agent-messenger's `KAKAO_MESSAGE_TYPE` because that const only covers
@@ -436,7 +436,7 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
436
436
  )
437
437
 
438
438
  // Ack the message BEFORE classify/route so the sender's unread "1"
439
- // (노란숫자) clears even when we drop the message (self-author,
439
+ // (the yellow unread badge) clears even when we drop the message (self-author,
440
440
  // unknown chat, empty text, etc.). The receiver of a kakao adapter is
441
441
  // expected to behave like a "read it as soon as it arrives" client —
442
442
  // the agent has observed the bytes, so the user should see the read
@@ -729,7 +729,7 @@ function dropHint(reason: InboundDropReason): string {
729
729
  case 'unknown_chat':
730
730
  return ' (chat not in cache after refresh and provisional registration; check earlier resolver-refresh-failed warnings)'
731
731
  case 'bot_message':
732
- return ' (LOCO message_type=71 is KakaoTalk notification/feed; official accounts like 카카오 고객센터 / 카카오계정 / login alerts)'
732
+ return ' (LOCO message_type=71 is KakaoTalk notification/feed; official accounts like KakaoTalk Customer Center / Kakao Account / login alerts)'
733
733
  case 'empty_text':
734
734
  case 'pre_connect':
735
735
  case 'self_author':
@@ -86,7 +86,7 @@ export function classifyInbound(
86
86
  // without any new config surface.
87
87
  const hasGroupMention = GROUP_MENTION_PATTERN.test(rawText)
88
88
  const isBotMention = hasGroupMention || rawText.includes(`<@${context.botUserId}>`)
89
- // Top-level alias addressing (e.g. "윙키야") is engagement-equivalent
89
+ // Top-level alias addressing (e.g. "Momo!" / "@Momo" by name) is engagement-equivalent
90
90
  // to a `<@bot>` mention (see engagement.ts: alias is unconditional and
91
91
  // ranks alongside explicit triggers). Anchor `thread` on the inbound
92
92
  // ts in that case too, so the bot's reply threads under the user's
@@ -1213,6 +1213,7 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1213
1213
  options.router.registerReaction('slack-bot', reactionCallback)
1214
1214
  options.router.registerRemoveReaction('slack-bot', removeReactionCallback)
1215
1215
  options.router.registerTyping('slack-bot', typingCallback)
1216
+ options.router.setTypingCapability('slack-bot', true)
1216
1217
  options.router.registerChannelNameResolver('slack-bot', channelResolver)
1217
1218
  options.router.registerSelfIdentity('slack-bot', selfIdentityResolver)
1218
1219
  options.router.registerHistory('slack-bot', historyCallback)
@@ -1230,6 +1231,7 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1230
1231
  options.router.unregisterReaction('slack-bot', reactionCallback)
1231
1232
  options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
1232
1233
  options.router.unregisterTyping('slack-bot', typingCallback)
1234
+ options.router.setTypingCapability('slack-bot', false)
1233
1235
  options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
1234
1236
  options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
1235
1237
  options.router.unregisterHistory('slack-bot', historyCallback)
@@ -1251,6 +1253,7 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1251
1253
  options.router.unregisterReaction('slack-bot', reactionCallback)
1252
1254
  options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
1253
1255
  options.router.unregisterTyping('slack-bot', typingCallback)
1256
+ options.router.setTypingCapability('slack-bot', false)
1254
1257
  options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
1255
1258
  options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
1256
1259
  options.router.unregisterHistory('slack-bot', historyCallback)
@@ -529,6 +529,7 @@ export function createTelegramBotAdapter(options: TelegramBotAdapterOptions): Te
529
529
 
530
530
  options.router.registerOutbound('telegram-bot', outboundCallback)
531
531
  options.router.registerTyping('telegram-bot', typingCallback)
532
+ options.router.setTypingCapability('telegram-bot', true)
532
533
  options.router.registerChannelNameResolver('telegram-bot', channelResolver)
533
534
  options.router.registerSelfIdentity('telegram-bot', selfIdentityResolver)
534
535
  options.router.registerFetchAttachment('telegram-bot', fetchAttachmentCallback)
@@ -537,6 +538,7 @@ export function createTelegramBotAdapter(options: TelegramBotAdapterOptions): Te
537
538
  const rollbackStart = (reason: string, cause: Error): never => {
538
539
  options.router.unregisterOutbound('telegram-bot', outboundCallback)
539
540
  options.router.unregisterTyping('telegram-bot', typingCallback)
541
+ options.router.setTypingCapability('telegram-bot', false)
540
542
  options.router.unregisterChannelNameResolver('telegram-bot', channelResolver)
541
543
  options.router.unregisterSelfIdentity('telegram-bot', selfIdentityResolver)
542
544
  options.router.unregisterFetchAttachment('telegram-bot', fetchAttachmentCallback)
@@ -565,6 +567,7 @@ export function createTelegramBotAdapter(options: TelegramBotAdapterOptions): Te
565
567
  started = false
566
568
  options.router.unregisterOutbound('telegram-bot', outboundCallback)
567
569
  options.router.unregisterTyping('telegram-bot', typingCallback)
570
+ options.router.setTypingCapability('telegram-bot', false)
568
571
  options.router.unregisterChannelNameResolver('telegram-bot', channelResolver)
569
572
  options.router.unregisterSelfIdentity('telegram-bot', selfIdentityResolver)
570
573
  options.router.unregisterFetchAttachment('telegram-bot', fetchAttachmentCallback)
@@ -89,6 +89,8 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
89
89
  if (config.trigger.includes('mention') && message.isBotMention) return 'engage'
90
90
  if (config.trigger.includes('reply') && message.replyToBotMessageId !== null) return 'engage'
91
91
 
92
+ const explicitOnly = message.suppressSticky === true
93
+
92
94
  // Multi-human pre-sticky target check. In a busy group the conversational
93
95
  // target shifts every message: the author we're mid-exchange with (and hold
94
96
  // a sticky credit for) may, on THIS turn, structurally address a third party
@@ -112,12 +114,13 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
112
114
  // The `!matchesAnyAlias` guard preserves the ladder invariant "explicit
113
115
  // address to us beats structural targeting of others": a message that names
114
116
  // us by alias engages on the alias rule below even when it ALSO tags a third
115
- // party (the "봉봉아 펭펭아 " multi-bot case), so we must not pre-empt
117
+ // party (the "Toto, Lala, both take a look" multi-bot case), so we must not pre-empt
116
118
  // it here. We only step aside for a credited author whose message is aimed
117
119
  // PURELY elsewhere.
118
120
  if (
119
121
  effectiveHumans > 1 &&
120
122
  config.stickiness !== 'off' &&
123
+ !explicitOnly &&
121
124
  !matchesAnyAlias(message.text, selfAliases) &&
122
125
  targetsSomeoneElse(message, participants, botInThread)
123
126
  ) {
@@ -134,7 +137,9 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
134
137
  // groups wholesale (the prior approach) dropped genuine follow-ups outright;
135
138
  // the pre-check above is narrower — it only steps aside when the message is
136
139
  // STRUCTURALLY addressed elsewhere, leaving plain follow-ups engaged.
137
- if (config.stickiness !== 'off' && ledger.consume(key, message.authorId, now)) {
140
+ // GitHub review-thread traffic must not spend content-blind sticky credit
141
+ // unless the bot was explicitly addressed.
142
+ if (!explicitOnly && config.stickiness !== 'off' && ledger.consume(key, message.authorId, now)) {
138
143
  return 'engage'
139
144
  }
140
145
 
@@ -143,12 +148,12 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
143
148
  // same priority as an explicit mention — operators add aliases
144
149
  // precisely because they expect the bot to respond when called by
145
150
  // name. Suppression on `mentionsOthers` would defeat the point: the
146
- // user can address two bots by name in one message ("봉봉아 펭펭아
147
- // ") and both should engage. Each bot only knows its own
151
+ // user can address two bots by name in one message ("Toto, Lala, both
152
+ // take a look") and both should engage. Each bot only knows its own
148
153
  // aliases, so cross-bot suppression isn't possible at this layer
149
154
  // anyway — the router-side peer-name suppression in the solo-human
150
155
  // fallback handles that case (follow-up).
151
- if (matchesAnyAlias(message.text, selfAliases)) return 'engage'
156
+ if (!explicitOnly && matchesAnyAlias(message.text, selfAliases)) return 'engage'
152
157
 
153
158
  // Solo-human fallback: the strict mention/reply/dm gate keeps the bot
154
159
  // quiet in multi-human conversations, but in a 1-human channel that
@@ -176,8 +181,8 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
176
181
  // who don't want to type `@bot` in their own DM-like channel; peer bots
177
182
  // have no such ergonomic excuse. Letting peer bots ride the fallback
178
183
  // produced bot-to-bot conversations in 1-human-N-bot channels (observed:
179
- // Winky and 돌쇠 introducing themselves to each other after a single
180
- // "얘들아" from the human, then continuing to address each other for
184
+ // Momo and Kiki introducing themselves to each other after a single
185
+ // "hey folks" from the human, then continuing to address each other for
181
186
  // ~6 turns). The router's loop guard only trips after 5 consecutive
182
187
  // peer engagements, which is too late to prevent the embarrassment.
183
188
  //