typeclaw 0.18.0 → 0.20.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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +9 -1
  3. package/src/agent/live-subagents.ts +4 -0
  4. package/src/agent/model-overrides.ts +77 -0
  5. package/src/agent/plugin-tools.ts +53 -4
  6. package/src/agent/session-origin.ts +32 -10
  7. package/src/agent/tools/channel-react.ts +79 -0
  8. package/src/agent/tools/grant-role.ts +102 -8
  9. package/src/agent/tools/spawn-subagent.ts +1 -0
  10. package/src/agent/tools/subagent-access.ts +67 -0
  11. package/src/agent/tools/subagent-cancel.ts +11 -6
  12. package/src/agent/tools/subagent-output.ts +10 -2
  13. package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
  14. package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
  15. package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
  16. package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
  17. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
  18. package/src/channels/adapters/discord-bot.ts +242 -7
  19. package/src/channels/adapters/github/inbound.ts +40 -55
  20. package/src/channels/adapters/github/index.ts +89 -18
  21. package/src/channels/adapters/github/membership.ts +4 -0
  22. package/src/channels/adapters/github/permission-guidance.ts +20 -1
  23. package/src/channels/adapters/github/reactions.ts +142 -0
  24. package/src/channels/adapters/slack-bot-slash-commands.ts +3 -1
  25. package/src/channels/adapters/slack-bot.ts +4 -4
  26. package/src/channels/commands.ts +10 -0
  27. package/src/channels/engagement.ts +30 -2
  28. package/src/channels/github-token-bridge.ts +42 -0
  29. package/src/channels/index.ts +6 -0
  30. package/src/channels/manager.ts +6 -0
  31. package/src/channels/membership.ts +9 -0
  32. package/src/channels/router.ts +295 -42
  33. package/src/channels/types.ts +42 -0
  34. package/src/cli/inspect.ts +3 -0
  35. package/src/cli/ui.ts +6 -0
  36. package/src/commands/index.ts +54 -4
  37. package/src/init/dockerfile.ts +60 -0
  38. package/src/init/validate-api-key.ts +15 -1
  39. package/src/inspect/loop.ts +12 -1
  40. package/src/permissions/permissions.ts +24 -0
  41. package/src/plugin/context.ts +8 -0
  42. package/src/plugin/manager.ts +3 -0
  43. package/src/plugin/types.ts +6 -0
  44. package/src/run/bundled-plugins.ts +9 -0
  45. package/src/run/index.ts +4 -0
  46. package/src/skills/typeclaw-channel-github/SKILL.md +80 -43
@@ -6,7 +6,12 @@ export type GithubAuthType = 'pat' | 'app'
6
6
  // permission-failure response. Each value maps to a distinct GitHub App
7
7
  // permission family (and, for PATs, a distinct scope), so each surfaces a
8
8
  // different remediation message.
9
- export type OutboundEndpointKind = 'issue-comment' | 'pr-review-reply' | 'discussion-comment'
9
+ export type OutboundEndpointKind =
10
+ | 'issue-comment'
11
+ | 'pr-review-reply'
12
+ | 'discussion-comment'
13
+ | 'issue-reaction'
14
+ | 'pr-review-comment-reaction'
10
15
 
11
16
  // Parses webhook-register errors of the shape `list hooks failed: <status> <body>`.
12
17
  // Returns the status code when it matches the two shapes GitHub emits for
@@ -127,6 +132,20 @@ const OUTBOUND_PERMISSION_FOR_KIND: Record<
127
132
  patScope: 'repo',
128
133
  patFineGrained: 'Discussions',
129
134
  },
135
+ // Reactions on an issue/PR body or an issue comment go through the Issues
136
+ // permission family; reactions on a PR review comment go through Pull requests.
137
+ 'issue-reaction': {
138
+ label: 'Issues',
139
+ level: 'Read and write',
140
+ patScope: 'repo (or public_repo for public repos)',
141
+ patFineGrained: 'Issues',
142
+ },
143
+ 'pr-review-comment-reaction': {
144
+ label: 'Pull requests',
145
+ level: 'Read and write',
146
+ patScope: 'repo',
147
+ patFineGrained: 'Pull requests',
148
+ },
130
149
  }
131
150
 
132
151
  // Decorate an outbound-API failure with the precise github.com permission a
@@ -0,0 +1,142 @@
1
+ import type { ReactionCallback, ReactionErrorCode, ReactionRef, ReactionResult } from '@/channels/types'
2
+
3
+ import type { GithubAuthContext } from './auth'
4
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
5
+ import {
6
+ buildOutboundPermissionGuidance,
7
+ type GithubAuthType,
8
+ isOutboundPermissionDenial,
9
+ type OutboundEndpointKind,
10
+ } from './permission-guidance'
11
+
12
+ // The reactable target, distinguished by the webhook event the inbound came
13
+ // from. The router collapses every github inbound to the same `chat`/
14
+ // `externalMessageId` pair, so this kind — known only at classification time —
15
+ // is what selects the right Reactions endpoint. `issue` covers both issue and
16
+ // PR bodies (GitHub models a PR body as an issue for reactions); `discussion`
17
+ // is unsupported until the GraphQL `addReaction` path lands, so the classifier
18
+ // does not stamp it today.
19
+ export type GithubReactionTarget =
20
+ | { kind: 'issue'; owner: string; repo: string; issueNumber: number }
21
+ | { kind: 'issue-comment'; owner: string; repo: string; commentId: number }
22
+ | { kind: 'pr-review-comment'; owner: string; repo: string; commentId: number }
23
+
24
+ export function encodeGithubReactionRef(target: GithubReactionTarget): ReactionRef {
25
+ return { adapter: 'github', value: JSON.stringify(target) }
26
+ }
27
+
28
+ export function decodeGithubReactionRef(ref: ReactionRef): GithubReactionTarget | null {
29
+ if (ref.adapter !== 'github') return null
30
+ let parsed: unknown
31
+ try {
32
+ parsed = JSON.parse(ref.value)
33
+ } catch {
34
+ return null
35
+ }
36
+ if (typeof parsed !== 'object' || parsed === null) return null
37
+ const t = parsed as Record<string, unknown>
38
+ const owner = typeof t.owner === 'string' ? t.owner : null
39
+ const repo = typeof t.repo === 'string' ? t.repo : null
40
+ if (owner === null || repo === null) return null
41
+ if (t.kind === 'issue' && typeof t.issueNumber === 'number') {
42
+ return { kind: 'issue', owner, repo, issueNumber: t.issueNumber }
43
+ }
44
+ if ((t.kind === 'issue-comment' || t.kind === 'pr-review-comment') && typeof t.commentId === 'number') {
45
+ return { kind: t.kind, owner, repo, commentId: t.commentId }
46
+ }
47
+ return null
48
+ }
49
+
50
+ // GitHub's Reactions API takes a fixed vocabulary of content strings. Map the
51
+ // adapter-generic emoji name onto it; anything outside the set is reported as
52
+ // `unsupported` so the model gets a clear signal rather than a silent 422.
53
+ const REACTION_CONTENT: Record<string, string> = {
54
+ eyes: 'eyes',
55
+ '+1': '+1',
56
+ thumbsup: '+1',
57
+ '-1': '-1',
58
+ thumbsdown: '-1',
59
+ laugh: 'laugh',
60
+ hooray: 'hooray',
61
+ tada: 'hooray',
62
+ confused: 'confused',
63
+ heart: 'heart',
64
+ rocket: 'rocket',
65
+ }
66
+
67
+ export function createGithubReactionCallback(deps: {
68
+ token: (context?: GithubAuthContext) => Promise<string>
69
+ authType: GithubAuthType
70
+ fetchImpl?: typeof fetch
71
+ }): ReactionCallback {
72
+ const fetchImpl = deps.fetchImpl ?? fetch
73
+ return async (req): Promise<ReactionResult> => {
74
+ if (req.adapter !== 'github') return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
75
+ const content = REACTION_CONTENT[req.emoji.replace(/^:|:$/g, '')]
76
+ if (content === undefined) {
77
+ return { ok: false, error: `github does not support reaction "${req.emoji}"`, code: 'unsupported' }
78
+ }
79
+ const target = decodeGithubReactionRef(req.reactionRef)
80
+ if (target === null) return { ok: false, error: 'unparseable github reaction ref', code: 'unsupported' }
81
+
82
+ const endpoint = reactionEndpoint(target)
83
+ const endpointKind: OutboundEndpointKind =
84
+ target.kind === 'pr-review-comment' ? 'pr-review-comment-reaction' : 'issue-reaction'
85
+ return await postReaction(fetchImpl, await deps.token({ repoSlug: `${target.owner}/${target.repo}` }), endpoint, {
86
+ content,
87
+ authType: deps.authType,
88
+ endpointKind,
89
+ })
90
+ }
91
+ }
92
+
93
+ function reactionEndpoint(target: GithubReactionTarget): string {
94
+ const base = `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}`
95
+ switch (target.kind) {
96
+ case 'issue':
97
+ return `${base}/issues/${target.issueNumber}/reactions`
98
+ case 'issue-comment':
99
+ return `${base}/issues/comments/${target.commentId}/reactions`
100
+ case 'pr-review-comment':
101
+ return `${base}/pulls/comments/${target.commentId}/reactions`
102
+ }
103
+ }
104
+
105
+ async function postReaction(
106
+ fetchImpl: typeof fetch,
107
+ token: string,
108
+ url: string,
109
+ options: { content: string; authType: GithubAuthType; endpointKind: OutboundEndpointKind },
110
+ ): Promise<ReactionResult> {
111
+ let response: Response
112
+ try {
113
+ response = await fetchImpl(url, {
114
+ method: 'POST',
115
+ headers: githubJsonHeaders(token),
116
+ body: JSON.stringify({ content: options.content }),
117
+ })
118
+ } catch (err) {
119
+ return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
120
+ }
121
+ // 201 = reaction created, 200 = the actor already left this same reaction.
122
+ // Both are success: an :eyes: that's already there is the desired end state,
123
+ // so a duplicate webhook delivery (or a retried engage) must not surface an error.
124
+ if (response.status === 200 || response.status === 201) return { ok: true }
125
+ const text = await response.text().catch(() => '')
126
+ const baseError = `GitHub API ${response.status}${text !== '' ? `: ${text}` : ''}`
127
+ if (isOutboundPermissionDenial(response.status, text)) {
128
+ return {
129
+ ok: false,
130
+ error: `${baseError}${buildOutboundPermissionGuidance({ authType: options.authType, endpointKind: options.endpointKind })}`,
131
+ code: 'permission-denied',
132
+ }
133
+ }
134
+ return { ok: false, error: baseError, code: classifyStatus(response.status) }
135
+ }
136
+
137
+ function classifyStatus(status: number): ReactionErrorCode {
138
+ if (status === 403) return 'permission-denied'
139
+ if (status === 404) return 'not-found'
140
+ if (status === 429) return 'rate-limited'
141
+ return 'transient'
142
+ }
@@ -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,10 +81,22 @@ 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
+ // Peer bots are excluded from the count — a 1-human-N-bot room is still
85
+ // "solo" for the fallback at the bottom.
86
+ const effectiveHumans = countEffectiveHumans(participants, input.membership, now)
87
+
84
88
  if (config.trigger.includes('dm') && message.isDm) return 'engage'
85
89
  if (config.trigger.includes('mention') && message.isBotMention) return 'engage'
86
90
  if (config.trigger.includes('reply') && message.replyToBotMessageId !== null) return 'engage'
87
91
 
92
+ // Sticky credit force-engages in EVERY context (groups included) for the
93
+ // full window. This gate is deliberately content-blind: it answers "am I in
94
+ // an active conversation with this author?", not "does THIS message need a
95
+ // reply?" — a boolean over membership cannot tell "where did you send it?"
96
+ // (reply) from "lol ok" (chatter). Selectivity is the MODEL's job: engaged
97
+ // group turns get a `composeTurnPrompt` nudge (keyed off `isMultiHumanGroup`)
98
+ // to answer real follow-ups and `NO_REPLY` chatter. Gating sticky off in
99
+ // groups instead (the prior approach) dropped genuine follow-ups outright.
88
100
  if (config.stickiness !== 'off' && ledger.consume(key, message.authorId, now)) {
89
101
  return 'engage'
90
102
  }
@@ -169,13 +181,29 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
169
181
  // peer's first message it's caught forever.
170
182
  if (textTargetsAnyPeerBot(message.text, participants)) return 'observe'
171
183
 
172
- const persistedHumans = participants.filter((p) => p.isBot !== true).length
173
- const effectiveHumans = resolveEffectiveHumans(persistedHumans, input.membership, now)
174
184
  if (effectiveHumans <= 1 && !message.authorIsBot) return 'engage'
175
185
 
176
186
  return 'observe'
177
187
  }
178
188
 
189
+ export function countEffectiveHumans(
190
+ participants: readonly ChannelParticipant[],
191
+ membership: MembershipCount | null,
192
+ now: number,
193
+ ): number {
194
+ const persistedHumans = participants.filter((p) => p.isBot !== true).length
195
+ return resolveEffectiveHumans(persistedHumans, membership, now)
196
+ }
197
+
198
+ // A multi-human group is where the prompt's default "answer everything"
199
+ // eagerness needs tempering. The router reads this in `route()` to decide
200
+ // whether to append the group-chat nudge that tells the model to be selective
201
+ // (answer real follow-ups, `NO_REPLY` chatter) on its engaged turns. DMs and
202
+ // solo-human channels skip the nudge — there, replying to everything is right.
203
+ export function isMultiHumanGroup(isDm: boolean, effectiveHumans: number): boolean {
204
+ return !isDm && effectiveHumans > 1
205
+ }
206
+
179
207
  function textTargetsAnyPeerBot(text: string, participants: readonly ChannelParticipant[]): boolean {
180
208
  const haystack = text.toLocaleLowerCase()
181
209
  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' }