typeclaw 0.18.0 → 0.19.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 (33) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +2 -1
  3. package/src/agent/model-overrides.ts +77 -0
  4. package/src/agent/plugin-tools.ts +53 -4
  5. package/src/agent/tools/grant-role.ts +102 -8
  6. package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
  7. package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
  8. package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
  9. package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
  10. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
  11. package/src/channels/adapters/discord-bot.ts +21 -4
  12. package/src/channels/adapters/github/inbound.ts +30 -55
  13. package/src/channels/adapters/github/index.ts +80 -18
  14. package/src/channels/adapters/github/membership.ts +4 -0
  15. package/src/channels/adapters/slack-bot-slash-commands.ts +3 -1
  16. package/src/channels/adapters/slack-bot.ts +4 -4
  17. package/src/channels/commands.ts +10 -0
  18. package/src/channels/engagement.ts +34 -3
  19. package/src/channels/github-token-bridge.ts +42 -0
  20. package/src/channels/index.ts +6 -0
  21. package/src/channels/manager.ts +6 -0
  22. package/src/channels/membership.ts +9 -0
  23. package/src/channels/router.ts +155 -37
  24. package/src/cli/ui.ts +6 -0
  25. package/src/commands/index.ts +54 -4
  26. package/src/init/dockerfile.ts +60 -0
  27. package/src/init/validate-api-key.ts +15 -1
  28. package/src/plugin/context.ts +8 -0
  29. package/src/plugin/manager.ts +3 -0
  30. package/src/plugin/types.ts +6 -0
  31. package/src/run/bundled-plugins.ts +9 -0
  32. package/src/run/index.ts +4 -0
  33. package/src/skills/typeclaw-channel-github/SKILL.md +70 -43
@@ -13,8 +13,8 @@ export type GithubWebhookHandlerOptions = {
13
13
  allowlist: () => readonly string[]
14
14
  selfId: () => string | null
15
15
  selfLogin: () => string | null
16
- // Defaults to 'pat' when omitted. Only 'app' promotes an opened PR to a
17
- // review request; see classifyOpenedAsReview for why.
16
+ // Defaults to 'pat' when omitted. In 'app' mode classifyReviewRequest also
17
+ // matches the App's decoy reviewer login; see resolveDecoyReviewerLogin.
18
18
  authType?: () => 'pat' | 'app'
19
19
  route: (message: InboundMessage) => void
20
20
  logger: GithubInboundLogger
@@ -178,17 +178,10 @@ export function classifyGithubInbound(
178
178
  number,
179
179
  base,
180
180
  selfLogin,
181
+ authType: options?.authType ?? 'pat',
181
182
  teamIsBotMember: options?.teamIsBotMember,
182
183
  })
183
184
  }
184
- // A GitHub App cannot be added to a PR's requested_reviewers, so it never
185
- // receives a review_requested event targeting itself. The opened event is
186
- // the only signal it can act on, so in App mode an opened PR is promoted to
187
- // a review request. A PAT-backed bot is a real user that can be requested,
188
- // so it waits for the explicit request instead of reviewing every PR.
189
- if (action === 'opened' && options?.authType === 'app') {
190
- return classifyOpenedAsReview({ payload, pr, number, base, selfLogin })
191
- }
192
185
  return buildInbound(
193
186
  { ...base, chat: `pr:${number}`, thread: null },
194
187
  pr.body,
@@ -242,23 +235,46 @@ type ReviewRequestInput = {
242
235
  number: number
243
236
  base: Pick<InboundMessage, 'adapter' | 'workspace' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'>
244
237
  selfLogin: string | null
238
+ authType: 'pat' | 'app'
245
239
  teamIsBotMember: boolean | undefined
246
240
  }
247
241
 
242
+ // A GitHub App can never be a `requested_reviewer` — that field only holds
243
+ // real user accounts, and the App actor (`slug[bot]`) is not one. The
244
+ // supported workaround is a decoy user account named after the App that an
245
+ // operator requests instead (see docs/content/docs/internals/github-decoy-reviewer.mdx).
246
+ // Its login is, by convention, the App slug — i.e. `selfLogin` with the
247
+ // `[bot]` suffix removed (`my-app[bot]` → `my-app`). This is the single seam
248
+ // where that login is resolved: when the decoy account's real login diverges
249
+ // from the slug, a future config field replaces this derivation without
250
+ // touching the matcher. PAT auth has no decoy (the bot IS a real user that can
251
+ // be requested directly), so it returns null.
252
+ const BOT_LOGIN_SUFFIX = '[bot]'
253
+
254
+ function resolveDecoyReviewerLogin(selfLogin: string, authType: 'pat' | 'app'): string | null {
255
+ if (authType !== 'app') return null
256
+ if (!selfLogin.endsWith(BOT_LOGIN_SUFFIX)) return null
257
+ const slug = selfLogin.slice(0, -BOT_LOGIN_SUFFIX.length)
258
+ return slug !== '' ? slug : null
259
+ }
260
+
248
261
  function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null {
249
- const { action, payload, pr, number, base, selfLogin, teamIsBotMember } = input
262
+ const { action, payload, pr, number, base, selfLogin, authType, teamIsBotMember } = input
250
263
  if (selfLogin === null) return null
264
+ const decoyLogin = resolveDecoyReviewerLogin(selfLogin, authType)
251
265
  const sender = readUser(payload.sender)
252
266
  if (sender === null) return null
253
- // Self-loop guard: if the bot itself requested (or un-requested) the
267
+ // Self-loop guard: if the bot (or its decoy) requested/un-requested the
254
268
  // review, drop the event. The bot adding itself as a reviewer would
255
269
  // otherwise wake a fresh session every time it self-assigns.
256
- if (sender.login === selfLogin) return null
270
+ if (sender.login === selfLogin || (decoyLogin !== null && sender.login === decoyLogin)) return null
257
271
 
258
272
  const requestedUser = readUser(payload.requested_reviewer)
259
273
  const requestedTeam = readReviewerTeam(payload.requested_team)
260
274
 
261
- const isMeAsUser = requestedUser !== null && requestedUser.login === selfLogin
275
+ const isMeAsUser =
276
+ requestedUser !== null &&
277
+ (requestedUser.login === selfLogin || (decoyLogin !== null && requestedUser.login === decoyLogin))
262
278
  const isMyTeam = requestedTeam !== null && teamIsBotMember === true
263
279
  if (!isMeAsUser && !isMyTeam) return null
264
280
 
@@ -303,47 +319,6 @@ function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null
303
319
  }
304
320
  }
305
321
 
306
- type OpenedAsReviewInput = {
307
- payload: Record<string, unknown>
308
- pr: Record<string, unknown>
309
- number: number
310
- base: Pick<InboundMessage, 'adapter' | 'workspace' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'>
311
- selfLogin: string | null
312
- }
313
-
314
- function classifyOpenedAsReview(input: OpenedAsReviewInput): InboundMessage | null {
315
- const { payload, pr, number, base, selfLogin } = input
316
- if (selfLogin === null) return null
317
- const sender = readUser(payload.sender)
318
- if (sender === null) return null
319
- if (sender.login === selfLogin) return null
320
-
321
- const title = readString(pr, 'title') ?? `#${number}`
322
- const head = readString(readRecord(pr.head), 'ref')
323
- const baseRef = readString(readRecord(pr.base), 'ref')
324
- const branchSegment = head !== null && baseRef !== null ? ` Branch: ${head} → ${baseRef}.` : ''
325
- const text =
326
- `@${sender.login} requested your review on PR #${number}: "${title}".${branchSegment}` +
327
- ' Please review the changes line-by-line and post your feedback.'
328
-
329
- const updatedAt = readString(pr, 'updated_at') ?? ''
330
- const prId = readNumber(pr, 'id') ?? number
331
-
332
- return {
333
- ...base,
334
- chat: `pr:${number}`,
335
- thread: null,
336
- text,
337
- externalMessageId: `pr-${prId}-opened-${updatedAt}`,
338
- authorId: String(sender.id),
339
- authorName: sender.login,
340
- authorIsBot: sender.type === 'Bot',
341
- isBotMention: true,
342
- replyToBotMessageId: null,
343
- ts: updatedAt !== '' ? Date.parse(updatedAt) || 0 : 0,
344
- }
345
- }
346
-
347
322
  export type GithubReviewerTeam = { slug: string; id: number; org: string | null }
348
323
 
349
324
  export function readReviewerTeam(value: unknown): GithubReviewerTeam | null {
@@ -1,3 +1,4 @@
1
+ import type { GithubTokenBridge } from '@/channels/github-token-bridge'
1
2
  import type { ChannelRouter } from '@/channels/router'
2
3
  import type { ChannelAdapterConfig, GithubAdapterConfig } from '@/channels/schema'
3
4
  import { resolveSecret } from '@/secrets/resolve'
@@ -61,6 +62,12 @@ export type GithubAdapterOptions = {
61
62
  // Test-only: replaces `setInterval` so tests can control when the
62
63
  // background refresh fires without waiting on real wall-clock time.
63
64
  setInterval?: (handler: () => void, ms: number) => { clear: () => void }
65
+ // Write-side of the GithubTokenBridge. On App-auth start the adapter
66
+ // registers a per-repo minter here so plugin hooks can resolve a token for
67
+ // ad-hoc `gh` commands; it unregisters on stop and on start rollback. PAT
68
+ // auth does not register (the seeded GH_TOKEN already covers every repo a
69
+ // classic PAT can reach, and a fine-grained PAT cannot be re-minted per repo).
70
+ githubTokenBridge?: GithubTokenBridge
64
71
  }
65
72
 
66
73
  export type GithubAdapter = {
@@ -93,6 +100,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
93
100
  let started = false
94
101
  let managedHooks: ReadonlyArray<{ repo: string; hookId: number }> = []
95
102
  let tokenRefreshTimer: { clear: () => void } | null = null
103
+ let unregisterTokenBridge: (() => void) | null = null
96
104
  const workspaceByChat = new Map<string, string>()
97
105
 
98
106
  const rememberWorkspace = (workspace: string, chat: string): void => {
@@ -176,17 +184,15 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
176
184
  throw err
177
185
  }
178
186
  started = true
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') {
187
+ // Seed the process-wide GH_TOKEN when it's unambiguous; skip otherwise.
188
+ // See ghTokenSeedDecision for why one owner is required. On skip, authToken
189
+ // still resolves a repo-scoped token per call for the adapter's own traffic.
190
+ const seed = ghTokenSeedDecision(options.secrets.auth.type, options.configRef().repos ?? [])
191
+ if (seed.kind === 'seed') {
192
+ const seedContext = seed.context
193
+ const seedGhToken = async (): Promise<void> => {
194
+ process.env.GH_TOKEN = await auth.token(seedContext)
195
+ }
190
196
  await seedGhToken()
191
197
  const tokenRefreshIntervalMs = options.tokenRefreshIntervalMs ?? DEFAULT_TOKEN_REFRESH_INTERVAL_MS
192
198
  if (tokenRefreshIntervalMs > 0) {
@@ -207,10 +213,28 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
207
213
  }
208
214
  } else {
209
215
  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.',
216
+ `${GH_TOKEN_SKIP_LOG[seed.reason]} Ad-hoc \`gh\` commands should set a repo-scoped token explicitly.`,
212
217
  )
213
218
  }
219
+ if (options.secrets.auth.type === 'app' && options.githubTokenBridge !== undefined) {
220
+ // Gate ad-hoc `gh` minting on the configured repos[]. The slug arrives
221
+ // from an attacker-controllable -R/--repo flag (untrusted PR/issue
222
+ // content can prompt-inject it); without this an injected `-R any/repo`
223
+ // would mint an installation-wide token for any repo the App is installed
224
+ // on — a cross-tenant leak under a multi-owner App. Enforced here, not in
225
+ // the parser, because this adapter is the authority that owns repos[].
226
+ unregisterTokenBridge = options.githubTokenBridge.registerResolver((repoSlug) => {
227
+ const allowed = new Set((options.configRef().repos ?? []).map(canonicalRepoSlug))
228
+ if (!allowed.has(canonicalRepoSlug(repoSlug))) {
229
+ throw new Error(
230
+ `repo \`${repoSlug}\` is not in this agent's configured \`channels.github.repos[]\`; ` +
231
+ 'refusing to mint a GitHub App token for it. Target a configured repo, ' +
232
+ 'or add it to `repos[]` if the agent is meant to operate there.',
233
+ )
234
+ }
235
+ return auth.token({ repoSlug })
236
+ })
237
+ }
214
238
  logger.info(`[github] webhook listening on port ${options.configRef().webhookPort} as @${self.login}`)
215
239
  // Best-effort: App-only preflight that compares the installation's granted
216
240
  // permissions against the configured eventAllowlist and warns about gaps.
@@ -291,6 +315,10 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
291
315
  tokenRefreshTimer.clear()
292
316
  tokenRefreshTimer = null
293
317
  }
318
+ if (unregisterTokenBridge !== null) {
319
+ unregisterTokenBridge()
320
+ unregisterTokenBridge = null
321
+ }
294
322
  await auth.dispose()
295
323
  delete process.env.GH_TOKEN
296
324
  server = null
@@ -427,11 +455,45 @@ function logDeregistrationOutcome(
427
455
  }
428
456
  }
429
457
 
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
458
+ type GhTokenSeedDecision =
459
+ | { kind: 'seed'; context?: GithubAuthContext }
460
+ | { kind: 'skip'; reason: 'no-repos' | 'multiple-owners' }
461
+
462
+ const GH_TOKEN_SKIP_LOG: Record<'no-repos' | 'multiple-owners', string> = {
463
+ 'no-repos':
464
+ '[github] no repos[] configured; GH_TOKEN not seeded globally (cannot prove which App installation to use).',
465
+ 'multiple-owners': '[github] repos span multiple owners (multiple App installations); GH_TOKEN not seeded globally.',
466
+ }
467
+
468
+ // Decides how to seed the process-wide GH_TOKEN. PATs aren't installation-scoped
469
+ // (seed context-free). For App auth we seed from a configured repo slug, which
470
+ // resolves the installation via repos/{owner}/{repo}/installation — the only
471
+ // lookup that works for both org- and user-owned repos. One owner is required:
472
+ // no-repos can't prove an installation, multi-owner needs >1 token.
473
+ function ghTokenSeedDecision(authType: 'pat' | 'app', repos: readonly string[]): GhTokenSeedDecision {
474
+ if (authType === 'pat') return { kind: 'seed' }
475
+ const slugs = [...new Set(repos.filter(isWellFormedSlug))].sort()
476
+ if (slugs.length === 0) return { kind: 'skip', reason: 'no-repos' }
477
+ const owners = new Set(slugs.map((slug) => slug.split('/')[0]))
478
+ if (owners.size > 1) return { kind: 'skip', reason: 'multiple-owners' }
479
+ return { kind: 'seed', context: { repoSlug: slugs[0] } }
480
+ }
481
+
482
+ function isWellFormedSlug(repo: string): boolean {
483
+ const [owner, name, ...rest] = repo.split('/')
484
+ return owner !== undefined && owner !== '' && name !== undefined && name !== '' && rest.length === 0
485
+ }
486
+
487
+ // Canonical form for repos[] allowlist comparison so the gate can't be bypassed
488
+ // by case, a trailing slash, or a `.git` suffix (GitHub treats owner/name
489
+ // case-insensitively). Applied identically to both configured repos[] and the
490
+ // runtime slug before exact Set membership.
491
+ function canonicalRepoSlug(repo: string): string {
492
+ return repo
493
+ .trim()
494
+ .replace(/\/+$/, '')
495
+ .replace(/\.git$/i, '')
496
+ .toLowerCase()
435
497
  }
436
498
 
437
499
  function defaultSleep(ms: number): Promise<void> {
@@ -28,6 +28,10 @@ export function createGithubMembershipResolver(options: {
28
28
  if (user.type === 'Bot') bots++
29
29
  else humans++
30
30
  }
31
+ // Counts only, no humanMemberIds: GitHub membership is the repo
32
+ // collaborator list, a different population from the authors that can
33
+ // comment into a PR/issue turn, so it is not a basis for the channel
34
+ // grant-role relaxation (see provesOnlyAgentBotPresent in grant-role.ts).
31
35
  return { humans, bots, fetchedAt: Date.now(), truncated: users.length >= 100 }
32
36
  } catch {
33
37
  return { kind: 'transient' }
@@ -135,7 +135,9 @@ export const SLACK_SLASH_REPLY_AMBIGUOUS =
135
135
  export function commandResultReply(result: ExecuteCommandResult): string {
136
136
  switch (result.kind) {
137
137
  case 'handled':
138
- return SLACK_SLASH_REPLY_ABORTED
138
+ // Dynamic commands (e.g. /help) carry their own reply; static control
139
+ // commands (/stop) leave it undefined and fall back to the fixed string.
140
+ return result.reply ?? SLACK_SLASH_REPLY_ABORTED
139
141
  case 'no-live-session':
140
142
  return SLACK_SLASH_REPLY_NO_LIVE_SESSION
141
143
  case 'permission-denied':
@@ -51,7 +51,7 @@ import { slackTsToMillis } from './slack-bot-time'
51
51
  // slash_commands events we route vs drop. The ui.test.ts manifest-drift
52
52
  // test asserts equality between this set and SLACK_APP_MANIFEST.features.
53
53
  // slash_commands so the two can never silently diverge.
54
- export const SLACK_SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(['stop'])
54
+ export const SLACK_SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(['help', 'stop'])
55
55
 
56
56
  // Resolvers fall back to the raw id on failure, so a name equal to the id
57
57
  // means resolution failed; we render the bare id rather than `id(id)`. The
@@ -459,14 +459,14 @@ export function createSlackMembershipResolver(deps: {
459
459
  }
460
460
 
461
461
  let bots = 0
462
- let humans = 0
462
+ const humanMemberIds: string[] = []
463
463
  for (const userId of members.value.members ?? []) {
464
464
  const cached = userBotCache.get(userId)
465
465
  const isBot = cached ?? (await resolveSlackUserIsBot(fetchFn, deps.token, userId, deps.logger, userBotCache))
466
466
  if (isBot) bots++
467
- else humans++
467
+ else humanMemberIds.push(userId)
468
468
  }
469
- return { humans, bots, fetchedAt: now(), truncated: false }
469
+ return { humans: humanMemberIds.length, bots, fetchedAt: now(), truncated: false, humanMemberIds }
470
470
  }
471
471
  }
472
472
 
@@ -0,0 +1,10 @@
1
+ import type { CommandInfo } from '@/commands'
2
+
3
+ // Generated from registry metadata so the listing can never drift from the
4
+ // actual command set. The `/` prefix is canonical across every surface; Slack
5
+ // threads accept the `!` alias for the same names.
6
+ export function formatChannelCommandHelp(commands: readonly CommandInfo[]): string {
7
+ if (commands.length === 0) return 'No commands are available.'
8
+ const lines = commands.map((command) => `/${command.name} — ${command.description}`)
9
+ return ['Available commands:', ...lines].join('\n')
10
+ }
@@ -81,11 +81,25 @@ export type EngagementInput = {
81
81
  export function decideEngagement(input: EngagementInput): EngagementDecision {
82
82
  const { message, config, key, ledger, now, participants, selfAliases, botInThread } = input
83
83
 
84
+ // The human count drives both the sticky-credit gate (below) and the
85
+ // solo-human fallback (bottom). Compute it once, up front. Peer bots are
86
+ // excluded — a 1-human-N-bot room is still "solo" for engagement purposes.
87
+ const effectiveHumans = countEffectiveHumans(participants, input.membership, now)
88
+ const multiHumanGroup = isMultiHumanGroup(message.isDm, effectiveHumans)
89
+
84
90
  if (config.trigger.includes('dm') && message.isDm) return 'engage'
85
91
  if (config.trigger.includes('mention') && message.isBotMention) return 'engage'
86
92
  if (config.trigger.includes('reply') && message.replyToBotMessageId !== null) return 'engage'
87
93
 
88
- if (config.stickiness !== 'off' && ledger.consume(key, message.authorId, now)) {
94
+ // Sticky credit. ALWAYS consume when present (the credit stays one-shot,
95
+ // so a later membership change can't resurrect stale conversational
96
+ // credit), but only let it FORCE engagement outside a multi-human group.
97
+ // In a group, sticky alone no longer wakes the bot on every follow-up —
98
+ // the author must re-address us (mention/reply/alias) to re-engage. This
99
+ // narrows an existing permissive rule in the exact context where it's
100
+ // harmful; it is NOT a new bot-specific gate (peer bots and humans are
101
+ // treated identically, via `multiHumanGroup`). See engagement.mdx.
102
+ if (config.stickiness !== 'off' && ledger.consume(key, message.authorId, now) && !multiHumanGroup) {
89
103
  return 'engage'
90
104
  }
91
105
 
@@ -169,13 +183,30 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
169
183
  // peer's first message it's caught forever.
170
184
  if (textTargetsAnyPeerBot(message.text, participants)) return 'observe'
171
185
 
172
- const persistedHumans = participants.filter((p) => p.isBot !== true).length
173
- const effectiveHumans = resolveEffectiveHumans(persistedHumans, input.membership, now)
174
186
  if (effectiveHumans <= 1 && !message.authorIsBot) return 'engage'
175
187
 
176
188
  return 'observe'
177
189
  }
178
190
 
191
+ export function countEffectiveHumans(
192
+ participants: readonly ChannelParticipant[],
193
+ membership: MembershipCount | null,
194
+ now: number,
195
+ ): number {
196
+ const persistedHumans = participants.filter((p) => p.isBot !== true).length
197
+ return resolveEffectiveHumans(persistedHumans, membership, now)
198
+ }
199
+
200
+ // A multi-human group is the one place where the chatty "reply to every
201
+ // follow-up" behavior (sticky credit, and the prompt's default eagerness) is
202
+ // wrong. DMs — 1:1, or platform group-DMs reached via the `dm` trigger — and
203
+ // solo-human channels keep the back-and-forth. The router reuses this to
204
+ // decide both sticky suppression and the group-chat prompt nudge, so the two
205
+ // stay in lockstep off one definition.
206
+ export function isMultiHumanGroup(isDm: boolean, effectiveHumans: number): boolean {
207
+ return !isDm && effectiveHumans > 1
208
+ }
209
+
179
210
  function textTargetsAnyPeerBot(text: string, participants: readonly ChannelParticipant[]): boolean {
180
211
  const haystack = text.toLocaleLowerCase()
181
212
  for (const p of participants) {
@@ -0,0 +1,42 @@
1
+ // Decoupled from ChannelRouter on purpose: minting a token for an arbitrary
2
+ // bash `gh` command is adjacent to channels but is not routing, and a global
3
+ // singleton would leak resolver state across tests. One instance is created in
4
+ // run/index.ts and threaded to both the plugin loader and the channel manager.
5
+
6
+ export type GithubTokenResolveResult = { kind: 'token'; token: string } | { kind: 'unavailable'; reason: string }
7
+
8
+ export type ResolveGithubTokenForRepo = (repoSlug: string) => Promise<GithubTokenResolveResult>
9
+
10
+ export type GithubTokenBridge = {
11
+ resolveTokenForRepo: ResolveGithubTokenForRepo
12
+ registerResolver: (resolver: (repoSlug: string) => Promise<string>) => () => void
13
+ }
14
+
15
+ const NO_RESOLVER_REASON =
16
+ 'GitHub App token unavailable; the GitHub channel adapter is not running or failed to start. ' +
17
+ 'Check `typeclaw logs` and `secrets.json#channels.github`.'
18
+
19
+ export function createGithubTokenBridge(): GithubTokenBridge {
20
+ let current: ((repoSlug: string) => Promise<string>) | null = null
21
+
22
+ return {
23
+ resolveTokenForRepo: async (repoSlug) => {
24
+ const resolver = current
25
+ if (resolver === null) return { kind: 'unavailable', reason: NO_RESOLVER_REASON }
26
+ try {
27
+ const token = await resolver(repoSlug)
28
+ return { kind: 'token', token }
29
+ } catch (err) {
30
+ return { kind: 'unavailable', reason: err instanceof Error ? err.message : String(err) }
31
+ }
32
+ },
33
+ registerResolver: (resolver) => {
34
+ current = resolver
35
+ return () => {
36
+ // Only clear if still the active resolver: a stop() racing a newer
37
+ // start() must not wipe the newer registration.
38
+ if (current === resolver) current = null
39
+ }
40
+ },
41
+ }
42
+ }
@@ -1,4 +1,10 @@
1
1
  export { createChannelManager, type ChannelManager, type ChannelManagerOptions } from './manager'
2
+ export {
3
+ createGithubTokenBridge,
4
+ type GithubTokenBridge,
5
+ type GithubTokenResolveResult,
6
+ type ResolveGithubTokenForRepo,
7
+ } from './github-token-bridge'
2
8
  export {
3
9
  createChannelRouter,
4
10
  type ChannelRouter,
@@ -12,6 +12,7 @@ import { createGithubAdapter, type GithubAdapter } from './adapters/github'
12
12
  import { createKakaotalkAdapter, type KakaotalkAdapter } from './adapters/kakaotalk'
13
13
  import { createSlackBotAdapter, type SlackBotAdapter } from './adapters/slack-bot'
14
14
  import { createTelegramBotAdapter, type TelegramBotAdapter } from './adapters/telegram-bot'
15
+ import type { GithubTokenBridge } from './github-token-bridge'
15
16
  import { createChannelRouter, type ChannelRouter, type ClaimHandler, type CreateSessionForChannel } from './router'
16
17
  import {
17
18
  ADAPTER_IDS,
@@ -84,6 +85,10 @@ export type ChannelManagerOptions = {
84
85
  // Production wiring (`src/run/index.ts`) always passes the agent's
85
86
  // Stream; tests typically omit it.
86
87
  stream?: Stream
88
+ // Write-side of the GithubTokenBridge. The github adapter publishes its
89
+ // per-repo App token minter here on start (App auth only) so plugin hooks
90
+ // can resolve a token for ad-hoc `gh` commands. Tests omit it.
91
+ githubTokenBridge?: GithubTokenBridge
87
92
  }
88
93
 
89
94
  export type ChannelManager = {
@@ -199,6 +204,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
199
204
  logger,
200
205
  tunnelUrl: () => options.tunnelUrlForChannel?.('github') ?? null,
201
206
  tunnelConfiguredForChannel: () => options.tunnelConfiguredForChannel?.('github') ?? false,
207
+ ...(options.githubTokenBridge !== undefined ? { githubTokenBridge: options.githubTokenBridge } : {}),
202
208
  })
203
209
  }
204
210
  if (name === 'telegram-bot') {
@@ -21,6 +21,15 @@ export type MembershipCount = {
21
21
  bots: number
22
22
  fetchedAt: number
23
23
  truncated: boolean
24
+ // Identities of the human members, present ONLY when the adapter enumerated
25
+ // the COMPLETE current membership and classified every listed member in the
26
+ // same pass that produced `humans`. When set, `humanMemberIds.length` equals
27
+ // `humans` by construction, so a consumer can prove "every human in the room
28
+ // is X" by resolving each id — something the bare `humans` count cannot do.
29
+ // Left undefined by approximate/truncated/history-derived reads and by
30
+ // adapters that cannot enumerate members (Telegram, KakaoTalk); consumers
31
+ // that need a completeness proof must fail closed when it is absent.
32
+ humanMemberIds?: readonly string[]
24
33
  }
25
34
 
26
35
  export type MembershipResolverFailure = { kind: 'transient' } | { kind: 'permanent' }