typeclaw 0.16.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.
Files changed (45) hide show
  1. package/auth.schema.json +0 -5
  2. package/package.json +2 -2
  3. package/secrets.schema.json +0 -5
  4. package/src/agent/index.ts +32 -1
  5. package/src/agent/session-origin.ts +54 -12
  6. package/src/agent/system-prompt.ts +1 -1
  7. package/src/agent/tools/grant-role.ts +214 -0
  8. package/src/channels/adapters/discord-bot-classify.ts +23 -0
  9. package/src/channels/adapters/discord-bot.ts +1 -0
  10. package/src/channels/adapters/github/auth-app.ts +49 -26
  11. package/src/channels/adapters/github/auth-pat.ts +3 -3
  12. package/src/channels/adapters/github/auth.ts +19 -5
  13. package/src/channels/adapters/github/channel-resolver.ts +3 -2
  14. package/src/channels/adapters/github/history.ts +3 -2
  15. package/src/channels/adapters/github/index.ts +85 -43
  16. package/src/channels/adapters/github/membership.ts +3 -2
  17. package/src/channels/adapters/github/outbound.ts +6 -2
  18. package/src/channels/adapters/github/team-membership.ts +4 -2
  19. package/src/channels/adapters/github/webhook-register.ts +19 -16
  20. package/src/channels/adapters/slack-bot-slash-commands.ts +76 -1
  21. package/src/channels/adapters/slack-bot.ts +115 -14
  22. package/src/channels/router.ts +87 -17
  23. package/src/cli/channel.ts +0 -12
  24. package/src/cli/init.ts +0 -9
  25. package/src/cli/role.ts +10 -1
  26. package/src/cli/ui.ts +6 -4
  27. package/src/config/reloadable.ts +10 -3
  28. package/src/init/github-webhook-install.ts +1 -2
  29. package/src/init/index.ts +9 -43
  30. package/src/init/run-owner-claim.ts +21 -3
  31. package/src/permissions/builtins.ts +14 -4
  32. package/src/permissions/grant.ts +92 -16
  33. package/src/permissions/index.ts +8 -2
  34. package/src/permissions/permissions.ts +9 -0
  35. package/src/permissions/resolve.ts +10 -0
  36. package/src/role-claim/index.ts +1 -0
  37. package/src/role-claim/reload-after-claim.ts +34 -0
  38. package/src/run/channel-session-factory.ts +6 -1
  39. package/src/run/index.ts +20 -1
  40. package/src/sandbox/build.ts +32 -0
  41. package/src/secrets/schema.ts +0 -1
  42. package/src/server/command-runner.ts +14 -0
  43. package/src/skills/typeclaw-channel-github/SKILL.md +15 -3
  44. package/src/skills/typeclaw-permissions/SKILL.md +11 -3
  45. package/src/skills/typeclaw-skills/SKILL.md +3 -1
@@ -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