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
@@ -0,0 +1,209 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { commitSystemFileSync } from '@/git/system-commit'
5
+
6
+ import { configSchema, loadConfigSync, validateConfig } from './config'
7
+ import {
8
+ KNOWN_PROVIDERS,
9
+ listKnownModelRefs,
10
+ providerForModelRef,
11
+ type KnownModelRef,
12
+ type KnownProviderId,
13
+ } from './providers'
14
+ import { isProviderConfigured } from './providers-mutation'
15
+
16
+ const CONFIG_FILE = 'typeclaw.json'
17
+
18
+ export type ModelProfileEntry = {
19
+ profile: string
20
+ ref: KnownModelRef
21
+ providerId: KnownProviderId
22
+ isDefault: boolean
23
+ credentialStatus: 'available' | 'missing-credentials'
24
+ }
25
+
26
+ export type ModelMutationResult = { ok: true } | { ok: false; reason: string }
27
+
28
+ export function listModelProfiles(cwd: string, env: NodeJS.ProcessEnv = process.env): ModelProfileEntry[] {
29
+ const models = loadConfigSync(cwd).models
30
+ const out: ModelProfileEntry[] = []
31
+ for (const [profile, ref] of Object.entries(models)) {
32
+ const providerId = providerForModelRef(ref)
33
+ out.push({
34
+ profile,
35
+ ref,
36
+ providerId,
37
+ isDefault: profile === 'default',
38
+ credentialStatus: hasUsableCredential(cwd, providerId, env) ? 'available' : 'missing-credentials',
39
+ })
40
+ }
41
+ // `default` always first; remaining profiles alphabetical so output is stable.
42
+ out.sort((a, b) => {
43
+ if (a.isDefault) return -1
44
+ if (b.isDefault) return 1
45
+ return a.profile.localeCompare(b.profile)
46
+ })
47
+ return out
48
+ }
49
+
50
+ export function listAvailableModelRefs(): KnownModelRef[] {
51
+ return listKnownModelRefs()
52
+ }
53
+
54
+ export function isKnownModelRef(value: string): value is KnownModelRef {
55
+ return (listKnownModelRefs() as ReadonlyArray<string>).includes(value)
56
+ }
57
+
58
+ // `set` is the canonical mutation for both creating a new profile and updating
59
+ // an existing one (mirrors how `models.<profile>` works in the schema).
60
+ // Refuses unknown model refs and providers without credentials (unless
61
+ // `force: true`) so a write can't leave the agent in a state where the next
62
+ // session start crashes with a missing-credential error.
63
+ export type SetProfileOptions = {
64
+ force?: boolean
65
+ env?: NodeJS.ProcessEnv
66
+ }
67
+
68
+ export function setProfile(
69
+ cwd: string,
70
+ profile: string,
71
+ ref: string,
72
+ options: SetProfileOptions = {},
73
+ ): ModelMutationResult {
74
+ const trimmed = profile.trim()
75
+ if (trimmed.length === 0) {
76
+ return { ok: false, reason: 'Profile name cannot be empty.' }
77
+ }
78
+ if (!isKnownModelRef(ref)) {
79
+ return {
80
+ ok: false,
81
+ reason: `Unknown model "${ref}". Run \`typeclaw model list --available\` to see valid options.`,
82
+ }
83
+ }
84
+ const providerId = providerForModelRef(ref)
85
+ if (options.force !== true && !hasUsableCredential(cwd, providerId, options.env ?? process.env)) {
86
+ return {
87
+ ok: false,
88
+ reason: `Provider "${providerId}" has no credentials. Run \`typeclaw provider add ${providerId}\` first, or pass --force to write anyway.`,
89
+ }
90
+ }
91
+
92
+ const existingBefore = readModelsRaw(cwd)
93
+ const verb = existingBefore !== null && trimmed in existingBefore ? 'set' : 'add'
94
+ return writeProfile(cwd, trimmed, ref, `model: ${verb} ${trimmed} → ${ref}`)
95
+ }
96
+
97
+ // `add` is just `set` with a uniqueness guard; users who want "update" should
98
+ // reach for `set`. Keeping it separate so the CLI can route distinct error
99
+ // messages without leaking force-overwrite as a happy path.
100
+ export function addProfile(
101
+ cwd: string,
102
+ profile: string,
103
+ ref: string,
104
+ options: SetProfileOptions = {},
105
+ ): ModelMutationResult {
106
+ const existing = readModelsRaw(cwd)
107
+ if (existing !== null && profile in existing) {
108
+ return {
109
+ ok: false,
110
+ reason: `Profile "${profile}" already exists. Use \`typeclaw model set ${profile} ${ref}\` to update it.`,
111
+ }
112
+ }
113
+ return setProfile(cwd, profile, ref, options)
114
+ }
115
+
116
+ // `default` is required by the schema (`modelsSchema.refine`). Removing it
117
+ // would make the file unparseable, so we reject with a precise hint instead of
118
+ // letting the next `validateConfig` failure confuse the user. To change the
119
+ // default model, the user runs `typeclaw model set default <ref>`.
120
+ export function removeProfile(cwd: string, profile: string): ModelMutationResult {
121
+ if (profile === 'default') {
122
+ return {
123
+ ok: false,
124
+ reason:
125
+ 'Cannot remove the `default` profile. Use `typeclaw model set default <ref>` to change the default model.',
126
+ }
127
+ }
128
+ const existing = readModelsRaw(cwd)
129
+ if (existing === null) {
130
+ return { ok: false, reason: `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` first.` }
131
+ }
132
+ if (!(profile in existing)) {
133
+ return { ok: false, reason: `Profile "${profile}" not found in ${CONFIG_FILE}.` }
134
+ }
135
+ const next = { ...existing }
136
+ delete next[profile]
137
+ return writeModels(cwd, next, `model: remove ${profile}`)
138
+ }
139
+
140
+ function writeProfile(cwd: string, profile: string, ref: KnownModelRef, message: string): ModelMutationResult {
141
+ const existing = readModelsRaw(cwd)
142
+ const next = existing === null ? { default: ref } : { ...existing, [profile]: ref }
143
+ if (existing === null && profile !== 'default') {
144
+ next.default = ref
145
+ }
146
+ return writeModels(cwd, next, message)
147
+ }
148
+
149
+ function writeModels(cwd: string, models: Record<string, string>, commitMessage: string): ModelMutationResult {
150
+ const path = join(cwd, CONFIG_FILE)
151
+ let parsed: Record<string, unknown>
152
+ try {
153
+ const raw = readFileSync(path, 'utf8')
154
+ parsed = JSON.parse(raw) as Record<string, unknown>
155
+ } catch (error) {
156
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
157
+ return { ok: false, reason: `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` first.` }
158
+ }
159
+ return { ok: false, reason: `Failed to read ${CONFIG_FILE}: ${(error as Error).message}` }
160
+ }
161
+ parsed.models = models
162
+ const check = configSchema.safeParse(parsed)
163
+ if (!check.success) {
164
+ return {
165
+ ok: false,
166
+ reason: `models block would be invalid: ${check.error.issues.map((i) => i.message).join('; ')}`,
167
+ }
168
+ }
169
+ try {
170
+ writeFileSync(path, `${JSON.stringify(parsed, null, 2)}\n`)
171
+ } catch (error) {
172
+ return { ok: false, reason: `Failed to write ${CONFIG_FILE}: ${(error as Error).message}` }
173
+ }
174
+ // Final schema-pass for parity with every other host-side mutation that runs
175
+ // through validateConfig. Mount checks etc. should never fail here because
176
+ // we only touched `models`, but if the file was already in a bad state we
177
+ // want to surface that instead of leaving the user wondering why `reload`
178
+ // fails.
179
+ const validation = validateConfig(cwd)
180
+ if (!validation.ok) {
181
+ return { ok: false, reason: validation.reason }
182
+ }
183
+ // Auto-commit so the agent folder is never silently dirty after a CLI
184
+ // config mutation. Same pattern as `persistMigratedConfig` and cron
185
+ // migrations: `commitSystemFileSync` no-ops on non-git folders, missing
186
+ // Bun, and clean files, so callers outside a git repo pay zero cost.
187
+ commitSystemFileSync(cwd, CONFIG_FILE, commitMessage)
188
+ return { ok: true }
189
+ }
190
+
191
+ function readModelsRaw(cwd: string): Record<string, string> | null {
192
+ try {
193
+ const raw = readFileSync(join(cwd, CONFIG_FILE), 'utf8')
194
+ const parsed = JSON.parse(raw) as { models?: Record<string, string> }
195
+ return parsed.models ?? null
196
+ } catch (error) {
197
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
198
+ throw error
199
+ }
200
+ }
201
+
202
+ function hasUsableCredential(cwd: string, providerId: KnownProviderId, env: NodeJS.ProcessEnv): boolean {
203
+ const provider = KNOWN_PROVIDERS[providerId]
204
+ if (provider.apiKeyEnv !== null) {
205
+ const fromEnv = env[provider.apiKeyEnv]
206
+ if (fromEnv !== undefined && fromEnv !== '') return true
207
+ }
208
+ return isProviderConfigured(cwd, providerId)
209
+ }
@@ -0,0 +1,250 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { SecretsBackend, type Secret } from '@/secrets'
4
+ import { providerKeyDefaultEnv } from '@/secrets/defaults'
5
+ import type { ProviderCredential, Providers } from '@/secrets/schema'
6
+
7
+ import { type Models, loadConfigSync } from './config'
8
+ import { KNOWN_PROVIDERS, type KnownModelRef, type KnownProviderId, providerForModelRef } from './providers'
9
+
10
+ // Where a configured credential resolves from at runtime. Reported by
11
+ // `typeclaw provider list` so users can tell whether their key is coming from
12
+ // `secrets.json#providers.<id>.key.value`, from `process.env.<API_KEY_ENV>`, or
13
+ // from an explicit `{ env: 'CUSTOM_NAME' }` binding. Drives the post-mutation
14
+ // hints (e.g. "key is env-overridden — `provider remove` will not unset env").
15
+ export type CredentialSource =
16
+ | { kind: 'file' }
17
+ | { kind: 'env-only'; envName: string }
18
+ | { kind: 'env-overridden'; envName: string }
19
+ | { kind: 'oauth' }
20
+
21
+ export type ConfiguredProvider = {
22
+ id: KnownProviderId | string
23
+ known: boolean
24
+ type: 'api_key' | 'oauth' | 'unknown'
25
+ source: CredentialSource
26
+ envName: string | undefined
27
+ referencedByProfiles: string[]
28
+ }
29
+
30
+ export type ProviderAddCredential =
31
+ | { type: 'api_key'; key: string; envBinding?: string | undefined }
32
+ | { type: 'env-binding'; envBinding: string }
33
+
34
+ export type ProviderMutationResult = { ok: true } | { ok: false; reason: string }
35
+
36
+ const SECRETS_FILE = 'secrets.json'
37
+
38
+ export function listConfiguredProviders(cwd: string, env: NodeJS.ProcessEnv = process.env): ConfiguredProvider[] {
39
+ const backend = new SecretsBackend(join(cwd, SECRETS_FILE))
40
+ const providers = backend.tryReadProvidersSync()
41
+ const models = readModelsOrNull(cwd)
42
+ const referencedByProfiles = buildProviderReferenceMap(models)
43
+
44
+ const ids = new Set<string>([...Object.keys(providers), ...Object.keys(KNOWN_PROVIDERS)])
45
+ const out: ConfiguredProvider[] = []
46
+ for (const id of ids) {
47
+ const credential = providers[id]
48
+ const known = id in KNOWN_PROVIDERS
49
+ if (credential === undefined) {
50
+ // Known provider with no file entry. Surface it only when an env var
51
+ // makes it usable; otherwise it's not "configured" and shouldn't appear
52
+ // in the list (would clutter output for the 5+ known providers users
53
+ // haven't touched).
54
+ const envName = providerKeyDefaultEnv(id)
55
+ if (envName !== undefined && readEnvKey(env, envName) !== undefined) {
56
+ out.push({
57
+ id: id as KnownProviderId,
58
+ known,
59
+ type: 'api_key',
60
+ source: { kind: 'env-only', envName },
61
+ envName,
62
+ referencedByProfiles: referencedByProfiles.get(id) ?? [],
63
+ })
64
+ }
65
+ continue
66
+ }
67
+ out.push({
68
+ id,
69
+ known,
70
+ type: credentialType(credential),
71
+ source: credentialSource(id, credential, env),
72
+ envName: effectiveEnvName(id, credential),
73
+ referencedByProfiles: referencedByProfiles.get(id) ?? [],
74
+ })
75
+ }
76
+ out.sort((a, b) => a.id.localeCompare(b.id))
77
+ return out
78
+ }
79
+
80
+ export function isProviderConfigured(cwd: string, providerId: string): boolean {
81
+ const backend = new SecretsBackend(join(cwd, SECRETS_FILE))
82
+ return providerId in backend.tryReadProvidersSync()
83
+ }
84
+
85
+ // Refuses to overwrite an existing provider — callers must use `setProvider`
86
+ // for the rotate path. Keeps the "I'm adding fresh credentials" intent
87
+ // distinct from the "I'm rotating an existing key" intent at the file-write
88
+ // boundary, so an `add` typo can't silently displace a working key.
89
+ export function addProvider(
90
+ cwd: string,
91
+ providerId: KnownProviderId,
92
+ credential: ProviderAddCredential,
93
+ ): ProviderMutationResult {
94
+ if (isProviderConfigured(cwd, providerId)) {
95
+ return {
96
+ ok: false,
97
+ reason: `Provider "${providerId}" is already configured in secrets.json. Use \`typeclaw provider set\` to rotate its credentials.`,
98
+ }
99
+ }
100
+ return writeApiKeyCredential(cwd, providerId, credential)
101
+ }
102
+
103
+ export function setProvider(
104
+ cwd: string,
105
+ providerId: KnownProviderId,
106
+ credential: ProviderAddCredential,
107
+ ): ProviderMutationResult {
108
+ return writeApiKeyCredential(cwd, providerId, credential)
109
+ }
110
+
111
+ // Refuses removal when any model profile in typeclaw.json references the
112
+ // provider — clearing the credential out from under an active profile would
113
+ // crash the next session start with a missing-credential error. Returns the
114
+ // list of offending profiles so the CLI can name them in the error message.
115
+ export type ProviderRemovalResult =
116
+ | { ok: true; existed: boolean }
117
+ | { ok: false; reason: 'referenced'; profiles: string[] }
118
+
119
+ export function removeProvider(
120
+ cwd: string,
121
+ providerId: string,
122
+ options: { force?: boolean } = {},
123
+ ): ProviderRemovalResult {
124
+ if (options.force !== true) {
125
+ const profiles = findModelsReferencingProvider(cwd, providerId)
126
+ if (profiles.length > 0) {
127
+ return { ok: false, reason: 'referenced', profiles }
128
+ }
129
+ }
130
+ const backend = new SecretsBackend(join(cwd, SECRETS_FILE))
131
+ const existed = backend.removeProviderCredentialSync(providerId)
132
+ return { ok: true, existed }
133
+ }
134
+
135
+ export function findModelsReferencingProvider(cwd: string, providerId: string): string[] {
136
+ const models = readModelsOrNull(cwd)
137
+ if (models === null) return []
138
+ const out: string[] = []
139
+ for (const [profile, ref] of Object.entries(models)) {
140
+ if (refTargetsProvider(ref, providerId)) out.push(profile)
141
+ }
142
+ return out
143
+ }
144
+
145
+ function writeApiKeyCredential(
146
+ cwd: string,
147
+ providerId: KnownProviderId,
148
+ credential: ProviderAddCredential,
149
+ ): ProviderMutationResult {
150
+ if (!(providerId in KNOWN_PROVIDERS)) {
151
+ return { ok: false, reason: `Unknown provider "${providerId}".` }
152
+ }
153
+ const provider = KNOWN_PROVIDERS[providerId]
154
+ if (provider.apiKeyEnv === null) {
155
+ return {
156
+ ok: false,
157
+ reason: `Provider "${providerId}" does not support api-key authentication. Use \`typeclaw provider add ${providerId} --oauth\` instead.`,
158
+ }
159
+ }
160
+ const secret = buildSecret(credential)
161
+ if (secret === null) {
162
+ return { ok: false, reason: 'API key cannot be empty.' }
163
+ }
164
+ const backend = new SecretsBackend(join(cwd, SECRETS_FILE))
165
+ backend.writeProviderCredentialSync(providerId, { type: 'api_key', key: secret })
166
+ return { ok: true }
167
+ }
168
+
169
+ function buildSecret(credential: ProviderAddCredential): Secret | null {
170
+ if (credential.type === 'env-binding') {
171
+ return { env: credential.envBinding }
172
+ }
173
+ if (credential.key.length === 0) return null
174
+ if (credential.envBinding !== undefined && credential.envBinding.length > 0) {
175
+ return { value: credential.key, env: credential.envBinding }
176
+ }
177
+ return { value: credential.key }
178
+ }
179
+
180
+ function credentialType(credential: ProviderCredential): 'api_key' | 'oauth' | 'unknown' {
181
+ if (credential.type === 'api_key') return 'api_key'
182
+ if (credential.type === 'oauth') return 'oauth'
183
+ return 'unknown'
184
+ }
185
+
186
+ function credentialSource(
187
+ providerId: string,
188
+ credential: ProviderCredential,
189
+ env: NodeJS.ProcessEnv,
190
+ ): CredentialSource {
191
+ if (credential.type === 'oauth') return { kind: 'oauth' }
192
+ if (credential.type !== 'api_key') return { kind: 'file' }
193
+ const envName = credential.key.env ?? providerKeyDefaultEnv(providerId)
194
+ if (envName !== undefined && readEnvKey(env, envName) !== undefined) {
195
+ if (credential.key.value === undefined) return { kind: 'env-only', envName }
196
+ return { kind: 'env-overridden', envName }
197
+ }
198
+ return { kind: 'file' }
199
+ }
200
+
201
+ function effectiveEnvName(providerId: string, credential: ProviderCredential): string | undefined {
202
+ if (credential.type !== 'api_key') return undefined
203
+ return credential.key.env ?? providerKeyDefaultEnv(providerId)
204
+ }
205
+
206
+ function readEnvKey(env: NodeJS.ProcessEnv, key: string): string | undefined {
207
+ const value = env[key]
208
+ if (value === undefined || value === '') return undefined
209
+ return value
210
+ }
211
+
212
+ function buildProviderReferenceMap(models: Models | null): Map<string, string[]> {
213
+ const out = new Map<string, string[]>()
214
+ if (models === null) return out
215
+ for (const [profile, ref] of Object.entries(models)) {
216
+ const providerId = safeProviderForRef(ref)
217
+ if (providerId === null) continue
218
+ const existing = out.get(providerId) ?? []
219
+ existing.push(profile)
220
+ out.set(providerId, existing)
221
+ }
222
+ return out
223
+ }
224
+
225
+ function refTargetsProvider(ref: string, providerId: string): boolean {
226
+ return ref.startsWith(`${providerId}/`)
227
+ }
228
+
229
+ function safeProviderForRef(ref: KnownModelRef): KnownProviderId | null {
230
+ try {
231
+ return providerForModelRef(ref)
232
+ } catch {
233
+ return null
234
+ }
235
+ }
236
+
237
+ function readModelsOrNull(cwd: string): Models | null {
238
+ try {
239
+ return loadConfigSync(cwd).models
240
+ } catch {
241
+ return null
242
+ }
243
+ }
244
+
245
+ export type ProviderListEntry = ConfiguredProvider
246
+
247
+ export type ProvidersSnapshot = {
248
+ providers: Providers
249
+ configuredIds: string[]
250
+ }
@@ -22,8 +22,9 @@ type KnownProvider = {
22
22
  }
23
23
 
24
24
  // Curated allowlist of providers + models that are wired into the agent
25
- // runtime. The values here back the Zod enum on `configSchema.model`, so any
26
- // model the user can put in `typeclaw.json` MUST appear here verbatim. The
25
+ // runtime. The values here back the Zod enum on every entry in
26
+ // `configSchema.models`, so any model the user can put in `typeclaw.json`
27
+ // (under any profile name) MUST appear here verbatim. The
27
28
  // init-time picker may surface additional models from models.dev, but it
28
29
  // resolves them through this list before scaffolding (anything missing falls
29
30
  // back to a curated default).
@@ -187,6 +188,144 @@ export const KNOWN_PROVIDERS = {
187
188
  },
188
189
  },
189
190
  },
191
+ // Z.AI (ZhipuAI / BigModel) general pay-as-you-go API. OpenAI-compatible
192
+ // (Bearer auth + /chat/completions shape), so models go through pi-ai's
193
+ // `openai-completions` adapter with a custom baseUrl — same trick as
194
+ // Fireworks. Costs and context windows mirror docs.z.ai/guides/overview/
195
+ // pricing as of 2026-05-15.
196
+ //
197
+ // The split with `zai-coding` below mirrors how we model `openai` /
198
+ // `openai-codex`: same upstream vendor, two distinct billing surfaces
199
+ // (paygo vs subscription), two distinct base URLs, two distinct env vars
200
+ // so a user can hold both keys simultaneously without collisions.
201
+ zai: {
202
+ id: 'zai',
203
+ name: 'Z.AI',
204
+ baseUrl: 'https://api.z.ai/api/paas/v4',
205
+ auth: ['api-key'],
206
+ apiKeyEnv: 'ZAI_API_KEY',
207
+ oauthProviderId: null,
208
+ models: {
209
+ 'glm-4.5-air': {
210
+ id: 'glm-4.5-air',
211
+ name: 'GLM-4.5-Air',
212
+ api: 'openai-completions',
213
+ provider: 'zai',
214
+ baseUrl: 'https://api.z.ai/api/paas/v4',
215
+ reasoning: true,
216
+ input: ['text'],
217
+ cost: { input: 0.2, output: 1.1, cacheRead: 0, cacheWrite: 0 },
218
+ contextWindow: 128000,
219
+ maxTokens: 96000,
220
+ },
221
+ 'glm-4.6': {
222
+ id: 'glm-4.6',
223
+ name: 'GLM-4.6',
224
+ api: 'openai-completions',
225
+ provider: 'zai',
226
+ baseUrl: 'https://api.z.ai/api/paas/v4',
227
+ reasoning: true,
228
+ input: ['text'],
229
+ cost: { input: 0.6, output: 2.2, cacheRead: 0, cacheWrite: 0 },
230
+ contextWindow: 200000,
231
+ maxTokens: 128000,
232
+ },
233
+ 'glm-4.7': {
234
+ id: 'glm-4.7',
235
+ name: 'GLM-4.7',
236
+ api: 'openai-completions',
237
+ provider: 'zai',
238
+ baseUrl: 'https://api.z.ai/api/paas/v4',
239
+ reasoning: true,
240
+ input: ['text'],
241
+ cost: { input: 0.6, output: 2.2, cacheRead: 0, cacheWrite: 0 },
242
+ contextWindow: 200000,
243
+ maxTokens: 128000,
244
+ },
245
+ },
246
+ },
247
+ // Z.AI GLM Coding Plan subscription. Same vendor, same key format, but a
248
+ // distinct base URL (/api/coding/paas/v4) and a separate billing surface
249
+ // — using a Coding Plan key against the paygo endpoint returns error 1113
250
+ // ("insufficient balance"). Distinct env var (`ZAI_CODING_API_KEY`) so a
251
+ // user can hold both a paygo and a Coding Plan key on different accounts.
252
+ //
253
+ // Model lineup is exactly the five models the Coding Plan docs name as
254
+ // "All plans support" plus GLM-5 (Pro/Max only per docs). Listing other
255
+ // GLM models here would silently bill against the wrong surface.
256
+ 'zai-coding': {
257
+ id: 'zai-coding',
258
+ name: 'Z.AI (GLM Coding Plan)',
259
+ baseUrl: 'https://api.z.ai/api/coding/paas/v4',
260
+ auth: ['api-key'],
261
+ apiKeyEnv: 'ZAI_CODING_API_KEY',
262
+ oauthProviderId: null,
263
+ models: {
264
+ 'glm-4.5-air': {
265
+ id: 'glm-4.5-air',
266
+ name: 'GLM-4.5-Air',
267
+ api: 'openai-completions',
268
+ provider: 'zai-coding',
269
+ baseUrl: 'https://api.z.ai/api/coding/paas/v4',
270
+ reasoning: true,
271
+ input: ['text'],
272
+ cost: { input: 0.2, output: 1.1, cacheRead: 0, cacheWrite: 0 },
273
+ contextWindow: 128000,
274
+ maxTokens: 96000,
275
+ },
276
+ 'glm-4.7': {
277
+ id: 'glm-4.7',
278
+ name: 'GLM-4.7',
279
+ api: 'openai-completions',
280
+ provider: 'zai-coding',
281
+ baseUrl: 'https://api.z.ai/api/coding/paas/v4',
282
+ reasoning: true,
283
+ input: ['text'],
284
+ cost: { input: 0.6, output: 2.2, cacheRead: 0, cacheWrite: 0 },
285
+ contextWindow: 200000,
286
+ maxTokens: 128000,
287
+ },
288
+ // GLM-5 access is Pro/Max tier only per docs.z.ai/devpack — Lite
289
+ // subscribers will see a quota error. We still list it because we
290
+ // can't introspect plan tier from the key alone.
291
+ 'glm-5': {
292
+ id: 'glm-5',
293
+ name: 'GLM-5',
294
+ api: 'openai-completions',
295
+ provider: 'zai-coding',
296
+ baseUrl: 'https://api.z.ai/api/coding/paas/v4',
297
+ reasoning: true,
298
+ input: ['text'],
299
+ cost: { input: 1.0, output: 3.2, cacheRead: 0, cacheWrite: 0 },
300
+ contextWindow: 200000,
301
+ maxTokens: 128000,
302
+ },
303
+ 'glm-5-turbo': {
304
+ id: 'glm-5-turbo',
305
+ name: 'GLM-5-Turbo',
306
+ api: 'openai-completions',
307
+ provider: 'zai-coding',
308
+ baseUrl: 'https://api.z.ai/api/coding/paas/v4',
309
+ reasoning: true,
310
+ input: ['text'],
311
+ cost: { input: 1.2, output: 4.0, cacheRead: 0, cacheWrite: 0 },
312
+ contextWindow: 200000,
313
+ maxTokens: 128000,
314
+ },
315
+ 'glm-5.1': {
316
+ id: 'glm-5.1',
317
+ name: 'GLM-5.1',
318
+ api: 'openai-completions',
319
+ provider: 'zai-coding',
320
+ baseUrl: 'https://api.z.ai/api/coding/paas/v4',
321
+ reasoning: true,
322
+ input: ['text'],
323
+ cost: { input: 1.4, output: 4.4, cacheRead: 0, cacheWrite: 0 },
324
+ contextWindow: 200000,
325
+ maxTokens: 128000,
326
+ },
327
+ },
328
+ },
190
329
  } as const satisfies Record<string, KnownProvider>
191
330
 
192
331
  export type KnownProviderId = keyof typeof KNOWN_PROVIDERS
@@ -1,20 +1,27 @@
1
+ import type { PermissionService } from '@/permissions'
1
2
  import type { Reloadable, ReloadResult } from '@/reload'
2
3
 
3
- import { type ConfigReloadDiff, reloadConfig, validateConfig } from './config'
4
+ import { getConfig, type ConfigReloadDiff, reloadConfig, validateConfig } from './config'
4
5
 
5
6
  export type CreateConfigReloadableOptions = {
6
7
  cwd: string
8
+ // Optional hook fired after a successful reload so the live permission
9
+ // service can rebuild its resolved role table from the new roles config.
10
+ // This is what makes `roles.<name>.match` edits (typeclaw role claim,
11
+ // hand-edits) take effect without a container restart. `roles.<name>.permissions`
12
+ // changes still require a restart — see FIELD_EFFECTS in config.ts.
13
+ permissions?: PermissionService
7
14
  }
8
15
 
9
- export function createConfigReloadable({ cwd }: CreateConfigReloadableOptions): Reloadable {
16
+ export function createConfigReloadable({ cwd, permissions }: CreateConfigReloadableOptions): Reloadable {
10
17
  return {
11
18
  scope: 'config',
12
19
  description: 'typeclaw.json runtime config',
13
- reload: async () => doReload(cwd),
20
+ reload: async () => doReload(cwd, permissions),
14
21
  }
15
22
  }
16
23
 
17
- async function doReload(cwd: string): Promise<ReloadResult> {
24
+ async function doReload(cwd: string, permissions: PermissionService | undefined): Promise<ReloadResult> {
18
25
  // Mount accessibility belongs to the validation surface, not loadConfigSync —
19
26
  // validateConfig is the single gate that every host-side caller goes through.
20
27
  // Run it before swapping the live config pointer so a mount that vanished
@@ -34,6 +41,10 @@ async function doReload(cwd: string): Promise<ReloadResult> {
34
41
  return { scope: 'config', ok: false, reason: message }
35
42
  }
36
43
 
44
+ if (permissions !== undefined && diff.applied.some((c) => c.path === 'roles.match')) {
45
+ permissions.replaceRoles(getConfig().roles)
46
+ }
47
+
37
48
  return {
38
49
  scope: 'config',
39
50
  ok: true,
@@ -1,5 +1,10 @@
1
1
  export { logs, planLogs, type LogsPlan, type LogsResult } from './logs'
2
2
  export { CONTAINER_PORT, TUI_TOKEN_LABEL, findFreePort, resolveHostPort, resolveTuiToken } from './port'
3
+ export {
4
+ requireContainerRunning,
5
+ type RequireContainerRunningOptions,
6
+ type RequireContainerRunningResult,
7
+ } from './require-running'
3
8
  export { planShell, shell, type ShellPlan, type ShellResult } from './shell'
4
9
  export { status, type ContainerStatus, type StatusOptions } from './status'
5
10
  export {