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
package/src/init/index.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  type KnownProviderId,
13
13
  } from '@/config/providers'
14
14
  import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
15
+ import { commitSystemFile } from '@/git/system-commit'
15
16
  import { createSecretsStoreForAgent, type Channels, type Secret, SecretsBackend } from '@/secrets'
16
17
  import { createTui } from '@/tui'
17
18
 
@@ -81,7 +82,15 @@ export type InitStepEvent =
81
82
  // portbroker — same path `typeclaw start` takes. When omitted (test fixtures,
82
83
  // programmatic callers that never want a daemon), `start()` skips the daemon
83
84
  // path entirely and the container runs unmanaged.
84
- export type HatchRunner = (options: { cwd: string; port: number; cliEntry?: string }) => Promise<HatchingResult>
85
+ export type HatchRunner = (options: {
86
+ cwd: string
87
+ port: number
88
+ cliEntry?: string
89
+ // Set when the wizard wired at least one channel adapter, so the runner
90
+ // can offer to run `typeclaw role claim` after the container is ready.
91
+ // Empty / undefined means "no channels — skip the claim flow".
92
+ configuredChannels?: readonly ChannelKind[]
93
+ }) => Promise<HatchingResult>
85
94
 
86
95
  export type KakaotalkAuthRunner = (options: { cwd: string }) => Promise<KakaotalkAuthResult>
87
96
 
@@ -101,16 +110,27 @@ export type InitOptions = {
101
110
  // defaults to the api-key path with `apiKey` (legacy field, still
102
111
  // supported for backwards compat with the old `runInit` signature).
103
112
  llmAuth?: LLMAuth
113
+ // Optional second model + auth, written as `models.vision` when the
114
+ // default model is text-only. Auth is reused from the default path
115
+ // when both refer to the same provider; the wizard enforces this
116
+ // pairing rule, so by the time we get here `visionAuth` is either
117
+ // (a) absent, or (b) the right auth for `visionModel`'s provider.
118
+ visionModel?: KnownModelRef
119
+ visionAuth?: LLMAuth
104
120
  apiKey?: string
105
121
  discordBotToken?: string
106
- discordAllowAll?: boolean
107
122
  slackBotToken?: string
108
123
  slackAppToken?: string
109
- slackAllowAll?: boolean
110
124
  telegramBotToken?: string
111
- telegramAllowAll?: boolean
125
+ // When reusing existing channel credentials from a pre-init `secrets.json`,
126
+ // the CLI passes `with<Adapter>: true` without a corresponding token so the
127
+ // scaffolded `typeclaw.json` wires the adapter while `writeSecrets` leaves
128
+ // the existing slot in `secrets.json#channels` untouched. Defaults below
129
+ // mirror the legacy derivation (`<token> !== undefined && !== ''`).
130
+ withDiscord?: boolean
131
+ withSlack?: boolean
132
+ withTelegram?: boolean
112
133
  withKakaotalk?: boolean
113
- kakaotalkAllowAll?: boolean
114
134
  runKakaotalkAuth?: KakaotalkAuthRunner
115
135
  onProgress?: (event: InitStepEvent) => void
116
136
  runHatching?: HatchRunner
@@ -129,15 +149,16 @@ export async function runInit({
129
149
  apiKey,
130
150
  llmAuth,
131
151
  model = DEFAULT_MODEL_REF,
152
+ visionModel,
153
+ visionAuth,
132
154
  discordBotToken,
133
- discordAllowAll = true,
134
155
  slackBotToken,
135
156
  slackAppToken,
136
- slackAllowAll = true,
137
157
  telegramBotToken,
138
- telegramAllowAll = true,
158
+ withDiscord,
159
+ withSlack,
160
+ withTelegram,
139
161
  withKakaotalk = false,
140
- kakaotalkAllowAll = false,
141
162
  runKakaotalkAuth,
142
163
  onProgress,
143
164
  runHatching = defaultRunHatching,
@@ -180,20 +201,36 @@ export async function runInit({
180
201
  }
181
202
  }
182
203
 
183
- const wantsDiscord = discordBotToken !== undefined && discordBotToken !== ''
184
- const wantsSlack = slackBotToken !== undefined && slackBotToken !== ''
185
- const wantsTelegram = telegramBotToken !== undefined && telegramBotToken !== ''
204
+ // When the vision profile uses a different provider than the default, its
205
+ // OAuth login runs here too, before any file write. Same-provider vision
206
+ // reuses the default auth (no separate login). API-key vision auth is
207
+ // captured in memory and persisted by writeSecrets() below.
208
+ if (
209
+ visionAuth !== undefined &&
210
+ visionAuth.kind === 'oauth' &&
211
+ visionModel !== undefined &&
212
+ providerForModelRef(visionModel) !== providerForModelRef(model)
213
+ ) {
214
+ emit({ step: 'oauth-login', phase: 'start' })
215
+ await mkdir(cwd, { recursive: true })
216
+ const result = await visionAuth.runLogin({ cwd, model: visionModel })
217
+ emit({ step: 'oauth-login', phase: 'done', result })
218
+ if (!result.ok) {
219
+ throw new Error(`OAuth login failed: ${result.reason}`)
220
+ }
221
+ }
222
+
223
+ const wantsDiscord = withDiscord ?? (discordBotToken !== undefined && discordBotToken !== '')
224
+ const wantsSlack = withSlack ?? (slackBotToken !== undefined && slackBotToken !== '')
225
+ const wantsTelegram = withTelegram ?? (telegramBotToken !== undefined && telegramBotToken !== '')
186
226
  emit({ step: 'scaffold', phase: 'start' })
187
227
  await scaffold(cwd, {
188
228
  model,
229
+ ...(visionModel !== undefined ? { visionModel } : {}),
189
230
  withDiscord: wantsDiscord,
190
- discordAllowAll,
191
231
  withSlack: wantsSlack,
192
- slackAllowAll,
193
232
  withTelegram: wantsTelegram,
194
- telegramAllowAll,
195
233
  withKakaotalk,
196
- kakaotalkAllowAll,
197
234
  })
198
235
  // Only write the LLM API key on the api-key path. OAuth providers persist
199
236
  // their credentials to secrets.json (via the OAuth login step above); writing
@@ -201,6 +238,9 @@ export async function runInit({
201
238
  await writeSecrets(cwd, {
202
239
  model,
203
240
  apiKey: resolvedAuth.kind === 'api-key' ? resolvedAuth.apiKey : undefined,
241
+ ...(visionModel !== undefined && visionAuth?.kind === 'api-key'
242
+ ? { visionModel, visionApiKey: visionAuth.apiKey }
243
+ : {}),
204
244
  discordBotToken,
205
245
  slackBotToken,
206
246
  slackAppToken,
@@ -235,8 +275,19 @@ export async function runInit({
235
275
  const git = await initGitRepo(cwd)
236
276
  emit({ step: 'git', phase: 'done', result: git })
237
277
 
278
+ const configuredChannels: ChannelKind[] = []
279
+ if (wantsDiscord) configuredChannels.push('discord-bot')
280
+ if (wantsSlack) configuredChannels.push('slack-bot')
281
+ if (wantsTelegram) configuredChannels.push('telegram-bot')
282
+ if (withKakaotalk) configuredChannels.push('kakaotalk')
283
+
238
284
  emit({ step: 'hatching', phase: 'start' })
239
- const hatching = await runHatching({ cwd, port: config.port, ...(cliEntry !== undefined ? { cliEntry } : {}) })
285
+ const hatching = await runHatching({
286
+ cwd,
287
+ port: config.port,
288
+ ...(cliEntry !== undefined ? { cliEntry } : {}),
289
+ ...(configuredChannels.length > 0 ? { configuredChannels } : {}),
290
+ })
240
291
  emit({ step: 'hatching', phase: 'done', result: hatching })
241
292
  }
242
293
 
@@ -250,16 +301,20 @@ export async function defaultRunHatching({
250
301
  cwd,
251
302
  port,
252
303
  cliEntry,
304
+ configuredChannels,
253
305
  startContainer = start,
254
306
  tui: tuiFactory = createTui,
255
307
  waitForAgent: waitForAgentFn = waitForAgent,
308
+ runClaim = defaultRunClaim,
256
309
  }: {
257
310
  cwd: string
258
311
  port: number
259
312
  cliEntry?: string
313
+ configuredChannels?: readonly ChannelKind[]
260
314
  startContainer?: typeof start
261
315
  tui?: typeof createTui
262
316
  waitForAgent?: typeof waitForAgent
317
+ runClaim?: ClaimRunner
263
318
  }): Promise<HatchingResult> {
264
319
  try {
265
320
  const launch = await startContainer({
@@ -276,6 +331,11 @@ export async function defaultRunHatching({
276
331
 
277
332
  await waitForAgentFn(`http://127.0.0.1:${hostPort}`, { timeoutMs: 30_000 })
278
333
 
334
+ if (configuredChannels !== undefined && configuredChannels.length > 0) {
335
+ const url = buildTuiUrl(hostPort, launch.tuiToken)
336
+ await runClaim({ url, configuredChannels })
337
+ }
338
+
279
339
  const tui = tuiFactory({
280
340
  url: buildTuiUrl(hostPort, launch.tuiToken),
281
341
  initialPrompt: HATCHING_PROMPT,
@@ -287,6 +347,13 @@ export async function defaultRunHatching({
287
347
  }
288
348
  }
289
349
 
350
+ export type ClaimRunner = (options: { url: string; configuredChannels: readonly ChannelKind[] }) => Promise<void>
351
+
352
+ const defaultRunClaim: ClaimRunner = async ({ url, configuredChannels }) => {
353
+ const { runOwnerClaim } = await import('./run-owner-claim')
354
+ await runOwnerClaim({ url, configuredChannels })
355
+ }
356
+
290
357
  function buildTuiUrl(hostPort: number, token: string | null): string {
291
358
  const url = new URL(`ws://127.0.0.1:${hostPort}`)
292
359
  if (token !== null) url.searchParams.set('token', token)
@@ -372,14 +439,11 @@ export async function isHatched(dir: string): Promise<boolean> {
372
439
 
373
440
  export type ScaffoldOptions = {
374
441
  model?: KnownModelRef
442
+ visionModel?: KnownModelRef
375
443
  withDiscord?: boolean
376
- discordAllowAll?: boolean
377
444
  withSlack?: boolean
378
- slackAllowAll?: boolean
379
445
  withTelegram?: boolean
380
- telegramAllowAll?: boolean
381
446
  withKakaotalk?: boolean
382
- kakaotalkAllowAll?: boolean
383
447
  }
384
448
 
385
449
  export async function scaffold(root: string, options: ScaffoldOptions = {}): Promise<void> {
@@ -392,30 +456,22 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
392
456
  // immediately populated, so packages/ is the only one that needs this.
393
457
  await writeFile(join(root, PACKAGES_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists)
394
458
 
395
- // Only fields without sensible defaults elsewhere are emitted, with one
396
- // exception: `network.blockInternal` is re-emitted at its default value
397
- // (`true`) because the field is security-relevant and users need to
398
- // discover it in their `typeclaw.json` to know they can opt out for LAN
399
- // access. `mounts` defaults to `[]` in configSchema, and the bundled
400
- // memory plugin owns its own defaults in src/bundled-plugins/memory/
401
- // index.ts re-emitting either here would be duplicate noise the user
402
- // has to maintain in sync with the source of truth.
459
+ // Only fields without sensible defaults elsewhere are emitted. Everything
460
+ // with a schema-provided default (e.g. `network.blockInternal`, `mounts`,
461
+ // `memory.*`) is omitted to keep the scaffold minimal duplicating defaults
462
+ // here would mean every schema change has to be mirrored in two places, and
463
+ // users would feel obligated to maintain values they never set.
464
+ const models: Record<string, KnownModelRef> = { default: options.model ?? DEFAULT_MODEL_REF }
465
+ if (options.visionModel !== undefined) models.vision = options.visionModel
403
466
  const config: Record<string, unknown> = {
404
467
  $schema: './node_modules/typeclaw/typeclaw.schema.json',
405
- model: options.model ?? DEFAULT_MODEL_REF,
406
- network: { blockInternal: true },
407
- }
408
- const channels: Record<string, { allow: string[] }> = {}
409
- if (options.withDiscord) channels['discord-bot'] = { allow: options.discordAllowAll === false ? [] : ['*'] }
410
- if (options.withSlack) channels['slack-bot'] = { allow: options.slackAllowAll === false ? [] : ['*'] }
411
- if (options.withTelegram) channels['telegram-bot'] = { allow: options.telegramAllowAll === false ? [] : ['*'] }
412
- if (options.withKakaotalk) {
413
- // KakaoTalk involves a personal account, so we default to a tighter
414
- // allow list (DMs only) than Slack/Discord/Telegram which scope to a
415
- // workspace the user explicitly admitted the bot into. The user can
416
- // broaden to `kakao:*` later by editing typeclaw.json.
417
- channels.kakaotalk = { allow: options.kakaotalkAllowAll === true ? ['kakao:*'] : ['kakao:dm/*'] }
468
+ models,
418
469
  }
470
+ const channels: Record<string, Record<string, never>> = {}
471
+ if (options.withDiscord) channels['discord-bot'] = {}
472
+ if (options.withSlack) channels['slack-bot'] = {}
473
+ if (options.withTelegram) channels['telegram-bot'] = {}
474
+ if (options.withKakaotalk) channels.kakaotalk = {}
419
475
  if (Object.keys(channels).length > 0) config.channels = channels
420
476
  await writeFile(join(root, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
421
477
 
@@ -578,6 +634,8 @@ export async function writeSecrets(
578
634
  {
579
635
  model = DEFAULT_MODEL_REF,
580
636
  apiKey,
637
+ visionModel,
638
+ visionApiKey,
581
639
  discordBotToken,
582
640
  slackBotToken,
583
641
  slackAppToken,
@@ -586,6 +644,8 @@ export async function writeSecrets(
586
644
  model?: KnownModelRef
587
645
  // Omitted on the OAuth path — credentials live in secrets.json via the OAuth runner.
588
646
  apiKey?: string
647
+ visionModel?: KnownModelRef
648
+ visionApiKey?: string
589
649
  discordBotToken?: string
590
650
  slackBotToken?: string
591
651
  slackAppToken?: string
@@ -594,8 +654,20 @@ export async function writeSecrets(
594
654
  ): Promise<void> {
595
655
  const providerId = providerForModelRef(model)
596
656
  const apiKeyEnv = KNOWN_PROVIDERS[providerId].apiKeyEnv
597
- if (apiKey !== undefined && apiKeyEnv !== null) {
598
- createSecretsStoreForAgent(join(root, 'secrets.json')).set(providerId, { type: 'api_key', key: apiKey })
657
+ const wantsDefaultKey = apiKey !== undefined && apiKeyEnv !== null
658
+ const visionProviderId = visionModel !== undefined ? providerForModelRef(visionModel) : null
659
+ const wantsVisionKey =
660
+ visionModel !== undefined &&
661
+ visionApiKey !== undefined &&
662
+ visionProviderId !== providerId &&
663
+ visionProviderId !== null &&
664
+ KNOWN_PROVIDERS[visionProviderId].apiKeyEnv !== null
665
+ if (wantsDefaultKey || wantsVisionKey) {
666
+ const secretsStore = createSecretsStoreForAgent(join(root, 'secrets.json'))
667
+ if (wantsDefaultKey) secretsStore.set(providerId, { type: 'api_key', key: apiKey! })
668
+ if (wantsVisionKey) {
669
+ secretsStore.set(visionProviderId, { type: 'api_key', key: visionApiKey! })
670
+ }
599
671
  }
600
672
 
601
673
  const channelTokens: Record<string, Record<string, Secret>> = {}
@@ -630,6 +702,70 @@ export async function readExistingProviderApiKey(root: string, providerId: Known
630
702
  return new SecretsBackend(join(root, 'secrets.json')).tryReadProviderApiKeySync(providerId)
631
703
  }
632
704
 
705
+ // Detects whether the requested channel already has usable credentials in
706
+ // `secrets.json#channels`, so the init wizard can offer to reuse them
707
+ // instead of re-prompting for tokens. Mirrors `readExistingProviderApiKey`:
708
+ // returns `true` only when ALL fields the adapter needs are present in a
709
+ // shape `hydrateChannelEnvFromSecrets` would inject at runtime — both the
710
+ // `{ value }` form and the `{ env }` env-binding form count, matching the
711
+ // runtime resolution rules in `src/secrets/resolve.ts`. Partial slots (e.g.
712
+ // `slack-bot` with `botToken` but no `appToken`) return `false` so the
713
+ // missing field gets filled in by the normal prompt.
714
+ //
715
+ // KakaoTalk reuse is stricter: a usable block requires both a complete
716
+ // account (currentAccount + matching entry in accounts) AND the renewal
717
+ // fields (email + encryptedPassword) the hostd renewal cron needs to mint
718
+ // fresh tokens unattended (`src/secrets/kakao-renewal.ts`). Without those,
719
+ // the saved `oauth_token` will work only until KakaoTalk's ~7-day TTL
720
+ // expires, after which the user has to run `typeclaw channel reauth
721
+ // kakaotalk` anyway — better to re-auth now during init.
722
+ export async function hasExistingChannelSecrets(
723
+ root: string,
724
+ channel: 'discord' | 'slack' | 'telegram' | 'kakaotalk',
725
+ ): Promise<boolean> {
726
+ const channels = new SecretsBackend(join(root, 'secrets.json')).tryReadChannelsSync()
727
+ if (channels === null) return false
728
+ switch (channel) {
729
+ case 'discord':
730
+ return hasSecretField(channels['discord-bot'], 'token')
731
+ case 'slack':
732
+ return hasSecretField(channels['slack-bot'], 'botToken') && hasSecretField(channels['slack-bot'], 'appToken')
733
+ case 'telegram':
734
+ return hasSecretField(channels['telegram-bot'], 'token')
735
+ case 'kakaotalk': {
736
+ const block = channels.kakaotalk
737
+ if (!isObjectRecord(block)) return false
738
+ const current = (block as { currentAccount?: unknown }).currentAccount
739
+ if (typeof current !== 'string' || current.length === 0) return false
740
+ const accounts = (block as { accounts?: unknown }).accounts
741
+ if (!isObjectRecord(accounts)) return false
742
+ const account = accounts[current]
743
+ if (!isObjectRecord(account)) return false
744
+ const email = (account as { email?: unknown }).email
745
+ const encryptedPassword = (account as { encryptedPassword?: unknown }).encryptedPassword
746
+ return typeof email === 'string' && email.length > 0 && isObjectRecord(encryptedPassword)
747
+ }
748
+ }
749
+ }
750
+
751
+ // Accepts either the `{ value }` form (resolves to a literal at runtime) or
752
+ // the `{ env }` form (resolves at runtime by reading `process.env[<env>]`).
753
+ // String shorthand is sugar for `{ value }`. The schema already rejects
754
+ // empty strings via `z.string().min(1)`, so the length checks here are
755
+ // defense-in-depth against forward-compat shape drift.
756
+ function hasSecretField(slot: unknown, field: string): boolean {
757
+ if (!isObjectRecord(slot)) return false
758
+ const secret = slot[field]
759
+ if (typeof secret === 'string') return secret.length > 0
760
+ if (isObjectRecord(secret)) {
761
+ const value = (secret as { value?: unknown }).value
762
+ if (typeof value === 'string' && value.length > 0) return true
763
+ const env = (secret as { env?: unknown }).env
764
+ if (typeof env === 'string' && env.length > 0) return true
765
+ }
766
+ return false
767
+ }
768
+
633
769
  function isObjectRecord(value: unknown): value is Record<string, unknown> {
634
770
  return typeof value === 'object' && value !== null && !Array.isArray(value)
635
771
  }
@@ -689,7 +825,6 @@ export type AddChannelStepEvent =
689
825
  // from prompts; tests build them inline.
690
826
  export type AddChannelOptions = {
691
827
  cwd: string
692
- allowAll?: boolean
693
828
  onProgress?: (event: AddChannelStepEvent) => void
694
829
  } & (
695
830
  | { channel: 'discord-bot'; discordBotToken: string }
@@ -717,7 +852,7 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
717
852
  }
718
853
 
719
854
  emit({ step: 'config', phase: 'start' })
720
- await mergeChannelIntoConfig(options.cwd, options.channel, options.allowAll ?? defaultAllowAll(options.channel))
855
+ await mergeChannelIntoConfig(options.cwd, options.channel)
721
856
  emit({ step: 'config', phase: 'done' })
722
857
 
723
858
  emit({ step: 'secrets', phase: 'start' })
@@ -726,14 +861,13 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
726
861
  await appendChannelSecrets(options.cwd, options.channel, tokens)
727
862
  }
728
863
  emit({ step: 'secrets', phase: 'done' })
729
- }
730
864
 
731
- // `channel add` mirrors `runInit`'s allow defaults: workspace-scoped adapters
732
- // (discord/slack/telegram) default to `*` because the bot only sees what the
733
- // operator invited it into, while KakaoTalk uses a personal account and
734
- // defaults to DMs only.
735
- function defaultAllowAll(channel: ChannelKind): boolean {
736
- return channel !== 'kakaotalk'
865
+ // Commit the typeclaw.json change so the agent folder isn't silently
866
+ // dirty after `typeclaw channel add`. Same `commitSystemFile` contract as
867
+ // every other host-side rewrite: no-op outside a git repo, when Bun is
868
+ // unavailable, or when the file is clean. secrets.json is gitignored, so
869
+ // only typeclaw.json is named here.
870
+ await commitSystemFile(options.cwd, CONFIG_FILE, `channel: add ${options.channel}`)
737
871
  }
738
872
 
739
873
  function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
@@ -774,7 +908,7 @@ export async function readConfiguredChannels(cwd: string): Promise<Set<ChannelKi
774
908
  return present
775
909
  }
776
910
 
777
- async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind, allowAll: boolean): Promise<void> {
911
+ async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind): Promise<void> {
778
912
  const path = join(cwd, CONFIG_FILE)
779
913
  let parsed: Record<string, unknown>
780
914
  try {
@@ -798,23 +932,18 @@ async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind, allowAl
798
932
  // Defense in depth — the CLI already filters configured channels out of
799
933
  // the picker and rejects them as the positional arg. Hitting this branch
800
934
  // means a programmatic caller passed a duplicate; better to fail loudly
801
- // than silently overwrite the user's existing allow list.
935
+ // than silently overwrite the user's existing config.
802
936
  throw new Error(`Channel "${channel}" is already configured in ${CONFIG_FILE}.`)
803
937
  }
804
938
 
805
939
  parsed.channels = {
806
940
  ...existingChannels,
807
- [channel]: { allow: buildAllow(channel, allowAll) },
941
+ [channel]: {},
808
942
  }
809
943
 
810
944
  await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
811
945
  }
812
946
 
813
- function buildAllow(channel: ChannelKind, allowAll: boolean): string[] {
814
- if (channel === 'kakaotalk') return allowAll ? ['kakao:*'] : ['kakao:dm/*']
815
- return allowAll ? ['*'] : []
816
- }
817
-
818
947
  // Writes per-adapter field values into `secrets.json#channels.<adapter>`.
819
948
  // Refuses to overwrite existing fields: if the user already has e.g.
820
949
  // `botToken` recorded (from a prior `channel add` whose follow-up steps
@@ -1,7 +1,11 @@
1
1
  import { createRequire } from 'node:module'
2
- import { join } from 'node:path'
2
+ import { join, resolve } from 'node:path'
3
3
 
4
+ import { containerNameFromCwd } from '@/container'
5
+ import { keysDir } from '@/hostd/paths'
6
+ import { encrypt } from '@/secrets/encryption'
4
7
  import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
8
+ import { createKeyStore, type KeyStore } from '@/secrets/keys'
5
9
 
6
10
  export type KakaotalkBootstrapStatus = { ok: true } | { ok: false; reason: string }
7
11
 
@@ -15,6 +19,13 @@ export type KakaotalkLoginInput = {
15
19
  agentDir: string
16
20
  callbacks: KakaotalkLoginCallbacks
17
21
  loginFlow?: LoginFlowFn
22
+ // Test seam: inject a custom keystore (typically pointing at a tmpdir).
23
+ // Production uses ~/.typeclaw/keys/<containerName>.key.
24
+ keyStore?: KeyStore
25
+ // Test seam: override the containerName used to bind the encrypted
26
+ // password's AAD. Production derives it from basename(agentDir) via
27
+ // containerNameFromCwd to match what `typeclaw start` registers with hostd.
28
+ containerName?: string
18
29
  }
19
30
 
20
31
  export type LoginFlowOptions = {
@@ -82,6 +93,10 @@ export async function runKakaotalkBootstrap(input: KakaotalkLoginInput): Promise
82
93
 
83
94
  const now = new Date().toISOString()
84
95
  const accountId = result.credentials.user_id || 'default'
96
+ const containerName = input.containerName ?? containerNameFromCwd(resolve(input.agentDir))
97
+ const keyStore = input.keyStore ?? createKeyStore({ keysDir: keysDir() })
98
+ const key = await keyStore.ensure(containerName)
99
+ const encryptedPassword = encrypt(input.password, key, { containerName, accountId })
85
100
  await credManager.setAccount({
86
101
  account_id: accountId,
87
102
  oauth_token: result.credentials.access_token,
@@ -92,6 +107,8 @@ export async function runKakaotalkBootstrap(input: KakaotalkLoginInput): Promise
92
107
  auth_method: 'login',
93
108
  created_at: now,
94
109
  updated_at: now,
110
+ email: input.email,
111
+ encryptedPassword,
95
112
  })
96
113
  await credManager.setCurrentAccount(accountId)
97
114
  await credManager.clearPendingLogin()
@@ -14,6 +14,11 @@ const PROVIDER_TO_MODELS_DEV: Record<KnownProviderId, string> = {
14
14
  // entries are surfaced regardless of upstream membership.
15
15
  'openai-codex': 'openai',
16
16
  fireworks: 'fireworks-ai',
17
+ zai: 'zai',
18
+ // zai-coding (GLM Coding Plan) is a billing surface, not a separate model
19
+ // catalog. models.dev tracks the underlying model metadata under `zai`,
20
+ // so we route lookups there. The curated entries still get surfaced.
21
+ 'zai-coding': 'zai',
17
22
  }
18
23
 
19
24
  export type ModelOption = {
@@ -25,6 +30,13 @@ export type ModelOption = {
25
30
  reasoning: boolean
26
31
  contextWindow: number | null
27
32
  curated: boolean
33
+ // True iff the model accepts image input. Sourced from the curated
34
+ // `Model.input` array (which is the source of truth — pi-ai consumes it
35
+ // directly) with a fallback to models.dev's `modalities.input` when the
36
+ // curated entry omits the field. The init wizard uses this to decide
37
+ // whether to prompt for a separate `vision` profile after the user picks
38
+ // a text-only `default` model.
39
+ supportsVision: boolean
28
40
  }
29
41
 
30
42
  type ModelsDevModel = {
@@ -115,7 +127,10 @@ function buildOption(ref: KnownModelRef, opts: BuildOptionOpts): ModelOption {
115
127
  const modelId = ref.slice(slash + 1)
116
128
  const provider = KNOWN_PROVIDERS[providerId]
117
129
  const curatedModel = (
118
- provider.models as Record<string, { name: string; contextWindow?: number; reasoning?: boolean }>
130
+ provider.models as Record<
131
+ string,
132
+ { name: string; contextWindow?: number; reasoning?: boolean; input?: ReadonlyArray<string> }
133
+ >
119
134
  )[modelId]
120
135
  return {
121
136
  ref,
@@ -126,5 +141,15 @@ function buildOption(ref: KnownModelRef, opts: BuildOptionOpts): ModelOption {
126
141
  reasoning: opts.upstream?.reasoning ?? curatedModel?.reasoning ?? false,
127
142
  contextWindow: opts.upstream?.limit?.context ?? curatedModel?.contextWindow ?? null,
128
143
  curated: opts.curated,
144
+ supportsVision: resolveSupportsVision(curatedModel?.input, opts.upstream?.modalities?.input),
129
145
  }
130
146
  }
147
+
148
+ function resolveSupportsVision(
149
+ curatedInput: ReadonlyArray<string> | undefined,
150
+ upstreamInput: ReadonlyArray<string> | undefined,
151
+ ): boolean {
152
+ if (curatedInput !== undefined) return curatedInput.includes('image')
153
+ if (upstreamInput !== undefined) return upstreamInput.includes('image')
154
+ return false
155
+ }
@@ -0,0 +1,77 @@
1
+ import { confirm, isCancel, log, note, spinner } from '@clack/prompts'
2
+
3
+ import { c } from '@/cli/ui'
4
+ import { runClaimSession } from '@/role-claim'
5
+
6
+ import type { ChannelKind } from './index'
7
+
8
+ const CHANNEL_LABELS: Record<ChannelKind, string> = {
9
+ 'slack-bot': 'Slack',
10
+ 'discord-bot': 'Discord',
11
+ 'telegram-bot': 'Telegram',
12
+ kakaotalk: 'KakaoTalk',
13
+ }
14
+
15
+ const DEFAULT_TTL_MS = 10 * 60 * 1000
16
+
17
+ export type RunOwnerClaimOptions = {
18
+ url: string
19
+ configuredChannels: readonly ChannelKind[]
20
+ }
21
+
22
+ // Drives the post-hatching claim flow: ask the operator whether to pair now,
23
+ // run the claim handshake against the running container, print the result.
24
+ // Aborts (kind: 'cancel' or a clack cancel) drop straight back into the
25
+ // normal hatching path so the TUI still opens — the operator can run
26
+ // `typeclaw role claim` later.
27
+ export async function runOwnerClaim({ url, configuredChannels }: RunOwnerClaimOptions): Promise<void> {
28
+ if (configuredChannels.length === 0) return
29
+
30
+ const channelList = configuredChannels.map((c) => CHANNEL_LABELS[c] ?? c).join(', ')
31
+
32
+ const proceed = await confirm({
33
+ message: `Claim owner role on ${channelList} now?`,
34
+ initialValue: true,
35
+ })
36
+ if (isCancel(proceed) || proceed === false) {
37
+ log.info(`Skipping. Run ${c.bold('typeclaw role claim')} later when you're ready.`)
38
+ return
39
+ }
40
+
41
+ const s = spinner()
42
+ s.start('Generating your claim code...')
43
+
44
+ const result = await runClaimSession({
45
+ url,
46
+ role: 'owner',
47
+ ttlMs: DEFAULT_TTL_MS,
48
+ onStarted: (payload) => {
49
+ const expiresInMin = Math.max(1, Math.round((payload.expiresAt - Date.now()) / 60_000))
50
+ s.stop('Code ready.')
51
+ note(
52
+ [
53
+ `Open ${channelList} and DM your bot with this code:`,
54
+ '',
55
+ ` ${c.bold(payload.code)}`,
56
+ '',
57
+ `(expires in ~${expiresInMin}m)`,
58
+ ].join('\n'),
59
+ 'Claim your owner role',
60
+ )
61
+ s.start('Waiting for your DM...')
62
+ },
63
+ })
64
+
65
+ if (result.kind === 'completed') {
66
+ s.stop(c.green(`Paired as owner.`))
67
+ log.info(`Match rule added to typeclaw.json#roles.owner.match: ${c.bold(result.payload.matchRule)}`)
68
+ return
69
+ }
70
+ if (result.kind === 'error') {
71
+ s.stop(c.red(`Claim failed: ${result.payload.reason}`))
72
+ log.info(`You can retry with ${c.bold('typeclaw role claim')} anytime.`)
73
+ return
74
+ }
75
+ s.stop(c.yellow(`Claim timed out — no DM received within the window.`))
76
+ log.info(`Run ${c.bold('typeclaw role claim')} when you're ready.`)
77
+ }