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
@@ -35,12 +35,11 @@ import {
35
35
  import { createSlackDedupe } from './slack-bot-dedupe'
36
36
  import {
37
37
  buildSlashAckPayload,
38
+ commandResultReply,
38
39
  parseSlashCommand,
39
- SLACK_SLASH_REPLY_ABORTED,
40
- SLACK_SLASH_REPLY_AMBIGUOUS,
40
+ parseThreadCommand,
41
41
  SLACK_SLASH_REPLY_FAILED,
42
- SLACK_SLASH_REPLY_NO_LIVE_SESSION,
43
- SLACK_SLASH_REPLY_PERMISSION_DENIED,
42
+ type ThreadCommandInput,
44
43
  } from './slack-bot-slash-commands'
45
44
  import { slackTsToMillis } from './slack-bot-time'
46
45
 
@@ -124,16 +123,7 @@ export function createSlashCommandHandler(
124
123
  return
125
124
  }
126
125
 
127
- const replyContent =
128
- result.kind === 'handled'
129
- ? SLACK_SLASH_REPLY_ABORTED
130
- : result.kind === 'no-live-session'
131
- ? SLACK_SLASH_REPLY_NO_LIVE_SESSION
132
- : result.kind === 'permission-denied'
133
- ? SLACK_SLASH_REPLY_PERMISSION_DENIED
134
- : result.kind === 'ambiguous'
135
- ? SLACK_SLASH_REPLY_AMBIGUOUS
136
- : SLACK_SLASH_REPLY_FAILED
126
+ const replyContent = commandResultReply(result)
137
127
 
138
128
  // Final ack on the happy path: own try/catch so a thrown ack here does
139
129
  // NOT cascade into the error-path ack above (which would violate the
@@ -158,6 +148,67 @@ export function createSlashCommandHandler(
158
148
  }
159
149
  }
160
150
 
151
+ export type ThreadCommandReplyPoster = (args: { chat: string; thread: string | null; text: string }) => Promise<void>
152
+
153
+ export type ThreadCommandHandlerDeps = {
154
+ router: Pick<ChannelRouter, 'executeCommand'>
155
+ knownCommandNames: ReadonlySet<string>
156
+ postReply: ThreadCommandReplyPoster
157
+ logger: SlackBotAdapterLoggerLike
158
+ }
159
+
160
+ export type ThreadCommandOutcome = { kind: 'not-a-command' } | { kind: 'duplicate' } | { kind: 'executed' }
161
+
162
+ // Synchronous reservation: the adapter marks the dedupe ring inside this hook,
163
+ // which the handler calls before its first `await`. Two duplicate Slack
164
+ // deliveries can both clear `dedupe.check()` on the same JS tick; whichever
165
+ // reserves first wins and returns `true`, the loser returns `false` and aborts
166
+ // — so a control command never runs twice across the check→execute window.
167
+ export type ThreadCommandReserve = () => boolean
168
+
169
+ // Routes a `!cmd` thread message through the SAME router.executeCommand path as
170
+ // native slashes, then posts the outcome back into the thread. Returns
171
+ // 'not-a-command' (caller proceeds with normal classify/route), 'duplicate'
172
+ // (a racing delivery already reserved this event — caller stops silently), or
173
+ // 'executed' (command handled — caller stops; it is not agent input).
174
+ export function createThreadCommandHandler(
175
+ deps: ThreadCommandHandlerDeps,
176
+ ): (input: ThreadCommandInput, reserve: ThreadCommandReserve) => Promise<ThreadCommandOutcome> {
177
+ return async (input, reserve) => {
178
+ const parsed = parseThreadCommand(input, deps.knownCommandNames)
179
+ if (parsed.kind === 'ignore') {
180
+ return { kind: 'not-a-command' }
181
+ }
182
+ // Reserve synchronously, before any await, to close the check→execute race.
183
+ if (!reserve()) {
184
+ return { kind: 'duplicate' }
185
+ }
186
+ const { command } = parsed
187
+ deps.logger.info(
188
+ `[slack-bot] thread-command !${command.name} invoker=${command.invokerId} team=${command.key.workspace} channel=${command.key.chat} thread=${command.key.thread ?? '(none)'}`,
189
+ )
190
+
191
+ let reply: string
192
+ try {
193
+ const result = await deps.router.executeCommand(command.key, command.name, {
194
+ invokerId: command.invokerId,
195
+ })
196
+ reply = commandResultReply(result)
197
+ deps.logger.info(`[slack-bot] thread-command !${command.name} result=${result.kind}`)
198
+ } catch (err) {
199
+ deps.logger.error(`[slack-bot] thread-command !${command.name} failed: ${describe(err)}`)
200
+ reply = SLACK_SLASH_REPLY_FAILED
201
+ }
202
+
203
+ try {
204
+ await deps.postReply({ chat: input.channel, thread: input.threadTs, text: reply })
205
+ } catch (err) {
206
+ deps.logger.warn(`[slack-bot] thread-command reply post failed: ${describe(err)}`)
207
+ }
208
+ return { kind: 'executed' }
209
+ }
210
+ }
211
+
161
212
  // app_mention payloads omit channel_type and never carry a subtype, so we
162
213
  // promote them to a message-shaped event for the shared classifier. The
163
214
  // promoted event is classified as a regular channel message; the
@@ -783,6 +834,24 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
783
834
  formatChannelTag,
784
835
  })
785
836
 
837
+ const handleThreadCommand = createThreadCommandHandler({
838
+ router: options.router,
839
+ knownCommandNames: SLACK_SLASH_COMMAND_NAMES,
840
+ logger,
841
+ postReply: async ({ chat, thread, text }) => {
842
+ const result = await outboundCallback({
843
+ adapter: 'slack-bot',
844
+ workspace: teamId ?? 'unknown',
845
+ chat,
846
+ ...(thread !== null ? { thread } : {}),
847
+ text,
848
+ })
849
+ if (!result.ok) {
850
+ throw new Error(result.error)
851
+ }
852
+ },
853
+ })
854
+
786
855
  const handleMessageEvent = async (
787
856
  event: SlackInboundMessageEvent,
788
857
  source: 'message' | 'app_mention',
@@ -813,6 +882,38 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
813
882
  return
814
883
  }
815
884
 
885
+ // Intercept `!cmd` thread-message commands BEFORE classifyInbound. A
886
+ // command is control traffic — neither dropped nor routed to the agent —
887
+ // so it must short-circuit here. Bypassing classifyInbound also bypasses
888
+ // its self_author / no_user drops, so we replicate those guards: never
889
+ // execute a command from our own message (echo loop) or a userless
890
+ // system event. The `reserve` closure marks dedupe synchronously the
891
+ // instant the command is recognised (before the router await), closing
892
+ // the check→execute race for duplicate deliveries.
893
+ if (event.user !== undefined && event.user !== '' && (botUserId === null || event.user !== botUserId)) {
894
+ const reserve = (): boolean => {
895
+ if (dedupe.check(event) !== null) return false
896
+ dedupe.mark(event)
897
+ return true
898
+ }
899
+ const outcome = await handleThreadCommand(
900
+ {
901
+ text: event.text ?? '',
902
+ channel: event.channel,
903
+ threadTs: event.thread_ts ?? null,
904
+ isDm: event.channel_type === 'im',
905
+ teamId,
906
+ invokerId: event.user,
907
+ },
908
+ reserve,
909
+ )
910
+ if (outcome.kind === 'executed') return
911
+ if (outcome.kind === 'duplicate') {
912
+ logger.info(`[slack-bot] dropped ts=${event.ts} reason=duplicate_delivery (thread-command race)`)
913
+ return
914
+ }
915
+ }
916
+
816
917
  const verdict = classifyInbound(event, options.configRef(), {
817
918
  teamId,
818
919
  botUserId,
@@ -164,6 +164,19 @@ export const SESSION_FRESHNESS_TTL_MS = 5 * 60 * 1000
164
164
  // instead of awaiting the same dead promise forever.
165
165
  export const ENSURE_LIVE_TIMEOUT_MS = 30_000
166
166
 
167
+ // Thrown by ensureLive() when a teardown (roles reload or shutdown) raced
168
+ // ahead of an in-flight creation. route() has no special handling — it
169
+ // propagates to the adapter's outer catch, dropping this one inbound. The
170
+ // next inbound creates a fresh, post-reload session, which is the intended
171
+ // outcome: a message that arrived mid-reload is cheap to drop, far cheaper
172
+ // than answering it through a session built with the stale role.
173
+ export class StaleLiveSessionError extends Error {
174
+ constructor(keyId: string) {
175
+ super(`[channels] ${keyId}: live session creation raced a teardown; discarded`)
176
+ this.name = 'StaleLiveSessionError'
177
+ }
178
+ }
179
+
167
180
  // Per-callback ceilings inside the ensureLive chain. The outer watchdog
168
181
  // catches the worst case, but per-step timeouts give better log
169
182
  // attribution (which step hung) AND graceful degradation: a hung name
@@ -562,6 +575,7 @@ export type ChannelRouter = {
562
575
  | { kind: 'recorded-after-send'; keyId: string }
563
576
  | { kind: 'no-live-session' }
564
577
  stop: () => Promise<void>
578
+ tearDownAllLive: () => Promise<void>
565
579
  liveCount: () => number
566
580
  __testing?: {
567
581
  flushDebounce: (key: ChannelKey) => Promise<void>
@@ -691,6 +705,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
691
705
  const stream = options.stream
692
706
  const liveSessions = new Map<string, LiveSession>()
693
707
  const creating = new Map<string, Promise<LiveSession>>()
708
+ // Bumped by tearDownAllLive() and stop() before they tear sessions down. An
709
+ // in-flight ensureLive() captures the value at creation start and re-checks
710
+ // it right before installing into liveSessions; if it changed, a teardown
711
+ // raced ahead of this creation (e.g. a roles.match reload), so the session
712
+ // was built with stale role context and must self-dispose instead of
713
+ // installing — otherwise it would reintroduce the very staleness the
714
+ // teardown was meant to clear.
715
+ let liveGeneration = 0
694
716
  const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
695
717
  const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
696
718
  const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
@@ -909,6 +931,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
909
931
  const inFlight = creating.get(keyId)
910
932
  if (inFlight) return inFlight
911
933
 
934
+ const generation = liveGeneration
935
+
912
936
  const promise = (async () => {
913
937
  await ensureLoaded()
914
938
  const record = mappings ? findRecord(mappings, key) : undefined
@@ -1073,6 +1097,17 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1073
1097
  live.unsubTypingActivity = subscribeTypingActivity(created.session, live)
1074
1098
  installChannelReplyTerminalHook(live)
1075
1099
  installChannelOutputCap(live)
1100
+
1101
+ // A teardown (roles reload / shutdown) ran while this session was being
1102
+ // built, so it carries stale role context. Dispose it instead of
1103
+ // installing — installing here is the exact window the race exploits.
1104
+ if (generation !== liveGeneration) {
1105
+ logger.info(
1106
+ `[channels] ${keyId}: discarding session created across a teardown (gen ${generation} → ${liveGeneration})`,
1107
+ )
1108
+ await tearDownLive(live)
1109
+ throw new StaleLiveSessionError(keyId)
1110
+ }
1076
1111
  liveSessions.set(keyId, live)
1077
1112
 
1078
1113
  if (isColdStart) {
@@ -1632,6 +1667,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1632
1667
  logger.info(`[channels] ${keyId}: ignoring unknown command /${parsedCommand.name}`)
1633
1668
  return
1634
1669
  }
1670
+ if (isSessionControlDenied(event)) {
1671
+ logger.info(
1672
+ `[channels] ${keyId}: denied command /${parsedCommand.name} by permissions (session.control) author=${event.authorId}`,
1673
+ )
1674
+ return
1675
+ }
1635
1676
  const existingLive = liveSessions.get(keyId)
1636
1677
  if (!existingLive || existingLive.destroyed) {
1637
1678
  logger.info(`[channels] ${keyId}: ignoring command /${parsedCommand.name} with no live session`)
@@ -1714,17 +1755,24 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1714
1755
  scheduleDebouncedDrain(live)
1715
1756
  }
1716
1757
 
1717
- const isChannelRespondDenied = (event: InboundMessage): boolean => {
1718
- const partial: SessionOrigin = {
1719
- kind: 'channel',
1720
- adapter: event.adapter,
1721
- workspace: event.workspace,
1722
- chat: event.chat,
1723
- thread: event.thread,
1724
- lastInboundAuthorId: event.authorId,
1725
- }
1726
- return !permissions.has(partial, CORE_PERMISSIONS.channelRespond)
1727
- }
1758
+ const inboundAuthorOrigin = (event: InboundMessage): SessionOrigin => ({
1759
+ kind: 'channel',
1760
+ adapter: event.adapter,
1761
+ workspace: event.workspace,
1762
+ chat: event.chat,
1763
+ thread: event.thread,
1764
+ lastInboundAuthorId: event.authorId,
1765
+ })
1766
+
1767
+ const isChannelRespondDenied = (event: InboundMessage): boolean =>
1768
+ !permissions.has(inboundAuthorOrigin(event), CORE_PERMISSIONS.channelRespond)
1769
+
1770
+ // Gated separately from channelRespond so a respond-capable guest (an
1771
+ // operator can grant guest channelRespond for masked stranger turns)
1772
+ // cannot /stop another speaker's in-flight turn. session.control is
1773
+ // member-and-up by default.
1774
+ const isSessionControlDenied = (event: InboundMessage): boolean =>
1775
+ !permissions.has(inboundAuthorOrigin(event), CORE_PERMISSIONS.sessionControl)
1728
1776
 
1729
1777
  const updateLoopGuard = (live: LiveSession, event: InboundMessage): void => {
1730
1778
  if (!event.authorIsBot) {
@@ -2334,6 +2382,27 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2334
2382
  const stop = async (): Promise<void> => {
2335
2383
  if (gcTimer) clearInterval(gcTimer)
2336
2384
  gcTimer = null
2385
+ liveGeneration++
2386
+ const all = Array.from(liveSessions.values())
2387
+ liveSessions.clear()
2388
+ for (const live of all) {
2389
+ await tearDownLive(live)
2390
+ }
2391
+ }
2392
+
2393
+ // Drops every in-memory session but KEEPS the on-disk records, so the next
2394
+ // inbound per channel rehydrates the same transcript through a fresh
2395
+ // createSession() — which re-renders the frozen system-prompt role block.
2396
+ // This is how a `roles.<name>.match` reload reaches live channel sessions.
2397
+ // Unlike stop() it leaves the GC timer running; unlike stale-rollover it
2398
+ // keeps the sessionId, so history survives.
2399
+ //
2400
+ // Bumping liveGeneration BEFORE the snapshot is what makes this race-free:
2401
+ // a session mid-creation (in `creating` but not yet in `liveSessions`) won't
2402
+ // appear in the snapshot below, but it captured the old generation and will
2403
+ // self-dispose at its install guard instead of resurrecting stale role state.
2404
+ const tearDownAllLive = async (): Promise<void> => {
2405
+ liveGeneration++
2337
2406
  const all = Array.from(liveSessions.values())
2338
2407
  liveSessions.clear()
2339
2408
  for (const live of all) {
@@ -2350,11 +2419,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2350
2419
  if (!commands.has(lowered)) {
2351
2420
  return { kind: 'unknown-command', name: lowered }
2352
2421
  }
2353
- // Permission gate runs BEFORE the live-session lookup so a guest user
2354
- // invoking /stop on a non-existent session gets 'permission-denied'
2355
- // (consistent answer regardless of session state) rather than leaking
2356
- // session presence via the 'no-live-session' vs 'permission-denied'
2357
- // distinction.
2422
+ // Gates on session.control (not channel.respond) so a respond-capable
2423
+ // guest cannot abort another speaker's turn. Runs BEFORE the live-session
2424
+ // lookup so an unauthorized invoker gets 'permission-denied' regardless of
2425
+ // session state, rather than leaking session presence via the
2426
+ // 'no-live-session' vs 'permission-denied' distinction.
2358
2427
  const partial: SessionOrigin = {
2359
2428
  kind: 'channel',
2360
2429
  adapter: key.adapter,
@@ -2363,7 +2432,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2363
2432
  thread: key.thread,
2364
2433
  lastInboundAuthorId: options.invokerId,
2365
2434
  }
2366
- if (!permissions.has(partial, CORE_PERMISSIONS.channelRespond)) {
2435
+ if (!permissions.has(partial, CORE_PERMISSIONS.sessionControl)) {
2367
2436
  return { kind: 'permission-denied' }
2368
2437
  }
2369
2438
  const resolved = resolveLiveSessionForCommand(liveSessions, key)
@@ -2476,6 +2545,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2476
2545
  injectSubagentCompletionReminder,
2477
2546
  markTurnSkipped,
2478
2547
  stop,
2548
+ tearDownAllLive,
2479
2549
  liveCount: () => liveSessions.size,
2480
2550
  __testing: {
2481
2551
  flushDebounce: async (key: ChannelKey) => {
@@ -842,7 +842,6 @@ async function promptGithubAppAuth(): Promise<{
842
842
  type: 'app'
843
843
  appId: number
844
844
  privateKey: string
845
- installationId?: number
846
845
  }> {
847
846
  const appId = await text({
848
847
  message: 'GitHub App ID',
@@ -857,21 +856,10 @@ async function promptGithubAppAuth(): Promise<{
857
856
  cancel('Aborted.')
858
857
  process.exit(0)
859
858
  }
860
- const installationId = await text({
861
- message: 'Installation ID (optional; leave blank to auto-discover)',
862
- validate: (value) =>
863
- value === undefined || value === '' ? undefined : validatePositiveInteger(value, 'Installation ID is required'),
864
- })
865
- if (isCancel(installationId)) {
866
- cancel('Aborted.')
867
- process.exit(0)
868
- }
869
- const parsedInstallationId = installationId === '' ? undefined : Number(installationId)
870
859
  return {
871
860
  type: 'app',
872
861
  appId: Number(appId),
873
862
  privateKey,
874
- ...(parsedInstallationId !== undefined ? { installationId: parsedInstallationId } : {}),
875
863
  }
876
864
  }
877
865
 
package/src/cli/init.ts CHANGED
@@ -1293,7 +1293,6 @@ async function promptGithubAppAuth(): Promise<{
1293
1293
  type: 'app'
1294
1294
  appId: number
1295
1295
  privateKey: string
1296
- installationId?: number
1297
1296
  } | null> {
1298
1297
  const appId = await text({
1299
1298
  message: 'GitHub App ID',
@@ -1302,18 +1301,10 @@ async function promptGithubAppAuth(): Promise<{
1302
1301
  if (isCancel(appId)) return null
1303
1302
  const privateKey = await promptPrivateKeyPem('GitHub App private key PEM, escaped PEM, or path to .pem file')
1304
1303
  if (privateKey === CANCEL_SYMBOL) return null
1305
- const installationId = await text({
1306
- message: 'Installation ID (optional; leave blank to auto-discover)',
1307
- validate: (v) =>
1308
- v === undefined || v === '' ? undefined : validatePositiveInteger(v, 'Installation ID is required'),
1309
- })
1310
- if (isCancel(installationId)) return null
1311
- const parsedInstallationId = installationId === '' ? undefined : Number(installationId)
1312
1304
  return {
1313
1305
  type: 'app',
1314
1306
  appId: Number(appId),
1315
1307
  privateKey,
1316
- ...(parsedInstallationId !== undefined ? { installationId: parsedInstallationId } : {}),
1317
1308
  }
1318
1309
  }
1319
1310
 
package/src/cli/role.ts CHANGED
@@ -3,7 +3,7 @@ import { defineCommand } from 'citty'
3
3
 
4
4
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
5
5
  import { findAgentDir } from '@/init'
6
- import { runClaimSession } from '@/role-claim'
6
+ import { reloadAfterClaim, runClaimSession } from '@/role-claim'
7
7
 
8
8
  import { c, errorLine } from './ui'
9
9
 
@@ -76,6 +76,15 @@ const claimSub = defineCommand({
76
76
 
77
77
  if (result.kind === 'completed') {
78
78
  s.stop(c.green(`Paired as ${result.payload.role}.`))
79
+ s.start('Reloading config so the new match rule takes effect...')
80
+ const reloaded = await reloadAfterClaim({ url })
81
+ if (reloaded.ok) {
82
+ s.stop(c.green('Config reloaded.'))
83
+ } else {
84
+ // The role is already persisted; a reload failure is non-fatal.
85
+ s.stop(c.yellow(`Config reload failed: ${reloaded.reason}`))
86
+ console.log(c.dim('Run `typeclaw reload` manually to apply the new match rule.'))
87
+ }
79
88
  outro(`Match rule added: ${c.bold(result.payload.matchRule)}`)
80
89
  return
81
90
  }
package/src/cli/ui.ts CHANGED
@@ -252,16 +252,18 @@ export function printSlackAppManifestSetup(output: NodeJS.WritableStream = proce
252
252
  // the exact permission bitfield the adapter uses. No-ops when the token isn't
253
253
  // parseable as a Discord bot token so we never block onboarding on best-effort
254
254
  // guidance.
255
- export function printDiscordInviteHint(token: string): void {
255
+ export function printDiscordInviteHint(token: string, output: NodeJS.WritableStream = process.stdout): void {
256
256
  const appId = deriveAppIdFromBotToken(token)
257
257
  if (appId === null) return
258
+ // URL stays OUT of note(): clack wraps long lines with a `│` gutter that
259
+ // corrupts copy-pasted URLs. Same fix as src/cli/oauth-callbacks.ts.
258
260
  note(
259
261
  [
260
- buildDiscordInviteUrl(appId),
261
- '',
262
- 'Open it, pick a server, click Authorize.',
262
+ 'Open the URL below, pick a server, click Authorize.',
263
263
  "The bot won't receive messages until it's in at least one server.",
264
264
  ].join('\n'),
265
265
  'Invite the bot to a server',
266
266
  )
267
+ output.write(`${buildDiscordInviteUrl(appId)}\n`)
268
+ output.write('\n')
267
269
  }
@@ -11,6 +11,10 @@ export type CreateConfigReloadableOptions = {
11
11
  // hand-edits) take effect without a container restart. `roles.<name>.permissions`
12
12
  // changes still require a restart — see FIELD_EFFECTS in config.ts.
13
13
  permissions?: PermissionService
14
+ // Fired after replaceRoles when a `roles.<name>.match` edit is applied. The
15
+ // run stage wires this to the channel router so live sessions are recreated
16
+ // and pick up the new role in their (otherwise frozen) system prompt.
17
+ onRolesChanged?: () => void | Promise<void>
14
18
  // Skip the mount-path accessibility check inside validateConfig. Mount paths
15
19
  // in typeclaw.json are host paths — they don't resolve inside the container,
16
20
  // so the check would always fail on any agent that declares mounts. `mounts`
@@ -22,18 +26,20 @@ export type CreateConfigReloadableOptions = {
22
26
  export function createConfigReloadable({
23
27
  cwd,
24
28
  permissions,
29
+ onRolesChanged,
25
30
  skipMountValidation = false,
26
31
  }: CreateConfigReloadableOptions): Reloadable {
27
32
  return {
28
33
  scope: 'config',
29
34
  description: 'typeclaw.json runtime config',
30
- reload: async () => doReload(cwd, permissions, skipMountValidation),
35
+ reload: async () => doReload(cwd, permissions, onRolesChanged, skipMountValidation),
31
36
  }
32
37
  }
33
38
 
34
39
  async function doReload(
35
40
  cwd: string,
36
41
  permissions: PermissionService | undefined,
42
+ onRolesChanged: (() => void | Promise<void>) | undefined,
37
43
  skipMountValidation: boolean,
38
44
  ): Promise<ReloadResult> {
39
45
  // Mount accessibility belongs to the validation surface, not loadConfigSync —
@@ -59,8 +65,9 @@ async function doReload(
59
65
  return { scope: 'config', ok: false, reason: message }
60
66
  }
61
67
 
62
- if (permissions !== undefined && diff.applied.some((c) => c.path === 'roles.match')) {
63
- permissions.replaceRoles(getConfig().roles)
68
+ if (diff.applied.some((c) => c.path === 'roles.match')) {
69
+ permissions?.replaceRoles(getConfig().roles)
70
+ await onRolesChanged?.()
64
71
  }
65
72
 
66
73
  return {
@@ -57,7 +57,7 @@ export async function installGithubWebhooksEagerly(
57
57
 
58
58
  try {
59
59
  const result = await registerGithubWebhooks({
60
- token: () => strategy.token(),
60
+ token: (repoSlug: string) => strategy.token({ repoSlug }),
61
61
  webhookUrl,
62
62
  webhookSecret: options.webhookSecret,
63
63
  repos: options.repos,
@@ -86,7 +86,6 @@ function authToSecretBlock(auth: GithubInitCredentials['auth']) {
86
86
  type: 'app' as const,
87
87
  appId: auth.appId,
88
88
  privateKey: { value: auth.privateKey },
89
- ...(auth.installationId !== undefined ? { installationId: auth.installationId } : {}),
90
89
  }
91
90
  }
92
91
 
package/src/init/index.ts CHANGED
@@ -39,14 +39,6 @@ const CONFIG_FILE = 'typeclaw.json'
39
39
  const CRON_FILE = 'cron.json'
40
40
  const PACKAGE_FILE = 'package.json'
41
41
 
42
- // Seeded into `typeclaw.json#roles.member.match[]` whenever a chat adapter
43
- // (slack-bot, discord-bot, telegram-bot, kakaotalk) is wired. The "*" rule
44
- // matches every channel session on every platform, so the built-in `member`
45
- // role (which already carries `channel.respond`) covers any inbound the
46
- // router sees. Without this, freshly-hatched agents silently drop every
47
- // chat message — see scaffold() and ensureDefaultChatMemberMatch() below.
48
- const DEFAULT_CHAT_MEMBER_MATCH_RULE = '*'
49
-
50
42
  const MARKDOWN_FILES = ['AGENTS.md', 'IDENTITY.md', 'SOUL.md', 'USER.md'] as const
51
43
 
52
44
  // `packages/` is a bun workspace root (see `workspaces` in buildPackageJson).
@@ -102,7 +94,7 @@ export type GithubInitCredentials = {
102
94
  hostname?: string
103
95
  tokenEnv?: string
104
96
  repos: string[]
105
- auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
97
+ auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string }
106
98
  }
107
99
 
108
100
  export type GithubTunnelProvider = 'cloudflare-quick' | 'cloudflare-named' | 'external' | 'none'
@@ -586,11 +578,11 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
586
578
  if (options.withTelegram) channels['telegram-bot'] = {}
587
579
  if (options.withKakaotalk) channels.kakaotalk = {}
588
580
  if (Object.keys(channels).length > 0) config.channels = channels
589
- // See DEFAULT_CHAT_MEMBER_MATCH_RULE for why this is here. GitHub is wired
590
- // separately (writeGithubChannelForInit) and seeds per-repo member.match
591
- // entries instead of the wildcard, so a github-only init stays scoped to
592
- // the repos the operator opted in to.
593
- if (Object.keys(channels).length > 0) config.roles = { member: { match: [DEFAULT_CHAT_MEMBER_MATCH_RULE] } }
581
+ // No default `member` match is seeded. A fresh chat agent starts with every
582
+ // inbound author resolving to `guest` (dropped) until the operator claims
583
+ // `owner` (runOwnerClaim, post-hatch) and explicitly grants others. GitHub is
584
+ // wired separately and seeds per-repo `member.match` entries scoped to the
585
+ // opted-in repos. See runOwnerClaim for the mute-until-claimed warning.
594
586
  await writeFile(join(root, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
595
587
 
596
588
  const cron = {
@@ -1006,7 +998,7 @@ export type AddChannelOptions = {
1006
998
  hostname?: string
1007
999
  tokenEnv?: string
1008
1000
  repos: string[]
1009
- auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
1001
+ auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string }
1010
1002
  fetchImpl?: typeof fetch
1011
1003
  }
1012
1004
  )
@@ -1046,8 +1038,6 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
1046
1038
  if (options.channel === 'github') {
1047
1039
  await appendGithubMatchRules(options.cwd, options.repos)
1048
1040
  await maybeInstallGithubWebhooks(options, emit)
1049
- } else {
1050
- await ensureDefaultChatMemberMatch(options.cwd)
1051
1041
  }
1052
1042
 
1053
1043
  // Commit the typeclaw.json change so the agent folder isn't silently
@@ -1273,9 +1263,6 @@ async function writeGithubChannelForInit(cwd: string, credentials: GithubInitCre
1273
1263
  type: 'app',
1274
1264
  appId: credentials.auth.appId,
1275
1265
  privateKey: { value: credentials.auth.privateKey } satisfies Secret,
1276
- ...(credentials.auth.installationId !== undefined
1277
- ? { installationId: credentials.auth.installationId }
1278
- : {}),
1279
1266
  },
1280
1267
  webhookSecret: { value: credentials.webhookSecret } satisfies Secret,
1281
1268
  }
@@ -1308,7 +1295,6 @@ async function appendGithubSecrets(
1308
1295
  type: 'app',
1309
1296
  appId: options.auth.appId,
1310
1297
  privateKey: { value: options.auth.privateKey } satisfies Secret,
1311
- ...(options.auth.installationId !== undefined ? { installationId: options.auth.installationId } : {}),
1312
1298
  },
1313
1299
  webhookSecret: { value: options.webhookSecret } satisfies Secret,
1314
1300
  }
@@ -1329,24 +1315,6 @@ async function appendGithubMatchRules(cwd: string, repos: readonly string[]): Pr
1329
1315
  await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
1330
1316
  }
1331
1317
 
1332
- // Chat-adapter counterpart of appendGithubMatchRules. See
1333
- // DEFAULT_CHAT_MEMBER_MATCH_RULE for the rationale. Set-union semantics: re-
1334
- // running `typeclaw channel add` for additional chat adapters is a no-op on
1335
- // the match list, and any pre-existing rules the operator hand-authored
1336
- // (e.g. owner-claim's per-author entry on `owner`) are left intact.
1337
- async function ensureDefaultChatMemberMatch(cwd: string): Promise<void> {
1338
- const path = join(cwd, CONFIG_FILE)
1339
- const parsed = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
1340
- const roles = isObjectRecord(parsed.roles) ? { ...parsed.roles } : {}
1341
- const member = isObjectRecord(roles.member) ? { ...roles.member } : {}
1342
- const existing = Array.isArray(member.match) ? member.match.filter((v): v is string => typeof v === 'string') : []
1343
- if (existing.includes(DEFAULT_CHAT_MEMBER_MATCH_RULE)) return
1344
- member.match = [...existing, DEFAULT_CHAT_MEMBER_MATCH_RULE]
1345
- roles.member = member
1346
- parsed.roles = roles
1347
- await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
1348
- }
1349
-
1350
1318
  // Writes per-adapter field values into `secrets.json#channels.<adapter>`.
1351
1319
  // Refuses to overwrite existing fields: if the user already has e.g.
1352
1320
  // `botToken` recorded (from a prior `channel add` whose follow-up steps
@@ -1489,13 +1457,13 @@ export async function setChannelSecrets(
1489
1457
  // previous auth type, since the two shapes share no fields beyond `type`).
1490
1458
  export type GithubCredentialPatch = {
1491
1459
  webhookSecret?: string
1492
- auth?: { type: 'pat'; pat: string } | { type: 'app'; privateKey: string; appId?: number; installationId?: number }
1460
+ auth?: { type: 'pat'; pat: string } | { type: 'app'; privateKey: string; appId?: number }
1493
1461
  }
1494
1462
 
1495
1463
  // Update one or more credential fields on an already-configured GitHub
1496
1464
  // channel. Like setChannelSecrets, refuses when secrets.json has no
1497
1465
  // existing github entry. Supports both same-type rotation (preserves env
1498
- // bindings, carries appId/installationId forward when not supplied) and
1466
+ // bindings, carries appId forward when not supplied) and
1499
1467
  // auth-type switching (replaces the entire auth block — see
1500
1468
  // `GithubCredentialPatch` above).
1501
1469
  export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch): Promise<SetChannelTokensResult> {
@@ -1531,7 +1499,6 @@ export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch
1531
1499
  } else {
1532
1500
  const existingApp = isSameType && isObjectRecord(existingAuth) ? (existingAuth as Record<string, unknown>) : {}
1533
1501
  const appId = patch.auth.appId ?? (existingApp.appId as number | undefined)
1534
- const installationId = patch.auth.installationId ?? (existingApp.installationId as number | undefined)
1535
1502
  if (typeof appId !== 'number') {
1536
1503
  return {
1537
1504
  result: {
@@ -1546,7 +1513,6 @@ export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch
1546
1513
  type: 'app',
1547
1514
  appId,
1548
1515
  privateKey: rotatedSecret(existingApp.privateKey, patch.auth.privateKey),
1549
- ...(installationId !== undefined ? { installationId } : {}),
1550
1516
  }
1551
1517
  }
1552
1518
  }