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,436 @@
1
+ import type { Server as BunServer, ServerWebSocket } from 'bun'
2
+
3
+ import {
4
+ createSessionWithDispose as defaultCreateSessionWithDispose,
5
+ type AgentSession,
6
+ type CreateSessionOptions,
7
+ type CreateSessionResult,
8
+ } from '@/agent'
9
+ import type { SessionOrigin } from '@/agent/session-origin'
10
+ import type { ChannelRouter } from '@/channels/router'
11
+ import type { HookBus } from '@/plugin'
12
+ import type { BrokerWsData, ContainerBroker } from '@/portbroker'
13
+ import type { ReloadAllResult, ReloadRegistry } from '@/reload'
14
+ import type { PluginRuntime, PluginRuntimeState } from '@/run/plugin-runtime'
15
+ import type { SessionFactory } from '@/sessions'
16
+ import type { ClientMessage, PromptDelivery, QueueStateItem, ReloadResultPayload, ServerMessage } from '@/shared'
17
+ import type { Stream, StreamMessage, StreamMessageId, Unsubscribe } from '@/stream'
18
+
19
+ export type ReloadAllFn = () => Promise<ReloadAllResult>
20
+ export type CreateSessionFn = (options?: CreateSessionOptions) => Promise<AgentSession | CreateSessionResult>
21
+
22
+ export type ServerOptions = {
23
+ port: number
24
+ reloadAll?: ReloadAllFn
25
+ reloadRegistry?: ReloadRegistry
26
+ createSession?: CreateSessionFn
27
+ sessionFactory?: SessionFactory
28
+ stream?: Stream
29
+ channelRouter?: ChannelRouter
30
+ agentDir?: string
31
+ pluginRuntime?: PluginRuntime
32
+ containerName?: string
33
+ // Optional in-process portbroker handler. When provided, requests to the
34
+ // /portbroker WS path are routed to it instead of being treated as TUI
35
+ // sessions. Omit to keep TUI-only behavior (used by tests + non-container
36
+ // dev runs).
37
+ containerBroker?: ContainerBroker
38
+ }
39
+
40
+ export type Server = ReturnType<typeof createServer>
41
+
42
+ type TuiWsData = { kind: 'tui'; sessionId: string }
43
+ type WsData = TuiWsData | BrokerWsData
44
+ type Ws = ServerWebSocket<TuiWsData>
45
+
46
+ type QueuedPrompt = {
47
+ streamMessageId: StreamMessageId
48
+ text: string
49
+ delivery: PromptDelivery
50
+ ts: number
51
+ }
52
+
53
+ type SessionState = {
54
+ session: AgentSession
55
+ sessionFileId: string
56
+ origin: SessionOrigin
57
+ sessionManager: { getSessionFile: () => string | undefined } | undefined
58
+ drainQueue: QueuedPrompt[]
59
+ draining: boolean
60
+ unsubBroadcast: Unsubscribe | null
61
+ unsubPrompts: Unsubscribe | null
62
+ // Captured at session open so close-time hooks fire against the same
63
+ // generation that ran session.start. A plugin reload mid-connection does
64
+ // not re-target this session's lifecycle hooks.
65
+ runtimeSnapshot: PluginRuntimeState | null
66
+ dispose: () => Promise<void>
67
+ }
68
+
69
+ function send(ws: Ws, msg: ServerMessage) {
70
+ ws.send(JSON.stringify(msg))
71
+ }
72
+
73
+ export function createServer({
74
+ port,
75
+ reloadAll,
76
+ reloadRegistry,
77
+ createSession = defaultCreateSessionWithDispose,
78
+ sessionFactory,
79
+ stream,
80
+ channelRouter,
81
+ agentDir,
82
+ pluginRuntime,
83
+ containerName,
84
+ containerBroker,
85
+ }: ServerOptions) {
86
+ const sessionStates = new WeakMap<Ws, SessionState>()
87
+
88
+ function start(): BunServer<WsData> {
89
+ const bunServer = Bun.serve<WsData>({
90
+ port,
91
+ fetch(req, server) {
92
+ const url = new URL(req.url)
93
+ if (url.pathname === '/portbroker') {
94
+ if (!containerBroker) return new Response('portbroker disabled', { status: 404 })
95
+ const data: BrokerWsData = { kind: 'portbroker', authed: false }
96
+ if (server.upgrade(req, { data })) return
97
+ return new Response('upgrade failed', { status: 400 })
98
+ }
99
+ const sessionId = crypto.randomUUID()
100
+ const data: TuiWsData = { kind: 'tui', sessionId }
101
+ if (server.upgrade(req, { data })) return
102
+ return new Response('typeclaw agent', { status: 200 })
103
+ },
104
+ websocket: {
105
+ async open(rawWs) {
106
+ if (rawWs.data.kind === 'portbroker') {
107
+ containerBroker?.open(rawWs as ServerWebSocket<BrokerWsData>)
108
+ return
109
+ }
110
+ const ws = rawWs as Ws
111
+ const sessionManager = sessionFactory?.createPersisted()
112
+ const sessionFileId = sessionManager?.getSessionId() ?? ws.data.sessionId
113
+ // Snapshot the runtime once so the entire session lifecycle for this
114
+ // ws connection sees one consistent generation of registry+hooks. A
115
+ // reload landing mid-connection swaps the live pointer; this session
116
+ // keeps using the snapshot it was created with until close.
117
+ const runtimeSnapshot = pluginRuntime?.get()
118
+ const pluginsWiring =
119
+ runtimeSnapshot !== undefined && agentDir !== undefined
120
+ ? {
121
+ registry: runtimeSnapshot.registry,
122
+ hooks: runtimeSnapshot.hooks,
123
+ sessionId: sessionFileId,
124
+ agentDir,
125
+ }
126
+ : undefined
127
+ const origin: SessionOrigin = { kind: 'tui', sessionId: sessionFileId }
128
+ const result = await createSession({
129
+ reloadRegistry,
130
+ sessionManager,
131
+ origin,
132
+ ...(stream ? { stream } : {}),
133
+ ...(channelRouter ? { channelRouter } : {}),
134
+ ...(pluginsWiring ? { plugins: pluginsWiring } : {}),
135
+ ...(containerName !== undefined ? { containerName } : {}),
136
+ })
137
+ const session = 'session' in result ? result.session : result
138
+ const dispose = 'session' in result && result.dispose ? result.dispose : async () => {}
139
+
140
+ const state: SessionState = {
141
+ session,
142
+ sessionFileId,
143
+ origin,
144
+ sessionManager,
145
+ drainQueue: [],
146
+ draining: false,
147
+ unsubBroadcast: null,
148
+ unsubPrompts: null,
149
+ runtimeSnapshot: runtimeSnapshot ?? null,
150
+ dispose,
151
+ }
152
+ sessionStates.set(ws, state)
153
+
154
+ if (runtimeSnapshot !== undefined && agentDir !== undefined) {
155
+ await runtimeSnapshot.hooks.runSessionStart({ sessionId: sessionFileId, agentDir })
156
+ }
157
+
158
+ forwardSessionEvents(ws, session)
159
+
160
+ if (stream) {
161
+ state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
162
+ enqueuePrompt(ws, state, msg),
163
+ )
164
+
165
+ state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
166
+ const payload: ServerMessage = {
167
+ type: 'notification',
168
+ payload: msg.payload,
169
+ ...(msg.replyTo !== undefined ? { replyTo: msg.replyTo } : {}),
170
+ ...(msg.meta !== undefined ? { meta: msg.meta } : {}),
171
+ }
172
+ send(ws, payload)
173
+ })
174
+ }
175
+
176
+ send(ws, { type: 'connected', sessionId: sessionFileId })
177
+ console.log(`session ${sessionFileId}: open`)
178
+ },
179
+ async message(rawWs, raw) {
180
+ if (rawWs.data.kind === 'portbroker') {
181
+ await containerBroker?.message(rawWs as ServerWebSocket<BrokerWsData>, raw as string | Buffer)
182
+ return
183
+ }
184
+ const ws = rawWs as Ws
185
+ const msg = JSON.parse(String(raw)) as ClientMessage
186
+ const state = sessionStates.get(ws)
187
+
188
+ if (msg.type === 'reload') {
189
+ await handleReload(ws, reloadAll, reloadRegistry, msg.scope)
190
+ return
191
+ }
192
+
193
+ if (msg.type === 'abort') {
194
+ if (!state) return
195
+ await state.session.abort()
196
+ return
197
+ }
198
+
199
+ if (msg.type === 'queue_cancel') {
200
+ if (!state) return
201
+ const before = state.drainQueue.length
202
+ state.drainQueue = state.drainQueue.filter((q) => q.streamMessageId !== msg.messageId)
203
+ if (state.drainQueue.length !== before) pushQueueState(ws, state)
204
+ return
205
+ }
206
+
207
+ if (msg.type === 'prompt') {
208
+ if (!state) return
209
+ if (stream) {
210
+ stream.publish({
211
+ target: { kind: 'session', sessionId: state.sessionFileId },
212
+ payload: { kind: 'prompt', text: msg.text, delivery: msg.delivery ?? 'queue' },
213
+ meta: { source: 'tui' },
214
+ })
215
+ return
216
+ }
217
+ send(ws, { type: 'prompt_started', messageId: `local-${crypto.randomUUID()}`, text: msg.text })
218
+ try {
219
+ await state.session.prompt(msg.text)
220
+ send(ws, { type: 'done' })
221
+ } catch (err) {
222
+ send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
223
+ }
224
+ const fallbackHooks = state.runtimeSnapshot?.hooks
225
+ if (fallbackHooks !== undefined) {
226
+ await fallbackHooks.runSessionIdle({
227
+ sessionId: state.sessionFileId,
228
+ parentTranscriptPath: state.sessionManager?.getSessionFile(),
229
+ idleMs: 0,
230
+ })
231
+ }
232
+ return
233
+ }
234
+ },
235
+ async close(rawWs) {
236
+ if (rawWs.data.kind === 'portbroker') {
237
+ containerBroker?.close(rawWs as ServerWebSocket<BrokerWsData>)
238
+ return
239
+ }
240
+ const ws = rawWs as Ws
241
+ const state = sessionStates.get(ws)
242
+ state?.unsubBroadcast?.()
243
+ state?.unsubPrompts?.()
244
+ try {
245
+ if (state && state.runtimeSnapshot !== null) {
246
+ await state.runtimeSnapshot.hooks.runSessionEnd({ sessionId: state.sessionFileId })
247
+ }
248
+ } finally {
249
+ if (state) {
250
+ state.session.dispose()
251
+ await state.dispose()
252
+ }
253
+ sessionStates.delete(ws)
254
+ console.log(`session ${state?.sessionFileId ?? ws.data.sessionId}: close`)
255
+ }
256
+ },
257
+ },
258
+ })
259
+
260
+ console.log(`typeclaw agent listening on ws://localhost:${bunServer.port}`)
261
+ return bunServer
262
+ }
263
+
264
+ return { start }
265
+ }
266
+
267
+ function forwardSessionEvents(ws: Ws, session: AgentSession): void {
268
+ const toolStartedAt = new Map<string, number>()
269
+
270
+ session.subscribe((event) => {
271
+ switch (event.type) {
272
+ case 'message_update':
273
+ if (event.assistantMessageEvent.type === 'text_delta') {
274
+ send(ws, { type: 'text_delta', delta: event.assistantMessageEvent.delta })
275
+ }
276
+ break
277
+ case 'message_end':
278
+ // pi-coding-agent encodes upstream LLM failures (billing, rate limit,
279
+ // network, malformed response, etc.) in the assistant message itself
280
+ // rather than throwing — `stopReason: 'error'` with a populated
281
+ // `errorMessage`. Without this branch the user sees an empty turn
282
+ // because no text deltas were ever emitted, which looks like a freeze.
283
+ // The server's existing try/catch around `session.prompt()` only
284
+ // catches throws, so it never sees these.
285
+ forwardAssistantError(ws, event.message)
286
+ break
287
+ case 'tool_execution_start':
288
+ toolStartedAt.set(event.toolCallId, Date.now())
289
+ send(ws, {
290
+ type: 'tool_start',
291
+ toolCallId: event.toolCallId,
292
+ name: event.toolName,
293
+ args: event.args,
294
+ })
295
+ break
296
+ case 'tool_execution_end': {
297
+ const startedAt = toolStartedAt.get(event.toolCallId)
298
+ toolStartedAt.delete(event.toolCallId)
299
+ const durationMs = startedAt === undefined ? 0 : Date.now() - startedAt
300
+ send(ws, {
301
+ type: 'tool_end',
302
+ toolCallId: event.toolCallId,
303
+ name: event.toolName,
304
+ error: event.isError,
305
+ result: event.result,
306
+ durationMs,
307
+ })
308
+ break
309
+ }
310
+ }
311
+ })
312
+ }
313
+
314
+ function forwardAssistantError(ws: Ws, message: unknown): void {
315
+ if (typeof message !== 'object' || message === null) return
316
+ const m = message as { role?: string; stopReason?: string; errorMessage?: string }
317
+ if (m.role !== 'assistant') return
318
+ if (m.stopReason !== 'error' && m.stopReason !== 'aborted') return
319
+ // 'aborted' is fired when the user hits Escape — don't surface it as an
320
+ // error message because the TUI already shows abort feedback elsewhere.
321
+ if (m.stopReason === 'aborted') return
322
+ const text = typeof m.errorMessage === 'string' && m.errorMessage.length > 0 ? m.errorMessage : 'LLM call failed'
323
+ send(ws, { type: 'error', message: text })
324
+ }
325
+
326
+ function enqueuePrompt(ws: Ws, state: SessionState, msg: StreamMessage): void {
327
+ const payload = msg.payload as { kind?: string; text?: string; delivery?: PromptDelivery }
328
+ if (payload?.kind !== 'prompt' || typeof payload.text !== 'string') return
329
+ const delivery: PromptDelivery = payload.delivery ?? 'queue'
330
+ if (delivery === 'interrupt') {
331
+ void state.session.abort().catch((err) => {
332
+ send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
333
+ })
334
+ }
335
+ state.drainQueue.push({
336
+ streamMessageId: msg.id,
337
+ text: payload.text,
338
+ delivery,
339
+ ts: msg.ts,
340
+ })
341
+ pushQueueState(ws, state)
342
+ void drain(ws, state)
343
+ }
344
+
345
+ // `session.idle` semantically means "the agent finished a prompt and is now
346
+ // awaiting next input". Plugins (notably the bundled memory plugin) own any
347
+ // debouncing on top of this signal. Core fires the hook synchronously after
348
+ // every `prompt()` completion (success or error), passing the current
349
+ // transcript path so plugins can spawn subagents that read it.
350
+ function makeIdleHookCaller(state: SessionState): () => Promise<void> {
351
+ const hooks: HookBus | undefined = state.runtimeSnapshot?.hooks
352
+ if (hooks === undefined) return async () => {}
353
+ return async () => {
354
+ await hooks.runSessionIdle({
355
+ sessionId: state.sessionFileId,
356
+ parentTranscriptPath: state.sessionManager?.getSessionFile(),
357
+ idleMs: 0,
358
+ origin: state.origin,
359
+ })
360
+ }
361
+ }
362
+
363
+ async function drain(ws: Ws, state: SessionState): Promise<void> {
364
+ if (state.draining) return
365
+ state.draining = true
366
+ const fireIdle = makeIdleHookCaller(state)
367
+ try {
368
+ while (state.drainQueue.length > 0) {
369
+ const item = state.drainQueue.shift()
370
+ if (!item) break
371
+ pushQueueState(ws, state)
372
+ send(ws, { type: 'prompt_started', messageId: item.streamMessageId, text: item.text })
373
+
374
+ try {
375
+ await state.session.prompt(item.text)
376
+ send(ws, { type: 'done' })
377
+ } catch (err) {
378
+ send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
379
+ }
380
+ await fireIdle()
381
+ }
382
+ } finally {
383
+ state.draining = false
384
+ }
385
+ }
386
+
387
+ function pushQueueState(ws: Ws, state: SessionState): void {
388
+ const pending: QueueStateItem[] = state.drainQueue.map((q) => ({
389
+ id: q.streamMessageId,
390
+ text: q.text,
391
+ ts: q.ts,
392
+ }))
393
+ send(ws, { type: 'queue_state', pending })
394
+ }
395
+
396
+ async function handleReload(
397
+ ws: Ws,
398
+ reloadAll: ReloadAllFn | undefined,
399
+ reloadRegistry: ReloadRegistry | undefined,
400
+ scope: string | undefined,
401
+ ): Promise<void> {
402
+ if (scope !== undefined && scope.length > 0) {
403
+ if (!reloadRegistry) {
404
+ send(ws, {
405
+ type: 'reload_result',
406
+ results: [{ scope, ok: false, reason: 'no reload registry configured' }],
407
+ })
408
+ return
409
+ }
410
+ try {
411
+ const result = await reloadRegistry.reloadOne(scope)
412
+ send(ws, { type: 'reload_result', results: [result] })
413
+ } catch (err) {
414
+ send(ws, {
415
+ type: 'reload_result',
416
+ results: [{ scope, ok: false, reason: err instanceof Error ? err.message : String(err) }],
417
+ })
418
+ }
419
+ return
420
+ }
421
+
422
+ if (!reloadAll) {
423
+ const empty: ReloadResultPayload[] = []
424
+ send(ws, { type: 'reload_result', results: empty })
425
+ return
426
+ }
427
+ try {
428
+ const { results } = await reloadAll()
429
+ send(ws, { type: 'reload_result', results })
430
+ } catch (err) {
431
+ send(ws, {
432
+ type: 'reload_result',
433
+ results: [{ scope: 'reload', ok: false, reason: err instanceof Error ? err.message : String(err) }],
434
+ })
435
+ }
436
+ }
@@ -0,0 +1,23 @@
1
+ import { mkdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { SessionManager } from '@mariozechner/pi-coding-agent'
5
+
6
+ export type SessionFactory = {
7
+ createPersisted(): SessionManager
8
+ sessionDir(): string
9
+ }
10
+
11
+ export type CreateSessionFactoryOptions = {
12
+ agentDir: string
13
+ }
14
+
15
+ export function createSessionFactory({ agentDir }: CreateSessionFactoryOptions): SessionFactory {
16
+ const dir = join(agentDir, 'sessions')
17
+ mkdirSync(dir, { recursive: true })
18
+
19
+ return {
20
+ createPersisted: () => SessionManager.create(agentDir, dir),
21
+ sessionDir: () => dir,
22
+ }
23
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ type ClientMessage,
3
+ type PromptDelivery,
4
+ type QueueStateItem,
5
+ type ReloadResultPayload,
6
+ type ServerMessage,
7
+ } from './protocol'
8
+
9
+ export { formatLocalDate, formatLocalDateTime } from './local-time'
@@ -0,0 +1,21 @@
1
+ function pad2(n: number): string {
2
+ return String(n).padStart(2, '0')
3
+ }
4
+
5
+ export function formatLocalDate(date: Date = new Date()): string {
6
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
7
+ }
8
+
9
+ export function formatLocalDateTime(date: Date = new Date()): string {
10
+ const datePart = formatLocalDate(date)
11
+ const timePart = `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
12
+ const offset = formatTimezoneOffset(date)
13
+ return `${datePart}T${timePart}${offset}`
14
+ }
15
+
16
+ function formatTimezoneOffset(date: Date): string {
17
+ const offsetMinutes = -date.getTimezoneOffset()
18
+ const sign = offsetMinutes >= 0 ? '+' : '-'
19
+ const abs = Math.abs(offsetMinutes)
20
+ return `${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`
21
+ }
@@ -0,0 +1,25 @@
1
+ export type ReloadResultPayload =
2
+ | { scope: string; ok: true; summary: string; details?: unknown }
3
+ | { scope: string; ok: false; reason: string }
4
+
5
+ export type PromptDelivery = 'queue' | 'steer' | 'interrupt'
6
+
7
+ export type ClientMessage =
8
+ | { type: 'prompt'; text: string; delivery?: PromptDelivery }
9
+ | { type: 'reload'; scope?: string }
10
+ | { type: 'abort' }
11
+ | { type: 'queue_cancel'; messageId: string }
12
+
13
+ export type QueueStateItem = { id: string; text: string; ts: number }
14
+
15
+ export type ServerMessage =
16
+ | { type: 'connected'; sessionId: string }
17
+ | { type: 'text_delta'; delta: string }
18
+ | { type: 'tool_start'; toolCallId: string; name: string; args: unknown }
19
+ | { type: 'tool_end'; toolCallId: string; name: string; error: boolean; result: unknown; durationMs: number }
20
+ | { type: 'done' }
21
+ | { type: 'error'; message: string }
22
+ | { type: 'reload_result'; results: ReloadResultPayload[] }
23
+ | { type: 'notification'; payload: unknown; replyTo?: string; meta?: Record<string, string> }
24
+ | { type: 'queue_state'; pending: QueueStateItem[] }
25
+ | { type: 'prompt_started'; messageId: string; text: string }
@@ -0,0 +1,87 @@
1
+ ---
2
+ name: typeclaw-channel-kakaotalk
3
+ description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `kakaotalk`. KakaoTalk renders messages as plain text — `**bold**`, `## headings`, `| tables |`, fenced code blocks, and other markdown all appear literally. There is no `@mention` syntax, no message threads, no replies-with-quote, and no file attachments. Read it before composing anything for KakaoTalk so you don't dump markdown into a chat window.
4
+ ---
5
+
6
+ # typeclaw-channel-kakaotalk
7
+
8
+ You are speaking through the `kakaotalk` channel adapter. The adapter wraps `agent-messenger`'s KakaoTalkClient/KakaoTalkListener, which speaks KakaoTalk's LOCO push protocol as a registered tablet sub-device of a real user account.
9
+
10
+ This means **you are messaging as a person, not as a bot.** Other participants see your typeclaw account's name and profile photo. Match their tone — the people you're talking with assume they're talking to a human.
11
+
12
+ ## What KakaoTalk does NOT support
13
+
14
+ If you produce any of the following, KakaoTalk will render it literally and the recipient will see the raw markup:
15
+
16
+ - **Bold / italic / strikethrough** — `**bold**` shows as `**bold**`. Drop the asterisks; emphasize with word choice or capitalization (sparingly).
17
+ - **Headings** — `# H1`, `## H2`, `### H3` all render as raw `#` characters.
18
+ - **Tables** — pipe-delimited tables become a wall of `|` characters. Use bullet lists or short prose paragraphs instead.
19
+ - **Code fences** — ``` blocks render as raw backticks. For short snippets, paste the code inline. For long snippets, summarize and offer to send it via another channel.
20
+ - **Inline code** — `` `foo` `` renders as `` `foo` ``. Just write `foo`.
21
+ - **Links with display text** — `[label](url)` becomes the literal string. Send the bare URL on its own; the KakaoTalk client will auto-link it.
22
+ - **Mentions** — there is no `@user` syntax that the protocol surfaces. Address people by name in the message body.
23
+ - **Threads / replies-with-quote** — every message is a top-level chat post. There is no per-message reply UI.
24
+ - **Attachments** — the adapter is text-only. If the user asks you to send a file, say so and offer an alternative (paste a link, summarize the file, ship it via another channel).
25
+
26
+ The adapter logs a warning the first time you try to send attachments and then drops them. The user-visible result is "your message arrived without the file."
27
+
28
+ ## What KakaoTalk DOES support
29
+
30
+ - Plain UTF-8 text. Emoji are fine.
31
+ - URLs auto-linkify in the client. Send them bare — `https://example.com/foo`, no markdown wrapping.
32
+ - Newlines render as line breaks. You can use `\n\n` to space paragraphs.
33
+
34
+ ## Message length & cadence
35
+
36
+ KakaoTalk is mobile-first. The reading surface is small and the user is on their phone. Keep messages **short and conversational**, not essay-length. If you have a long answer:
37
+
38
+ 1. Lead with a one-sentence summary.
39
+ 2. Optional: 2–4 short bullet-style lines (use `-` or `•` as line prefixes — the client renders them as text, not lists, but the visual rhythm still helps).
40
+ 3. Stop. If the user wants more, they will ask.
41
+
42
+ Splitting one logical answer across multiple messages is fine and often more natural than one wall of text.
43
+
44
+ ## Engagement model
45
+
46
+ The adapter exposes three engagement triggers via `channels.kakaotalk.engagement.trigger` in `typeclaw.json`:
47
+
48
+ - `dm` — every message in a 1:1 chat. Default-on.
49
+ - `reply` — every message in a chat where you sent the previous message. Default-on.
50
+ - `mention` — KakaoTalk has no mention syntax in the protocol. The adapter reads this trigger as "respond when an alias from `alias[]` in `typeclaw.json` appears in the text". Without configured aliases, this trigger never fires.
51
+
52
+ Stickiness behaves the same as Slack/Discord: once you've engaged in a chat, follow-up messages within `engagement.stickiness.perReply.window` ms will route to you regardless of trigger.
53
+
54
+ If you find yourself NOT receiving messages you expect to, the most likely cause is the `allow` list. KakaoTalk uses a different grammar from Slack/Discord:
55
+
56
+ - `kakao:*` — every chat the account can see (use sparingly: this is every group and DM you are a member of)
57
+ - `kakao:dm/*` — every 1:1 chat
58
+ - `kakao:group/*` — every multi-person group chat
59
+ - `kakao:open/*` — every open chat
60
+ - `kakao:<chat-id>` — a specific chat by numeric chat_id
61
+
62
+ The init wizard's default is `kakao:dm/*` because group chats with personal accounts are sensitive — every member sees every reply. Only broaden the allow list when the user explicitly asks.
63
+
64
+ ## Mark read on every inbound
65
+
66
+ The adapter sends a LOCO `NOTIREAD` ack to KakaoTalk for every inbound message event it observes. The sender's unread "1" (노란숫자) clears in their client as soon as the agent's container receives the bytes. This is always-on — there is no config flag to turn it off short of editing the source. (An earlier `channels.kakaotalk.autoMarkRead` opt-in field was removed; existing configs still parse but the field is ignored.)
67
+
68
+ Things to know about this behavior:
69
+
70
+ - Auto-acking every received message is a distinct behavioral fingerprint compared to a human. A human reads messages when they open the chat; this adapter acks every received message instantly, even ones you never reply to. KakaoTalk's abuse detection may flag accounts that ack rapidly and unconditionally. **Run the kakaotalk adapter only on dedicated agent accounts you can afford to lose.**
71
+ - Dropped messages are still acked. If classify drops the message (your own self-sent loopback, empty text, sender not in `allow`), the unread "1" still clears — the agent has observed the bytes, so the read indicator should match.
72
+ - Open chats (오픈채팅) are skipped: the LOCO `NOTIREAD` packet needs a `linkId` for open chats and the adapter doesn't surface it yet. Unread counters in open chats will not decrement.
73
+ - The phone's home-screen OS unread badge may lag until the phone client foregrounds; the in-chat counter and other participants' indicators update immediately. KakaoTalk client quirk, not a typeclaw bug.
74
+
75
+ If a markRead call fails (network blip, non-success status from the server, container shutdown), it is logged at warn level (`[kakaotalk] mark-read failed: ...`) and silently moves on — message delivery is never blocked by an ack failure.
76
+
77
+ ## Self-loop guard
78
+
79
+ The adapter drops every inbound where `event.author_id` equals the logged-in account's `user_id`. Two typeclaw agents talking to each other through KakaoTalk would otherwise loop indefinitely (KakaoTalk has no bot-flag, so neither side can detect the other is automated). Do not try to defeat this guard.
80
+
81
+ ## When you cannot answer in KakaoTalk
82
+
83
+ If the user asks you to do something the adapter cannot do (send a file, render markdown, post in a thread), say so plainly:
84
+
85
+ > "I can't attach files through this chat. Want me to drop the file in [other channel] instead?"
86
+
87
+ Better than silently dropping the attachment and pretending you sent it.
@@ -0,0 +1,64 @@
1
+ ---
2
+ name: typeclaw-channel-telegram-bot
3
+ description: "How replies sent through the `telegram-bot` channel adapter actually render in Telegram. Read this BEFORE composing a reply that contains formatting markers — `**bold**`, `*italic*`, `_italic_`, `` `code` ``, fenced code blocks (```), `[label](url)`, `~~strike~~`, `||spoiler||`, `# heading`, `- list`, `| table |`, raw `.` `!` `(` `)` `_` `*` punctuation in URLs/IDs, snake_case identifiers — and your draft is going to a Telegram chat or supergroup. Also load if a Telegram user reports your message arrived as raw markdown (literal `**asterisks**`), as garbled text with stray backslashes, or with a Telegram error like `Bad Request: can't parse entities`. Covers: which markers actually render, which ones are stripped or escaped, what Telegram has no equivalent for (headings, bulleted/numbered lists, tables — they fall through as escaped literals), how the adapter's MarkdownV2 escaping behaves, and how to keep your message readable when the rendering and the source diverge."
4
+ ---
5
+
6
+ # typeclaw-channel-telegram-bot
7
+
8
+ When you reply through the `telegram-bot` channel adapter, your text does NOT go to Telegram unchanged. It goes through `toTelegramMarkdownV2` in `src/channels/adapters/telegram-bot-format.ts`, which translates the common Markdown you write into Telegram's strict **MarkdownV2** dialect and escapes every reserved char that isn't part of a recognized formatting marker. The Telegram Bot API is then called with `parse_mode: 'MarkdownV2'`.
9
+
10
+ This is necessary because Telegram's MarkdownV2 parser is unforgiving: any unescaped `_ * [ ] ( ) ~ \` > # + - = | { } . !`outside an entity returns`Bad Request: can't parse entities`and the whole message is rejected. Plain text would never crash, but then your`**bold**`would render as the literal six characters`**bold**` — which is the bug this skill exists to prevent you from re-introducing on the user side ("just write what you mean, the adapter will handle it").
11
+
12
+ ## What the adapter renders
13
+
14
+ These markers translate to native Telegram formatting:
15
+
16
+ - `**bold**` → bold (renders as `*bold*` on the wire — MarkdownV2 reserves `*` for bold)
17
+ - `__bold__` → bold (when not adjacent to a word char on either side; otherwise treated as literal underscores so `my__var__name` stays a snake_case identifier)
18
+ - `*italic*` → italic (when the asterisks are NOT between word chars — `a*b*c` stays literal)
19
+ - `_italic_` → italic (same word-boundary rule as `*italic*` — `var_name` stays an identifier)
20
+ - `` `inline code` `` → monospace inline code
21
+ - ` ```language\n...code...\n``` ` → fenced code block; the optional language tag rides through (Telegram displays it but does no syntax highlighting)
22
+ - `[label](url)` → clickable hyperlink
23
+ - `~~strike~~` → strikethrough
24
+ - `||spoiler||` → spoiler (tap to reveal)
25
+
26
+ Empty markers (`****`, ` `` `, `~~~~`, `||||`) are NOT emitted as zero-width entities; they fall through as escaped literals. Telegram rejects empty entities, and the adapter's safety pass catches this for you.
27
+
28
+ ## What the adapter does NOT render
29
+
30
+ Telegram's MarkdownV2 has no native rendering for any of these. They fall through as escaped literal characters — visible in the message, but not formatted:
31
+
32
+ - `# Heading`, `## Heading 2`, etc. — appear as `\# Heading` / `\## Heading 2` (the `#` shows but it's not a heading)
33
+ - `- item` / `* item` / `1. item` (bulleted or numbered lists) — the markers escape to `\-` / `\*` / `1\.` and the message stays a plain paragraph with line breaks; Telegram users see the dash or number, not a styled list
34
+ - `| col | col |\n|---|---|\n` (Markdown tables) — every `|` and `-` escapes; the result is illegible. Don't send tables. Send a fenced code block with aligned columns instead.
35
+ - `> quote` — the `>` escapes to `\>`; Telegram does have a native blockquote syntax but the agent's common-Markdown writer has no way to opt into it through this adapter today
36
+ - HTML tags (`<b>`, `<i>`, `<a>`) — the adapter does NOT use HTML mode; tag chars escape and the literal `<b>foo</b>` shows up
37
+
38
+ When you need any of these effects, **rewrite the message** to use what Telegram does support (bold for emphasis instead of headings; bullet points written as separate lines with bold leading words instead of `-` markers; aligned-column code fences instead of tables).
39
+
40
+ ## Punctuation rules you can stop worrying about
41
+
42
+ You do NOT need to manually escape any of `_ * [ ] ( ) ~ \` > # + - = | { } . !` in your draft. The adapter handles it.
43
+
44
+ - Periods, exclamation points, hyphens, plus signs in prose — type them naturally; the adapter escapes them.
45
+ - Snake_case identifiers (`my_var_name`, `foo__bar__baz`) — type them naturally; the word-boundary guards keep them from italicizing.
46
+ - Math/code asterisks in prose (`a*b*c`, `2 * 3 = 6`) — same; the word-boundary guards keep them literal.
47
+ - URLs containing `_`, `.`, `-`, `~` — they pass through fine inside `[label](url)`.
48
+
49
+ URLs containing **unescaped parentheses** (Wikipedia-style `Foo_(bar)`) intentionally fall back to escaped literal text rather than render as a link — the adapter cannot disambiguate the closing `)` from a content paren. If you need to link such a URL, percent-encode the parens in the URL (`%28`, `%29`) before putting it in the link.
50
+
51
+ ## When the user says "your formatting looks broken"
52
+
53
+ Three classes of failure to triage in this order:
54
+
55
+ 1. **Literal `**asterisks**` in the rendered message.** Means the adapter was bypassed (someone shipped a regression that flipped `parse_mode` off). Check `src/channels/adapters/telegram-bot.ts` — `sendMessage` MUST include `{ parse_mode: 'MarkdownV2' }` and the text must come from `toTelegramMarkdownV2(...)`. The mutation-guard test in `telegram-bot-outbound.test.ts` exists exactly to catch this.
56
+ 2. **Visible backslashes (e.g. `v1\.2\.3`).** Means the user is on a Telegram client too old to honor MarkdownV2 entity-rendering, OR the message was forwarded to a context that doesn't (rare). Nothing the adapter can do — the wire format is correct.
57
+ 3. **Telegram error: `Bad Request: can't parse entities`.** Means the formatter emitted invalid MarkdownV2 — either an entity-rule edge case the formatter mishandled, or the agent wrote something pathological (e.g. a literal MarkdownV2-escaped fragment by hand). File a bug against the formatter with the exact input string; do not work around it by sending plain text (that loses formatting for everyone).
58
+
59
+ ## File pointers
60
+
61
+ - `src/channels/adapters/telegram-bot.ts` — adapter; `createOutboundCallback` is where rendering happens.
62
+ - `src/channels/adapters/telegram-bot-format.ts` — the formatter; pure, no dependencies, fully tested.
63
+ - `src/channels/adapters/telegram-bot-format.test.ts` — every formatter rule above is mutation-guarded by a named test here.
64
+ - `src/channels/adapters/telegram-bot-outbound.test.ts` — the integration mutation guard that pins `parse_mode: 'MarkdownV2'`.