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.
- package/package.json +1 -1
- package/src/agent/index.ts +9 -1
- package/src/agent/live-subagents.ts +4 -0
- package/src/agent/model-overrides.ts +77 -0
- package/src/agent/plugin-tools.ts +53 -4
- package/src/agent/session-origin.ts +32 -10
- package/src/agent/tools/channel-react.ts +79 -0
- package/src/agent/tools/grant-role.ts +102 -8
- package/src/agent/tools/spawn-subagent.ts +1 -0
- package/src/agent/tools/subagent-access.ts +67 -0
- package/src/agent/tools/subagent-cancel.ts +11 -6
- package/src/agent/tools/subagent-output.ts +10 -2
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
- package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
- package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
- package/src/channels/adapters/discord-bot.ts +242 -7
- package/src/channels/adapters/github/inbound.ts +40 -55
- package/src/channels/adapters/github/index.ts +89 -18
- package/src/channels/adapters/github/membership.ts +4 -0
- package/src/channels/adapters/github/permission-guidance.ts +20 -1
- package/src/channels/adapters/github/reactions.ts +142 -0
- package/src/channels/adapters/slack-bot-slash-commands.ts +3 -1
- package/src/channels/adapters/slack-bot.ts +4 -4
- package/src/channels/commands.ts +10 -0
- package/src/channels/engagement.ts +30 -2
- package/src/channels/github-token-bridge.ts +42 -0
- package/src/channels/index.ts +6 -0
- package/src/channels/manager.ts +6 -0
- package/src/channels/membership.ts +9 -0
- package/src/channels/router.ts +295 -42
- package/src/channels/types.ts +42 -0
- package/src/cli/inspect.ts +3 -0
- package/src/cli/ui.ts +6 -0
- package/src/commands/index.ts +54 -4
- package/src/init/dockerfile.ts +60 -0
- package/src/init/validate-api-key.ts +15 -1
- package/src/inspect/loop.ts +12 -1
- package/src/permissions/permissions.ts +24 -0
- package/src/plugin/context.ts +8 -0
- package/src/plugin/manager.ts +3 -0
- package/src/plugin/types.ts +6 -0
- package/src/run/bundled-plugins.ts +9 -0
- package/src/run/index.ts +4 -0
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/channels/index.ts
CHANGED
|
@@ -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,
|
package/src/channels/manager.ts
CHANGED
|
@@ -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' }
|