typeclaw 0.1.5 → 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 (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 +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 +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 +183 -62
  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
@@ -81,7 +81,15 @@ export type InitStepEvent =
81
81
  // portbroker — same path `typeclaw start` takes. When omitted (test fixtures,
82
82
  // programmatic callers that never want a daemon), `start()` skips the daemon
83
83
  // path entirely and the container runs unmanaged.
84
- export type HatchRunner = (options: { cwd: string; port: number; cliEntry?: string }) => Promise<HatchingResult>
84
+ export type HatchRunner = (options: {
85
+ cwd: string
86
+ port: number
87
+ cliEntry?: string
88
+ // Set when the wizard wired at least one channel adapter, so the runner
89
+ // can offer to run `typeclaw role claim` after the container is ready.
90
+ // Empty / undefined means "no channels — skip the claim flow".
91
+ configuredChannels?: readonly ChannelKind[]
92
+ }) => Promise<HatchingResult>
85
93
 
86
94
  export type KakaotalkAuthRunner = (options: { cwd: string }) => Promise<KakaotalkAuthResult>
87
95
 
@@ -101,16 +109,27 @@ export type InitOptions = {
101
109
  // defaults to the api-key path with `apiKey` (legacy field, still
102
110
  // supported for backwards compat with the old `runInit` signature).
103
111
  llmAuth?: LLMAuth
112
+ // Optional second model + auth, written as `models.vision` when the
113
+ // default model is text-only. Auth is reused from the default path
114
+ // when both refer to the same provider; the wizard enforces this
115
+ // pairing rule, so by the time we get here `visionAuth` is either
116
+ // (a) absent, or (b) the right auth for `visionModel`'s provider.
117
+ visionModel?: KnownModelRef
118
+ visionAuth?: LLMAuth
104
119
  apiKey?: string
105
120
  discordBotToken?: string
106
- discordAllowAll?: boolean
107
121
  slackBotToken?: string
108
122
  slackAppToken?: string
109
- slackAllowAll?: boolean
110
123
  telegramBotToken?: string
111
- telegramAllowAll?: boolean
124
+ // When reusing existing channel credentials from a pre-init `secrets.json`,
125
+ // the CLI passes `with<Adapter>: true` without a corresponding token so the
126
+ // scaffolded `typeclaw.json` wires the adapter while `writeSecrets` leaves
127
+ // the existing slot in `secrets.json#channels` untouched. Defaults below
128
+ // mirror the legacy derivation (`<token> !== undefined && !== ''`).
129
+ withDiscord?: boolean
130
+ withSlack?: boolean
131
+ withTelegram?: boolean
112
132
  withKakaotalk?: boolean
113
- kakaotalkAllowAll?: boolean
114
133
  runKakaotalkAuth?: KakaotalkAuthRunner
115
134
  onProgress?: (event: InitStepEvent) => void
116
135
  runHatching?: HatchRunner
@@ -129,15 +148,16 @@ export async function runInit({
129
148
  apiKey,
130
149
  llmAuth,
131
150
  model = DEFAULT_MODEL_REF,
151
+ visionModel,
152
+ visionAuth,
132
153
  discordBotToken,
133
- discordAllowAll = true,
134
154
  slackBotToken,
135
155
  slackAppToken,
136
- slackAllowAll = true,
137
156
  telegramBotToken,
138
- telegramAllowAll = true,
157
+ withDiscord,
158
+ withSlack,
159
+ withTelegram,
139
160
  withKakaotalk = false,
140
- kakaotalkAllowAll = false,
141
161
  runKakaotalkAuth,
142
162
  onProgress,
143
163
  runHatching = defaultRunHatching,
@@ -180,20 +200,36 @@ export async function runInit({
180
200
  }
181
201
  }
182
202
 
183
- const wantsDiscord = discordBotToken !== undefined && discordBotToken !== ''
184
- const wantsSlack = slackBotToken !== undefined && slackBotToken !== ''
185
- const wantsTelegram = telegramBotToken !== undefined && telegramBotToken !== ''
203
+ // When the vision profile uses a different provider than the default, its
204
+ // OAuth login runs here too, before any file write. Same-provider vision
205
+ // reuses the default auth (no separate login). API-key vision auth is
206
+ // captured in memory and persisted by writeSecrets() below.
207
+ if (
208
+ visionAuth !== undefined &&
209
+ visionAuth.kind === 'oauth' &&
210
+ visionModel !== undefined &&
211
+ providerForModelRef(visionModel) !== providerForModelRef(model)
212
+ ) {
213
+ emit({ step: 'oauth-login', phase: 'start' })
214
+ await mkdir(cwd, { recursive: true })
215
+ const result = await visionAuth.runLogin({ cwd, model: visionModel })
216
+ emit({ step: 'oauth-login', phase: 'done', result })
217
+ if (!result.ok) {
218
+ throw new Error(`OAuth login failed: ${result.reason}`)
219
+ }
220
+ }
221
+
222
+ const wantsDiscord = withDiscord ?? (discordBotToken !== undefined && discordBotToken !== '')
223
+ const wantsSlack = withSlack ?? (slackBotToken !== undefined && slackBotToken !== '')
224
+ const wantsTelegram = withTelegram ?? (telegramBotToken !== undefined && telegramBotToken !== '')
186
225
  emit({ step: 'scaffold', phase: 'start' })
187
226
  await scaffold(cwd, {
188
227
  model,
228
+ ...(visionModel !== undefined ? { visionModel } : {}),
189
229
  withDiscord: wantsDiscord,
190
- discordAllowAll,
191
230
  withSlack: wantsSlack,
192
- slackAllowAll,
193
231
  withTelegram: wantsTelegram,
194
- telegramAllowAll,
195
232
  withKakaotalk,
196
- kakaotalkAllowAll,
197
233
  })
198
234
  // Only write the LLM API key on the api-key path. OAuth providers persist
199
235
  // their credentials to secrets.json (via the OAuth login step above); writing
@@ -201,6 +237,9 @@ export async function runInit({
201
237
  await writeSecrets(cwd, {
202
238
  model,
203
239
  apiKey: resolvedAuth.kind === 'api-key' ? resolvedAuth.apiKey : undefined,
240
+ ...(visionModel !== undefined && visionAuth?.kind === 'api-key'
241
+ ? { visionModel, visionApiKey: visionAuth.apiKey }
242
+ : {}),
204
243
  discordBotToken,
205
244
  slackBotToken,
206
245
  slackAppToken,
@@ -235,8 +274,19 @@ export async function runInit({
235
274
  const git = await initGitRepo(cwd)
236
275
  emit({ step: 'git', phase: 'done', result: git })
237
276
 
277
+ const configuredChannels: ChannelKind[] = []
278
+ if (wantsDiscord) configuredChannels.push('discord-bot')
279
+ if (wantsSlack) configuredChannels.push('slack-bot')
280
+ if (wantsTelegram) configuredChannels.push('telegram-bot')
281
+ if (withKakaotalk) configuredChannels.push('kakaotalk')
282
+
238
283
  emit({ step: 'hatching', phase: 'start' })
239
- const hatching = await runHatching({ cwd, port: config.port, ...(cliEntry !== undefined ? { cliEntry } : {}) })
284
+ const hatching = await runHatching({
285
+ cwd,
286
+ port: config.port,
287
+ ...(cliEntry !== undefined ? { cliEntry } : {}),
288
+ ...(configuredChannels.length > 0 ? { configuredChannels } : {}),
289
+ })
240
290
  emit({ step: 'hatching', phase: 'done', result: hatching })
241
291
  }
242
292
 
@@ -250,16 +300,20 @@ export async function defaultRunHatching({
250
300
  cwd,
251
301
  port,
252
302
  cliEntry,
303
+ configuredChannels,
253
304
  startContainer = start,
254
305
  tui: tuiFactory = createTui,
255
306
  waitForAgent: waitForAgentFn = waitForAgent,
307
+ runClaim = defaultRunClaim,
256
308
  }: {
257
309
  cwd: string
258
310
  port: number
259
311
  cliEntry?: string
312
+ configuredChannels?: readonly ChannelKind[]
260
313
  startContainer?: typeof start
261
314
  tui?: typeof createTui
262
315
  waitForAgent?: typeof waitForAgent
316
+ runClaim?: ClaimRunner
263
317
  }): Promise<HatchingResult> {
264
318
  try {
265
319
  const launch = await startContainer({
@@ -276,6 +330,11 @@ export async function defaultRunHatching({
276
330
 
277
331
  await waitForAgentFn(`http://127.0.0.1:${hostPort}`, { timeoutMs: 30_000 })
278
332
 
333
+ if (configuredChannels !== undefined && configuredChannels.length > 0) {
334
+ const url = buildTuiUrl(hostPort, launch.tuiToken)
335
+ await runClaim({ url, configuredChannels })
336
+ }
337
+
279
338
  const tui = tuiFactory({
280
339
  url: buildTuiUrl(hostPort, launch.tuiToken),
281
340
  initialPrompt: HATCHING_PROMPT,
@@ -287,6 +346,13 @@ export async function defaultRunHatching({
287
346
  }
288
347
  }
289
348
 
349
+ export type ClaimRunner = (options: { url: string; configuredChannels: readonly ChannelKind[] }) => Promise<void>
350
+
351
+ const defaultRunClaim: ClaimRunner = async ({ url, configuredChannels }) => {
352
+ const { runOwnerClaim } = await import('./run-owner-claim')
353
+ await runOwnerClaim({ url, configuredChannels })
354
+ }
355
+
290
356
  function buildTuiUrl(hostPort: number, token: string | null): string {
291
357
  const url = new URL(`ws://127.0.0.1:${hostPort}`)
292
358
  if (token !== null) url.searchParams.set('token', token)
@@ -372,14 +438,11 @@ export async function isHatched(dir: string): Promise<boolean> {
372
438
 
373
439
  export type ScaffoldOptions = {
374
440
  model?: KnownModelRef
441
+ visionModel?: KnownModelRef
375
442
  withDiscord?: boolean
376
- discordAllowAll?: boolean
377
443
  withSlack?: boolean
378
- slackAllowAll?: boolean
379
444
  withTelegram?: boolean
380
- telegramAllowAll?: boolean
381
445
  withKakaotalk?: boolean
382
- kakaotalkAllowAll?: boolean
383
446
  }
384
447
 
385
448
  export async function scaffold(root: string, options: ScaffoldOptions = {}): Promise<void> {
@@ -392,30 +455,22 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
392
455
  // immediately populated, so packages/ is the only one that needs this.
393
456
  await writeFile(join(root, PACKAGES_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists)
394
457
 
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.
458
+ // Only fields without sensible defaults elsewhere are emitted. Everything
459
+ // with a schema-provided default (e.g. `network.blockInternal`, `mounts`,
460
+ // `memory.*`) is omitted to keep the scaffold minimal duplicating defaults
461
+ // here would mean every schema change has to be mirrored in two places, and
462
+ // users would feel obligated to maintain values they never set.
463
+ const models: Record<string, KnownModelRef> = { default: options.model ?? DEFAULT_MODEL_REF }
464
+ if (options.visionModel !== undefined) models.vision = options.visionModel
403
465
  const config: Record<string, unknown> = {
404
466
  $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/*'] }
467
+ models,
418
468
  }
469
+ const channels: Record<string, Record<string, never>> = {}
470
+ if (options.withDiscord) channels['discord-bot'] = {}
471
+ if (options.withSlack) channels['slack-bot'] = {}
472
+ if (options.withTelegram) channels['telegram-bot'] = {}
473
+ if (options.withKakaotalk) channels.kakaotalk = {}
419
474
  if (Object.keys(channels).length > 0) config.channels = channels
420
475
  await writeFile(join(root, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
421
476
 
@@ -578,6 +633,8 @@ export async function writeSecrets(
578
633
  {
579
634
  model = DEFAULT_MODEL_REF,
580
635
  apiKey,
636
+ visionModel,
637
+ visionApiKey,
581
638
  discordBotToken,
582
639
  slackBotToken,
583
640
  slackAppToken,
@@ -586,6 +643,8 @@ export async function writeSecrets(
586
643
  model?: KnownModelRef
587
644
  // Omitted on the OAuth path — credentials live in secrets.json via the OAuth runner.
588
645
  apiKey?: string
646
+ visionModel?: KnownModelRef
647
+ visionApiKey?: string
589
648
  discordBotToken?: string
590
649
  slackBotToken?: string
591
650
  slackAppToken?: string
@@ -594,8 +653,20 @@ export async function writeSecrets(
594
653
  ): Promise<void> {
595
654
  const providerId = providerForModelRef(model)
596
655
  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 })
656
+ const wantsDefaultKey = apiKey !== undefined && apiKeyEnv !== null
657
+ const visionProviderId = visionModel !== undefined ? providerForModelRef(visionModel) : null
658
+ const wantsVisionKey =
659
+ visionModel !== undefined &&
660
+ visionApiKey !== undefined &&
661
+ visionProviderId !== providerId &&
662
+ visionProviderId !== null &&
663
+ KNOWN_PROVIDERS[visionProviderId].apiKeyEnv !== null
664
+ if (wantsDefaultKey || wantsVisionKey) {
665
+ const secretsStore = createSecretsStoreForAgent(join(root, 'secrets.json'))
666
+ if (wantsDefaultKey) secretsStore.set(providerId, { type: 'api_key', key: apiKey! })
667
+ if (wantsVisionKey) {
668
+ secretsStore.set(visionProviderId, { type: 'api_key', key: visionApiKey! })
669
+ }
599
670
  }
600
671
 
601
672
  const channelTokens: Record<string, Record<string, Secret>> = {}
@@ -630,6 +701,70 @@ export async function readExistingProviderApiKey(root: string, providerId: Known
630
701
  return new SecretsBackend(join(root, 'secrets.json')).tryReadProviderApiKeySync(providerId)
631
702
  }
632
703
 
704
+ // Detects whether the requested channel already has usable credentials in
705
+ // `secrets.json#channels`, so the init wizard can offer to reuse them
706
+ // instead of re-prompting for tokens. Mirrors `readExistingProviderApiKey`:
707
+ // returns `true` only when ALL fields the adapter needs are present in a
708
+ // shape `hydrateChannelEnvFromSecrets` would inject at runtime — both the
709
+ // `{ value }` form and the `{ env }` env-binding form count, matching the
710
+ // runtime resolution rules in `src/secrets/resolve.ts`. Partial slots (e.g.
711
+ // `slack-bot` with `botToken` but no `appToken`) return `false` so the
712
+ // missing field gets filled in by the normal prompt.
713
+ //
714
+ // KakaoTalk reuse is stricter: a usable block requires both a complete
715
+ // account (currentAccount + matching entry in accounts) AND the renewal
716
+ // fields (email + encryptedPassword) the hostd renewal cron needs to mint
717
+ // fresh tokens unattended (`src/secrets/kakao-renewal.ts`). Without those,
718
+ // the saved `oauth_token` will work only until KakaoTalk's ~7-day TTL
719
+ // expires, after which the user has to run `typeclaw channel reauth
720
+ // kakaotalk` anyway — better to re-auth now during init.
721
+ export async function hasExistingChannelSecrets(
722
+ root: string,
723
+ channel: 'discord' | 'slack' | 'telegram' | 'kakaotalk',
724
+ ): Promise<boolean> {
725
+ const channels = new SecretsBackend(join(root, 'secrets.json')).tryReadChannelsSync()
726
+ if (channels === null) return false
727
+ switch (channel) {
728
+ case 'discord':
729
+ return hasSecretField(channels['discord-bot'], 'token')
730
+ case 'slack':
731
+ return hasSecretField(channels['slack-bot'], 'botToken') && hasSecretField(channels['slack-bot'], 'appToken')
732
+ case 'telegram':
733
+ return hasSecretField(channels['telegram-bot'], 'token')
734
+ case 'kakaotalk': {
735
+ const block = channels.kakaotalk
736
+ if (!isObjectRecord(block)) return false
737
+ const current = (block as { currentAccount?: unknown }).currentAccount
738
+ if (typeof current !== 'string' || current.length === 0) return false
739
+ const accounts = (block as { accounts?: unknown }).accounts
740
+ if (!isObjectRecord(accounts)) return false
741
+ const account = accounts[current]
742
+ if (!isObjectRecord(account)) return false
743
+ const email = (account as { email?: unknown }).email
744
+ const encryptedPassword = (account as { encryptedPassword?: unknown }).encryptedPassword
745
+ return typeof email === 'string' && email.length > 0 && isObjectRecord(encryptedPassword)
746
+ }
747
+ }
748
+ }
749
+
750
+ // Accepts either the `{ value }` form (resolves to a literal at runtime) or
751
+ // the `{ env }` form (resolves at runtime by reading `process.env[<env>]`).
752
+ // String shorthand is sugar for `{ value }`. The schema already rejects
753
+ // empty strings via `z.string().min(1)`, so the length checks here are
754
+ // defense-in-depth against forward-compat shape drift.
755
+ function hasSecretField(slot: unknown, field: string): boolean {
756
+ if (!isObjectRecord(slot)) return false
757
+ const secret = slot[field]
758
+ if (typeof secret === 'string') return secret.length > 0
759
+ if (isObjectRecord(secret)) {
760
+ const value = (secret as { value?: unknown }).value
761
+ if (typeof value === 'string' && value.length > 0) return true
762
+ const env = (secret as { env?: unknown }).env
763
+ if (typeof env === 'string' && env.length > 0) return true
764
+ }
765
+ return false
766
+ }
767
+
633
768
  function isObjectRecord(value: unknown): value is Record<string, unknown> {
634
769
  return typeof value === 'object' && value !== null && !Array.isArray(value)
635
770
  }
@@ -689,7 +824,6 @@ export type AddChannelStepEvent =
689
824
  // from prompts; tests build them inline.
690
825
  export type AddChannelOptions = {
691
826
  cwd: string
692
- allowAll?: boolean
693
827
  onProgress?: (event: AddChannelStepEvent) => void
694
828
  } & (
695
829
  | { channel: 'discord-bot'; discordBotToken: string }
@@ -717,7 +851,7 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
717
851
  }
718
852
 
719
853
  emit({ step: 'config', phase: 'start' })
720
- await mergeChannelIntoConfig(options.cwd, options.channel, options.allowAll ?? defaultAllowAll(options.channel))
854
+ await mergeChannelIntoConfig(options.cwd, options.channel)
721
855
  emit({ step: 'config', phase: 'done' })
722
856
 
723
857
  emit({ step: 'secrets', phase: 'start' })
@@ -728,14 +862,6 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
728
862
  emit({ step: 'secrets', phase: 'done' })
729
863
  }
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'
737
- }
738
-
739
865
  function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
740
866
  switch (options.channel) {
741
867
  case 'discord-bot':
@@ -774,7 +900,7 @@ export async function readConfiguredChannels(cwd: string): Promise<Set<ChannelKi
774
900
  return present
775
901
  }
776
902
 
777
- async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind, allowAll: boolean): Promise<void> {
903
+ async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind): Promise<void> {
778
904
  const path = join(cwd, CONFIG_FILE)
779
905
  let parsed: Record<string, unknown>
780
906
  try {
@@ -798,23 +924,18 @@ async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind, allowAl
798
924
  // Defense in depth — the CLI already filters configured channels out of
799
925
  // the picker and rejects them as the positional arg. Hitting this branch
800
926
  // means a programmatic caller passed a duplicate; better to fail loudly
801
- // than silently overwrite the user's existing allow list.
927
+ // than silently overwrite the user's existing config.
802
928
  throw new Error(`Channel "${channel}" is already configured in ${CONFIG_FILE}.`)
803
929
  }
804
930
 
805
931
  parsed.channels = {
806
932
  ...existingChannels,
807
- [channel]: { allow: buildAllow(channel, allowAll) },
933
+ [channel]: {},
808
934
  }
809
935
 
810
936
  await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
811
937
  }
812
938
 
813
- function buildAllow(channel: ChannelKind, allowAll: boolean): string[] {
814
- if (channel === 'kakaotalk') return allowAll ? ['kakao:*'] : ['kakao:dm/*']
815
- return allowAll ? ['*'] : []
816
- }
817
-
818
939
  // Writes per-adapter field values into `secrets.json#channels.<adapter>`.
819
940
  // Refuses to overwrite existing fields: if the user already has e.g.
820
941
  // `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
+ }
@@ -0,0 +1,70 @@
1
+ import type { MatchRule } from './match-rule'
2
+
3
+ export type BuiltinRoleName = 'owner' | 'trusted' | 'member' | 'guest'
4
+
5
+ export const BUILTIN_ROLE_NAMES: readonly BuiltinRoleName[] = ['owner', 'trusted', 'member', 'guest']
6
+
7
+ // Core-owned permission strings; not contributed by plugins. The security
8
+ // plugin's `security.bypass.*` strings are NOT listed here — they are
9
+ // collected from plugin contributions and merged into `owner`'s permission
10
+ // set at boot via expandOwnerWildcard.
11
+ export const CORE_PERMISSIONS = {
12
+ channelRespond: 'channel.respond',
13
+ cronSchedule: 'cron.schedule',
14
+ cronModify: 'cron.modify',
15
+ } as const
16
+
17
+ // Sentinel that `expandOwnerWildcard` swaps for the concrete union of
18
+ // plugin-registered `security.bypass.*` strings. Users cannot write `*` in
19
+ // their own `permissions[]`; the sentinel exists only inside the built-in
20
+ // `owner` spec.
21
+ export const OWNER_SECURITY_WILDCARD = '__BUILTIN_OWNER_SECURITY_WILDCARD__'
22
+
23
+ export type BuiltinRoleSpec = {
24
+ readonly match: readonly MatchRule[]
25
+ readonly permissions: readonly string[]
26
+ }
27
+
28
+ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> = {
29
+ owner: {
30
+ match: [{ kind: 'tui' }],
31
+ permissions: [
32
+ CORE_PERMISSIONS.channelRespond,
33
+ CORE_PERMISSIONS.cronSchedule,
34
+ CORE_PERMISSIONS.cronModify,
35
+ OWNER_SECURITY_WILDCARD,
36
+ ],
37
+ },
38
+ trusted: {
39
+ match: [],
40
+ permissions: [CORE_PERMISSIONS.channelRespond, CORE_PERMISSIONS.cronSchedule, 'security.bypass.secretExfilBash'],
41
+ },
42
+ member: {
43
+ match: [],
44
+ permissions: [CORE_PERMISSIONS.channelRespond],
45
+ },
46
+ guest: {
47
+ match: [],
48
+ permissions: [],
49
+ },
50
+ }
51
+
52
+ export function expandOwnerWildcard(
53
+ ownerPermissions: readonly string[],
54
+ pluginContributed: readonly string[],
55
+ ): readonly string[] {
56
+ const bypass = pluginContributed.filter((p) => p.startsWith('security.bypass.'))
57
+ const out: string[] = []
58
+ for (const p of ownerPermissions) {
59
+ if (p === OWNER_SECURITY_WILDCARD) {
60
+ for (const b of bypass) if (!out.includes(b)) out.push(b)
61
+ continue
62
+ }
63
+ if (!out.includes(p)) out.push(p)
64
+ }
65
+ return out
66
+ }
67
+
68
+ export function isBuiltinRoleName(name: string): name is BuiltinRoleName {
69
+ return (BUILTIN_ROLE_NAMES as readonly string[]).includes(name)
70
+ }