typeclaw 0.19.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 +7 -0
- package/src/agent/live-subagents.ts +4 -0
- package/src/agent/session-origin.ts +32 -10
- package/src/agent/tools/channel-react.ts +79 -0
- 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/channels/adapters/discord-bot.ts +238 -20
- package/src/channels/adapters/github/inbound.ts +10 -0
- package/src/channels/adapters/github/index.ts +9 -0
- package/src/channels/adapters/github/permission-guidance.ts +20 -1
- package/src/channels/adapters/github/reactions.ts +142 -0
- package/src/channels/engagement.ts +16 -19
- package/src/channels/router.ts +151 -16
- package/src/channels/types.ts +42 -0
- package/src/cli/inspect.ts +3 -0
- package/src/inspect/loop.ts +12 -1
- package/src/permissions/permissions.ts +24 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +12 -2
|
@@ -4,6 +4,7 @@ import type { InboundMessage } from '@/channels/types'
|
|
|
4
4
|
|
|
5
5
|
import type { DeliveryDedup } from './dedup'
|
|
6
6
|
import { isGithubEventAllowed } from './event-allowlist'
|
|
7
|
+
import { encodeGithubReactionRef, type GithubReactionTarget } from './reactions'
|
|
7
8
|
|
|
8
9
|
export type GithubInboundLogger = { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
|
|
9
10
|
|
|
@@ -109,6 +110,7 @@ export function classifyGithubInbound(
|
|
|
109
110
|
user,
|
|
110
111
|
selfLogin,
|
|
111
112
|
comment.created_at,
|
|
113
|
+
{ kind: 'issue-comment', owner: repository.owner, repo: repository.name, commentId: id },
|
|
112
114
|
)
|
|
113
115
|
}
|
|
114
116
|
|
|
@@ -127,6 +129,7 @@ export function classifyGithubInbound(
|
|
|
127
129
|
readUser(comment.user),
|
|
128
130
|
selfLogin,
|
|
129
131
|
comment.created_at,
|
|
132
|
+
{ kind: 'pr-review-comment', owner: repository.owner, repo: repository.name, commentId: id },
|
|
130
133
|
)
|
|
131
134
|
}
|
|
132
135
|
|
|
@@ -144,6 +147,7 @@ export function classifyGithubInbound(
|
|
|
144
147
|
readUser(comment.user),
|
|
145
148
|
selfLogin,
|
|
146
149
|
comment.created_at,
|
|
150
|
+
null,
|
|
147
151
|
)
|
|
148
152
|
}
|
|
149
153
|
|
|
@@ -160,6 +164,7 @@ export function classifyGithubInbound(
|
|
|
160
164
|
readUser(issue.user),
|
|
161
165
|
selfLogin,
|
|
162
166
|
issue.created_at,
|
|
167
|
+
{ kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
|
|
163
168
|
)
|
|
164
169
|
}
|
|
165
170
|
|
|
@@ -189,6 +194,7 @@ export function classifyGithubInbound(
|
|
|
189
194
|
readUser(pr.user),
|
|
190
195
|
selfLogin,
|
|
191
196
|
pr.created_at,
|
|
197
|
+
{ kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
|
|
192
198
|
)
|
|
193
199
|
}
|
|
194
200
|
|
|
@@ -206,6 +212,7 @@ export function classifyGithubInbound(
|
|
|
206
212
|
readUser(review.user),
|
|
207
213
|
selfLogin,
|
|
208
214
|
review.submitted_at,
|
|
215
|
+
null,
|
|
209
216
|
)
|
|
210
217
|
}
|
|
211
218
|
|
|
@@ -222,6 +229,7 @@ export function classifyGithubInbound(
|
|
|
222
229
|
readUser(discussion.user),
|
|
223
230
|
selfLogin,
|
|
224
231
|
discussion.created_at,
|
|
232
|
+
null,
|
|
225
233
|
)
|
|
226
234
|
}
|
|
227
235
|
|
|
@@ -340,6 +348,7 @@ function buildInbound(
|
|
|
340
348
|
user: GithubUser | null,
|
|
341
349
|
selfLogin: string | null,
|
|
342
350
|
rawTs: unknown,
|
|
351
|
+
reactionTarget: GithubReactionTarget | null,
|
|
343
352
|
): InboundMessage | null {
|
|
344
353
|
if (user === null) return null
|
|
345
354
|
const text = typeof rawText === 'string' ? rawText : ''
|
|
@@ -347,6 +356,7 @@ function buildInbound(
|
|
|
347
356
|
...key,
|
|
348
357
|
text,
|
|
349
358
|
externalMessageId: String(id),
|
|
359
|
+
...(reactionTarget !== null ? { reactionRef: encodeGithubReactionRef(reactionTarget) } : {}),
|
|
350
360
|
authorId: String(user.id),
|
|
351
361
|
authorName: user.login,
|
|
352
362
|
authorIsBot: user.type === 'Bot',
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
buildPermissionGuidance,
|
|
20
20
|
parseListHooksPermissionStatus,
|
|
21
21
|
} from './permission-guidance'
|
|
22
|
+
import { createGithubReactionCallback } from './reactions'
|
|
22
23
|
import { createTeamMembershipChecker } from './team-membership'
|
|
23
24
|
import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
|
|
24
25
|
|
|
@@ -119,6 +120,11 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
119
120
|
logger,
|
|
120
121
|
fetchImpl,
|
|
121
122
|
})
|
|
123
|
+
const reaction = createGithubReactionCallback({
|
|
124
|
+
token: authToken,
|
|
125
|
+
authType: options.secrets.auth.type,
|
|
126
|
+
fetchImpl,
|
|
127
|
+
})
|
|
122
128
|
const history = createGithubHistoryCallback({
|
|
123
129
|
token: authToken,
|
|
124
130
|
fetchImpl,
|
|
@@ -161,6 +167,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
161
167
|
// Register all callbacks before binding the HTTP listener so the router
|
|
162
168
|
// is fully wired before any webhook can arrive.
|
|
163
169
|
options.router.registerOutbound('github', outbound)
|
|
170
|
+
options.router.registerReaction('github', reaction)
|
|
164
171
|
options.router.registerTyping('github', typing)
|
|
165
172
|
options.router.registerHistory('github', history)
|
|
166
173
|
options.router.registerMembership('github', membership)
|
|
@@ -172,6 +179,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
172
179
|
// Listener failed — roll back all registrations so stop() is a no-op
|
|
173
180
|
// and the manager can report the failure cleanly.
|
|
174
181
|
options.router.unregisterOutbound('github', outbound)
|
|
182
|
+
options.router.unregisterReaction('github', reaction)
|
|
175
183
|
options.router.unregisterTyping('github', typing)
|
|
176
184
|
options.router.unregisterHistory('github', history)
|
|
177
185
|
options.router.unregisterMembership('github', membership)
|
|
@@ -292,6 +300,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
292
300
|
if (!started) return
|
|
293
301
|
started = false
|
|
294
302
|
options.router.unregisterOutbound('github', outbound)
|
|
303
|
+
options.router.unregisterReaction('github', reaction)
|
|
295
304
|
options.router.unregisterTyping('github', typing)
|
|
296
305
|
options.router.unregisterHistory('github', history)
|
|
297
306
|
options.router.unregisterMembership('github', membership)
|
|
@@ -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
|
+
}
|
|
@@ -81,25 +81,23 @@ 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
|
-
//
|
|
85
|
-
// solo
|
|
86
|
-
// excluded — a 1-human-N-bot room is still "solo" for engagement purposes.
|
|
84
|
+
// Peer bots are excluded from the count — a 1-human-N-bot room is still
|
|
85
|
+
// "solo" for the fallback at the bottom.
|
|
87
86
|
const effectiveHumans = countEffectiveHumans(participants, input.membership, now)
|
|
88
|
-
const multiHumanGroup = isMultiHumanGroup(message.isDm, effectiveHumans)
|
|
89
87
|
|
|
90
88
|
if (config.trigger.includes('dm') && message.isDm) return 'engage'
|
|
91
89
|
if (config.trigger.includes('mention') && message.isBotMention) return 'engage'
|
|
92
90
|
if (config.trigger.includes('reply') && message.replyToBotMessageId !== null) return 'engage'
|
|
93
91
|
|
|
94
|
-
// Sticky credit
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
if (config.stickiness !== 'off' && ledger.consume(key, message.authorId, now)
|
|
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.
|
|
100
|
+
if (config.stickiness !== 'off' && ledger.consume(key, message.authorId, now)) {
|
|
103
101
|
return 'engage'
|
|
104
102
|
}
|
|
105
103
|
|
|
@@ -197,12 +195,11 @@ export function countEffectiveHumans(
|
|
|
197
195
|
return resolveEffectiveHumans(persistedHumans, membership, now)
|
|
198
196
|
}
|
|
199
197
|
|
|
200
|
-
// A multi-human group is
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
// stay in lockstep off one definition.
|
|
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.
|
|
206
203
|
export function isMultiHumanGroup(isDm: boolean, effectiveHumans: number): boolean {
|
|
207
204
|
return !isDm && effectiveHumans > 1
|
|
208
205
|
}
|