typeclaw 0.17.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 (50) 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 +2 -1
  5. package/src/agent/model-overrides.ts +77 -0
  6. package/src/agent/plugin-tools.ts +53 -4
  7. package/src/agent/tools/grant-role.ts +102 -8
  8. package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
  9. package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
  10. package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
  11. package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
  12. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
  13. package/src/channels/adapters/discord-bot-classify.ts +23 -0
  14. package/src/channels/adapters/discord-bot.ts +22 -4
  15. package/src/channels/adapters/github/auth-app.ts +49 -26
  16. package/src/channels/adapters/github/auth-pat.ts +3 -3
  17. package/src/channels/adapters/github/auth.ts +19 -5
  18. package/src/channels/adapters/github/channel-resolver.ts +3 -2
  19. package/src/channels/adapters/github/history.ts +3 -2
  20. package/src/channels/adapters/github/inbound.ts +30 -55
  21. package/src/channels/adapters/github/index.ts +147 -43
  22. package/src/channels/adapters/github/membership.ts +7 -2
  23. package/src/channels/adapters/github/outbound.ts +6 -2
  24. package/src/channels/adapters/github/team-membership.ts +4 -2
  25. package/src/channels/adapters/github/webhook-register.ts +19 -16
  26. package/src/channels/adapters/slack-bot-slash-commands.ts +78 -1
  27. package/src/channels/adapters/slack-bot.ts +119 -18
  28. package/src/channels/commands.ts +10 -0
  29. package/src/channels/engagement.ts +34 -3
  30. package/src/channels/github-token-bridge.ts +42 -0
  31. package/src/channels/index.ts +6 -0
  32. package/src/channels/manager.ts +6 -0
  33. package/src/channels/membership.ts +9 -0
  34. package/src/channels/router.ts +155 -37
  35. package/src/cli/channel.ts +0 -12
  36. package/src/cli/init.ts +0 -9
  37. package/src/cli/ui.ts +6 -0
  38. package/src/commands/index.ts +54 -4
  39. package/src/init/dockerfile.ts +60 -0
  40. package/src/init/github-webhook-install.ts +1 -2
  41. package/src/init/index.ts +4 -10
  42. package/src/init/validate-api-key.ts +15 -1
  43. package/src/plugin/context.ts +8 -0
  44. package/src/plugin/manager.ts +3 -0
  45. package/src/plugin/types.ts +6 -0
  46. package/src/run/bundled-plugins.ts +9 -0
  47. package/src/run/index.ts +6 -0
  48. package/src/secrets/schema.ts +0 -1
  49. package/src/server/command-runner.ts +14 -0
  50. package/src/skills/typeclaw-channel-github/SKILL.md +70 -43
@@ -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
 
@@ -52,7 +51,7 @@ import { slackTsToMillis } from './slack-bot-time'
52
51
  // slash_commands events we route vs drop. The ui.test.ts manifest-drift
53
52
  // test asserts equality between this set and SLACK_APP_MANIFEST.features.
54
53
  // slash_commands so the two can never silently diverge.
55
- export const SLACK_SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(['stop'])
54
+ export const SLACK_SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(['help', 'stop'])
56
55
 
57
56
  // Resolvers fall back to the raw id on failure, so a name equal to the id
58
57
  // means resolution failed; we render the bare id rather than `id(id)`. The
@@ -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
@@ -408,14 +459,14 @@ export function createSlackMembershipResolver(deps: {
408
459
  }
409
460
 
410
461
  let bots = 0
411
- let humans = 0
462
+ const humanMemberIds: string[] = []
412
463
  for (const userId of members.value.members ?? []) {
413
464
  const cached = userBotCache.get(userId)
414
465
  const isBot = cached ?? (await resolveSlackUserIsBot(fetchFn, deps.token, userId, deps.logger, userBotCache))
415
466
  if (isBot) bots++
416
- else humans++
467
+ else humanMemberIds.push(userId)
417
468
  }
418
- return { humans, bots, fetchedAt: now(), truncated: false }
469
+ return { humans: humanMemberIds.length, bots, fetchedAt: now(), truncated: false, humanMemberIds }
419
470
  }
420
471
  }
421
472
 
@@ -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,
@@ -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' }