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
@@ -1,4 +1,4 @@
1
- import { accessSync, constants as fsConstants, readFileSync, statSync } from 'node:fs'
1
+ import { accessSync, constants as fsConstants, readFileSync, statSync, writeFileSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
3
  import { isAbsolute, join, resolve } from 'node:path'
4
4
 
@@ -6,6 +6,8 @@ import type { Model } from '@mariozechner/pi-ai'
6
6
  import { z } from 'zod'
7
7
 
8
8
  import { channelsSchema } from '@/channels/schema'
9
+ import { commitSystemFileSync } from '@/git/system-commit'
10
+ import { rolesConfigSchema } from '@/permissions/schema'
9
11
 
10
12
  import {
11
13
  DEFAULT_MODEL_REF,
@@ -75,7 +77,7 @@ const dockerfileFeatureSchema = z.union([
75
77
  ])
76
78
 
77
79
  // `default(() => ({}))` paired with field-level defaults is the idiom that
78
- // makes both `dockerfile: {}` and an omitted `dockerfile` key resolve to the
80
+ // makes both `docker.file: {}` and an omitted `docker.file` key resolve to the
79
81
  // SAME fully-populated object. A plain `.default({})` would short-circuit the
80
82
  // inner field defaults when the key is omitted, leaving downstream code with
81
83
  // `{ append: undefined, tmux: undefined, ... }` and a `lines.length` crash.
@@ -92,17 +94,42 @@ export const dockerfileSchema = dockerfileObjectSchema.default(() => dockerfileO
92
94
  export type DockerfileConfig = z.infer<typeof dockerfileSchema>
93
95
  export type DockerfileFeatureToggle = z.infer<typeof dockerfileFeatureSchema>
94
96
 
97
+ // The `docker` namespace nests Docker-related blocks under one top-level key
98
+ // so future extensions (e.g. `docker.compose`, `docker.buildArgs`) have a home
99
+ // without polluting the root. Today the only inhabitant is `docker.file`,
100
+ // which holds the same shape that used to live at top-level `dockerfile`.
101
+ // One-time migration (see `migrateLegacyConfigShape`) rewrites the old
102
+ // top-level key into the new path on first load.
103
+ export const dockerSchema = z
104
+ .object({
105
+ file: dockerfileSchema,
106
+ })
107
+ .default(() => ({ file: dockerfileObjectSchema.parse({}) }))
108
+
109
+ export type DockerConfig = z.infer<typeof dockerSchema>
110
+
95
111
  const gitignoreLineSchema = z.string().refine((line) => !/[\r\n]/.test(line), {
96
- message: 'gitignore.append entries must be single gitignore lines; split multiline patterns into array entries',
112
+ message: 'git.ignore.append entries must be single gitignore lines; split multiline patterns into array entries',
113
+ })
114
+
115
+ const gitignoreObjectSchema = z.object({
116
+ append: z.array(gitignoreLineSchema).default([]),
97
117
  })
98
118
 
99
- export const gitignoreSchema = z
119
+ export const gitignoreSchema = gitignoreObjectSchema.default(() => gitignoreObjectSchema.parse({}))
120
+
121
+ export type GitignoreConfig = z.infer<typeof gitignoreSchema>
122
+
123
+ // Same rationale as `dockerSchema`: a `git` namespace today carries `git.ignore`
124
+ // and leaves room for future siblings (e.g. `git.attributes`). The one-time
125
+ // migration also handles the rename of legacy top-level `gitignore`.
126
+ export const gitSchema = z
100
127
  .object({
101
- append: z.array(gitignoreLineSchema).default([]),
128
+ ignore: gitignoreSchema,
102
129
  })
103
- .default({ append: [] })
130
+ .default(() => ({ ignore: gitignoreObjectSchema.parse({}) }))
104
131
 
105
- export type GitignoreConfig = z.infer<typeof gitignoreSchema>
132
+ export type GitConfig = z.infer<typeof gitSchema>
106
133
 
107
134
  const IPV4_CIDR_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/
108
135
 
@@ -178,11 +205,41 @@ export const networkSchema = z
178
205
 
179
206
  export type NetworkConfig = z.infer<typeof networkSchema>
180
207
 
208
+ // `models` is a map from profile name to a single curated model ref. The
209
+ // `default` profile is mandatory; every other profile is optional and falls
210
+ // back to `default` at resolution time (see `resolveProfile`).
211
+ //
212
+ // Profile names are open strings; the runtime recognizes a handful of
213
+ // well-known names by convention (`default`, `fast`, `deep`, `vision`) but
214
+ // any string is valid. Subagents may declare a static profile preference;
215
+ // callers may override per-spawn. Unknown profile names resolve to `default`
216
+ // with a one-time warning at session construction.
217
+ //
218
+ // The pre-multi-model schema had a single `model: KnownModelRef` at the top
219
+ // level. `migrateLegacyConfigShape` rewrites that to `models: { default: ... }`
220
+ // on first load (and writes the result back to disk + commits via
221
+ // `persistMigratedConfig`), so every downstream consumer sees the new shape.
222
+ export const modelsSchema = z
223
+ .record(z.string().min(1), z.enum(knownModelRefs))
224
+ .refine((m) => 'default' in m, { message: 'models.default is required' })
225
+
226
+ // Zod's `z.record(..., refine)` doesn't refine the inferred type — the inferred
227
+ // shape is `Record<string, KnownModelRef>` where every access is `T | undefined`.
228
+ // The runtime guarantee (the `refine` above) is that `default` is present, so
229
+ // we narrow the type here. Every consumer (auth.ts, agent/index.ts,
230
+ // resolveProfile) reads `models.default` on the hot path; without this
231
+ // narrowing they all have to assert or `?? throw`, which is noise around an
232
+ // invariant the schema already enforces.
233
+ export type Models = Record<string, KnownModelRef> & { default: KnownModelRef }
234
+
181
235
  export const configSchema = z
182
236
  .object({
183
237
  $schema: z.string().optional(),
184
238
  port: z.number().int().min(1).max(65535).default(DEFAULT_PORT),
185
- model: z.enum(knownModelRefs).default(DEFAULT_MODEL_REF),
239
+ // `default(() => ...)` ensures every parsed config has at least
240
+ // `models.default`. Direct `.default({ default: ... })` would short-circuit
241
+ // the refinement, so we lean on the lazy thunk form.
242
+ models: modelsSchema.default(() => ({ default: DEFAULT_MODEL_REF })) as unknown as z.ZodType<Models>,
186
243
  // Defaults to `[]` so the field can be omitted from `typeclaw.json` (no
187
244
  // host paths exposed) without failing the whole config load. `typeclaw
188
245
  // init` omits this field so users don't see noise for the empty case.
@@ -198,8 +255,9 @@ export const configSchema = z
198
255
  channels: channelsSchema,
199
256
  portForward: portForwardSchema,
200
257
  network: networkSchema,
201
- dockerfile: dockerfileSchema,
202
- gitignore: gitignoreSchema,
258
+ docker: dockerSchema,
259
+ git: gitSchema,
260
+ roles: rolesConfigSchema.optional(),
203
261
  })
204
262
  .catchall(z.unknown())
205
263
 
@@ -213,6 +271,28 @@ export function resolveModel(ref: KnownModelRef): Model<'openai-completions'> |
213
271
  return KNOWN_PROVIDERS[providerId].models[modelId as never]
214
272
  }
215
273
 
274
+ // Resolves a profile name (e.g. `fast`, `deep`, `vision`) to a concrete model
275
+ // ref. Unknown profiles fall back to `default` so callers can pass through
276
+ // arbitrary subagent-declared or user-overridden strings without crashing.
277
+ // Returns the resolved ref plus whether it came from the requested profile or
278
+ // from the `default` fallback, so the caller can warn once per session
279
+ // instead of every prompt.
280
+ export type ResolvedProfile = {
281
+ ref: KnownModelRef
282
+ profile: string
283
+ fellBackToDefault: boolean
284
+ }
285
+
286
+ export function resolveProfile(models: Models, name: string | undefined): ResolvedProfile {
287
+ const requested = name ?? 'default'
288
+ const ref = models[requested]
289
+ if (ref !== undefined) {
290
+ return { ref, profile: requested, fellBackToDefault: false }
291
+ }
292
+ const fallback = models.default
293
+ return { ref: fallback, profile: 'default', fellBackToDefault: true }
294
+ }
295
+
216
296
  // Resolves a mount's `path` field to an absolute host path, mirroring shell
217
297
  // expansion rules: `~`/`~/...` → home dir, relative → resolved against `cwd`,
218
298
  // absolute → unchanged. Single source of truth so validation and Docker arg
@@ -281,7 +361,7 @@ export type FieldEffect = 'applied' | 'restart-required' | 'ignored'
281
361
 
282
362
  export const FIELD_EFFECTS: Record<string, FieldEffect> = {
283
363
  $schema: 'ignored',
284
- model: 'applied',
364
+ models: 'applied',
285
365
  port: 'restart-required',
286
366
  mounts: 'restart-required',
287
367
  plugins: 'restart-required',
@@ -289,8 +369,18 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
289
369
  channels: 'applied',
290
370
  portForward: 'restart-required',
291
371
  network: 'restart-required',
292
- dockerfile: 'restart-required',
293
- gitignore: 'restart-required',
372
+ 'docker.file': 'restart-required',
373
+ 'git.ignore': 'restart-required',
374
+ // Split: `match` lists are reload-safe (typeclaw role claim, hand-edits
375
+ // adding/removing match rules apply without a container restart);
376
+ // `permissions` lists are restart-required (changing what a role can DO
377
+ // is a bigger deal than changing WHO fills it, and several consumers —
378
+ // plugin contexts, the security plugin guards — capture the permissions
379
+ // contract at boot). The diff machinery in diffConfig() understands
380
+ // `roles.match` and `roles.permissions` as virtual paths and compares
381
+ // the corresponding projections of the whole `roles` block.
382
+ 'roles.match': 'applied',
383
+ 'roles.permissions': 'restart-required',
294
384
  }
295
385
 
296
386
  // Stable JSON for value comparison. Fields are small JSON-shaped objects, so
@@ -329,6 +419,8 @@ function diffConfig(before: Config, after: Config): ConfigReloadDiff {
329
419
  }
330
420
 
331
421
  function readPath(obj: unknown, path: string): unknown {
422
+ if (path === 'roles.match') return projectRoles(obj, 'match')
423
+ if (path === 'roles.permissions') return projectRoles(obj, 'permissions')
332
424
  let cur: unknown = obj
333
425
  for (const part of path.split('.')) {
334
426
  if (cur === null || cur === undefined) return undefined
@@ -337,6 +429,19 @@ function readPath(obj: unknown, path: string): unknown {
337
429
  return cur
338
430
  }
339
431
 
432
+ function projectRoles(obj: unknown, field: 'match' | 'permissions'): unknown {
433
+ if (typeof obj !== 'object' || obj === null) return undefined
434
+ const roles = (obj as Record<string, unknown>).roles
435
+ if (typeof roles !== 'object' || roles === null) return undefined
436
+ const projection: Record<string, unknown> = {}
437
+ for (const [roleName, roleVal] of Object.entries(roles as Record<string, unknown>)) {
438
+ if (typeof roleVal !== 'object' || roleVal === null) continue
439
+ const val = (roleVal as Record<string, unknown>)[field]
440
+ if (val !== undefined) projection[roleName] = val
441
+ }
442
+ return projection
443
+ }
444
+
340
445
  // Plugin configs live at the top level of typeclaw.json keyed by plugin name
341
446
  // (e.g. "standup-log": { ... }). They are preserved by configSchema.catchall(z.unknown())
342
447
  // because the schema does not predeclare these keys. This helper returns the
@@ -347,14 +452,17 @@ export function extractPluginConfigs(raw: unknown): Record<string, unknown> {
347
452
  const known = new Set([
348
453
  '$schema',
349
454
  'port',
350
- 'model',
455
+ 'models',
351
456
  'mounts',
352
457
  'plugins',
458
+ 'alias',
353
459
  'channels',
354
460
  'portForward',
355
461
  'network',
356
- 'dockerfile',
357
- 'gitignore',
462
+ 'docker',
463
+ 'git',
464
+ 'roles',
465
+ 'permissions',
358
466
  ])
359
467
  const result: Record<string, unknown> = {}
360
468
  for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
@@ -376,7 +484,11 @@ export function loadPluginConfigsSync(cwd: string): Record<string, unknown> {
376
484
  } catch {
377
485
  return {}
378
486
  }
379
- return extractPluginConfigs(json)
487
+ const migrated = migrateLegacyConfigShape(json)
488
+ if (migrated.changed) {
489
+ persistMigratedConfig(cwd, migrated.json, migrated.applied)
490
+ }
491
+ return extractPluginConfigs(migrated.json)
380
492
  }
381
493
 
382
494
  export function loadConfigSync(cwd: string): Config {
@@ -395,13 +507,368 @@ export function loadConfigSync(cwd: string): Config {
395
507
  throw new Error(`${CONFIG_FILE} is not valid JSON: ${detail}`)
396
508
  }
397
509
 
398
- const result = configSchema.safeParse(json)
510
+ const migrated = migrateLegacyConfigShape(json)
511
+ if (migrated.changed) {
512
+ persistMigratedConfig(cwd, migrated.json, migrated.applied)
513
+ }
514
+
515
+ const result = configSchema.safeParse(migrated.json)
399
516
  if (!result.success) {
400
517
  throw new Error(`${CONFIG_FILE} is invalid: ${formatZodError(result.error)}`)
401
518
  }
402
519
  return result.data
403
520
  }
404
521
 
522
+ // One-shot rename of legacy top-level `dockerfile` / `gitignore` keys into the
523
+ // nested `docker.file` / `git.ignore` shape introduced for namespace
524
+ // extensibility (`docker.compose`, `git.attributes`, etc. land here later
525
+ // without a second migration). Called from every entry point that reads
526
+ // `typeclaw.json` so the rest of the pipeline only ever sees the new shape.
527
+ //
528
+ // Precedence when both legacy and new keys coexist: the new shape wins and
529
+ // the legacy key is dropped silently. Two ways this happens in practice:
530
+ // 1. User hand-edited the new shape after auto-migration but forgot to
531
+ // delete the legacy key.
532
+ // 2. Two `typeclaw start` invocations raced on a stale checkout.
533
+ // Either way, the new shape is the source of truth — losing the legacy
534
+ // duplicate is the right call because it would otherwise be shadowed at
535
+ // parse time anyway (`configSchema` has no `dockerfile`/`gitignore` keys).
536
+ //
537
+ // The returned `applied` array names each migration step that fired, so
538
+ // callers in `typeclaw start` can build a meaningful git commit message
539
+ // instead of a generic "migrate legacy shape" subject. `changed` is the
540
+ // boolean equivalent of `applied.length > 0` and is preserved for back-compat
541
+ // with the many call sites that only care whether ANY rewrite happened.
542
+ export type MigrationStep =
543
+ | { kind: 'dockerfile-to-docker-file' }
544
+ | { kind: 'gitignore-to-git-ignore' }
545
+ | { kind: 'channels-allow-to-roles-member-match'; rules: string[]; dropped: string[] }
546
+ | { kind: 'strip-permissions-gate-channel-respond' }
547
+ | { kind: 'model-to-models'; ref: string }
548
+ | { kind: 'drop-stale-model'; ref: string }
549
+
550
+ export type MigrationResult = { json: unknown; changed: boolean; applied: MigrationStep[] }
551
+
552
+ export function migrateLegacyConfigShape(json: unknown): MigrationResult {
553
+ if (typeof json !== 'object' || json === null || Array.isArray(json)) {
554
+ return { json, changed: false, applied: [] }
555
+ }
556
+
557
+ const obj = json as Record<string, unknown>
558
+ const hasLegacyDockerfile = 'dockerfile' in obj
559
+ const hasLegacyGitignore = 'gitignore' in obj
560
+ const channelsAllowMigration = collectChannelsAllowMigration(obj)
561
+ const hasLegacyGateChannelRespond = isPlainObject(obj.permissions) && 'gateChannelRespond' in obj.permissions
562
+ // The pre-multi-model schema had a top-level `model: KnownModelRef` and no
563
+ // `models` key. Detecting the legacy shape requires both: `model` present
564
+ // AND `models` absent. If both coexist (user hand-edited after auto-migrate
565
+ // but didn't delete the legacy key), `models` wins and `model` is dropped
566
+ // silently — same precedence rule as the dockerfile/gitignore migrations.
567
+ const hasLegacyModel = 'model' in obj && !('models' in obj) && typeof obj.model === 'string'
568
+ const hasStaleModelAlongsideModels = 'model' in obj && 'models' in obj
569
+ if (
570
+ !hasLegacyDockerfile &&
571
+ !hasLegacyGitignore &&
572
+ !channelsAllowMigration.found &&
573
+ !hasLegacyGateChannelRespond &&
574
+ !hasLegacyModel &&
575
+ !hasStaleModelAlongsideModels
576
+ ) {
577
+ return { json, changed: false, applied: [] }
578
+ }
579
+
580
+ const applied: MigrationStep[] = []
581
+ const next: Record<string, unknown> = { ...obj }
582
+ if (hasLegacyDockerfile) {
583
+ const legacy = next.dockerfile
584
+ delete next.dockerfile
585
+ if (!('docker' in next)) {
586
+ next.docker = { file: legacy }
587
+ } else if (isPlainObject(next.docker) && !('file' in next.docker)) {
588
+ next.docker = { ...next.docker, file: legacy }
589
+ }
590
+ applied.push({ kind: 'dockerfile-to-docker-file' })
591
+ }
592
+ if (hasLegacyGitignore) {
593
+ const legacy = next.gitignore
594
+ delete next.gitignore
595
+ if (!('git' in next)) {
596
+ next.git = { ignore: legacy }
597
+ } else if (isPlainObject(next.git) && !('ignore' in next.git)) {
598
+ next.git = { ...next.git, ignore: legacy }
599
+ }
600
+ applied.push({ kind: 'gitignore-to-git-ignore' })
601
+ }
602
+ if (channelsAllowMigration.found) {
603
+ applyChannelsAllowMigration(next, channelsAllowMigration)
604
+ applied.push({
605
+ kind: 'channels-allow-to-roles-member-match',
606
+ rules: channelsAllowMigration.rules,
607
+ dropped: channelsAllowMigration.warnings,
608
+ })
609
+ }
610
+ if (hasLegacyGateChannelRespond) {
611
+ const perms = { ...(next.permissions as Record<string, unknown>) }
612
+ delete perms.gateChannelRespond
613
+ if (Object.keys(perms).length === 0) {
614
+ delete next.permissions
615
+ } else {
616
+ next.permissions = perms
617
+ }
618
+ applied.push({ kind: 'strip-permissions-gate-channel-respond' })
619
+ }
620
+ if (hasLegacyModel) {
621
+ const ref = next.model as string
622
+ delete next.model
623
+ next.models = { default: ref }
624
+ applied.push({ kind: 'model-to-models', ref })
625
+ } else if (hasStaleModelAlongsideModels) {
626
+ // `models` wins (per the same precedence rule as dockerfile/gitignore), but
627
+ // the drop is still a tracked migration step so the disk rewrite gets a
628
+ // commit instead of silently dirtying the worktree. Without this, the
629
+ // file would be rewritten by persistMigratedConfig and no commit would
630
+ // fire (buildConfigMigrationCommitMessage returns null for empty applied
631
+ // lists), contradicting the invariant in persistMigratedConfig's comment.
632
+ const ref = typeof next.model === 'string' ? next.model : ''
633
+ delete next.model
634
+ applied.push({ kind: 'drop-stale-model', ref })
635
+ }
636
+ return { json: next, changed: true, applied }
637
+ }
638
+
639
+ // Builds a meaningful one-line git commit subject for a typeclaw.json
640
+ // migration. Single-step migrations get a specific subject; multi-step ones
641
+ // fall back to a stable summary subject with the count. The body (after the
642
+ // blank line) enumerates each step so `git log -p typeclaw.json` is an
643
+ // auditable trail of what legacy shapes the agent has graduated from.
644
+ //
645
+ // Returns null when no steps were applied — callers should not commit in
646
+ // that case. Keeping the null branch here (vs an empty string) makes the
647
+ // "nothing happened" case impossible to misuse at the call site.
648
+ export function buildConfigMigrationCommitMessage(applied: readonly MigrationStep[]): string | null {
649
+ const first = applied[0]
650
+ if (first === undefined) return null
651
+
652
+ const subject =
653
+ applied.length === 1
654
+ ? `typeclaw.json: ${shortStepLabel(first)}`
655
+ : `typeclaw.json: migrate legacy shape (${applied.length} steps)`
656
+
657
+ const bodyLines: string[] = applied.map((step) => `- ${describeStep(step)}`)
658
+
659
+ // Surface dropped rules in the commit body so a user inspecting `git log -p`
660
+ // sees exactly which legacy entries had to be hand-re-added (the lossy
661
+ // `channel:<id>` case). Without this, the silent-drop is invisible after
662
+ // the fact.
663
+ for (const step of applied) {
664
+ if (step.kind === 'channels-allow-to-roles-member-match' && step.dropped.length > 0) {
665
+ for (const warning of step.dropped) {
666
+ bodyLines.push(` warning: ${warning}`)
667
+ }
668
+ }
669
+ }
670
+
671
+ return `${subject}\n\n${bodyLines.join('\n')}\n`
672
+ }
673
+
674
+ function shortStepLabel(step: MigrationStep): string {
675
+ switch (step.kind) {
676
+ case 'dockerfile-to-docker-file':
677
+ return 'lift dockerfile → docker.file'
678
+ case 'gitignore-to-git-ignore':
679
+ return 'lift gitignore → git.ignore'
680
+ case 'channels-allow-to-roles-member-match':
681
+ return 'lift channels.<adapter>.allow[] → roles.member.match[]'
682
+ case 'strip-permissions-gate-channel-respond':
683
+ return 'drop permissions.gateChannelRespond'
684
+ case 'model-to-models':
685
+ return 'lift model → models.default'
686
+ case 'drop-stale-model':
687
+ return 'drop stale legacy model alongside models'
688
+ }
689
+ }
690
+
691
+ function describeStep(step: MigrationStep): string {
692
+ switch (step.kind) {
693
+ case 'dockerfile-to-docker-file':
694
+ return 'lift top-level dockerfile into docker.file'
695
+ case 'gitignore-to-git-ignore':
696
+ return 'lift top-level gitignore into git.ignore'
697
+ case 'channels-allow-to-roles-member-match': {
698
+ if (step.rules.length === 0) {
699
+ return 'strip channels.<adapter>.allow[] (no translatable rules)'
700
+ }
701
+ return `lift channels.<adapter>.allow[] → roles.member.match[]: ${step.rules.join(', ')}`
702
+ }
703
+ case 'strip-permissions-gate-channel-respond':
704
+ return 'drop permissions.gateChannelRespond (removed key)'
705
+ case 'model-to-models':
706
+ return `lift top-level model into models.default: ${step.ref}`
707
+ case 'drop-stale-model':
708
+ return step.ref !== ''
709
+ ? `drop stale top-level model (${step.ref}) — models block takes precedence`
710
+ : 'drop stale top-level model — models block takes precedence'
711
+ }
712
+ }
713
+
714
+ // Channels.<adapter>.allow[] → roles.member.match[] migration.
715
+ //
716
+ // Phase 3 removes the per-adapter allow-list and unifies wake-up gating
717
+ // through `roles.member.match[]` + the `channel.respond` permission. This
718
+ // helper translates legacy `allow` entries into canonical match-rule DSL
719
+ // strings and appends them (deduplicated, preserving declaration order)
720
+ // to `roles.member.match[]`. The `allow` field is then stripped from each
721
+ // adapter block; the block survives — only the field is gone.
722
+ //
723
+ // `channel:<id>` rules cannot round-trip (the DSL forbids
724
+ // wildcard-workspace + specific-chat) and are dropped with a warning. All
725
+ // other shapes translate losslessly per the table in match-rule.ts.
726
+ type ChannelsAllowMigration = {
727
+ found: boolean
728
+ rules: string[]
729
+ warnings: string[]
730
+ }
731
+
732
+ function collectChannelsAllowMigration(obj: Record<string, unknown>): ChannelsAllowMigration {
733
+ const out: ChannelsAllowMigration = { found: false, rules: [], warnings: [] }
734
+ const channels = obj.channels
735
+ if (!isPlainObject(channels)) return out
736
+ for (const [adapter, value] of Object.entries(channels)) {
737
+ if (!isPlainObject(value)) continue
738
+ if (!('allow' in value)) continue
739
+ out.found = true
740
+ const allow = value.allow
741
+ if (!Array.isArray(allow)) continue
742
+ for (const entry of allow) {
743
+ if (typeof entry !== 'string') continue
744
+ const translated = translateLegacyAllowRule(entry)
745
+ if (translated.kind === 'rule') {
746
+ out.rules.push(translated.value)
747
+ } else {
748
+ out.warnings.push(`channels.${adapter}.allow[]: dropped '${entry}' (${translated.reason})`)
749
+ }
750
+ }
751
+ }
752
+ return out
753
+ }
754
+
755
+ function applyChannelsAllowMigration(next: Record<string, unknown>, migration: ChannelsAllowMigration): void {
756
+ const channels = next.channels
757
+ if (isPlainObject(channels)) {
758
+ const updated: Record<string, unknown> = {}
759
+ for (const [adapter, value] of Object.entries(channels)) {
760
+ if (isPlainObject(value) && 'allow' in value) {
761
+ const { allow: _allow, ...rest } = value
762
+ updated[adapter] = rest
763
+ } else {
764
+ updated[adapter] = value
765
+ }
766
+ }
767
+ next.channels = updated
768
+ }
769
+
770
+ if (migration.rules.length === 0) {
771
+ for (const warning of migration.warnings) {
772
+ console.warn(`[config] ${warning}`)
773
+ }
774
+ return
775
+ }
776
+
777
+ const roles = isPlainObject(next.roles) ? { ...next.roles } : {}
778
+ const member = isPlainObject(roles.member) ? { ...roles.member } : {}
779
+ const existingMatch = Array.isArray(member.match)
780
+ ? (member.match as unknown[]).filter((m) => typeof m === 'string')
781
+ : []
782
+ const seen = new Set<string>(existingMatch as string[])
783
+ const merged = [...(existingMatch as string[])]
784
+ for (const rule of migration.rules) {
785
+ if (!seen.has(rule)) {
786
+ seen.add(rule)
787
+ merged.push(rule)
788
+ }
789
+ }
790
+ member.match = merged
791
+ roles.member = member
792
+ next.roles = roles
793
+
794
+ console.warn(`[config] migrated channels.<adapter>.allow[] -> roles.member.match[]: ${migration.rules.join(', ')}`)
795
+ for (const warning of migration.warnings) {
796
+ console.warn(`[config] ${warning}`)
797
+ }
798
+ }
799
+
800
+ type TranslatedRule = { kind: 'rule'; value: string } | { kind: 'drop'; reason: string }
801
+
802
+ function translateLegacyAllowRule(rule: string): TranslatedRule {
803
+ // Already canonical / cross-platform.
804
+ if (rule === '*') return { kind: 'rule', value: '*' }
805
+ if (rule.startsWith('kakao:')) return { kind: 'rule', value: rule }
806
+
807
+ // Discord: guild → discord, dm → discord:dm.
808
+ if (rule === 'guild:*') return { kind: 'rule', value: 'discord:*' }
809
+ if (rule.startsWith('guild:')) return { kind: 'rule', value: `discord:${rule.slice('guild:'.length)}` }
810
+ if (rule === 'dm:*') return { kind: 'rule', value: 'discord:dm/*' }
811
+ if (rule.startsWith('dm:')) return { kind: 'rule', value: `discord:dm/${rule.slice('dm:'.length)}` }
812
+
813
+ // Slack: team → slack, im → slack:dm.
814
+ if (rule === 'team:*') return { kind: 'rule', value: 'slack:*' }
815
+ if (rule.startsWith('team:')) return { kind: 'rule', value: `slack:${rule.slice('team:'.length)}` }
816
+ if (rule === 'im:*') return { kind: 'rule', value: 'slack:dm/*' }
817
+ if (rule.startsWith('im:')) return { kind: 'rule', value: `slack:dm/${rule.slice('im:'.length)}` }
818
+
819
+ // Telegram: tg → telegram.
820
+ if (rule === 'tg:*') return { kind: 'rule', value: 'telegram:*' }
821
+ if (rule.startsWith('tg:')) return { kind: 'rule', value: `telegram:${rule.slice('tg:'.length)}` }
822
+
823
+ // `channel:<id>` had no workspace; canonical DSL rejects wildcard
824
+ // workspace + specific chat. Drop with a warning so the operator knows
825
+ // to re-add the rule explicitly with a workspace coordinate.
826
+ if (rule.startsWith('channel:')) {
827
+ return {
828
+ kind: 'drop',
829
+ reason:
830
+ 'channel:<id> rules require an explicit workspace under the new DSL; re-add as discord:<guild>/<id> or slack:<team>/<id>',
831
+ }
832
+ }
833
+
834
+ return { kind: 'drop', reason: `unrecognized legacy allow shape '${rule}'` }
835
+ }
836
+
837
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
838
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
839
+ }
840
+
841
+ function persistMigratedConfig(cwd: string, json: unknown, applied: readonly MigrationStep[]): void {
842
+ try {
843
+ writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(json, null, 2)}\n`)
844
+ } catch {
845
+ // Best-effort write-back: the migration is also applied in-memory on every
846
+ // load, so a read-only filesystem (e.g. snapshotted CI checkout) just
847
+ // means the rewrite retries next start. Surfacing the error would brick
848
+ // load paths the user didn't ask to mutate. Bail before the commit step
849
+ // too — without the write there's nothing to commit.
850
+ return
851
+ }
852
+
853
+ // Pair the disk rewrite with a git commit so the agent folder is never
854
+ // silently dirty after a legacy-shape migration. typeclaw.json is in
855
+ // git's "tracked" category (unlike Dockerfile, which is regenerated on
856
+ // every start and intentionally gitignored), so an uncommitted rewrite
857
+ // gets mixed into unrelated commits the moment any other tool touches
858
+ // the repo. commitSystemFileSync no-ops on non-git folders, missing
859
+ // Bun, and clean files, so canonical-shape reads pay zero cost.
860
+ //
861
+ // Called from every entry point that reads typeclaw.json (host CLI,
862
+ // hostd daemon, container runtime) so the commit follows the rewrite
863
+ // wherever it happens — not only from `typeclaw start`. The earlier
864
+ // design that committed only in start() missed the long-running hostd
865
+ // daemon, doctor, tui, reload, and compose paths.
866
+ const message = buildConfigMigrationCommitMessage(applied)
867
+ if (message !== null) {
868
+ commitSystemFileSync(cwd, CONFIG_FILE, message)
869
+ }
870
+ }
871
+
405
872
  export type ValidateConfigResult = { ok: true } | { ok: false; reason: string }
406
873
 
407
874
  // Missing file → ok (matches `loadMounts` in src/container/up.ts; `isInitialized`
@@ -430,7 +897,12 @@ export function validateConfig(cwd: string): ValidateConfigResult {
430
897
  return { ok: false, reason: `${CONFIG_FILE} is not valid JSON: ${detail}` }
431
898
  }
432
899
 
433
- const result = configSchema.safeParse(json)
900
+ const migrated = migrateLegacyConfigShape(json)
901
+ if (migrated.changed) {
902
+ persistMigratedConfig(cwd, migrated.json, migrated.applied)
903
+ }
904
+
905
+ const result = configSchema.safeParse(migrated.json)
434
906
  if (!result.success) {
435
907
  return { ok: false, reason: `${CONFIG_FILE} is invalid: ${formatZodError(result.error)}` }
436
908
  }
@@ -1,24 +1,38 @@
1
1
  export {
2
+ buildConfigMigrationCommitMessage,
2
3
  config,
3
4
  configSchema,
5
+ dockerSchema,
6
+ dockerfileSchema,
4
7
  expandMountPath,
5
8
  extractPluginConfigs,
6
9
  getConfig,
10
+ gitSchema,
11
+ gitignoreSchema,
7
12
  loadConfigSync,
8
13
  loadPluginConfigsSync,
14
+ migrateLegacyConfigShape,
15
+ modelsSchema,
9
16
  mountSchema,
10
- dockerfileSchema,
11
17
  portForwardSchema,
12
18
  reloadConfig,
13
19
  resolveModel,
20
+ resolveProfile,
14
21
  validateConfig,
15
22
  validateMount,
16
23
  type Config,
17
24
  type ConfigChange,
18
25
  type ConfigReloadDiff,
26
+ type DockerConfig,
19
27
  type DockerfileConfig,
28
+ type GitConfig,
29
+ type GitignoreConfig,
30
+ type MigrationResult,
31
+ type MigrationStep,
32
+ type Models,
20
33
  type Mount,
21
34
  type PortForward,
35
+ type ResolvedProfile,
22
36
  type ValidateConfigResult,
23
37
  } from './config'
24
38
  export { type KnownModelRef, type KnownProviderId } from './providers'