typeclaw 0.37.4 → 0.37.6

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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/agent/doctor.ts +6 -1
  3. package/src/agent/plugin-tools.ts +23 -1
  4. package/src/agent/subagents.ts +146 -14
  5. package/src/agent/todo/scope.ts +4 -2
  6. package/src/agent/tools/channel-reply.ts +7 -9
  7. package/src/bundled-plugins/doc-render/index.ts +10 -0
  8. package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +171 -165
  9. package/src/bundled-plugins/doc-render/templates/lib.typ +339 -0
  10. package/src/bundled-plugins/github-cli-auth/gh-command.ts +95 -11
  11. package/src/bundled-plugins/github-cli-auth/git-command.ts +11 -0
  12. package/src/bundled-plugins/github-cli-auth/index.ts +68 -7
  13. package/src/bundled-plugins/memory/index.ts +9 -6
  14. package/src/bundled-plugins/memory/load-memory.ts +16 -2
  15. package/src/bundled-plugins/memory/slug.ts +19 -0
  16. package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
  17. package/src/channels/adapters/github/inbound.ts +68 -43
  18. package/src/channels/adapters/github/index.ts +57 -9
  19. package/src/channels/adapters/github/recover-failed-deliveries.ts +270 -0
  20. package/src/channels/adapters/kakaotalk.ts +5 -1
  21. package/src/channels/adapters/mention-hints.ts +17 -0
  22. package/src/channels/manager.ts +77 -1
  23. package/src/channels/router.ts +181 -12
  24. package/src/cli/compose.ts +11 -2
  25. package/src/cli/dreams.ts +2 -2
  26. package/src/cli/inspect.ts +2 -2
  27. package/src/cli/logs.ts +2 -2
  28. package/src/cli/mount.ts +5 -5
  29. package/src/cli/require-agent-dir.ts +31 -0
  30. package/src/cli/restart.ts +2 -1
  31. package/src/cli/shell.ts +2 -2
  32. package/src/cli/start.ts +2 -1
  33. package/src/cli/stop.ts +2 -2
  34. package/src/cli/tui.ts +20 -6
  35. package/src/cli/ui.ts +13 -0
  36. package/src/compose/restart.ts +1 -1
  37. package/src/compose/start.ts +4 -2
  38. package/src/config/config.ts +200 -9
  39. package/src/container/shared.ts +18 -0
  40. package/src/container/start.ts +1 -1
  41. package/src/cron/consumer.ts +3 -3
  42. package/src/hostd/client.ts +48 -52
  43. package/src/hostd/daemon.ts +82 -39
  44. package/src/hostd/paths.ts +22 -2
  45. package/src/hostd/spawn.ts +7 -0
  46. package/src/init/dockerfile.ts +11 -8
  47. package/src/init/kakaotalk-auth.ts +2 -2
  48. package/src/init/packagejson.ts +2 -2
  49. package/src/plugin/loader.ts +7 -4
  50. package/src/sandbox/session-tmp.ts +6 -1
  51. package/src/secrets/export-claude-credentials-file.ts +2 -2
@@ -7,7 +7,7 @@ import { createApproveIdempotencyGuard } from './approve-idempotency'
7
7
  import { createGithubEffectiveApprovalResolver, createGithubHeadShaResolver } from './effective-approval'
8
8
  import { analyzeGhCommand, effectiveGhTokensForAuthenticatedUserEndpoint } from './gh-command'
9
9
  import { ensureGitAskPassHelper } from './git-askpass'
10
- import { analyzeGitCommand, defaultGitResolvers } from './git-command'
10
+ import { analyzeGitCommand, defaultGitResolvers, resolveGhDefaultRepoFromCwd } from './git-command'
11
11
  import { checkGraphqlAuthNudge } from './graphql-auth-nudge'
12
12
  import { commitReviewIfSucceeded, noteReviewCommand } from './review-recorder'
13
13
  import { classifyGhToken, shouldMintAppToken } from './token-class'
@@ -72,6 +72,35 @@ export default definePlugin({
72
72
 
73
73
  type HookResult = void | { block: true; reason: string }
74
74
 
75
+ // A TRUSTED repo to fill in for a repo-less `gh` command, resolved from
76
+ // sources the command author cannot forge: (1) a GitHub channel session's
77
+ // own repo (origin.workspace comes from the signed webhook payload), then
78
+ // (2) the working tree's `origin` remote. NOT from any `-R`/path in the
79
+ // command (that is the attacker-controllable input the parser already
80
+ // handles). The slug is still gated by the repos[] allowlist at mint time.
81
+ const resolveTrustedFallbackRepo = async (origin: SessionOrigin | undefined): Promise<string | undefined> => {
82
+ if (origin?.kind === 'channel' && origin.adapter === 'github' && origin.workspace !== '') {
83
+ return origin.workspace
84
+ }
85
+ const fromCwd = await resolveGhDefaultRepoFromCwd(ctx.agentDir, defaultGitResolvers)
86
+ return fromCwd ?? undefined
87
+ }
88
+
89
+ // When a repo-less `gh` is blocked but a trusted repo IS available, show the
90
+ // exact single-bare rewrite so the agent recovers in one step instead of
91
+ // guessing. Composition blocks get a split-the-script instruction. The
92
+ // returned text is appended to the block reason (synchronous, always seen).
93
+ const buildGhBlockGuidance = (code: string, fallbackRepo: string | undefined): string => {
94
+ const slug = fallbackRepo ?? 'owner/repo'
95
+ if (code === 'composition') {
96
+ return (
97
+ ` Run each gh as its own single bare command, e.g. \`gh label edit <name> -R ${slug} --name ...\` —` +
98
+ ' not inside a function, `if`/`then`, `&&`, `;`, or `$(...)`.'
99
+ )
100
+ }
101
+ return ` For example: \`gh <cmd> -R ${slug}\` as a single bare command.`
102
+ }
103
+
75
104
  // 'fall-through' means "not a repo-targeting gh command" so the caller can
76
105
  // try the git path on the same command (e.g. `git ... # gh` substrings).
77
106
  const handleGhCommand = async (params: {
@@ -91,7 +120,26 @@ export default definePlugin({
91
120
  }
92
121
  if (review.dump !== null) return review.dump
93
122
 
94
- const decision = analyzeGhCommand(command)
123
+ // Analyze first WITHOUT the fallback: an explicit `-R`/path repo must win,
124
+ // and we only pay for fallback resolution (a git subprocess) when the
125
+ // command is otherwise repo-less. A trusted fallback is then applied ONLY to
126
+ // a `missing-repo` block (never to composition/non-literal/multi-owner/api),
127
+ // and re-analysis re-runs the SAME composition gate, so a compound command
128
+ // still blocks. `fallbackRepoUsed` marks an inject that came from the
129
+ // fallback so we also set GH_REPO (gh needs the repo, not just the token).
130
+ let decision = analyzeGhCommand(command)
131
+ let fallbackRepo: string | undefined
132
+ let fallbackRepoUsed = false
133
+ if (decision.kind === 'block' && decision.code === 'missing-repo') {
134
+ fallbackRepo = await resolveTrustedFallbackRepo(event.origin)
135
+ if (fallbackRepo !== undefined) {
136
+ const withFallback = analyzeGhCommand(command, fallbackRepo)
137
+ if (withFallback.kind === 'inject') {
138
+ decision = withFallback
139
+ fallbackRepoUsed = true
140
+ }
141
+ }
142
+ }
95
143
 
96
144
  // `/user` classifies as pass-through (no repo to mint for), so this block
97
145
  // must run BEFORE the pass-through return. Resolve the EFFECTIVE token per
@@ -140,7 +188,10 @@ export default definePlugin({
140
188
  // NOT apply — a PAT needs no per-repo mint — so we never surface it here.
141
189
  if (runsUnsandboxed(event.origin)) {
142
190
  if (decision.kind === 'inject') {
143
- event.args[TYPECLAW_INTERNAL_BASH_ENV] = { GH_TOKEN: process.env.GH_TOKEN as string }
191
+ event.args[TYPECLAW_INTERNAL_BASH_ENV] = {
192
+ GH_TOKEN: process.env.GH_TOKEN as string,
193
+ ...(fallbackRepoUsed && fallbackRepo !== undefined ? { GH_REPO: fallbackRepo } : {}),
194
+ }
144
195
  }
145
196
  return
146
197
  }
@@ -148,14 +199,18 @@ export default definePlugin({
148
199
  // available (a PAT must NOT suppress it, or the original silent-failure
149
200
  // bug returns); otherwise block with guidance rather than failing mute.
150
201
  if (!shouldMintAppToken(undefined, hasAppTokenResolver())) {
151
- if (decision.kind === 'block') return { block: true, reason: decision.reason }
202
+ if (decision.kind === 'block') {
203
+ return { block: true, reason: decision.reason + buildGhBlockGuidance(decision.code, fallbackRepo) }
204
+ }
152
205
  warnSandboxedPatWithheldOnce()
153
206
  return { block: true, reason: sandboxedPatWithheldReason }
154
207
  }
155
208
  mintForSandboxedPat = true
156
209
  }
157
210
 
158
- if (decision.kind === 'block') return { block: true, reason: decision.reason }
211
+ if (decision.kind === 'block') {
212
+ return { block: true, reason: decision.reason + buildGhBlockGuidance(decision.code, fallbackRepo) }
213
+ }
159
214
 
160
215
  // No App auth (no App-class GH_TOKEN and no live minter): leave whatever
161
216
  // is seeded so `gh` fails honestly rather than us guessing a token. The
@@ -166,8 +221,14 @@ export default definePlugin({
166
221
  if (result.kind === 'unavailable') return { block: true, reason: result.reason }
167
222
  // Inject via the internal env overlay (delivered to the spawn / bwrap
168
223
  // --setenv by the bash wrapper) so the token never enters the command
169
- // string, where it could leak through logs or later hooks.
170
- event.args[TYPECLAW_INTERNAL_BASH_ENV] = { GH_TOKEN: result.token }
224
+ // string, where it could leak through logs or later hooks. When the repo
225
+ // came from a trusted fallback (not an explicit -R), also set GH_REPO so
226
+ // `gh` actually targets it — a token alone leaves the repo unresolved.
227
+ // GH_REPO is non-secret; the token still scopes reach to that repo.
228
+ event.args[TYPECLAW_INTERNAL_BASH_ENV] = {
229
+ GH_TOKEN: result.token,
230
+ ...(fallbackRepoUsed ? { GH_REPO: decision.repoSlug } : {}),
231
+ }
171
232
  return
172
233
  }
173
234
 
@@ -155,12 +155,17 @@ const VECTOR_TURN_TOP_K = 10
155
155
  // without loading the ~279 MB model, or `hybridSearch` to fake retrieval while
156
156
  // testing hook orchestration — without leaking state across other tests in the
157
157
  // same worker. Production uses the real `embed` and `hybridSearch`.
158
- type MemoryPluginDeps = {
158
+ export type MemoryPluginDeps = {
159
159
  hybridSearch: typeof hybridSearch
160
160
  queryEmbedFn: EmbedFn
161
+ openAppendVectorStore: (agentDir: string) => VectorStore
161
162
  }
162
163
 
163
- const defaultDeps: MemoryPluginDeps = { hybridSearch, queryEmbedFn: embed }
164
+ const defaultDeps: MemoryPluginDeps = {
165
+ hybridSearch,
166
+ queryEmbedFn: embed,
167
+ openAppendVectorStore: (agentDir) => VectorStore.open(join(agentDir, 'memory', '.vectors', 'index.db')),
168
+ }
164
169
 
165
170
  // Builds the per-turn user-prompt memory block for a vector agent. Non-channel
166
171
  // turns always use top-K hybrid search, regardless of total shard size. Repeated
@@ -231,7 +236,7 @@ async function renderVectorTurnMemory(
231
236
  }
232
237
  }
233
238
 
234
- function createMemoryPlugin(deps: MemoryPluginDeps = defaultDeps) {
239
+ export function createMemoryPlugin(deps: MemoryPluginDeps = defaultDeps) {
235
240
  return definePlugin({
236
241
  configSchema: memoryConfigSchema,
237
242
  plugin: async (ctx) => {
@@ -401,9 +406,7 @@ function createMemoryPlugin(deps: MemoryPluginDeps = defaultDeps) {
401
406
  }
402
407
 
403
408
  // Open a long-lived VectorStore for append-time indexing when vector is enabled.
404
- const appendVectorStore = ctx.config.vector.enabled
405
- ? VectorStore.open(join(ctx.agentDir, 'memory', '.vectors', 'index.db'))
406
- : undefined
409
+ const appendVectorStore = ctx.config.vector.enabled ? deps.openAppendVectorStore(ctx.agentDir) : undefined
407
410
 
408
411
  return {
409
412
  subagents: {
@@ -6,6 +6,7 @@ import type { SessionOrigin } from '@/agent/session-origin'
6
6
  import { buildInjectionPlan, DEFAULT_INJECTION_BUDGET_BYTES, type InjectionPlan } from './injection-plan'
7
7
  import { loadAllShards, type TopicShard } from './load-shards'
8
8
  import { topicsDir } from './paths'
9
+ import { slugIsHeadingEcho } from './slug'
9
10
  import type { DedupedRetrievedItem } from './turn-dedup'
10
11
 
11
12
  const MAX_FILE_BYTES = 12 * 1024
@@ -130,7 +131,7 @@ export function renderRetrievedMemorySection(
130
131
  lines.push(`## ${item.heading}`)
131
132
  lines.push(item.excerpt.trimEnd(), '')
132
133
  } else if (item.source === 'topic' || item.source === 'reference') {
133
- lines.push(`- ${item.heading} \`${item.key}\``)
134
+ lines.push(topicIndexEntry(item.heading, item.key))
134
135
  } else {
135
136
  lines.push(`- ${item.heading} _(recent observation)_`)
136
137
  }
@@ -150,11 +151,24 @@ export function renderTopicIndexMemorySection(
150
151
  if (options.origin?.kind === 'channel') lines.push(...CHANNEL_MEMORY_BOUNDARY, '')
151
152
  lines.push(topicIndexDirective(options), '')
152
153
  for (const shard of shards) {
153
- lines.push(`- ${shard.frontmatter.heading} \`${shard.slug}\``)
154
+ lines.push(topicIndexEntry(shard.frontmatter.heading, shard.slug))
154
155
  }
155
156
  return lines.join('\n').trimEnd()
156
157
  }
157
158
 
159
+ // A topic-index line names a topic so the model can decide whether to open it
160
+ // (the slug is the `memory_search({ topic })` key). When the slug is just a kebab
161
+ // echo of the heading the heading adds no signal, so render the slug alone; keep
162
+ // both when they diverge (e.g. `gh-api-labels-array-syntax` vs "GitHub API label
163
+ // management in the agent environment") or when the heading has no ASCII form
164
+ // (e.g. CJK), where `slugIsHeadingEcho` returns false and the readable name stays.
165
+ function topicIndexEntry(heading: string, slug: string): string {
166
+ if (slugIsHeadingEcho(heading, slug)) {
167
+ return `- \`${slug}\``
168
+ }
169
+ return `- ${heading} \`${slug}\``
170
+ }
171
+
158
172
  function topicIndexDirective(options: Pick<LoadMemoryOptions, 'origin'>): string {
159
173
  if (options.origin?.kind === 'channel') {
160
174
  return 'Memory shown as headings only in channels. Call `memory_search({ topic: "<slug>" })` with a slug below to read a full body.'
@@ -20,6 +20,25 @@ export function headingToSlug(heading: string, existingSlugs: Set<string>): stri
20
20
  return slug
21
21
  }
22
22
 
23
+ // True only when `slug` is a clean kebab echo of `heading` (the readable form
24
+ // adds nothing the slug doesn't). `headingToSlug` maps every non-ASCII letter,
25
+ // ideograph, or symbol to `-` (or to an `untitled-<hash>` when nothing survives),
26
+ // so a heading like `한글 memo` slugifies to `memo` and an all-CJK/emoji heading to
27
+ // the fallback — collapsing either would drop the only human-readable name. Guard
28
+ // by requiring the diacritic-folded heading to consist solely of ASCII
29
+ // alphanumerics and separators/punctuation; any surviving CJK/emoji/symbol means
30
+ // normalization discarded content, so it is never an echo. (Diacritics are
31
+ // transliterated, not dropped — `café` → `cafe` stays a legitimate echo.)
32
+ const ECHO_SAFE_HEADING = /^[A-Za-z0-9\s\p{P}]*$/u
33
+
34
+ export function slugIsHeadingEcho(heading: string, slug: string): boolean {
35
+ const folded = heading.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
36
+ if (!ECHO_SAFE_HEADING.test(folded)) {
37
+ return false
38
+ }
39
+ return headingToSlug(heading, new Set<string>()) === slug
40
+ }
41
+
23
42
  function normalizeHeading(heading: string): string {
24
43
  let normalized = heading.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
25
44
 
@@ -178,7 +178,10 @@ function matchHidden(
178
178
  }
179
179
  for (const dir of deniedDirs) {
180
180
  const realDir = realpathRealIntendedPath(dir)
181
- if (resolved === realDir || resolved.startsWith(`${realDir}/`)) return dir
181
+ // realpathRealIntendedPath joins with the platform separator, so the
182
+ // under-dir test must use path.sep too — a hardcoded "/" never matches the
183
+ // "\"-joined paths a win32 test runner produces.
184
+ if (resolved === realDir || resolved.startsWith(`${realDir}${path.sep}`)) return dir
182
185
  }
183
186
  return undefined
184
187
  }
@@ -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)