typeclaw 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +14 -12
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +7 -4
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +23 -9
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +799 -319
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +6 -1
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +8 -1
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +385 -12
  67. package/src/config/index.ts +7 -0
  68. package/src/config/models-mutation.ts +209 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +5 -0
  73. package/src/container/require-running.ts +33 -0
  74. package/src/container/start.ts +39 -58
  75. package/src/cron/consumer.ts +22 -2
  76. package/src/cron/index.ts +45 -4
  77. package/src/cron/schema.ts +104 -0
  78. package/src/doctor/checks.ts +50 -33
  79. package/src/git/system-commit.ts +103 -0
  80. package/src/hostd/daemon.ts +16 -0
  81. package/src/hostd/kakao-renewal-manager.ts +223 -0
  82. package/src/hostd/paths.ts +7 -0
  83. package/src/init/dockerfile.ts +32 -6
  84. package/src/init/index.ts +190 -61
  85. package/src/init/kakaotalk-auth.ts +18 -1
  86. package/src/init/models-dev.ts +26 -1
  87. package/src/init/run-owner-claim.ts +77 -0
  88. package/src/permissions/builtins.ts +70 -0
  89. package/src/permissions/grant.ts +99 -0
  90. package/src/permissions/index.ts +29 -0
  91. package/src/permissions/match-rule.ts +305 -0
  92. package/src/permissions/permissions.ts +196 -0
  93. package/src/permissions/resolve.ts +80 -0
  94. package/src/permissions/schema.ts +79 -0
  95. package/src/plugin/context.ts +8 -4
  96. package/src/plugin/define.ts +2 -0
  97. package/src/plugin/index.ts +2 -0
  98. package/src/plugin/manager.ts +41 -0
  99. package/src/plugin/registry.ts +9 -0
  100. package/src/plugin/types.ts +35 -1
  101. package/src/role-claim/client.ts +182 -0
  102. package/src/role-claim/code.ts +53 -0
  103. package/src/role-claim/controller.ts +194 -0
  104. package/src/role-claim/index.ts +19 -0
  105. package/src/role-claim/match-rule.ts +43 -0
  106. package/src/role-claim/pending.ts +100 -0
  107. package/src/run/channel-session-factory.ts +76 -5
  108. package/src/run/index.ts +55 -6
  109. package/src/secrets/encryption.ts +116 -0
  110. package/src/secrets/kakao-renewal.ts +248 -0
  111. package/src/secrets/kakao-store.ts +66 -7
  112. package/src/secrets/keys.ts +173 -0
  113. package/src/secrets/schema.ts +23 -0
  114. package/src/secrets/storage.ts +68 -0
  115. package/src/server/index.ts +122 -11
  116. package/src/shared/index.ts +4 -0
  117. package/src/shared/protocol.ts +27 -0
  118. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  119. package/src/skills/typeclaw-config/SKILL.md +38 -64
  120. package/src/skills/typeclaw-memory/SKILL.md +1 -1
  121. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  122. package/src/stream/types.ts +7 -1
  123. package/src/usage/aggregate.ts +117 -0
  124. package/src/usage/format.ts +30 -0
  125. package/src/usage/index.ts +68 -0
  126. package/src/usage/report.ts +354 -0
  127. package/src/usage/scan.ts +186 -0
  128. package/typeclaw.schema.json +57 -45
@@ -175,6 +175,74 @@ export class SecretsBackend implements AuthStorageBackend {
175
175
  }
176
176
  }
177
177
 
178
+ // Returns a shallow snapshot of the `providers` slice. Used by post-init CLI
179
+ // commands (`typeclaw provider list/remove`, `typeclaw model list`) that need
180
+ // to inspect what's on disk without forcing AuthStorage's env-wins flatten —
181
+ // we want to show users which providers are file-backed vs env-overridden,
182
+ // and the flatten path collapses that distinction.
183
+ tryReadProvidersSync(): Providers {
184
+ if (!existsSync(this.secretsPath)) return {}
185
+ let release: (() => void) | undefined
186
+ try {
187
+ release = this.acquireSyncLockWithRetry()
188
+ return { ...this.readEnvelope().providers }
189
+ } finally {
190
+ release?.()
191
+ }
192
+ }
193
+
194
+ // Atomic provider credential write. Idempotent at the type level: callers
195
+ // pass the full `ProviderCredential` (api_key or oauth), and the entry is
196
+ // merged into `providers.<id>` verbatim — same shape the schema accepts on
197
+ // read. Used by `typeclaw provider add/set` to write api-key credentials
198
+ // without going through AuthStorage's flatten/unflatten round-trip.
199
+ // OAuth flows MUST continue to go through `AuthStorage.login()` so refresh
200
+ // tokens land in the correct shape; this method is api-key oriented.
201
+ writeProviderCredentialSync(providerId: string, credential: ProviderCredential): void {
202
+ this.ensureParentDir()
203
+ this.ensureFileExists()
204
+ let release: (() => void) | undefined
205
+ try {
206
+ release = this.acquireSyncLockWithRetry()
207
+ const envelope = this.readEnvelope()
208
+ const next: SecretsFile = {
209
+ ...envelope,
210
+ $schema: envelope.$schema ?? SCHEMA_REL,
211
+ version: SECRETS_FILE_VERSION,
212
+ providers: { ...envelope.providers, [providerId]: credential },
213
+ }
214
+ this.writeEnvelopeAtomic(next)
215
+ } finally {
216
+ release?.()
217
+ }
218
+ }
219
+
220
+ // Removes `providers.<id>` from the envelope. Returns `true` when the
221
+ // provider was present and removed, `false` when nothing changed (idempotent
222
+ // on the CLI side — `provider remove fireworks` twice should not error on
223
+ // the second call). The file is rewritten only when something changed so
224
+ // canonical-shape reads pay zero cost.
225
+ removeProviderCredentialSync(providerId: string): boolean {
226
+ if (!existsSync(this.secretsPath)) return false
227
+ let release: (() => void) | undefined
228
+ try {
229
+ release = this.acquireSyncLockWithRetry()
230
+ const envelope = this.readEnvelope()
231
+ if (!(providerId in envelope.providers)) return false
232
+ const { [providerId]: _removed, ...rest } = envelope.providers
233
+ const next: SecretsFile = {
234
+ ...envelope,
235
+ $schema: envelope.$schema ?? SCHEMA_REL,
236
+ version: SECRETS_FILE_VERSION,
237
+ providers: rest,
238
+ }
239
+ this.writeEnvelopeAtomic(next)
240
+ return true
241
+ } finally {
242
+ release?.()
243
+ }
244
+ }
245
+
178
246
  writeChannelsSync(next: Channels): void {
179
247
  this.ensureParentDir()
180
248
  this.ensureFileExists()
@@ -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
@@ -37,6 +44,22 @@ export type ServerOptions = {
37
44
  // sessions. Omit to keep TUI-only behavior (used by tests + non-container
38
45
  // dev runs).
39
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),
40
63
  }
41
64
 
42
65
  export type Server = ReturnType<typeof createServer>
@@ -61,6 +84,8 @@ type SessionState = {
61
84
  draining: boolean
62
85
  unsubBroadcast: Unsubscribe | null
63
86
  unsubPrompts: Unsubscribe | null
87
+ unsubClaim: Unsubscribe | null
88
+ activeClaimCode: string | null
64
89
  // Captured at session open so close-time hooks fire against the same
65
90
  // generation that ran session.start. A plugin reload mid-connection does
66
91
  // not re-target this session's lifecycle hooks.
@@ -85,6 +110,8 @@ export function createServer({
85
110
  containerName,
86
111
  tuiToken,
87
112
  containerBroker,
113
+ logger = consoleLogger,
114
+ claimController,
88
115
  }: ServerOptions) {
89
116
  const sessionStates = new WeakMap<Ws, SessionState>()
90
117
 
@@ -153,6 +180,8 @@ export function createServer({
153
180
  draining: false,
154
181
  unsubBroadcast: null,
155
182
  unsubPrompts: null,
183
+ unsubClaim: null,
184
+ activeClaimCode: null,
156
185
  runtimeSnapshot: runtimeSnapshot ?? null,
157
186
  dispose,
158
187
  }
@@ -162,11 +191,11 @@ export function createServer({
162
191
  await runtimeSnapshot.hooks.runSessionStart({ sessionId: sessionFileId, agentDir })
163
192
  }
164
193
 
165
- forwardSessionEvents(ws, session)
194
+ forwardSessionEvents(ws, session, logger, sessionFileId)
166
195
 
167
196
  if (stream) {
168
197
  state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
169
- enqueuePrompt(ws, state, msg, agentDir),
198
+ enqueuePrompt(ws, state, msg, agentDir, logger),
170
199
  )
171
200
 
172
201
  state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
@@ -198,6 +227,73 @@ export function createServer({
198
227
  const msg = JSON.parse(String(raw)) as ClientMessage
199
228
  const state = sessionStates.get(ws)
200
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
+
201
297
  if (msg.type === 'reload') {
202
298
  await handleReload(ws, reloadAll, reloadRegistry, msg.scope)
203
299
  return
@@ -250,7 +346,9 @@ export function createServer({
250
346
  await state.session.prompt(msg.text)
251
347
  send(ws, { type: 'done' })
252
348
  } catch (err) {
253
- 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 })
254
352
  }
255
353
  if (fallbackHooks !== undefined && agentDir !== undefined) {
256
354
  await fallbackHooks.runSessionTurnEnd({
@@ -278,9 +376,13 @@ export function createServer({
278
376
  const state = sessionStates.get(ws)
279
377
  state?.unsubBroadcast?.()
280
378
  state?.unsubPrompts?.()
379
+ state?.unsubClaim?.()
380
+ if (state?.activeClaimCode !== null && state?.activeClaimCode !== undefined && claimController) {
381
+ claimController.cancelClaim(state.activeClaimCode)
382
+ }
281
383
  try {
282
384
  if (state && state.runtimeSnapshot !== null) {
283
- await state.runtimeSnapshot.hooks.runSessionEnd({ sessionId: state.sessionFileId })
385
+ await state.runtimeSnapshot.hooks.runSessionEnd({ sessionId: state.sessionFileId, origin: state.origin })
284
386
  }
285
387
  } finally {
286
388
  if (state) {
@@ -305,7 +407,7 @@ function isWebSocketUpgrade(req: Request): boolean {
305
407
  return req.headers.get('upgrade')?.toLowerCase() === 'websocket'
306
408
  }
307
409
 
308
- function forwardSessionEvents(ws: Ws, session: AgentSession): void {
410
+ function forwardSessionEvents(ws: Ws, session: AgentSession, logger: ServerLogger, sessionFileId: string): void {
309
411
  const toolStartedAt = new Map<string, number>()
310
412
 
311
413
  session.subscribe((event) => {
@@ -323,7 +425,7 @@ function forwardSessionEvents(ws: Ws, session: AgentSession): void {
323
425
  // because no text deltas were ever emitted, which looks like a freeze.
324
426
  // The server's existing try/catch around `session.prompt()` only
325
427
  // catches throws, so it never sees these.
326
- forwardAssistantError(ws, event.message)
428
+ forwardAssistantError(ws, event.message, logger, sessionFileId)
327
429
  break
328
430
  case 'tool_execution_start':
329
431
  toolStartedAt.set(event.toolCallId, Date.now())
@@ -352,7 +454,7 @@ function forwardSessionEvents(ws: Ws, session: AgentSession): void {
352
454
  })
353
455
  }
354
456
 
355
- function forwardAssistantError(ws: Ws, message: unknown): void {
457
+ function forwardAssistantError(ws: Ws, message: unknown, logger: ServerLogger, sessionFileId: string): void {
356
458
  if (typeof message !== 'object' || message === null) return
357
459
  const m = message as { role?: string; stopReason?: string; errorMessage?: string }
358
460
  if (m.role !== 'assistant') return
@@ -361,10 +463,17 @@ function forwardAssistantError(ws: Ws, message: unknown): void {
361
463
  // error message because the TUI already shows abort feedback elsewhere.
362
464
  if (m.stopReason === 'aborted') return
363
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}`)
364
467
  send(ws, { type: 'error', message: text })
365
468
  }
366
469
 
367
- 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 {
368
477
  const payload = msg.payload as { kind?: string; text?: string; delivery?: PromptDelivery }
369
478
  if (payload?.kind !== 'prompt' || typeof payload.text !== 'string') return
370
479
  const delivery: PromptDelivery = payload.delivery ?? 'queue'
@@ -380,7 +489,7 @@ function enqueuePrompt(ws: Ws, state: SessionState, msg: StreamMessage, agentDir
380
489
  ts: msg.ts,
381
490
  })
382
491
  pushQueueState(ws, state)
383
- void drain(ws, state, agentDir)
492
+ void drain(ws, state, agentDir, logger)
384
493
  }
385
494
 
386
495
  // `session.idle` semantically means "the agent finished a prompt and is now
@@ -416,7 +525,7 @@ function makeTurnHookCallers(
416
525
  }
417
526
  }
418
527
 
419
- 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> {
420
529
  if (state.draining) return
421
530
  state.draining = true
422
531
  const fireIdle = makeIdleHookCaller(state)
@@ -433,7 +542,9 @@ async function drain(ws: Ws, state: SessionState, agentDir: string | undefined):
433
542
  await state.session.prompt(item.text)
434
543
  send(ws, { type: 'done' })
435
544
  } catch (err) {
436
- 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 })
437
548
  }
438
549
  await fireTurnEnd()
439
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