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
@@ -6,7 +6,9 @@ import { SessionManager } from '@mariozechner/pi-coding-agent'
6
6
  import { createSession, type AgentSession } from '@/agent'
7
7
  import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
8
8
  import { createCommandRegistry } from '@/commands'
9
+ import { CORE_PERMISSIONS, type PermissionService } from '@/permissions'
9
10
  import type { HookBus } from '@/plugin'
11
+ import { extractClaimCode } from '@/role-claim'
10
12
 
11
13
  import { decideEngagement, grantStickyForReplyTargets, StickyLedger, type EngagementDecision } from './engagement'
12
14
  import {
@@ -75,6 +77,18 @@ export const MAX_TYPING_HEARTBEAT_MS = 2 * 60 * 1000
75
77
  export const SESSION_IDLE_MS = 30 * 60 * 1000
76
78
  export const SESSION_GC_INTERVAL_MS = 60 * 1000
77
79
 
80
+ /**
81
+ * Maximum age of the last engaged inbound before the next inbound triggers a fresh session.
82
+ * Set to the LLM provider's KV-cache TTL (5 min) so the new session's system prompt is
83
+ * guaranteed to be a cache hit on the provider side.
84
+ *
85
+ * Unlike SESSION_IDLE_MS (which evicts the in-memory entry without rollover), this constant
86
+ * triggers a full tearDownLive + recreate on the next engaged inbound. The old session's
87
+ * transcript is preserved on disk; only the in-memory live entry and sessions.json pointer
88
+ * are replaced.
89
+ */
90
+ export const SESSION_FRESHNESS_TTL_MS = 5 * 60 * 1000
91
+
78
92
  // Watchdog ceiling for ensureLive's full async chain (resolve names →
79
93
  // fetch membership → open session manager → persist mapping → prefetch
80
94
  // history). A legitimate cold-start completes in well under a second;
@@ -154,6 +168,12 @@ export type CreateSessionForChannel = (params: {
154
168
  existingSessionFile?: string
155
169
  participants: readonly ChannelParticipant[]
156
170
  origin: SessionOrigin
171
+ // Mutable holder the router updates per turn (with the current turn's
172
+ // lastInboundAuthorId, participants, etc.) so tool.before events stamp
173
+ // the live actor identity rather than the cold-start snapshot. The
174
+ // factory is expected to pass this through to createSession as
175
+ // `options.originRef`.
176
+ originRef: { current: SessionOrigin | undefined }
157
177
  }) => Promise<{
158
178
  session: AgentSession
159
179
  sessionId: string
@@ -201,6 +221,7 @@ type LiveSession = {
201
221
  getTranscriptPath: (() => string | undefined) | undefined
202
222
  participants: ChannelParticipant[]
203
223
  resolvedNames: ResolvedChannelNames
224
+ originRef: { current: SessionOrigin | undefined }
204
225
  promptQueue: QueuedInbound[]
205
226
  contextBuffer: ObservedInbound[]
206
227
  draining: boolean
@@ -308,6 +329,46 @@ export type CreateChannelRouterOptions = {
308
329
  // Test seam: bound the session.idle hook chain so the timeout path is
309
330
  // exercisable in tens of milliseconds instead of the 30s default.
310
331
  sessionIdleTimeoutMs?: number
332
+ // Wake-up gate: every inbound is gated by `permissions.has(partialOrigin,
333
+ // 'channel.respond')` BEFORE ensureLive. Required by the production
334
+ // wiring (manager.ts forwards `pluginsLoaded.permissions`); defaulted
335
+ // to a grant-all service inside the factory so existing direct test
336
+ // instantiations don't need to inject one. The default is intentionally
337
+ // permissive — the manager-to-router seam is the place where production
338
+ // injection is enforced; direct-router tests opt into gate semantics by
339
+ // passing their own service.
340
+ permissions?: PermissionService
341
+ // Optional role-claim handler. When set, the router intercepts DM
342
+ // inbounds whose text contains a claim code BEFORE the channel.respond
343
+ // gate, hands the inbound to the handler, and short-circuits the normal
344
+ // route path (no session creation, no permission check, no engagement
345
+ // pipeline). The handler returns the reply text the router should send
346
+ // back over the same chat, or null to fall through to normal routing
347
+ // when no pending claim window matches.
348
+ claimHandler?: ClaimHandler
349
+ }
350
+
351
+ export type ClaimHandlerInput = {
352
+ adapter: ChannelKey['adapter']
353
+ workspace: string
354
+ chat: string
355
+ isDm: boolean
356
+ authorId: string
357
+ text: string
358
+ }
359
+
360
+ export type ClaimHandlerOutcome =
361
+ | { kind: 'consumed'; reply: string }
362
+ | { kind: 'fail'; reply: string }
363
+ | { kind: 'fallthrough' }
364
+
365
+ export type ClaimHandler = (input: ClaimHandlerInput) => Promise<ClaimHandlerOutcome>
366
+
367
+ const GRANT_ALL_PERMISSIONS: PermissionService = {
368
+ has: () => true,
369
+ resolveRole: () => 'owner',
370
+ describe: () => ({ role: 'owner', permissions: [CORE_PERMISSIONS.channelRespond] }),
371
+ replaceRoles: () => {},
311
372
  }
312
373
 
313
374
  export function createChannelRouter(options: CreateChannelRouterOptions): ChannelRouter {
@@ -317,6 +378,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
317
378
  const resolveChannelNamesTimeoutMs = options.resolveChannelNamesTimeoutMs ?? RESOLVE_CHANNEL_NAMES_TIMEOUT_MS
318
379
  const fetchHistoryTimeoutMs = options.fetchHistoryTimeoutMs ?? FETCH_HISTORY_TIMEOUT_MS
319
380
  const sessionIdleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS
381
+ const permissions = options.permissions ?? GRANT_ALL_PERMISSIONS
382
+ const claimHandler = options.claimHandler
320
383
  const liveSessions = new Map<string, LiveSession>()
321
384
  const creating = new Map<string, Promise<LiveSession>>()
322
385
  const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
@@ -355,6 +418,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
355
418
 
356
419
  let mappings: ChannelSessionRecord[] | null = null
357
420
  let loadOnce: Promise<void> | null = null
421
+ let persistChain: Promise<void> = Promise.resolve()
358
422
 
359
423
  const ensureLoaded = async (): Promise<void> => {
360
424
  if (mappings !== null) return
@@ -368,12 +432,16 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
368
432
 
369
433
  const persist = async (): Promise<void> => {
370
434
  if (mappings === null) return
371
- await saveChannelSessions(options.agentDir, mappings, logger)
435
+ persistChain = persistChain.then(async () => {
436
+ if (mappings === null) return
437
+ await saveChannelSessions(options.agentDir, mappings, logger)
438
+ })
439
+ await persistChain
372
440
  }
373
441
 
374
442
  const createForChannel: CreateSessionForChannel =
375
443
  options.createSessionForChannel ??
376
- (async ({ key, existingSessionId, existingSessionFile, origin }) => {
444
+ (async ({ key, existingSessionId, existingSessionFile, origin, originRef }) => {
377
445
  const sessionDir = options.sessionDir ?? `${options.agentDir}/sessions`
378
446
  const sessionManager =
379
447
  existingSessionId !== undefined
@@ -382,6 +450,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
382
450
  const session = await createSession({
383
451
  sessionManager,
384
452
  origin,
453
+ originRef,
385
454
  })
386
455
  const sessionId = sessionManager.getSessionId()
387
456
  void key
@@ -476,10 +545,44 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
476
545
  return membership
477
546
  }
478
547
 
479
- const ensureLive = async (key: ChannelKey, triggeringMessageId?: string): Promise<LiveSession> => {
548
+ const ensureLive = async (
549
+ key: ChannelKey,
550
+ triggeringMessageId?: string,
551
+ triggeringAuthorId?: string,
552
+ ): Promise<LiveSession> => {
480
553
  const keyId = channelKeyId(key)
481
554
  const existing = liveSessions.get(keyId)
482
- if (existing && !existing.destroyed) return existing
555
+ if (existing && !existing.destroyed) {
556
+ const idleMs = now() - existing.lastInboundAt
557
+ if (idleMs > SESSION_FRESHNESS_TTL_MS) {
558
+ logger.info(`[channels] ${keyId}: stale-rollover (live: ${idleMs}ms idle)`)
559
+ await tearDownLive(existing)
560
+ liveSessions.delete(keyId)
561
+ if (mappings) {
562
+ const idx = mappings.findIndex(
563
+ (s) =>
564
+ s.adapter === key.adapter &&
565
+ s.workspace === key.workspace &&
566
+ s.chat === key.chat &&
567
+ (s.thread ?? null) === (key.thread ?? null),
568
+ )
569
+ if (idx >= 0) {
570
+ const prev = mappings[idx]!
571
+ mappings[idx] = {
572
+ adapter: prev.adapter,
573
+ workspace: prev.workspace,
574
+ chat: prev.chat,
575
+ thread: prev.thread,
576
+ participants: prev.participants,
577
+ lastInboundAt: 0,
578
+ }
579
+ await persist()
580
+ }
581
+ }
582
+ } else {
583
+ return existing
584
+ }
585
+ }
483
586
 
484
587
  const inFlight = creating.get(keyId)
485
588
  if (inFlight) return inFlight
@@ -487,14 +590,51 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
487
590
  const promise = (async () => {
488
591
  await ensureLoaded()
489
592
  const record = mappings ? findRecord(mappings, key) : undefined
490
- const phase = record?.sessionId === undefined ? 'cold-start' : 'rehydrate'
593
+ let resolvedRecord = record
594
+ if (
595
+ record?.sessionId !== undefined &&
596
+ existing === undefined &&
597
+ now() - (record.lastInboundAt ?? 0) > SESSION_FRESHNESS_TTL_MS
598
+ ) {
599
+ const idleMs = now() - (record.lastInboundAt ?? 0)
600
+ logger.info(`[channels] ${keyId}: stale-rollover (persisted: ${idleMs}ms idle)`)
601
+ resolvedRecord = {
602
+ adapter: record.adapter,
603
+ workspace: record.workspace,
604
+ chat: record.chat,
605
+ thread: record.thread,
606
+ participants: record.participants,
607
+ lastInboundAt: 0,
608
+ }
609
+ if (mappings) {
610
+ const idx = mappings.findIndex(
611
+ (s) =>
612
+ s.adapter === key.adapter &&
613
+ s.workspace === key.workspace &&
614
+ s.chat === key.chat &&
615
+ (s.thread ?? null) === (key.thread ?? null),
616
+ )
617
+ if (idx >= 0) {
618
+ mappings[idx] = resolvedRecord
619
+ await persist()
620
+ }
621
+ }
622
+ }
623
+ const phase = resolvedRecord?.sessionId === undefined ? 'cold-start' : 'rehydrate'
491
624
  logger.info(`[channels] ${keyId}: ensureLive begin (${phase})`)
492
- const participants = (record?.participants ?? []) as ChannelParticipant[]
625
+ const participants = (resolvedRecord?.participants ?? []) as ChannelParticipant[]
493
626
  const membershipFetch = warmMembership(key)
494
627
  const resolvedNames = await resolveChannelNames(key)
495
628
  logger.info(`[channels] ${keyId}: ensureLive resolved-names`)
496
629
  const membership = await membershipForPrompt(key, membershipFetch)
497
630
  logger.info(`[channels] ${keyId}: ensureLive resolved-membership`)
631
+ // The session-creation origin is what the resource loader sees when it
632
+ // renders the role/permissions block into the system prompt. It must
633
+ // include the triggering author so author-scoped roles
634
+ // (`slack:T/C author:U_ME`) resolve to the same role here that the
635
+ // channel.respond gate just admitted on. Per-turn updates after this
636
+ // point are handled by `originRef.current = buildLiveOrigin(live)`
637
+ // before each prompt() call.
498
638
  const origin: SessionOrigin = {
499
639
  kind: 'channel',
500
640
  adapter: key.adapter,
@@ -503,18 +643,24 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
503
643
  chat: key.chat,
504
644
  ...(resolvedNames.chatName !== undefined ? { chatName: resolvedNames.chatName } : {}),
505
645
  thread: key.thread,
646
+ ...(triggeringAuthorId !== undefined ? { lastInboundAuthorId: triggeringAuthorId } : {}),
506
647
  participants,
507
648
  ...(membership !== null ? { membership } : {}),
508
649
  }
509
650
 
510
- const isColdStart = record?.sessionId === undefined
651
+ const isColdStart = resolvedRecord?.sessionId === undefined
652
+
653
+ // The router writes into this holder before every prompt() so the
654
+ // tool wrappers' getOrigin() sees the current-turn origin.
655
+ const originRef: { current: SessionOrigin | undefined } = { current: origin }
511
656
 
512
657
  const created = await createForChannel({
513
658
  key,
514
- ...(record?.sessionId ? { existingSessionId: record.sessionId } : {}),
515
- ...(record?.sessionFile ? { existingSessionFile: record.sessionFile } : {}),
659
+ ...(resolvedRecord?.sessionId ? { existingSessionId: resolvedRecord.sessionId } : {}),
660
+ ...(resolvedRecord?.sessionFile ? { existingSessionFile: resolvedRecord.sessionFile } : {}),
516
661
  participants,
517
662
  origin,
663
+ originRef,
518
664
  })
519
665
  logger.info(`[channels] ${keyId}: ensureLive session-created sessionId=${created.sessionId}`)
520
666
 
@@ -526,6 +672,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
526
672
  thread: key.thread,
527
673
  sessionId: created.sessionId,
528
674
  ...(transcriptPath ? { sessionFile: basename(transcriptPath) } : {}),
675
+ lastInboundAt: now(),
529
676
  participants,
530
677
  }
531
678
  if (mappings) {
@@ -553,6 +700,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
553
700
  getTranscriptPath: created.getTranscriptPath,
554
701
  participants,
555
702
  resolvedNames,
703
+ originRef,
556
704
  promptQueue: [],
557
705
  contextBuffer: [],
558
706
  draining: false,
@@ -697,8 +845,6 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
697
845
  await persist()
698
846
  }
699
847
 
700
- const regenerateOrigin = (live: LiveSession): SessionOrigin => buildLiveOrigin(live)
701
-
702
848
  const fireTyping = async (live: LiveSession, phase: 'tick' | 'stop'): Promise<void> => {
703
849
  const callbacks = typingCallbacks.get(live.key.adapter)
704
850
  if (!callbacks || callbacks.size === 0) return
@@ -860,13 +1006,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
860
1006
  live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
861
1007
  if (batch.length > 0) live.consecutiveSends.clear()
862
1008
 
863
- // The agent's view of the channel should reflect the current
864
- // participants + last inbound author. We update the in-memory
865
- // origin via the session-origin renderer, but the loader was
866
- // captured at session creation. v0.1 keeps the per-session loader
867
- // (so origin reflects participants at session-creation time);
868
- // per-prompt regeneration of system prompts is a v0.2 work.
869
- void regenerateOrigin
1009
+ // Update the live origin holder so this turn's tool.before events
1010
+ // carry the current actor's id. The DefaultResourceLoader still
1011
+ // renders the session-creation origin into the system prompt (v0.2
1012
+ // work to regenerate that per-turn); but permission gating off
1013
+ // `lastInboundAuthorId` happens in the tool layer and now sees the
1014
+ // live value.
1015
+ live.originRef.current = buildLiveOrigin(live)
870
1016
 
871
1017
  // Bracketing logs around the LLM call so a hung prompt() is
872
1018
  // diagnosable from logs alone (we see prompting without prompted).
@@ -906,6 +1052,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
906
1052
  const elapsedSinceFirst = t - live.firstUnprocessedAt
907
1053
  const wait = Math.max(0, Math.min(baseWait, MAX_DEBOUNCE_MS - elapsedSinceFirst))
908
1054
  live.lastInboundAt = t
1055
+ if (mappings) {
1056
+ const idx = mappings.findIndex(
1057
+ (s) =>
1058
+ s.adapter === live.key.adapter &&
1059
+ s.workspace === live.key.workspace &&
1060
+ s.chat === live.key.chat &&
1061
+ (s.thread ?? null) === (live.key.thread ?? null),
1062
+ )
1063
+ if (idx >= 0) {
1064
+ mappings[idx] = { ...mappings[idx]!, lastInboundAt: t }
1065
+ void persist()
1066
+ }
1067
+ }
909
1068
  live.debounceTimer = setTimeout(() => {
910
1069
  live.debounceTimer = null
911
1070
  live.firstUnprocessedAt = 0
@@ -924,8 +1083,46 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
924
1083
  thread: event.thread,
925
1084
  }
926
1085
 
1086
+ // Role-claim intercept runs BEFORE the channel.respond gate so the
1087
+ // operator can bootstrap permissions on a fresh agent that has no
1088
+ // role match rules yet. Cheap pre-check: only DMs whose text contains
1089
+ // a `claim-` prefix can be claim attempts, and only when a handler
1090
+ // is registered. Everything else falls straight through to the gate.
1091
+ if (claimHandler !== undefined && event.isDm && extractClaimCode(event.text) !== null) {
1092
+ const outcome = await claimHandler({
1093
+ adapter: event.adapter,
1094
+ workspace: event.workspace,
1095
+ chat: event.chat,
1096
+ isDm: event.isDm,
1097
+ authorId: event.authorId,
1098
+ text: event.text,
1099
+ })
1100
+ if (outcome.kind !== 'fallthrough') {
1101
+ logger.info(
1102
+ `[channels] ${channelKeyId(key)}: claim ${outcome.kind} author=${event.authorId} id=${event.externalMessageId}`,
1103
+ )
1104
+ await send({
1105
+ adapter: event.adapter,
1106
+ workspace: event.workspace,
1107
+ chat: event.chat,
1108
+ thread: event.thread,
1109
+ text: outcome.reply,
1110
+ })
1111
+ return
1112
+ }
1113
+ }
1114
+
1115
+ if (isChannelRespondDenied(event)) {
1116
+ logger.info(
1117
+ `[channels] ${channelKeyId(key)}: denied by permissions (channel.respond) author=${event.authorId} id=${event.externalMessageId}`,
1118
+ )
1119
+ return
1120
+ }
1121
+
927
1122
  const parsedCommand = commands.parse(event.text)
928
1123
  if (parsedCommand !== null) {
1124
+ // Commands are control traffic, not engaged inbounds; if the session is stale,
1125
+ // the next engaged inbound will perform the rollover before prompting.
929
1126
  const keyId = channelKeyId(key)
930
1127
  if (!commands.has(parsedCommand.name)) {
931
1128
  logger.info(`[channels] ${keyId}: ignoring unknown command /${parsedCommand.name}`)
@@ -940,7 +1137,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
940
1137
  if (commandResult.kind !== 'not-command') return
941
1138
  }
942
1139
 
943
- const live = await ensureLive(key, event.externalMessageId)
1140
+ const live = await ensureLive(key, event.externalMessageId, event.authorId)
944
1141
 
945
1142
  const isNewAuthor = !live.participants.some((p) => p.authorId === event.authorId)
946
1143
  live.participants = updateParticipants(
@@ -1010,6 +1207,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1010
1207
  scheduleDebouncedDrain(live)
1011
1208
  }
1012
1209
 
1210
+ const isChannelRespondDenied = (event: InboundMessage): boolean => {
1211
+ const partial: SessionOrigin = {
1212
+ kind: 'channel',
1213
+ adapter: event.adapter,
1214
+ workspace: event.workspace,
1215
+ chat: event.chat,
1216
+ thread: event.thread,
1217
+ lastInboundAuthorId: event.authorId,
1218
+ }
1219
+ return !permissions.has(partial, CORE_PERMISSIONS.channelRespond)
1220
+ }
1221
+
1013
1222
  const updateLoopGuard = (live: LiveSession, event: InboundMessage): void => {
1014
1223
  if (!event.authorIsBot) {
1015
1224
  live.recentEngagedPeerBotTurns.length = 0
@@ -4,11 +4,6 @@ export const ADAPTER_IDS = ['discord-bot', 'kakaotalk', 'slack-bot', 'telegram-b
4
4
 
5
5
  export type AdapterId = (typeof ADAPTER_IDS)[number]
6
6
 
7
- const allowRuleSchema = z.string().min(1).refine(isValidAllowRule, {
8
- message:
9
- 'allow rule must be one of: *, guild:*, guild:<id>, guild:<id>/<channel>, team:*, team:<id>, team:<id>/<channel>, tg:*, tg:<chat_id>, channel:<id>, dm:*, dm:<id>, im:*, im:<id>, kakao:*, kakao:<chat>, kakao:dm/*, kakao:group/*, kakao:open/*',
10
- })
11
-
12
7
  const engagementTriggerSchema = z.enum(['mention', 'reply', 'dm'])
13
8
 
14
9
  const stickinessSchema = z.union([
@@ -92,8 +87,13 @@ const historySchema = z
92
87
  },
93
88
  })
94
89
 
90
+ // Deliberately non-strict: a stale on-disk file may still carry the
91
+ // legacy `allow` field (`migrateLegacyConfigShape` lifts it into
92
+ // `roles.member.match[]` on load, but a between-reload window can
93
+ // briefly contain both). Zod silently drops unknown keys here, which is
94
+ // exactly what we want — a hard `.strict()` reject would brick recovery
95
+ // for any user mid-migration.
95
96
  const adapterSchema = z.object({
96
- allow: z.array(allowRuleSchema).default([]),
97
97
  engagement: engagementSchema,
98
98
  history: historySchema,
99
99
  enabled: z.boolean().default(true),
@@ -118,156 +118,6 @@ export const channelsSchema = z
118
118
  })
119
119
  .default({})
120
120
 
121
- export type AllowRule = string
122
121
  export type EngagementConfig = z.infer<typeof engagementSchema>
123
122
  export type ChannelAdapterConfig = z.infer<typeof adapterSchema>
124
- export type KakaotalkAdapterConfig = ChannelAdapterConfig
125
123
  export type ChannelsConfig = z.infer<typeof channelsSchema>
126
-
127
- // Discord IDs are numeric snowflakes; Slack IDs start with a single uppercase
128
- // letter (T for teams, C/D/G for channels) followed by alphanumerics; Telegram
129
- // chat IDs are signed integers (negative for groups, `-100…` for supergroups
130
- // and channels); KakaoTalk chat IDs are LOCO-protocol decimal integers
131
- // (large enough to need BigInt at the protocol layer, but rendered as plain
132
- // decimal strings here). All shapes are accepted on every adapter so the
133
- // allow list stays declarative — the runtime ensures only the right adapter
134
- // ever sees its own IDs.
135
- const RULE_PATTERNS = [
136
- /^\*$/,
137
- // Discord
138
- /^guild:\*$/,
139
- /^guild:[0-9]+$/,
140
- /^guild:[0-9]+\/[0-9]+$/,
141
- /^dm:\*$/,
142
- /^dm:[0-9]+$/,
143
- // Slack
144
- /^team:\*$/,
145
- /^team:[A-Z0-9]+$/,
146
- /^team:[A-Z0-9]+\/[A-Z0-9]+$/,
147
- /^im:\*$/,
148
- /^im:[A-Z0-9]+$/,
149
- // Telegram (`tg:*` admits all chats; `tg:<chat_id>` scopes to one chat —
150
- // numeric, may be negative). There is no team/guild concept; every chat is
151
- // identified by its absolute id.
152
- /^tg:\*$/,
153
- /^tg:-?[0-9]+$/,
154
- // KakaoTalk: a single workspace per logged-in account, so the rules scope
155
- // by chat-type (1:1 / group / open) rather than by workspace. `kakao:*`
156
- // admits every chat the account can see; `kakao:dm/*`, `kakao:group/*`,
157
- // `kakao:open/*` admit one chat-type bucket; `kakao:<chat-id>` admits a
158
- // single chat. The runtime classifies each chat into a bucket based on
159
- // KakaoChat.type at chat-resolver time and surfaces the bucket via the
160
- // workspace coordinate.
161
- /^kakao:\*$/,
162
- /^kakao:dm\/\*$/,
163
- /^kakao:group\/\*$/,
164
- /^kakao:open\/\*$/,
165
- /^kakao:[0-9]+$/,
166
- // Shared (channel ids are unique on both platforms)
167
- /^channel:[A-Z0-9]+$/,
168
- /^channel:-?[0-9]+$/,
169
- ]
170
-
171
- function isValidAllowRule(rule: string): boolean {
172
- return RULE_PATTERNS.some((p) => p.test(rule))
173
- }
174
-
175
- export function isAllowed(rules: readonly AllowRule[], workspace: string, chat: string): boolean {
176
- for (const rule of rules) {
177
- if (matchRule(rule, workspace, chat)) return true
178
- }
179
- return false
180
- }
181
-
182
- // `*` → every workspace channel + every DM (catch-all)
183
- // `guild:*` → every Discord guild channel (no DMs)
184
- // `guild:G` → every channel in guild G
185
- // `guild:G/C` → channel C in guild G only
186
- // `team:*` → every Slack team channel (no DMs)
187
- // `team:T` → every channel in team T
188
- // `team:T/C` → channel C in team T only
189
- // `tg:*` → every Telegram chat (DMs, groups, supergroups, channels)
190
- // `tg:C` → Telegram chat C only (signed numeric chat id)
191
- // `channel:C` → channel C in any workspace (IDs are globally unique on
192
- // Discord/Slack and Telegram chat ids are also globally
193
- // unique numeric values)
194
- // `dm:*` → every Discord DM
195
- // `dm:C` → Discord DM channel C only
196
- // `im:*` → every Slack DM (im channel)
197
- // `im:D` → Slack DM channel D only
198
- // `kakao:*` → every KakaoTalk chat the account is in
199
- // `kakao:dm/*` → every KakaoTalk 1:1 chat
200
- // `kakao:group/*` → every KakaoTalk group chat
201
- // `kakao:open/*` → every KakaoTalk open chat
202
- // `kakao:<id>` → KakaoTalk chat with the given numeric chat_id
203
- //
204
- // `guild:`/`dm:`, `team:`/`im:`, `tg:`, and `kakao:` identify which adapter
205
- // the rule was written for, but the matcher applies any rule that the
206
- // (workspace, chat) pair satisfies. That keeps the adapter-side coupling at
207
- // the schema/UX layer (Slack users write `team:`, Discord users write
208
- // `guild:`, Telegram users write `tg:`, KakaoTalk users write `kakao:`)
209
- // without bloating the matching logic. Telegram has no workspace concept;
210
- // the adapter pins workspace to `'telegram'` so `tg:*` only ever admits
211
- // Telegram chats. KakaoTalk uses `@kakao-dm` / `@kakao-group` / `@kakao-open`
212
- // as workspace coordinates so the bucket-* rules are pure prefix matches
213
- // against `workspace`.
214
- function matchRule(rule: string, workspace: string, chat: string): boolean {
215
- // KakaoTalk workspaces accept the global `*` catch-all or any `kakao:`
216
- // rule. Adapter-specific non-kakao rules (`team:*`, `guild:*`, `dm:*`,
217
- // `im:*`, `tg:*`) never admit kakao workspaces — those are scoped to
218
- // their own adapter's coordinate space and would be meaningless here.
219
- // The init wizard still defaults kakaotalk to the narrower `kakao:dm/*`
220
- // (group chats with personal accounts are sensitive — every member sees
221
- // every reply), so opting into `*` is an explicit, per-adapter decision
222
- // made in `channels.kakaotalk.allow`.
223
- if (KAKAO_WORKSPACES.has(workspace)) {
224
- if (rule === '*') return true
225
- if (rule.startsWith('kakao:')) return matchKakaoRule(rule.slice(6), workspace, chat)
226
- return false
227
- }
228
-
229
- if (rule === '*') return true
230
- if (rule.startsWith('kakao:')) return false
231
-
232
- if (workspace === '@dm') {
233
- if (rule === 'dm:*' || rule === 'im:*') return true
234
- if (rule.startsWith('dm:')) return rule.slice(3) === chat
235
- if (rule.startsWith('im:')) return rule.slice(3) === chat
236
- if (rule.startsWith('channel:')) return rule.slice(8) === chat
237
- return false
238
- }
239
-
240
- if (workspace === 'telegram') {
241
- if (rule === 'tg:*') return true
242
- if (rule.startsWith('tg:')) return rule.slice(3) === chat
243
- if (rule.startsWith('channel:')) return rule.slice(8) === chat
244
- return false
245
- }
246
-
247
- if (rule === 'guild:*' || rule === 'team:*') return true
248
- if (rule.startsWith('channel:')) return rule.slice(8) === chat
249
- if (rule.startsWith('guild:')) {
250
- const body = rule.slice(6)
251
- const slash = body.indexOf('/')
252
- if (slash === -1) return body === workspace
253
- return body.slice(0, slash) === workspace && body.slice(slash + 1) === chat
254
- }
255
- if (rule.startsWith('team:')) {
256
- const body = rule.slice(5)
257
- const slash = body.indexOf('/')
258
- if (slash === -1) return body === workspace
259
- return body.slice(0, slash) === workspace && body.slice(slash + 1) === chat
260
- }
261
- return false
262
- }
263
-
264
- const KAKAO_WORKSPACES = new Set(['@kakao-dm', '@kakao-group', '@kakao-open'])
265
-
266
- function matchKakaoRule(body: string, workspace: string, chat: string): boolean {
267
- if (!KAKAO_WORKSPACES.has(workspace)) return false
268
- if (body === '*') return true
269
- if (body === 'dm/*') return workspace === '@kakao-dm'
270
- if (body === 'group/*') return workspace === '@kakao-group'
271
- if (body === 'open/*') return workspace === '@kakao-open'
272
- return body === chat
273
- }