typeclaw 0.1.5 → 0.2.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 (128) hide show
  1. package/README.md +14 -12
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +7 -4
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +23 -9
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +799 -319
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +6 -1
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +8 -1
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +385 -12
  67. package/src/config/index.ts +7 -0
  68. package/src/config/models-mutation.ts +209 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +5 -0
  73. package/src/container/require-running.ts +33 -0
  74. package/src/container/start.ts +39 -58
  75. package/src/cron/consumer.ts +22 -2
  76. package/src/cron/index.ts +45 -4
  77. package/src/cron/schema.ts +104 -0
  78. package/src/doctor/checks.ts +50 -33
  79. package/src/git/system-commit.ts +103 -0
  80. package/src/hostd/daemon.ts +16 -0
  81. package/src/hostd/kakao-renewal-manager.ts +223 -0
  82. package/src/hostd/paths.ts +7 -0
  83. package/src/init/dockerfile.ts +32 -6
  84. package/src/init/index.ts +190 -61
  85. package/src/init/kakaotalk-auth.ts +18 -1
  86. package/src/init/models-dev.ts +26 -1
  87. package/src/init/run-owner-claim.ts +77 -0
  88. package/src/permissions/builtins.ts +70 -0
  89. package/src/permissions/grant.ts +99 -0
  90. package/src/permissions/index.ts +29 -0
  91. package/src/permissions/match-rule.ts +305 -0
  92. package/src/permissions/permissions.ts +196 -0
  93. package/src/permissions/resolve.ts +80 -0
  94. package/src/permissions/schema.ts +79 -0
  95. package/src/plugin/context.ts +8 -4
  96. package/src/plugin/define.ts +2 -0
  97. package/src/plugin/index.ts +2 -0
  98. package/src/plugin/manager.ts +41 -0
  99. package/src/plugin/registry.ts +9 -0
  100. package/src/plugin/types.ts +35 -1
  101. package/src/role-claim/client.ts +182 -0
  102. package/src/role-claim/code.ts +53 -0
  103. package/src/role-claim/controller.ts +194 -0
  104. package/src/role-claim/index.ts +19 -0
  105. package/src/role-claim/match-rule.ts +43 -0
  106. package/src/role-claim/pending.ts +100 -0
  107. package/src/run/channel-session-factory.ts +76 -5
  108. package/src/run/index.ts +55 -6
  109. package/src/secrets/encryption.ts +116 -0
  110. package/src/secrets/kakao-renewal.ts +248 -0
  111. package/src/secrets/kakao-store.ts +66 -7
  112. package/src/secrets/keys.ts +173 -0
  113. package/src/secrets/schema.ts +23 -0
  114. package/src/secrets/storage.ts +68 -0
  115. package/src/server/index.ts +122 -11
  116. package/src/shared/index.ts +4 -0
  117. package/src/shared/protocol.ts +27 -0
  118. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  119. package/src/skills/typeclaw-config/SKILL.md +38 -64
  120. package/src/skills/typeclaw-memory/SKILL.md +1 -1
  121. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  122. package/src/stream/types.ts +7 -1
  123. package/src/usage/aggregate.ts +117 -0
  124. package/src/usage/format.ts +30 -0
  125. package/src/usage/index.ts +68 -0
  126. package/src/usage/report.ts +354 -0
  127. package/src/usage/scan.ts +186 -0
  128. package/typeclaw.schema.json +57 -45
@@ -14,7 +14,7 @@ import {
14
14
  import type { KakaoAccountCredentials, KakaoConfig, PendingLoginState } from 'agent-messenger/kakaotalk'
15
15
 
16
16
  import type { ChannelRouter } from '@/channels/router'
17
- import { isAllowed, type ChannelAdapterConfig, type KakaotalkAdapterConfig } from '@/channels/schema'
17
+ import type { ChannelAdapterConfig } from '@/channels/schema'
18
18
  import type {
19
19
  ChannelHistoryMessage,
20
20
  FetchHistoryArgs,
@@ -106,7 +106,7 @@ const consoleLogger: KakaotalkAdapterLogger = {
106
106
 
107
107
  export type KakaotalkAdapterOptions = {
108
108
  router: ChannelRouter
109
- configRef: () => KakaotalkAdapterConfig
109
+ configRef: () => ChannelAdapterConfig
110
110
  logger?: KakaotalkAdapterLogger
111
111
  selfAliasesRef?: () => readonly string[]
112
112
  credentialsStore?: KakaoCredentialStore
@@ -162,20 +162,14 @@ function formatLabel(name: string | undefined, id: string, prefix = ''): string
162
162
 
163
163
  export function createOutboundCallback(deps: {
164
164
  client: Pick<KakaoTalkClient, 'sendMessage'>
165
- configRef: () => ChannelAdapterConfig
166
165
  logger: KakaotalkAdapterLogger
167
166
  formatChannelTag: (workspace: string, chat: string) => Promise<string>
168
167
  }): OutboundCallback {
169
- const { client, configRef, logger, formatChannelTag } = deps
168
+ const { client, logger, formatChannelTag } = deps
170
169
  return async (msg: OutboundMessage): Promise<SendResult> => {
171
170
  if (msg.adapter !== 'kakaotalk') {
172
171
  return { ok: false, error: `unknown adapter: ${msg.adapter}` }
173
172
  }
174
- const config = configRef()
175
- if (!isAllowed(config.allow, msg.workspace, msg.chat)) {
176
- logger.warn(`[kakaotalk] outbound denied by allow rules: ${msg.workspace}/${msg.chat}`)
177
- return { ok: false, error: 'denied by allow rules' }
178
- }
179
173
  const text = msg.text ?? ''
180
174
  const attachments = msg.attachments ?? []
181
175
  if (attachments.length > 0) {
@@ -214,27 +208,12 @@ export function createOutboundCallback(deps: {
214
208
 
215
209
  export function createKakaoHistoryCallback(deps: {
216
210
  client: Pick<KakaoTalkClient, 'getMessages'>
217
- configRef: () => ChannelAdapterConfig
218
211
  logger: KakaotalkAdapterLogger
219
- channelResolver: Pick<KakaoChannelResolver, 'lookupChat' | 'refresh'>
220
212
  authorResolver: Pick<KakaoAuthorResolver, 'resolve'>
221
213
  selfUserIdRef: () => string | null
222
214
  }): HistoryCallback {
223
- const { client, configRef, logger, channelResolver, authorResolver, selfUserIdRef } = deps
215
+ const { client, logger, authorResolver, selfUserIdRef } = deps
224
216
  return async (args: FetchHistoryArgs): Promise<FetchHistoryResult> => {
225
- const config = configRef()
226
- let lookup = channelResolver.lookupChat(args.chat)
227
- if (lookup === null) {
228
- await channelResolver.refresh()
229
- lookup = channelResolver.lookupChat(args.chat)
230
- }
231
- // Fallback to the most restrictive bucket (group) when the resolver
232
- // can't classify after refresh — keeps allow-rule enforcement strict
233
- // rather than defaulting to a permissive bucket.
234
- const workspace = lookup?.workspace ?? '@kakao-group'
235
- if (!isAllowed(config.allow, workspace, args.chat)) {
236
- return { ok: false, error: 'denied by allow rules' }
237
- }
238
217
  const limit = clampLimit(args.limit, KAKAO_HISTORY_LIMIT_MAX)
239
218
  try {
240
219
  const messages = await client.getMessages(args.chat, {
@@ -311,16 +290,13 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
311
290
 
312
291
  const historyCallback = createKakaoHistoryCallback({
313
292
  client,
314
- configRef: options.configRef,
315
293
  logger,
316
- channelResolver,
317
294
  authorResolver,
318
295
  selfUserIdRef: () => selfUserId,
319
296
  })
320
297
 
321
298
  const outboundCallback = createOutboundCallback({
322
299
  client,
323
- configRef: options.configRef,
324
300
  logger,
325
301
  formatChannelTag,
326
302
  })
@@ -390,10 +366,7 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
390
366
  ...(options.selfAliasesRef ? { selfAliases: options.selfAliasesRef() } : {}),
391
367
  })
392
368
  if (verdict.kind === 'drop') {
393
- const bucket = channelResolver.lookupChat(event.chat_id)?.workspace ?? null
394
- logger.info(
395
- `[kakaotalk] dropped log_id=${event.log_id} reason=${verdict.reason}${dropHint(verdict.reason, bucket, event.chat_id)}`,
396
- )
369
+ logger.info(`[kakaotalk] dropped log_id=${event.log_id} reason=${verdict.reason}${dropHint(verdict.reason)}`)
397
370
  return
398
371
  }
399
372
 
@@ -462,6 +435,15 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
462
435
  logger.info(`[kakaotalk] authenticated as ${profile.nickname || profile.user_id} (${profile.user_id})`)
463
436
  } catch (err) {
464
437
  started = false
438
+ if (isKakaoUnauthorizedError(err)) {
439
+ const message =
440
+ 'KakaoTalk sub-device session is stale (server returned 401 on getProfile). ' +
441
+ 'This usually means the ~7-day token TTL has expired and the hostd renewal cron has not refreshed it yet — ' +
442
+ 'either because the agent was just initialized without stored credentials, or because the encryption key ' +
443
+ 'is missing/wrong. Run `typeclaw channel reauth kakaotalk` to mint fresh tokens, then `typeclaw reload`.'
444
+ logger.error(`[kakaotalk] ${message}`)
445
+ throw new Error(message)
446
+ }
465
447
  logger.error(`[kakaotalk] getProfile failed: ${describe(err)}`)
466
448
  throw err
467
449
  }
@@ -525,7 +507,8 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
525
507
  `[kakaotalk] session is DEAD after KICKOUT — ${reason}. ` +
526
508
  'Likely a real cross-device login is fighting our session. ' +
527
509
  'Stop the other client, then run `typeclaw restart`. ' +
528
- 'If the conflict persists, re-run `typeclaw init` to mint a new device_uuid.',
510
+ 'If the conflict persists, run `typeclaw channel reauth kakaotalk` to mint a fresh sub-device session ' +
511
+ '(the existing device_uuid is preserved by default so the new login skips phone-passcode confirmation).',
529
512
  )
530
513
  resetRecoveryEpisode()
531
514
  return
@@ -656,14 +639,8 @@ function markReadIfSupported(deps: {
656
639
  )
657
640
  }
658
641
 
659
- function dropHint(
660
- reason: InboundDropReason,
661
- bucket: '@kakao-dm' | '@kakao-group' | '@kakao-open' | null,
662
- chatId: string,
663
- ): string {
642
+ function dropHint(reason: InboundDropReason): string {
664
643
  switch (reason) {
665
- case 'not_in_allow_list':
666
- return ` (add ${suggestedAllowPattern(bucket, chatId)} to channels.kakaotalk.allow to admit this chat)`
667
644
  case 'unknown_chat':
668
645
  return ' (chat not in cache after refresh and provisional registration; check earlier resolver-refresh-failed warnings)'
669
646
  case 'empty_text':
@@ -673,14 +650,18 @@ function dropHint(
673
650
  }
674
651
  }
675
652
 
676
- function suggestedAllowPattern(bucket: '@kakao-dm' | '@kakao-group' | '@kakao-open' | null, chatId: string): string {
677
- if (bucket === '@kakao-dm') return `"kakao:dm/*" or "kakao:${chatId}"`
678
- if (bucket === '@kakao-group') return `"kakao:group/*" or "kakao:${chatId}"`
679
- if (bucket === '@kakao-open') return `"kakao:open/*" or "kakao:${chatId}"`
680
- return `"kakao:${chatId}"`
681
- }
682
-
683
653
  function isKickoutError(err: unknown): boolean {
684
654
  if (!(err instanceof Error)) return false
685
655
  return err.message.includes('kicked') || err.message.includes('KICKOUT')
686
656
  }
657
+
658
+ // String-match on agent-messenger's `Profile request failed: ${status}`
659
+ // error format (see kakaotalk/client.js:544). The SDK throws KakaoTalkError
660
+ // with code='profile_request_failed' for any non-2xx status, so we have to
661
+ // inspect the message to tell 401 (expired sub-device token, needs renewal)
662
+ // apart from 5xx (transient server issue). Until the SDK exposes a typed
663
+ // `unauthorized` code, this is the realistic detection path.
664
+ function isKakaoUnauthorizedError(err: unknown): boolean {
665
+ if (!(err instanceof Error)) return false
666
+ return /Profile request failed: 401\b/.test(err.message)
667
+ }
@@ -1,7 +1,7 @@
1
1
  import type { SlackFile, SlackSocketModeAppMentionEvent, SlackSocketModeMessageEvent } from 'agent-messenger/slackbot'
2
2
 
3
3
  import { matchesAnyAlias } from '@/channels/engagement'
4
- import { isAllowed, type ChannelAdapterConfig } from '@/channels/schema'
4
+ import type { ChannelAdapterConfig } from '@/channels/schema'
5
5
  import type { InboundMessage } from '@/channels/types'
6
6
 
7
7
  import { slackTsToMillis } from './slack-bot-time'
@@ -38,7 +38,6 @@ export type InboundDropReason =
38
38
  | 'self_author' // event.user === botUserId; we never route our own messages back to ourselves
39
39
  | 'no_user' // event has no `user` field (e.g. system messages: channel_join, message_changed)
40
40
  | 'empty_text' // event has neither text nor files — nothing for the agent to act on
41
- | 'not_in_allow_list' // workspace/channel not admitted by typeclaw.json `channels.slack-bot.allow`
42
41
  | 'pre_connect' // bot identity is not known yet, so mention/self/reply classification cannot be trusted
43
42
 
44
43
  export type InboundClassification =
@@ -67,7 +66,7 @@ export type SlackInboundContext = {
67
66
  // forces logging to stay exhaustive.
68
67
  export function classifyInbound(
69
68
  event: SlackInboundMessageEvent,
70
- config: ChannelAdapterConfig,
69
+ _config: ChannelAdapterConfig,
71
70
  context: SlackInboundContext,
72
71
  ): InboundClassification {
73
72
  // Self-drop is the hard floor: never route our own messages back to
@@ -92,9 +91,6 @@ export function classifyInbound(
92
91
 
93
92
  const isDm = event.channel_type === 'im'
94
93
  const workspace = isDm ? '@dm' : context.teamId
95
- if (!isAllowed(config.allow, workspace, event.channel)) {
96
- return { kind: 'drop', reason: 'not_in_allow_list' }
97
- }
98
94
 
99
95
  if (context.botUserId === null) {
100
96
  return { kind: 'drop', reason: 'pre_connect' }
@@ -8,7 +8,7 @@ import {
8
8
  } from '@/channels/membership'
9
9
  import { deriveMembershipFromHistory } from '@/channels/membership-from-history'
10
10
  import type { ChannelRouter } from '@/channels/router'
11
- import { isAllowed, type ChannelAdapterConfig } from '@/channels/schema'
11
+ import type { ChannelAdapterConfig } from '@/channels/schema'
12
12
  import type {
13
13
  ChannelHistoryMessage,
14
14
  FetchAttachmentCallback,
@@ -170,15 +170,12 @@ export function createSlackTypingTracker(deps: {
170
170
 
171
171
  export function createTypingCallback(deps: {
172
172
  typingTracker: Pick<SlackTypingTracker, 'setStatus' | 'clearAfterSend'>
173
- configRef: () => ChannelAdapterConfig
174
173
  logger: SlackBotAdapterLogger
175
174
  formatChannelTag?: (workspace: string, chat: string) => Promise<string>
176
175
  }): TypingCallback {
177
- const { typingTracker, configRef, logger, formatChannelTag } = deps
176
+ const { typingTracker, logger, formatChannelTag } = deps
178
177
  return async (target: TypingTarget): Promise<void> => {
179
178
  if (target.adapter !== 'slack-bot') return
180
- const config = configRef()
181
- if (!isAllowed(config.allow, target.workspace, target.chat)) return
182
179
  const tag = formatChannelTag
183
180
  ? await formatChannelTag(target.workspace, target.thread ?? target.chat)
184
181
  : `channel=${target.thread ?? target.chat}`
@@ -370,23 +367,13 @@ function slackFailureForError(error: string): MembershipResolverFailure {
370
367
  // and is the most-tested wire format.
371
368
  export function createSlackHistoryCallback(deps: {
372
369
  token: string
373
- configRef: () => ChannelAdapterConfig
374
370
  logger: SlackBotAdapterLogger
375
371
  botUserIdRef: () => string | null
376
372
  fetchImpl?: typeof fetch
377
373
  }): HistoryCallback {
378
- const { token, configRef, logger, botUserIdRef } = deps
374
+ const { token, logger, botUserIdRef } = deps
379
375
  const fetchFn = deps.fetchImpl ?? fetch
380
376
  return async (args: FetchHistoryArgs): Promise<FetchHistoryResult> => {
381
- const config = configRef()
382
- if (!isAllowed(config.allow, '@dm', args.chat) && !isAllowedAnyTeam(config.allow, args.chat)) {
383
- // Same defense-in-depth as outbound: refuse to fetch history for a
384
- // channel the operator hasn't admitted, even if the agent somehow
385
- // resolved its id. Returning an error rather than empty so the
386
- // agent doesn't think the channel is genuinely silent.
387
- return { ok: false, error: 'denied by allow rules' }
388
- }
389
-
390
377
  const limit = clampLimit(args.limit, SLACK_HISTORY_LIMIT_MAX)
391
378
  const endpoint = args.thread === null ? 'conversations.history' : 'conversations.replies'
392
379
  const body = new URLSearchParams()
@@ -465,25 +452,6 @@ function clampLimit(requested: number, max: number): number {
465
452
  return Math.min(Math.floor(requested), max)
466
453
  }
467
454
 
468
- // Slack channel ids are globally unique on Slack's side, so a `channel:C…`
469
- // or `team:T/C` rule for any team admits this chat. We use this for the
470
- // history allow check because at fetch time we only know the channel id,
471
- // not the workspace (the tool resolves the chat from session origin and
472
- // the workspace doesn't always round-trip through cursor pagination).
473
- function isAllowedAnyTeam(rules: readonly string[], chat: string): boolean {
474
- for (const rule of rules) {
475
- if (rule === '*') return true
476
- if (rule === 'team:*' || rule === 'guild:*') return true
477
- if (rule.startsWith('channel:') && rule.slice(8) === chat) return true
478
- if (rule.startsWith('team:')) {
479
- const body = rule.slice(5)
480
- const slash = body.indexOf('/')
481
- if (slash !== -1 && body.slice(slash + 1) === chat) return true
482
- }
483
- }
484
- return false
485
- }
486
-
487
455
  // Slack supports text+file in a single API call via `initial_comment`, and
488
456
  // honors `thread_ts` on every upload — both luxuries Discord lacks. So we
489
457
  // fold `text` into the FIRST attachment's `initial_comment` rather than
@@ -528,23 +496,17 @@ function buildMarkdownBlock(text: string): MarkdownBlock {
528
496
 
529
497
  export function createOutboundCallback(deps: {
530
498
  client: Pick<SlackBotClient, 'postMessage' | 'uploadFile'>
531
- configRef: () => ChannelAdapterConfig
532
499
  logger: SlackBotAdapterLogger
533
500
  formatChannelTag: (workspace: string, chat: string) => Promise<string>
534
501
  readFile?: (path: string) => Promise<Buffer>
535
502
  typingTracker?: Pick<SlackTypingTracker, 'clearAfterSend'>
536
503
  }): OutboundCallback {
537
- const { client, configRef, logger, formatChannelTag, typingTracker } = deps
504
+ const { client, logger, formatChannelTag, typingTracker } = deps
538
505
  const readFile = deps.readFile ?? readAttachmentBuffer
539
506
  return async (msg: OutboundMessage): Promise<SendResult> => {
540
507
  if (msg.adapter !== 'slack-bot') {
541
508
  return { ok: false, error: `unknown adapter: ${msg.adapter}` }
542
509
  }
543
- const config = configRef()
544
- if (!isAllowed(config.allow, msg.workspace, msg.chat)) {
545
- logger.warn(`[slack-bot] outbound denied by allow rules: ${msg.workspace}/${msg.chat}`)
546
- return { ok: false, error: 'denied by allow rules' }
547
- }
548
510
  const text = msg.text ?? ''
549
511
  const attachments = msg.attachments ?? []
550
512
  if (text === '' && attachments.length === 0) {
@@ -672,14 +634,12 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
672
634
 
673
635
  const typingCallback = createTypingCallback({
674
636
  typingTracker,
675
- configRef: options.configRef,
676
637
  logger,
677
638
  formatChannelTag,
678
639
  })
679
640
 
680
641
  const historyCallback = createSlackHistoryCallback({
681
642
  token: options.token,
682
- configRef: options.configRef,
683
643
  logger,
684
644
  botUserIdRef: () => botUserId,
685
645
  })
@@ -692,7 +652,6 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
692
652
 
693
653
  const outboundCallback = createOutboundCallback({
694
654
  client,
695
- configRef: options.configRef,
696
655
  logger,
697
656
  formatChannelTag,
698
657
  typingTracker,
@@ -865,13 +824,8 @@ function describe(err: unknown): string {
865
824
  return err instanceof Error ? err.message : String(err)
866
825
  }
867
826
 
868
- // Operator hints appended to drop logs. Kept short — full guidance lives in
869
- // docs. The not_in_allow_list hint is the highest-leverage one because that
870
- // failure mode is invisible from Slack's side (bot stays online).
871
827
  function dropHint(reason: InboundDropReason): string {
872
828
  switch (reason) {
873
- case 'not_in_allow_list':
874
- return ' (extend channels.slack-bot.allow in typeclaw.json to admit this team/channel)'
875
829
  case 'empty_text':
876
830
  case 'no_user':
877
831
  case 'pre_connect':
@@ -1,9 +1,9 @@
1
1
  import type { TelegramBotUser, TelegramMessage, TelegramMessageEntity } from 'agent-messenger/telegrambot'
2
2
 
3
- import { isAllowed, type ChannelAdapterConfig } from '@/channels/schema'
3
+ import type { ChannelAdapterConfig } from '@/channels/schema'
4
4
  import type { InboundMessage } from '@/channels/types'
5
5
 
6
- export type InboundDropReason = 'self_author' | 'no_user' | 'empty_text' | 'not_in_allow_list' | 'pre_connect'
6
+ export type InboundDropReason = 'self_author' | 'no_user' | 'empty_text' | 'pre_connect'
7
7
 
8
8
  export type InboundClassification =
9
9
  | { kind: 'drop'; reason: InboundDropReason }
@@ -13,13 +13,14 @@ export const TELEGRAM_WORKSPACE = 'telegram'
13
13
 
14
14
  // Telegram has no team/guild concept — every chat is identified by an
15
15
  // absolute (signed) numeric id. We pin `workspace` to a single bucket so
16
- // allow-rules like `tg:*` and `tg:<chat_id>` have a stable key to match
17
- // against. DMs use `private` chats and route the same way as group chats
18
- // from the router's perspective; `isDm` is set from `chat.type` so the
19
- // engagement layer can apply the DM-specific trigger.
16
+ // match rules like `telegram:*` and `telegram:<chat_id>` resolve against
17
+ // a stable key downstream in the permissions service. DMs use `private`
18
+ // chats and route the same way as group chats from the router's
19
+ // perspective; `isDm` is set from `chat.type` so the engagement layer
20
+ // can apply the DM-specific trigger.
20
21
  export function classifyInbound(
21
22
  event: TelegramMessage,
22
- config: ChannelAdapterConfig,
23
+ _config: ChannelAdapterConfig,
23
24
  bot: TelegramBotUser | null,
24
25
  ): InboundClassification {
25
26
  const author = event.from
@@ -34,9 +35,6 @@ export function classifyInbound(
34
35
  if (text === '') return { kind: 'drop', reason: 'empty_text' }
35
36
 
36
37
  const chat = String(event.chat.id)
37
- if (!isAllowed(config.allow, TELEGRAM_WORKSPACE, chat)) {
38
- return { kind: 'drop', reason: 'not_in_allow_list' }
39
- }
40
38
 
41
39
  if (bot === null) {
42
40
  return { kind: 'drop', reason: 'pre_connect' }
@@ -3,7 +3,7 @@ import type { TelegramBotUser, TelegramMessage } from 'agent-messenger/telegramb
3
3
 
4
4
  import type { MembershipResolver, MembershipResolverFailure, MembershipResolverResult } from '@/channels/membership'
5
5
  import type { ChannelRouter } from '@/channels/router'
6
- import { isAllowed, type ChannelAdapterConfig } from '@/channels/schema'
6
+ import type { ChannelAdapterConfig } from '@/channels/schema'
7
7
  import type {
8
8
  ChannelNameResolver,
9
9
  FetchAttachmentCallback,
@@ -77,12 +77,11 @@ export type TelegramBotAdapter = {
77
77
 
78
78
  export function createTypingCallback(deps: {
79
79
  token: string
80
- configRef: () => ChannelAdapterConfig
81
80
  logger: TelegramBotAdapterLogger
82
81
  formatChannelTag?: (chat: string) => Promise<string>
83
82
  fetchImpl?: typeof fetch
84
83
  }): TypingCallback {
85
- const { token, configRef, logger, formatChannelTag } = deps
84
+ const { token, logger, formatChannelTag } = deps
86
85
  const fetchImpl = deps.fetchImpl ?? fetch
87
86
  return async (target: TypingTarget): Promise<void> => {
88
87
  if (target.adapter !== 'telegram-bot') return
@@ -91,8 +90,6 @@ export function createTypingCallback(deps: {
91
90
  // a missed beat just gaps the indicator. There is no explicit clear,
92
91
  // so the 'stop' phase is a no-op.
93
92
  if (target.phase === 'stop') return
94
- const config = configRef()
95
- if (!isAllowed(config.allow, target.workspace, target.chat)) return
96
93
  const tag = formatChannelTag ? await formatChannelTag(target.chat) : `chat=${target.chat}`
97
94
  const body: Record<string, unknown> = { chat_id: target.chat, action: 'typing' }
98
95
  const threadId = parseThreadId(target.thread)
@@ -197,21 +194,15 @@ export function createTelegramMembershipResolver(deps: {
197
194
 
198
195
  export function createOutboundCallback(deps: {
199
196
  client: Pick<TelegramBotClient, 'sendMessage' | 'sendDocument'>
200
- configRef: () => ChannelAdapterConfig
201
197
  logger: TelegramBotAdapterLogger
202
198
  formatChannelTag: (chat: string) => Promise<string>
203
199
  resolvePath?: (path: string) => string
204
200
  }): OutboundCallback {
205
- const { client, configRef, logger, formatChannelTag, resolvePath } = deps
201
+ const { client, logger, formatChannelTag, resolvePath } = deps
206
202
  return async (msg: OutboundMessage): Promise<SendResult> => {
207
203
  if (msg.adapter !== 'telegram-bot') {
208
204
  return { ok: false, error: `unknown adapter: ${msg.adapter}` }
209
205
  }
210
- const config = configRef()
211
- if (!isAllowed(config.allow, msg.workspace, msg.chat)) {
212
- logger.warn(`[telegram-bot] outbound denied by allow rules: ${msg.workspace}/${msg.chat}`)
213
- return { ok: false, error: 'denied by allow rules' }
214
- }
215
206
  const text = msg.text ?? ''
216
207
  const attachments = msg.attachments ?? []
217
208
  if (text === '' && attachments.length === 0) {
@@ -396,7 +387,6 @@ export function createTelegramBotAdapter(options: TelegramBotAdapterOptions): Te
396
387
 
397
388
  const typingCallback = createTypingCallback({
398
389
  token: options.token,
399
- configRef: options.configRef,
400
390
  logger,
401
391
  formatChannelTag,
402
392
  })
@@ -405,7 +395,6 @@ export function createTelegramBotAdapter(options: TelegramBotAdapterOptions): Te
405
395
 
406
396
  const outboundCallback = createOutboundCallback({
407
397
  client,
408
- configRef: options.configRef,
409
398
  logger,
410
399
  formatChannelTag,
411
400
  })
@@ -595,8 +584,6 @@ function dropHint(reason: InboundDropReason): string {
595
584
  return ' (channel post / anonymous; cannot attribute to an author)'
596
585
  case 'empty_text':
597
586
  return ' (message had no text and no recognized media; check Telegram privacy mode in @BotFather)'
598
- case 'not_in_allow_list':
599
- return ' (extend channels.telegram-bot.allow in typeclaw.json to admit this chat)'
600
587
  case 'pre_connect':
601
588
  case 'self_author':
602
589
  return ''
@@ -2,17 +2,18 @@ export { createChannelManager, type ChannelManager, type ChannelManagerOptions }
2
2
  export {
3
3
  createChannelRouter,
4
4
  type ChannelRouter,
5
+ type ClaimHandler,
6
+ type ClaimHandlerInput,
7
+ type ClaimHandlerOutcome,
5
8
  type CreateChannelRouterOptions,
6
9
  type CreateSessionForChannel,
7
10
  } from './router'
8
11
  export { createChannelsReloadable } from './reloadable'
9
12
  export {
10
13
  channelsSchema,
11
- isAllowed,
12
14
  ADAPTER_IDS,
13
15
  STICKY_DEFAULT_WINDOW_MS,
14
16
  type AdapterId,
15
- type AllowRule,
16
17
  type ChannelAdapterConfig,
17
18
  type ChannelsConfig,
18
19
  type EngagementConfig,
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { join } from 'node:path'
3
3
 
4
+ import type { PermissionService } from '@/permissions'
4
5
  import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
5
6
  import { SecretsBackend } from '@/secrets/storage'
6
7
 
@@ -8,7 +9,7 @@ import { createDiscordBotAdapter, type DiscordBotAdapter } from './adapters/disc
8
9
  import { createKakaotalkAdapter, type KakaotalkAdapter } from './adapters/kakaotalk'
9
10
  import { createSlackBotAdapter, type SlackBotAdapter } from './adapters/slack-bot'
10
11
  import { createTelegramBotAdapter, type TelegramBotAdapter } from './adapters/telegram-bot'
11
- import { createChannelRouter, type ChannelRouter, type CreateSessionForChannel } from './router'
12
+ import { createChannelRouter, type ChannelRouter, type ClaimHandler, type CreateSessionForChannel } from './router'
12
13
  import { ADAPTER_IDS, type AdapterId, type ChannelAdapterConfig, type ChannelsConfig } from './schema'
13
14
 
14
15
  export type ChannelManagerLogger = {
@@ -50,6 +51,17 @@ export type ChannelManagerOptions = {
50
51
  createKakaotalkAdapter?: typeof createKakaotalkAdapter
51
52
  createSlackAdapter?: typeof createSlackBotAdapter
52
53
  createTelegramAdapter?: typeof createTelegramBotAdapter
54
+ // Wake-up gate: forwarded to the router, which calls
55
+ // `permissions.has(origin, 'channel.respond')` BEFORE creating a
56
+ // session for any inbound. Optional here to keep direct manager-level
57
+ // tests easy to spin up; production wiring in src/run/index.ts always
58
+ // passes `pluginsLoaded.permissions`. Omitting it falls through to the
59
+ // router's grant-all default — see CreateChannelRouterOptions.
60
+ permissions?: PermissionService
61
+ // Forwarded to the router; intercepts DM inbounds carrying a role-claim
62
+ // code. Production wiring sets this from the role-claim subsystem (see
63
+ // src/run/index.ts). Tests typically omit it.
64
+ claimHandler?: ClaimHandler
53
65
  }
54
66
 
55
67
  export type ChannelManager = {
@@ -82,6 +94,8 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
82
94
  logger,
83
95
  ...(options.aliasesRef ? { configuredAliases: options.aliasesRef } : {}),
84
96
  ...(options.createSessionForChannel ? { createSessionForChannel: options.createSessionForChannel } : {}),
97
+ ...(options.permissions ? { permissions: options.permissions } : {}),
98
+ ...(options.claimHandler ? { claimHandler: options.claimHandler } : {}),
85
99
  })
86
100
  const createDiscordAdapter = options.createDiscordAdapter ?? createDiscordBotAdapter
87
101
  const createKakaotalk = options.createKakaotalkAdapter ?? createKakaotalkAdapter
@@ -6,7 +6,7 @@ import type { ChannelParticipant } from '@/agent/session-origin'
6
6
  import type { AdapterId } from './schema'
7
7
  import type { ChannelKey } from './types'
8
8
 
9
- const FILE_VERSION = 3
9
+ const FILE_VERSION = 4
10
10
 
11
11
  // `sessionFile` is the basename (not the full path) of the JSONL transcript
12
12
  // for this (adapter, workspace, chat, thread) tuple. pi-coding-agent writes
@@ -25,11 +25,17 @@ export type ChannelSessionRecord = {
25
25
  workspace: string
26
26
  chat: string
27
27
  thread: string | null
28
- sessionId: string
28
+ sessionId?: string
29
29
  sessionFile?: string
30
+ lastInboundAt?: number
30
31
  participants: ChannelParticipant[]
31
32
  }
32
33
 
34
+ type FileV4 = {
35
+ version: 4
36
+ sessions: ChannelSessionRecord[]
37
+ }
38
+
33
39
  type FileV3 = {
34
40
  version: 3
35
41
  sessions: ChannelSessionRecord[]
@@ -41,11 +47,13 @@ type FileV2 = {
41
47
  }
42
48
 
43
49
  export type ChannelSessionsLogger = {
50
+ info: (msg: string) => void
44
51
  warn: (msg: string) => void
45
52
  error: (msg: string) => void
46
53
  }
47
54
 
48
55
  const consoleLogger: ChannelSessionsLogger = {
56
+ info: (m) => console.log(m),
49
57
  warn: (m) => console.warn(m),
50
58
  error: (m) => console.error(m),
51
59
  }
@@ -82,17 +90,25 @@ export async function loadChannelSessions(
82
90
  }
83
91
  const version = (parsed as { version?: unknown }).version
84
92
  if (version === FILE_VERSION) {
85
- const file = parsed as FileV3
93
+ const file = parsed as FileV4
86
94
  if (!Array.isArray(file.sessions)) return []
87
95
  return file.sessions.filter(isValidRecord)
88
96
  }
97
+ if (version === 3) {
98
+ const file = parsed as FileV3
99
+ if (!Array.isArray(file.sessions)) return []
100
+ return migrateV3ToV4(file.sessions.filter(isValidRecord), logger)
101
+ }
89
102
  if (version === 2) {
90
103
  const file = parsed as FileV2
91
104
  if (!Array.isArray(file.sessions)) return []
92
105
  const v2Records = file.sessions.filter(isValidV2Record)
93
- return await migrateV2Records(agentDir, v2Records, logger)
106
+ const v3Records = await migrateV2Records(agentDir, v2Records, logger)
107
+ return migrateV3ToV4(v3Records, logger)
94
108
  }
95
- logger.warn(`[channels] ${path} version ${String(version)} not supported (expected 2 or ${FILE_VERSION}); ignored`)
109
+ logger.warn(
110
+ `[channels] ${path} version ${String(version)} not supported (expected 2, 3, or ${FILE_VERSION}); ignored`,
111
+ )
96
112
  return []
97
113
  }
98
114
 
@@ -102,7 +118,7 @@ export async function saveChannelSessions(
102
118
  logger: ChannelSessionsLogger = consoleLogger,
103
119
  ): Promise<void> {
104
120
  const path = channelsSessionsPath(agentDir)
105
- const payload: FileV3 = { version: FILE_VERSION, sessions: dedupe(sessions) }
121
+ const payload: FileV4 = { version: FILE_VERSION, sessions: dedupe(sessions) }
106
122
  try {
107
123
  await mkdir(dirname(path), { recursive: true })
108
124
  const tmp = `${path}.tmp`
@@ -123,7 +139,7 @@ export async function saveChannelSessions(
123
139
  // we'll be migrated forward.)
124
140
  async function migrateV2Records(
125
141
  agentDir: string,
126
- v2Records: readonly Omit<ChannelSessionRecord, 'sessionFile'>[],
142
+ v2Records: readonly (Omit<ChannelSessionRecord, 'sessionFile' | 'sessionId'> & { sessionId: string })[],
127
143
  logger: ChannelSessionsLogger,
128
144
  ): Promise<ChannelSessionRecord[]> {
129
145
  if (v2Records.length === 0) return []
@@ -160,6 +176,13 @@ async function migrateV2Records(
160
176
  })
161
177
  }
162
178
 
179
+ function migrateV3ToV4(v3Records: ChannelSessionRecord[], logger: ChannelSessionsLogger): ChannelSessionRecord[] {
180
+ logger.info(
181
+ `[channels] v3→v4: ${v3Records.length} record(s) migrated; first post-upgrade inbound will force fresh session`,
182
+ )
183
+ return v3Records.map((r) => ({ ...r, lastInboundAt: 0 }))
184
+ }
185
+
163
186
  function dedupe(sessions: readonly ChannelSessionRecord[]): ChannelSessionRecord[] {
164
187
  const seen = new Map<string, ChannelSessionRecord>()
165
188
  for (const s of sessions) {
@@ -185,7 +208,9 @@ function isObject(v: unknown): v is Record<string, unknown> {
185
208
  return typeof v === 'object' && v !== null && !Array.isArray(v)
186
209
  }
187
210
 
188
- function isValidV2Record(v: unknown): v is Omit<ChannelSessionRecord, 'sessionFile'> {
211
+ function isValidV2Record(
212
+ v: unknown,
213
+ ): v is Omit<ChannelSessionRecord, 'sessionFile' | 'sessionId'> & { sessionId: string } {
189
214
  if (!isObject(v)) return false
190
215
  const r = v as Record<string, unknown>
191
216
  return (
@@ -199,9 +224,18 @@ function isValidV2Record(v: unknown): v is Omit<ChannelSessionRecord, 'sessionFi
199
224
  }
200
225
 
201
226
  function isValidRecord(v: unknown): v is ChannelSessionRecord {
202
- if (!isValidV2Record(v)) return false
227
+ if (!isObject(v)) return false
203
228
  const r = v as Record<string, unknown>
204
- return r.sessionFile === undefined || typeof r.sessionFile === 'string'
229
+ return (
230
+ typeof r.adapter === 'string' &&
231
+ typeof r.workspace === 'string' &&
232
+ typeof r.chat === 'string' &&
233
+ (r.thread === null || typeof r.thread === 'string') &&
234
+ (r.sessionId === undefined || typeof r.sessionId === 'string') &&
235
+ (r.sessionFile === undefined || typeof r.sessionFile === 'string') &&
236
+ (r.lastInboundAt === undefined || typeof r.lastInboundAt === 'number') &&
237
+ Array.isArray(r.participants)
238
+ )
205
239
  }
206
240
 
207
241
  function describe(err: unknown): string {