typeclaw 0.1.0 → 0.1.2

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 (57) hide show
  1. package/README.md +12 -12
  2. package/package.json +3 -2
  3. package/src/agent/auth.ts +10 -4
  4. package/src/agent/doctor.ts +173 -0
  5. package/src/agent/subagents.ts +24 -2
  6. package/src/bundled-plugins/backup/README.md +81 -0
  7. package/src/bundled-plugins/backup/index.ts +209 -0
  8. package/src/bundled-plugins/backup/runner.ts +231 -0
  9. package/src/bundled-plugins/backup/subagents.ts +200 -0
  10. package/src/bundled-plugins/memory/index.ts +42 -1
  11. package/src/bundled-plugins/security/index.ts +5 -1
  12. package/src/bundled-plugins/security/policies/git-exfil.ts +184 -4
  13. package/src/bundled-plugins/security/policies/remote-taint-state.ts +59 -0
  14. package/src/channels/adapters/kakaotalk-attachment.ts +224 -0
  15. package/src/channels/adapters/kakaotalk-channel-resolver.ts +20 -1
  16. package/src/channels/adapters/kakaotalk-fetch-attachment.ts +91 -0
  17. package/src/channels/adapters/kakaotalk.ts +58 -3
  18. package/src/channels/router.ts +40 -2
  19. package/src/cli/compose.ts +92 -1
  20. package/src/cli/doctor.ts +100 -0
  21. package/src/cli/index.ts +1 -0
  22. package/src/compose/doctor.ts +141 -0
  23. package/src/compose/index.ts +8 -0
  24. package/src/compose/logs.ts +32 -19
  25. package/src/config/config.ts +20 -0
  26. package/src/container/log-colors.ts +75 -0
  27. package/src/container/log-timestamps.ts +84 -0
  28. package/src/container/logs.ts +71 -5
  29. package/src/container/start.ts +23 -8
  30. package/src/cron/consumer.ts +29 -7
  31. package/src/doctor/checks.ts +426 -0
  32. package/src/doctor/commit.ts +71 -0
  33. package/src/doctor/index.ts +287 -0
  34. package/src/doctor/plugin-bridge.ts +147 -0
  35. package/src/doctor/report.ts +142 -0
  36. package/src/doctor/types.ts +87 -0
  37. package/src/init/cli-version.ts +81 -0
  38. package/src/init/dockerfile.ts +223 -25
  39. package/src/init/ensure-deps.ts +2 -2
  40. package/src/init/index.ts +23 -13
  41. package/src/init/run-bun-install.ts +17 -1
  42. package/src/plugin/hooks.ts +32 -0
  43. package/src/plugin/index.ts +7 -0
  44. package/src/plugin/manager.ts +2 -0
  45. package/src/plugin/registry.ts +32 -3
  46. package/src/plugin/types.ts +65 -0
  47. package/src/run/bundled-plugins.ts +8 -0
  48. package/src/run/index.ts +10 -5
  49. package/src/secrets/env.ts +43 -0
  50. package/src/secrets/index.ts +2 -0
  51. package/src/server/index.ts +103 -5
  52. package/src/shared/index.ts +3 -0
  53. package/src/shared/protocol.ts +22 -0
  54. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +26 -3
  55. package/src/skills/typeclaw-config/SKILL.md +1 -1
  56. package/tsconfig.json +30 -0
  57. package/typeclaw.schema.json +50 -4
@@ -3,13 +3,28 @@ import { existsSync } from 'node:fs'
3
3
  import type { CronJob, PromptJob } from '@/cron'
4
4
 
5
5
  import type { HookBus } from './hooks'
6
- import type { PluginCronJob, PluginExports, PluginLogger, PluginSkill, Subagent, Tool } from './types'
6
+ import type {
7
+ PluginCronJob,
8
+ PluginDoctorCheck,
9
+ PluginExports,
10
+ PluginLogger,
11
+ PluginSkill,
12
+ Subagent,
13
+ Tool,
14
+ } from './types'
7
15
 
8
16
  export type RegisteredTool = { pluginName: string; toolName: string; tool: Tool<any>; logger: PluginLogger }
9
17
  export type RegisteredSubagent = { pluginName: string; subagentName: string; subagent: Subagent<any> }
10
18
  export type RegisteredCronJob = { pluginName: string; localId: string; globalId: string; job: CronJob }
11
19
  export type RegisteredSkillEntry = { pluginName: string; localName: string; skill: PluginSkill }
12
20
  export type RegisteredSkillDir = { pluginName: string; path: string }
21
+ export type RegisteredDoctorCheck = {
22
+ pluginName: string
23
+ checkName: string
24
+ pluginConfig: unknown
25
+ logger: PluginLogger
26
+ check: PluginDoctorCheck
27
+ }
13
28
 
14
29
  export type PluginRegistry = {
15
30
  tools: RegisteredTool[]
@@ -17,6 +32,7 @@ export type PluginRegistry = {
17
32
  cronJobs: RegisteredCronJob[]
18
33
  skills: RegisteredSkillEntry[]
19
34
  skillsDirs: RegisteredSkillDir[]
35
+ doctorChecks: RegisteredDoctorCheck[]
20
36
  }
21
37
 
22
38
  export type RegisterContributionsOptions = {
@@ -26,6 +42,7 @@ export type RegisterContributionsOptions = {
26
42
  registry: PluginRegistry
27
43
  hooks: HookBus
28
44
  agentDir: string
45
+ pluginConfig: unknown
29
46
  }
30
47
 
31
48
  export function buildPluginCronGlobalId(pluginName: string, localId: string): string {
@@ -33,7 +50,7 @@ export function buildPluginCronGlobalId(pluginName: string, localId: string): st
33
50
  }
34
51
 
35
52
  export function registerContributions(opts: RegisterContributionsOptions): void {
36
- const { pluginName, logger, exports: ex, registry, hooks, agentDir } = opts
53
+ const { pluginName, logger, exports: ex, registry, hooks, agentDir, pluginConfig } = opts
37
54
 
38
55
  if (ex.tools) {
39
56
  for (const [toolName, tool] of Object.entries(ex.tools)) {
@@ -99,6 +116,17 @@ export function registerContributions(opts: RegisterContributionsOptions): void
99
116
  if (ex.hooks) {
100
117
  hooks.registerAll(pluginName, agentDir, logger, ex.hooks)
101
118
  }
119
+
120
+ if (ex.doctorChecks) {
121
+ for (const [checkName, check] of Object.entries(ex.doctorChecks)) {
122
+ assertNotEmpty('doctor check name', checkName, pluginName)
123
+ const conflict = registry.doctorChecks.find((c) => c.pluginName === pluginName && c.checkName === checkName)
124
+ if (conflict) {
125
+ throw new Error(`plugin ${pluginName}: doctor check "${checkName}" already registered`)
126
+ }
127
+ registry.doctorChecks.push({ pluginName, checkName, pluginConfig, logger, check })
128
+ }
129
+ }
102
130
  }
103
131
 
104
132
  export function discardRegistrationsBy(pluginName: string, registry: PluginRegistry, hooks: HookBus): void {
@@ -107,11 +135,12 @@ export function discardRegistrationsBy(pluginName: string, registry: PluginRegis
107
135
  registry.cronJobs = registry.cronJobs.filter((j) => j.pluginName !== pluginName)
108
136
  registry.skills = registry.skills.filter((s) => s.pluginName !== pluginName)
109
137
  registry.skillsDirs = registry.skillsDirs.filter((d) => d.pluginName !== pluginName)
138
+ registry.doctorChecks = registry.doctorChecks.filter((d) => d.pluginName !== pluginName)
110
139
  hooks.unregisterAll(pluginName)
111
140
  }
112
141
 
113
142
  export function emptyRegistry(): PluginRegistry {
114
- return { tools: [], subagents: [], cronJobs: [], skills: [], skillsDirs: [] }
143
+ return { tools: [], subagents: [], cronJobs: [], skills: [], skillsDirs: [], doctorChecks: [] }
115
144
  }
116
145
 
117
146
  function assertNotEmpty(kind: string, value: string, pluginName: string): void {
@@ -97,6 +97,24 @@ export type SessionIdleEvent = {
97
97
  origin?: SessionOrigin
98
98
  }
99
99
 
100
+ // Brackets every `session.prompt(...)` invocation. Distinct from
101
+ // `session.start`/`session.end` (which bracket session lifetime) so that
102
+ // long-lived TUI or channel sessions, which can sit idle between turns,
103
+ // don't wedge a turn-counter forever. `origin` carries the session's origin
104
+ // so observers can exclude their own induced turns when counting (e.g. the
105
+ // backup plugin excludes `subagent: 'backup'` to avoid self-gating).
106
+ export type SessionTurnStartEvent = {
107
+ sessionId: string
108
+ agentDir: string
109
+ origin?: SessionOrigin
110
+ }
111
+
112
+ export type SessionTurnEndEvent = {
113
+ sessionId: string
114
+ agentDir: string
115
+ origin?: SessionOrigin
116
+ }
117
+
100
118
  // Provider prompt caching requires byte-identical prefixes. Mutations near the
101
119
  // end of `event.prompt` preserve cache hits across sessions; mutations near
102
120
  // the start invalidate the cache on every LLM call.
@@ -136,6 +154,8 @@ export type Hooks = {
136
154
  'session.end'?: (event: SessionEndEvent, ctx: HookContext) => Promise<void> | void
137
155
  'session.idle'?: (event: SessionIdleEvent, ctx: HookContext) => Promise<void> | void
138
156
  'session.prompt'?: (event: SessionPromptEvent, ctx: HookContext) => Promise<void> | void
157
+ 'session.turn.start'?: (event: SessionTurnStartEvent, ctx: HookContext) => Promise<void> | void
158
+ 'session.turn.end'?: (event: SessionTurnEndEvent, ctx: HookContext) => Promise<void> | void
139
159
  'tool.before'?: (event: ToolBeforeEvent, ctx: HookContext) => Promise<ToolBeforeResult> | ToolBeforeResult
140
160
  'tool.after'?: (event: ToolAfterEvent, ctx: HookContext) => Promise<void> | void
141
161
  }
@@ -164,6 +184,51 @@ export type PluginExports = {
164
184
  skills?: Record<string, PluginSkill>
165
185
  skillsDirs?: string[]
166
186
  hooks?: Hooks
187
+ doctorChecks?: Record<string, PluginDoctorCheck>
188
+ }
189
+
190
+ // `typeclaw doctor` plugin extension surface. Each check is read-only by
191
+ // default; declaring `fix.apply` opts the check into `typeclaw doctor --fix`,
192
+ // where the host serializes plugin fixes, validates their `changedPaths`
193
+ // against the agent folder, and commits the union of all fixes in a single
194
+ // commit.
195
+ export type PluginDoctorCheck = {
196
+ description: string
197
+ category?: string
198
+ run: (ctx: PluginDoctorContext) => Promise<PluginCheckResult>
199
+ }
200
+
201
+ export type PluginDoctorContext = {
202
+ readonly pluginName: string
203
+ readonly agentDir: string
204
+ readonly config: unknown
205
+ readonly logger: PluginLogger
206
+ }
207
+
208
+ export type PluginCheckStatus = 'ok' | 'warning' | 'error'
209
+
210
+ export type PluginCheckResult = {
211
+ status: PluginCheckStatus
212
+ message: string
213
+ details?: string[]
214
+ fix?: PluginFixSuggestion
215
+ }
216
+
217
+ export type PluginFixSuggestion = {
218
+ description: string
219
+ // When omitted, the fix is advisory-only. `typeclaw doctor --fix` only
220
+ // attempts to remediate checks whose suggestion includes an `apply`.
221
+ apply?: (ctx: PluginDoctorContext) => Promise<PluginFixResult>
222
+ }
223
+
224
+ export type PluginFixResult = {
225
+ // One-line description that appears in the commit body as a bullet.
226
+ summary: string
227
+ // POSIX paths relative to agentDir; the host validates each one stays
228
+ // inside agentDir before `git add`ing. Absolute paths and `..` segments
229
+ // are rejected to keep plugin fixes from staging files outside the agent
230
+ // folder. Empty array is valid (e.g. a fix that only logs).
231
+ changedPaths: string[]
167
232
  }
168
233
 
169
234
  export type DefinedPlugin<TConfig = never> = {
@@ -1,4 +1,5 @@
1
1
  import agentBrowserPlugin from '@/bundled-plugins/agent-browser'
2
+ import backupPlugin from '@/bundled-plugins/backup'
2
3
  import guardPlugin from '@/bundled-plugins/guard'
3
4
  import memoryPlugin from '@/bundled-plugins/memory'
4
5
  import securityPlugin from '@/bundled-plugins/security'
@@ -16,9 +17,16 @@ import type { ResolvedPlugin } from '@/plugin'
16
17
  // Letting `guard` run first would still work today since the two plugins
17
18
  // guard disjoint surfaces, but seeding the order now means future overlap
18
19
  // (e.g. a security policy on writes) blocks before guard's softer advice.
20
+ //
21
+ // `memory` is registered before `backup` so memory's dreaming commits always
22
+ // land in the same git index window before backup's commit-and-push cycle.
23
+ // They commit disjoint paths today (memory/ vs sessions/ + agent changes),
24
+ // but if either ever holds .git/index.lock the deterministic order makes the
25
+ // contention easier to reason about.
19
26
  export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
20
27
  { name: 'security', version: undefined, source: '<bundled>', defined: securityPlugin },
21
28
  { name: 'guard', version: undefined, source: '<bundled>', defined: guardPlugin },
22
29
  { name: 'memory', version: undefined, source: '<bundled>', defined: memoryPlugin },
30
+ { name: 'backup', version: undefined, source: '<bundled>', defined: backupPlugin },
23
31
  { name: 'agent-browser', version: undefined, source: '<bundled>', defined: agentBrowserPlugin },
24
32
  ]
package/src/run/index.ts CHANGED
@@ -142,14 +142,15 @@ export async function startAgent({
142
142
  const entry = snap.pluginSubagentByShim.get(subagent)
143
143
  if (entry) {
144
144
  const sessionId = `subagent-${entry.pluginName}-${crypto.randomUUID()}`
145
+ const origin = {
146
+ kind: 'subagent' as const,
147
+ subagent: subagentOptions?.name ?? entry.subagentName,
148
+ parentSessionId: subagentOptions?.parentSessionId ?? '<unknown>',
149
+ }
145
150
  const created = await createSessionWithDispose({
146
151
  systemPromptOverride: entry.pluginSubagent.systemPrompt,
147
152
  channelRouter: channelManager.router,
148
- origin: {
149
- kind: 'subagent',
150
- subagent: subagentOptions?.name ?? entry.subagentName,
151
- parentSessionId: subagentOptions?.parentSessionId ?? '<unknown>',
152
- },
153
+ origin,
153
154
  plugins: {
154
155
  registry: snap.registry,
155
156
  hooks: snap.hooks,
@@ -167,6 +168,8 @@ export async function startAgent({
167
168
  ...created,
168
169
  hooks: snap.hooks,
169
170
  sessionId,
171
+ agentDir: cwd,
172
+ origin,
170
173
  }
171
174
  }
172
175
  return defaultCreateSessionForSubagent(subagent, subagentOptions)
@@ -221,6 +224,8 @@ export async function startAgent({
221
224
  prompt: (text) => session.prompt(text),
222
225
  dispose: () => session.dispose(),
223
226
  sessionId,
227
+ agentDir: cwd,
228
+ origin: { kind: 'cron' as const, jobId: job.id, jobKind: 'prompt' as const },
224
229
  ...(snap.hasAnyPluginContent ? { hooks: snap.hooks } : {}),
225
230
  getTranscriptPath: () => sessionManager.getSessionFile(),
226
231
  }
@@ -0,0 +1,43 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs'
2
+
3
+ // No-op when the file is missing or the key is absent: the caller has
4
+ // already persisted to `secrets.json` and just wants `.env` to stop being a
5
+ // second source of truth. Parsing matches `parseEnvKeys` in
6
+ // `src/init/index.ts` — line-based, trim, skip blanks/comments, split on the
7
+ // first `=`. Duplicate assignments to the same key are all removed because
8
+ // dotenv resolves "last wins" so every duplicate carries the value we just
9
+ // promoted.
10
+ export function stripEnvKey(path: string, key: string): void {
11
+ let original: string
12
+ try {
13
+ original = readFileSync(path, 'utf8')
14
+ } catch (error) {
15
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
16
+ throw error
17
+ }
18
+
19
+ const next = removeKeyFromEnvText(original, key)
20
+ if (next === original) return
21
+ writeFileSync(path, next)
22
+ }
23
+
24
+ export function removeKeyFromEnvText(content: string, key: string): string {
25
+ const lines = content.split('\n')
26
+ const kept: string[] = []
27
+ for (const line of lines) {
28
+ const trimmed = line.trim()
29
+ if (trimmed === '' || trimmed.startsWith('#')) {
30
+ kept.push(line)
31
+ continue
32
+ }
33
+ const eq = trimmed.indexOf('=')
34
+ if (eq <= 0) {
35
+ kept.push(line)
36
+ continue
37
+ }
38
+ const lineKey = trimmed.slice(0, eq).trim()
39
+ if (lineKey === key) continue
40
+ kept.push(line)
41
+ }
42
+ return kept.join('\n')
43
+ }
@@ -11,3 +11,5 @@ export {
11
11
  } from './schema'
12
12
 
13
13
  export { createSecretsStoreForAgent, SecretsBackend } from './storage'
14
+
15
+ export { stripEnvKey } from './env'
@@ -6,6 +6,7 @@ import {
6
6
  type CreateSessionOptions,
7
7
  type CreateSessionResult,
8
8
  } from '@/agent'
9
+ import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
9
10
  import type { SessionOrigin } from '@/agent/session-origin'
10
11
  import type { ChannelRouter } from '@/channels/router'
11
12
  import type { HookBus } from '@/plugin'
@@ -159,7 +160,7 @@ export function createServer({
159
160
 
160
161
  if (stream) {
161
162
  state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
162
- enqueuePrompt(ws, state, msg),
163
+ enqueuePrompt(ws, state, msg, agentDir),
163
164
  )
164
165
 
165
166
  state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
@@ -190,6 +191,16 @@ export function createServer({
190
191
  return
191
192
  }
192
193
 
194
+ if (msg.type === 'doctor') {
195
+ await handleDoctor(ws, msg.requestId, pluginRuntime, agentDir)
196
+ return
197
+ }
198
+
199
+ if (msg.type === 'doctor_fix') {
200
+ await handleDoctorFix(ws, msg.requestId, msg.checkId, pluginRuntime, agentDir)
201
+ return
202
+ }
203
+
193
204
  if (msg.type === 'abort') {
194
205
  if (!state) return
195
206
  await state.session.abort()
@@ -215,13 +226,27 @@ export function createServer({
215
226
  return
216
227
  }
217
228
  send(ws, { type: 'prompt_started', messageId: `local-${crypto.randomUUID()}`, text: msg.text })
229
+ const fallbackHooks = state.runtimeSnapshot?.hooks
230
+ if (fallbackHooks !== undefined && agentDir !== undefined) {
231
+ await fallbackHooks.runSessionTurnStart({
232
+ sessionId: state.sessionFileId,
233
+ agentDir,
234
+ origin: state.origin,
235
+ })
236
+ }
218
237
  try {
219
238
  await state.session.prompt(msg.text)
220
239
  send(ws, { type: 'done' })
221
240
  } catch (err) {
222
241
  send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
223
242
  }
224
- const fallbackHooks = state.runtimeSnapshot?.hooks
243
+ if (fallbackHooks !== undefined && agentDir !== undefined) {
244
+ await fallbackHooks.runSessionTurnEnd({
245
+ sessionId: state.sessionFileId,
246
+ agentDir,
247
+ origin: state.origin,
248
+ })
249
+ }
225
250
  if (fallbackHooks !== undefined) {
226
251
  await fallbackHooks.runSessionIdle({
227
252
  sessionId: state.sessionFileId,
@@ -323,7 +348,7 @@ function forwardAssistantError(ws: Ws, message: unknown): void {
323
348
  send(ws, { type: 'error', message: text })
324
349
  }
325
350
 
326
- function enqueuePrompt(ws: Ws, state: SessionState, msg: StreamMessage): void {
351
+ function enqueuePrompt(ws: Ws, state: SessionState, msg: StreamMessage, agentDir: string | undefined): void {
327
352
  const payload = msg.payload as { kind?: string; text?: string; delivery?: PromptDelivery }
328
353
  if (payload?.kind !== 'prompt' || typeof payload.text !== 'string') return
329
354
  const delivery: PromptDelivery = payload.delivery ?? 'queue'
@@ -339,7 +364,7 @@ function enqueuePrompt(ws: Ws, state: SessionState, msg: StreamMessage): void {
339
364
  ts: msg.ts,
340
365
  })
341
366
  pushQueueState(ws, state)
342
- void drain(ws, state)
367
+ void drain(ws, state, agentDir)
343
368
  }
344
369
 
345
370
  // `session.idle` semantically means "the agent finished a prompt and is now
@@ -360,10 +385,26 @@ function makeIdleHookCaller(state: SessionState): () => Promise<void> {
360
385
  }
361
386
  }
362
387
 
363
- async function drain(ws: Ws, state: SessionState): Promise<void> {
388
+ function makeTurnHookCallers(
389
+ state: SessionState,
390
+ agentDir: string | undefined,
391
+ ): { fireTurnStart: () => Promise<void>; fireTurnEnd: () => Promise<void> } {
392
+ const hooks: HookBus | undefined = state.runtimeSnapshot?.hooks
393
+ if (hooks === undefined || agentDir === undefined) {
394
+ return { fireTurnStart: async () => {}, fireTurnEnd: async () => {} }
395
+ }
396
+ const event = { sessionId: state.sessionFileId, agentDir, origin: state.origin }
397
+ return {
398
+ fireTurnStart: () => hooks.runSessionTurnStart(event),
399
+ fireTurnEnd: () => hooks.runSessionTurnEnd(event),
400
+ }
401
+ }
402
+
403
+ async function drain(ws: Ws, state: SessionState, agentDir: string | undefined): Promise<void> {
364
404
  if (state.draining) return
365
405
  state.draining = true
366
406
  const fireIdle = makeIdleHookCaller(state)
407
+ const { fireTurnStart, fireTurnEnd } = makeTurnHookCallers(state, agentDir)
367
408
  try {
368
409
  while (state.drainQueue.length > 0) {
369
410
  const item = state.drainQueue.shift()
@@ -371,12 +412,14 @@ async function drain(ws: Ws, state: SessionState): Promise<void> {
371
412
  pushQueueState(ws, state)
372
413
  send(ws, { type: 'prompt_started', messageId: item.streamMessageId, text: item.text })
373
414
 
415
+ await fireTurnStart()
374
416
  try {
375
417
  await state.session.prompt(item.text)
376
418
  send(ws, { type: 'done' })
377
419
  } catch (err) {
378
420
  send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
379
421
  }
422
+ await fireTurnEnd()
380
423
  await fireIdle()
381
424
  }
382
425
  } finally {
@@ -393,6 +436,61 @@ function pushQueueState(ws: Ws, state: SessionState): void {
393
436
  send(ws, { type: 'queue_state', pending })
394
437
  }
395
438
 
439
+ async function handleDoctor(
440
+ ws: Ws,
441
+ requestId: string,
442
+ pluginRuntime: PluginRuntime | undefined,
443
+ agentDir: string | undefined,
444
+ ): Promise<void> {
445
+ if (pluginRuntime === undefined || agentDir === undefined) {
446
+ send(ws, { type: 'doctor_result', requestId, checks: [] })
447
+ return
448
+ }
449
+ const snapshot = pluginRuntime.get()
450
+ if (snapshot === undefined) {
451
+ send(ws, { type: 'doctor_result', requestId, checks: [] })
452
+ return
453
+ }
454
+ try {
455
+ const checks = await runPluginDoctorChecks({ registry: snapshot.registry, agentDir })
456
+ send(ws, { type: 'doctor_result', requestId, checks })
457
+ } catch (err) {
458
+ send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
459
+ }
460
+ }
461
+
462
+ async function handleDoctorFix(
463
+ ws: Ws,
464
+ requestId: string,
465
+ checkId: string,
466
+ pluginRuntime: PluginRuntime | undefined,
467
+ agentDir: string | undefined,
468
+ ): Promise<void> {
469
+ if (pluginRuntime === undefined || agentDir === undefined) {
470
+ send(ws, {
471
+ type: 'doctor_fix_result',
472
+ requestId,
473
+ result: { ok: false, checkId, error: 'plugin runtime not configured' },
474
+ })
475
+ return
476
+ }
477
+ const snapshot = pluginRuntime.get()
478
+ if (snapshot === undefined) {
479
+ send(ws, {
480
+ type: 'doctor_fix_result',
481
+ requestId,
482
+ result: { ok: false, checkId, error: 'plugin runtime not configured' },
483
+ })
484
+ return
485
+ }
486
+ const outcome = await runPluginDoctorFix({ registry: snapshot.registry, agentDir, checkId })
487
+ const result =
488
+ outcome.ok === true
489
+ ? { ok: true as const, checkId, summary: outcome.summary, changedPaths: outcome.changedPaths }
490
+ : { ok: false as const, checkId, error: outcome.error }
491
+ send(ws, { type: 'doctor_fix_result', requestId, result })
492
+ }
493
+
396
494
  async function handleReload(
397
495
  ws: Ws,
398
496
  reloadAll: ReloadAllFn | undefined,
@@ -1,5 +1,8 @@
1
1
  export {
2
2
  type ClientMessage,
3
+ type DoctorCheckPayload,
4
+ type DoctorFixPayload,
5
+ type DoctorRequestId,
3
6
  type PromptDelivery,
4
7
  type QueueStateItem,
5
8
  type ReloadResultPayload,
@@ -4,11 +4,31 @@ export type ReloadResultPayload =
4
4
 
5
5
  export type PromptDelivery = 'queue' | 'steer' | 'interrupt'
6
6
 
7
+ export type DoctorRequestId = string
8
+
9
+ export type DoctorCheckPayload = {
10
+ id: string
11
+ pluginName: string
12
+ checkName: string
13
+ description: string
14
+ category: string
15
+ status: 'ok' | 'warning' | 'error'
16
+ message: string
17
+ details?: string[]
18
+ fix?: { description: string; hasApply: boolean }
19
+ }
20
+
21
+ export type DoctorFixPayload =
22
+ | { ok: true; checkId: string; summary: string; changedPaths: string[] }
23
+ | { ok: false; checkId: string; error: string }
24
+
7
25
  export type ClientMessage =
8
26
  | { type: 'prompt'; text: string; delivery?: PromptDelivery }
9
27
  | { type: 'reload'; scope?: string }
10
28
  | { type: 'abort' }
11
29
  | { type: 'queue_cancel'; messageId: string }
30
+ | { type: 'doctor'; requestId: DoctorRequestId }
31
+ | { type: 'doctor_fix'; requestId: DoctorRequestId; checkId: string }
12
32
 
13
33
  export type QueueStateItem = { id: string; text: string; ts: number }
14
34
 
@@ -23,3 +43,5 @@ export type ServerMessage =
23
43
  | { type: 'notification'; payload: unknown; replyTo?: string; meta?: Record<string, string> }
24
44
  | { type: 'queue_state'; pending: QueueStateItem[] }
25
45
  | { type: 'prompt_started'; messageId: string; text: string }
46
+ | { type: 'doctor_result'; requestId: DoctorRequestId; checks: DoctorCheckPayload[] }
47
+ | { type: 'doctor_fix_result'; requestId: DoctorRequestId; result: DoctorFixPayload }
@@ -1,6 +1,6 @@
1
1
  ---
2
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.
3
+ description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `kakaotalk`, AND before calling `channel_fetch_attachment` against a KakaoTalk URL. 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 outbound file attachments or stickers. Inbound photos / files / video / audio CAN be downloaded via `channel_fetch_attachment` (the placeholder text includes the URL); inbound stickers are metadata-only and cannot be fetched. URLs expire ~3 days after the message arrives. Read this skill before composing or fetching anything on KakaoTalk.
4
4
  ---
5
5
 
6
6
  # typeclaw-channel-kakaotalk
@@ -21,9 +21,9 @@ If you produce any of the following, KakaoTalk will render it literally and the
21
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
22
  - **Mentions** — there is no `@user` syntax that the protocol surfaces. Address people by name in the message body.
23
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).
24
+ - **Outbound attachments / stickers** — agent-messenger's KakaoTalk SDK exposes no upload API. The adapter is outbound text-only. If the user asks you to send a file or sticker, say so and offer an alternative (paste a link, summarize the file, ship it via another channel).
25
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."
26
+ The adapter rejects outbound attachments via `ok: false` rather than partially sending the text the agent contract is "ok=true means the whole request succeeded", so a silent drop would let you confidently report "I sent your file" when the file never arrived.
27
27
 
28
28
  ## What KakaoTalk DOES support
29
29
 
@@ -31,6 +31,29 @@ The adapter logs a warning the first time you try to send attachments and then d
31
31
  - URLs auto-linkify in the client. Send them bare — `https://example.com/foo`, no markdown wrapping.
32
32
  - Newlines render as line breaks. You can use `\n\n` to space paragraphs.
33
33
 
34
+ ## Inbound attachments and stickers
35
+
36
+ Even though you cannot SEND attachments or stickers, you DO receive them. The adapter surfaces incoming non-text content by appending a `[KakaoTalk message with ...]` placeholder to the inbound text (same convention as Slack/Discord/Telegram). Examples of what you'll see:
37
+
38
+ - A photo (with no caption): `[KakaoTalk message with photo 1320x2868 (image/jpeg) https://talk.kakaocdn.net/...]`
39
+ - A photo with a caption: `look at this\n[KakaoTalk message with photo 1320x2868 (image/jpeg) https://...]`
40
+ - A file: `[KakaoTalk message with file spec.pdf (application/pdf) size=12345 https://...]`
41
+ - A video / audio (with a usable URL): `[KakaoTalk message with video (keys=[dur,url]) https://talk.kakaocdn.net/...]`. The SDK leaves video / audio / multiphoto payloads opaque, so we list the keys that were present alongside the URL when one exists; when no URL is present the placeholder is just `[KakaoTalk message with video keys=[...]]` and there is nothing for you to fetch.
42
+ - A sticker / emoticon: `[KakaoTalk message with sticker (sticker) pack=4412724 path=4412724.emot_001.webp]`
43
+ - An animated sticker: `[KakaoTalk message with sticker (sticker_ani) pack=... path=...]`
44
+
45
+ ### Fetching attachment bytes
46
+
47
+ For photos, files, and any video / audio / multiphoto whose placeholder includes a `https://...kakaocdn.net/...` URL, call `channel_fetch_attachment` with that URL as the `ref` to download the bytes. The adapter validates the host (only `*.kakaocdn.net` is accepted — you cannot use this tool as a generic web fetcher) and returns the raw buffer plus mimetype.
48
+
49
+ Use this when you actually need to look at the content — e.g. the user sends a screenshot and asks "what's in this?". The download lands in your inbox directory and you can pass it to a vision-capable inspection tool or read it directly depending on the file type.
50
+
51
+ **Expiry caveat**: KakaoCDN URLs are pre-signed with an `expires=` timestamp baked into the query string — empirically ~3 days after the message arrived. Fetch promptly. If the URL has expired you will get a `403` error with the hint _"likely an expired pre-signed URL; ask the sender to re-share"_ — relay that to the user verbatim rather than guessing the cause.
52
+
53
+ **Stickers cannot be fetched** as bytes through this tool. The sticker placeholder carries `pack=` and `path=` identifiers (KakaoTalk sticker pack metadata), not a downloadable URL. Treat stickers as descriptive metadata only — acknowledge them ("cute sticker") without trying to "see" them.
54
+
55
+ If the inbound text is JUST a sticker (no accompanying text), the agent still gets a routed event — stickers count as engagement under `reply` and `dm` triggers (group chats with only sticker activity will not trigger `mention` because aliases require text matching).
56
+
34
57
  ## Message length & cadence
35
58
 
36
59
  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:
@@ -403,7 +403,7 @@ RUN apt-get install ... <baseline + enabled toggle packages> ← toggles fan o
403
403
  ENV NODE_ENV=production
404
404
  # Custom lines from typeclaw.json#dockerfile.append. ← only emitted when append is non-empty
405
405
  <your appended lines>
406
- ENTRYPOINT ["bun", "run", "typeclaw"]
406
+ ENTRYPOINT ["/usr/local/bin/typeclaw-entrypoint"]
407
407
  CMD ["run"]
408
408
  ```
409
409
 
package/tsconfig.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2025",
4
+ "module": "Preserve",
5
+ "moduleDetection": "force",
6
+ "moduleResolution": "bundler",
7
+ "verbatimModuleSyntax": true,
8
+ "noEmit": true,
9
+
10
+ "lib": ["ESNext"],
11
+ "types": ["bun"],
12
+ "jsx": "react-jsx",
13
+ "allowJs": true,
14
+
15
+ "strict": true,
16
+ "skipLibCheck": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "noUncheckedIndexedAccess": true,
19
+ "noImplicitOverride": true,
20
+
21
+ "noUnusedLocals": false,
22
+ "noUnusedParameters": false,
23
+ "noPropertyAccessFromIndexSignature": false,
24
+
25
+ "paths": {
26
+ "@/*": ["./src/*"]
27
+ }
28
+ },
29
+ "include": ["src", "scripts"]
30
+ }