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
@@ -23,8 +23,6 @@ import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
23
23
 
24
24
  import type { DoctorCheck } from './types'
25
25
 
26
- const REQUIRED_DIRS = ['workspace', 'sessions', '.agents/skills', 'mounts', 'packages'] as const
27
-
28
26
  export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): DoctorCheck[] {
29
27
  const dockerExec = opts.dockerExec ?? defaultDockerExec
30
28
 
@@ -32,12 +30,12 @@ export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): Docto
32
30
  dockerDaemon(dockerExec),
33
31
  bunRuntime(),
34
32
  agentFolderInitialized(),
35
- agentFolderRequiredDirs(),
36
33
  agentFolderDockerfileTemplate(),
37
34
  agentFolderGitignoreTemplate(),
38
35
  agentFolderNodeModules(),
39
36
  agentFolderGitRepo(),
40
37
  configValid(),
38
+ configBundledProfiles(),
41
39
  hostdHomeWritable(),
42
40
  hostdReachable(),
43
41
  hostdRegistration(),
@@ -103,36 +101,6 @@ function agentFolderInitialized(): DoctorCheck {
103
101
  }
104
102
  }
105
103
 
106
- function agentFolderRequiredDirs(): DoctorCheck {
107
- return {
108
- name: 'agent-folder.required-dirs',
109
- category: 'agent-folder',
110
- description: 'required agent directories exist',
111
- applies: (ctx) => ctx.hasAgentFolder,
112
- async run(ctx) {
113
- const missing = REQUIRED_DIRS.filter((d) => !existsSync(join(ctx.cwd, d)))
114
- if (missing.length === 0) return { status: 'ok', message: 'all required directories present' }
115
- return {
116
- status: 'warning',
117
- message: `${missing.length} required ${missing.length === 1 ? 'directory' : 'directories'} missing`,
118
- details: missing.map((d) => `missing: ${d}/`),
119
- fix: {
120
- description: `Create the missing directories (${missing.map((d) => `${d}/`).join(', ')}).`,
121
- autoFix: async () => {
122
- for (const d of missing) {
123
- mkdirSync(join(ctx.cwd, d), { recursive: true })
124
- }
125
- return {
126
- summary: `created ${missing.map((d) => `${d}/`).join(', ')}`,
127
- changedPaths: missing.map((d) => `${d}/`),
128
- }
129
- },
130
- },
131
- }
132
- },
133
- }
134
- }
135
-
136
104
  function agentFolderDockerfileTemplate(): DoctorCheck {
137
105
  return {
138
106
  name: 'agent-folder.dockerfile-managed',
@@ -247,6 +215,55 @@ function configValid(): DoctorCheck {
247
215
  }
248
216
  }
249
217
 
218
+ // Warns (not errors) when a model profile that a bundled subagent prefers is
219
+ // absent from `models`. Bundled subagents fall back to `default` silently
220
+ // today, but the operator likely declared a `fast`/`deep`/`vision` model in
221
+ // the design discussion's tier scheme expecting the bundled subagents to
222
+ // pick them up. This check surfaces the gap once at `typeclaw doctor` time
223
+ // instead of leaving it buried in container logs (where the rate-limited
224
+ // fallback warning lives).
225
+ //
226
+ // We deliberately limit this to known bundled profiles (memory-logger=fast,
227
+ // dreaming=deep, multimodal-looker=vision). Plugin-contributed subagents
228
+ // would require loading the plugin registry — a heavyweight async path
229
+ // that doesn't belong in doctor's static check surface.
230
+ const BUNDLED_PROFILES: ReadonlyArray<{ profile: string; subagent: string }> = [
231
+ { profile: 'fast', subagent: 'memory-logger' },
232
+ { profile: 'deep', subagent: 'dreaming' },
233
+ { profile: 'vision', subagent: 'multimodal-looker (via look_at tool)' },
234
+ ]
235
+
236
+ function configBundledProfiles(): DoctorCheck {
237
+ return {
238
+ name: 'config.bundled-profiles',
239
+ category: 'config',
240
+ description: 'bundled subagent profiles (`fast`, `deep`, `vision`) declared in models',
241
+ applies: (ctx) => ctx.hasAgentFolder,
242
+ async run(ctx) {
243
+ const validation = validateConfig(ctx.cwd)
244
+ if (!validation.ok) {
245
+ return { status: 'ok', message: 'skipped (config.valid will report the underlying error)' }
246
+ }
247
+ const config = loadConfigSync(ctx.cwd)
248
+ const declared = new Set(Object.keys(config.models))
249
+ const missing = BUNDLED_PROFILES.filter((p) => !declared.has(p.profile))
250
+ if (missing.length === 0) {
251
+ return { status: 'ok', message: 'all bundled subagent profiles declared' }
252
+ }
253
+ return {
254
+ status: 'warning',
255
+ message: `${missing.length} bundled profile(s) missing; will fall back to \`default\``,
256
+ details: missing.map(
257
+ (m) => `${m.profile}: used by ${m.subagent}; declare \`models.${m.profile}\` in typeclaw.json to override`,
258
+ ),
259
+ fix: {
260
+ description: 'Add the missing profile(s) under `models` in typeclaw.json. See the typeclaw-config skill.',
261
+ },
262
+ }
263
+ },
264
+ }
265
+ }
266
+
250
267
  function hostdHomeWritable(): DoctorCheck {
251
268
  return {
252
269
  name: 'hostd.home-writable',
@@ -390,7 +407,7 @@ function loadConfigStrictForTemplate(
390
407
  const result = validateConfig(cwd)
391
408
  if (!result.ok) return null
392
409
  const cfg = loadConfigSync(cwd)
393
- return { dockerfile: cfg.dockerfile, gitignore: cfg.gitignore }
410
+ return { dockerfile: cfg.docker.file, gitignore: cfg.git.ignore }
394
411
  }
395
412
 
396
413
  async function safeRead(path: string): Promise<string | null> {
@@ -1,4 +1,4 @@
1
- import { resolveHostPort } from '@/container'
1
+ import { resolveHostPort, resolveTuiToken } from '@/container'
2
2
  import type { ClientMessage, DoctorCheckPayload, DoctorFixPayload, ServerMessage } from '@/shared'
3
3
 
4
4
  export type PluginBridgeOptions = {
@@ -80,12 +80,14 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
80
80
  if (url === undefined) {
81
81
  try {
82
82
  const port = await resolveHostPort({ cwd: opts.cwd })
83
- url = `ws://localhost:${port}`
83
+ const token = await resolveTuiToken({ cwd: opts.cwd })
84
+ url = buildBridgeUrl(port, token)
84
85
  } catch (err) {
85
86
  return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
86
87
  }
87
88
  }
88
89
  const ws = new WebSocket(url)
90
+ const displayUrl = redactUrl(url)
89
91
  try {
90
92
  await new Promise<void>((resolve, reject) => {
91
93
  // `timer` is declared up front so `cleanup` can reference it without
@@ -97,6 +99,7 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
97
99
  if (timer !== undefined) clearTimeout(timer)
98
100
  ws.removeEventListener('open', onOpen)
99
101
  ws.removeEventListener('error', onError)
102
+ ws.removeEventListener('close', onClose)
100
103
  }
101
104
  const onOpen = () => {
102
105
  cleanup()
@@ -104,7 +107,11 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
104
107
  }
105
108
  const onError = (err: unknown) => {
106
109
  cleanup()
107
- reject(err instanceof Error ? err : new Error(`failed to connect to ${url}`))
110
+ reject(new Error(`failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`))
111
+ }
112
+ const onClose = () => {
113
+ cleanup()
114
+ reject(new Error(`connection to ${displayUrl} closed before opening`))
108
115
  }
109
116
  // Bun's WebSocket has no built-in connect timeout. Without this, a TCP
110
117
  // handshake that completes but never produces an Upgrade response
@@ -117,10 +124,11 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
117
124
  try {
118
125
  ws.close()
119
126
  } catch {}
120
- reject(new Error(`websocket connect timeout after ${timeoutMs}ms`))
127
+ reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
121
128
  }, timeoutMs)
122
129
  ws.addEventListener('open', onOpen, { once: true })
123
130
  ws.addEventListener('error', onError, { once: true })
131
+ ws.addEventListener('close', onClose, { once: true })
124
132
  })
125
133
  } catch (err) {
126
134
  return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
@@ -128,6 +136,22 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
128
136
  return { kind: 'ok', ws, timeoutMs }
129
137
  }
130
138
 
139
+ function buildBridgeUrl(port: number, token: string | null): string {
140
+ const url = new URL(`ws://127.0.0.1:${port}`)
141
+ if (token !== null) url.searchParams.set('token', token)
142
+ return url.toString()
143
+ }
144
+
145
+ function redactUrl(url: string): string {
146
+ try {
147
+ const parsed = new URL(url)
148
+ if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
149
+ return parsed.toString()
150
+ } catch {
151
+ return url
152
+ }
153
+ }
154
+
131
155
  async function withRequest<R extends { kind: string }>(
132
156
  ws: WebSocket,
133
157
  timeoutMs: number,
@@ -0,0 +1,103 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ // Commits TypeClaw-owned tracked files (.gitignore, package.json,
5
+ // typeclaw.json) if any are dirty in git. Skips silently when the agent
6
+ // folder is not a git repo, when Bun is unavailable, or when every named
7
+ // file is clean. Uses the user's global git config for authorship —
8
+ // TypeClaw does not impersonate the user here.
9
+ //
10
+ // Accepts a single file or an array; the array form produces a single
11
+ // atomic commit covering all listed paths (used for migrations that touch
12
+ // multiple files together, e.g. enabling bun workspaces writes both
13
+ // package.json and packages/.gitkeep in one commit).
14
+ //
15
+ // Lives under src/git/ rather than src/container/ because both the
16
+ // host-stage launcher (typeclaw start) and src/config/config.ts (called
17
+ // from every entry point that reads typeclaw.json, host AND container)
18
+ // need to commit migration artifacts. Putting it in src/container/ would
19
+ // pull container-level imports into the config module and create a
20
+ // circular dependency at the package boundary.
21
+ export async function commitSystemFile(cwd: string, file: string | readonly string[], message: string): Promise<void> {
22
+ const files = typeof file === 'string' ? [file] : file
23
+ if (files.length === 0) return
24
+
25
+ const bun = getBunAsync()
26
+ if (!bun) return
27
+ if (!existsSync(join(cwd, '.git'))) return
28
+
29
+ const status = bun.spawn({
30
+ cmd: ['git', 'status', '--porcelain', '--', ...files],
31
+ cwd,
32
+ stdout: 'pipe',
33
+ stderr: 'pipe',
34
+ })
35
+ if ((await status.exited) !== 0) return
36
+ const dirty = (await new Response(status.stdout).text()).trim().length > 0
37
+ if (!dirty) return
38
+
39
+ const add = bun.spawn({ cmd: ['git', 'add', '--', ...files], cwd, stdout: 'pipe', stderr: 'pipe' })
40
+ if ((await add.exited) !== 0) return
41
+
42
+ const commit = bun.spawn({
43
+ cmd: ['git', 'commit', '-m', message, '--only', '--', ...files],
44
+ cwd,
45
+ stdout: 'pipe',
46
+ stderr: 'pipe',
47
+ })
48
+ await commit.exited
49
+ }
50
+
51
+ // Synchronous variant for callers that already hold a synchronous codepath
52
+ // — specifically `persistMigratedConfig` in src/config/config.ts. The
53
+ // migration write is itself synchronous (writeFileSync) and the call sites
54
+ // (loadConfigSync, validateConfig, loadPluginConfigsSync) are sync, so we
55
+ // cannot await an async commit without forcing them all to become async,
56
+ // which would ripple into hundreds of call sites across the codebase.
57
+ //
58
+ // The commit overhead (~10-50ms) is paid exactly once per agent folder
59
+ // per legacy form: after the first call rewrites the file to canonical
60
+ // shape, subsequent migrateLegacyConfigShape calls return changed=false
61
+ // and this codepath is unreachable. On canonical folders (the common
62
+ // case) this function is never called at all.
63
+ //
64
+ // Same skip semantics as the async variant — no-op when the folder is not
65
+ // a git repo, when Bun is unavailable, or when the file is clean.
66
+ export function commitSystemFileSync(cwd: string, file: string | readonly string[], message: string): void {
67
+ const files = typeof file === 'string' ? [file] : file
68
+ if (files.length === 0) return
69
+
70
+ const bun = getBunSync()
71
+ if (!bun) return
72
+ if (!existsSync(join(cwd, '.git'))) return
73
+
74
+ const status = bun.spawnSync({
75
+ cmd: ['git', 'status', '--porcelain', '--', ...files],
76
+ cwd,
77
+ stdout: 'pipe',
78
+ stderr: 'pipe',
79
+ })
80
+ if (status.exitCode !== 0) return
81
+ if (new TextDecoder().decode(status.stdout).trim().length === 0) return
82
+
83
+ const add = bun.spawnSync({ cmd: ['git', 'add', '--', ...files], cwd, stdout: 'pipe', stderr: 'pipe' })
84
+ if (add.exitCode !== 0) return
85
+
86
+ bun.spawnSync({
87
+ cmd: ['git', 'commit', '-m', message, '--only', '--', ...files],
88
+ cwd,
89
+ stdout: 'pipe',
90
+ stderr: 'pipe',
91
+ })
92
+ }
93
+
94
+ // Bun-availability shims kept tight to the two functions so the module
95
+ // has no module-level side effects (matters for the sync codepath, which
96
+ // is called during typeclaw.json reads on hot import).
97
+ function getBunAsync(): { spawn: typeof Bun.spawn } | undefined {
98
+ return (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
99
+ }
100
+
101
+ function getBunSync(): { spawnSync: typeof Bun.spawnSync } | undefined {
102
+ return (globalThis as { Bun?: { spawnSync: typeof Bun.spawnSync } }).Bun
103
+ }
@@ -11,6 +11,7 @@ import { kakaoChannelBlockSchema } from '@/secrets/schema'
11
11
  import { SecretsBackend } from '@/secrets/storage'
12
12
 
13
13
  import { isDaemonReachable } from './client'
14
+ import type { KakaoRenewalCallbacks, KakaoRenewalLogEvent } from './kakao-renewal-manager'
14
15
  import { ensureDirs, registrationFilePath, registrationsDir, socketPath } from './paths'
15
16
  import type {
16
17
  HttpInfoResult,
@@ -54,6 +55,11 @@ export type DaemonOptions = {
54
55
  // fields trigger broker spawn alongside supervisor registration. Tests omit
55
56
  // it to keep the broker out of unrelated suites.
56
57
  portbroker?: PortbrokerCallbacks
58
+ // KakaoTalk credential renewal capability. When provided, the daemon
59
+ // starts a per-container daily renewal tick on register and stops it on
60
+ // deregister. Omit to disable in tests / when the agent has no kakaotalk
61
+ // channel configured.
62
+ kakaoRenewal?: KakaoRenewalCallbacks
57
63
  }
58
64
 
59
65
  export type RestartPreflight = (input: {
@@ -94,6 +100,7 @@ export type DaemonLogEvent =
94
100
  | { kind: 'shutdown-requested' }
95
101
  | { kind: 'port-forward-event'; event: PortForwardEvent }
96
102
  | { kind: 'tailscale-serve-event'; event: TailscaleServeEvent }
103
+ | KakaoRenewalLogEvent
97
104
 
98
105
  export type Daemon = {
99
106
  registered: () => string[]
@@ -284,6 +291,9 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
284
291
  onTailscaleServeEvent: (event) => log({ kind: 'tailscale-serve-event', event }),
285
292
  })
286
293
  }
294
+ if (opts.kakaoRenewal) {
295
+ opts.kakaoRenewal.start({ containerName: payload.containerName, cwd: payload.cwd })
296
+ }
287
297
  }
288
298
 
289
299
  const handleRegister = async (req: RegisterPayload): Promise<RpcResponse> => {
@@ -309,6 +319,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
309
319
  restartTokens.delete(req.containerName)
310
320
  gcMisses.delete(req.containerName)
311
321
  if (opts.portbroker) await opts.portbroker.stop(req.containerName, 'deregistered').catch(() => {})
322
+ if (opts.kakaoRenewal) await opts.kakaoRenewal.stop(req.containerName).catch(() => {})
312
323
  await removeRegistrationFile(req.containerName)
313
324
  if (hadCwd) log({ kind: 'deregister', containerName: req.containerName, reason: 'requested' })
314
325
  return { ok: true }
@@ -574,6 +585,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
574
585
  const hadCwd = cwds.delete(name)
575
586
  restartTokens.delete(name)
576
587
  if (opts.portbroker) await opts.portbroker.stop(name, 'deregistered').catch(() => {})
588
+ if (opts.kakaoRenewal) await opts.kakaoRenewal.stop(name).catch(() => {})
577
589
  await removeRegistrationFile(name)
578
590
  if (hadCwd) log({ kind: 'deregister', containerName: name, reason: 'gone' })
579
591
  return { ok: true }
@@ -601,6 +613,10 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
601
613
  const names = Array.from(cwds.keys())
602
614
  await Promise.allSettled(names.map((n) => opts.portbroker!.stop(n, 'broker-stopped')))
603
615
  }
616
+ if (opts.kakaoRenewal) {
617
+ const names = Array.from(cwds.keys())
618
+ await Promise.allSettled(names.map((n) => opts.kakaoRenewal!.stop(n)))
619
+ }
604
620
  cwds.clear()
605
621
  restartTokens.clear()
606
622
  try {
@@ -0,0 +1,223 @@
1
+ import { renewCurrentAccount, type AttemptLoginFn } from '@/secrets/kakao-renewal'
2
+ import { createKeyStore, type KeyStore } from '@/secrets/keys'
3
+
4
+ import { keysDir } from './paths'
5
+
6
+ const DEFAULT_TICK_INTERVAL_MS = 24 * 60 * 60 * 1000
7
+
8
+ export type KakaoRenewalCallbacks = {
9
+ start: (input: KakaoRenewalStartInput) => void
10
+ stop: (containerName: string) => Promise<void>
11
+ drain: () => Promise<void>
12
+ }
13
+
14
+ export type KakaoRenewalStartInput = {
15
+ containerName: string
16
+ cwd: string
17
+ }
18
+
19
+ export type KakaoRenewalLogEvent =
20
+ | { kind: 'kakao-renewal-tick-start'; containerName: string }
21
+ | { kind: 'kakao-renewal-tick-skipped'; containerName: string; reason: string; ageMs?: number }
22
+ | { kind: 'kakao-renewal-tick-ok'; containerName: string; accountId: string; previousUpdatedAt: string }
23
+ | {
24
+ kind: 'kakao-renewal-tick-reauth-required'
25
+ containerName: string
26
+ accountId: string
27
+ reason: string
28
+ message: string
29
+ }
30
+ | { kind: 'kakao-renewal-tick-transient-failure'; containerName: string; accountId: string; reason: string }
31
+ | { kind: 'kakao-renewal-tick-error'; containerName: string; error: string }
32
+ | { kind: 'kakao-renewal-restart-scheduled'; containerName: string; accountId: string }
33
+ | { kind: 'kakao-renewal-restart-failed'; containerName: string; accountId: string; reason: string }
34
+
35
+ export type KakaoRenewalManagerOptions = {
36
+ onLog?: (event: KakaoRenewalLogEvent) => void
37
+ tickIntervalMs?: number
38
+ keyStoreFactory?: () => KeyStore
39
+ attemptLogin?: AttemptLoginFn
40
+ schedule?: (fn: () => void, intervalMs: number) => { stop: () => void }
41
+ // Invoked after a successful renewal so the host can restart the container
42
+ // and the in-memory adapter picks up the fresh tokens. Without this, the
43
+ // cron writes new tokens to secrets.json but the live LOCO client keeps the
44
+ // old token in its closure and still hits 401 at the ~7-day wall. Production
45
+ // wires this to the same restart path the `restart` RPC uses; tests can
46
+ // observe it via a fake.
47
+ onRenewalOk?: (input: { containerName: string; cwd: string; accountId: string }) => Promise<void>
48
+ // Optional predicate: only start the renewal cron for containers whose
49
+ // `typeclaw.json` actually has a `channels.kakaotalk` block. Without this,
50
+ // every typeclaw agent on the host emits daily `no_account` skip logs.
51
+ shouldRenew?: (input: KakaoRenewalStartInput) => boolean
52
+ }
53
+
54
+ // Per-container daily renewal tick. Mirrors portbroker-manager.ts: hostd calls
55
+ // start() on register and stop() on deregister, and the manager owns timer
56
+ // lifecycle plus the actual renewal work. The keystore lives on the host
57
+ // (~/.typeclaw/keys/<name>.key), unreachable from inside the container —
58
+ // that's load-bearing for encryption.ts's threat model.
59
+ export function createKakaoRenewalManager(opts: KakaoRenewalManagerOptions = {}): KakaoRenewalCallbacks {
60
+ const intervalMs = opts.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS
61
+ const keyStore = (opts.keyStoreFactory ?? (() => createKeyStore({ keysDir: keysDir() })))()
62
+ const log = opts.onLog ?? (() => {})
63
+ const schedule =
64
+ opts.schedule ??
65
+ ((fn: () => void, ms: number) => {
66
+ const handle = setInterval(fn, ms)
67
+ return { stop: () => clearInterval(handle) }
68
+ })
69
+
70
+ const timers = new Map<string, { stop: () => void }>()
71
+ // Track the latest registration input per container so a re-register
72
+ // arriving during an in-flight tick can both (a) prevent the in-flight tick
73
+ // from acting on stale cwd, and (b) trigger a fresh tick once the in-flight
74
+ // one finishes. The Map's value is the most recent input.
75
+ const latestInput = new Map<string, KakaoRenewalStartInput>()
76
+ // Track in-flight tick promises per container so stop()/drain() can await
77
+ // them. Without this, daemon shutdown abandons an in-flight attemptLogin
78
+ // HTTPS request mid-write.
79
+ const inFlight = new Map<string, Promise<void>>()
80
+ // Pending immediate-tick request: set when start() is called while a tick
81
+ // is in flight, so we re-fire one tick after the in-flight settles.
82
+ const pendingRerun = new Set<string>()
83
+
84
+ const runTick = async (input: KakaoRenewalStartInput): Promise<void> => {
85
+ log({ kind: 'kakao-renewal-tick-start', containerName: input.containerName })
86
+ try {
87
+ const result = await renewCurrentAccount({
88
+ containerName: input.containerName,
89
+ agentDir: input.cwd,
90
+ keyStore,
91
+ ...(opts.attemptLogin ? { attemptLogin: opts.attemptLogin } : {}),
92
+ })
93
+ if (result.kind === 'skipped') {
94
+ log({
95
+ kind: 'kakao-renewal-tick-skipped',
96
+ containerName: input.containerName,
97
+ reason: result.reason,
98
+ ...(result.ageMs !== undefined ? { ageMs: result.ageMs } : {}),
99
+ })
100
+ } else if (result.kind === 'ok') {
101
+ log({
102
+ kind: 'kakao-renewal-tick-ok',
103
+ containerName: input.containerName,
104
+ accountId: result.account_id,
105
+ previousUpdatedAt: result.previousUpdatedAt,
106
+ })
107
+ if (opts.onRenewalOk) {
108
+ log({
109
+ kind: 'kakao-renewal-restart-scheduled',
110
+ containerName: input.containerName,
111
+ accountId: result.account_id,
112
+ })
113
+ try {
114
+ await opts.onRenewalOk({
115
+ containerName: input.containerName,
116
+ cwd: input.cwd,
117
+ accountId: result.account_id,
118
+ })
119
+ } catch (err) {
120
+ log({
121
+ kind: 'kakao-renewal-restart-failed',
122
+ containerName: input.containerName,
123
+ accountId: result.account_id,
124
+ reason: err instanceof Error ? err.message : String(err),
125
+ })
126
+ }
127
+ }
128
+ } else if (result.kind === 'reauth_required') {
129
+ log({
130
+ kind: 'kakao-renewal-tick-reauth-required',
131
+ containerName: input.containerName,
132
+ accountId: result.account_id,
133
+ reason: result.reason,
134
+ message: result.message,
135
+ })
136
+ } else {
137
+ log({
138
+ kind: 'kakao-renewal-tick-transient-failure',
139
+ containerName: input.containerName,
140
+ accountId: result.account_id,
141
+ reason: result.reason,
142
+ })
143
+ }
144
+ } catch (err) {
145
+ // Defensive: renewCurrentAccount's contract is to return a structured
146
+ // result, but a malformed secrets.json or a disk error could surface
147
+ // here. Log and move on — the next tick retries.
148
+ log({
149
+ kind: 'kakao-renewal-tick-error',
150
+ containerName: input.containerName,
151
+ error: err instanceof Error ? err.message : String(err),
152
+ })
153
+ }
154
+ }
155
+
156
+ // Single-flight per container: dedupe overlapping ticks, but if a new
157
+ // tick request arrives while one is in flight, queue ONE rerun so the new
158
+ // registration's cwd (or a manual nudge) gets a chance after the in-flight
159
+ // settles. The Promise stored in inFlight resolves only after any queued
160
+ // rerun also completes, so stop()/drain() awaiting inFlight is enough to
161
+ // observe a quiescent manager.
162
+ const scheduleTick = (containerName: string): Promise<void> => {
163
+ const existing = inFlight.get(containerName)
164
+ if (existing) {
165
+ pendingRerun.add(containerName)
166
+ return existing
167
+ }
168
+ const promise = (async () => {
169
+ // Loop until no rerun was queued during this tick, so the LAST input
170
+ // recorded for the container is the one we end on. This handles the
171
+ // re-register-while-in-flight + cwd-change case described in the
172
+ // pendingRerun comment above.
173
+ while (true) {
174
+ const input = latestInput.get(containerName)
175
+ if (!input) return
176
+ await runTick(input)
177
+ if (!pendingRerun.has(containerName)) return
178
+ pendingRerun.delete(containerName)
179
+ }
180
+ })().finally(() => {
181
+ inFlight.delete(containerName)
182
+ pendingRerun.delete(containerName)
183
+ })
184
+ inFlight.set(containerName, promise)
185
+ return promise
186
+ }
187
+
188
+ return {
189
+ start(input: KakaoRenewalStartInput): void {
190
+ if (opts.shouldRenew && !opts.shouldRenew(input)) return
191
+ const existing = timers.get(input.containerName)
192
+ if (existing) existing.stop()
193
+ latestInput.set(input.containerName, input)
194
+ const handle = schedule(() => {
195
+ void scheduleTick(input.containerName)
196
+ }, intervalMs)
197
+ timers.set(input.containerName, handle)
198
+ void scheduleTick(input.containerName)
199
+ },
200
+
201
+ async stop(containerName: string): Promise<void> {
202
+ const handle = timers.get(containerName)
203
+ if (handle) {
204
+ timers.delete(containerName)
205
+ handle.stop()
206
+ }
207
+ latestInput.delete(containerName)
208
+ // Await any in-flight tick before resolving so the caller can rely on
209
+ // "stop returned → no work outstanding". Daemon shutdown depends on
210
+ // this; without it, a mid-tick `attemptLogin` HTTPS call is abandoned.
211
+ const promise = inFlight.get(containerName)
212
+ if (promise) await promise.catch(() => {})
213
+ },
214
+
215
+ async drain(): Promise<void> {
216
+ for (const [, handle] of timers) handle.stop()
217
+ timers.clear()
218
+ latestInput.clear()
219
+ const promises = Array.from(inFlight.values())
220
+ await Promise.allSettled(promises)
221
+ },
222
+ }
223
+ }
@@ -9,6 +9,7 @@ import { join } from 'node:path'
9
9
  const CONTAINER_HOST_RUN_DIR = '/run/typeclaw-host'
10
10
  const SOCKET_FILE = 'hostd.sock'
11
11
  const REGISTRATIONS_DIR = 'registrations'
12
+ const KEYS_DIR = 'keys'
12
13
 
13
14
  // Defense-in-depth: containerName arrives from RPC payloads (some of which
14
15
  // originate inside the container). Docker already forbids slashes and most
@@ -51,6 +52,10 @@ export function registrationsDir(): string {
51
52
  return join(runDir(), REGISTRATIONS_DIR)
52
53
  }
53
54
 
55
+ export function keysDir(): string {
56
+ return join(homeRoot(), KEYS_DIR)
57
+ }
58
+
54
59
  // Throws on any name that could traverse out of registrationsDir() or
55
60
  // confuse the filesystem. Caller's responsibility to handle the error;
56
61
  // don't catch-and-ignore — an invalid name is a protocol violation.
@@ -76,7 +81,9 @@ export async function ensureDirs(): Promise<void> {
76
81
  await mkdir(runDir(), { recursive: true })
77
82
  await mkdir(logDir(), { recursive: true })
78
83
  await mkdir(registrationsDir(), { recursive: true })
84
+ await mkdir(keysDir(), { recursive: true })
79
85
  await chmod(runDir(), 0o700).catch(() => {})
80
86
  await chmod(logDir(), 0o700).catch(() => {})
81
87
  await chmod(registrationsDir(), 0o700).catch(() => {})
88
+ await chmod(keysDir(), 0o700).catch(() => {})
82
89
  }