typeclaw 0.37.3 → 0.37.5

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 (58) hide show
  1. package/README.md +69 -46
  2. package/package.json +1 -1
  3. package/src/agent/compaction.ts +24 -15
  4. package/src/agent/doctor.ts +6 -1
  5. package/src/agent/session-origin.ts +101 -173
  6. package/src/agent/subagents.ts +146 -14
  7. package/src/agent/system-prompt.ts +46 -48
  8. package/src/agent/todo/scope.ts +4 -2
  9. package/src/agent/tools/channel-reply.ts +7 -9
  10. package/src/bundled-plugins/memory/index.ts +33 -33
  11. package/src/bundled-plugins/memory/load-memory.ts +92 -35
  12. package/src/bundled-plugins/memory/slug.ts +19 -0
  13. package/src/bundled-plugins/memory/turn-dedup.ts +32 -29
  14. package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
  15. package/src/bundled-plugins/tool-result-cap/README.md +7 -7
  16. package/src/bundled-plugins/tool-result-cap/index.ts +1 -1
  17. package/src/channels/adapters/discord-bot.ts +11 -4
  18. package/src/channels/adapters/github/inbound.ts +68 -43
  19. package/src/channels/adapters/github/index.ts +57 -9
  20. package/src/channels/adapters/github/recover-failed-deliveries.ts +270 -0
  21. package/src/channels/adapters/kakaotalk.ts +5 -1
  22. package/src/channels/adapters/mention-hints.ts +75 -0
  23. package/src/channels/adapters/slack-bot.ts +8 -2
  24. package/src/channels/continuation-willingness.ts +216 -68
  25. package/src/channels/router.ts +149 -15
  26. package/src/cli/dreams.ts +2 -2
  27. package/src/cli/init.ts +41 -7
  28. package/src/cli/inspect.ts +2 -2
  29. package/src/cli/logs.ts +2 -2
  30. package/src/cli/qr.ts +4 -3
  31. package/src/cli/require-agent-dir.ts +31 -0
  32. package/src/cli/shell.ts +2 -2
  33. package/src/cli/stop.ts +2 -2
  34. package/src/cli/tui.ts +20 -6
  35. package/src/cli/ui.ts +8 -4
  36. package/src/container/shared.ts +18 -0
  37. package/src/container/start.ts +1 -1
  38. package/src/doctor/checks.ts +145 -2
  39. package/src/hostd/client.ts +48 -52
  40. package/src/hostd/daemon.ts +82 -39
  41. package/src/hostd/paths.ts +22 -2
  42. package/src/hostd/spawn.ts +7 -0
  43. package/src/hostd/tailscale.ts +12 -1
  44. package/src/init/index.ts +35 -8
  45. package/src/init/kakaotalk-auth.ts +2 -2
  46. package/src/init/packagejson.ts +2 -2
  47. package/src/init/run-bun-install.ts +71 -37
  48. package/src/inspect/transcript-view.ts +15 -2
  49. package/src/plugin/loader.ts +7 -4
  50. package/src/portbroker/hostd-client.ts +32 -6
  51. package/src/sandbox/session-tmp.ts +6 -1
  52. package/src/secrets/export-claude-credentials-file.ts +2 -2
  53. package/src/shared/index.ts +4 -0
  54. package/src/shared/platform.ts +11 -0
  55. package/src/shared/wsl.ts +139 -0
  56. package/src/tui/index.ts +26 -8
  57. package/src/tui/terminal-guard.ts +139 -0
  58. package/typeclaw.schema.json +2 -2
@@ -61,56 +61,81 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
61
61
  }
62
62
 
63
63
  const delivery = req.headers.get('x-github-delivery') ?? ''
64
- if (delivery !== '' && options.dedup.has(delivery)) {
65
- options.logger.info(`[github] duplicate delivery ignored id=${delivery}`)
66
- return ok()
67
- }
68
-
69
64
  const event = req.headers.get('x-github-event') ?? ''
70
65
  const payload = parseJson(body)
71
66
  if (payload === null) return ok()
72
- const action = readString(payload, 'action')
73
- if (!isGithubEventAllowed(options.allowlist(), event, action)) return ok()
74
-
75
- const selfId = options.selfId()
76
- const selfLogin = options.selfLogin()
77
- const author = readAuthor(event, payload)
78
- if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
79
- maybeScheduleDecoyReviewerDrop({ event, action, payload, selfLogin, options })
80
- options.logger.info(
81
- `[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
82
- )
83
- return ok()
84
- }
67
+ // The HTTP request is only transport; event handling is the shared core.
68
+ await processVerifiedGithubDelivery(options, { event, delivery, payload })
69
+ return ok()
70
+ }
71
+ }
85
72
 
86
- // A push to an open PR (`synchronize`) is not a message to react to — it is
87
- // a trigger to re-evaluate the bot's own outstanding review obligations on
88
- // this PR: unresolved review threads it authored AND a sticky
89
- // CHANGES_REQUESTED block (which leaves no threads when filed as a top-level
90
- // verdict the black hole this path closes). Both need an API round-trip,
91
- // so it runs OFF the ACK path (like the decoy-reviewer drop) and only wakes a
92
- // session when an obligation is outstanding. Returning here also keeps
93
- // synchronize out of the generic awareness-only fallthrough below.
94
- if (event === 'pull_request' && action === 'synchronize') {
95
- if (delivery !== '') options.dedup.add(delivery)
96
- scheduleReviewFollowup({ payload, selfLogin, options })
97
- return ok()
73
+ // Post-verification core shared by the live HTTP handler AND the missed-delivery
74
+ // recovery sweep (recover-failed-deliveries.ts), which pulls lost payloads from
75
+ // GitHub's authenticated deliveries API and re-injects them with no HTTP request
76
+ // or signature. Routing both through this one function is the load-bearing
77
+ // invariant: recovery must never become a second, divergent event pipeline.
78
+ // `delivery` is the `X-GitHub-Delivery` GUID (stable across redeliveries, equal
79
+ // to a delivery's `guid`) used as the dedup key, so a recovered event and its
80
+ // later/duplicate live delivery collapse to one route. HMAC is the caller's job:
81
+ // the live handler verifies the body; the sweep trusts the authenticated API.
82
+ export async function processVerifiedGithubDelivery(
83
+ options: GithubWebhookHandlerOptions,
84
+ input: { event: string; delivery: string; payload: Record<string, unknown> },
85
+ ): Promise<void> {
86
+ const { event, delivery, payload } = input
87
+ if (delivery !== '') {
88
+ if (options.dedup.has(delivery)) {
89
+ options.logger.info(`[github] duplicate delivery ignored id=${delivery}`)
90
+ return
98
91
  }
92
+ // Reserve the delivery id synchronously, BEFORE the awaits below, so a live
93
+ // webhook and the recovery sweep can never both clear the dedup gate for the
94
+ // same event and route it twice. JS is single-threaded: nothing else runs
95
+ // between this has-check and add, so the reservation is atomic. The awaits
96
+ // and classify that follow are all throw-safe, so reserving early cannot
97
+ // strand a routable event.
98
+ options.dedup.add(delivery)
99
+ }
99
100
 
100
- const teamIsBotMember = await resolveTeamMembership(event, payload, options)
101
- const reviewCommentParent = await resolveReviewCommentParent(event, payload, selfId, selfLogin, options)
102
- const classified = classifyGithubInbound(event, payload, selfLogin, {
103
- teamIsBotMember,
104
- authType: options.authType?.() ?? 'pat',
105
- reviewOn: options.reviewOn?.() ?? 'review_requested',
106
- ...(reviewCommentParent !== null ? { reviewCommentParent } : {}),
107
- })
108
- if (classified === null) return ok()
109
-
110
- if (delivery !== '') options.dedup.add(delivery)
111
- options.route(withApprovalPolicy(classified, options.allowApprove?.() ?? true))
112
- return ok()
101
+ const action = readString(payload, 'action')
102
+ if (!isGithubEventAllowed(options.allowlist(), event, action)) return
103
+
104
+ const selfId = options.selfId()
105
+ const selfLogin = options.selfLogin()
106
+ const author = readAuthor(event, payload)
107
+ if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
108
+ maybeScheduleDecoyReviewerDrop({ event, action, payload, selfLogin, options })
109
+ options.logger.info(
110
+ `[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
111
+ )
112
+ return
113
113
  }
114
+
115
+ // A push to an open PR (`synchronize`) is not a message to react to — it is
116
+ // a trigger to re-evaluate the bot's own outstanding review obligations on
117
+ // this PR: unresolved review threads it authored AND a sticky
118
+ // CHANGES_REQUESTED block (which leaves no threads when filed as a top-level
119
+ // verdict — the black hole this path closes). Both need an API round-trip,
120
+ // so it runs OFF the ACK path (like the decoy-reviewer drop) and only wakes a
121
+ // session when an obligation is outstanding. Returning here also keeps
122
+ // synchronize out of the generic awareness-only fallthrough below.
123
+ if (event === 'pull_request' && action === 'synchronize') {
124
+ scheduleReviewFollowup({ payload, selfLogin, options })
125
+ return
126
+ }
127
+
128
+ const teamIsBotMember = await resolveTeamMembership(event, payload, options)
129
+ const reviewCommentParent = await resolveReviewCommentParent(event, payload, selfId, selfLogin, options)
130
+ const classified = classifyGithubInbound(event, payload, selfLogin, {
131
+ teamIsBotMember,
132
+ authType: options.authType?.() ?? 'pat',
133
+ reviewOn: options.reviewOn?.() ?? 'review_requested',
134
+ ...(reviewCommentParent !== null ? { reviewCommentParent } : {}),
135
+ })
136
+ if (classified === null) return
137
+
138
+ options.route(withApprovalPolicy(classified, options.allowApprove?.() ?? true))
114
139
  }
115
140
 
116
141
  export const PR_APPROVAL_DISABLED_NOTE =
@@ -11,7 +11,7 @@ import { createDeliveryDedup } from './dedup'
11
11
  import { findPermissionGaps } from './event-permissions'
12
12
  import { createGithubFetchAttachmentCallback } from './fetch-attachment'
13
13
  import { createGithubHistoryCallback } from './history'
14
- import { createGithubWebhookHandler } from './inbound'
14
+ import { createGithubWebhookHandler, processVerifiedGithubDelivery, type GithubWebhookHandlerOptions } from './inbound'
15
15
  import { applyManagedPath, buildManagedPath, resolveAgentId } from './managed-path'
16
16
  import { createGithubMembershipResolver } from './membership'
17
17
  import { createGithubOutboundCallback } from './outbound'
@@ -22,6 +22,7 @@ import {
22
22
  } from './permission-guidance'
23
23
  import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
24
24
  import { reconcileOpenPrs } from './reconcile-open-prs'
25
+ import { createRecoveredGuidLog, recoverFailedGithubDeliveries } from './recover-failed-deliveries'
25
26
  import { createGithubReviewStateResolver } from './review-state'
26
27
  import { createGithubReviewThreadResolver } from './review-thread-resolver'
27
28
  import { createTeamMembershipChecker } from './team-membership'
@@ -67,6 +68,10 @@ export type GithubAdapterOptions = {
67
68
  // Test-only: replaces `setInterval` so tests can control when the
68
69
  // background refresh fires without waiting on real wall-clock time.
69
70
  setInterval?: (handler: () => void, ms: number) => { clear: () => void }
71
+ // How often to sweep each managed hook's GitHub delivery log for events whose
72
+ // inbound delivery failed (and that GitHub never redelivered), re-injecting
73
+ // them through the live event path. Zero disables the sweep. Default: 5 min.
74
+ deliveryRecoveryIntervalMs?: number
70
75
  // Write-side of the GithubTokenBridge. On App-auth start the adapter
71
76
  // registers a per-repo minter here so plugin hooks can resolve a token for
72
77
  // ad-hoc `gh` commands; it unregisters on stop and on start rollback. PAT
@@ -89,6 +94,12 @@ const consoleLogger: GithubAdapterLogger = {
89
94
 
90
95
  const DEFAULT_WEBHOOK_REGISTRATION_DELAY_MS = 2_000
91
96
  const DEFAULT_TOKEN_REFRESH_INTERVAL_MS = 30 * 60 * 1000
97
+ const DEFAULT_DELIVERY_RECOVERY_INTERVAL_MS = 5 * 60 * 1000
98
+ // GitHub retains the delivery log for 3 days; sweep a little under that so a
99
+ // failed delivery is always still listable on the next interval.
100
+ const DELIVERY_RECOVERY_LOOKBACK_MS = 70 * 60 * 60 * 1000
101
+ // Bounds an LLM-session storm if a bad tunnel window drops a large burst.
102
+ const MAX_RECOVERED_PER_SWEEP = 50
92
103
 
93
104
  export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapter {
94
105
  const logger = options.logger ?? consoleLogger
@@ -105,8 +116,15 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
105
116
  let started = false
106
117
  let managedHooks: ReadonlyArray<{ repo: string; hookId: number }> = []
107
118
  let tokenRefreshTimer: { clear: () => void } | null = null
119
+ let deliveryRecoveryTimer: { clear: () => void } | null = null
108
120
  let unregisterTokenBridge: (() => void) | null = null
109
121
  const workspaceByChat = new Map<string, string>()
122
+ const setIntervalFn =
123
+ options.setInterval ??
124
+ ((handler: () => void, ms: number) => {
125
+ const timer = setInterval(handler, ms)
126
+ return { clear: () => clearInterval(timer) }
127
+ })
110
128
 
111
129
  const rememberWorkspace = (workspace: string, chat: string): void => {
112
130
  workspaceByChat.set(chat, workspace)
@@ -174,7 +192,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
174
192
  logger.error(`[github] route failed: ${err instanceof Error ? err.message : String(err)}`)
175
193
  })
176
194
  }
177
- const handler = createGithubWebhookHandler({
195
+ const handlerOptions: GithubWebhookHandlerOptions = {
178
196
  webhookSecret,
179
197
  dedup,
180
198
  allowlist: () => options.configRef().eventAllowlist,
@@ -188,7 +206,8 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
188
206
  fetchImpl,
189
207
  logger,
190
208
  route: routeInbound,
191
- })
209
+ }
210
+ const handler = createGithubWebhookHandler(handlerOptions)
192
211
 
193
212
  return {
194
213
  async start(): Promise<void> {
@@ -251,12 +270,6 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
251
270
  )
252
271
  })
253
272
  }
254
- const setIntervalFn =
255
- options.setInterval ??
256
- ((handler: () => void, ms: number) => {
257
- const timer = setInterval(handler, ms)
258
- return { clear: () => clearInterval(timer) }
259
- })
260
273
  tokenRefreshTimer = setIntervalFn(refresh, tokenRefreshIntervalMs)
261
274
  }
262
275
  } else {
@@ -355,10 +368,45 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
355
368
  logger.warn(`[github] reconcile pass failed: ${err instanceof Error ? err.message : String(err)}`)
356
369
  })
357
370
  }
371
+ // Periodically recover inbound deliveries that failed at the tunnel and
372
+ // were never redelivered (the cloudflare-quick 502 loss). Registered only
373
+ // when we manage hooks to query, and driven by the same injectable timer
374
+ // as the token refresh. The first sweep fires after one interval — NOT
375
+ // inside start() — so start() stays free of surprise API traffic; the
376
+ // reconcile pass above already covers the review-needed case immediately.
377
+ const deliveryRecoveryIntervalMs = options.deliveryRecoveryIntervalMs ?? DEFAULT_DELIVERY_RECOVERY_INTERVAL_MS
378
+ if (managedHooks.length > 0 && deliveryRecoveryIntervalMs > 0) {
379
+ // Created once and captured by `sweep`, so recovery idempotency persists
380
+ // across ticks even when the shared live dedup evicts the guid.
381
+ const recoveredLog = createRecoveredGuidLog(DELIVERY_RECOVERY_LOOKBACK_MS)
382
+ const sweep = () => {
383
+ recoverFailedGithubDeliveries({
384
+ hooks: managedHooks,
385
+ token: (repoSlug: string) => auth.token({ repoSlug }),
386
+ process: (input) => processVerifiedGithubDelivery(handlerOptions, input),
387
+ alreadySeen: (guid: string) => dedup.has(guid),
388
+ recoveredLog,
389
+ lookbackMs: DELIVERY_RECOVERY_LOOKBACK_MS,
390
+ maxPerSweep: MAX_RECOVERED_PER_SWEEP,
391
+ logger,
392
+ fetchImpl,
393
+ }).catch((err: unknown) => {
394
+ logger.warn(`[github] delivery recovery sweep failed: ${err instanceof Error ? err.message : String(err)}`)
395
+ })
396
+ }
397
+ deliveryRecoveryTimer = setIntervalFn(sweep, deliveryRecoveryIntervalMs)
398
+ }
358
399
  },
359
400
  async stop(): Promise<void> {
360
401
  if (!started) return
361
402
  started = false
403
+ // Stop the recovery sweep first: its async work outlives the synchronous
404
+ // unregister calls below, and a tick landing mid-teardown would query a
405
+ // hook we're about to deregister and could route during shutdown.
406
+ if (deliveryRecoveryTimer !== null) {
407
+ deliveryRecoveryTimer.clear()
408
+ deliveryRecoveryTimer = null
409
+ }
362
410
  options.router.unregisterOutbound('github', outbound)
363
411
  options.router.unregisterReaction('github', reaction)
364
412
  options.router.unregisterRemoveReaction('github', removeReaction)
@@ -0,0 +1,270 @@
1
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
2
+
3
+ // Recovers webhook events whose delivery to our ingress FAILED and that GitHub
4
+ // never successfully redelivered. The production failure mode is inbound-only: a
5
+ // cloudflare-quick tunnel drops ~half its deliveries with 502 "failed to connect
6
+ // to host", and GitHub does not auto-redeliver issue_comment events — so a
7
+ // `@bot review please` comment is lost with no log entry and no reply.
8
+ //
9
+ // This sweep is OUTBOUND-only, so it never touches the broken inbound leg: it
10
+ // lists each managed hook's delivery log, finds events with no successful
11
+ // delivery, fetches the original payload from GitHub's authenticated deliveries
12
+ // API, and feeds it through the SAME processVerifiedGithubDelivery core a live
13
+ // webhook uses (passed in as `process`). It is a floor, not the primary path —
14
+ // webhooks remain low-latency when delivery works; reconcile-open-prs.ts is the
15
+ // sibling floor for review-state drift.
16
+
17
+ export type ManagedHook = { repo: string; hookId: number }
18
+
19
+ // Recovery-owned idempotency keyed by delivery GUID, retained for the FULL
20
+ // lookback window. The shared live dedup cannot serve this alone: it is a
21
+ // fixed 1000-entry LRU, so during a 70h lookback across many repos it can evict
22
+ // a GUID we already recovered, after which the still-listed failed delivery
23
+ // would be re-fetched and re-routed. This TTL log holds only RECOVERED GUIDs
24
+ // (failed-then-recovered deliveries, a small set), expiring each exactly when it
25
+ // also falls out of the scan window — so a recovered delivery is routed once
26
+ // regardless of live-dedup churn. (The shared dedup still guards the live-vs-
27
+ // sweep concurrency race; this guards cross-sweep durability.)
28
+ export type RecoveredGuidLog = { has: (guid: string) => boolean; record: (guid: string) => void }
29
+
30
+ export function createRecoveredGuidLog(ttlMs: number, now: () => number = Date.now): RecoveredGuidLog {
31
+ const expiresAt = new Map<string, number>()
32
+ return {
33
+ has(guid: string): boolean {
34
+ const expiry = expiresAt.get(guid)
35
+ if (expiry === undefined) return false
36
+ if (expiry <= now()) {
37
+ expiresAt.delete(guid)
38
+ return false
39
+ }
40
+ return true
41
+ },
42
+ record(guid: string): void {
43
+ const t = now()
44
+ for (const [g, expiry] of expiresAt) if (expiry <= t) expiresAt.delete(g)
45
+ expiresAt.set(guid, t + ttlMs)
46
+ },
47
+ }
48
+ }
49
+
50
+ export type RecoverFailedDeliveriesOptions = {
51
+ hooks: readonly ManagedHook[]
52
+ token: (repoSlug: string) => Promise<string>
53
+ // The shared processVerifiedGithubDelivery, bound to the adapter's handler
54
+ // options. `delivery` is the GUID; the core dedups, filters by allowlist,
55
+ // drops self-authored, and routes exactly as the live path does.
56
+ process: (input: { event: string; delivery: string; payload: Record<string, unknown> }) => Promise<void>
57
+ // Fast-path skip backed by the LIVE delivery dedup (shared with the webhook
58
+ // handler): a guid here was just routed live (or reserved by `process` on
59
+ // entry), so skip it. Best-effort only — it is a 1000-entry LRU and may evict
60
+ // within the lookback window, which is exactly why `recoveredLog` exists.
61
+ alreadySeen: (guid: string) => boolean
62
+ // Durable recovery idempotency for the whole lookback window (see
63
+ // createRecoveredGuidLog). Caller-owned so it persists across sweeps.
64
+ recoveredLog: RecoveredGuidLog
65
+ lookbackMs: number
66
+ maxPerSweep: number
67
+ logger: { info: (m: string) => void; warn: (m: string) => void }
68
+ now?: () => number
69
+ fetchImpl?: typeof fetch
70
+ }
71
+
72
+ export type RecoverOutcome = { recovered: number; scanned: number }
73
+
74
+ export async function recoverFailedGithubDeliveries(options: RecoverFailedDeliveriesOptions): Promise<RecoverOutcome> {
75
+ const fetchImpl = options.fetchImpl ?? fetch
76
+ const now = options.now ?? Date.now
77
+ const cutoff = now() - options.lookbackMs
78
+ let recovered = 0
79
+ let scanned = 0
80
+
81
+ for (const hook of options.hooks) {
82
+ // maxPerSweep is a GLOBAL budget across all hooks (an LLM-session storm
83
+ // guard), so pass the remaining budget into each hook rather than letting
84
+ // every hook recover up to the full cap independently.
85
+ const remaining = options.maxPerSweep - recovered
86
+ if (remaining <= 0) break
87
+ const target = parseRepo(hook.repo)
88
+ if (target === null) {
89
+ options.logger.warn(`[github] recovery skipped malformed repo slug "${hook.repo}"`)
90
+ continue
91
+ }
92
+ try {
93
+ const result = await recoverHook(hook, target, cutoff, options, remaining, fetchImpl)
94
+ scanned += result.scanned
95
+ recovered += result.recovered
96
+ if (result.recovered > 0) {
97
+ options.logger.info(`[github] recovered ${result.recovered} missed delivery(s) on ${hook.repo}`)
98
+ }
99
+ } catch (err) {
100
+ // Per-hook isolation: one repo's token/list/detail failure must not abort
101
+ // the others. The next interval retries this hook.
102
+ options.logger.warn(`[github] delivery recovery failed for ${hook.repo}: ${describe(err)}`)
103
+ }
104
+ }
105
+ return { recovered, scanned }
106
+ }
107
+
108
+ async function recoverHook(
109
+ hook: ManagedHook,
110
+ target: RepoTarget,
111
+ cutoff: number,
112
+ options: RecoverFailedDeliveriesOptions,
113
+ budget: number,
114
+ fetchImpl: typeof fetch,
115
+ ): Promise<RecoverOutcome> {
116
+ const token = await options.token(hook.repo)
117
+ const deliveries = await listRecentDeliveries(fetchImpl, token, target, hook.hookId, cutoff)
118
+
119
+ // Any 2xx/3xx delivery for a guid means the event got through (e.g. GitHub
120
+ // auto-redelivered, or a manual redeliver succeeded). Never recover those.
121
+ const succeededGuids = new Set<string>()
122
+ for (const d of deliveries) {
123
+ if (isSuccess(d.statusCode)) succeededGuids.add(d.guid)
124
+ }
125
+
126
+ let recovered = 0
127
+ let scanned = 0
128
+ const handledThisSweep = new Set<string>()
129
+ for (const delivery of deliveries) {
130
+ if (recovered >= budget) break
131
+ if (isSuccess(delivery.statusCode)) continue
132
+ scanned += 1
133
+ const guid = delivery.guid
134
+ if (guid === '') continue
135
+ if (
136
+ succeededGuids.has(guid) ||
137
+ handledThisSweep.has(guid) ||
138
+ options.alreadySeen(guid) ||
139
+ options.recoveredLog.has(guid)
140
+ ) {
141
+ continue
142
+ }
143
+ handledThisSweep.add(guid)
144
+
145
+ const payload = await fetchDeliveryPayload(fetchImpl, token, target, hook.hookId, delivery.id)
146
+ if (payload === null) continue
147
+ await options.process({ event: delivery.event, delivery: guid, payload })
148
+ // Record AFTER process resolves: an unexpected throw leaves the guid
149
+ // unrecorded so the next sweep retries it. A no-op classify still records
150
+ // (process returned), so a non-routable failed delivery is not refetched.
151
+ options.recoveredLog.record(guid)
152
+ recovered += 1
153
+ }
154
+ return { recovered, scanned }
155
+ }
156
+
157
+ type RepoTarget = { owner: string; repo: string }
158
+
159
+ type DeliverySummary = { id: number; guid: string; event: string; statusCode: number }
160
+
161
+ async function listRecentDeliveries(
162
+ fetchImpl: typeof fetch,
163
+ token: string,
164
+ target: RepoTarget,
165
+ hookId: number,
166
+ cutoff: number,
167
+ ): Promise<DeliverySummary[]> {
168
+ const summaries: DeliverySummary[] = []
169
+ let url: string | null =
170
+ `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/hooks/${hookId}/deliveries?per_page=100`
171
+ while (url !== null) {
172
+ const response = await fetchImpl(url, { headers: githubJsonHeaders(token) })
173
+ if (!response.ok) {
174
+ const body = await response.text().catch(() => '')
175
+ throw new Error(`GitHub deliveries ${response.status}${body !== '' ? `: ${body}` : ''}`)
176
+ }
177
+ const page = (await response.json().catch(() => null)) as DeliveryRow[] | null
178
+ if (page === null) throw new Error('GitHub deliveries returned non-JSON')
179
+ // Deliveries are newest-first; once a page's oldest entry predates the
180
+ // lookback cutoff we can stop paginating instead of walking the full log.
181
+ let reachedCutoff = false
182
+ for (const row of page) {
183
+ const parsed = parseDeliveryRow(row)
184
+ if (parsed === null) continue
185
+ if (parsed.deliveredAt !== null && parsed.deliveredAt < cutoff) {
186
+ reachedCutoff = true
187
+ continue
188
+ }
189
+ summaries.push(parsed.summary)
190
+ }
191
+ if (reachedCutoff) break
192
+ url = nextLink(response.headers.get('link'))
193
+ }
194
+ return summaries
195
+ }
196
+
197
+ async function fetchDeliveryPayload(
198
+ fetchImpl: typeof fetch,
199
+ token: string,
200
+ target: RepoTarget,
201
+ hookId: number,
202
+ deliveryId: number,
203
+ ): Promise<Record<string, unknown> | null> {
204
+ const response = await fetchImpl(
205
+ `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/hooks/${hookId}/deliveries/${deliveryId}`,
206
+ { headers: githubJsonHeaders(token) },
207
+ )
208
+ if (!response.ok) return null
209
+ const raw = (await response.json().catch(() => null)) as { request?: { payload?: unknown } } | null
210
+ return coercePayload(raw?.request?.payload)
211
+ }
212
+
213
+ function coercePayload(value: unknown): Record<string, unknown> | null {
214
+ if (typeof value === 'string') {
215
+ try {
216
+ const parsed = JSON.parse(value) as unknown
217
+ return isRecord(parsed) ? parsed : null
218
+ } catch {
219
+ return null
220
+ }
221
+ }
222
+ return isRecord(value) ? value : null
223
+ }
224
+
225
+ // GitHub records a non-delivery (connection refused / DNS / tunnel down) as
226
+ // status_code 0, and HTTP failures as 4xx/5xx. Treat 2xx and 3xx as success.
227
+ function isSuccess(statusCode: number): boolean {
228
+ return statusCode >= 200 && statusCode < 400
229
+ }
230
+
231
+ type DeliveryRow = {
232
+ id?: unknown
233
+ guid?: unknown
234
+ event?: unknown
235
+ status_code?: unknown
236
+ delivered_at?: unknown
237
+ }
238
+
239
+ function parseDeliveryRow(row: DeliveryRow): { summary: DeliverySummary; deliveredAt: number | null } | null {
240
+ const id = typeof row.id === 'number' ? row.id : null
241
+ const guid = typeof row.guid === 'string' ? row.guid : null
242
+ const event = typeof row.event === 'string' ? row.event : null
243
+ const statusCode = typeof row.status_code === 'number' ? row.status_code : null
244
+ if (id === null || guid === null || event === null || statusCode === null) return null
245
+ const deliveredAt = typeof row.delivered_at === 'string' ? Date.parse(row.delivered_at) || null : null
246
+ return { summary: { id, guid, event, statusCode }, deliveredAt }
247
+ }
248
+
249
+ function parseRepo(slug: string): RepoTarget | null {
250
+ const [owner, repo, ...rest] = slug.trim().split('/')
251
+ if (owner === undefined || owner === '' || repo === undefined || repo === '' || rest.length > 0) return null
252
+ return { owner, repo }
253
+ }
254
+
255
+ function nextLink(linkHeader: string | null): string | null {
256
+ if (linkHeader === null) return null
257
+ for (const part of linkHeader.split(',')) {
258
+ const m = /<([^>]+)>;\s*rel="next"/.exec(part)
259
+ if (m !== null) return m[1] ?? null
260
+ }
261
+ return null
262
+ }
263
+
264
+ function isRecord(value: unknown): value is Record<string, unknown> {
265
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
266
+ }
267
+
268
+ function describe(err: unknown): string {
269
+ return err instanceof Error ? err.message : String(err)
270
+ }
@@ -1,3 +1,5 @@
1
+ import { basename } from 'node:path'
2
+
1
3
  import {
2
4
  KakaoCredentialManager,
3
5
  KakaoTalkClient as RealKakaoTalkClient,
@@ -203,7 +205,9 @@ export function createOutboundCallback(deps: {
203
205
  try {
204
206
  items = await Promise.all(
205
207
  attachments.map(async (a) => {
206
- const filename = a.filename ?? a.path.split('/').pop() ?? 'file'
208
+ // basename (not split('/')) so a native-Windows host's backslash
209
+ // attachment paths still yield just the filename (issue #899).
210
+ const filename = a.filename ?? (basename(a.path) || 'file')
207
211
  const data = await readFile(a.path)
208
212
  return { data, filename }
209
213
  }),
@@ -0,0 +1,75 @@
1
+ import type { AdapterId } from '../schema'
2
+
3
+ export type DiscordMentionUser = { id: string; username?: string; global_name?: string | null }
4
+
5
+ export type MentionHintOptions = { botUserId?: string | null }
6
+
7
+ // Slack encodes user mentions as `<@U…>`/`<@W…>`, optionally with a native
8
+ // `|label` fallback suffix. We capture the id and the whole token so the bare
9
+ // `<@id>` can be reconstructed (dropping any legacy label) and a resolved hint
10
+ // appended after it.
11
+ const SLACK_MENTION_PATTERN = /<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g
12
+
13
+ // Discord uses `<@id>` and the nickname form `<@!id>`; the `!` is optional and
14
+ // irrelevant to the target user, so it is captured but discarded on rewrite.
15
+ const DISCORD_MENTION_PATTERN = /<@!?(\d+)>/g
16
+
17
+ // User ids the agent addressed by @-mention in an outbound message, so the
18
+ // router can grant them sticky credit (their reply answers us without a
19
+ // re-mention). Only Slack (`<@U…>`) and Discord (`<@id>`) encode mentions as
20
+ // raw id tokens that map 1:1 to an inbound `authorId`; Telegram (`@username`),
21
+ // GitHub (`@login`), LINE and KakaoTalk have no token→authorId mapping here, so
22
+ // they return [] by design and the caller falls back to existing rules.
23
+ export function extractMentionedUserIds(adapter: AdapterId, text: string): string[] {
24
+ const pattern =
25
+ adapter === 'slack-bot' ? SLACK_MENTION_PATTERN : adapter === 'discord-bot' ? DISCORD_MENTION_PATTERN : null
26
+ if (pattern === null) return []
27
+ const ids = new Set<string>()
28
+ for (const match of text.matchAll(pattern)) ids.add(match[1]!)
29
+ return Array.from(ids)
30
+ }
31
+
32
+ export async function addSlackMentionHints(
33
+ text: string,
34
+ resolveUserName: (id: string) => Promise<string>,
35
+ options: MentionHintOptions = {},
36
+ ): Promise<string> {
37
+ const ids = new Set<string>()
38
+ for (const match of text.matchAll(SLACK_MENTION_PATTERN)) ids.add(match[1]!)
39
+ if (ids.size === 0) return text
40
+
41
+ const hints = new Map<string, string>()
42
+ await Promise.all(
43
+ Array.from(ids).map(async (id) => {
44
+ const hint = resolveHint(id, await resolveUserName(id), options.botUserId)
45
+ if (hint !== null) hints.set(id, hint)
46
+ }),
47
+ )
48
+
49
+ return text.replace(SLACK_MENTION_PATTERN, (_token, id: string) => renderToken(id, hints.get(id)))
50
+ }
51
+
52
+ export function addDiscordMentionHints(
53
+ text: string,
54
+ usersById: Map<string, DiscordMentionUser>,
55
+ options: MentionHintOptions = {},
56
+ ): string {
57
+ return text.replace(DISCORD_MENTION_PATTERN, (token, id: string) => {
58
+ const user = usersById.get(id)
59
+ const name = user === undefined ? id : (user.global_name ?? user.username ?? id)
60
+ const hint = resolveHint(id, name, options.botUserId)
61
+ return hint === null ? token : `${token} (${hint})`
62
+ })
63
+ }
64
+
65
+ function resolveHint(id: string, resolvedName: string, botUserId: string | null | undefined): string | null {
66
+ if (id === botUserId) return 'you'
67
+ // The resolver echoes the id back when it cannot find a name; a bare id is
68
+ // not a useful hint, so leave the token unannotated in that case.
69
+ if (resolvedName === id || resolvedName === '') return null
70
+ return resolvedName
71
+ }
72
+
73
+ function renderToken(id: string, hint: string | undefined): string {
74
+ return hint === undefined ? `<@${id}>` : `<@${id}> (${hint})`
75
+ }
@@ -32,6 +32,7 @@ import type {
32
32
  } from '@/channels/types'
33
33
  import { chunkMarkdown } from '@/markdown'
34
34
 
35
+ import { addSlackMentionHints } from './mention-hints'
35
36
  import { createSlackAuthorResolver, type SlackAuthorResolver } from './slack-bot-author-resolver'
36
37
  import { createSlackChannelResolver } from './slack-bot-channel-resolver'
37
38
  import {
@@ -703,11 +704,14 @@ export function createSlackHistoryCallback(deps: {
703
704
  // users.info entry. The resolver caches/coalesces, so repeated authors
704
705
  // cost one lookup each.
705
706
  if (authorResolver !== undefined) {
707
+ const resolver = authorResolver
708
+ const botId = botUserIdRef()
706
709
  await Promise.all(
707
710
  mapped.map(async (message, index) => {
711
+ message.text = await addSlackMentionHints(message.text, resolver.resolve, { botUserId: botId })
708
712
  const userId = rawMessages[index]?.user
709
713
  if (userId === undefined || userId === '') return
710
- message.authorName = await authorResolver.resolve(userId)
714
+ message.authorName = await resolver.resolve(userId)
711
715
  }),
712
716
  )
713
717
  }
@@ -1133,9 +1137,10 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1133
1137
  }
1134
1138
 
1135
1139
  dedupe.mark(event)
1140
+ const hintedText = await addSlackMentionHints(verdict.payload.text, authorResolver.resolve, { botUserId })
1136
1141
  const slackAttachments = Array.isArray(event.attachments) ? event.attachments : undefined
1137
1142
  const referenceResult = await enrichSlackReferenceContext({
1138
- text: verdict.payload.text,
1143
+ text: hintedText,
1139
1144
  channelId: event.channel,
1140
1145
  messageTs: event.ts,
1141
1146
  ...(slackAttachments !== undefined ? { attachments: slackAttachments } : {}),
@@ -1143,6 +1148,7 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1143
1148
  })
1144
1149
  const enriched = {
1145
1150
  ...verdict.payload,
1151
+ text: hintedText,
1146
1152
  authorName: resolvedUserName,
1147
1153
  ...(referenceResult.referenceContext !== undefined
1148
1154
  ? { referenceContext: referenceResult.referenceContext }