typeclaw 0.1.4 → 0.1.6

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 (134) hide show
  1. package/README.md +15 -13
  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 +13 -10
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +137 -7
  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 +809 -300
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +11 -3
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +13 -3
  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 +491 -19
  67. package/src/config/index.ts +15 -1
  68. package/src/config/models-mutation.ts +200 -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 +6 -1
  73. package/src/container/port.ts +10 -0
  74. package/src/container/require-running.ts +33 -0
  75. package/src/container/start.ts +81 -63
  76. package/src/cron/consumer.ts +22 -2
  77. package/src/cron/index.ts +45 -4
  78. package/src/cron/schema.ts +104 -0
  79. package/src/doctor/checks.ts +51 -34
  80. package/src/doctor/plugin-bridge.ts +28 -4
  81. package/src/git/system-commit.ts +103 -0
  82. package/src/hostd/daemon.ts +16 -0
  83. package/src/hostd/kakao-renewal-manager.ts +223 -0
  84. package/src/hostd/paths.ts +7 -0
  85. package/src/init/dockerfile.ts +36 -10
  86. package/src/init/gitignore.ts +1 -1
  87. package/src/init/index.ts +213 -85
  88. package/src/init/kakaotalk-auth.ts +18 -1
  89. package/src/init/models-dev.ts +26 -1
  90. package/src/init/run-owner-claim.ts +77 -0
  91. package/src/permissions/builtins.ts +70 -0
  92. package/src/permissions/grant.ts +99 -0
  93. package/src/permissions/index.ts +29 -0
  94. package/src/permissions/match-rule.ts +305 -0
  95. package/src/permissions/permissions.ts +196 -0
  96. package/src/permissions/resolve.ts +80 -0
  97. package/src/permissions/schema.ts +79 -0
  98. package/src/plugin/context.ts +8 -4
  99. package/src/plugin/define.ts +2 -0
  100. package/src/plugin/index.ts +2 -0
  101. package/src/plugin/manager.ts +41 -0
  102. package/src/plugin/registry.ts +9 -0
  103. package/src/plugin/types.ts +35 -1
  104. package/src/reload/client.ts +25 -1
  105. package/src/role-claim/client.ts +182 -0
  106. package/src/role-claim/code.ts +53 -0
  107. package/src/role-claim/controller.ts +194 -0
  108. package/src/role-claim/index.ts +19 -0
  109. package/src/role-claim/match-rule.ts +43 -0
  110. package/src/role-claim/pending.ts +100 -0
  111. package/src/run/channel-session-factory.ts +76 -5
  112. package/src/run/index.ts +68 -7
  113. package/src/secrets/encryption.ts +116 -0
  114. package/src/secrets/kakao-renewal.ts +248 -0
  115. package/src/secrets/kakao-store.ts +66 -7
  116. package/src/secrets/keys.ts +173 -0
  117. package/src/secrets/schema.ts +23 -0
  118. package/src/secrets/storage.ts +83 -0
  119. package/src/server/index.ts +198 -71
  120. package/src/shared/index.ts +4 -0
  121. package/src/shared/protocol.ts +27 -0
  122. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  123. package/src/skills/typeclaw-config/SKILL.md +104 -112
  124. package/src/skills/typeclaw-memory/SKILL.md +9 -9
  125. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  126. package/src/stream/types.ts +7 -1
  127. package/src/tui/client.ts +66 -5
  128. package/src/tui/index.ts +61 -9
  129. package/src/usage/aggregate.ts +117 -0
  130. package/src/usage/format.ts +30 -0
  131. package/src/usage/index.ts +68 -0
  132. package/src/usage/report.ts +354 -0
  133. package/src/usage/scan.ts +186 -0
  134. package/typeclaw.schema.json +134 -98
@@ -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 {
@@ -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