typeclaw 0.16.0 → 0.18.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 (45) 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 +32 -1
  5. package/src/agent/session-origin.ts +54 -12
  6. package/src/agent/system-prompt.ts +1 -1
  7. package/src/agent/tools/grant-role.ts +214 -0
  8. package/src/channels/adapters/discord-bot-classify.ts +23 -0
  9. package/src/channels/adapters/discord-bot.ts +1 -0
  10. package/src/channels/adapters/github/auth-app.ts +49 -26
  11. package/src/channels/adapters/github/auth-pat.ts +3 -3
  12. package/src/channels/adapters/github/auth.ts +19 -5
  13. package/src/channels/adapters/github/channel-resolver.ts +3 -2
  14. package/src/channels/adapters/github/history.ts +3 -2
  15. package/src/channels/adapters/github/index.ts +85 -43
  16. package/src/channels/adapters/github/membership.ts +3 -2
  17. package/src/channels/adapters/github/outbound.ts +6 -2
  18. package/src/channels/adapters/github/team-membership.ts +4 -2
  19. package/src/channels/adapters/github/webhook-register.ts +19 -16
  20. package/src/channels/adapters/slack-bot-slash-commands.ts +76 -1
  21. package/src/channels/adapters/slack-bot.ts +115 -14
  22. package/src/channels/router.ts +87 -17
  23. package/src/cli/channel.ts +0 -12
  24. package/src/cli/init.ts +0 -9
  25. package/src/cli/role.ts +10 -1
  26. package/src/cli/ui.ts +6 -4
  27. package/src/config/reloadable.ts +10 -3
  28. package/src/init/github-webhook-install.ts +1 -2
  29. package/src/init/index.ts +9 -43
  30. package/src/init/run-owner-claim.ts +21 -3
  31. package/src/permissions/builtins.ts +14 -4
  32. package/src/permissions/grant.ts +92 -16
  33. package/src/permissions/index.ts +8 -2
  34. package/src/permissions/permissions.ts +9 -0
  35. package/src/permissions/resolve.ts +10 -0
  36. package/src/role-claim/index.ts +1 -0
  37. package/src/role-claim/reload-after-claim.ts +34 -0
  38. package/src/run/channel-session-factory.ts +6 -1
  39. package/src/run/index.ts +20 -1
  40. package/src/sandbox/build.ts +32 -0
  41. package/src/secrets/schema.ts +0 -1
  42. package/src/server/command-runner.ts +14 -0
  43. package/src/skills/typeclaw-channel-github/SKILL.md +15 -3
  44. package/src/skills/typeclaw-permissions/SKILL.md +11 -3
  45. package/src/skills/typeclaw-skills/SKILL.md +3 -1
package/auth.schema.json CHANGED
@@ -213,11 +213,6 @@
213
213
  }
214
214
  }
215
215
  ]
216
- },
217
- "installationId": {
218
- "type": "integer",
219
- "exclusiveMinimum": 0,
220
- "maximum": 9007199254740991
221
216
  }
222
217
  },
223
218
  "required": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -46,7 +46,7 @@
46
46
  "@mariozechner/pi-coding-agent": "^0.67.3",
47
47
  "@mariozechner/pi-tui": "^0.67.3",
48
48
  "@mozilla/readability": "^0.6.0",
49
- "agent-messenger": "2.19.0",
49
+ "agent-messenger": "2.19.1",
50
50
  "cheerio": "^1.2.0",
51
51
  "citty": "^0.2.2",
52
52
  "cron-parser": "^5.5.0",
@@ -213,11 +213,6 @@
213
213
  }
214
214
  }
215
215
  ]
216
- },
217
- "installationId": {
218
- "type": "integer",
219
- "exclusiveMinimum": 0,
220
- "maximum": 9007199254740991
221
216
  }
222
217
  },
223
218
  "required": [
@@ -9,7 +9,7 @@ import { loadMemory } from '@/bundled-plugins/memory/load-memory'
9
9
  import type { ChannelRouter } from '@/channels/router'
10
10
  import { getConfig, resolveModel, resolveProfile } from '@/config'
11
11
  import { defaultThinkingLevelForRef, providerForModelRef, type KnownModelRef } from '@/config/providers'
12
- import type { PermissionService } from '@/permissions'
12
+ import type { PermissionService, RolesConfig } from '@/permissions'
13
13
  import type {
14
14
  BuiltinToolRef,
15
15
  HookBus,
@@ -50,6 +50,7 @@ import { createChannelFetchAttachmentTool } from './tools/channel-fetch-attachme
50
50
  import { createChannelHistoryTool } from './tools/channel-history'
51
51
  import { createChannelReplyTool } from './tools/channel-reply'
52
52
  import { createChannelSendTool } from './tools/channel-send'
53
+ import { createGrantRoleTool } from './tools/grant-role'
53
54
  import { createRestartTool } from './tools/restart'
54
55
  import { createSkipResponseTool } from './tools/skip-response'
55
56
  import { createSpawnSubagentTool } from './tools/spawn-subagent'
@@ -141,6 +142,11 @@ export type CreateSessionOptions = {
141
142
  // prompt is not regenerated; see `typeclaw-permissions` skill for how the
142
143
  // agent should interpret the snapshot on later turns.
143
144
  permissions?: PermissionService
145
+ // Re-reads roles from disk for the grant_role tool's hot-reload after a match
146
+ // grant. Production threads a reload-then-read (reloadConfig + getConfig);
147
+ // must not be an in-memory snapshot or the grant reapplies stale roles.
148
+ // Omitted when no grant_role tool is wired (the tool requires permissions).
149
+ reloadRoles?: () => RolesConfig | undefined
144
150
  // Model profile name. Resolved against `config.models` to pick the concrete
145
151
  // model ref this session binds to. Unknown profile names fall back to
146
152
  // `default` with a one-time console warning. Omitted → `default`. Threaded
@@ -322,6 +328,12 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
322
328
  permissions: options.permissions,
323
329
  stream: options.stream,
324
330
  }),
331
+ ...buildRoleGrantTools({
332
+ agentDir: options.plugins?.agentDir,
333
+ getOrigin,
334
+ permissions: options.permissions,
335
+ reloadRoles: options.reloadRoles,
336
+ }),
325
337
  ]
326
338
  // Hook coverage for pi's builtin coding tools (read/bash/edit/write/grep/
327
339
  // find/ls) — pi 0.67.3 ignores `tools:` for implementation, so the only
@@ -577,6 +589,25 @@ export function buildSubagentOrchestrationTools(opts: {
577
589
  ]
578
590
  }
579
591
 
592
+ export function buildRoleGrantTools(opts: {
593
+ agentDir: string | undefined
594
+ getOrigin: () => SessionOrigin | undefined
595
+ permissions: PermissionService | undefined
596
+ reloadRoles: (() => RolesConfig | undefined) | undefined
597
+ }): ToolDefinition[] {
598
+ if (opts.agentDir === undefined || opts.permissions === undefined || opts.reloadRoles === undefined) {
599
+ return []
600
+ }
601
+ return [
602
+ createGrantRoleTool({
603
+ agentDir: opts.agentDir,
604
+ getOrigin: opts.getOrigin,
605
+ permissions: opts.permissions,
606
+ reloadRoles: opts.reloadRoles,
607
+ }),
608
+ ]
609
+ }
610
+
580
611
  function wrapRegistryTools(
581
612
  plugins: PluginSessionWiring | undefined,
582
613
  getOrigin: () => SessionOrigin | undefined,
@@ -111,11 +111,12 @@ function getPlatformInfo(adapter: AdapterId): PlatformInfo {
111
111
  // TUI is always `owner` by construction — annotating it would add noise to
112
112
  // every interactive session for zero new information.
113
113
  //
114
- // For channel sessions this is a session-creation snapshot. The router
115
- // re-resolves per-turn for tool gating, but the system prompt is not
116
- // regenerated mid-session; the role line is accurate at admission and the
117
- // `typeclaw-permissions` skill spells out how to interpret it on later
118
- // turns when a different speaker may have spoken last.
114
+ // Channel origins do NOT render this concrete role. A channel session is
115
+ // keyed by chat/thread, so the opener's role is wrong for every later
116
+ // speaker and printing their permission list leaks it into shared context.
117
+ // Channel origins render renderChannelRolePolicy() instead, and the
118
+ // authoritative per-turn role rides in the non-cacheable `<your-role>`
119
+ // turn anchor (renderTurnRoleAnchor in system-prompt.ts).
119
120
  export type SessionRoleContext = {
120
121
  role: string
121
122
  permissions: readonly string[]
@@ -128,21 +129,22 @@ export function renderSessionOrigin(
128
129
  ): string {
129
130
  switch (origin.kind) {
130
131
  case 'tui':
131
- return withRoleContext(renderTuiOrigin(), roleContext)
132
+ return withRoleContext(renderTuiOrigin(), roleContext, origin.kind)
132
133
  case 'cron':
133
- return withRoleContext(renderCronOrigin(origin), roleContext)
134
+ return withRoleContext(renderCronOrigin(origin), roleContext, origin.kind)
134
135
  case 'channel':
135
- return withRoleContext(renderChannelOrigin(origin, now), roleContext)
136
+ return withRoleContext(renderChannelOrigin(origin, now), roleContext, origin.kind)
136
137
  case 'subagent':
137
- return withRoleContext(renderSubagentOrigin(origin), roleContext)
138
+ return withRoleContext(renderSubagentOrigin(origin), roleContext, origin.kind)
138
139
  case 'system':
139
- return withRoleContext(renderSystemOrigin(origin), roleContext)
140
+ return withRoleContext(renderSystemOrigin(origin), roleContext, origin.kind)
140
141
  }
141
142
  }
142
143
 
143
- function withRoleContext(block: string, ctx: SessionRoleContext | undefined): string {
144
+ function withRoleContext(block: string, ctx: SessionRoleContext | undefined, kind: SessionOrigin['kind']): string {
144
145
  if (ctx === undefined) return block
145
- return `${block}\n\n${renderRoleContext(ctx)}`
146
+ const roleBlock = kind === 'channel' ? renderChannelRolePolicy() : renderRoleContext(ctx)
147
+ return `${block}\n\n${roleBlock}`
146
148
  }
147
149
 
148
150
  function renderRoleContext(ctx: SessionRoleContext): string {
@@ -160,6 +162,30 @@ function renderRoleContext(ctx: SessionRoleContext): string {
160
162
  ].join('\n')
161
163
  }
162
164
 
165
+ // Channel sessions are keyed by chat/thread, not by author: one session can see
166
+ // many speakers with different roles. Rendering the opener's concrete role here
167
+ // would (1) be wrong for every later speaker and (2) leak the opener's full
168
+ // permission list into shared context. So channel origins get a cache-stable
169
+ // policy instead of a resolved identity; the authoritative per-turn role rides
170
+ // in the non-cacheable `<your-role>` turn anchor.
171
+ function renderChannelRolePolicy(): string {
172
+ return [
173
+ '## Your role in this session',
174
+ '',
175
+ 'This is a channel conversation that may include multiple speakers. Do not',
176
+ 'assume one speaker’s role applies to later messages. For each user turn the',
177
+ 'current speaker’s effective role is provided in the turn context as a',
178
+ '`<your-role>` tag; that per-turn role is authoritative for the current',
179
+ 'message and overrides any role implied by session-opening context. An absent',
180
+ '`<your-role>` tag means the current speaker is the unconstrained default.',
181
+ '',
182
+ 'Tool calls and channel admission are gated by the current speaker’s',
183
+ 'permissions; a `blocked:` or "denied by permissions" message means that',
184
+ 'speaker lacks the permission the guard wanted. See the',
185
+ '`typeclaw-permissions` skill for what each role can do.',
186
+ ].join('\n')
187
+ }
188
+
163
189
  function renderTuiOrigin(): string {
164
190
  return [
165
191
  '## Session origin',
@@ -262,6 +288,22 @@ function renderChannelOrigin(
262
288
  'is a tool call. Plain-text output is invisible.',
263
289
  ]
264
290
 
291
+ // GitHub has no separate "chat" surface — channel_reply IS a public comment
292
+ // on this PR/issue. Without saying so, models default to the Slack-style
293
+ // two-surface split and post operator-facing meta-commentary ("Posted review
294
+ // result for PR #511") straight into the PR thread, where it reads absurdly.
295
+ if (origin.adapter === 'github') {
296
+ lines.push(
297
+ '',
298
+ '**`channel_reply` posts a public comment directly on this PR/issue.** It',
299
+ 'is not a side-report to an operator — the reply lands in this exact',
300
+ 'thread, read by everyone on the PR. Write the substance for that',
301
+ 'audience: post the answer (or review summary) itself, never a status',
302
+ 'line about having posted it elsewhere. A narrated "Posted review result',
303
+ 'for PR #N: …" inside the PR is exactly the failure to avoid.',
304
+ )
305
+ }
306
+
265
307
  const conversationLine = renderConversationLine(origin)
266
308
  if (conversationLine !== null) lines.push('', conversationLine)
267
309
 
@@ -173,7 +173,7 @@ export function renderTurnTimeAnchor(now: Date = new Date()): string {
173
173
  // block for a TUI owner.
174
174
  export function renderTurnRoleAnchor(role: string): string | undefined {
175
175
  if (role === 'owner') return undefined
176
- return `<your-role>${role}</your-role>`
176
+ return `<your-role authority="current-speaker">${role}</your-role> (authoritative for this message; overrides any role implied by the system prompt)`
177
177
  }
178
178
 
179
179
  // Compact replacement for DEFAULT_SYSTEM_PROMPT, used by non-interactive
@@ -0,0 +1,214 @@
1
+ import { Type } from '@mariozechner/pi-ai'
2
+ import { defineTool } from '@mariozechner/pi-coding-agent'
3
+
4
+ import {
5
+ grantRole,
6
+ grantRolePermission,
7
+ isDmChannelOrigin,
8
+ parseMatchRule,
9
+ type PermissionService,
10
+ type RolesConfig,
11
+ } from '@/permissions'
12
+
13
+ import type { SessionOrigin } from '../session-origin'
14
+
15
+ export type GrantRoleToolDetails =
16
+ | { ok: true; mode: 'match' | 'permission'; role: string; value: string; added: boolean; restartRequired: boolean }
17
+ | { ok: false; error: string }
18
+
19
+ export type CreateGrantRoleToolOptions = {
20
+ agentDir: string
21
+ getOrigin: () => SessionOrigin | undefined
22
+ permissions: PermissionService
23
+ // Re-reads roles FROM DISK and returns the fresh set, for hot-reloading a
24
+ // match-grant after grantRole writes typeclaw.json. Must NOT read an
25
+ // in-memory config snapshot: grantRole writes the file directly, so a
26
+ // snapshot taken before the live config pointer is reloaded would be stale
27
+ // and replaceRoles would reapply the pre-grant table. Production wires this
28
+ // to reloadConfig(agentDir) + getConfig().roles, matching the config
29
+ // reloadable. A permission-grant is restart-required, so the reload has no
30
+ // runtime effect for it, but we reload anyway to keep a same-session
31
+ // subsequent match-grant reading a consistent table.
32
+ reloadRoles: () => RolesConfig | undefined
33
+ }
34
+
35
+ // Roles this tool may target, lowest to highest. `guest` is a valid target
36
+ // (an operator opening guest channel.respond) but never a granter — the gate
37
+ // below requires the caller to resolve to owner/trusted.
38
+ const TIER_ORDER = ['guest', 'member', 'trusted', 'owner'] as const
39
+ type TierRole = (typeof TIER_ORDER)[number]
40
+
41
+ function tierOf(role: string): number {
42
+ return TIER_ORDER.indexOf(role as TierRole)
43
+ }
44
+
45
+ function isTierRole(role: string): role is TierRole {
46
+ return (TIER_ORDER as readonly string[]).includes(role)
47
+ }
48
+
49
+ // A single-principal turn carries only the principal's own first-party words:
50
+ // the TUI (a human typing directly) or a 1:1 DM (principal + bot, no
51
+ // third-party messages buffered in). A group/open channel turn always mixes in
52
+ // other authors' messages, which is the confused-deputy surface that lets a
53
+ // guest prompt-inject a trusted turn into rewriting the access-control table.
54
+ // Role grants are confined to single-principal turns so that surface does not
55
+ // exist.
56
+ function isSinglePrincipalOrigin(origin: SessionOrigin | undefined): boolean {
57
+ if (origin === undefined) return false
58
+ if (origin.kind === 'tui') return true
59
+ if (origin.kind === 'channel') return isDmChannelOrigin(origin)
60
+ return false
61
+ }
62
+
63
+ export function createGrantRoleTool(options: CreateGrantRoleToolOptions) {
64
+ const { agentDir, getOrigin, permissions, reloadRoles } = options
65
+
66
+ return defineTool({
67
+ name: 'grant_role',
68
+ label: 'Grant Role',
69
+ description:
70
+ 'Assign an author to a role (match grant) or give a role a capability (permission grant), by editing typeclaw.json#roles. ' +
71
+ 'Use this to onboard a teammate ("respond to author U_X" → grant them member) or to open the agent to a wider audience ' +
72
+ '("let anyone in this channel message you" → grant guest channel.respond). ' +
73
+ 'Only callable from the TUI or a 1:1 DM by an owner or trusted user — group-channel turns cannot use it. ' +
74
+ 'Permission grants are restart-required: they land in typeclaw.json but take effect on the next `typeclaw restart`.',
75
+ parameters: Type.Object({
76
+ role: Type.Union(
77
+ [Type.Literal('owner'), Type.Literal('trusted'), Type.Literal('member'), Type.Literal('guest')],
78
+ {
79
+ description: 'The role to grant TO.',
80
+ },
81
+ ),
82
+ match: Type.Optional(
83
+ Type.String({
84
+ description:
85
+ 'A match rule assigning an author/scope to the role, e.g. "slack:T0123 author:U_WIFE". ' +
86
+ 'Provide exactly one of match or permission.',
87
+ }),
88
+ ),
89
+ permission: Type.Optional(
90
+ Type.String({
91
+ description:
92
+ 'A capability to add to the role, e.g. "channel.respond". Provide exactly one of match or permission. ' +
93
+ 'security.bypass.* permissions cannot be granted through this tool.',
94
+ }),
95
+ ),
96
+ }),
97
+
98
+ async execute(_toolCallId, params): Promise<ToolReturn> {
99
+ const origin = getOrigin()
100
+
101
+ if (!isSinglePrincipalOrigin(origin)) {
102
+ return err(
103
+ 'grant_role is only available from the TUI or a 1:1 DM. A group-channel turn cannot change roles, ' +
104
+ 'because it mixes in other participants\u2019 messages (prompt-injection surface).',
105
+ )
106
+ }
107
+
108
+ const callerRole = permissions.resolveRole(origin)
109
+ if (callerRole !== 'owner' && callerRole !== 'trusted') {
110
+ return err(`grant_role denied: caller resolves to '${callerRole}'; only owner or trusted may grant roles.`)
111
+ }
112
+
113
+ const hasMatch = typeof params.match === 'string' && params.match.length > 0
114
+ const hasPermission = typeof params.permission === 'string' && params.permission.length > 0
115
+ if (hasMatch === hasPermission) {
116
+ return err('Provide exactly one of `match` or `permission`.')
117
+ }
118
+
119
+ if (!isTierRole(params.role)) {
120
+ return err(`Unknown target role '${params.role}'.`)
121
+ }
122
+
123
+ // Tier ceiling: a granter may not assign or empower a role ABOVE its own.
124
+ // owner(3) ≥ trusted(2) ≥ member(1) ≥ guest(0).
125
+ if (tierOf(params.role) > tierOf(callerRole)) {
126
+ return err(`grant_role denied: a ${callerRole} caller cannot grant the higher '${params.role}' role.`)
127
+ }
128
+
129
+ return hasMatch
130
+ ? grantMatch(params.role, params.match as string)
131
+ : grantPermission(callerRole, params.role, params.permission as string)
132
+ },
133
+ })
134
+
135
+ function grantMatch(role: string, matchRule: string): ToolReturn {
136
+ const parsed = parseMatchRule(matchRule)
137
+ if (!parsed.ok) {
138
+ return err(`Invalid match rule '${matchRule}': ${parsed.error}`)
139
+ }
140
+
141
+ const result = grantRole({ cwd: agentDir, roleName: role, matchRule })
142
+ if (!result.ok) return err(result.reason)
143
+
144
+ reload()
145
+ return ok('match', role, matchRule, result.added, false)
146
+ }
147
+
148
+ function grantPermission(callerRole: string, role: string, permission: string): ToolReturn {
149
+ // security.bypass.* defeats guards rather than enabling a feature; never
150
+ // grantable through an agent tool. It stays a deliberate hand-edit gated by
151
+ // the rolePromotion guard + an explicit ack.
152
+ if (permission.startsWith('security.bypass.')) {
153
+ return err(
154
+ `grant_role refuses to grant '${permission}': security.bypass.* permissions disable security guards and ` +
155
+ 'must be set by hand-editing typeclaw.json (gated by the rolePromotion guard), not via this tool.',
156
+ )
157
+ }
158
+
159
+ // "Grant only what you hold": the caller cannot confer a capability it does
160
+ // not itself possess. Owner's resolved set is the expanded wildcard, so an
161
+ // owner can grant any non-bypass core permission; trusted is capped at its
162
+ // literal set.
163
+ const callerPerms = permissions.describe(getOrigin()).permissions
164
+ if (!callerPerms.includes(permission)) {
165
+ return err(
166
+ `grant_role denied: a ${callerRole} caller cannot grant '${permission}' because it does not hold that permission.`,
167
+ )
168
+ }
169
+
170
+ const result = grantRolePermission({ cwd: agentDir, roleName: role, permission })
171
+ if (!result.ok) return err(result.reason)
172
+
173
+ reload()
174
+ return ok('permission', role, permission, result.added, true)
175
+ }
176
+
177
+ function reload(): void {
178
+ try {
179
+ permissions.replaceRoles(reloadRoles())
180
+ } catch {
181
+ // Best-effort hot-reload of match-grants. A failure here does not undo
182
+ // the on-disk write; the next config reload / restart picks it up.
183
+ }
184
+ }
185
+ }
186
+
187
+ function ok(
188
+ mode: 'match' | 'permission',
189
+ role: string,
190
+ value: string,
191
+ added: boolean,
192
+ restartRequired: boolean,
193
+ ): ToolReturn {
194
+ const details: GrantRoleToolDetails = { ok: true, mode, role, value, added, restartRequired }
195
+ const note = added ? '' : ' (already on file)'
196
+ const restart = restartRequired
197
+ ? ' This permission grant is restart-required; run `typeclaw restart` for it to take effect.'
198
+ : ''
199
+ const text =
200
+ mode === 'match'
201
+ ? `Granted role '${role}' the match rule '${value}'${note}.${restart}`
202
+ : `Granted role '${role}' the permission '${value}'${note}.${restart}`
203
+ return { content: [{ type: 'text', text }], details }
204
+ }
205
+
206
+ function err(message: string): ToolReturn {
207
+ const details: GrantRoleToolDetails = { ok: false, error: message }
208
+ return { content: [{ type: 'text', text: message }], details }
209
+ }
210
+
211
+ type ToolReturn = {
212
+ content: { type: 'text'; text: string }[]
213
+ details: GrantRoleToolDetails
214
+ }
@@ -12,6 +12,7 @@ export type InboundDropReason =
12
12
  | 'self_author' // event.author.id === botUserId; we never route our own messages back to ourselves
13
13
  | 'empty_content' // SDK delivered content: '' — usually missing MessageContent intent
14
14
  | 'pre_connect' // bot identity is not known yet, so mention/self/reply classification cannot be trusted
15
+ | 'thread_created_system' // Discord's THREAD_CREATED system notice posted to the PARENT channel when a public thread is created from a message
15
16
 
16
17
  export type InboundClassification =
17
18
  | { kind: 'drop'; reason: InboundDropReason }
@@ -38,6 +39,10 @@ export function classifyInbound(
38
39
  const { text, attachments } = splitInbound(event)
39
40
  if (text === '') return { kind: 'drop', reason: 'empty_content' }
40
41
 
42
+ if (isThreadCreatedSystemMessage(event)) {
43
+ return { kind: 'drop', reason: 'thread_created_system' }
44
+ }
45
+
41
46
  const isDm = event.guild_id === undefined
42
47
  const workspace = isDm ? '@dm' : event.guild_id!
43
48
 
@@ -108,6 +113,24 @@ function isReplyToBot(event: DiscordGatewayMessageCreateEvent, botUserId: string
108
113
  return (event.mentions ?? []).some((m) => m.id === botUserId)
109
114
  }
110
115
 
116
+ // Creating a public thread from a message fires TWO MESSAGE_CREATE events: a
117
+ // THREAD_CREATED notice in the parent channel (content = thread name) and a
118
+ // THREAD_STARTER_MESSAGE inside the thread. Each opens its own router session,
119
+ // so the agent replies twice. The numeric Discord message type (18) that would
120
+ // filter this cleanly is destroyed by the agent-messenger listener (it does
121
+ // `{ ...d, type: t }`, overwriting `d.type` with the dispatch name), so we
122
+ // fingerprint the notice by its reference instead: it points at a DIFFERENT
123
+ // channel (the new thread) with no source `message_id`. The message_id-absent
124
+ // check is load-bearing — it spares normal cross-channel replies and the
125
+ // in-thread starter, both of which DO carry a message_id.
126
+ function isThreadCreatedSystemMessage(event: DiscordGatewayMessageCreateEvent): boolean {
127
+ const ref = event.message_reference
128
+ if (ref?.channel_id === undefined) return false
129
+ if (ref.channel_id === event.channel_id) return false
130
+ if (ref.message_id !== undefined) return false
131
+ return ref.guild_id === undefined || event.guild_id === undefined || ref.guild_id === event.guild_id
132
+ }
133
+
111
134
  type SplitInbound = { text: string; attachments: InboundAttachment[] }
112
135
 
113
136
  function splitInbound(event: DiscordGatewayMessageCreateEvent): SplitInbound {
@@ -763,6 +763,7 @@ function dropHint(reason: InboundDropReason): string {
763
763
  return ' (enable MESSAGE CONTENT INTENT in Discord Developer Portal and restart)'
764
764
  case 'pre_connect':
765
765
  case 'self_author':
766
+ case 'thread_created_system':
766
767
  return ''
767
768
  }
768
769
  }
@@ -2,33 +2,43 @@ import { createPrivateKey } from 'node:crypto'
2
2
 
3
3
  import { resolveSecret, type Secret } from '@/secrets/resolve'
4
4
 
5
- import type { GithubAuthStrategy, GithubInstallationGrants, GithubSelfUser } from './auth'
5
+ import type { GithubAuthContext, GithubAuthStrategy, GithubInstallationGrants, GithubSelfUser } from './auth'
6
6
  import { GITHUB_API_BASE, githubJsonHeaders, githubPublicHeaders } from './auth-pat'
7
7
 
8
+ type TokenCacheEntry = { value: string; expiresAt: number }
9
+
8
10
  export class AppAuthStrategy implements GithubAuthStrategy {
9
11
  private readonly appId: number
10
12
  private readonly privateKeyPem: string
11
- private readonly installationId: number | null
12
13
  private readonly fetchImpl: typeof fetch
13
- private cachedToken: { value: string; expiresAt: number } | null = null
14
- private resolvedInstallationId: number | null = null
14
+ // Keyed by installation id: a single App may span multiple owners, each a
15
+ // separate installation with its own short-lived token.
16
+ private readonly tokenCache = new Map<number, TokenCacheEntry>()
17
+ private readonly repoInstallationCache = new Map<string, number>()
18
+ private soleInstallationId: number | null = null
15
19
  private _selfUser: GithubSelfUser | null = null
16
20
 
17
- constructor(options: { appId: number; privateKey: Secret; installationId?: number; fetchImpl?: typeof fetch }) {
21
+ constructor(options: { appId: number; privateKey: Secret; fetchImpl?: typeof fetch }) {
18
22
  const privateKeyPem = resolveSecret(options.privateKey, undefined, process.env)
19
23
  if (privateKeyPem === undefined || privateKeyPem.trim() === '') throw new Error('GitHub App private key is missing')
20
24
  this.appId = options.appId
21
25
  this.privateKeyPem = privateKeyPem
22
- this.installationId = options.installationId ?? null
23
26
  this.fetchImpl = options.fetchImpl ?? fetch
24
27
  }
25
28
 
26
- async token(): Promise<string> {
27
- if (this.cachedToken && Date.now() < this.cachedToken.expiresAt - 5 * 60 * 1000) {
28
- return this.cachedToken.value
29
- }
29
+ async token(context?: GithubAuthContext): Promise<string> {
30
30
  const jwt = await this.mintJwt()
31
- const installId = await this.resolveInstallationId(jwt)
31
+ const installId = await this.resolveInstallationId(jwt, context)
32
+ return this.installationToken(jwt, installId)
33
+ }
34
+
35
+ async authHeaders(context?: GithubAuthContext): Promise<HeadersInit> {
36
+ return githubJsonHeaders(await this.token(context))
37
+ }
38
+
39
+ private async installationToken(jwt: string, installId: number): Promise<string> {
40
+ const cached = this.tokenCache.get(installId)
41
+ if (cached && Date.now() < cached.expiresAt - 5 * 60 * 1000) return cached.value
32
42
  const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations/${installId}/access_tokens`, {
33
43
  method: 'POST',
34
44
  headers: githubJsonHeaders(jwt),
@@ -37,14 +47,10 @@ export class AppAuthStrategy implements GithubAuthStrategy {
37
47
  const raw = (await response.json()) as { token?: unknown; expires_at?: unknown }
38
48
  if (typeof raw.token !== 'string') throw new Error('GitHub App token response missing token')
39
49
  const expiresAt = typeof raw.expires_at === 'string' ? Date.parse(raw.expires_at) : Date.now() + 60 * 60 * 1000
40
- this.cachedToken = { value: raw.token, expiresAt }
50
+ this.tokenCache.set(installId, { value: raw.token, expiresAt })
41
51
  return raw.token
42
52
  }
43
53
 
44
- async authHeaders(): Promise<HeadersInit> {
45
- return githubJsonHeaders(await this.token())
46
- }
47
-
48
54
  async getSelf(): Promise<GithubSelfUser> {
49
55
  if (this._selfUser) return this._selfUser
50
56
  const jwt = await this.mintJwt()
@@ -69,9 +75,9 @@ export class AppAuthStrategy implements GithubAuthStrategy {
69
75
  return this._selfUser
70
76
  }
71
77
 
72
- async getInstallationGrants(): Promise<GithubInstallationGrants> {
78
+ async getInstallationGrants(context?: GithubAuthContext): Promise<GithubInstallationGrants> {
73
79
  const jwt = await this.mintJwt()
74
- const installId = await this.resolveInstallationId(jwt)
80
+ const installId = await this.resolveInstallationId(jwt, context)
75
81
  const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations/${installId}`, {
76
82
  headers: githubJsonHeaders(jwt),
77
83
  })
@@ -88,7 +94,8 @@ export class AppAuthStrategy implements GithubAuthStrategy {
88
94
  }
89
95
 
90
96
  async dispose(): Promise<void> {
91
- this.cachedToken = null
97
+ this.tokenCache.clear()
98
+ this.repoInstallationCache.clear()
92
99
  }
93
100
 
94
101
  private async mintJwt(): Promise<string> {
@@ -107,25 +114,41 @@ export class AppAuthStrategy implements GithubAuthStrategy {
107
114
  return `${signingInput}.${base64url(Buffer.from(signature))}`
108
115
  }
109
116
 
110
- private async resolveInstallationId(jwt: string): Promise<number> {
111
- if (this.resolvedInstallationId !== null) return this.resolvedInstallationId
112
- if (this.installationId !== null) {
113
- this.resolvedInstallationId = this.installationId
114
- return this.installationId
117
+ private async resolveInstallationId(jwt: string, context?: GithubAuthContext): Promise<number> {
118
+ if (context?.repoSlug !== undefined && context.repoSlug !== '') {
119
+ return this.resolveInstallationByEndpoint(jwt, `repos/${context.repoSlug}/installation`, context.repoSlug)
120
+ }
121
+ if (context?.owner !== undefined && context.owner !== '') {
122
+ return this.resolveInstallationByEndpoint(jwt, `orgs/${context.owner}/installation`, context.owner)
115
123
  }
124
+ if (this.soleInstallationId !== null) return this.soleInstallationId
116
125
  const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations`, { headers: githubJsonHeaders(jwt) })
117
126
  if (!response.ok) throw new Error(`GitHub App installations fetch failed: ${response.status}`)
118
127
  const list = (await response.json()) as Array<{ id?: unknown }>
119
128
  if (list.length === 0) throw new Error('GitHub App has no installations')
120
129
  if (list.length > 1) {
121
130
  const ids = list.map((installation) => installation.id).join(', ')
122
- throw new Error(`GitHub App has multiple installations (${ids}); set installationId in secrets.json`)
131
+ throw new Error(`GitHub App has multiple installations (${ids}); a repo must be specified to select one`)
123
132
  }
124
133
  const id = list[0]?.id
125
134
  if (typeof id !== 'number') throw new Error('GitHub App installation missing id')
126
- this.resolvedInstallationId = id
135
+ this.soleInstallationId = id
127
136
  return id
128
137
  }
138
+
139
+ private async resolveInstallationByEndpoint(jwt: string, path: string, target: string): Promise<number> {
140
+ const cached = this.repoInstallationCache.get(target)
141
+ if (cached !== undefined) return cached
142
+ const response = await this.fetchImpl(`${GITHUB_API_BASE}/${path}`, { headers: githubJsonHeaders(jwt) })
143
+ if (response.status === 404) {
144
+ throw new Error(`GitHub App is not installed for ${target} or lacks access to that repository`)
145
+ }
146
+ if (!response.ok) throw new Error(`GitHub App installation lookup for ${target} failed: ${response.status}`)
147
+ const raw = (await response.json()) as { id?: unknown }
148
+ if (typeof raw.id !== 'number') throw new Error(`GitHub App installation for ${target} missing id`)
149
+ this.repoInstallationCache.set(target, raw.id)
150
+ return raw.id
151
+ }
129
152
  }
130
153
 
131
154
  function base64url(input: string | Buffer): string {
@@ -1,6 +1,6 @@
1
1
  import { resolveSecret, type Secret } from '@/secrets/resolve'
2
2
 
3
- import type { GithubAuthStrategy, GithubSelfUser } from './auth'
3
+ import type { GithubAuthContext, GithubAuthStrategy, GithubSelfUser } from './auth'
4
4
 
5
5
  export const GITHUB_API_BASE = 'https://api.github.com'
6
6
 
@@ -15,11 +15,11 @@ export class PatAuthStrategy implements GithubAuthStrategy {
15
15
  this.fetchImpl = options.fetchImpl ?? fetch
16
16
  }
17
17
 
18
- async token(): Promise<string> {
18
+ async token(_context?: GithubAuthContext): Promise<string> {
19
19
  return this._token
20
20
  }
21
21
 
22
- async authHeaders(): Promise<HeadersInit> {
22
+ async authHeaders(_context?: GithubAuthContext): Promise<HeadersInit> {
23
23
  return githubJsonHeaders(this._token)
24
24
  }
25
25