typeclaw 0.1.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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/auth.schema.json +63 -0
  4. package/cron.schema.json +96 -0
  5. package/package.json +72 -0
  6. package/scripts/emit-base-dockerfile.ts +5 -0
  7. package/scripts/generate-schema.ts +34 -0
  8. package/secrets.schema.json +63 -0
  9. package/src/agent/auth.ts +119 -0
  10. package/src/agent/compaction.ts +35 -0
  11. package/src/agent/git-nudge.ts +95 -0
  12. package/src/agent/index.ts +451 -0
  13. package/src/agent/plugin-tools.ts +269 -0
  14. package/src/agent/reload-tool.ts +71 -0
  15. package/src/agent/self.ts +45 -0
  16. package/src/agent/session-origin.ts +288 -0
  17. package/src/agent/subagents.ts +253 -0
  18. package/src/agent/system-prompt.ts +68 -0
  19. package/src/agent/tools/channel-fetch-attachment.ts +118 -0
  20. package/src/agent/tools/channel-history.ts +119 -0
  21. package/src/agent/tools/channel-reply.ts +182 -0
  22. package/src/agent/tools/channel-send.ts +212 -0
  23. package/src/agent/tools/ddg.ts +218 -0
  24. package/src/agent/tools/restart.ts +122 -0
  25. package/src/agent/tools/stream-snapshot.ts +181 -0
  26. package/src/agent/tools/webfetch/fetch.ts +102 -0
  27. package/src/agent/tools/webfetch/index.ts +1 -0
  28. package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
  29. package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
  30. package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
  31. package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
  32. package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
  33. package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
  34. package/src/agent/tools/webfetch/tool.ts +281 -0
  35. package/src/agent/tools/webfetch/types.ts +33 -0
  36. package/src/agent/tools/websearch.ts +96 -0
  37. package/src/agent/tools/wikipedia.ts +52 -0
  38. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
  39. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
  40. package/src/bundled-plugins/agent-browser/index.ts +179 -0
  41. package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
  42. package/src/bundled-plugins/agent-browser/shim.ts +152 -0
  43. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
  44. package/src/bundled-plugins/guard/index.ts +26 -0
  45. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
  46. package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
  47. package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
  48. package/src/bundled-plugins/guard/policy.ts +18 -0
  49. package/src/bundled-plugins/memory/README.md +71 -0
  50. package/src/bundled-plugins/memory/append-tool.ts +84 -0
  51. package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
  52. package/src/bundled-plugins/memory/dreaming.ts +470 -0
  53. package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
  54. package/src/bundled-plugins/memory/index.ts +238 -0
  55. package/src/bundled-plugins/memory/load-memory.ts +122 -0
  56. package/src/bundled-plugins/memory/memory-logger.ts +257 -0
  57. package/src/bundled-plugins/memory/secret-detector.ts +49 -0
  58. package/src/bundled-plugins/memory/watermark.ts +15 -0
  59. package/src/bundled-plugins/security/index.ts +35 -0
  60. package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
  61. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
  62. package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
  63. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
  64. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
  65. package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
  66. package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
  67. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
  68. package/src/bundled-plugins/security/policy.ts +9 -0
  69. package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
  70. package/src/channels/adapters/discord-bot-classify.ts +148 -0
  71. package/src/channels/adapters/discord-bot.ts +640 -0
  72. package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
  73. package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
  74. package/src/channels/adapters/kakaotalk-classify.ts +77 -0
  75. package/src/channels/adapters/kakaotalk.ts +622 -0
  76. package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
  77. package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
  78. package/src/channels/adapters/slack-bot-classify.ts +213 -0
  79. package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
  80. package/src/channels/adapters/slack-bot-time.ts +10 -0
  81. package/src/channels/adapters/slack-bot.ts +881 -0
  82. package/src/channels/adapters/telegram-bot-classify.ts +155 -0
  83. package/src/channels/adapters/telegram-bot-format.ts +309 -0
  84. package/src/channels/adapters/telegram-bot.ts +604 -0
  85. package/src/channels/engagement.ts +227 -0
  86. package/src/channels/index.ts +21 -0
  87. package/src/channels/manager.ts +292 -0
  88. package/src/channels/membership-cache.ts +116 -0
  89. package/src/channels/membership-from-history.ts +53 -0
  90. package/src/channels/membership.ts +30 -0
  91. package/src/channels/participants.ts +47 -0
  92. package/src/channels/persistence.ts +209 -0
  93. package/src/channels/reloadable.ts +28 -0
  94. package/src/channels/router.ts +1570 -0
  95. package/src/channels/schema.ts +273 -0
  96. package/src/channels/types.ts +160 -0
  97. package/src/cli/channel.ts +403 -0
  98. package/src/cli/compose-status.ts +95 -0
  99. package/src/cli/compose.ts +240 -0
  100. package/src/cli/hostd.ts +163 -0
  101. package/src/cli/index.ts +27 -0
  102. package/src/cli/init.ts +592 -0
  103. package/src/cli/logs.ts +38 -0
  104. package/src/cli/reload.ts +68 -0
  105. package/src/cli/restart.ts +66 -0
  106. package/src/cli/run.ts +77 -0
  107. package/src/cli/shell.ts +33 -0
  108. package/src/cli/start.ts +57 -0
  109. package/src/cli/status.ts +178 -0
  110. package/src/cli/stop.ts +31 -0
  111. package/src/cli/tui.ts +35 -0
  112. package/src/cli/ui.ts +110 -0
  113. package/src/commands/index.ts +74 -0
  114. package/src/compose/discover.ts +43 -0
  115. package/src/compose/index.ts +25 -0
  116. package/src/compose/logs.ts +162 -0
  117. package/src/compose/restart.ts +69 -0
  118. package/src/compose/start.ts +62 -0
  119. package/src/compose/status.ts +28 -0
  120. package/src/compose/stop.ts +43 -0
  121. package/src/config/config.ts +424 -0
  122. package/src/config/index.ts +25 -0
  123. package/src/config/providers.ts +234 -0
  124. package/src/config/reloadable.ts +47 -0
  125. package/src/container/index.ts +27 -0
  126. package/src/container/logs.ts +37 -0
  127. package/src/container/port.ts +137 -0
  128. package/src/container/shared.ts +290 -0
  129. package/src/container/shell.ts +58 -0
  130. package/src/container/start.ts +670 -0
  131. package/src/container/status.ts +76 -0
  132. package/src/container/stop.ts +120 -0
  133. package/src/container/verify-running.ts +149 -0
  134. package/src/cron/consumer.ts +138 -0
  135. package/src/cron/index.ts +54 -0
  136. package/src/cron/reloadable.ts +64 -0
  137. package/src/cron/scheduler.ts +200 -0
  138. package/src/cron/schema.ts +96 -0
  139. package/src/hostd/client.ts +113 -0
  140. package/src/hostd/daemon.ts +587 -0
  141. package/src/hostd/index.ts +25 -0
  142. package/src/hostd/paths.ts +82 -0
  143. package/src/hostd/portbroker-manager.ts +101 -0
  144. package/src/hostd/protocol.ts +48 -0
  145. package/src/hostd/spawn.ts +224 -0
  146. package/src/hostd/supervisor.ts +60 -0
  147. package/src/hostd/tailscale.ts +172 -0
  148. package/src/hostd/version.ts +115 -0
  149. package/src/init/dockerfile.ts +327 -0
  150. package/src/init/ensure-deps.ts +152 -0
  151. package/src/init/gitignore.ts +46 -0
  152. package/src/init/hatching.ts +60 -0
  153. package/src/init/index.ts +786 -0
  154. package/src/init/kakaotalk-auth.ts +114 -0
  155. package/src/init/models-dev.ts +130 -0
  156. package/src/init/oauth-login.ts +74 -0
  157. package/src/init/packagejson.ts +94 -0
  158. package/src/init/paths.ts +2 -0
  159. package/src/init/run-bun-install.ts +20 -0
  160. package/src/markdown/chunk.ts +299 -0
  161. package/src/markdown/index.ts +1 -0
  162. package/src/plugin/context.ts +40 -0
  163. package/src/plugin/define.ts +35 -0
  164. package/src/plugin/hooks.ts +204 -0
  165. package/src/plugin/index.ts +63 -0
  166. package/src/plugin/loader.ts +111 -0
  167. package/src/plugin/manager.ts +136 -0
  168. package/src/plugin/registry.ts +145 -0
  169. package/src/plugin/skills.ts +62 -0
  170. package/src/plugin/types.ts +172 -0
  171. package/src/portbroker/bind-with-forward.ts +102 -0
  172. package/src/portbroker/container-server.ts +305 -0
  173. package/src/portbroker/forward-result-bus.ts +36 -0
  174. package/src/portbroker/hostd-client.ts +443 -0
  175. package/src/portbroker/index.ts +33 -0
  176. package/src/portbroker/policy.ts +24 -0
  177. package/src/portbroker/proc-net-tcp.ts +72 -0
  178. package/src/portbroker/protocol.ts +39 -0
  179. package/src/reload/client.ts +59 -0
  180. package/src/reload/index.ts +3 -0
  181. package/src/reload/registry.ts +60 -0
  182. package/src/reload/types.ts +13 -0
  183. package/src/run/bundled-plugins.ts +24 -0
  184. package/src/run/channel-session-factory.ts +105 -0
  185. package/src/run/index.ts +432 -0
  186. package/src/run/plugin-runtime.ts +43 -0
  187. package/src/run/schema-with-plugins.ts +14 -0
  188. package/src/secrets/index.ts +13 -0
  189. package/src/secrets/migrate.ts +95 -0
  190. package/src/secrets/schema.ts +75 -0
  191. package/src/secrets/storage.ts +231 -0
  192. package/src/server/index.ts +436 -0
  193. package/src/sessions/index.ts +23 -0
  194. package/src/shared/index.ts +9 -0
  195. package/src/shared/local-time.ts +21 -0
  196. package/src/shared/protocol.ts +25 -0
  197. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
  198. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
  199. package/src/skills/typeclaw-config/SKILL.md +643 -0
  200. package/src/skills/typeclaw-cron/SKILL.md +159 -0
  201. package/src/skills/typeclaw-git/SKILL.md +89 -0
  202. package/src/skills/typeclaw-memory/SKILL.md +174 -0
  203. package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
  204. package/src/skills/typeclaw-plugins/SKILL.md +594 -0
  205. package/src/skills/typeclaw-skills/SKILL.md +246 -0
  206. package/src/stream/broker.ts +161 -0
  207. package/src/stream/index.ts +16 -0
  208. package/src/stream/types.ts +69 -0
  209. package/src/tui/client.ts +45 -0
  210. package/src/tui/format.ts +317 -0
  211. package/src/tui/index.ts +225 -0
  212. package/src/tui/theme.ts +41 -0
  213. package/typeclaw.schema.json +826 -0
@@ -0,0 +1,1570 @@
1
+ import { basename } from 'node:path'
2
+
3
+ import type { AssistantMessage } from '@mariozechner/pi-ai'
4
+ import { SessionManager } from '@mariozechner/pi-coding-agent'
5
+
6
+ import { createSession, type AgentSession } from '@/agent'
7
+ import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
8
+ import { createCommandRegistry } from '@/commands'
9
+ import type { HookBus } from '@/plugin'
10
+
11
+ import { decideEngagement, grantStickyForReplyTargets, StickyLedger, type EngagementDecision } from './engagement'
12
+ import {
13
+ MEMBERSHIP_COLD_FETCH_TIMEOUT_MS,
14
+ type MembershipCount,
15
+ type MembershipResolver,
16
+ type MembershipResolverResult,
17
+ } from './membership'
18
+ import { createMembershipCache, type MembershipCache } from './membership-cache'
19
+ import { updateParticipants } from './participants'
20
+ import {
21
+ channelsSessionsPath,
22
+ findRecord,
23
+ loadChannelSessions,
24
+ saveChannelSessions,
25
+ type ChannelSessionRecord,
26
+ } from './persistence'
27
+ import type { ChannelAdapterConfig } from './schema'
28
+ import type {
29
+ ChannelHistoryMessage,
30
+ ChannelKey,
31
+ ChannelNameResolver,
32
+ FetchAttachmentArgs,
33
+ FetchAttachmentCallback,
34
+ FetchAttachmentResult,
35
+ FetchHistoryArgs,
36
+ FetchHistoryResult,
37
+ HistoryCallback,
38
+ InboundMessage,
39
+ OutboundCallback,
40
+ OutboundMessage,
41
+ ResolvedChannelNames,
42
+ SendResult,
43
+ TypingCallback,
44
+ } from './types'
45
+ import { channelKeyId } from './types'
46
+
47
+ export const INITIAL_DEBOUNCE_MS = 600
48
+ export const HOT_DEBOUNCE_MS = 1500
49
+ export const MAX_DEBOUNCE_MS = 4000
50
+ export const HOT_THRESHOLD_MS = 3000
51
+ export const MAX_CONSECUTIVE_ABORTS = 3
52
+ export const CONTEXT_BUFFER_SIZE = 20
53
+ // Discord's typing indicator expires after ~10s; an 8s heartbeat keeps it
54
+ // continuously visible while we debounce + generate without spamming the API.
55
+ export const TYPING_HEARTBEAT_MS = 8000
56
+ // A stuck model call or an agent that never yields should not keep re-arming
57
+ // platform-side typing forever. Slack Assistant status in particular has a
58
+ // documented 2-minute timeout, so repeatedly refreshing it after that point
59
+ // turns a temporary status into a permanent-looking artifact.
60
+ export const MAX_TYPING_HEARTBEAT_MS = 2 * 60 * 1000
61
+
62
+ // Idle GC: a LiveSession whose `lastInboundAt` is older than
63
+ // SESSION_IDLE_MS gets evicted on the next GC tick. Persistence
64
+ // (channels/sessions.json) is intentionally untouched — the next inbound
65
+ // rehydrates from disk against the same sessionId, so the on-disk
66
+ // transcript continues across the eviction. The point is to free memory
67
+ // (LiveSession holds an open SessionManager + transcript in RAM) and to
68
+ // give the next conversation a fresh start without forcing the user to
69
+ // notice anything. `lastInboundAt` is bumped only by *engaged* inbounds
70
+ // (see scheduleDebouncedDrain), so passive observation alone won't keep
71
+ // a session warm forever — that's intentional. The session is seeded
72
+ // with `now()` at creation (not `0`) so a freshly-created observe-only
73
+ // session gets a full SESSION_IDLE_MS window before its first GC sweep,
74
+ // not a 56-year-old idle reading from `Date.now() - 0`.
75
+ export const SESSION_IDLE_MS = 30 * 60 * 1000
76
+ export const SESSION_GC_INTERVAL_MS = 60 * 1000
77
+
78
+ // Watchdog ceiling for ensureLive's full async chain (resolve names →
79
+ // fetch membership → open session manager → persist mapping → prefetch
80
+ // history). A legitimate cold-start completes in well under a second;
81
+ // values above ~10s are always either a hung Discord REST call or a
82
+ // rate-limited retry storm. 30s leaves headroom for slow disks or a
83
+ // truly large transcript replay without making operator-noticed hangs
84
+ // indistinguishable from normal latency. On timeout the throw evicts
85
+ // the `creating` map entry so the next inbound retries from scratch
86
+ // instead of awaiting the same dead promise forever.
87
+ export const ENSURE_LIVE_TIMEOUT_MS = 30_000
88
+
89
+ // Per-callback ceilings inside the ensureLive chain. The outer watchdog
90
+ // catches the worst case, but per-step timeouts give better log
91
+ // attribution (which step hung) AND graceful degradation: a hung name
92
+ // resolver still lets engagement run on IDs alone, a hung history fetch
93
+ // still lets the agent answer without prefetched context. Both paths
94
+ // loop over registered callbacks and currently `await` each unbounded.
95
+ // 5s matches Discord's median REST p99 with comfortable headroom.
96
+ export const RESOLVE_CHANNEL_NAMES_TIMEOUT_MS = 5_000
97
+ export const FETCH_HISTORY_TIMEOUT_MS = 5_000
98
+
99
+ // Watchdog over the whole session.idle hook chain. The drain loop awaits
100
+ // `fireSessionIdle` between turns; a single hung plugin handler (e.g. a
101
+ // memory-logger awaiting a network call that never resolves) wedges the
102
+ // loop with `live.draining` stuck `true`, which means subsequent mention
103
+ // inbounds enqueue silently and never fire. Observe-decisions still log
104
+ // because engagement runs in `route()` before the draining check, so the
105
+ // symptom from logs alone is "thread receives observed lines forever
106
+ // after the last `prompted elapsed_ms=...`". Bounding the chain here
107
+ // matches the ensureLive watchdog (30s) so a misbehaving plugin
108
+ // degrades the current turn instead of bricking the channel until
109
+ // container restart. Per-handler attribution lives in plugin/hooks.ts.
110
+ export const SESSION_IDLE_TIMEOUT_MS = 30_000
111
+
112
+ // Two-axis loop guard for peer-bot conversation. Peer bots route into
113
+ // engagement under the SAME rules as humans, so a small ring (A→B→C→A) or
114
+ // a fast cascade can otherwise ping-pong without bound. The guard trips
115
+ // when EITHER axis hits its limit and clears on the next human inbound.
116
+ //
117
+ // Why two axes:
118
+ // - The since-human counter catches slow rings that would never fill a
119
+ // 60s window (3 bots replying every 30s = 4 turns/min, never trips a
120
+ // 60s sliding count).
121
+ // - The 60s window catches fast bursts that would never accumulate enough
122
+ // total turns to pressure the since-human counter (a single bot reflex
123
+ // replying to its own mention 5x in 2s).
124
+ //
125
+ // The model receives a non-fatal warning prepended into composeTurnPrompt
126
+ // when tripped; the LLM decides whether to keep replying. Hard interrupt
127
+ // is intentionally not part of v1 (would require pi-coding-agent abort
128
+ // semantics during in-flight tool calls).
129
+ export const PEER_BOT_TURNS_WINDOW_MS = 60_000
130
+ export const MAX_PEER_BOT_TURNS_IN_WINDOW = 5
131
+ export const MAX_CONSECUTIVE_PEER_BOT_TURNS_SINCE_HUMAN = 5
132
+
133
+ export type RouterLogger = {
134
+ info: (msg: string) => void
135
+ warn: (msg: string) => void
136
+ error: (msg: string) => void
137
+ }
138
+
139
+ const consoleLogger: RouterLogger = {
140
+ info: (m) => console.log(m),
141
+ warn: (m) => console.warn(m),
142
+ error: (m) => console.error(m),
143
+ }
144
+
145
+ export type CreateSessionForChannel = (params: {
146
+ key: ChannelKey
147
+ existingSessionId?: string
148
+ // Basename of the JSONL file the prior session wrote to, captured at
149
+ // creation time and persisted in channels/sessions.json. Used for
150
+ // reopening — without this, sessionId alone is insufficient because
151
+ // pi-coding-agent prefixes filenames with an ISO timestamp at write time
152
+ // that the UUID does not encode. Optional for forward-compat with v2
153
+ // mappings that predate the `sessionFile` field.
154
+ existingSessionFile?: string
155
+ participants: readonly ChannelParticipant[]
156
+ origin: SessionOrigin
157
+ }) => Promise<{
158
+ session: AgentSession
159
+ sessionId: string
160
+ dispose: () => Promise<void>
161
+ hooks?: HookBus
162
+ getTranscriptPath?: () => string | undefined
163
+ }>
164
+
165
+ export type ConfigForAdapter = (adapter: ChannelKey['adapter']) => ChannelAdapterConfig | undefined
166
+
167
+ type QueuedInbound = {
168
+ text: string
169
+ authorId: string
170
+ authorName: string
171
+ authorIsBot: boolean
172
+ externalMessageId: string
173
+ isBotMention: boolean
174
+ replyToBotMessageId: string | null
175
+ isDm: boolean
176
+ receivedAt: number
177
+ // Original platform timestamp (Slack/Discord), in ms since epoch. Used
178
+ // by composeTurnPrompt to render an ISO 8601 prefix on each line so the
179
+ // model sees when each message was actually posted, not when the router
180
+ // happened to dequeue it. Zero means "unknown" (the formatter omits the
181
+ // prefix for those).
182
+ ts: number
183
+ }
184
+
185
+ type ObservedInbound = {
186
+ text: string
187
+ authorId: string
188
+ authorName: string
189
+ authorIsBot: boolean
190
+ receivedAt: number
191
+ ts: number
192
+ }
193
+
194
+ type LiveSession = {
195
+ key: ChannelKey
196
+ keyId: string
197
+ session: AgentSession
198
+ sessionId: string
199
+ dispose: () => Promise<void>
200
+ hooks: HookBus | undefined
201
+ getTranscriptPath: (() => string | undefined) | undefined
202
+ participants: ChannelParticipant[]
203
+ resolvedNames: ResolvedChannelNames
204
+ promptQueue: QueuedInbound[]
205
+ contextBuffer: ObservedInbound[]
206
+ draining: boolean
207
+ debounceTimer: ReturnType<typeof setTimeout> | null
208
+ typingTimer: ReturnType<typeof setInterval> | null
209
+ typingStartedAt: number
210
+ typingTimedOut: boolean
211
+ typingStopPromise: Promise<void> | null
212
+ lastInboundAt: number
213
+ firstUnprocessedAt: number
214
+ currentTurnAuthorId: string | null
215
+ currentTurnAuthorIds: Set<string>
216
+ lastTurnAuthorIds: Set<string>
217
+ consecutiveAborts: number
218
+ // Per-(chat:thread) count of bot messages sent without intervening user
219
+ // input being rendered into the model's context. Reset at the top of each
220
+ // drain() iteration that picks up a non-empty batch (= a new user turn is
221
+ // about to be shown to the model). channel_send reads this BEFORE calling
222
+ // router.send so the hint reflects the position of the about-to-happen send
223
+ // (n-th in a row), nudging the model to yield without forcing it to.
224
+ consecutiveSends: Map<string, number>
225
+ successfulChannelSends: number
226
+ // Loop-guard state. See PEER_BOT_TURNS_WINDOW_MS / MAX_* constants
227
+ // above. Updated in route() on every engaged peer-bot inbound, reset on
228
+ // any human inbound. The two axes (window ring buffer + since-human
229
+ // counter) are independent — either tripping sets `loopGuardActive`
230
+ // until the next human posts. The active flag is read by
231
+ // composeTurnPrompt() and prepended to the user-turn text.
232
+ recentEngagedPeerBotTurns: { authorId: string; ts: number }[]
233
+ consecutiveEngagedPeerBotTurns: number
234
+ loopGuardActive: boolean
235
+ membershipFetch: Promise<MembershipCount | null> | null
236
+ destroyed: boolean
237
+ }
238
+
239
+ type ChannelCommandContext = {
240
+ live: LiveSession
241
+ event: InboundMessage
242
+ }
243
+
244
+ export type ChannelRouter = {
245
+ route: (event: InboundMessage) => Promise<void>
246
+ send: (msg: OutboundMessage) => Promise<SendResult>
247
+ getConsecutiveSendCount: (target: {
248
+ adapter: ChannelKey['adapter']
249
+ workspace: string
250
+ chat: string
251
+ thread?: string | null
252
+ }) => number
253
+ registerOutbound: (adapter: ChannelKey['adapter'], cb: OutboundCallback) => void
254
+ unregisterOutbound: (adapter: ChannelKey['adapter'], cb: OutboundCallback) => void
255
+ registerTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
256
+ unregisterTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
257
+ registerChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
258
+ unregisterChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
259
+ registerMembership: (adapter: ChannelKey['adapter'], resolver: MembershipResolver) => void
260
+ unregisterMembership: (adapter: ChannelKey['adapter'], resolver: MembershipResolver) => void
261
+ registerHistory: (adapter: ChannelKey['adapter'], cb: HistoryCallback) => void
262
+ unregisterHistory: (adapter: ChannelKey['adapter'], cb: HistoryCallback) => void
263
+ fetchHistory: (adapter: ChannelKey['adapter'], args: FetchHistoryArgs) => Promise<FetchHistoryResult>
264
+ registerFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
265
+ unregisterFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
266
+ fetchAttachment: (adapter: ChannelKey['adapter'], args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
267
+ // Lowered self-aliases (configured + implicit dir-name). Adapters use
268
+ // this to anchor outbound threading on alias-only inbounds — see
269
+ // slack-bot-classify.ts. Read live so a reload of `alias` propagates
270
+ // to adapters without a restart.
271
+ getSelfAliases: () => readonly string[]
272
+ stop: () => Promise<void>
273
+ liveCount: () => number
274
+ __testing?: {
275
+ flushDebounce: (key: ChannelKey) => Promise<void>
276
+ fireTypingHeartbeat: (key: ChannelKey, phase?: 'tick' | 'stop') => Promise<void>
277
+ fireTypingInterval: (key: ChannelKey) => Promise<void>
278
+ isTypingActive: (key: ChannelKey) => boolean
279
+ runIdleGc: () => Promise<void>
280
+ }
281
+ }
282
+
283
+ // Returns the additional aliases the agent answers to (beyond the
284
+ // implicit dir-name). Read from the live config every inbound — `alias`
285
+ // is classified `applied` in FIELD_EFFECTS, so a `reload` should change
286
+ // engagement behavior immediately. Defaults to an empty list when not
287
+ // provided, which means alias-based engagement is effectively off (the
288
+ // dir-name is still implicit and added by the router below).
289
+ export type AliasesProvider = () => readonly string[]
290
+
291
+ export type CreateChannelRouterOptions = {
292
+ agentDir: string
293
+ configForAdapter: ConfigForAdapter
294
+ configuredAliases?: AliasesProvider
295
+ createSessionForChannel?: CreateSessionForChannel
296
+ sessionDir?: string
297
+ logger?: RouterLogger
298
+ // Test seam: clock for sticky/debounce/participants. Defaults to Date.now.
299
+ now?: () => number
300
+ // Test seam: override the ensureLive watchdog ceiling so the timeout path
301
+ // is exercisable in <100ms instead of the 30s production default.
302
+ ensureLiveTimeoutMs?: number
303
+ // Test seam: per-callback ceiling for channel name resolvers; mirrors the
304
+ // ensureLive seam so timeout paths can be exercised quickly in tests.
305
+ resolveChannelNamesTimeoutMs?: number
306
+ // Test seam: per-callback ceiling for history fetches.
307
+ fetchHistoryTimeoutMs?: number
308
+ // Test seam: bound the session.idle hook chain so the timeout path is
309
+ // exercisable in tens of milliseconds instead of the 30s default.
310
+ sessionIdleTimeoutMs?: number
311
+ }
312
+
313
+ export function createChannelRouter(options: CreateChannelRouterOptions): ChannelRouter {
314
+ const logger = options.logger ?? consoleLogger
315
+ const now = options.now ?? Date.now
316
+ const ensureLiveTimeoutMs = options.ensureLiveTimeoutMs ?? ENSURE_LIVE_TIMEOUT_MS
317
+ const resolveChannelNamesTimeoutMs = options.resolveChannelNamesTimeoutMs ?? RESOLVE_CHANNEL_NAMES_TIMEOUT_MS
318
+ const fetchHistoryTimeoutMs = options.fetchHistoryTimeoutMs ?? FETCH_HISTORY_TIMEOUT_MS
319
+ const sessionIdleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS
320
+ const liveSessions = new Map<string, LiveSession>()
321
+ const creating = new Map<string, Promise<LiveSession>>()
322
+ const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
323
+ const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
324
+ const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
325
+ const membershipResolvers = new Map<ChannelKey['adapter'], Set<MembershipResolver>>()
326
+ const membershipCaches = new Map<ChannelKey['adapter'], MembershipCache>()
327
+ const historyCallbacks = new Map<ChannelKey['adapter'], Set<HistoryCallback>>()
328
+ const fetchAttachmentCallbacks = new Map<ChannelKey['adapter'], Set<FetchAttachmentCallback>>()
329
+ const stickyLedger = new StickyLedger()
330
+ const commands = createCommandRegistry<ChannelCommandContext>([
331
+ {
332
+ name: 'stop',
333
+ handler: async ({ live }) => {
334
+ await stopCurrentChannelTurn(live)
335
+ },
336
+ },
337
+ ])
338
+
339
+ // Implicit dir-name alias: agent folder basename matches Docker
340
+ // container name (per AGENTS.md), the typical Discord/Slack bot
341
+ // username, and the natural way the operator refers to the agent.
342
+ // Lowered once at construction since basename(agentDir) doesn't change
343
+ // over the router's lifetime; configured aliases are lowered per-call
344
+ // because they're read from live config.
345
+ const dirAlias = basename(options.agentDir).toLocaleLowerCase()
346
+ const computeSelfAliases = (): readonly string[] => {
347
+ const configured = options.configuredAliases?.() ?? []
348
+ const set = new Set<string>([dirAlias])
349
+ for (const a of configured) {
350
+ const lower = a.toLocaleLowerCase()
351
+ if (lower !== '') set.add(lower)
352
+ }
353
+ return Array.from(set)
354
+ }
355
+
356
+ let mappings: ChannelSessionRecord[] | null = null
357
+ let loadOnce: Promise<void> | null = null
358
+
359
+ const ensureLoaded = async (): Promise<void> => {
360
+ if (mappings !== null) return
361
+ if (loadOnce === null) {
362
+ loadOnce = loadChannelSessions(options.agentDir, logger).then((records) => {
363
+ mappings = records
364
+ })
365
+ }
366
+ await loadOnce
367
+ }
368
+
369
+ const persist = async (): Promise<void> => {
370
+ if (mappings === null) return
371
+ await saveChannelSessions(options.agentDir, mappings, logger)
372
+ }
373
+
374
+ const createForChannel: CreateSessionForChannel =
375
+ options.createSessionForChannel ??
376
+ (async ({ key, existingSessionId, existingSessionFile, origin }) => {
377
+ const sessionDir = options.sessionDir ?? `${options.agentDir}/sessions`
378
+ const sessionManager =
379
+ existingSessionId !== undefined
380
+ ? tryOpenSessionManager(options.agentDir, sessionDir, existingSessionId, existingSessionFile, logger)
381
+ : SessionManager.create(options.agentDir, sessionDir)
382
+ const session = await createSession({
383
+ sessionManager,
384
+ origin,
385
+ })
386
+ const sessionId = sessionManager.getSessionId()
387
+ void key
388
+ return {
389
+ session,
390
+ sessionId,
391
+ dispose: async () => {
392
+ session.dispose()
393
+ },
394
+ getTranscriptPath: () => sessionManager.getSessionFile(),
395
+ }
396
+ })
397
+
398
+ const resolveChannelNames = async (key: ChannelKey): Promise<ResolvedChannelNames> => {
399
+ const resolvers = channelNameResolvers.get(key.adapter)
400
+ if (!resolvers || resolvers.size === 0) return {}
401
+ const snapshot = Array.from(resolvers)
402
+ const merged: ResolvedChannelNames = {}
403
+ for (const resolver of snapshot) {
404
+ try {
405
+ const result = await raceWithTimeout(
406
+ resolver(key),
407
+ resolveChannelNamesTimeoutMs,
408
+ `[channels] ${channelKeyId(key)}: name resolver`,
409
+ )
410
+ if (result.chatName !== undefined && merged.chatName === undefined) merged.chatName = result.chatName
411
+ if (result.workspaceName !== undefined && merged.workspaceName === undefined) {
412
+ merged.workspaceName = result.workspaceName
413
+ }
414
+ } catch (err) {
415
+ logger.warn(`[channels] name resolver threw for ${channelKeyId(key)}: ${describe(err)}`)
416
+ }
417
+ }
418
+ return merged
419
+ }
420
+
421
+ const readMembership = (key: ChannelKey): MembershipCount | null => {
422
+ if (key.workspace === '@dm') return dmMembership(now())
423
+ return membershipCaches.get(key.adapter)?.get(key) ?? null
424
+ }
425
+
426
+ const warmMembership = (key: ChannelKey): Promise<MembershipCount | null> | null => {
427
+ if (key.workspace === '@dm') return Promise.resolve(dmMembership(now()))
428
+ const cache = membershipCaches.get(key.adapter)
429
+ if (cache === undefined) return null
430
+ return cache.warmUp(key)
431
+ }
432
+
433
+ const resolveThroughRegisteredMembership = async (key: ChannelKey): Promise<MembershipResolverResult> => {
434
+ const resolvers = membershipResolvers.get(key.adapter)
435
+ if (!resolvers || resolvers.size === 0) return { kind: 'transient' }
436
+ const snapshot = Array.from(resolvers)
437
+ let lastFailure: MembershipResolverResult = { kind: 'transient' }
438
+ for (const resolver of snapshot) {
439
+ const result = await resolver(key)
440
+ if ('humans' in result) return result
441
+ lastFailure = result
442
+ }
443
+ return lastFailure
444
+ }
445
+
446
+ const membershipForPrompt = async (
447
+ key: ChannelKey,
448
+ fetchPromise: Promise<MembershipCount | null> | null,
449
+ ): Promise<MembershipCount | null> => {
450
+ if (key.workspace === '@dm') return dmMembership(now())
451
+ const cached = readMembership(key)
452
+ if (cached !== null) return cached
453
+ if (fetchPromise === null) return null
454
+ return await withMembershipTimeout(fetchPromise, key, logger)
455
+ }
456
+
457
+ const membershipForEngagement = async (live: LiveSession): Promise<MembershipCount | null> => {
458
+ if (live.key.workspace === '@dm') return dmMembership(now())
459
+ const cache = membershipCaches.get(live.key.adapter)
460
+ if (cache === undefined) return null
461
+
462
+ const cached = cache.read(live.key)
463
+ if (cached.kind === 'hit') return cached.membership
464
+ if (cached.kind === 'stale') {
465
+ void cache.warmUp(live.key).catch((err) => {
466
+ logger.warn(`[channels] membership refresh failed for ${live.keyId}: ${describe(err)}`)
467
+ })
468
+ return cached.membership
469
+ }
470
+
471
+ const fetchPromise = live.membershipFetch ?? warmMembership(live.key)
472
+ live.membershipFetch = fetchPromise
473
+ if (fetchPromise === null) return null
474
+ const membership = await withMembershipTimeout(fetchPromise, live.key, logger)
475
+ if (live.membershipFetch === fetchPromise) live.membershipFetch = null
476
+ return membership
477
+ }
478
+
479
+ const ensureLive = async (key: ChannelKey, triggeringMessageId?: string): Promise<LiveSession> => {
480
+ const keyId = channelKeyId(key)
481
+ const existing = liveSessions.get(keyId)
482
+ if (existing && !existing.destroyed) return existing
483
+
484
+ const inFlight = creating.get(keyId)
485
+ if (inFlight) return inFlight
486
+
487
+ const promise = (async () => {
488
+ await ensureLoaded()
489
+ const record = mappings ? findRecord(mappings, key) : undefined
490
+ const phase = record?.sessionId === undefined ? 'cold-start' : 'rehydrate'
491
+ logger.info(`[channels] ${keyId}: ensureLive begin (${phase})`)
492
+ const participants = (record?.participants ?? []) as ChannelParticipant[]
493
+ const membershipFetch = warmMembership(key)
494
+ const resolvedNames = await resolveChannelNames(key)
495
+ logger.info(`[channels] ${keyId}: ensureLive resolved-names`)
496
+ const membership = await membershipForPrompt(key, membershipFetch)
497
+ logger.info(`[channels] ${keyId}: ensureLive resolved-membership`)
498
+ const origin: SessionOrigin = {
499
+ kind: 'channel',
500
+ adapter: key.adapter,
501
+ workspace: key.workspace,
502
+ ...(resolvedNames.workspaceName !== undefined ? { workspaceName: resolvedNames.workspaceName } : {}),
503
+ chat: key.chat,
504
+ ...(resolvedNames.chatName !== undefined ? { chatName: resolvedNames.chatName } : {}),
505
+ thread: key.thread,
506
+ participants,
507
+ ...(membership !== null ? { membership } : {}),
508
+ }
509
+
510
+ const isColdStart = record?.sessionId === undefined
511
+
512
+ const created = await createForChannel({
513
+ key,
514
+ ...(record?.sessionId ? { existingSessionId: record.sessionId } : {}),
515
+ ...(record?.sessionFile ? { existingSessionFile: record.sessionFile } : {}),
516
+ participants,
517
+ origin,
518
+ })
519
+ logger.info(`[channels] ${keyId}: ensureLive session-created sessionId=${created.sessionId}`)
520
+
521
+ const transcriptPath = created.getTranscriptPath?.()
522
+ const persistedRecord: ChannelSessionRecord = {
523
+ adapter: key.adapter,
524
+ workspace: key.workspace,
525
+ chat: key.chat,
526
+ thread: key.thread,
527
+ sessionId: created.sessionId,
528
+ ...(transcriptPath ? { sessionFile: basename(transcriptPath) } : {}),
529
+ participants,
530
+ }
531
+ if (mappings) {
532
+ const idx = mappings.findIndex(
533
+ (s) =>
534
+ s.adapter === key.adapter &&
535
+ s.workspace === key.workspace &&
536
+ s.chat === key.chat &&
537
+ (s.thread ?? null) === (key.thread ?? null),
538
+ )
539
+ if (idx >= 0) mappings[idx] = persistedRecord
540
+ else mappings.push(persistedRecord)
541
+ } else {
542
+ mappings = [persistedRecord]
543
+ }
544
+ await persist()
545
+
546
+ const live: LiveSession = {
547
+ key,
548
+ keyId,
549
+ session: created.session,
550
+ sessionId: created.sessionId,
551
+ dispose: created.dispose,
552
+ hooks: created.hooks,
553
+ getTranscriptPath: created.getTranscriptPath,
554
+ participants,
555
+ resolvedNames,
556
+ promptQueue: [],
557
+ contextBuffer: [],
558
+ draining: false,
559
+ debounceTimer: null,
560
+ typingTimer: null,
561
+ typingStartedAt: 0,
562
+ typingTimedOut: false,
563
+ typingStopPromise: null,
564
+ lastInboundAt: now(),
565
+ firstUnprocessedAt: 0,
566
+ currentTurnAuthorId: null,
567
+ currentTurnAuthorIds: new Set(),
568
+ lastTurnAuthorIds: new Set(),
569
+ consecutiveAborts: 0,
570
+ consecutiveSends: new Map(),
571
+ successfulChannelSends: 0,
572
+ recentEngagedPeerBotTurns: [],
573
+ consecutiveEngagedPeerBotTurns: 0,
574
+ loopGuardActive: false,
575
+ membershipFetch,
576
+ destroyed: false,
577
+ }
578
+ liveSessions.set(keyId, live)
579
+
580
+ if (isColdStart) {
581
+ const adapterConfig = options.configForAdapter(key.adapter)
582
+ if (adapterConfig) {
583
+ await prefetchChannelContext(live, adapterConfig, triggeringMessageId)
584
+ logger.info(`[channels] ${keyId}: ensureLive prefetched-context`)
585
+ }
586
+ }
587
+
588
+ logger.info(`[channels] ${keyId}: ensureLive done (${phase})`)
589
+ return live
590
+ })()
591
+
592
+ creating.set(keyId, promise)
593
+ try {
594
+ return await raceWithTimeout(promise, ensureLiveTimeoutMs, `[channels] ${keyId} ensureLive`)
595
+ } catch (err) {
596
+ // The orphaned `promise` may still settle eventually; that's OK because
597
+ // the only side effect it produces post-timeout is a `liveSessions.set`,
598
+ // which the next inbound's existence-check short-circuit at the top of
599
+ // ensureLive will treat as a usable warm session — strictly better than
600
+ // a permanent silent drop. The caller (route() in this file, ultimately
601
+ // the adapter's outer catch) sees the timeout error and logs it.
602
+ logger.error(`[channels] ${keyId}: ensureLive failed: ${describe(err)}`)
603
+ throw err
604
+ } finally {
605
+ creating.delete(keyId)
606
+ }
607
+ }
608
+
609
+ const prefetchChannelContext = async (
610
+ live: LiveSession,
611
+ adapterConfig: ChannelAdapterConfig,
612
+ triggeringMessageId: string | undefined,
613
+ ): Promise<void> => {
614
+ const prefetch = adapterConfig.history.prefetch
615
+ const isThread = live.key.thread !== null
616
+ const head = isThread ? prefetch.thread.head : 0
617
+ const tail = isThread ? prefetch.thread.tail : prefetch.channel.tail
618
+ if (head === 0 && tail === 0) return
619
+
620
+ // One fetch per cold start. We always pass the live thread when present so
621
+ // we get the thread-scoped history; channel cold starts pass `thread: null`
622
+ // so we get the channel scrollback. The router's contract is oldest-first,
623
+ // which lets us slice [head] + [tail] without re-sorting. We over-request
624
+ // by one (head + tail + 1) so we can detect "exactly head + tail" without
625
+ // emitting a misleading elision marker for a zero-length gap.
626
+ const requested = head + tail + 1
627
+ const result = await fetchHistory(live.key.adapter, {
628
+ chat: live.key.chat,
629
+ thread: live.key.thread,
630
+ limit: requested,
631
+ })
632
+
633
+ if (!result.ok) {
634
+ logger.warn(`[channels] ${live.keyId}: prefetch skipped (history fetch failed: ${result.error})`)
635
+ return
636
+ }
637
+
638
+ // Drop the engaging message itself if it appears in the history result.
639
+ // Without this, the model would see the same message twice — once in
640
+ // "Recent context" and once in "Current message". Adapters typically
641
+ // return the latest channel/thread messages, so this overlap is the
642
+ // common case, not the edge case.
643
+ const filteredMessages =
644
+ triggeringMessageId !== undefined
645
+ ? result.messages.filter((m) => m.externalMessageId !== triggeringMessageId)
646
+ : result.messages
647
+ if (filteredMessages.length === 0) return
648
+
649
+ const seeded = sliceHeadTail(filteredMessages, head, tail)
650
+ const observed: ObservedInbound[] = []
651
+ for (const item of seeded) {
652
+ if (item.kind === 'message') {
653
+ observed.push({
654
+ text: item.message.text,
655
+ authorId: item.message.authorId,
656
+ authorName: item.message.authorName,
657
+ authorIsBot: item.message.isBot,
658
+ receivedAt: now(),
659
+ ts: item.message.ts,
660
+ })
661
+ } else {
662
+ observed.push({
663
+ text: `[… ${item.elidedCount} earlier messages elided; call channel_history for full thread …]`,
664
+ authorId: '__typeclaw_system__',
665
+ authorName: 'TypeClaw',
666
+ authorIsBot: true,
667
+ receivedAt: now(),
668
+ ts: 0,
669
+ })
670
+ }
671
+ }
672
+
673
+ if (observed.length === 0) return
674
+
675
+ // Cold-start prefetch is one-shot and may exceed CONTEXT_BUFFER_SIZE — the
676
+ // 20-message cap exists to bound *runtime* observation drift, not the
677
+ // initial seed. Subsequent observe() calls will trim back to the cap as
678
+ // normal. We push into contextBuffer (not promptQueue) because these are
679
+ // background context for the model, not turns it must respond to.
680
+ live.contextBuffer.push(...observed)
681
+ logger.info(`[channels] ${live.keyId}: prefetched ${observed.length} context messages`)
682
+ }
683
+
684
+ const persistParticipants = async (live: LiveSession): Promise<void> => {
685
+ if (mappings === null) return
686
+ const idx = mappings.findIndex(
687
+ (s) =>
688
+ s.adapter === live.key.adapter &&
689
+ s.workspace === live.key.workspace &&
690
+ s.chat === live.key.chat &&
691
+ (s.thread ?? null) === (live.key.thread ?? null),
692
+ )
693
+ if (idx < 0) return
694
+ const next = mappings.slice()
695
+ next[idx] = { ...next[idx]!, participants: live.participants }
696
+ mappings = next
697
+ await persist()
698
+ }
699
+
700
+ const regenerateOrigin = (live: LiveSession): SessionOrigin => buildLiveOrigin(live)
701
+
702
+ const fireTyping = async (live: LiveSession, phase: 'tick' | 'stop'): Promise<void> => {
703
+ const callbacks = typingCallbacks.get(live.key.adapter)
704
+ if (!callbacks || callbacks.size === 0) return
705
+ // Snapshot before iterating: a callback could unregister mid-call.
706
+ const snapshot = Array.from(callbacks)
707
+ const target = {
708
+ adapter: live.key.adapter,
709
+ workspace: live.key.workspace,
710
+ chat: live.key.chat,
711
+ thread: live.key.thread,
712
+ phase,
713
+ }
714
+ await Promise.all(
715
+ snapshot.map((cb) =>
716
+ cb(target).catch((err) => {
717
+ logger.warn(`[channels] typing callback threw for ${live.keyId}: ${describe(err)}`)
718
+ }),
719
+ ),
720
+ )
721
+ }
722
+
723
+ const startTypingHeartbeat = (live: LiveSession): void => {
724
+ if (live.typingTimedOut || live.typingStopPromise) return
725
+ if (live.typingTimer || live.destroyed) return
726
+ live.typingStartedAt = now()
727
+ // Fire immediately so the indicator appears on the very first inbound,
728
+ // not 8 seconds later.
729
+ void fireTyping(live, 'tick')
730
+ live.typingTimer = setInterval(() => {
731
+ if (live.destroyed) {
732
+ void stopTypingHeartbeat(live)
733
+ return
734
+ }
735
+ if (now() - live.typingStartedAt >= MAX_TYPING_HEARTBEAT_MS) {
736
+ logger.warn(`[channels] ${live.keyId}: typing heartbeat timed out after ${MAX_TYPING_HEARTBEAT_MS}ms`)
737
+ live.typingTimedOut = true
738
+ void stopTypingHeartbeat(live)
739
+ return
740
+ }
741
+ void fireTyping(live, 'tick')
742
+ }, TYPING_HEARTBEAT_MS)
743
+ }
744
+
745
+ const stopTypingHeartbeat = async (live: LiveSession): Promise<void> => {
746
+ if (!live.typingTimer) {
747
+ await live.typingStopPromise
748
+ return
749
+ }
750
+ clearInterval(live.typingTimer)
751
+ live.typingTimer = null
752
+ live.typingStartedAt = 0
753
+ // Fire 'stop' phase even when destroyed: adapters need the chance to
754
+ // clear platform-side state (e.g. Slack's 2-min server timeout) on
755
+ // teardown. The FIFO inside the slack adapter ensures this clear lands
756
+ // AFTER any in-flight 'tick' from the heartbeat that just stopped.
757
+ const stopped = fireTyping(live, 'stop').finally(() => {
758
+ if (live.typingStopPromise === stopped) live.typingStopPromise = null
759
+ })
760
+ live.typingStopPromise = stopped
761
+ await stopped
762
+ }
763
+
764
+ const fireSessionIdle = async (live: LiveSession): Promise<void> => {
765
+ if (!live.hooks) return
766
+ const work = live.hooks.runSessionIdle({
767
+ sessionId: live.sessionId,
768
+ parentTranscriptPath: live.getTranscriptPath?.(),
769
+ idleMs: 0,
770
+ origin: buildLiveOrigin(live),
771
+ })
772
+ try {
773
+ await raceWithTimeout(work, sessionIdleTimeoutMs, `[channels] ${live.keyId} session.idle`)
774
+ } catch (err) {
775
+ logger.warn(`[channels] session.idle hook threw for ${live.keyId}: ${describe(err)}`)
776
+ }
777
+ }
778
+
779
+ const buildLiveOrigin = (live: LiveSession): SessionOrigin => {
780
+ const membership = readMembership(live.key)
781
+ return {
782
+ kind: 'channel',
783
+ adapter: live.key.adapter,
784
+ workspace: live.key.workspace,
785
+ ...(live.resolvedNames.workspaceName !== undefined ? { workspaceName: live.resolvedNames.workspaceName } : {}),
786
+ chat: live.key.chat,
787
+ ...(live.resolvedNames.chatName !== undefined ? { chatName: live.resolvedNames.chatName } : {}),
788
+ thread: live.key.thread,
789
+ ...(live.currentTurnAuthorId !== null ? { lastInboundAuthorId: live.currentTurnAuthorId } : {}),
790
+ participants: live.participants,
791
+ ...(membership !== null ? { membership } : {}),
792
+ }
793
+ }
794
+
795
+ const fireSessionEnd = async (live: LiveSession): Promise<void> => {
796
+ if (!live.hooks) return
797
+ try {
798
+ await live.hooks.runSessionEnd({ sessionId: live.sessionId })
799
+ } catch (err) {
800
+ logger.warn(`[channels] session.end hook threw for ${live.keyId}: ${describe(err)}`)
801
+ }
802
+ }
803
+
804
+ const stopCurrentChannelTurn = async (live: LiveSession): Promise<void> => {
805
+ if (live.debounceTimer) clearTimeout(live.debounceTimer)
806
+ live.debounceTimer = null
807
+ live.firstUnprocessedAt = 0
808
+ live.promptQueue.length = 0
809
+ await stopTypingHeartbeat(live)
810
+ try {
811
+ await live.session.abort()
812
+ logger.info(`[channels] ${live.keyId}: command /stop aborted current turn`)
813
+ } catch (err) {
814
+ logger.warn(`[channels] ${live.keyId}: command /stop abort failed: ${describe(err)}`)
815
+ }
816
+ }
817
+
818
+ const drain = async (live: LiveSession): Promise<void> => {
819
+ if (live.draining || live.destroyed) return
820
+ live.draining = true
821
+ try {
822
+ while (live.promptQueue.length > 0 && !live.destroyed) {
823
+ live.typingTimedOut = false
824
+ // Heartbeat must run during generation as well as during debounce.
825
+ // Because new inbounds during a turn just push into promptQueue
826
+ // without re-entering route(), the route() call site alone wouldn't
827
+ // keep the indicator alive across multiple drain iterations.
828
+ startTypingHeartbeat(live)
829
+ const batch = live.promptQueue.splice(0, live.promptQueue.length)
830
+ const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
831
+ const text = composeTurnPrompt(observed, batch, { loopGuardActive: live.loopGuardActive })
832
+
833
+ live.currentTurnAuthorId = batch.length > 0 ? batch[batch.length - 1]!.authorId : null
834
+ live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
835
+ if (batch.length > 0) live.consecutiveSends.clear()
836
+
837
+ // The agent's view of the channel should reflect the current
838
+ // participants + last inbound author. We update the in-memory
839
+ // origin via the session-origin renderer, but the loader was
840
+ // captured at session creation. v0.1 keeps the per-session loader
841
+ // (so origin reflects participants at session-creation time);
842
+ // per-prompt regeneration of system prompts is a v0.2 work.
843
+ void regenerateOrigin
844
+
845
+ // Bracketing logs around the LLM call so a hung prompt() is
846
+ // diagnosable from logs alone (we see prompting without prompted).
847
+ // text length is a proxy for "did we send something at all".
848
+ logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
849
+ const promptStart = now()
850
+ const successfulSendsBeforePrompt = live.successfulChannelSends
851
+ try {
852
+ await live.session.prompt(text)
853
+ await validateChannelTurn(live, successfulSendsBeforePrompt)
854
+ live.consecutiveAborts = 0
855
+ logger.info(`[channels] ${live.keyId} prompted elapsed_ms=${now() - promptStart}`)
856
+ } catch (err) {
857
+ logger.warn(`[channels] ${live.keyId}: prompt threw: ${describe(err)}`)
858
+ live.consecutiveSends.clear()
859
+ }
860
+ await fireSessionIdle(live)
861
+ live.lastTurnAuthorIds = new Set(live.currentTurnAuthorIds)
862
+ }
863
+ } finally {
864
+ live.draining = false
865
+ live.currentTurnAuthorId = null
866
+ live.currentTurnAuthorIds = new Set()
867
+ await stopTypingHeartbeat(live)
868
+ }
869
+ }
870
+
871
+ const scheduleDebouncedDrain = (live: LiveSession): void => {
872
+ if (live.debounceTimer) clearTimeout(live.debounceTimer)
873
+ const t = now()
874
+ const sinceLast = t - live.lastInboundAt
875
+ const baseWait = sinceLast < HOT_THRESHOLD_MS ? HOT_DEBOUNCE_MS : INITIAL_DEBOUNCE_MS
876
+ if (live.firstUnprocessedAt === 0) live.firstUnprocessedAt = t
877
+ const elapsedSinceFirst = t - live.firstUnprocessedAt
878
+ const wait = Math.max(0, Math.min(baseWait, MAX_DEBOUNCE_MS - elapsedSinceFirst))
879
+ live.lastInboundAt = t
880
+ live.debounceTimer = setTimeout(() => {
881
+ live.debounceTimer = null
882
+ live.firstUnprocessedAt = 0
883
+ void drain(live)
884
+ }, wait)
885
+ }
886
+
887
+ const route = async (event: InboundMessage): Promise<void> => {
888
+ const adapterConfig = options.configForAdapter(event.adapter)
889
+ if (!adapterConfig) return
890
+
891
+ const key: ChannelKey = {
892
+ adapter: event.adapter,
893
+ workspace: event.workspace,
894
+ chat: event.chat,
895
+ thread: event.thread,
896
+ }
897
+
898
+ const parsedCommand = commands.parse(event.text)
899
+ if (parsedCommand !== null) {
900
+ const keyId = channelKeyId(key)
901
+ if (!commands.has(parsedCommand.name)) {
902
+ logger.info(`[channels] ${keyId}: ignoring unknown command /${parsedCommand.name}`)
903
+ return
904
+ }
905
+ const existingLive = liveSessions.get(keyId)
906
+ if (!existingLive || existingLive.destroyed) {
907
+ logger.info(`[channels] ${keyId}: ignoring command /${parsedCommand.name} with no live session`)
908
+ return
909
+ }
910
+ const commandResult = await commands.execute(event.text, { live: existingLive, event })
911
+ if (commandResult.kind !== 'not-command') return
912
+ }
913
+
914
+ const live = await ensureLive(key, event.externalMessageId)
915
+
916
+ const isNewAuthor = !live.participants.some((p) => p.authorId === event.authorId)
917
+ live.participants = updateParticipants(
918
+ live.participants,
919
+ event.authorId,
920
+ event.authorName,
921
+ now(),
922
+ event.authorIsBot,
923
+ )
924
+ void persistParticipants(live)
925
+
926
+ // A previously-unseen author just spoke. The cached membership count
927
+ // (from /members or history-derived) was computed without them, so
928
+ // invalidate and warm in the background. We don't await — the warmup
929
+ // runs alongside this turn's `membershipForEngagement` call so the
930
+ // *next* turn sees fresh data, but the current turn still gets a
931
+ // fast answer (cache miss → cold fetch with timeout, or stale-ok).
932
+ if (isNewAuthor && live.key.workspace !== '@dm') {
933
+ const cache = membershipCaches.get(live.key.adapter)
934
+ if (cache !== undefined) {
935
+ cache.invalidate(live.key)
936
+ void cache.warmUp(live.key).catch((err) => {
937
+ logger.warn(`[channels] membership warmup after new author failed for ${live.keyId}: ${describe(err)}`)
938
+ })
939
+ }
940
+ }
941
+
942
+ const membership = await membershipForEngagement(live)
943
+
944
+ const decision: EngagementDecision = decideEngagement({
945
+ message: event,
946
+ config: adapterConfig.engagement,
947
+ key: live.keyId,
948
+ ledger: stickyLedger,
949
+ now: now(),
950
+ participants: live.participants,
951
+ membership,
952
+ selfAliases: computeSelfAliases(),
953
+ botInThread: hasBotParticipated(live),
954
+ })
955
+
956
+ if (decision === 'observe') {
957
+ // Log every observe so an unanswered mention is diagnosable from logs
958
+ // alone instead of "routed but no prompting" silence. The bracketed
959
+ // shape mirrors `prompting batch=` so log scraping can pair them.
960
+ logger.info(`[channels] ${live.keyId} observed id=${event.externalMessageId}`)
961
+ observe(live, event)
962
+ return
963
+ }
964
+
965
+ updateLoopGuard(live, event)
966
+
967
+ enqueue(live, event)
968
+
969
+ // Start showing "typing..." the moment we know we're going to engage,
970
+ // so users see the indicator during the debounce window — not just
971
+ // during LLM generation. drain() will keep it alive across iterations
972
+ // and the finally-block will stop it when the queue empties.
973
+ startTypingHeartbeat(live)
974
+
975
+ if (live.draining) {
976
+ // In-flight turn; let coalesce-on-drain pick it up. Same-author abort
977
+ // is a v0.2 enhancement once we have safe abort semantics through
978
+ // pi-coding-agent for in-flight tool calls.
979
+ return
980
+ }
981
+ scheduleDebouncedDrain(live)
982
+ }
983
+
984
+ const updateLoopGuard = (live: LiveSession, event: InboundMessage): void => {
985
+ if (!event.authorIsBot) {
986
+ live.recentEngagedPeerBotTurns.length = 0
987
+ live.consecutiveEngagedPeerBotTurns = 0
988
+ live.loopGuardActive = false
989
+ return
990
+ }
991
+ const t = now()
992
+ live.consecutiveEngagedPeerBotTurns++
993
+ live.recentEngagedPeerBotTurns.push({ authorId: event.authorId, ts: t })
994
+ const cutoff = t - PEER_BOT_TURNS_WINDOW_MS
995
+ while (live.recentEngagedPeerBotTurns.length > 0 && live.recentEngagedPeerBotTurns[0]!.ts < cutoff) {
996
+ live.recentEngagedPeerBotTurns.shift()
997
+ }
998
+ if (
999
+ live.consecutiveEngagedPeerBotTurns >= MAX_CONSECUTIVE_PEER_BOT_TURNS_SINCE_HUMAN ||
1000
+ live.recentEngagedPeerBotTurns.length >= MAX_PEER_BOT_TURNS_IN_WINDOW
1001
+ ) {
1002
+ live.loopGuardActive = true
1003
+ }
1004
+ }
1005
+
1006
+ const hasBotParticipated = (live: LiveSession): boolean => {
1007
+ if (live.successfulChannelSends > 0) return true
1008
+ for (const item of live.contextBuffer) {
1009
+ if (item.authorIsBot) return true
1010
+ }
1011
+ return false
1012
+ }
1013
+
1014
+ const observe = (live: LiveSession, event: InboundMessage): void => {
1015
+ live.contextBuffer.push({
1016
+ text: event.text,
1017
+ authorId: event.authorId,
1018
+ authorName: event.authorName,
1019
+ authorIsBot: event.authorIsBot,
1020
+ receivedAt: now(),
1021
+ ts: event.ts,
1022
+ })
1023
+ if (live.contextBuffer.length > CONTEXT_BUFFER_SIZE) {
1024
+ live.contextBuffer.splice(0, live.contextBuffer.length - CONTEXT_BUFFER_SIZE)
1025
+ }
1026
+ }
1027
+
1028
+ const enqueue = (live: LiveSession, event: InboundMessage): void => {
1029
+ live.promptQueue.push({
1030
+ text: event.text,
1031
+ authorId: event.authorId,
1032
+ authorName: event.authorName,
1033
+ authorIsBot: event.authorIsBot,
1034
+ externalMessageId: event.externalMessageId,
1035
+ isBotMention: event.isBotMention,
1036
+ replyToBotMessageId: event.replyToBotMessageId,
1037
+ isDm: event.isDm,
1038
+ receivedAt: now(),
1039
+ ts: event.ts,
1040
+ })
1041
+ }
1042
+
1043
+ const registerOutbound = (adapter: ChannelKey['adapter'], cb: OutboundCallback): void => {
1044
+ let set = outboundCallbacks.get(adapter)
1045
+ if (!set) {
1046
+ set = new Set()
1047
+ outboundCallbacks.set(adapter, set)
1048
+ }
1049
+ set.add(cb)
1050
+ }
1051
+
1052
+ const unregisterOutbound = (adapter: ChannelKey['adapter'], cb: OutboundCallback): void => {
1053
+ outboundCallbacks.get(adapter)?.delete(cb)
1054
+ }
1055
+
1056
+ const registerTyping = (adapter: ChannelKey['adapter'], cb: TypingCallback): void => {
1057
+ let set = typingCallbacks.get(adapter)
1058
+ if (!set) {
1059
+ set = new Set()
1060
+ typingCallbacks.set(adapter, set)
1061
+ }
1062
+ set.add(cb)
1063
+ }
1064
+
1065
+ const unregisterTyping = (adapter: ChannelKey['adapter'], cb: TypingCallback): void => {
1066
+ typingCallbacks.get(adapter)?.delete(cb)
1067
+ }
1068
+
1069
+ const registerChannelNameResolver = (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver): void => {
1070
+ let set = channelNameResolvers.get(adapter)
1071
+ if (!set) {
1072
+ set = new Set()
1073
+ channelNameResolvers.set(adapter, set)
1074
+ }
1075
+ set.add(resolver)
1076
+ }
1077
+
1078
+ const unregisterChannelNameResolver = (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver): void => {
1079
+ channelNameResolvers.get(adapter)?.delete(resolver)
1080
+ }
1081
+
1082
+ const registerMembership = (adapter: ChannelKey['adapter'], resolver: MembershipResolver): void => {
1083
+ let set = membershipResolvers.get(adapter)
1084
+ if (!set) {
1085
+ set = new Set()
1086
+ membershipResolvers.set(adapter, set)
1087
+ }
1088
+ set.add(resolver)
1089
+ if (!membershipCaches.has(adapter)) {
1090
+ membershipCaches.set(
1091
+ adapter,
1092
+ createMembershipCache({ resolver: resolveThroughRegisteredMembership, now, logger }),
1093
+ )
1094
+ }
1095
+ }
1096
+
1097
+ const unregisterMembership = (adapter: ChannelKey['adapter'], resolver: MembershipResolver): void => {
1098
+ membershipResolvers.get(adapter)?.delete(resolver)
1099
+ if ((membershipResolvers.get(adapter)?.size ?? 0) === 0) {
1100
+ membershipCaches.delete(adapter)
1101
+ }
1102
+ }
1103
+
1104
+ const registerHistory = (adapter: ChannelKey['adapter'], cb: HistoryCallback): void => {
1105
+ let set = historyCallbacks.get(adapter)
1106
+ if (!set) {
1107
+ set = new Set()
1108
+ historyCallbacks.set(adapter, set)
1109
+ }
1110
+ set.add(cb)
1111
+ }
1112
+
1113
+ const unregisterHistory = (adapter: ChannelKey['adapter'], cb: HistoryCallback): void => {
1114
+ historyCallbacks.get(adapter)?.delete(cb)
1115
+ }
1116
+
1117
+ const fetchHistory = async (adapter: ChannelKey['adapter'], args: FetchHistoryArgs): Promise<FetchHistoryResult> => {
1118
+ const callbacks = historyCallbacks.get(adapter)
1119
+ if (!callbacks || callbacks.size === 0) {
1120
+ return { ok: false, error: 'history-not-supported' }
1121
+ }
1122
+ // Snapshot before iterating, mirroring `send`: a callback that mutates
1123
+ // the set (e.g. unregisters mid-call) must not skip siblings.
1124
+ const snapshot = Array.from(callbacks)
1125
+ let lastError: FetchHistoryResult & { ok: false } = { ok: false, error: 'history-not-supported' }
1126
+ for (const cb of snapshot) {
1127
+ try {
1128
+ const result = await raceWithTimeout(cb(args), fetchHistoryTimeoutMs, `[channels] ${adapter} history fetch`)
1129
+ if (result.ok) return result
1130
+ lastError = result
1131
+ } catch (err) {
1132
+ logger.warn(`[channels] history fetch threw for ${adapter}: ${describe(err)}`)
1133
+ lastError = { ok: false, error: 'history-not-supported' }
1134
+ }
1135
+ }
1136
+ return lastError
1137
+ }
1138
+
1139
+ const registerFetchAttachment = (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback): void => {
1140
+ let set = fetchAttachmentCallbacks.get(adapter)
1141
+ if (!set) {
1142
+ set = new Set()
1143
+ fetchAttachmentCallbacks.set(adapter, set)
1144
+ }
1145
+ set.add(cb)
1146
+ }
1147
+
1148
+ const unregisterFetchAttachment = (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback): void => {
1149
+ fetchAttachmentCallbacks.get(adapter)?.delete(cb)
1150
+ }
1151
+
1152
+ const fetchAttachment = async (
1153
+ adapter: ChannelKey['adapter'],
1154
+ args: FetchAttachmentArgs,
1155
+ ): Promise<FetchAttachmentResult> => {
1156
+ const callbacks = fetchAttachmentCallbacks.get(adapter)
1157
+ if (!callbacks || callbacks.size === 0) {
1158
+ return { ok: false, error: 'fetch-attachment-not-supported' }
1159
+ }
1160
+ const snapshot = Array.from(callbacks)
1161
+ let lastError: FetchAttachmentResult & { ok: false } = { ok: false, error: 'fetch-attachment-not-supported' }
1162
+ for (const cb of snapshot) {
1163
+ const result = await cb(args)
1164
+ if (result.ok) return result
1165
+ lastError = result
1166
+ }
1167
+ return lastError
1168
+ }
1169
+
1170
+ const send = async (msg: OutboundMessage): Promise<SendResult> => {
1171
+ const callbacks = outboundCallbacks.get(msg.adapter)
1172
+ if (!callbacks || callbacks.size === 0) {
1173
+ return { ok: false, error: `no adapter registered for "${msg.adapter}"` }
1174
+ }
1175
+
1176
+ // Snapshot the callbacks before iterating so a callback that mutates the
1177
+ // set (e.g. unregisters mid-send) does not cause the iterator to skip
1178
+ // siblings or trip into surprising behavior.
1179
+ const snapshot = Array.from(callbacks)
1180
+ let lastError: string | undefined
1181
+ let delivered = false
1182
+ for (const cb of snapshot) {
1183
+ const result = await cb(msg)
1184
+ if (result.ok) {
1185
+ delivered = true
1186
+ break
1187
+ }
1188
+ lastError = result.error
1189
+ }
1190
+
1191
+ if (!delivered) {
1192
+ return { ok: false, error: lastError ?? 'no callback accepted the outbound' }
1193
+ }
1194
+
1195
+ const keyId = channelKeyId({
1196
+ adapter: msg.adapter,
1197
+ workspace: msg.workspace,
1198
+ chat: msg.chat,
1199
+ thread: msg.thread ?? null,
1200
+ })
1201
+ const live = liveSessions.get(keyId)
1202
+ if (live) {
1203
+ live.successfulChannelSends++
1204
+ await stopTypingHeartbeat(live)
1205
+ const adapterConfig = options.configForAdapter(msg.adapter)
1206
+ if (adapterConfig) {
1207
+ const targetIds = Array.from(
1208
+ live.currentTurnAuthorIds.size > 0 ? live.currentTurnAuthorIds : live.lastTurnAuthorIds,
1209
+ )
1210
+ if (targetIds.length > 0) {
1211
+ grantStickyForReplyTargets(stickyLedger, keyId, targetIds, adapterConfig.engagement, now())
1212
+ }
1213
+ }
1214
+ const sendKey = consecutiveSendKey(msg.chat, msg.thread)
1215
+ live.consecutiveSends.set(sendKey, (live.consecutiveSends.get(sendKey) ?? 0) + 1)
1216
+ }
1217
+
1218
+ return { ok: true }
1219
+ }
1220
+
1221
+ const validateChannelTurn = async (live: LiveSession, successfulSendsBeforePrompt: number): Promise<void> => {
1222
+ if (live.successfulChannelSends > successfulSendsBeforePrompt) return
1223
+
1224
+ const assistantText = latestAssistantText(live.session)
1225
+ if (assistantText === null) return
1226
+
1227
+ if (isNoReplySignal(assistantText)) {
1228
+ logger.info(`[channels] ${live.keyId} no_reply`)
1229
+ return
1230
+ }
1231
+
1232
+ logger.warn(
1233
+ `[channels] ${live.keyId}: recovering assistant_text_without_channel_tool text_len=${assistantText.length}`,
1234
+ )
1235
+ const result = await send({
1236
+ adapter: live.key.adapter,
1237
+ workspace: live.key.workspace,
1238
+ chat: live.key.chat,
1239
+ thread: live.key.thread,
1240
+ text: assistantText,
1241
+ })
1242
+ if (!result.ok) {
1243
+ logger.warn(`[channels] ${live.keyId}: recovery send failed: ${result.error}`)
1244
+ }
1245
+ }
1246
+
1247
+ const getConsecutiveSendCount = (target: {
1248
+ adapter: ChannelKey['adapter']
1249
+ workspace: string
1250
+ chat: string
1251
+ thread?: string | null
1252
+ }): number => {
1253
+ const keyId = channelKeyId({
1254
+ adapter: target.adapter,
1255
+ workspace: target.workspace,
1256
+ chat: target.chat,
1257
+ thread: target.thread ?? null,
1258
+ })
1259
+ const live = liveSessions.get(keyId)
1260
+ if (!live) return 0
1261
+ return live.consecutiveSends.get(consecutiveSendKey(target.chat, target.thread)) ?? 0
1262
+ }
1263
+
1264
+ const tearDownLive = async (live: LiveSession): Promise<void> => {
1265
+ live.destroyed = true
1266
+ if (live.debounceTimer) clearTimeout(live.debounceTimer)
1267
+ live.debounceTimer = null
1268
+ await stopTypingHeartbeat(live)
1269
+ try {
1270
+ await live.session.abort()
1271
+ } catch (err) {
1272
+ logger.warn(`[channels] abort failed for ${live.keyId}: ${describe(err)}`)
1273
+ }
1274
+ await fireSessionEnd(live)
1275
+ try {
1276
+ await live.dispose()
1277
+ } catch (err) {
1278
+ logger.warn(`[channels] dispose failed for ${live.keyId}: ${describe(err)}`)
1279
+ }
1280
+ }
1281
+
1282
+ const runIdleGc = async (): Promise<void> => {
1283
+ const t = now()
1284
+ const victims: LiveSession[] = []
1285
+ for (const live of liveSessions.values()) {
1286
+ if (live.destroyed) continue
1287
+ if (live.draining) continue
1288
+ if (live.promptQueue.length > 0) continue
1289
+ if (t - live.lastInboundAt <= SESSION_IDLE_MS) continue
1290
+ victims.push(live)
1291
+ }
1292
+ for (const live of victims) {
1293
+ liveSessions.delete(live.keyId)
1294
+ logger.info(`[channels] ${live.keyId} idle_gc evicting after ${t - live.lastInboundAt}ms idle`)
1295
+ await tearDownLive(live)
1296
+ }
1297
+ }
1298
+
1299
+ let gcTimer: ReturnType<typeof setInterval> | null = setInterval(() => {
1300
+ void runIdleGc()
1301
+ }, SESSION_GC_INTERVAL_MS)
1302
+ // Don't keep the Bun process alive just for the GC tick; the host
1303
+ // server's WebSocket listener owns process lifetime.
1304
+ gcTimer.unref?.()
1305
+
1306
+ const stop = async (): Promise<void> => {
1307
+ if (gcTimer) clearInterval(gcTimer)
1308
+ gcTimer = null
1309
+ const all = Array.from(liveSessions.values())
1310
+ liveSessions.clear()
1311
+ for (const live of all) {
1312
+ await tearDownLive(live)
1313
+ }
1314
+ }
1315
+
1316
+ return {
1317
+ route,
1318
+ send,
1319
+ getConsecutiveSendCount,
1320
+ registerOutbound,
1321
+ unregisterOutbound,
1322
+ registerTyping,
1323
+ unregisterTyping,
1324
+ registerChannelNameResolver,
1325
+ unregisterChannelNameResolver,
1326
+ registerMembership,
1327
+ unregisterMembership,
1328
+ registerHistory,
1329
+ unregisterHistory,
1330
+ fetchHistory,
1331
+ registerFetchAttachment,
1332
+ unregisterFetchAttachment,
1333
+ fetchAttachment,
1334
+ getSelfAliases: computeSelfAliases,
1335
+ stop,
1336
+ liveCount: () => liveSessions.size,
1337
+ __testing: {
1338
+ flushDebounce: async (key: ChannelKey) => {
1339
+ const live = liveSessions.get(channelKeyId(key))
1340
+ if (!live) return
1341
+ if (live.debounceTimer) {
1342
+ clearTimeout(live.debounceTimer)
1343
+ live.debounceTimer = null
1344
+ }
1345
+ live.firstUnprocessedAt = 0
1346
+ await drain(live)
1347
+ },
1348
+ fireTypingHeartbeat: async (key: ChannelKey, phase: 'tick' | 'stop' = 'tick') => {
1349
+ const live = liveSessions.get(channelKeyId(key))
1350
+ if (!live) return
1351
+ await fireTyping(live, phase)
1352
+ },
1353
+ fireTypingInterval: async (key: ChannelKey) => {
1354
+ const live = liveSessions.get(channelKeyId(key))
1355
+ if (!live || !live.typingTimer) return
1356
+ if (live.destroyed) {
1357
+ await stopTypingHeartbeat(live)
1358
+ return
1359
+ }
1360
+ if (now() - live.typingStartedAt >= MAX_TYPING_HEARTBEAT_MS) {
1361
+ logger.warn(`[channels] ${live.keyId}: typing heartbeat timed out after ${MAX_TYPING_HEARTBEAT_MS}ms`)
1362
+ live.typingTimedOut = true
1363
+ await stopTypingHeartbeat(live)
1364
+ return
1365
+ }
1366
+ await fireTyping(live, 'tick')
1367
+ },
1368
+ isTypingActive: (key: ChannelKey) => {
1369
+ const live = liveSessions.get(channelKeyId(key))
1370
+ return live?.typingTimer !== null && live?.typingTimer !== undefined
1371
+ },
1372
+ runIdleGc,
1373
+ },
1374
+ }
1375
+ }
1376
+
1377
+ function composeTurnPrompt(
1378
+ observed: readonly ObservedInbound[],
1379
+ batch: readonly QueuedInbound[],
1380
+ state: { loopGuardActive: boolean } = { loopGuardActive: false },
1381
+ ): string {
1382
+ const parts: string[] = []
1383
+ // Loop-guard notice lives in the user-turn text (recomposed every drain)
1384
+ // rather than in the system prompt so it does not invalidate the
1385
+ // prompt-prefix cache. The cached prefix covers system + tools + earlier
1386
+ // turns; the current user-turn suffix is non-cacheable by design, so
1387
+ // adding a section here is cache-neutral.
1388
+ //
1389
+ // SYSTEM MESSAGE convention: any runtime-injected block in the user turn
1390
+ // that is NOT from a chat participant must use the
1391
+ // `**[SYSTEM MESSAGE — not from a human]**` framing fenced by horizontal
1392
+ // rules (`---`). This is structurally distinct from the H2 sections used
1393
+ // for actual conversation content (`## Recent context`,
1394
+ // `## Current message`). Without the fencing, models — especially
1395
+ // persona-rich ones like Kimi — read the heading as a human-authored
1396
+ // instruction and reply to it ("알겠습니다, 대화 여기까지 할게요"). The
1397
+ // bracketed marker plus the explicit "Do not acknowledge or reply to this
1398
+ // notice" line is the trust boundary that prevents this. New runtime
1399
+ // notices (rate-limit, schema-mismatch, abort signals, etc.) MUST follow
1400
+ // this same convention so models learn the pattern.
1401
+ if (state.loopGuardActive) {
1402
+ parts.push(
1403
+ '---',
1404
+ '**[SYSTEM MESSAGE — not from a human]**',
1405
+ '',
1406
+ `The TypeClaw runtime detected that peer bots have engaged you ${MAX_CONSECUTIVE_PEER_BOT_TURNS_SINCE_HUMAN}+ times in`,
1407
+ `a row without any human input (or ${MAX_PEER_BOT_TURNS_IN_WINDOW}+ times in the last ${PEER_BOT_TURNS_WINDOW_MS / 1000}s). This message`,
1408
+ 'is an automated signal from the channel router, not a message from anyone',
1409
+ 'in the chat. **Do not acknowledge or reply to this notice.**',
1410
+ '',
1411
+ 'Guidance:',
1412
+ '- If the current message clearly needs a reply, send one and ignore this notice.',
1413
+ '- If continuing would add noise, reply with `NO_REPLY` to stay silent this turn.',
1414
+ '',
1415
+ 'This notice clears automatically once a human posts again.',
1416
+ '',
1417
+ '---',
1418
+ '',
1419
+ )
1420
+ }
1421
+ if (observed.length > 0) {
1422
+ parts.push('## Recent context (not addressed to you, for awareness only)')
1423
+ for (const o of observed) {
1424
+ parts.push(formatAuthorLine(o.ts, o.authorId, o.authorName, o.authorIsBot, o.text))
1425
+ }
1426
+ parts.push('')
1427
+ parts.push(batch.length === 1 ? '## Current message (addressed to you)' : '## Current messages (addressed to you)')
1428
+ }
1429
+ for (const b of batch) {
1430
+ parts.push(formatAuthorLine(b.ts, b.authorId, b.authorName, b.authorIsBot, b.text))
1431
+ }
1432
+ return parts.join('\n')
1433
+ }
1434
+
1435
+ function formatAuthorLine(
1436
+ ts: number,
1437
+ authorId: string,
1438
+ authorName: string,
1439
+ authorIsBot: boolean,
1440
+ text: string,
1441
+ ): string {
1442
+ const tag = authorIsBot ? ' [bot]' : ''
1443
+ const stamp = ts > 0 ? `[${new Date(ts).toISOString()}] ` : ''
1444
+ return `${stamp}<@${authorId}> (${authorName})${tag}: ${text}`
1445
+ }
1446
+
1447
+ type Sliced = { kind: 'message'; message: ChannelHistoryMessage } | { kind: 'elision'; elidedCount: number }
1448
+
1449
+ export function sliceHeadTail(messages: readonly ChannelHistoryMessage[], head: number, tail: number): Sliced[] {
1450
+ if (head < 0 || tail < 0) throw new Error(`sliceHeadTail: head and tail must be non-negative (got ${head}, ${tail})`)
1451
+ if (head === 0 && tail === 0) return []
1452
+ if (messages.length <= head + tail) {
1453
+ return messages.map((m) => ({ kind: 'message', message: m }))
1454
+ }
1455
+ const headSlice: Sliced[] = head > 0 ? messages.slice(0, head).map((m) => ({ kind: 'message', message: m })) : []
1456
+ const tailSlice: Sliced[] = tail > 0 ? messages.slice(-tail).map((m) => ({ kind: 'message', message: m })) : []
1457
+ const elidedCount = messages.length - head - tail
1458
+ return [...headSlice, { kind: 'elision', elidedCount }, ...tailSlice]
1459
+ }
1460
+
1461
+ function tryOpenSessionManager(
1462
+ agentDir: string,
1463
+ sessionDir: string,
1464
+ existingSessionId: string,
1465
+ existingSessionFile: string | undefined,
1466
+ logger: RouterLogger,
1467
+ ): SessionManager {
1468
+ if (existingSessionFile === undefined) {
1469
+ logger.warn(
1470
+ `[channels] session ${existingSessionId} has no sessionFile (v2 mapping not yet migrated); creating new`,
1471
+ )
1472
+ return SessionManager.create(agentDir, sessionDir)
1473
+ }
1474
+ try {
1475
+ const path = `${sessionDir}/${existingSessionFile}`
1476
+ return SessionManager.open(path)
1477
+ } catch (err) {
1478
+ logger.warn(
1479
+ `[channels] could not rehydrate session ${existingSessionId} from ${existingSessionFile}: ${describe(err)}; creating new`,
1480
+ )
1481
+ return SessionManager.create(agentDir, sessionDir)
1482
+ }
1483
+ }
1484
+
1485
+ function consecutiveSendKey(chat: string, thread: string | null | undefined): string {
1486
+ return `${chat}:${thread ?? ''}`
1487
+ }
1488
+
1489
+ function dmMembership(fetchedAt: number): MembershipCount {
1490
+ return { humans: 1, bots: 1, fetchedAt, truncated: false }
1491
+ }
1492
+
1493
+ async function withMembershipTimeout(
1494
+ promise: Promise<MembershipCount | null>,
1495
+ key: ChannelKey,
1496
+ logger: RouterLogger,
1497
+ ): Promise<MembershipCount | null> {
1498
+ let timer: ReturnType<typeof setTimeout> | null = null
1499
+ const timeout = new Promise<null>((resolve) => {
1500
+ timer = setTimeout(() => {
1501
+ logger.warn(
1502
+ `[channels] ${channelKeyId(key)}: membership cold fetch timed out after ${MEMBERSHIP_COLD_FETCH_TIMEOUT_MS}ms`,
1503
+ )
1504
+ resolve(null)
1505
+ }, MEMBERSHIP_COLD_FETCH_TIMEOUT_MS)
1506
+ })
1507
+ try {
1508
+ return await Promise.race([promise, timeout])
1509
+ } finally {
1510
+ if (timer !== null) clearTimeout(timer)
1511
+ }
1512
+ }
1513
+
1514
+ // Throwing variant of the membership timeout pattern: races the work against
1515
+ // a deadline and rejects with a descriptive error on miss. Used wherever a
1516
+ // hung registered callback (Discord/Slack/Telegram REST) would otherwise
1517
+ // leave an awaiting caller stuck forever and there is no graceful-
1518
+ // degradation value the caller could substitute (contrast withMembershipTimeout,
1519
+ // which returns null because engagement can run on a stale membership reading).
1520
+ // The helper owns timer lifetime so callers cannot leak timers on a fast
1521
+ // resolution.
1522
+ async function raceWithTimeout<T>(work: Promise<T>, ms: number, label: string): Promise<T> {
1523
+ let timer: ReturnType<typeof setTimeout> | null = null
1524
+ const timeout = new Promise<never>((_, reject) => {
1525
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
1526
+ })
1527
+ try {
1528
+ return await Promise.race([work, timeout])
1529
+ } finally {
1530
+ if (timer !== null) clearTimeout(timer)
1531
+ }
1532
+ }
1533
+
1534
+ function latestAssistantText(session: AgentSession): string | null {
1535
+ const entry = session.sessionManager.getLeafEntry()
1536
+ if (entry?.type !== 'message') return null
1537
+ if (entry.message.role !== 'assistant') return null
1538
+ if (entry.message.stopReason !== 'stop') return null
1539
+ return visibleAssistantText(entry.message)
1540
+ }
1541
+
1542
+ function visibleAssistantText(message: AssistantMessage): string {
1543
+ return message.content
1544
+ .filter((block) => block.type === 'text')
1545
+ .map((block) => block.text)
1546
+ .join('')
1547
+ }
1548
+
1549
+ // Lenient on purpose: distilled / smaller models routinely drift off the
1550
+ // documented `NO_REPLY` form. We additionally accept `(NO_REPLY)` (Claude-style
1551
+ // hedging) and empty visible text (e.g. Kimi-distilled models that emit only a
1552
+ // thinking block and end the turn) — without the empty case we'd recover an
1553
+ // empty string into the chat. The prompt contract still teaches the strict
1554
+ // literal; this just widens what we accept. Shared with channel_send /
1555
+ // channel_reply so all three call sites stay in lockstep.
1556
+ export function isNoReplySignal(text: string): boolean {
1557
+ const trimmed = text.trim()
1558
+ if (trimmed === '') return true
1559
+ if (trimmed === 'NO_REPLY') return true
1560
+ if (trimmed === '(NO_REPLY)') return true
1561
+ return false
1562
+ }
1563
+
1564
+ function describe(err: unknown): string {
1565
+ return err instanceof Error ? err.message : String(err)
1566
+ }
1567
+
1568
+ // Used by tests / external diagnostics.
1569
+ export type { ChannelSessionRecord }
1570
+ export { channelsSessionsPath }