typeclaw 0.1.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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/auth.schema.json +63 -0
  4. package/cron.schema.json +96 -0
  5. package/package.json +72 -0
  6. package/scripts/emit-base-dockerfile.ts +5 -0
  7. package/scripts/generate-schema.ts +34 -0
  8. package/secrets.schema.json +63 -0
  9. package/src/agent/auth.ts +119 -0
  10. package/src/agent/compaction.ts +35 -0
  11. package/src/agent/git-nudge.ts +95 -0
  12. package/src/agent/index.ts +451 -0
  13. package/src/agent/plugin-tools.ts +269 -0
  14. package/src/agent/reload-tool.ts +71 -0
  15. package/src/agent/self.ts +45 -0
  16. package/src/agent/session-origin.ts +288 -0
  17. package/src/agent/subagents.ts +253 -0
  18. package/src/agent/system-prompt.ts +68 -0
  19. package/src/agent/tools/channel-fetch-attachment.ts +118 -0
  20. package/src/agent/tools/channel-history.ts +119 -0
  21. package/src/agent/tools/channel-reply.ts +182 -0
  22. package/src/agent/tools/channel-send.ts +212 -0
  23. package/src/agent/tools/ddg.ts +218 -0
  24. package/src/agent/tools/restart.ts +122 -0
  25. package/src/agent/tools/stream-snapshot.ts +181 -0
  26. package/src/agent/tools/webfetch/fetch.ts +102 -0
  27. package/src/agent/tools/webfetch/index.ts +1 -0
  28. package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
  29. package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
  30. package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
  31. package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
  32. package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
  33. package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
  34. package/src/agent/tools/webfetch/tool.ts +281 -0
  35. package/src/agent/tools/webfetch/types.ts +33 -0
  36. package/src/agent/tools/websearch.ts +96 -0
  37. package/src/agent/tools/wikipedia.ts +52 -0
  38. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
  39. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
  40. package/src/bundled-plugins/agent-browser/index.ts +179 -0
  41. package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
  42. package/src/bundled-plugins/agent-browser/shim.ts +152 -0
  43. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
  44. package/src/bundled-plugins/guard/index.ts +26 -0
  45. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
  46. package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
  47. package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
  48. package/src/bundled-plugins/guard/policy.ts +18 -0
  49. package/src/bundled-plugins/memory/README.md +71 -0
  50. package/src/bundled-plugins/memory/append-tool.ts +84 -0
  51. package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
  52. package/src/bundled-plugins/memory/dreaming.ts +470 -0
  53. package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
  54. package/src/bundled-plugins/memory/index.ts +238 -0
  55. package/src/bundled-plugins/memory/load-memory.ts +122 -0
  56. package/src/bundled-plugins/memory/memory-logger.ts +257 -0
  57. package/src/bundled-plugins/memory/secret-detector.ts +49 -0
  58. package/src/bundled-plugins/memory/watermark.ts +15 -0
  59. package/src/bundled-plugins/security/index.ts +35 -0
  60. package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
  61. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
  62. package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
  63. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
  64. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
  65. package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
  66. package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
  67. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
  68. package/src/bundled-plugins/security/policy.ts +9 -0
  69. package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
  70. package/src/channels/adapters/discord-bot-classify.ts +148 -0
  71. package/src/channels/adapters/discord-bot.ts +640 -0
  72. package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
  73. package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
  74. package/src/channels/adapters/kakaotalk-classify.ts +77 -0
  75. package/src/channels/adapters/kakaotalk.ts +622 -0
  76. package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
  77. package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
  78. package/src/channels/adapters/slack-bot-classify.ts +213 -0
  79. package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
  80. package/src/channels/adapters/slack-bot-time.ts +10 -0
  81. package/src/channels/adapters/slack-bot.ts +881 -0
  82. package/src/channels/adapters/telegram-bot-classify.ts +155 -0
  83. package/src/channels/adapters/telegram-bot-format.ts +309 -0
  84. package/src/channels/adapters/telegram-bot.ts +604 -0
  85. package/src/channels/engagement.ts +227 -0
  86. package/src/channels/index.ts +21 -0
  87. package/src/channels/manager.ts +292 -0
  88. package/src/channels/membership-cache.ts +116 -0
  89. package/src/channels/membership-from-history.ts +53 -0
  90. package/src/channels/membership.ts +30 -0
  91. package/src/channels/participants.ts +47 -0
  92. package/src/channels/persistence.ts +209 -0
  93. package/src/channels/reloadable.ts +28 -0
  94. package/src/channels/router.ts +1570 -0
  95. package/src/channels/schema.ts +273 -0
  96. package/src/channels/types.ts +160 -0
  97. package/src/cli/channel.ts +403 -0
  98. package/src/cli/compose-status.ts +95 -0
  99. package/src/cli/compose.ts +240 -0
  100. package/src/cli/hostd.ts +163 -0
  101. package/src/cli/index.ts +27 -0
  102. package/src/cli/init.ts +592 -0
  103. package/src/cli/logs.ts +38 -0
  104. package/src/cli/reload.ts +68 -0
  105. package/src/cli/restart.ts +66 -0
  106. package/src/cli/run.ts +77 -0
  107. package/src/cli/shell.ts +33 -0
  108. package/src/cli/start.ts +57 -0
  109. package/src/cli/status.ts +178 -0
  110. package/src/cli/stop.ts +31 -0
  111. package/src/cli/tui.ts +35 -0
  112. package/src/cli/ui.ts +110 -0
  113. package/src/commands/index.ts +74 -0
  114. package/src/compose/discover.ts +43 -0
  115. package/src/compose/index.ts +25 -0
  116. package/src/compose/logs.ts +162 -0
  117. package/src/compose/restart.ts +69 -0
  118. package/src/compose/start.ts +62 -0
  119. package/src/compose/status.ts +28 -0
  120. package/src/compose/stop.ts +43 -0
  121. package/src/config/config.ts +424 -0
  122. package/src/config/index.ts +25 -0
  123. package/src/config/providers.ts +234 -0
  124. package/src/config/reloadable.ts +47 -0
  125. package/src/container/index.ts +27 -0
  126. package/src/container/logs.ts +37 -0
  127. package/src/container/port.ts +137 -0
  128. package/src/container/shared.ts +290 -0
  129. package/src/container/shell.ts +58 -0
  130. package/src/container/start.ts +670 -0
  131. package/src/container/status.ts +76 -0
  132. package/src/container/stop.ts +120 -0
  133. package/src/container/verify-running.ts +149 -0
  134. package/src/cron/consumer.ts +138 -0
  135. package/src/cron/index.ts +54 -0
  136. package/src/cron/reloadable.ts +64 -0
  137. package/src/cron/scheduler.ts +200 -0
  138. package/src/cron/schema.ts +96 -0
  139. package/src/hostd/client.ts +113 -0
  140. package/src/hostd/daemon.ts +587 -0
  141. package/src/hostd/index.ts +25 -0
  142. package/src/hostd/paths.ts +82 -0
  143. package/src/hostd/portbroker-manager.ts +101 -0
  144. package/src/hostd/protocol.ts +48 -0
  145. package/src/hostd/spawn.ts +224 -0
  146. package/src/hostd/supervisor.ts +60 -0
  147. package/src/hostd/tailscale.ts +172 -0
  148. package/src/hostd/version.ts +115 -0
  149. package/src/init/dockerfile.ts +327 -0
  150. package/src/init/ensure-deps.ts +152 -0
  151. package/src/init/gitignore.ts +46 -0
  152. package/src/init/hatching.ts +60 -0
  153. package/src/init/index.ts +786 -0
  154. package/src/init/kakaotalk-auth.ts +114 -0
  155. package/src/init/models-dev.ts +130 -0
  156. package/src/init/oauth-login.ts +74 -0
  157. package/src/init/packagejson.ts +94 -0
  158. package/src/init/paths.ts +2 -0
  159. package/src/init/run-bun-install.ts +20 -0
  160. package/src/markdown/chunk.ts +299 -0
  161. package/src/markdown/index.ts +1 -0
  162. package/src/plugin/context.ts +40 -0
  163. package/src/plugin/define.ts +35 -0
  164. package/src/plugin/hooks.ts +204 -0
  165. package/src/plugin/index.ts +63 -0
  166. package/src/plugin/loader.ts +111 -0
  167. package/src/plugin/manager.ts +136 -0
  168. package/src/plugin/registry.ts +145 -0
  169. package/src/plugin/skills.ts +62 -0
  170. package/src/plugin/types.ts +172 -0
  171. package/src/portbroker/bind-with-forward.ts +102 -0
  172. package/src/portbroker/container-server.ts +305 -0
  173. package/src/portbroker/forward-result-bus.ts +36 -0
  174. package/src/portbroker/hostd-client.ts +443 -0
  175. package/src/portbroker/index.ts +33 -0
  176. package/src/portbroker/policy.ts +24 -0
  177. package/src/portbroker/proc-net-tcp.ts +72 -0
  178. package/src/portbroker/protocol.ts +39 -0
  179. package/src/reload/client.ts +59 -0
  180. package/src/reload/index.ts +3 -0
  181. package/src/reload/registry.ts +60 -0
  182. package/src/reload/types.ts +13 -0
  183. package/src/run/bundled-plugins.ts +24 -0
  184. package/src/run/channel-session-factory.ts +105 -0
  185. package/src/run/index.ts +432 -0
  186. package/src/run/plugin-runtime.ts +43 -0
  187. package/src/run/schema-with-plugins.ts +14 -0
  188. package/src/secrets/index.ts +13 -0
  189. package/src/secrets/migrate.ts +95 -0
  190. package/src/secrets/schema.ts +75 -0
  191. package/src/secrets/storage.ts +231 -0
  192. package/src/server/index.ts +436 -0
  193. package/src/sessions/index.ts +23 -0
  194. package/src/shared/index.ts +9 -0
  195. package/src/shared/local-time.ts +21 -0
  196. package/src/shared/protocol.ts +25 -0
  197. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
  198. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
  199. package/src/skills/typeclaw-config/SKILL.md +643 -0
  200. package/src/skills/typeclaw-cron/SKILL.md +159 -0
  201. package/src/skills/typeclaw-git/SKILL.md +89 -0
  202. package/src/skills/typeclaw-memory/SKILL.md +174 -0
  203. package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
  204. package/src/skills/typeclaw-plugins/SKILL.md +594 -0
  205. package/src/skills/typeclaw-skills/SKILL.md +246 -0
  206. package/src/stream/broker.ts +161 -0
  207. package/src/stream/index.ts +16 -0
  208. package/src/stream/types.ts +69 -0
  209. package/src/tui/client.ts +45 -0
  210. package/src/tui/format.ts +317 -0
  211. package/src/tui/index.ts +225 -0
  212. package/src/tui/theme.ts +41 -0
  213. package/typeclaw.schema.json +826 -0
@@ -0,0 +1,424 @@
1
+ import { accessSync, constants as fsConstants, readFileSync, statSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { isAbsolute, join, resolve } from 'node:path'
4
+
5
+ import type { Model } from '@mariozechner/pi-ai'
6
+ import { z } from 'zod'
7
+
8
+ import { channelsSchema } from '@/channels/schema'
9
+
10
+ import {
11
+ DEFAULT_MODEL_REF,
12
+ KNOWN_PROVIDERS,
13
+ listKnownModelRefs,
14
+ type KnownModelRef,
15
+ type KnownProviderId,
16
+ } from './providers'
17
+
18
+ const CONFIG_FILE = 'typeclaw.json'
19
+
20
+ const knownModelRefs = listKnownModelRefs() as [KnownModelRef, ...KnownModelRef[]]
21
+
22
+ // T9 keypad: T=8, Y=9, P=7, E=3
23
+ const DEFAULT_PORT = 8973
24
+
25
+ // Mount names land on disk as `mounts/<name>` inside the agent folder, so they
26
+ // share a namespace with regular filenames. Restricting to lowercase
27
+ // alphanumerics + `-`/`_` keeps them shell-safe and avoids accidental shadowing
28
+ // of files like `mounts/.git` or `mounts/Hello`.
29
+ const MOUNT_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]*$/
30
+
31
+ export const mountSchema = z.object({
32
+ name: z.string().regex(MOUNT_NAME_PATTERN, 'mount name must be lowercase alphanumeric with - or _'),
33
+ path: z.string().min(1),
34
+ readOnly: z.boolean().default(false),
35
+ description: z.string().optional(),
36
+ })
37
+
38
+ export type Mount = z.infer<typeof mountSchema>
39
+
40
+ const portNumber = z.number().int().min(1).max(65535)
41
+
42
+ // `allow` is the discriminator between "forward everything" ('*') and a fixed
43
+ // allowlist (number[]). `deny` is only meaningful when allow === '*'; combining
44
+ // it with a number[] allow is rejected at parse time so a typo doesn't silently
45
+ // drop the deny rule. An empty allowlist (`allow: []`) is the off switch.
46
+ export const portForwardSchema = z
47
+ .object({
48
+ allow: z.union([z.literal('*'), z.array(portNumber)]),
49
+ deny: z.array(portNumber).optional(),
50
+ })
51
+ .refine((v) => !(Array.isArray(v.allow) && v.deny !== undefined && v.deny.length > 0), {
52
+ message: 'portForward.deny is only meaningful when allow is "*"; remove deny or set allow to "*"',
53
+ path: ['deny'],
54
+ })
55
+ .default({ allow: '*' })
56
+
57
+ export type PortForward = z.infer<typeof portForwardSchema>
58
+
59
+ const dockerfileLineSchema = z.string().refine((line) => !/[\r\n]/.test(line), {
60
+ message: 'dockerfile.append entries must be single Dockerfile lines; split multiline instructions into array entries',
61
+ })
62
+
63
+ // A feature toggle is either a boolean (install latest / don't install) or a
64
+ // version string that becomes an apt pin (`pkg=<version>`). The string form
65
+ // rejects whitespace and `=` so the `pkg=<version>` invocation we pass to
66
+ // apt-get cannot be smuggled into a separate package or option flag.
67
+ const dockerfileFeatureSchema = z.union([
68
+ z.boolean(),
69
+ z
70
+ .string()
71
+ .min(1)
72
+ .refine((v) => !/[\s=]/.test(v), {
73
+ message: 'dockerfile feature version strings must not contain whitespace or "="',
74
+ }),
75
+ ])
76
+
77
+ // `default(() => ({}))` paired with field-level defaults is the idiom that
78
+ // makes both `dockerfile: {}` and an omitted `dockerfile` key resolve to the
79
+ // SAME fully-populated object. A plain `.default({})` would short-circuit the
80
+ // inner field defaults when the key is omitted, leaving downstream code with
81
+ // `{ append: undefined, tmux: undefined, ... }` and a `lines.length` crash.
82
+ const dockerfileObjectSchema = z.object({
83
+ ffmpeg: dockerfileFeatureSchema.default(false),
84
+ gh: dockerfileFeatureSchema.default(true),
85
+ python: z.boolean().default(true),
86
+ tmux: dockerfileFeatureSchema.default(true),
87
+ append: z.array(dockerfileLineSchema).default([]),
88
+ })
89
+
90
+ export const dockerfileSchema = dockerfileObjectSchema.default(() => dockerfileObjectSchema.parse({}))
91
+
92
+ export type DockerfileConfig = z.infer<typeof dockerfileSchema>
93
+ export type DockerfileFeatureToggle = z.infer<typeof dockerfileFeatureSchema>
94
+
95
+ 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',
97
+ })
98
+
99
+ export const gitignoreSchema = z
100
+ .object({
101
+ append: z.array(gitignoreLineSchema).default([]),
102
+ })
103
+ .default({ append: [] })
104
+
105
+ export type GitignoreConfig = z.infer<typeof gitignoreSchema>
106
+
107
+ export const configSchema = z
108
+ .object({
109
+ $schema: z.string().optional(),
110
+ port: z.number().int().min(1).max(65535).default(DEFAULT_PORT),
111
+ model: z.enum(knownModelRefs).default(DEFAULT_MODEL_REF),
112
+ // Defaults to `[]` so the field can be omitted from `typeclaw.json` (no
113
+ // host paths exposed) without failing the whole config load. `typeclaw
114
+ // init` omits this field so users don't see noise for the empty case.
115
+ mounts: z.array(mountSchema).default([]),
116
+ plugins: z.array(z.string().min(1)).default([]),
117
+ // Additional names the agent answers to in channel engagement, on top
118
+ // of `basename(agentDir)` which is always implicit. Each entry is a
119
+ // plain string matched case-insensitively as a substring of the
120
+ // inbound text. Empty/whitespace-only entries are rejected at parse
121
+ // time. Defaults to `[]`. Hatching appends the agent's chosen name
122
+ // here, so a freshly-hatched bot already has its identity wired up.
123
+ alias: z.array(z.string().trim().min(1)).default([]),
124
+ channels: channelsSchema,
125
+ portForward: portForwardSchema,
126
+ dockerfile: dockerfileSchema,
127
+ gitignore: gitignoreSchema,
128
+ })
129
+ .catchall(z.unknown())
130
+
131
+ export type Config = z.infer<typeof configSchema>
132
+
133
+ export function resolveModel(ref: KnownModelRef): Model<'openai-completions'> | Model<'openai-responses'> {
134
+ // Model IDs can contain '/', so split only on the first separator.
135
+ const slash = ref.indexOf('/')
136
+ const providerId = ref.slice(0, slash) as KnownProviderId
137
+ const modelId = ref.slice(slash + 1)
138
+ return KNOWN_PROVIDERS[providerId].models[modelId as never]
139
+ }
140
+
141
+ // Resolves a mount's `path` field to an absolute host path, mirroring shell
142
+ // expansion rules: `~`/`~/...` → home dir, relative → resolved against `cwd`,
143
+ // absolute → unchanged. Single source of truth so validation and Docker arg
144
+ // building agree on the resolved path.
145
+ export function expandMountPath(input: string, cwd: string): string {
146
+ if (input === '~' || input.startsWith('~/')) {
147
+ return join(homedir(), input.slice(1))
148
+ }
149
+ return isAbsolute(input) ? input : resolve(cwd, input)
150
+ }
151
+
152
+ // Loaded eagerly from process.cwd()/typeclaw.json at module-import time so
153
+ // citty arg defaults (e.g. config.port in src/cli/*.ts) see real values, not
154
+ // hardcoded fallbacks. Missing file → schema defaults; malformed file → throw,
155
+ // which surfaces during CLI startup instead of silently reverting to defaults
156
+ // and confusing the user.
157
+ //
158
+ // `config` is a module-import-time snapshot. Container-stage code that must
159
+ // observe `typeclaw run` reloads should call `getConfig()` instead, which
160
+ // returns the current swapped-in value. Host-stage CLI processes are
161
+ // short-lived, so they keep using `config` directly.
162
+ export const config: Config = loadConfigSync(process.cwd())
163
+
164
+ let current: Config = config
165
+
166
+ export function getConfig(): Config {
167
+ return current
168
+ }
169
+
170
+ // Test-only: restore the live pointer to the module-import-time snapshot. Lets
171
+ // reload-aware tests run without leaking a swapped pointer into other test
172
+ // files that still mutate the eager `config` export directly.
173
+ export function __resetConfigForTesting(): void {
174
+ current = config
175
+ }
176
+
177
+ export type ConfigChange = {
178
+ path: string
179
+ before: unknown
180
+ after: unknown
181
+ }
182
+
183
+ export type ConfigReloadDiff = {
184
+ applied: ConfigChange[]
185
+ restartRequired: ConfigChange[]
186
+ ignored: ConfigChange[]
187
+ }
188
+
189
+ // Reloads typeclaw.json from disk and atomically swaps the live config pointer
190
+ // on success. Throws (and leaves `current` untouched) when the file is
191
+ // malformed or schema-invalid — callers translate that into a `Reloadable`
192
+ // failure result.
193
+ export function reloadConfig(cwd: string): ConfigReloadDiff {
194
+ const next = loadConfigSync(cwd)
195
+ const diff = diffConfig(current, next)
196
+ current = next
197
+ return diff
198
+ }
199
+
200
+ // Field classification. The fence is intentional: only fields that are read
201
+ // fresh on each session/subagent/cron-reload land in `applied`. Boot-only
202
+ // fields (port, mounts, container/server bind) are reported as
203
+ // `restartRequired` so the user knows the reload landed but the change won't
204
+ // take effect until restart.
205
+ export type FieldEffect = 'applied' | 'restart-required' | 'ignored'
206
+
207
+ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
208
+ $schema: 'ignored',
209
+ model: 'applied',
210
+ port: 'restart-required',
211
+ mounts: 'restart-required',
212
+ plugins: 'restart-required',
213
+ alias: 'applied',
214
+ channels: 'applied',
215
+ portForward: 'restart-required',
216
+ dockerfile: 'restart-required',
217
+ gitignore: 'restart-required',
218
+ }
219
+
220
+ // Stable JSON for value comparison. Fields are small JSON-shaped objects, so
221
+ // JSON.stringify with sorted keys is sufficient and avoids a deep-equal dep.
222
+ function stableStringify(value: unknown): string {
223
+ if (value === undefined) return 'undefined'
224
+ return JSON.stringify(value, (_key, v: unknown) => {
225
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
226
+ const sorted: Record<string, unknown> = {}
227
+ for (const k of Object.keys(v as Record<string, unknown>).sort()) {
228
+ sorted[k] = (v as Record<string, unknown>)[k]
229
+ }
230
+ return sorted
231
+ }
232
+ return v
233
+ })
234
+ }
235
+
236
+ function diffConfig(before: Config, after: Config): ConfigReloadDiff {
237
+ const diff: ConfigReloadDiff = { applied: [], restartRequired: [], ignored: [] }
238
+ const keys = new Set<string>(Object.keys(FIELD_EFFECTS))
239
+
240
+ for (const path of keys) {
241
+ const b = readPath(before, path)
242
+ const a = readPath(after, path)
243
+ if (stableStringify(b) === stableStringify(a)) continue
244
+
245
+ const change: ConfigChange = { path, before: b, after: a }
246
+ const effect = FIELD_EFFECTS[path] ?? 'applied'
247
+ if (effect === 'applied') diff.applied.push(change)
248
+ else if (effect === 'restart-required') diff.restartRequired.push(change)
249
+ else diff.ignored.push(change)
250
+ }
251
+
252
+ return diff
253
+ }
254
+
255
+ function readPath(obj: unknown, path: string): unknown {
256
+ let cur: unknown = obj
257
+ for (const part of path.split('.')) {
258
+ if (cur === null || cur === undefined) return undefined
259
+ cur = (cur as Record<string, unknown>)[part]
260
+ }
261
+ return cur
262
+ }
263
+
264
+ // Plugin configs live at the top level of typeclaw.json keyed by plugin name
265
+ // (e.g. "standup-log": { ... }). They are preserved by configSchema.catchall(z.unknown())
266
+ // because the schema does not predeclare these keys. This helper returns the
267
+ // raw map of unknown values keyed by plugin name; the plugin loader re-validates
268
+ // each block against its plugin's `configSchema`.
269
+ export function extractPluginConfigs(raw: unknown): Record<string, unknown> {
270
+ if (typeof raw !== 'object' || raw === null) return {}
271
+ const known = new Set([
272
+ '$schema',
273
+ 'port',
274
+ 'model',
275
+ 'mounts',
276
+ 'plugins',
277
+ 'channels',
278
+ 'portForward',
279
+ 'dockerfile',
280
+ 'gitignore',
281
+ ])
282
+ const result: Record<string, unknown> = {}
283
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
284
+ if (!known.has(key)) result[key] = value
285
+ }
286
+ return result
287
+ }
288
+
289
+ export function loadPluginConfigsSync(cwd: string): Record<string, unknown> {
290
+ let raw: string
291
+ try {
292
+ raw = readFileSync(join(cwd, CONFIG_FILE), 'utf8')
293
+ } catch {
294
+ return {}
295
+ }
296
+ let json: unknown
297
+ try {
298
+ json = JSON.parse(raw)
299
+ } catch {
300
+ return {}
301
+ }
302
+ return extractPluginConfigs(json)
303
+ }
304
+
305
+ export function loadConfigSync(cwd: string): Config {
306
+ let raw: string
307
+ try {
308
+ raw = readFileSync(join(cwd, CONFIG_FILE), 'utf8')
309
+ } catch {
310
+ return configSchema.parse({})
311
+ }
312
+
313
+ let json: unknown
314
+ try {
315
+ json = JSON.parse(raw)
316
+ } catch (error) {
317
+ const detail = error instanceof Error ? error.message : String(error)
318
+ throw new Error(`${CONFIG_FILE} is not valid JSON: ${detail}`)
319
+ }
320
+
321
+ const result = configSchema.safeParse(json)
322
+ if (!result.success) {
323
+ throw new Error(`${CONFIG_FILE} is invalid: ${formatZodError(result.error)}`)
324
+ }
325
+ return result.data
326
+ }
327
+
328
+ export type ValidateConfigResult = { ok: true } | { ok: false; reason: string }
329
+
330
+ // Missing file → ok (matches `loadMounts` in src/container/up.ts; `isInitialized`
331
+ // is the dedicated check for "not initialized"). Present but invalid → fail, so
332
+ // `restart` doesn't stop the container before discovering the config is broken.
333
+ //
334
+ // Mount accessibility is checked here (after schema parse succeeds) so every
335
+ // caller — `typeclaw start`, `restart`, `reload`, hostd's restart RPC — fails
336
+ // fast with a clear, mount-named error instead of letting Docker surface a
337
+ // confusing path-sharing error (or, on some Linux setups, silently bind-mount
338
+ // an empty auto-created directory). First-failure reporting matches the
339
+ // schema-error path's shape; users fix one and re-run.
340
+ export function validateConfig(cwd: string): ValidateConfigResult {
341
+ let raw: string
342
+ try {
343
+ raw = readFileSync(join(cwd, CONFIG_FILE), 'utf8')
344
+ } catch {
345
+ return { ok: true }
346
+ }
347
+
348
+ let json: unknown
349
+ try {
350
+ json = JSON.parse(raw)
351
+ } catch (error) {
352
+ const detail = error instanceof Error ? error.message : String(error)
353
+ return { ok: false, reason: `${CONFIG_FILE} is not valid JSON: ${detail}` }
354
+ }
355
+
356
+ const result = configSchema.safeParse(json)
357
+ if (!result.success) {
358
+ return { ok: false, reason: `${CONFIG_FILE} is invalid: ${formatZodError(result.error)}` }
359
+ }
360
+
361
+ for (const mount of result.data.mounts) {
362
+ const check = validateMount(mount, cwd)
363
+ if (!check.ok) return check
364
+ }
365
+
366
+ return { ok: true }
367
+ }
368
+
369
+ // Verifies a mount's host path: exists, is a directory, is readable, and is
370
+ // writable when not declared `readOnly`. Symlinks are followed (statSync's
371
+ // default) so a broken symlink reads as "does not exist". Permission checks
372
+ // are skipped when running as root (uid 0) — euidaccess returns success
373
+ // regardless, so the test would be vacuous and inconsistent with non-root.
374
+ export function validateMount(mount: Mount, cwd: string): ValidateConfigResult {
375
+ const resolved = expandMountPath(mount.path, cwd)
376
+ const label = `mount "${mount.name}"`
377
+
378
+ let stats: ReturnType<typeof statSync>
379
+ try {
380
+ stats = statSync(resolved)
381
+ } catch (error) {
382
+ const code = (error as NodeJS.ErrnoException).code
383
+ if (code === 'ENOENT') {
384
+ return { ok: false, reason: `${label}: path ${resolved} does not exist` }
385
+ }
386
+ const detail = error instanceof Error ? error.message : String(error)
387
+ return { ok: false, reason: `${label}: cannot stat ${resolved}: ${detail}` }
388
+ }
389
+
390
+ if (!stats.isDirectory()) {
391
+ return { ok: false, reason: `${label}: path ${resolved} is not a directory` }
392
+ }
393
+
394
+ const isRoot = typeof process.getuid === 'function' && process.getuid() === 0
395
+ if (isRoot) return { ok: true }
396
+
397
+ try {
398
+ accessSync(resolved, fsConstants.R_OK)
399
+ } catch {
400
+ return { ok: false, reason: `${label}: path ${resolved} is not readable` }
401
+ }
402
+
403
+ if (!mount.readOnly) {
404
+ try {
405
+ accessSync(resolved, fsConstants.W_OK)
406
+ } catch {
407
+ return {
408
+ ok: false,
409
+ reason: `${label}: path ${resolved} is not writable (declare readOnly: true if read-only access is intended)`,
410
+ }
411
+ }
412
+ }
413
+
414
+ return { ok: true }
415
+ }
416
+
417
+ function formatZodError(error: z.ZodError): string {
418
+ return error.issues
419
+ .map((issue) => {
420
+ const path = issue.path.length > 0 ? issue.path.join('.') : '<root>'
421
+ return `${path}: ${issue.message}`
422
+ })
423
+ .join('; ')
424
+ }
@@ -0,0 +1,25 @@
1
+ export {
2
+ config,
3
+ configSchema,
4
+ expandMountPath,
5
+ extractPluginConfigs,
6
+ getConfig,
7
+ loadConfigSync,
8
+ loadPluginConfigsSync,
9
+ mountSchema,
10
+ dockerfileSchema,
11
+ portForwardSchema,
12
+ reloadConfig,
13
+ resolveModel,
14
+ validateConfig,
15
+ validateMount,
16
+ type Config,
17
+ type ConfigChange,
18
+ type ConfigReloadDiff,
19
+ type DockerfileConfig,
20
+ type Mount,
21
+ type PortForward,
22
+ type ValidateConfigResult,
23
+ } from './config'
24
+ export { type KnownModelRef, type KnownProviderId } from './providers'
25
+ export { createConfigReloadable, type CreateConfigReloadableOptions } from './reloadable'
@@ -0,0 +1,234 @@
1
+ import type { Api, Model } from '@mariozechner/pi-ai'
2
+
3
+ // Authentication mechanism a provider supports. `api-key` reads a static key
4
+ // from .env (the original path); `oauth` runs a browser flow at init time and
5
+ // stores rotating credentials in secrets.json. The CLI picker uses this to ask
6
+ // "API key or OAuth?" only when both are wired up.
7
+ export type AuthMethod = 'api-key' | 'oauth'
8
+
9
+ // `apiKeyEnv` and `oauthProviderId` are both always present on the literal
10
+ // to keep `as const satisfies` narrowing easy on the consumer side; entries
11
+ // that don't apply to a given provider are set to `null` rather than omitted.
12
+ // Consumers check `auth.includes('api-key')` / `auth.includes('oauth')` to
13
+ // decide which field to consult.
14
+ type KnownProvider = {
15
+ id: string
16
+ name: string
17
+ baseUrl: string
18
+ auth: ReadonlyArray<AuthMethod>
19
+ apiKeyEnv: string | null
20
+ oauthProviderId: string | null
21
+ models: Record<string, Model<Api>>
22
+ }
23
+
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
27
+ // init-time picker may surface additional models from models.dev, but it
28
+ // resolves them through this list before scaffolding (anything missing falls
29
+ // back to a curated default).
30
+ //
31
+ // Adding a new model: append it to the matching provider's `models` map. Each
32
+ // model object is the literal `Model<...>` that pi-ai consumes — keep it
33
+ // faithful to https://github.com/mariozechner/pi-ai (the readme's "Custom
34
+ // Models" section). `setRuntimeApiKey(provider, key)` keys off the `provider`
35
+ // field, so it MUST match the outer provider id.
36
+ //
37
+ // Adding a new provider: add a top-level entry. Set `auth` to the supported
38
+ // methods. For `api-key` providers, `apiKeyEnv` is the .env var typeclaw
39
+ // writes at init and reads at boot (match the upstream provider's standard,
40
+ // e.g. `OPENAI_API_KEY`). For `oauth` providers, `oauthProviderId` MUST match
41
+ // a pi-ai OAuth provider id exactly, otherwise `authStorage.login()` will
42
+ // throw "Unknown OAuth provider".
43
+ export const KNOWN_PROVIDERS = {
44
+ openai: {
45
+ id: 'openai',
46
+ name: 'OpenAI',
47
+ // OpenAI's library auto-detects this from `provider: 'openai'`, but we
48
+ // store it explicitly so the init wizard can show users which endpoint
49
+ // their key will hit.
50
+ baseUrl: 'https://api.openai.com/v1',
51
+ auth: ['api-key'],
52
+ apiKeyEnv: 'OPENAI_API_KEY',
53
+ oauthProviderId: null,
54
+ // Costs and context windows mirror models.dev as of 2026-05-10. When
55
+ // refreshing, also rerun `scripts/generate-schema.ts` so typeclaw.schema.json
56
+ // picks up new enum values.
57
+ models: {
58
+ // Default. Cheapest tool-calling reasoning model in the family;
59
+ // available on every paid OpenAI account tier.
60
+ 'gpt-5.4-nano': {
61
+ id: 'gpt-5.4-nano',
62
+ name: 'GPT-5.4 nano',
63
+ api: 'openai-responses',
64
+ provider: 'openai',
65
+ baseUrl: 'https://api.openai.com/v1',
66
+ reasoning: true,
67
+ input: ['text', 'image'],
68
+ cost: { input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0 },
69
+ contextWindow: 400000,
70
+ maxTokens: 128000,
71
+ },
72
+ 'gpt-5.4-mini': {
73
+ id: 'gpt-5.4-mini',
74
+ name: 'GPT-5.4 mini',
75
+ api: 'openai-responses',
76
+ provider: 'openai',
77
+ baseUrl: 'https://api.openai.com/v1',
78
+ reasoning: true,
79
+ input: ['text', 'image'],
80
+ cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
81
+ contextWindow: 400000,
82
+ maxTokens: 128000,
83
+ },
84
+ 'gpt-5.4': {
85
+ id: 'gpt-5.4',
86
+ name: 'GPT-5.4',
87
+ api: 'openai-responses',
88
+ provider: 'openai',
89
+ baseUrl: 'https://api.openai.com/v1',
90
+ reasoning: true,
91
+ input: ['text', 'image'],
92
+ cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
93
+ contextWindow: 1050000,
94
+ maxTokens: 128000,
95
+ },
96
+ 'gpt-5.5': {
97
+ id: 'gpt-5.5',
98
+ name: 'GPT-5.5',
99
+ api: 'openai-responses',
100
+ provider: 'openai',
101
+ baseUrl: 'https://api.openai.com/v1',
102
+ reasoning: true,
103
+ input: ['text', 'image'],
104
+ cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
105
+ contextWindow: 1050000,
106
+ maxTokens: 128000,
107
+ },
108
+ },
109
+ },
110
+ // ChatGPT Plus/Pro subscription via the OAuth Codex backend. No API key
111
+ // path here on purpose — the Codex backend is OAuth-only upstream.
112
+ //
113
+ // pi-ai 0.73.1's `openai-codex` bucket carries gpt-5.5 (and 5.4) against
114
+ // chatgpt.com/backend-api. We pin pi-coding-agent ^0.67.3 today, which
115
+ // ships pi-ai 0.67.3 and lacks those entries — but we hand pi-ai a
116
+ // freshly-constructed `Model<>` literal via resolveModel(), bypassing its
117
+ // built-in catalog entirely (same trick we use for kimi-k2p6-turbo). So
118
+ // these ids work end-to-end as long as the Codex backend itself accepts
119
+ // them, which it does for ChatGPT Plus/Pro accounts as of 2026-05-10.
120
+ 'openai-codex': {
121
+ id: 'openai-codex',
122
+ name: 'OpenAI Codex (ChatGPT Plus/Pro)',
123
+ baseUrl: 'https://chatgpt.com/backend-api',
124
+ auth: ['oauth'],
125
+ apiKeyEnv: null,
126
+ oauthProviderId: 'openai-codex',
127
+ models: {
128
+ 'gpt-5.4-mini': {
129
+ id: 'gpt-5.4-mini',
130
+ name: 'GPT-5.4 mini',
131
+ api: 'openai-codex-responses',
132
+ provider: 'openai-codex',
133
+ baseUrl: 'https://chatgpt.com/backend-api',
134
+ reasoning: true,
135
+ input: ['text', 'image'],
136
+ cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
137
+ contextWindow: 272000,
138
+ maxTokens: 128000,
139
+ },
140
+ 'gpt-5.4': {
141
+ id: 'gpt-5.4',
142
+ name: 'GPT-5.4',
143
+ api: 'openai-codex-responses',
144
+ provider: 'openai-codex',
145
+ baseUrl: 'https://chatgpt.com/backend-api',
146
+ reasoning: true,
147
+ input: ['text', 'image'],
148
+ cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
149
+ contextWindow: 272000,
150
+ maxTokens: 128000,
151
+ },
152
+ 'gpt-5.5': {
153
+ id: 'gpt-5.5',
154
+ name: 'GPT-5.5',
155
+ api: 'openai-codex-responses',
156
+ provider: 'openai-codex',
157
+ baseUrl: 'https://chatgpt.com/backend-api',
158
+ reasoning: true,
159
+ input: ['text', 'image'],
160
+ cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
161
+ contextWindow: 272000,
162
+ maxTokens: 128000,
163
+ },
164
+ },
165
+ },
166
+ fireworks: {
167
+ id: 'fireworks',
168
+ name: 'Fireworks',
169
+ baseUrl: 'https://api.fireworks.ai/inference/v1',
170
+ auth: ['api-key'],
171
+ apiKeyEnv: 'FIREWORKS_API_KEY',
172
+ oauthProviderId: null,
173
+ models: {
174
+ // Kept available even though models.dev hasn't indexed it yet —
175
+ // Fireworks ships this router as an alias to the latest k2.6 weights.
176
+ 'accounts/fireworks/routers/kimi-k2p6-turbo': {
177
+ id: 'accounts/fireworks/routers/kimi-k2p6-turbo',
178
+ name: 'Kimi K2.6 Turbo',
179
+ api: 'openai-completions',
180
+ provider: 'fireworks',
181
+ baseUrl: 'https://api.fireworks.ai/inference/v1',
182
+ reasoning: true,
183
+ input: ['text', 'image'],
184
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
185
+ contextWindow: 256000,
186
+ maxTokens: 256000,
187
+ },
188
+ },
189
+ },
190
+ } as const satisfies Record<string, KnownProvider>
191
+
192
+ export type KnownProviderId = keyof typeof KNOWN_PROVIDERS
193
+
194
+ export type KnownModelRef = {
195
+ [P in KnownProviderId]: `${P}/${Extract<keyof (typeof KNOWN_PROVIDERS)[P]['models'], string>}`
196
+ }[KnownProviderId]
197
+
198
+ export function listKnownModelRefs(): KnownModelRef[] {
199
+ const refs: string[] = []
200
+ for (const providerId of Object.keys(KNOWN_PROVIDERS) as KnownProviderId[]) {
201
+ for (const modelId of Object.keys(KNOWN_PROVIDERS[providerId].models)) {
202
+ refs.push(`${providerId}/${modelId}`)
203
+ }
204
+ }
205
+ return refs as KnownModelRef[]
206
+ }
207
+
208
+ // The default we hand to scaffolded `typeclaw.json` and the schema's
209
+ // `model.default`. Lives here (next to the provider table) so adding a model
210
+ // can't drift from the field default — both come from the same module.
211
+ export const DEFAULT_MODEL_REF: KnownModelRef = 'openai/gpt-5.4-nano'
212
+
213
+ export function providerForModelRef(ref: KnownModelRef): KnownProviderId {
214
+ // KnownModelRef is `${provider}/${modelId}`, but provider IDs themselves can
215
+ // contain '-' and model IDs can contain '/' (Fireworks). We split on the
216
+ // first slash that follows a registered provider id.
217
+ for (const providerId of Object.keys(KNOWN_PROVIDERS) as KnownProviderId[]) {
218
+ if (ref.startsWith(`${providerId}/`)) return providerId
219
+ }
220
+ throw new Error(`Unknown provider in model ref: ${ref}`)
221
+ }
222
+
223
+ // `as const satisfies` narrows each entry's `auth` to a tuple of its specific
224
+ // literal values, which makes `provider.auth.includes('oauth')` fail to
225
+ // compile on api-key-only entries (because TS thinks the array can never
226
+ // contain 'oauth'). These accessors widen the membership check back to
227
+ // AuthMethod so consumers can branch without per-provider casts.
228
+ export function supportsApiKey(provider: { auth: ReadonlyArray<AuthMethod> }): boolean {
229
+ return (provider.auth as ReadonlyArray<AuthMethod>).includes('api-key')
230
+ }
231
+
232
+ export function supportsOAuth(provider: { auth: ReadonlyArray<AuthMethod> }): boolean {
233
+ return (provider.auth as ReadonlyArray<AuthMethod>).includes('oauth')
234
+ }