typeclaw 0.17.0 → 0.18.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.
package/auth.schema.json CHANGED
@@ -213,11 +213,6 @@
213
213
  }
214
214
  }
215
215
  ]
216
- },
217
- "installationId": {
218
- "type": "integer",
219
- "exclusiveMinimum": 0,
220
- "maximum": 9007199254740991
221
216
  }
222
217
  },
223
218
  "required": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -46,7 +46,7 @@
46
46
  "@mariozechner/pi-coding-agent": "^0.67.3",
47
47
  "@mariozechner/pi-tui": "^0.67.3",
48
48
  "@mozilla/readability": "^0.6.0",
49
- "agent-messenger": "2.19.0",
49
+ "agent-messenger": "2.19.1",
50
50
  "cheerio": "^1.2.0",
51
51
  "citty": "^0.2.2",
52
52
  "cron-parser": "^5.5.0",
@@ -213,11 +213,6 @@
213
213
  }
214
214
  }
215
215
  ]
216
- },
217
- "installationId": {
218
- "type": "integer",
219
- "exclusiveMinimum": 0,
220
- "maximum": 9007199254740991
221
216
  }
222
217
  },
223
218
  "required": [
@@ -12,6 +12,7 @@ export type InboundDropReason =
12
12
  | 'self_author' // event.author.id === botUserId; we never route our own messages back to ourselves
13
13
  | 'empty_content' // SDK delivered content: '' — usually missing MessageContent intent
14
14
  | 'pre_connect' // bot identity is not known yet, so mention/self/reply classification cannot be trusted
15
+ | 'thread_created_system' // Discord's THREAD_CREATED system notice posted to the PARENT channel when a public thread is created from a message
15
16
 
16
17
  export type InboundClassification =
17
18
  | { kind: 'drop'; reason: InboundDropReason }
@@ -38,6 +39,10 @@ export function classifyInbound(
38
39
  const { text, attachments } = splitInbound(event)
39
40
  if (text === '') return { kind: 'drop', reason: 'empty_content' }
40
41
 
42
+ if (isThreadCreatedSystemMessage(event)) {
43
+ return { kind: 'drop', reason: 'thread_created_system' }
44
+ }
45
+
41
46
  const isDm = event.guild_id === undefined
42
47
  const workspace = isDm ? '@dm' : event.guild_id!
43
48
 
@@ -108,6 +113,24 @@ function isReplyToBot(event: DiscordGatewayMessageCreateEvent, botUserId: string
108
113
  return (event.mentions ?? []).some((m) => m.id === botUserId)
109
114
  }
110
115
 
116
+ // Creating a public thread from a message fires TWO MESSAGE_CREATE events: a
117
+ // THREAD_CREATED notice in the parent channel (content = thread name) and a
118
+ // THREAD_STARTER_MESSAGE inside the thread. Each opens its own router session,
119
+ // so the agent replies twice. The numeric Discord message type (18) that would
120
+ // filter this cleanly is destroyed by the agent-messenger listener (it does
121
+ // `{ ...d, type: t }`, overwriting `d.type` with the dispatch name), so we
122
+ // fingerprint the notice by its reference instead: it points at a DIFFERENT
123
+ // channel (the new thread) with no source `message_id`. The message_id-absent
124
+ // check is load-bearing — it spares normal cross-channel replies and the
125
+ // in-thread starter, both of which DO carry a message_id.
126
+ function isThreadCreatedSystemMessage(event: DiscordGatewayMessageCreateEvent): boolean {
127
+ const ref = event.message_reference
128
+ if (ref?.channel_id === undefined) return false
129
+ if (ref.channel_id === event.channel_id) return false
130
+ if (ref.message_id !== undefined) return false
131
+ return ref.guild_id === undefined || event.guild_id === undefined || ref.guild_id === event.guild_id
132
+ }
133
+
111
134
  type SplitInbound = { text: string; attachments: InboundAttachment[] }
112
135
 
113
136
  function splitInbound(event: DiscordGatewayMessageCreateEvent): SplitInbound {
@@ -763,6 +763,7 @@ function dropHint(reason: InboundDropReason): string {
763
763
  return ' (enable MESSAGE CONTENT INTENT in Discord Developer Portal and restart)'
764
764
  case 'pre_connect':
765
765
  case 'self_author':
766
+ case 'thread_created_system':
766
767
  return ''
767
768
  }
768
769
  }
@@ -2,33 +2,43 @@ import { createPrivateKey } from 'node:crypto'
2
2
 
3
3
  import { resolveSecret, type Secret } from '@/secrets/resolve'
4
4
 
5
- import type { GithubAuthStrategy, GithubInstallationGrants, GithubSelfUser } from './auth'
5
+ import type { GithubAuthContext, GithubAuthStrategy, GithubInstallationGrants, GithubSelfUser } from './auth'
6
6
  import { GITHUB_API_BASE, githubJsonHeaders, githubPublicHeaders } from './auth-pat'
7
7
 
8
+ type TokenCacheEntry = { value: string; expiresAt: number }
9
+
8
10
  export class AppAuthStrategy implements GithubAuthStrategy {
9
11
  private readonly appId: number
10
12
  private readonly privateKeyPem: string
11
- private readonly installationId: number | null
12
13
  private readonly fetchImpl: typeof fetch
13
- private cachedToken: { value: string; expiresAt: number } | null = null
14
- private resolvedInstallationId: number | null = null
14
+ // Keyed by installation id: a single App may span multiple owners, each a
15
+ // separate installation with its own short-lived token.
16
+ private readonly tokenCache = new Map<number, TokenCacheEntry>()
17
+ private readonly repoInstallationCache = new Map<string, number>()
18
+ private soleInstallationId: number | null = null
15
19
  private _selfUser: GithubSelfUser | null = null
16
20
 
17
- constructor(options: { appId: number; privateKey: Secret; installationId?: number; fetchImpl?: typeof fetch }) {
21
+ constructor(options: { appId: number; privateKey: Secret; fetchImpl?: typeof fetch }) {
18
22
  const privateKeyPem = resolveSecret(options.privateKey, undefined, process.env)
19
23
  if (privateKeyPem === undefined || privateKeyPem.trim() === '') throw new Error('GitHub App private key is missing')
20
24
  this.appId = options.appId
21
25
  this.privateKeyPem = privateKeyPem
22
- this.installationId = options.installationId ?? null
23
26
  this.fetchImpl = options.fetchImpl ?? fetch
24
27
  }
25
28
 
26
- async token(): Promise<string> {
27
- if (this.cachedToken && Date.now() < this.cachedToken.expiresAt - 5 * 60 * 1000) {
28
- return this.cachedToken.value
29
- }
29
+ async token(context?: GithubAuthContext): Promise<string> {
30
30
  const jwt = await this.mintJwt()
31
- const installId = await this.resolveInstallationId(jwt)
31
+ const installId = await this.resolveInstallationId(jwt, context)
32
+ return this.installationToken(jwt, installId)
33
+ }
34
+
35
+ async authHeaders(context?: GithubAuthContext): Promise<HeadersInit> {
36
+ return githubJsonHeaders(await this.token(context))
37
+ }
38
+
39
+ private async installationToken(jwt: string, installId: number): Promise<string> {
40
+ const cached = this.tokenCache.get(installId)
41
+ if (cached && Date.now() < cached.expiresAt - 5 * 60 * 1000) return cached.value
32
42
  const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations/${installId}/access_tokens`, {
33
43
  method: 'POST',
34
44
  headers: githubJsonHeaders(jwt),
@@ -37,14 +47,10 @@ export class AppAuthStrategy implements GithubAuthStrategy {
37
47
  const raw = (await response.json()) as { token?: unknown; expires_at?: unknown }
38
48
  if (typeof raw.token !== 'string') throw new Error('GitHub App token response missing token')
39
49
  const expiresAt = typeof raw.expires_at === 'string' ? Date.parse(raw.expires_at) : Date.now() + 60 * 60 * 1000
40
- this.cachedToken = { value: raw.token, expiresAt }
50
+ this.tokenCache.set(installId, { value: raw.token, expiresAt })
41
51
  return raw.token
42
52
  }
43
53
 
44
- async authHeaders(): Promise<HeadersInit> {
45
- return githubJsonHeaders(await this.token())
46
- }
47
-
48
54
  async getSelf(): Promise<GithubSelfUser> {
49
55
  if (this._selfUser) return this._selfUser
50
56
  const jwt = await this.mintJwt()
@@ -69,9 +75,9 @@ export class AppAuthStrategy implements GithubAuthStrategy {
69
75
  return this._selfUser
70
76
  }
71
77
 
72
- async getInstallationGrants(): Promise<GithubInstallationGrants> {
78
+ async getInstallationGrants(context?: GithubAuthContext): Promise<GithubInstallationGrants> {
73
79
  const jwt = await this.mintJwt()
74
- const installId = await this.resolveInstallationId(jwt)
80
+ const installId = await this.resolveInstallationId(jwt, context)
75
81
  const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations/${installId}`, {
76
82
  headers: githubJsonHeaders(jwt),
77
83
  })
@@ -88,7 +94,8 @@ export class AppAuthStrategy implements GithubAuthStrategy {
88
94
  }
89
95
 
90
96
  async dispose(): Promise<void> {
91
- this.cachedToken = null
97
+ this.tokenCache.clear()
98
+ this.repoInstallationCache.clear()
92
99
  }
93
100
 
94
101
  private async mintJwt(): Promise<string> {
@@ -107,25 +114,41 @@ export class AppAuthStrategy implements GithubAuthStrategy {
107
114
  return `${signingInput}.${base64url(Buffer.from(signature))}`
108
115
  }
109
116
 
110
- private async resolveInstallationId(jwt: string): Promise<number> {
111
- if (this.resolvedInstallationId !== null) return this.resolvedInstallationId
112
- if (this.installationId !== null) {
113
- this.resolvedInstallationId = this.installationId
114
- return this.installationId
117
+ private async resolveInstallationId(jwt: string, context?: GithubAuthContext): Promise<number> {
118
+ if (context?.repoSlug !== undefined && context.repoSlug !== '') {
119
+ return this.resolveInstallationByEndpoint(jwt, `repos/${context.repoSlug}/installation`, context.repoSlug)
120
+ }
121
+ if (context?.owner !== undefined && context.owner !== '') {
122
+ return this.resolveInstallationByEndpoint(jwt, `orgs/${context.owner}/installation`, context.owner)
115
123
  }
124
+ if (this.soleInstallationId !== null) return this.soleInstallationId
116
125
  const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations`, { headers: githubJsonHeaders(jwt) })
117
126
  if (!response.ok) throw new Error(`GitHub App installations fetch failed: ${response.status}`)
118
127
  const list = (await response.json()) as Array<{ id?: unknown }>
119
128
  if (list.length === 0) throw new Error('GitHub App has no installations')
120
129
  if (list.length > 1) {
121
130
  const ids = list.map((installation) => installation.id).join(', ')
122
- throw new Error(`GitHub App has multiple installations (${ids}); set installationId in secrets.json`)
131
+ throw new Error(`GitHub App has multiple installations (${ids}); a repo must be specified to select one`)
123
132
  }
124
133
  const id = list[0]?.id
125
134
  if (typeof id !== 'number') throw new Error('GitHub App installation missing id')
126
- this.resolvedInstallationId = id
135
+ this.soleInstallationId = id
127
136
  return id
128
137
  }
138
+
139
+ private async resolveInstallationByEndpoint(jwt: string, path: string, target: string): Promise<number> {
140
+ const cached = this.repoInstallationCache.get(target)
141
+ if (cached !== undefined) return cached
142
+ const response = await this.fetchImpl(`${GITHUB_API_BASE}/${path}`, { headers: githubJsonHeaders(jwt) })
143
+ if (response.status === 404) {
144
+ throw new Error(`GitHub App is not installed for ${target} or lacks access to that repository`)
145
+ }
146
+ if (!response.ok) throw new Error(`GitHub App installation lookup for ${target} failed: ${response.status}`)
147
+ const raw = (await response.json()) as { id?: unknown }
148
+ if (typeof raw.id !== 'number') throw new Error(`GitHub App installation for ${target} missing id`)
149
+ this.repoInstallationCache.set(target, raw.id)
150
+ return raw.id
151
+ }
129
152
  }
130
153
 
131
154
  function base64url(input: string | Buffer): string {
@@ -1,6 +1,6 @@
1
1
  import { resolveSecret, type Secret } from '@/secrets/resolve'
2
2
 
3
- import type { GithubAuthStrategy, GithubSelfUser } from './auth'
3
+ import type { GithubAuthContext, GithubAuthStrategy, GithubSelfUser } from './auth'
4
4
 
5
5
  export const GITHUB_API_BASE = 'https://api.github.com'
6
6
 
@@ -15,11 +15,11 @@ export class PatAuthStrategy implements GithubAuthStrategy {
15
15
  this.fetchImpl = options.fetchImpl ?? fetch
16
16
  }
17
17
 
18
- async token(): Promise<string> {
18
+ async token(_context?: GithubAuthContext): Promise<string> {
19
19
  return this._token
20
20
  }
21
21
 
22
- async authHeaders(): Promise<HeadersInit> {
22
+ async authHeaders(_context?: GithubAuthContext): Promise<HeadersInit> {
23
23
  return githubJsonHeaders(this._token)
24
24
  }
25
25
 
@@ -3,15 +3,30 @@ import type { GithubAppAuthBlock, GithubPatAuthBlock } from '@/secrets/schema'
3
3
  import { AppAuthStrategy } from './auth-app'
4
4
  import { PatAuthStrategy } from './auth-pat'
5
5
 
6
+ // Repo identity threaded through every auth call so App auth can pick the
7
+ // correct installation. `repoSlug` is the canonical input ("owner/name"); App
8
+ // auth resolves it to an installation via GET /repos/{owner}/{repo}/installation
9
+ // and caches the result. PAT auth ignores it entirely. Omitted context means
10
+ // "no specific repo" — App auth then falls back to a single discoverable
11
+ // installation (and errors if the App spans multiple installations).
12
+ export type GithubAuthContext = {
13
+ repoSlug?: string
14
+ // Org login, for operations that aren't repo-scoped (e.g. team-membership
15
+ // lookups under GET /orgs/{org}/...). App auth resolves it to an
16
+ // installation via GET /orgs/{org}/installation. Ignored when repoSlug is set.
17
+ owner?: string
18
+ }
19
+
6
20
  export type GithubAuthStrategy = {
7
- token: () => Promise<string>
8
- authHeaders: () => Promise<HeadersInit>
21
+ token: (context?: GithubAuthContext) => Promise<string>
22
+ authHeaders: (context?: GithubAuthContext) => Promise<HeadersInit>
9
23
  getSelf: () => Promise<GithubSelfUser>
10
24
  // App-only: returns the installation's granted-permissions map and declared
11
25
  // events so the adapter can preflight against the configured eventAllowlist
12
26
  // before any webhook arrives. PATs return access via token scopes, not an
13
- // installation grant, so they leave this undefined.
14
- getInstallationGrants?: () => Promise<GithubInstallationGrants>
27
+ // installation grant, so they leave this undefined. Context selects which
28
+ // installation to inspect when the App spans multiple owners.
29
+ getInstallationGrants?: (context?: GithubAuthContext) => Promise<GithubInstallationGrants>
15
30
  dispose: () => Promise<void>
16
31
  }
17
32
 
@@ -36,7 +51,6 @@ export function buildAuthStrategy(options: {
36
51
  return new AppAuthStrategy({
37
52
  appId: options.auth.appId,
38
53
  privateKey: options.auth.privateKey,
39
- installationId: options.auth.installationId,
40
54
  fetchImpl: options.fetchImpl,
41
55
  })
42
56
  }
@@ -1,10 +1,11 @@
1
1
  import type { ChannelNameResolver, ResolvedChannelNames } from '@/channels/types'
2
2
 
3
+ import type { GithubAuthContext } from './auth'
3
4
  import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
4
5
  import { parseChat, parseRepo } from './outbound'
5
6
 
6
7
  export function createGithubChannelNameResolver(options: {
7
- token: () => Promise<string>
8
+ token: (context?: GithubAuthContext) => Promise<string>
8
9
  fetchImpl?: typeof fetch
9
10
  }): ChannelNameResolver {
10
11
  const fetchImpl = options.fetchImpl ?? fetch
@@ -18,7 +19,7 @@ export function createGithubChannelNameResolver(options: {
18
19
  const path = chat.kind === 'issue' ? `issues/${chat.number}` : `pulls/${chat.number}`
19
20
  try {
20
21
  const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/${path}`, {
21
- headers: githubJsonHeaders(await options.token()),
22
+ headers: githubJsonHeaders(await options.token({ repoSlug: key.workspace })),
22
23
  })
23
24
  if (!response.ok) return names
24
25
  const raw = (await response.json()) as { title?: string }
@@ -1,10 +1,11 @@
1
1
  import type { ChannelHistoryMessage, FetchHistoryArgs, FetchHistoryResult, HistoryCallback } from '@/channels/types'
2
2
 
3
+ import type { GithubAuthContext } from './auth'
3
4
  import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
4
5
  import { parseChat, parseRepo } from './outbound'
5
6
 
6
7
  export function createGithubHistoryCallback(options: {
7
- token: () => Promise<string>
8
+ token: (context?: GithubAuthContext) => Promise<string>
8
9
  workspaceForChat: (chat: string) => string | null
9
10
  fetchImpl?: typeof fetch
10
11
  }): HistoryCallback {
@@ -26,7 +27,7 @@ export function createGithubHistoryCallback(options: {
26
27
  const response = await fetchImpl(
27
28
  `${endpoint}?per_page=${Math.min(Math.max(args.limit, 1), 100)}&direction=desc${cursor}`,
28
29
  {
29
- headers: githubJsonHeaders(await options.token()),
30
+ headers: githubJsonHeaders(await options.token({ repoSlug: workspace })),
30
31
  },
31
32
  )
32
33
  if (!response.ok) return { ok: false, error: `GitHub history ${response.status}` }
@@ -3,7 +3,7 @@ import type { ChannelAdapterConfig, GithubAdapterConfig } from '@/channels/schem
3
3
  import { resolveSecret } from '@/secrets/resolve'
4
4
  import type { GithubSecretsBlock } from '@/secrets/schema'
5
5
 
6
- import { buildAuthStrategy } from './auth'
6
+ import { buildAuthStrategy, type GithubAuthContext } from './auth'
7
7
  import { createGithubChannelNameResolver } from './channel-resolver'
8
8
  import { createDeliveryDedup } from './dedup'
9
9
  import { findPermissionGaps } from './event-permissions'
@@ -99,29 +99,30 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
99
99
  workspaceByChat.set(chat, workspace)
100
100
  }
101
101
 
102
- const tokenFn = async () => {
103
- const t = await auth.token()
104
- process.env.GH_TOKEN = t
105
- return t
106
- }
102
+ // Repo/owner-aware token resolver. A single GitHub App can span multiple
103
+ // installations (one per owner); each consumer passes its repo/owner so the
104
+ // right installation token is minted. Unlike the old single-token path, this
105
+ // does NOT mutate process.env.GH_TOKEN — that global is seeded separately and
106
+ // only when exactly one installation applies (see seedGhTokenIfSingle).
107
+ const authToken = (context?: GithubAuthContext) => auth.token(context)
107
108
  const outbound = createGithubOutboundCallback({
108
- token: tokenFn,
109
+ token: authToken,
109
110
  authType: options.secrets.auth.type,
110
111
  logger,
111
112
  fetchImpl,
112
113
  })
113
114
  const history = createGithubHistoryCallback({
114
- token: tokenFn,
115
+ token: authToken,
115
116
  fetchImpl,
116
117
  workspaceForChat: (chat) => workspaceByChat.get(chat) ?? null,
117
118
  })
118
- const membership = createGithubMembershipResolver({ token: tokenFn, fetchImpl })
119
- const channelNameResolver = createGithubChannelNameResolver({ token: tokenFn, fetchImpl })
119
+ const membership = createGithubMembershipResolver({ token: authToken, fetchImpl })
120
+ const channelNameResolver = createGithubChannelNameResolver({ token: authToken, fetchImpl })
120
121
  const fetchAttachment = createGithubFetchAttachmentCallback()
121
122
  // No-op typing callback: GitHub has no typing indicator API.
122
123
  const typing = async (): Promise<void> => {}
123
124
  const dedup = createDeliveryDedup()
124
- const isBotInTeam = createTeamMembershipChecker({ token: tokenFn, fetchImpl })
125
+ const isBotInTeam = createTeamMembershipChecker({ token: authToken, fetchImpl })
125
126
  const handler = createGithubWebhookHandler({
126
127
  webhookSecret,
127
128
  dedup,
@@ -174,35 +175,48 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
174
175
  selfLogin = null
175
176
  throw err
176
177
  }
177
- // Seed GH_TOKEN so `gh` CLI calls in the container are pre-authenticated.
178
- // tokenFn keeps it current on every adapter API call; App tokens refresh
179
- // automatically when within 5 minutes of expiry.
180
- process.env.GH_TOKEN = await auth.token()
181
178
  started = true
182
- // Keep GH_TOKEN warm even when the adapter is only receiving inbound
183
- // webhooks and not making outbound API calls. This prevents `gh` CLI
184
- // calls from the agent from failing with 401 after the token expires.
185
- const tokenRefreshIntervalMs = options.tokenRefreshIntervalMs ?? DEFAULT_TOKEN_REFRESH_INTERVAL_MS
186
- if (tokenRefreshIntervalMs > 0) {
187
- const refresh = () => {
188
- tokenFn().catch((err) => {
189
- logger.error(`[github] periodic token refresh failed: ${err instanceof Error ? err.message : String(err)}`)
190
- })
179
+ // GH_TOKEN is a single process-wide env var the container's `gh` CLI
180
+ // reads, but a GitHub App spanning multiple owners has no single correct
181
+ // token. Seed/refresh it only when exactly one repo is configured (PAT,
182
+ // or App with one unambiguous installation). With multiple repos we skip
183
+ // the global seed: ad-hoc `gh` calls must target a specific repo, and the
184
+ // adapter's own API calls always resolve a repo-scoped token via authToken.
185
+ const ghTokenRepo = ghTokenSeedRepo(options.configRef().repos ?? [])
186
+ const seedGhToken = async (): Promise<void> => {
187
+ process.env.GH_TOKEN = await auth.token(ghTokenRepo === null ? undefined : { repoSlug: ghTokenRepo })
188
+ }
189
+ if (ghTokenRepo !== null || options.secrets.auth.type === 'pat') {
190
+ await seedGhToken()
191
+ const tokenRefreshIntervalMs = options.tokenRefreshIntervalMs ?? DEFAULT_TOKEN_REFRESH_INTERVAL_MS
192
+ if (tokenRefreshIntervalMs > 0) {
193
+ const refresh = () => {
194
+ seedGhToken().catch((err) => {
195
+ logger.error(
196
+ `[github] periodic token refresh failed: ${err instanceof Error ? err.message : String(err)}`,
197
+ )
198
+ })
199
+ }
200
+ const setIntervalFn =
201
+ options.setInterval ??
202
+ ((handler: () => void, ms: number) => {
203
+ const timer = setInterval(handler, ms)
204
+ return { clear: () => clearInterval(timer) }
205
+ })
206
+ tokenRefreshTimer = setIntervalFn(refresh, tokenRefreshIntervalMs)
191
207
  }
192
- const setIntervalFn =
193
- options.setInterval ??
194
- ((handler: () => void, ms: number) => {
195
- const timer = setInterval(handler, ms)
196
- return { clear: () => clearInterval(timer) }
197
- })
198
- tokenRefreshTimer = setIntervalFn(refresh, tokenRefreshIntervalMs)
208
+ } else {
209
+ logger.info(
210
+ '[github] multiple repos configured across possibly-different owners; GH_TOKEN not seeded globally. ' +
211
+ 'Ad-hoc `gh` commands should set a repo-scoped token explicitly.',
212
+ )
199
213
  }
200
214
  logger.info(`[github] webhook listening on port ${options.configRef().webhookPort} as @${self.login}`)
201
215
  // Best-effort: App-only preflight that compares the installation's granted
202
216
  // permissions against the configured eventAllowlist and warns about gaps.
203
217
  // Catches the most common misconfiguration (App installed with the default
204
218
  // metadata-only permission set) before any event fires a 403.
205
- await runAppPermissionPreflight(logger, auth, options.configRef().eventAllowlist)
219
+ await runAppPermissionPreflight(logger, auth, options.configRef().eventAllowlist, options.configRef().repos ?? [])
206
220
  // Repository webhook registration is best-effort: failures are logged
207
221
  // per-repo, the adapter stays up. A misconfigured PAT or App that
208
222
  // can't manage hooks must not prevent the adapter from accepting
@@ -225,6 +239,9 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
225
239
  })
226
240
  } else if (repos.length > 0) {
227
241
  const legacyProviderHostSuffix = detectLegacyProviderHostSuffix(effectiveUrl)
242
+ logger.info(
243
+ `[github] registering webhook for ${repos.length} repo(s) [${repos.join(', ')}] -> ${effectiveUrl} (events: ${cfg.eventAllowlist.join(', ')})`,
244
+ )
228
245
  if (webhookRegistrationDelayMs > 0) {
229
246
  logger.info(
230
247
  `[github] waiting ${webhookRegistrationDelayMs}ms before registering webhook so the Cloudflare edge can warm up`,
@@ -232,7 +249,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
232
249
  await sleep(webhookRegistrationDelayMs)
233
250
  }
234
251
  const registration = await registerGithubWebhooks({
235
- token: tokenFn,
252
+ token: (repoSlug: string) => auth.token({ repoSlug }),
236
253
  webhookUrl: effectiveUrl,
237
254
  webhookSecret,
238
255
  repos,
@@ -263,7 +280,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
263
280
  // last to clear the cached App-installation token.
264
281
  if (managedHooks.length > 0) {
265
282
  const deregistration = await deregisterGithubWebhooks({
266
- token: tokenFn,
283
+ token: (repoSlug: string) => auth.token({ repoSlug }),
267
284
  hooks: managedHooks,
268
285
  fetchImpl,
269
286
  })
@@ -367,18 +384,36 @@ async function runAppPermissionPreflight(
367
384
  logger: GithubAdapterLogger,
368
385
  auth: ReturnType<typeof buildAuthStrategy>,
369
386
  eventAllowlist: readonly string[],
387
+ repos: readonly string[],
370
388
  ): Promise<void> {
371
389
  if (auth.getInstallationGrants === undefined) return
372
- let grants
373
- try {
374
- grants = await auth.getInstallationGrants()
375
- } catch (err) {
376
- logger.warn(`[github] permission preflight skipped: ${err instanceof Error ? err.message : String(err)}`)
377
- return
390
+ const getGrants = (context: GithubAuthContext | undefined) => auth.getInstallationGrants?.(context)
391
+ // One grants check per distinct owner: installations are owner-scoped, so
392
+ // repos sharing an owner share an installation. The first repo per owner is
393
+ // the resolution key. With no repos, fall back to a single context-free check.
394
+ const reposByOwner = new Map<string, string>()
395
+ for (const repo of repos) {
396
+ const owner = repo.split('/')[0]
397
+ if (owner !== undefined && owner !== '' && !reposByOwner.has(owner)) reposByOwner.set(owner, repo)
398
+ }
399
+ const contexts: Array<{ label: string; context: { repoSlug: string } | undefined }> =
400
+ reposByOwner.size === 0
401
+ ? [{ label: 'app', context: undefined }]
402
+ : [...reposByOwner.values()].map((repo) => ({ label: repo, context: { repoSlug: repo } }))
403
+ for (const { label, context } of contexts) {
404
+ let grants
405
+ try {
406
+ grants = await getGrants(context)
407
+ } catch (err) {
408
+ logger.warn(
409
+ `[github] permission preflight skipped for ${label}: ${err instanceof Error ? err.message : String(err)}`,
410
+ )
411
+ continue
412
+ }
413
+ if (grants === undefined) continue
414
+ const gaps = findPermissionGaps(eventAllowlist, grants.permissions)
415
+ if (gaps.length > 0) logger.warn(buildAppPermissionPreflightGuidance(gaps))
378
416
  }
379
- const gaps = findPermissionGaps(eventAllowlist, grants.permissions)
380
- if (gaps.length === 0) return
381
- logger.warn(buildAppPermissionPreflightGuidance(gaps))
382
417
  }
383
418
 
384
419
  function logDeregistrationOutcome(
@@ -392,6 +427,13 @@ function logDeregistrationOutcome(
392
427
  }
393
428
  }
394
429
 
430
+ // Two repos under the same owner share an installation and could in principle
431
+ // share a global GH_TOKEN, but the marginal value doesn't justify the special
432
+ // case — only a single configured repo yields an unambiguous seed.
433
+ function ghTokenSeedRepo(repos: readonly string[]): string | null {
434
+ return repos.length === 1 ? (repos[0] ?? null) : null
435
+ }
436
+
395
437
  function defaultSleep(ms: number): Promise<void> {
396
438
  return new Promise((resolve) => setTimeout(resolve, ms))
397
439
  }
@@ -1,10 +1,11 @@
1
1
  import type { MembershipResolver, MembershipResolverResult } from '@/channels/membership'
2
2
 
3
+ import type { GithubAuthContext } from './auth'
3
4
  import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
4
5
  import { parseRepo } from './outbound'
5
6
 
6
7
  export function createGithubMembershipResolver(options: {
7
- token: () => Promise<string>
8
+ token: (context?: GithubAuthContext) => Promise<string>
8
9
  fetchImpl?: typeof fetch
9
10
  }): MembershipResolver {
10
11
  const fetchImpl = options.fetchImpl ?? fetch
@@ -16,7 +17,7 @@ export function createGithubMembershipResolver(options: {
16
17
  const response = await fetchImpl(
17
18
  `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/collaborators?per_page=100`,
18
19
  {
19
- headers: githubJsonHeaders(await options.token()),
20
+ headers: githubJsonHeaders(await options.token({ repoSlug: key.workspace })),
20
21
  },
21
22
  )
22
23
  if (!response.ok) return response.status >= 500 ? { kind: 'transient' } : { kind: 'permanent' }
@@ -1,5 +1,6 @@
1
1
  import type { OutboundCallback, OutboundMessage, SendResult } from '@/channels/types'
2
2
 
3
+ import type { GithubAuthContext } from './auth'
3
4
  import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
4
5
  import {
5
6
  buildOutboundPermissionGuidance,
@@ -11,7 +12,7 @@ import {
11
12
  export type GithubOutboundLogger = { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
12
13
 
13
14
  export function createGithubOutboundCallback(deps: {
14
- token: () => Promise<string>
15
+ token: (context?: GithubAuthContext) => Promise<string>
15
16
  authType: GithubAuthType
16
17
  logger: GithubOutboundLogger
17
18
  fetchImpl?: typeof fetch
@@ -28,9 +29,12 @@ export function createGithubOutboundCallback(deps: {
28
29
  const target = parseChat(msg.chat)
29
30
  if (target === null) return { ok: false, error: `invalid GitHub chat: ${msg.chat}` }
30
31
 
32
+ const token = () => deps.token({ repoSlug: msg.workspace })
33
+
31
34
  if (target.kind === 'discussion') {
32
35
  return await postDiscussionComment({
33
36
  ...deps,
37
+ token,
34
38
  fetchImpl,
35
39
  repo,
36
40
  discussionNumber: target.number,
@@ -44,7 +48,7 @@ export function createGithubOutboundCallback(deps: {
44
48
  : `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/issues/${target.number}/comments`
45
49
  return await postJson(
46
50
  fetchImpl,
47
- await deps.token(),
51
+ await token(),
48
52
  endpoint,
49
53
  { body },
50
54
  {
@@ -7,12 +7,14 @@
7
7
  // request rebuilds it). Errors fall closed (return false): we'd rather drop
8
8
  // a real review request than wake the agent on a team the bot isn't in.
9
9
 
10
+ import type { GithubAuthContext } from './auth'
11
+
10
12
  const ACTIVE_MEMBERSHIP_STATE = 'active'
11
13
 
12
14
  export type TeamMembershipChecker = (input: { org: string; slug: string; login: string }) => Promise<boolean>
13
15
 
14
16
  export function createTeamMembershipChecker(options: {
15
- token: () => Promise<string>
17
+ token: (context?: GithubAuthContext) => Promise<string>
16
18
  fetchImpl?: typeof fetch
17
19
  }): TeamMembershipChecker {
18
20
  const fetchImpl = options.fetchImpl ?? fetch
@@ -23,7 +25,7 @@ export function createTeamMembershipChecker(options: {
23
25
  const cached = cache.get(key)
24
26
  if (cached !== undefined) return cached
25
27
 
26
- const result = await lookup(fetchImpl, await options.token(), org, slug, login)
28
+ const result = await lookup(fetchImpl, await options.token({ owner: org }), org, slug, login)
27
29
  cache.set(key, result)
28
30
  return result
29
31
  }
@@ -1,7 +1,10 @@
1
1
  import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
2
2
 
3
3
  export type RegisterGithubWebhooksOptions = {
4
- token: () => Promise<string>
4
+ // Resolves an installation token scoped to the given "owner/name" repo. A
5
+ // single GitHub App may span multiple owners (separate installations), so
6
+ // each repo's hook must be created/listed with that repo's own token.
7
+ token: (repoSlug: string) => Promise<string>
5
8
  webhookUrl: string
6
9
  webhookSecret: string
7
10
  repos: readonly string[]
@@ -51,22 +54,22 @@ export async function registerGithubWebhooks(
51
54
  options: RegisterGithubWebhooksOptions,
52
55
  ): Promise<WebhookRegistrationResult> {
53
56
  const fetchImpl = options.fetchImpl ?? fetch
54
- let token: string
55
- try {
56
- token = await options.token()
57
- } catch (err) {
58
- const error = describe(err)
59
- return { repos: options.repos.map((repo) => ({ repo, action: 'failed' as const, error })) }
60
- }
61
57
  const repos: WebhookRepoResult[] = []
62
58
  for (const repo of options.repos) {
59
+ let token: string
60
+ try {
61
+ token = await options.token(repo)
62
+ } catch (err) {
63
+ repos.push({ repo, action: 'failed', error: describe(err) })
64
+ continue
65
+ }
63
66
  repos.push(await registerOne(fetchImpl, token, repo, options))
64
67
  }
65
68
  return { repos }
66
69
  }
67
70
 
68
71
  export type DeregisterGithubWebhooksOptions = {
69
- token: () => Promise<string>
72
+ token: (repoSlug: string) => Promise<string>
70
73
  hooks: ReadonlyArray<{ repo: string; hookId: number }>
71
74
  fetchImpl?: typeof fetch
72
75
  }
@@ -79,15 +82,15 @@ export async function deregisterGithubWebhooks(
79
82
  options: DeregisterGithubWebhooksOptions,
80
83
  ): Promise<WebhookDeregistrationResult> {
81
84
  const fetchImpl = options.fetchImpl ?? fetch
82
- let token: string
83
- try {
84
- token = await options.token()
85
- } catch (err) {
86
- const error = describe(err)
87
- return { hooks: options.hooks.map((h) => ({ ...h, action: 'failed', error })) }
88
- }
89
85
  const hooks: WebhookDeregistrationResult['hooks'] = []
90
86
  for (const hook of options.hooks) {
87
+ let token: string
88
+ try {
89
+ token = await options.token(hook.repo)
90
+ } catch (err) {
91
+ hooks.push({ ...hook, action: 'failed', error: describe(err) })
92
+ continue
93
+ }
91
94
  hooks.push(await deleteOne(fetchImpl, token, hook))
92
95
  }
93
96
  return { hooks }
@@ -1,5 +1,6 @@
1
1
  import type { SlackSocketModeSlashCommandArgs } from 'agent-messenger/slackbot'
2
2
 
3
+ import type { ExecuteCommandResult } from '@/channels/router'
3
4
  import type { ChannelKey } from '@/channels/types'
4
5
 
5
6
  // Slack channel ids: 'C' = public, 'G' = private/legacy multi-party DM,
@@ -64,13 +65,87 @@ export function parseSlashCommand(
64
65
  }
65
66
  }
66
67
 
68
+ // Slack blocks native slash commands inside threads ("/stop is not supported
69
+ // in threads. Sorry!"), so the only way to abort a thread-scoped turn from
70
+ // inside that thread is a normal message. We recognise a leading `!` as an
71
+ // alternate command prefix and route it through the same router.executeCommand
72
+ // path as native slashes. The guard is strict: only a first token that resolves
73
+ // to a known command name is rewritten, so casual messages like "!nice work"
74
+ // pass through untouched as regular agent input.
75
+ //
76
+ // Unlike native slash payloads (which never carry a thread and rely on the
77
+ // router's workspace+chat fallback), a thread message carries `thread_ts`,
78
+ // letting us target the exact thread session and skip the ambiguous-match case
79
+ // entirely.
80
+ export const THREAD_COMMAND_PREFIX = '!'
81
+
82
+ export type ThreadCommandInput = {
83
+ text: string
84
+ channel: string
85
+ threadTs: string | null
86
+ isDm: boolean
87
+ teamId: string
88
+ invokerId: string
89
+ }
90
+
91
+ export type ParseThreadCommandResult =
92
+ | { kind: 'parsed'; command: ParsedSlackSlashCommand }
93
+ | { kind: 'ignore'; reason: 'no-prefix' | 'unknown-command' }
94
+
95
+ // Both ignore reasons (`no-prefix`, `unknown-command`) are non-fatal: the
96
+ // caller lets the message flow through as ordinary agent input.
97
+ export function parseThreadCommand(
98
+ input: ThreadCommandInput,
99
+ knownCommands: ReadonlySet<string>,
100
+ ): ParseThreadCommandResult {
101
+ const trimmed = input.text.trimStart()
102
+ if (!trimmed.startsWith(THREAD_COMMAND_PREFIX)) {
103
+ return { kind: 'ignore', reason: 'no-prefix' }
104
+ }
105
+ const firstToken = trimmed.slice(THREAD_COMMAND_PREFIX.length).split(/\s/, 1)[0] ?? ''
106
+ const name = firstToken.toLowerCase()
107
+ if (name === '' || !knownCommands.has(name)) {
108
+ return { kind: 'ignore', reason: 'unknown-command' }
109
+ }
110
+
111
+ const workspace = input.isDm ? '@dm' : input.teamId
112
+ return {
113
+ kind: 'parsed',
114
+ command: {
115
+ name,
116
+ key: { adapter: 'slack-bot', workspace, chat: input.channel, thread: input.threadTs },
117
+ invokerId: input.invokerId,
118
+ },
119
+ }
120
+ }
121
+
67
122
  export const SLACK_SLASH_REPLY_ABORTED = 'Stopped the current turn.'
68
123
  export const SLACK_SLASH_REPLY_NO_LIVE_SESSION = 'Nothing to stop — no active turn in this channel.'
69
124
  export const SLACK_SLASH_REPLY_FAILED = 'Could not stop the current turn (internal error).'
70
125
  export const SLACK_SLASH_REPLY_PERMISSION_DENIED =
71
126
  'You do not have permission to stop the current turn in this channel.'
127
+ // Native slash commands cannot be invoked from a thread, so the only way to
128
+ // disambiguate is the `!stop` thread-message fallback — advise that, not the
129
+ // impossible `/stop`-in-thread.
72
130
  export const SLACK_SLASH_REPLY_AMBIGUOUS =
73
- 'Multiple active turns in this channel. Reply `/stop` from inside the specific thread you want to stop.'
131
+ 'Multiple active turns in this channel. Reply `!stop` inside the specific thread you want to stop.'
132
+
133
+ // Single outcome→reply mapping shared by the native-slash (ack payload) and
134
+ // `!cmd` thread (postMessage) delivery paths so the two never drift.
135
+ export function commandResultReply(result: ExecuteCommandResult): string {
136
+ switch (result.kind) {
137
+ case 'handled':
138
+ return SLACK_SLASH_REPLY_ABORTED
139
+ case 'no-live-session':
140
+ return SLACK_SLASH_REPLY_NO_LIVE_SESSION
141
+ case 'permission-denied':
142
+ return SLACK_SLASH_REPLY_PERMISSION_DENIED
143
+ case 'ambiguous':
144
+ return SLACK_SLASH_REPLY_AMBIGUOUS
145
+ case 'unknown-command':
146
+ return SLACK_SLASH_REPLY_FAILED
147
+ }
148
+ }
74
149
 
75
150
  // Slack's ack callback accepts an optional response payload that becomes
76
151
  // the user-visible reply. `response_type: 'ephemeral'` keeps the reply
@@ -35,12 +35,11 @@ import {
35
35
  import { createSlackDedupe } from './slack-bot-dedupe'
36
36
  import {
37
37
  buildSlashAckPayload,
38
+ commandResultReply,
38
39
  parseSlashCommand,
39
- SLACK_SLASH_REPLY_ABORTED,
40
- SLACK_SLASH_REPLY_AMBIGUOUS,
40
+ parseThreadCommand,
41
41
  SLACK_SLASH_REPLY_FAILED,
42
- SLACK_SLASH_REPLY_NO_LIVE_SESSION,
43
- SLACK_SLASH_REPLY_PERMISSION_DENIED,
42
+ type ThreadCommandInput,
44
43
  } from './slack-bot-slash-commands'
45
44
  import { slackTsToMillis } from './slack-bot-time'
46
45
 
@@ -124,16 +123,7 @@ export function createSlashCommandHandler(
124
123
  return
125
124
  }
126
125
 
127
- const replyContent =
128
- result.kind === 'handled'
129
- ? SLACK_SLASH_REPLY_ABORTED
130
- : result.kind === 'no-live-session'
131
- ? SLACK_SLASH_REPLY_NO_LIVE_SESSION
132
- : result.kind === 'permission-denied'
133
- ? SLACK_SLASH_REPLY_PERMISSION_DENIED
134
- : result.kind === 'ambiguous'
135
- ? SLACK_SLASH_REPLY_AMBIGUOUS
136
- : SLACK_SLASH_REPLY_FAILED
126
+ const replyContent = commandResultReply(result)
137
127
 
138
128
  // Final ack on the happy path: own try/catch so a thrown ack here does
139
129
  // NOT cascade into the error-path ack above (which would violate the
@@ -158,6 +148,67 @@ export function createSlashCommandHandler(
158
148
  }
159
149
  }
160
150
 
151
+ export type ThreadCommandReplyPoster = (args: { chat: string; thread: string | null; text: string }) => Promise<void>
152
+
153
+ export type ThreadCommandHandlerDeps = {
154
+ router: Pick<ChannelRouter, 'executeCommand'>
155
+ knownCommandNames: ReadonlySet<string>
156
+ postReply: ThreadCommandReplyPoster
157
+ logger: SlackBotAdapterLoggerLike
158
+ }
159
+
160
+ export type ThreadCommandOutcome = { kind: 'not-a-command' } | { kind: 'duplicate' } | { kind: 'executed' }
161
+
162
+ // Synchronous reservation: the adapter marks the dedupe ring inside this hook,
163
+ // which the handler calls before its first `await`. Two duplicate Slack
164
+ // deliveries can both clear `dedupe.check()` on the same JS tick; whichever
165
+ // reserves first wins and returns `true`, the loser returns `false` and aborts
166
+ // — so a control command never runs twice across the check→execute window.
167
+ export type ThreadCommandReserve = () => boolean
168
+
169
+ // Routes a `!cmd` thread message through the SAME router.executeCommand path as
170
+ // native slashes, then posts the outcome back into the thread. Returns
171
+ // 'not-a-command' (caller proceeds with normal classify/route), 'duplicate'
172
+ // (a racing delivery already reserved this event — caller stops silently), or
173
+ // 'executed' (command handled — caller stops; it is not agent input).
174
+ export function createThreadCommandHandler(
175
+ deps: ThreadCommandHandlerDeps,
176
+ ): (input: ThreadCommandInput, reserve: ThreadCommandReserve) => Promise<ThreadCommandOutcome> {
177
+ return async (input, reserve) => {
178
+ const parsed = parseThreadCommand(input, deps.knownCommandNames)
179
+ if (parsed.kind === 'ignore') {
180
+ return { kind: 'not-a-command' }
181
+ }
182
+ // Reserve synchronously, before any await, to close the check→execute race.
183
+ if (!reserve()) {
184
+ return { kind: 'duplicate' }
185
+ }
186
+ const { command } = parsed
187
+ deps.logger.info(
188
+ `[slack-bot] thread-command !${command.name} invoker=${command.invokerId} team=${command.key.workspace} channel=${command.key.chat} thread=${command.key.thread ?? '(none)'}`,
189
+ )
190
+
191
+ let reply: string
192
+ try {
193
+ const result = await deps.router.executeCommand(command.key, command.name, {
194
+ invokerId: command.invokerId,
195
+ })
196
+ reply = commandResultReply(result)
197
+ deps.logger.info(`[slack-bot] thread-command !${command.name} result=${result.kind}`)
198
+ } catch (err) {
199
+ deps.logger.error(`[slack-bot] thread-command !${command.name} failed: ${describe(err)}`)
200
+ reply = SLACK_SLASH_REPLY_FAILED
201
+ }
202
+
203
+ try {
204
+ await deps.postReply({ chat: input.channel, thread: input.threadTs, text: reply })
205
+ } catch (err) {
206
+ deps.logger.warn(`[slack-bot] thread-command reply post failed: ${describe(err)}`)
207
+ }
208
+ return { kind: 'executed' }
209
+ }
210
+ }
211
+
161
212
  // app_mention payloads omit channel_type and never carry a subtype, so we
162
213
  // promote them to a message-shaped event for the shared classifier. The
163
214
  // promoted event is classified as a regular channel message; the
@@ -783,6 +834,24 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
783
834
  formatChannelTag,
784
835
  })
785
836
 
837
+ const handleThreadCommand = createThreadCommandHandler({
838
+ router: options.router,
839
+ knownCommandNames: SLACK_SLASH_COMMAND_NAMES,
840
+ logger,
841
+ postReply: async ({ chat, thread, text }) => {
842
+ const result = await outboundCallback({
843
+ adapter: 'slack-bot',
844
+ workspace: teamId ?? 'unknown',
845
+ chat,
846
+ ...(thread !== null ? { thread } : {}),
847
+ text,
848
+ })
849
+ if (!result.ok) {
850
+ throw new Error(result.error)
851
+ }
852
+ },
853
+ })
854
+
786
855
  const handleMessageEvent = async (
787
856
  event: SlackInboundMessageEvent,
788
857
  source: 'message' | 'app_mention',
@@ -813,6 +882,38 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
813
882
  return
814
883
  }
815
884
 
885
+ // Intercept `!cmd` thread-message commands BEFORE classifyInbound. A
886
+ // command is control traffic — neither dropped nor routed to the agent —
887
+ // so it must short-circuit here. Bypassing classifyInbound also bypasses
888
+ // its self_author / no_user drops, so we replicate those guards: never
889
+ // execute a command from our own message (echo loop) or a userless
890
+ // system event. The `reserve` closure marks dedupe synchronously the
891
+ // instant the command is recognised (before the router await), closing
892
+ // the check→execute race for duplicate deliveries.
893
+ if (event.user !== undefined && event.user !== '' && (botUserId === null || event.user !== botUserId)) {
894
+ const reserve = (): boolean => {
895
+ if (dedupe.check(event) !== null) return false
896
+ dedupe.mark(event)
897
+ return true
898
+ }
899
+ const outcome = await handleThreadCommand(
900
+ {
901
+ text: event.text ?? '',
902
+ channel: event.channel,
903
+ threadTs: event.thread_ts ?? null,
904
+ isDm: event.channel_type === 'im',
905
+ teamId,
906
+ invokerId: event.user,
907
+ },
908
+ reserve,
909
+ )
910
+ if (outcome.kind === 'executed') return
911
+ if (outcome.kind === 'duplicate') {
912
+ logger.info(`[slack-bot] dropped ts=${event.ts} reason=duplicate_delivery (thread-command race)`)
913
+ return
914
+ }
915
+ }
916
+
816
917
  const verdict = classifyInbound(event, options.configRef(), {
817
918
  teamId,
818
919
  botUserId,
@@ -842,7 +842,6 @@ async function promptGithubAppAuth(): Promise<{
842
842
  type: 'app'
843
843
  appId: number
844
844
  privateKey: string
845
- installationId?: number
846
845
  }> {
847
846
  const appId = await text({
848
847
  message: 'GitHub App ID',
@@ -857,21 +856,10 @@ async function promptGithubAppAuth(): Promise<{
857
856
  cancel('Aborted.')
858
857
  process.exit(0)
859
858
  }
860
- const installationId = await text({
861
- message: 'Installation ID (optional; leave blank to auto-discover)',
862
- validate: (value) =>
863
- value === undefined || value === '' ? undefined : validatePositiveInteger(value, 'Installation ID is required'),
864
- })
865
- if (isCancel(installationId)) {
866
- cancel('Aborted.')
867
- process.exit(0)
868
- }
869
- const parsedInstallationId = installationId === '' ? undefined : Number(installationId)
870
859
  return {
871
860
  type: 'app',
872
861
  appId: Number(appId),
873
862
  privateKey,
874
- ...(parsedInstallationId !== undefined ? { installationId: parsedInstallationId } : {}),
875
863
  }
876
864
  }
877
865
 
package/src/cli/init.ts CHANGED
@@ -1293,7 +1293,6 @@ async function promptGithubAppAuth(): Promise<{
1293
1293
  type: 'app'
1294
1294
  appId: number
1295
1295
  privateKey: string
1296
- installationId?: number
1297
1296
  } | null> {
1298
1297
  const appId = await text({
1299
1298
  message: 'GitHub App ID',
@@ -1302,18 +1301,10 @@ async function promptGithubAppAuth(): Promise<{
1302
1301
  if (isCancel(appId)) return null
1303
1302
  const privateKey = await promptPrivateKeyPem('GitHub App private key PEM, escaped PEM, or path to .pem file')
1304
1303
  if (privateKey === CANCEL_SYMBOL) return null
1305
- const installationId = await text({
1306
- message: 'Installation ID (optional; leave blank to auto-discover)',
1307
- validate: (v) =>
1308
- v === undefined || v === '' ? undefined : validatePositiveInteger(v, 'Installation ID is required'),
1309
- })
1310
- if (isCancel(installationId)) return null
1311
- const parsedInstallationId = installationId === '' ? undefined : Number(installationId)
1312
1304
  return {
1313
1305
  type: 'app',
1314
1306
  appId: Number(appId),
1315
1307
  privateKey,
1316
- ...(parsedInstallationId !== undefined ? { installationId: parsedInstallationId } : {}),
1317
1308
  }
1318
1309
  }
1319
1310
 
@@ -57,7 +57,7 @@ export async function installGithubWebhooksEagerly(
57
57
 
58
58
  try {
59
59
  const result = await registerGithubWebhooks({
60
- token: () => strategy.token(),
60
+ token: (repoSlug: string) => strategy.token({ repoSlug }),
61
61
  webhookUrl,
62
62
  webhookSecret: options.webhookSecret,
63
63
  repos: options.repos,
@@ -86,7 +86,6 @@ function authToSecretBlock(auth: GithubInitCredentials['auth']) {
86
86
  type: 'app' as const,
87
87
  appId: auth.appId,
88
88
  privateKey: { value: auth.privateKey },
89
- ...(auth.installationId !== undefined ? { installationId: auth.installationId } : {}),
90
89
  }
91
90
  }
92
91
 
package/src/init/index.ts CHANGED
@@ -94,7 +94,7 @@ export type GithubInitCredentials = {
94
94
  hostname?: string
95
95
  tokenEnv?: string
96
96
  repos: string[]
97
- auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
97
+ auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string }
98
98
  }
99
99
 
100
100
  export type GithubTunnelProvider = 'cloudflare-quick' | 'cloudflare-named' | 'external' | 'none'
@@ -998,7 +998,7 @@ export type AddChannelOptions = {
998
998
  hostname?: string
999
999
  tokenEnv?: string
1000
1000
  repos: string[]
1001
- auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
1001
+ auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string }
1002
1002
  fetchImpl?: typeof fetch
1003
1003
  }
1004
1004
  )
@@ -1263,9 +1263,6 @@ async function writeGithubChannelForInit(cwd: string, credentials: GithubInitCre
1263
1263
  type: 'app',
1264
1264
  appId: credentials.auth.appId,
1265
1265
  privateKey: { value: credentials.auth.privateKey } satisfies Secret,
1266
- ...(credentials.auth.installationId !== undefined
1267
- ? { installationId: credentials.auth.installationId }
1268
- : {}),
1269
1266
  },
1270
1267
  webhookSecret: { value: credentials.webhookSecret } satisfies Secret,
1271
1268
  }
@@ -1298,7 +1295,6 @@ async function appendGithubSecrets(
1298
1295
  type: 'app',
1299
1296
  appId: options.auth.appId,
1300
1297
  privateKey: { value: options.auth.privateKey } satisfies Secret,
1301
- ...(options.auth.installationId !== undefined ? { installationId: options.auth.installationId } : {}),
1302
1298
  },
1303
1299
  webhookSecret: { value: options.webhookSecret } satisfies Secret,
1304
1300
  }
@@ -1461,13 +1457,13 @@ export async function setChannelSecrets(
1461
1457
  // previous auth type, since the two shapes share no fields beyond `type`).
1462
1458
  export type GithubCredentialPatch = {
1463
1459
  webhookSecret?: string
1464
- auth?: { type: 'pat'; pat: string } | { type: 'app'; privateKey: string; appId?: number; installationId?: number }
1460
+ auth?: { type: 'pat'; pat: string } | { type: 'app'; privateKey: string; appId?: number }
1465
1461
  }
1466
1462
 
1467
1463
  // Update one or more credential fields on an already-configured GitHub
1468
1464
  // channel. Like setChannelSecrets, refuses when secrets.json has no
1469
1465
  // existing github entry. Supports both same-type rotation (preserves env
1470
- // bindings, carries appId/installationId forward when not supplied) and
1466
+ // bindings, carries appId forward when not supplied) and
1471
1467
  // auth-type switching (replaces the entire auth block — see
1472
1468
  // `GithubCredentialPatch` above).
1473
1469
  export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch): Promise<SetChannelTokensResult> {
@@ -1503,7 +1499,6 @@ export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch
1503
1499
  } else {
1504
1500
  const existingApp = isSameType && isObjectRecord(existingAuth) ? (existingAuth as Record<string, unknown>) : {}
1505
1501
  const appId = patch.auth.appId ?? (existingApp.appId as number | undefined)
1506
- const installationId = patch.auth.installationId ?? (existingApp.installationId as number | undefined)
1507
1502
  if (typeof appId !== 'number') {
1508
1503
  return {
1509
1504
  result: {
@@ -1518,7 +1513,6 @@ export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch
1518
1513
  type: 'app',
1519
1514
  appId,
1520
1515
  privateKey: rotatedSecret(existingApp.privateKey, patch.auth.privateKey),
1521
- ...(installationId !== undefined ? { installationId } : {}),
1522
1516
  }
1523
1517
  }
1524
1518
  }
package/src/run/index.ts CHANGED
@@ -371,6 +371,7 @@ export async function startAgent({
371
371
  runtimeVersion: runtimeVersionOpt.runtimeVersion,
372
372
  containerName: containerNameOpt.containerName,
373
373
  sessionFactory,
374
+ channelRouter: channelManager.router,
374
375
  }),
375
376
  subagent: (subName: string, payload?: unknown) =>
376
377
  dispatchSpawnSubagent(subName, payload, {
@@ -585,6 +586,7 @@ export async function startAgent({
585
586
  containerName,
586
587
  outbound,
587
588
  sessionFactory,
589
+ channelRouter: channelManager.router,
588
590
  })
589
591
 
590
592
  const server = createServer({
@@ -49,7 +49,6 @@ const githubAppAuthSchema = z.object({
49
49
  type: z.literal('app'),
50
50
  appId: z.number().int().positive(),
51
51
  privateKey: secretFieldSchema,
52
- installationId: z.number().int().positive().optional(),
53
52
  })
54
53
 
55
54
  const githubChannelSchema = z.object({
@@ -5,6 +5,7 @@ import {
5
5
  type CreateSessionResult,
6
6
  type SessionOrigin,
7
7
  } from '@/agent'
8
+ import type { ChannelRouter } from '@/channels/router'
8
9
  import type { PermissionService } from '@/permissions'
9
10
  import type {
10
11
  CommandExecResult,
@@ -44,6 +45,14 @@ export type CommandRunnerOptions = {
44
45
  // `SessionManager.inMemory()` and never persist usage — see
45
46
  // `runPromptForCommand` below.
46
47
  sessionFactory: SessionFactory
48
+ // Channel router threaded into every `ctx.prompt` session so the model can
49
+ // call `channel_send`. Without this, `buildChannelTools` (src/agent/index.ts)
50
+ // receives `undefined` and emits no channel tools — a plugin command or cron
51
+ // handler told to post to a channel then has no tool to do it and falls back
52
+ // to flailing bash loops. The cron `prompt` path already passes
53
+ // `channelManager.router` via `createSessionForCron`; this is the matching
54
+ // wire for the handler/command path.
55
+ channelRouter: ChannelRouter | undefined
47
56
  }
48
57
 
49
58
  type CommandHandle = {
@@ -182,6 +191,7 @@ export function createCommandRunner(opts: CommandRunnerOptions): CommandRunner {
182
191
  permissions: opts.permissions,
183
192
  signal: abortController.signal,
184
193
  sessionFactory: opts.sessionFactory,
194
+ channelRouter: opts.channelRouter,
185
195
  }),
186
196
  subagent: (subName, payload) =>
187
197
  opts.spawnSubagent(subName, payload, {
@@ -363,6 +373,9 @@ export async function runPromptForCommand(args: {
363
373
  // cron `prompt` path uses in src/run/index.ts. Passing in-memory here
364
374
  // regresses `typeclaw usage` (see CommandRunnerOptions.sessionFactory).
365
375
  sessionFactory: SessionFactory
376
+ // See CommandRunnerOptions.channelRouter. Threaded to createSessionWithDispose
377
+ // so the spawned session exposes `channel_send`.
378
+ channelRouter?: ChannelRouter
366
379
  // Test seam for the agent-session boundary. Production passes the real
367
380
  // `createSessionWithDispose`; tests inject a fake to verify wiring
368
381
  // (specifically: the sessionManager handed off must be persisted, not
@@ -388,6 +401,7 @@ export async function runPromptForCommand(args: {
388
401
  sessionId,
389
402
  agentDir: args.agentDir,
390
403
  },
404
+ ...(args.channelRouter !== undefined ? { channelRouter: args.channelRouter } : {}),
391
405
  ...(args.runtimeVersion !== undefined ? { runtimeVersion: args.runtimeVersion } : {}),
392
406
  ...(args.containerName !== undefined ? { containerName: args.containerName } : {}),
393
407
  })