typeclaw 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +15 -13
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +13 -10
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +137 -7
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +809 -300
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +11 -3
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +13 -3
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +491 -19
  67. package/src/config/index.ts +15 -1
  68. package/src/config/models-mutation.ts +200 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +6 -1
  73. package/src/container/port.ts +10 -0
  74. package/src/container/require-running.ts +33 -0
  75. package/src/container/start.ts +81 -63
  76. package/src/cron/consumer.ts +22 -2
  77. package/src/cron/index.ts +45 -4
  78. package/src/cron/schema.ts +104 -0
  79. package/src/doctor/checks.ts +51 -34
  80. package/src/doctor/plugin-bridge.ts +28 -4
  81. package/src/git/system-commit.ts +103 -0
  82. package/src/hostd/daemon.ts +16 -0
  83. package/src/hostd/kakao-renewal-manager.ts +223 -0
  84. package/src/hostd/paths.ts +7 -0
  85. package/src/init/dockerfile.ts +36 -10
  86. package/src/init/gitignore.ts +1 -1
  87. package/src/init/index.ts +213 -85
  88. package/src/init/kakaotalk-auth.ts +18 -1
  89. package/src/init/models-dev.ts +26 -1
  90. package/src/init/run-owner-claim.ts +77 -0
  91. package/src/permissions/builtins.ts +70 -0
  92. package/src/permissions/grant.ts +99 -0
  93. package/src/permissions/index.ts +29 -0
  94. package/src/permissions/match-rule.ts +305 -0
  95. package/src/permissions/permissions.ts +196 -0
  96. package/src/permissions/resolve.ts +80 -0
  97. package/src/permissions/schema.ts +79 -0
  98. package/src/plugin/context.ts +8 -4
  99. package/src/plugin/define.ts +2 -0
  100. package/src/plugin/index.ts +2 -0
  101. package/src/plugin/manager.ts +41 -0
  102. package/src/plugin/registry.ts +9 -0
  103. package/src/plugin/types.ts +35 -1
  104. package/src/reload/client.ts +25 -1
  105. package/src/role-claim/client.ts +182 -0
  106. package/src/role-claim/code.ts +53 -0
  107. package/src/role-claim/controller.ts +194 -0
  108. package/src/role-claim/index.ts +19 -0
  109. package/src/role-claim/match-rule.ts +43 -0
  110. package/src/role-claim/pending.ts +100 -0
  111. package/src/run/channel-session-factory.ts +76 -5
  112. package/src/run/index.ts +68 -7
  113. package/src/secrets/encryption.ts +116 -0
  114. package/src/secrets/kakao-renewal.ts +248 -0
  115. package/src/secrets/kakao-store.ts +66 -7
  116. package/src/secrets/keys.ts +173 -0
  117. package/src/secrets/schema.ts +23 -0
  118. package/src/secrets/storage.ts +83 -0
  119. package/src/server/index.ts +198 -71
  120. package/src/shared/index.ts +4 -0
  121. package/src/shared/protocol.ts +27 -0
  122. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  123. package/src/skills/typeclaw-config/SKILL.md +104 -112
  124. package/src/skills/typeclaw-memory/SKILL.md +9 -9
  125. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  126. package/src/stream/types.ts +7 -1
  127. package/src/tui/client.ts +66 -5
  128. package/src/tui/index.ts +61 -9
  129. package/src/usage/aggregate.ts +117 -0
  130. package/src/usage/format.ts +30 -0
  131. package/src/usage/index.ts +68 -0
  132. package/src/usage/report.ts +354 -0
  133. package/src/usage/scan.ts +186 -0
  134. package/typeclaw.schema.json +134 -98
@@ -12,6 +12,7 @@ import type { ChannelRouter } from '@/channels/router'
12
12
  import type { HookBus } from '@/plugin'
13
13
  import type { BrokerWsData, ContainerBroker } from '@/portbroker'
14
14
  import type { ReloadAllResult, ReloadRegistry } from '@/reload'
15
+ import type { ClaimController, ClaimResultEvent } from '@/role-claim'
15
16
  import type { PluginRuntime, PluginRuntimeState } from '@/run/plugin-runtime'
16
17
  import type { SessionFactory } from '@/sessions'
17
18
  import type { ClientMessage, PromptDelivery, QueueStateItem, ReloadResultPayload, ServerMessage } from '@/shared'
@@ -20,6 +21,12 @@ import type { Stream, StreamMessage, StreamMessageId, Unsubscribe } from '@/stre
20
21
  export type ReloadAllFn = () => Promise<ReloadAllResult>
21
22
  export type CreateSessionFn = (options?: CreateSessionOptions) => Promise<AgentSession | CreateSessionResult>
22
23
 
24
+ export type ServerLogger = {
25
+ info: (msg: string) => void
26
+ warn: (msg: string) => void
27
+ error: (msg: string) => void
28
+ }
29
+
23
30
  export type ServerOptions = {
24
31
  port: number
25
32
  reloadAll?: ReloadAllFn
@@ -31,11 +38,28 @@ export type ServerOptions = {
31
38
  agentDir?: string
32
39
  pluginRuntime?: PluginRuntime
33
40
  containerName?: string
41
+ tuiToken?: string
34
42
  // Optional in-process portbroker handler. When provided, requests to the
35
43
  // /portbroker WS path are routed to it instead of being treated as TUI
36
44
  // sessions. Omit to keep TUI-only behavior (used by tests + non-container
37
45
  // dev runs).
38
46
  containerBroker?: ContainerBroker
47
+ // Optional logger for server-side events. Defaults to `consoleLogger`
48
+ // which writes to stdout/stderr so `typeclaw logs` surfaces every event.
49
+ // Tests inject a fake logger to assert on captured output.
50
+ logger?: ServerLogger
51
+ // Optional role-claim controller. When set, the server accepts
52
+ // `claim_start` / `claim_cancel` from TUI-class WS clients (the host
53
+ // CLI's `typeclaw role claim` command in particular), and pushes
54
+ // `claim_started` / `claim_completed` / `claim_error` back over the
55
+ // same connection. Omitted in tests that don't exercise the flow.
56
+ claimController?: ClaimController
57
+ }
58
+
59
+ const consoleLogger: ServerLogger = {
60
+ info: (m) => console.log(m),
61
+ warn: (m) => console.warn(m),
62
+ error: (m) => console.error(m),
39
63
  }
40
64
 
41
65
  export type Server = ReturnType<typeof createServer>
@@ -60,6 +84,8 @@ type SessionState = {
60
84
  draining: boolean
61
85
  unsubBroadcast: Unsubscribe | null
62
86
  unsubPrompts: Unsubscribe | null
87
+ unsubClaim: Unsubscribe | null
88
+ activeClaimCode: string | null
63
89
  // Captured at session open so close-time hooks fire against the same
64
90
  // generation that ran session.start. A plugin reload mid-connection does
65
91
  // not re-target this session's lifecycle hooks.
@@ -82,7 +108,10 @@ export function createServer({
82
108
  agentDir,
83
109
  pluginRuntime,
84
110
  containerName,
111
+ tuiToken,
85
112
  containerBroker,
113
+ logger = consoleLogger,
114
+ claimController,
86
115
  }: ServerOptions) {
87
116
  const sessionStates = new WeakMap<Ws, SessionState>()
88
117
 
@@ -97,6 +126,9 @@ export function createServer({
97
126
  if (server.upgrade(req, { data })) return
98
127
  return new Response('upgrade failed', { status: 400 })
99
128
  }
129
+ if (isWebSocketUpgrade(req) && tuiToken !== undefined && url.searchParams.get('token') !== tuiToken) {
130
+ return new Response('unauthorized', { status: 401 })
131
+ }
100
132
  const sessionId = crypto.randomUUID()
101
133
  const data: TuiWsData = { kind: 'tui', sessionId }
102
134
  if (server.upgrade(req, { data })) return
@@ -109,73 +141,82 @@ export function createServer({
109
141
  return
110
142
  }
111
143
  const ws = rawWs as Ws
112
- const sessionManager = sessionFactory?.createPersisted()
113
- const sessionFileId = sessionManager?.getSessionId() ?? ws.data.sessionId
114
- // Snapshot the runtime once so the entire session lifecycle for this
115
- // ws connection sees one consistent generation of registry+hooks. A
116
- // reload landing mid-connection swaps the live pointer; this session
117
- // keeps using the snapshot it was created with until close.
118
- const runtimeSnapshot = pluginRuntime?.get()
119
- const pluginsWiring =
120
- runtimeSnapshot !== undefined && agentDir !== undefined
121
- ? {
122
- registry: runtimeSnapshot.registry,
123
- hooks: runtimeSnapshot.hooks,
124
- sessionId: sessionFileId,
125
- agentDir,
126
- }
127
- : undefined
128
- const origin: SessionOrigin = { kind: 'tui', sessionId: sessionFileId }
129
- const result = await createSession({
130
- reloadRegistry,
131
- sessionManager,
132
- origin,
133
- ...(stream ? { stream } : {}),
134
- ...(channelRouter ? { channelRouter } : {}),
135
- ...(pluginsWiring ? { plugins: pluginsWiring } : {}),
136
- ...(containerName !== undefined ? { containerName } : {}),
137
- })
138
- const session = 'session' in result ? result.session : result
139
- const dispose = 'session' in result && result.dispose ? result.dispose : async () => {}
140
-
141
- const state: SessionState = {
142
- session,
143
- sessionFileId,
144
- origin,
145
- sessionManager,
146
- drainQueue: [],
147
- draining: false,
148
- unsubBroadcast: null,
149
- unsubPrompts: null,
150
- runtimeSnapshot: runtimeSnapshot ?? null,
151
- dispose,
152
- }
153
- sessionStates.set(ws, state)
144
+ try {
145
+ const sessionManager = sessionFactory?.createPersisted()
146
+ const sessionFileId = sessionManager?.getSessionId() ?? ws.data.sessionId
147
+ // Snapshot the runtime once so the entire session lifecycle for this
148
+ // ws connection sees one consistent generation of registry+hooks. A
149
+ // reload landing mid-connection swaps the live pointer; this session
150
+ // keeps using the snapshot it was created with until close.
151
+ const runtimeSnapshot = pluginRuntime?.get()
152
+ const pluginsWiring =
153
+ runtimeSnapshot !== undefined && agentDir !== undefined
154
+ ? {
155
+ registry: runtimeSnapshot.registry,
156
+ hooks: runtimeSnapshot.hooks,
157
+ sessionId: sessionFileId,
158
+ agentDir,
159
+ }
160
+ : undefined
161
+ const origin: SessionOrigin = { kind: 'tui', sessionId: sessionFileId }
162
+ const result = await createSession({
163
+ reloadRegistry,
164
+ sessionManager,
165
+ origin,
166
+ ...(stream ? { stream } : {}),
167
+ ...(channelRouter ? { channelRouter } : {}),
168
+ ...(pluginsWiring ? { plugins: pluginsWiring } : {}),
169
+ ...(containerName !== undefined ? { containerName } : {}),
170
+ })
171
+ const session = 'session' in result ? result.session : result
172
+ const dispose = 'session' in result && result.dispose ? result.dispose : async () => {}
173
+
174
+ const state: SessionState = {
175
+ session,
176
+ sessionFileId,
177
+ origin,
178
+ sessionManager,
179
+ drainQueue: [],
180
+ draining: false,
181
+ unsubBroadcast: null,
182
+ unsubPrompts: null,
183
+ unsubClaim: null,
184
+ activeClaimCode: null,
185
+ runtimeSnapshot: runtimeSnapshot ?? null,
186
+ dispose,
187
+ }
188
+ sessionStates.set(ws, state)
154
189
 
155
- if (runtimeSnapshot !== undefined && agentDir !== undefined) {
156
- await runtimeSnapshot.hooks.runSessionStart({ sessionId: sessionFileId, agentDir })
157
- }
190
+ if (runtimeSnapshot !== undefined && agentDir !== undefined) {
191
+ await runtimeSnapshot.hooks.runSessionStart({ sessionId: sessionFileId, agentDir })
192
+ }
158
193
 
159
- forwardSessionEvents(ws, session)
194
+ forwardSessionEvents(ws, session, logger, sessionFileId)
160
195
 
161
- if (stream) {
162
- state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
163
- enqueuePrompt(ws, state, msg, agentDir),
164
- )
196
+ if (stream) {
197
+ state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
198
+ enqueuePrompt(ws, state, msg, agentDir, logger),
199
+ )
200
+
201
+ state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
202
+ const payload: ServerMessage = {
203
+ type: 'notification',
204
+ payload: msg.payload,
205
+ ...(msg.replyTo !== undefined ? { replyTo: msg.replyTo } : {}),
206
+ ...(msg.meta !== undefined ? { meta: msg.meta } : {}),
207
+ }
208
+ send(ws, payload)
209
+ })
210
+ }
165
211
 
166
- state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
167
- const payload: ServerMessage = {
168
- type: 'notification',
169
- payload: msg.payload,
170
- ...(msg.replyTo !== undefined ? { replyTo: msg.replyTo } : {}),
171
- ...(msg.meta !== undefined ? { meta: msg.meta } : {}),
172
- }
173
- send(ws, payload)
174
- })
212
+ send(ws, { type: 'connected', sessionId: sessionFileId })
213
+ console.log(`session ${sessionFileId}: open`)
214
+ } catch (err) {
215
+ const message = err instanceof Error ? err.message : String(err)
216
+ console.error(`session ${ws.data.sessionId}: open failed: ${message}`)
217
+ send(ws, { type: 'error', message })
218
+ ws.close()
175
219
  }
176
-
177
- send(ws, { type: 'connected', sessionId: sessionFileId })
178
- console.log(`session ${sessionFileId}: open`)
179
220
  },
180
221
  async message(rawWs, raw) {
181
222
  if (rawWs.data.kind === 'portbroker') {
@@ -186,6 +227,73 @@ export function createServer({
186
227
  const msg = JSON.parse(String(raw)) as ClientMessage
187
228
  const state = sessionStates.get(ws)
188
229
 
230
+ if (msg.type === 'claim_start') {
231
+ if (!state) return
232
+ if (!claimController) {
233
+ send(ws, {
234
+ type: 'claim_error',
235
+ payload: { code: msg.code, reason: 'role-claim is not enabled on this agent' },
236
+ })
237
+ return
238
+ }
239
+ if (state.unsubClaim) {
240
+ state.unsubClaim()
241
+ state.unsubClaim = null
242
+ }
243
+ const result = claimController.startClaim({
244
+ code: msg.code,
245
+ role: msg.role,
246
+ ttlMs: msg.ttlMs,
247
+ ...(msg.channel !== undefined ? { channel: msg.channel } : {}),
248
+ })
249
+ if (!result.ok) {
250
+ send(ws, { type: 'claim_error', payload: { code: msg.code, reason: result.reason } })
251
+ return
252
+ }
253
+ state.activeClaimCode = msg.code
254
+ state.unsubClaim = claimController.onResult((event: ClaimResultEvent) => {
255
+ if (event.kind === 'completed' && event.code === msg.code) {
256
+ send(ws, {
257
+ type: 'claim_completed',
258
+ payload: {
259
+ code: event.code,
260
+ role: event.role,
261
+ matchRule: event.matchRule,
262
+ adapter: event.adapter,
263
+ authorId: event.authorId,
264
+ },
265
+ })
266
+ } else if (event.kind === 'error' && event.code === msg.code) {
267
+ send(ws, { type: 'claim_error', payload: { code: event.code, reason: event.reason } })
268
+ } else if (event.kind === 'cancelled' && event.code === msg.code) {
269
+ send(ws, { type: 'claim_error', payload: { code: event.code, reason: 'cancelled' } })
270
+ }
271
+ })
272
+ send(ws, {
273
+ type: 'claim_started',
274
+ payload: {
275
+ code: msg.code,
276
+ role: msg.role,
277
+ ...(msg.channel !== undefined ? { channel: msg.channel } : {}),
278
+ expiresAt: result.expiresAt,
279
+ },
280
+ })
281
+ return
282
+ }
283
+
284
+ if (msg.type === 'claim_cancel') {
285
+ if (!state || !claimController) return
286
+ if (state.activeClaimCode !== null) {
287
+ claimController.cancelClaim(state.activeClaimCode)
288
+ state.activeClaimCode = null
289
+ }
290
+ if (state.unsubClaim) {
291
+ state.unsubClaim()
292
+ state.unsubClaim = null
293
+ }
294
+ return
295
+ }
296
+
189
297
  if (msg.type === 'reload') {
190
298
  await handleReload(ws, reloadAll, reloadRegistry, msg.scope)
191
299
  return
@@ -238,7 +346,9 @@ export function createServer({
238
346
  await state.session.prompt(msg.text)
239
347
  send(ws, { type: 'done' })
240
348
  } catch (err) {
241
- send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
349
+ const message = err instanceof Error ? err.message : String(err)
350
+ logger.error(`[server] ${state.sessionFileId}: prompt failed: ${message}`)
351
+ send(ws, { type: 'error', message })
242
352
  }
243
353
  if (fallbackHooks !== undefined && agentDir !== undefined) {
244
354
  await fallbackHooks.runSessionTurnEnd({
@@ -266,9 +376,13 @@ export function createServer({
266
376
  const state = sessionStates.get(ws)
267
377
  state?.unsubBroadcast?.()
268
378
  state?.unsubPrompts?.()
379
+ state?.unsubClaim?.()
380
+ if (state?.activeClaimCode !== null && state?.activeClaimCode !== undefined && claimController) {
381
+ claimController.cancelClaim(state.activeClaimCode)
382
+ }
269
383
  try {
270
384
  if (state && state.runtimeSnapshot !== null) {
271
- await state.runtimeSnapshot.hooks.runSessionEnd({ sessionId: state.sessionFileId })
385
+ await state.runtimeSnapshot.hooks.runSessionEnd({ sessionId: state.sessionFileId, origin: state.origin })
272
386
  }
273
387
  } finally {
274
388
  if (state) {
@@ -289,7 +403,11 @@ export function createServer({
289
403
  return { start }
290
404
  }
291
405
 
292
- function forwardSessionEvents(ws: Ws, session: AgentSession): void {
406
+ function isWebSocketUpgrade(req: Request): boolean {
407
+ return req.headers.get('upgrade')?.toLowerCase() === 'websocket'
408
+ }
409
+
410
+ function forwardSessionEvents(ws: Ws, session: AgentSession, logger: ServerLogger, sessionFileId: string): void {
293
411
  const toolStartedAt = new Map<string, number>()
294
412
 
295
413
  session.subscribe((event) => {
@@ -307,7 +425,7 @@ function forwardSessionEvents(ws: Ws, session: AgentSession): void {
307
425
  // because no text deltas were ever emitted, which looks like a freeze.
308
426
  // The server's existing try/catch around `session.prompt()` only
309
427
  // catches throws, so it never sees these.
310
- forwardAssistantError(ws, event.message)
428
+ forwardAssistantError(ws, event.message, logger, sessionFileId)
311
429
  break
312
430
  case 'tool_execution_start':
313
431
  toolStartedAt.set(event.toolCallId, Date.now())
@@ -336,7 +454,7 @@ function forwardSessionEvents(ws: Ws, session: AgentSession): void {
336
454
  })
337
455
  }
338
456
 
339
- function forwardAssistantError(ws: Ws, message: unknown): void {
457
+ function forwardAssistantError(ws: Ws, message: unknown, logger: ServerLogger, sessionFileId: string): void {
340
458
  if (typeof message !== 'object' || message === null) return
341
459
  const m = message as { role?: string; stopReason?: string; errorMessage?: string }
342
460
  if (m.role !== 'assistant') return
@@ -345,10 +463,17 @@ function forwardAssistantError(ws: Ws, message: unknown): void {
345
463
  // error message because the TUI already shows abort feedback elsewhere.
346
464
  if (m.stopReason === 'aborted') return
347
465
  const text = typeof m.errorMessage === 'string' && m.errorMessage.length > 0 ? m.errorMessage : 'LLM call failed'
466
+ logger.error(`[server] ${sessionFileId}: LLM call failed: ${text}`)
348
467
  send(ws, { type: 'error', message: text })
349
468
  }
350
469
 
351
- function enqueuePrompt(ws: Ws, state: SessionState, msg: StreamMessage, agentDir: string | undefined): void {
470
+ function enqueuePrompt(
471
+ ws: Ws,
472
+ state: SessionState,
473
+ msg: StreamMessage,
474
+ agentDir: string | undefined,
475
+ logger: ServerLogger,
476
+ ): void {
352
477
  const payload = msg.payload as { kind?: string; text?: string; delivery?: PromptDelivery }
353
478
  if (payload?.kind !== 'prompt' || typeof payload.text !== 'string') return
354
479
  const delivery: PromptDelivery = payload.delivery ?? 'queue'
@@ -364,7 +489,7 @@ function enqueuePrompt(ws: Ws, state: SessionState, msg: StreamMessage, agentDir
364
489
  ts: msg.ts,
365
490
  })
366
491
  pushQueueState(ws, state)
367
- void drain(ws, state, agentDir)
492
+ void drain(ws, state, agentDir, logger)
368
493
  }
369
494
 
370
495
  // `session.idle` semantically means "the agent finished a prompt and is now
@@ -400,7 +525,7 @@ function makeTurnHookCallers(
400
525
  }
401
526
  }
402
527
 
403
- async function drain(ws: Ws, state: SessionState, agentDir: string | undefined): Promise<void> {
528
+ async function drain(ws: Ws, state: SessionState, agentDir: string | undefined, logger: ServerLogger): Promise<void> {
404
529
  if (state.draining) return
405
530
  state.draining = true
406
531
  const fireIdle = makeIdleHookCaller(state)
@@ -417,7 +542,9 @@ async function drain(ws: Ws, state: SessionState, agentDir: string | undefined):
417
542
  await state.session.prompt(item.text)
418
543
  send(ws, { type: 'done' })
419
544
  } catch (err) {
420
- send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
545
+ const message = err instanceof Error ? err.message : String(err)
546
+ logger.error(`[server] ${state.sessionFileId}: prompt failed: ${message}`)
547
+ send(ws, { type: 'error', message })
421
548
  }
422
549
  await fireTurnEnd()
423
550
  await fireIdle()
@@ -1,4 +1,8 @@
1
1
  export {
2
+ type ClaimCompletedPayload,
3
+ type ClaimErrorPayload,
4
+ type ClaimRoleChoice,
5
+ type ClaimStartedPayload,
2
6
  type ClientMessage,
3
7
  type DoctorCheckPayload,
4
8
  type DoctorFixPayload,
@@ -22,6 +22,8 @@ export type DoctorFixPayload =
22
22
  | { ok: true; checkId: string; summary: string; changedPaths: string[] }
23
23
  | { ok: false; checkId: string; error: string }
24
24
 
25
+ export type ClaimRoleChoice = 'owner' | 'member' | 'trusted' | (string & {})
26
+
25
27
  export type ClientMessage =
26
28
  | { type: 'prompt'; text: string; delivery?: PromptDelivery }
27
29
  | { type: 'reload'; scope?: string }
@@ -29,9 +31,31 @@ export type ClientMessage =
29
31
  | { type: 'queue_cancel'; messageId: string }
30
32
  | { type: 'doctor'; requestId: DoctorRequestId }
31
33
  | { type: 'doctor_fix'; requestId: DoctorRequestId; checkId: string }
34
+ | { type: 'claim_start'; code: string; role: ClaimRoleChoice; channel?: string; ttlMs: number }
35
+ | { type: 'claim_cancel' }
32
36
 
33
37
  export type QueueStateItem = { id: string; text: string; ts: number }
34
38
 
39
+ export type ClaimStartedPayload = {
40
+ code: string
41
+ role: string
42
+ channel?: string
43
+ expiresAt: number
44
+ }
45
+
46
+ export type ClaimCompletedPayload = {
47
+ code: string
48
+ role: string
49
+ matchRule: string
50
+ adapter: string
51
+ authorId: string
52
+ }
53
+
54
+ export type ClaimErrorPayload = {
55
+ code: string
56
+ reason: string
57
+ }
58
+
35
59
  export type ServerMessage =
36
60
  | { type: 'connected'; sessionId: string }
37
61
  | { type: 'text_delta'; delta: string }
@@ -45,3 +69,6 @@ export type ServerMessage =
45
69
  | { type: 'prompt_started'; messageId: string; text: string }
46
70
  | { type: 'doctor_result'; requestId: DoctorRequestId; checks: DoctorCheckPayload[] }
47
71
  | { type: 'doctor_fix_result'; requestId: DoctorRequestId; result: DoctorFixPayload }
72
+ | { type: 'claim_started'; payload: ClaimStartedPayload }
73
+ | { type: 'claim_completed'; payload: ClaimCompletedPayload }
74
+ | { type: 'claim_error'; payload: ClaimErrorPayload }
@@ -74,7 +74,7 @@ The adapter exposes three engagement triggers via `channels.kakaotalk.engagement
74
74
 
75
75
  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.
76
76
 
77
- 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:
77
+ If you find yourself NOT receiving messages you expect to, the most likely cause is missing role coverage in `roles.<role>.match[]`. KakaoTalk's match-rule grammar:
78
78
 
79
79
  - `kakao:*` — every chat the account can see (use sparingly: this is every group and DM you are a member of)
80
80
  - `kakao:dm/*` — every 1:1 chat
@@ -82,7 +82,7 @@ If you find yourself NOT receiving messages you expect to, the most likely cause
82
82
  - `kakao:open/*` — every open chat
83
83
  - `kakao:<chat-id>` — a specific chat by numeric chat_id
84
84
 
85
- 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.
85
+ Group chats with personal accounts are sensitive — every member sees every reply. Be conservative when widening match-rules: a `kakao:group/*` entry on a permissive role exposes the agent in every group the account is a member of. See the `typeclaw-permissions` skill.
86
86
 
87
87
  ## Mark read on every inbound
88
88
 
@@ -91,7 +91,7 @@ The adapter sends a LOCO `NOTIREAD` ack to KakaoTalk for every inbound message e
91
91
  Things to know about this behavior:
92
92
 
93
93
  - 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.**
94
- - 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.
94
+ - Dropped messages are still acked. If classify drops the message (your own self-sent loopback, empty text, unknown chat), or the router drops it on the `channel.respond` gate, the unread "1" still clears — the agent has observed the bytes, so the read indicator should match.
95
95
  - 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.
96
96
  - 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.
97
97