typeclaw 0.36.8 → 0.37.1

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 (112) hide show
  1. package/README.md +3 -3
  2. package/package.json +3 -2
  3. package/src/agent/index.ts +31 -11
  4. package/src/agent/live-sessions.ts +12 -0
  5. package/src/agent/model-fallback.ts +17 -15
  6. package/src/agent/model-overrides.ts +2 -2
  7. package/src/agent/session-meta.ts +10 -0
  8. package/src/agent/subagents.ts +30 -3
  9. package/src/agent/system-prompt.ts +9 -3
  10. package/src/agent/todo/continuation-policy.ts +6 -3
  11. package/src/agent/todo/continuation-wiring.ts +4 -2
  12. package/src/agent/todo/continuation.ts +3 -3
  13. package/src/agent/tools/todo/index.ts +27 -4
  14. package/src/bundled-plugins/agent-browser/index.ts +33 -108
  15. package/src/bundled-plugins/agent-browser/shim.ts +3 -94
  16. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +8 -33
  17. package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +2 -2
  18. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +7 -1
  19. package/src/bundled-plugins/memory/README.md +80 -23
  20. package/src/bundled-plugins/memory/append-tool.ts +74 -53
  21. package/src/bundled-plugins/memory/citation-superset.ts +4 -0
  22. package/src/bundled-plugins/memory/citations.ts +54 -0
  23. package/src/bundled-plugins/memory/dreaming-metrics.ts +30 -0
  24. package/src/bundled-plugins/memory/dreaming.ts +444 -21
  25. package/src/bundled-plugins/memory/index.ts +544 -400
  26. package/src/bundled-plugins/memory/load-memory.ts +87 -10
  27. package/src/bundled-plugins/memory/load-shards.ts +48 -22
  28. package/src/bundled-plugins/memory/memory-logger.ts +95 -106
  29. package/src/bundled-plugins/memory/memory-retrieval.ts +3 -3
  30. package/src/bundled-plugins/memory/parent-link.ts +33 -0
  31. package/src/bundled-plugins/memory/paths.ts +12 -0
  32. package/src/bundled-plugins/memory/references/frontmatter.ts +197 -0
  33. package/src/bundled-plugins/memory/references/load-references.ts +212 -0
  34. package/src/bundled-plugins/memory/references/store-reference-tool.ts +59 -0
  35. package/src/bundled-plugins/memory/search-tool.ts +282 -45
  36. package/src/bundled-plugins/memory/stream-events.ts +1 -0
  37. package/src/bundled-plugins/memory/stream-io.ts +28 -3
  38. package/src/bundled-plugins/memory/turn-dedup.ts +40 -0
  39. package/src/bundled-plugins/memory/vector/cache-write.ts +19 -0
  40. package/src/bundled-plugins/memory/vector/config.ts +28 -0
  41. package/src/bundled-plugins/memory/vector/doctor.ts +124 -0
  42. package/src/bundled-plugins/memory/vector/embedder.ts +246 -0
  43. package/src/bundled-plugins/memory/vector/hybrid.ts +439 -0
  44. package/src/bundled-plugins/memory/vector/index-on-write.ts +34 -0
  45. package/src/bundled-plugins/memory/vector/inspect.ts +111 -0
  46. package/src/bundled-plugins/memory/vector/passages.ts +125 -0
  47. package/src/bundled-plugins/memory/vector/reference-index-on-write.ts +50 -0
  48. package/src/bundled-plugins/memory/vector/relevance-gate.ts +93 -0
  49. package/src/bundled-plugins/memory/vector/startup.ts +71 -0
  50. package/src/bundled-plugins/memory/vector/store.ts +203 -0
  51. package/src/bundled-plugins/memory/vector/truncation.ts +124 -0
  52. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +2 -0
  53. package/src/channels/router.ts +239 -40
  54. package/src/cli/incomplete-init.ts +57 -0
  55. package/src/cli/init.ts +166 -18
  56. package/src/cli/inspect.ts +11 -5
  57. package/src/cli/model.ts +115 -36
  58. package/src/cli/provider.ts +5 -3
  59. package/src/cli/restart.ts +24 -0
  60. package/src/cli/start.ts +24 -0
  61. package/src/cli/tunnel.ts +53 -8
  62. package/src/config/config.ts +110 -19
  63. package/src/config/index.ts +5 -1
  64. package/src/config/models-mutation.ts +29 -11
  65. package/src/config/providers-mutation.ts +2 -2
  66. package/src/config/providers.ts +146 -12
  67. package/src/container/shared.ts +9 -0
  68. package/src/container/start.ts +87 -4
  69. package/src/cron/consumer.ts +13 -7
  70. package/src/hostd/models.ts +64 -0
  71. package/src/hostd/paths.ts +6 -0
  72. package/src/hostd/portbroker-manager.ts +2 -2
  73. package/src/init/checkpoint.ts +201 -0
  74. package/src/init/dockerfile.ts +121 -34
  75. package/src/init/gitignore.ts +7 -7
  76. package/src/init/index.ts +41 -9
  77. package/src/init/models-dev.ts +96 -21
  78. package/src/init/oauth-login.ts +3 -3
  79. package/src/init/progress.ts +29 -0
  80. package/src/init/validate-api-key.ts +4 -0
  81. package/src/inspect/index.ts +13 -6
  82. package/src/inspect/item-list.ts +11 -2
  83. package/src/inspect/live-list.ts +65 -0
  84. package/src/inspect/open-item.ts +22 -1
  85. package/src/inspect/session-list.ts +29 -0
  86. package/src/models/embedding-model.ts +114 -0
  87. package/src/models/transformers-version.ts +55 -0
  88. package/src/plugin/types.ts +3 -0
  89. package/src/portbroker/container-server.ts +23 -0
  90. package/src/portbroker/forward-request-bus.ts +35 -0
  91. package/src/portbroker/forward-result-bus.ts +2 -3
  92. package/src/portbroker/hostd-client.ts +182 -36
  93. package/src/portbroker/index.ts +6 -1
  94. package/src/portbroker/protocol.ts +9 -2
  95. package/src/run/channel-session-factory.ts +11 -1
  96. package/src/run/index.ts +65 -8
  97. package/src/server/command-runner.ts +24 -1
  98. package/src/server/index.ts +42 -8
  99. package/src/shared/index.ts +2 -0
  100. package/src/shared/protocol.ts +31 -0
  101. package/src/skills/typeclaw-channels/SKILL.md +4 -4
  102. package/src/skills/typeclaw-config/SKILL.md +2 -2
  103. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  104. package/src/skills/typeclaw-permissions/SKILL.md +3 -3
  105. package/src/skills/typeclaw-skills/SKILL.md +1 -1
  106. package/src/skills/typeclaw-tunnels/SKILL.md +22 -1
  107. package/src/tunnels/providers/cloudflare-quick.ts +65 -7
  108. package/src/tunnels/upstream-probe.ts +25 -0
  109. package/typeclaw.schema.json +156 -67
  110. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +0 -170
  111. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +0 -421
  112. package/src/portbroker/bind-with-forward.ts +0 -102
@@ -0,0 +1,201 @@
1
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import {
5
+ KNOWN_PROVIDERS,
6
+ listKnownModelRefs,
7
+ listKnownProviderVendorIds,
8
+ providerIdsForVendor,
9
+ type KnownProviderId,
10
+ type KnownProviderVendorId,
11
+ type ModelRef,
12
+ } from '@/config/providers'
13
+
14
+ // In-folder scratch for an in-progress `typeclaw init`, co-located with the
15
+ // agent it describes. Lives under the agent's `.typeclaw/` (the same gitignored
16
+ // local-scratch dir as the persistent-$HOME overlay), so it self-cleans when
17
+ // the half-init folder is deleted, survives a folder rename mid-init, and is
18
+ // inspectable right next to the thing being resumed. Gitignored via
19
+ // `TRULY_IGNORED_PATTERNS`; never committed.
20
+ export const INIT_CHECKPOINT_PATH = join('.typeclaw', 'init-progress.json')
21
+
22
+ export const WIZARD_CHECKPOINT_VERSION = 1
23
+
24
+ export type WizardChannelChoice = 'slack' | 'discord' | 'telegram' | 'kakaotalk' | 'github' | 'none'
25
+
26
+ export type AuthMethod = 'api-key' | 'oauth'
27
+
28
+ // Only stable selection IDs — never `llmAuth`, tokens, OAuth data, channel
29
+ // secrets, the volatile models.dev catalog, or full `ModelOption` objects. The
30
+ // projection in `checkpointFromSelections` is the single seam that enforces
31
+ // this, so a secret can never leak into host state by accident.
32
+ export interface WizardAnswerCheckpointV1 {
33
+ version: typeof WIZARD_CHECKPOINT_VERSION
34
+ cwd: string
35
+ updatedAt: string
36
+ vendorId?: KnownProviderVendorId
37
+ providerId?: KnownProviderId
38
+ modelRef?: ModelRef | string
39
+ authMethod?: AuthMethod
40
+ visionVendorId?: KnownProviderVendorId
41
+ visionProviderId?: KnownProviderId
42
+ visionModelRef?: ModelRef | string
43
+ visionAuthMethod?: AuthMethod
44
+ channelChoice?: WizardChannelChoice
45
+ }
46
+
47
+ export interface WizardCheckpointStore {
48
+ load(cwd: string): Promise<WizardAnswerCheckpointV1 | undefined>
49
+ save(cwd: string, checkpoint: WizardAnswerCheckpointV1): Promise<void>
50
+ clear(cwd: string): Promise<void>
51
+ }
52
+
53
+ // Selections the wizard already holds, projected to the persisted shape. Keep
54
+ // this the ONLY place that reads `WizardState` so secret fields are physically
55
+ // unable to reach the checkpoint file.
56
+ export interface WizardCheckpointSelections {
57
+ cwd: string
58
+ vendorId?: KnownProviderVendorId
59
+ providerId?: KnownProviderId
60
+ modelRef?: ModelRef | string
61
+ authMethod?: AuthMethod
62
+ visionVendorId?: KnownProviderVendorId
63
+ visionProviderId?: KnownProviderId
64
+ visionModelRef?: ModelRef | string
65
+ visionAuthMethod?: AuthMethod
66
+ channelChoice?: WizardChannelChoice
67
+ }
68
+
69
+ export function checkpointFromSelections(selections: WizardCheckpointSelections): WizardAnswerCheckpointV1 {
70
+ return {
71
+ version: WIZARD_CHECKPOINT_VERSION,
72
+ cwd: selections.cwd,
73
+ updatedAt: new Date().toISOString(),
74
+ ...(selections.vendorId !== undefined ? { vendorId: selections.vendorId } : {}),
75
+ ...(selections.providerId !== undefined ? { providerId: selections.providerId } : {}),
76
+ ...(selections.modelRef !== undefined ? { modelRef: selections.modelRef } : {}),
77
+ ...(selections.authMethod !== undefined ? { authMethod: selections.authMethod } : {}),
78
+ ...(selections.visionVendorId !== undefined ? { visionVendorId: selections.visionVendorId } : {}),
79
+ ...(selections.visionProviderId !== undefined ? { visionProviderId: selections.visionProviderId } : {}),
80
+ ...(selections.visionModelRef !== undefined ? { visionModelRef: selections.visionModelRef } : {}),
81
+ ...(selections.visionAuthMethod !== undefined ? { visionAuthMethod: selections.visionAuthMethod } : {}),
82
+ ...(selections.channelChoice !== undefined ? { channelChoice: selections.channelChoice } : {}),
83
+ }
84
+ }
85
+
86
+ // Drop any saved field that no longer references a real vendor/provider/model.
87
+ // Stale values cascade downward: an unknown provider invalidates its model and
88
+ // auth-method too, since those were chosen for a provider that no longer
89
+ // exists. Returns a sanitized copy; never throws on drift.
90
+ export function sanitizeCheckpointAgainstCatalog(
91
+ checkpoint: WizardAnswerCheckpointV1,
92
+ validModelRefs: ReadonlySet<string> = new Set(listKnownModelRefs()),
93
+ ): WizardAnswerCheckpointV1 {
94
+ const sanitized: WizardAnswerCheckpointV1 = {
95
+ version: WIZARD_CHECKPOINT_VERSION,
96
+ cwd: checkpoint.cwd,
97
+ updatedAt: checkpoint.updatedAt,
98
+ }
99
+
100
+ const vendor = pruneVendor(checkpoint.vendorId)
101
+ const provider = pruneProvider(vendor, checkpoint.providerId)
102
+ if (vendor !== undefined) sanitized.vendorId = vendor
103
+ if (provider !== undefined) {
104
+ sanitized.providerId = provider
105
+ if (checkpoint.modelRef !== undefined && validModelRefs.has(checkpoint.modelRef)) {
106
+ sanitized.modelRef = checkpoint.modelRef
107
+ }
108
+ if (checkpoint.authMethod !== undefined) sanitized.authMethod = checkpoint.authMethod
109
+ }
110
+
111
+ const visionVendor = pruneVendor(checkpoint.visionVendorId)
112
+ const visionProvider = pruneProvider(visionVendor, checkpoint.visionProviderId)
113
+ if (visionVendor !== undefined) sanitized.visionVendorId = visionVendor
114
+ if (visionProvider !== undefined) {
115
+ sanitized.visionProviderId = visionProvider
116
+ if (checkpoint.visionModelRef !== undefined && validModelRefs.has(checkpoint.visionModelRef)) {
117
+ sanitized.visionModelRef = checkpoint.visionModelRef
118
+ }
119
+ if (checkpoint.visionAuthMethod !== undefined) sanitized.visionAuthMethod = checkpoint.visionAuthMethod
120
+ }
121
+
122
+ if (checkpoint.channelChoice !== undefined) sanitized.channelChoice = checkpoint.channelChoice
123
+
124
+ return sanitized
125
+ }
126
+
127
+ function pruneVendor(vendorId: KnownProviderVendorId | undefined): KnownProviderVendorId | undefined {
128
+ if (vendorId === undefined) return undefined
129
+ return listKnownProviderVendorIds().includes(vendorId) ? vendorId : undefined
130
+ }
131
+
132
+ function pruneProvider(
133
+ vendorId: KnownProviderVendorId | undefined,
134
+ providerId: KnownProviderId | undefined,
135
+ ): KnownProviderId | undefined {
136
+ if (vendorId === undefined || providerId === undefined) return undefined
137
+ if (!(providerId in KNOWN_PROVIDERS)) return undefined
138
+ return providerIdsForVendor(vendorId).includes(providerId) ? providerId : undefined
139
+ }
140
+
141
+ function checkpointFilePath(cwd: string): string {
142
+ return join(cwd, INIT_CHECKPOINT_PATH)
143
+ }
144
+
145
+ export function createLocalWizardCheckpointStore(): WizardCheckpointStore {
146
+ return {
147
+ async load(cwd) {
148
+ const path = checkpointFilePath(cwd)
149
+ let parsed: unknown
150
+ try {
151
+ parsed = JSON.parse(await readFile(path, 'utf8'))
152
+ } catch {
153
+ return undefined
154
+ }
155
+ if (!isValidCheckpoint(parsed)) return undefined
156
+ return parsed
157
+ },
158
+
159
+ // Atomic write: temp + rename within the agent's .typeclaw/ so a crash
160
+ // mid-write never leaves a half-written file that `load` would misparse and
161
+ // discard.
162
+ async save(cwd, checkpoint) {
163
+ const final = checkpointFilePath(cwd)
164
+ const tmp = `${final}.${process.pid}.tmp`
165
+ await mkdir(dirname(final), { recursive: true })
166
+ await writeFile(tmp, JSON.stringify(checkpoint), { mode: 0o600 })
167
+ await rename(tmp, final)
168
+ },
169
+
170
+ async clear(cwd) {
171
+ try {
172
+ await unlink(checkpointFilePath(cwd))
173
+ } catch {}
174
+ },
175
+ }
176
+ }
177
+
178
+ function isValidCheckpoint(value: unknown): value is WizardAnswerCheckpointV1 {
179
+ if (typeof value !== 'object' || value === null) return false
180
+ const v = value as Record<string, unknown>
181
+ if (v.version !== WIZARD_CHECKPOINT_VERSION) return false
182
+ if (typeof v.cwd !== 'string') return false
183
+ if (typeof v.updatedAt !== 'string') return false
184
+ // Every optional selection field must be a string when present. Membership
185
+ // (is this a real provider/model?) is the sanitizer's job, but a non-string
186
+ // here is structurally corrupt and would later index KNOWN_PROVIDERS or join
187
+ // into prompt text with a wrong shape — reject it at load.
188
+ return OPTIONAL_STRING_FIELDS.every((field) => v[field] === undefined || typeof v[field] === 'string')
189
+ }
190
+
191
+ const OPTIONAL_STRING_FIELDS = [
192
+ 'vendorId',
193
+ 'providerId',
194
+ 'modelRef',
195
+ 'authMethod',
196
+ 'visionVendorId',
197
+ 'visionProviderId',
198
+ 'visionModelRef',
199
+ 'visionAuthMethod',
200
+ 'channelChoice',
201
+ ] as const satisfies ReadonlyArray<keyof WizardAnswerCheckpointV1>
@@ -18,6 +18,10 @@ export type BuildDockerfileOptions = {
18
18
  // host signal — e.g. a bare `buildDockerfile()` in tests — stays small and
19
19
  // deterministic.
20
20
  cjkFontsAuto?: boolean
21
+ // Emit a BuildKit Dockerfile (default) or strip BuildKit-only directives for
22
+ // hosts without buildx. `start` sets this to false when buildx is absent so
23
+ // the legacy `docker build` can still build the agent image.
24
+ buildKit?: boolean
21
25
  }
22
26
 
23
27
  // Apt packages that EVERY image must have — git for the agent runtime,
@@ -75,6 +79,7 @@ const BASELINE_APT_PACKAGES = [
75
79
  'util-linux',
76
80
  'bubblewrap',
77
81
  'jq',
82
+ 'libgomp1',
78
83
  ] as const
79
84
 
80
85
  // curl-impersonate is the only currently-working way to query DuckDuckGo from
@@ -573,6 +578,72 @@ RUN echo "${encoded}" | base64 -d > ${TYPECLAW_ENTRYPOINT_PATH} \\
573
578
  && chmod +x ${TYPECLAW_ENTRYPOINT_PATH}`
574
579
  }
575
580
 
581
+ // Layer 7: install @huggingface/transformers with its linux-native binaries.
582
+ //
583
+ // The host node_modules is bind-mounted over /agent at runtime carrying
584
+ // whatever binaries the host (typically macOS) resolved, so this layer seeds
585
+ // LINUX binaries into an image path that survives the bind mount and still
586
+ // wins Node/Bun resolution. Two native-dep mechanisms, fixed differently:
587
+ //
588
+ // 1. onnxruntime-node ships its addon via a POSTINSTALL that materializes the
589
+ // binary inside the package dir, so a plain `bun add` covers it.
590
+ // 2. sharp resolves its native code from SEPARATE `@img/sharp-*` optional
591
+ // PACKAGES, not a postinstall addon. sharp is a MANDATORY dep of
592
+ // transformers, loaded eagerly because the package main chains
593
+ // transformers.js -> utils/image.js (top-level `import sharp`) even though
594
+ // typeclaw only does text feature-extraction. `bun add
595
+ // @huggingface/transformers` alone does NOT pull the linux `@img/*` when
596
+ // the bind-mounted host tree already satisfies sharp with the macOS ones —
597
+ // so `sharp.js` throws at import ("Could not load the sharp module using
598
+ // the linux-<arch> runtime") and the container exits at startup. We
599
+ // install the arch-matched linux platform packages EXPLICITLY to fix it.
600
+ //
601
+ // CRITICAL — install location. `typeclaw start` bind-mounts the host agent
602
+ // folder over /agent at runtime (`-v <cwd>:/agent`), so anything written under
603
+ // /agent/node_modules at BUILD time is MASKED at runtime and can never win.
604
+ // The prior fix ran this `bun add` under `WORKDIR /agent`, populating
605
+ // /agent/node_modules — invisible behind the bind mount, so the sharp crash
606
+ // persisted. We install from `WORKDIR /` instead: the linux packages land in
607
+ // the IMAGE's /node_modules, which is NOT masked by the /agent mount. Node/Bun
608
+ // resolution for `require('@img/sharp-linux-<arch>')` ascends from
609
+ // /agent/node_modules/sharp up the directory chain and finds /node_modules,
610
+ // so the linux binary resolves. WORKDIR is restored to /agent afterwards so
611
+ // later layers and the runtime CWD are unchanged.
612
+ //
613
+ // EXACT transformers version this image installs. Must stay in lockstep with
614
+ // `@huggingface/transformers` in the repo package.json (a guard test in
615
+ // dockerfile.test.ts asserts the two agree). The pin is EXACT — not a caret —
616
+ // because this layer's `bun add` runs at `WORKDIR /` with NO project lockfile,
617
+ // so the repo `bun.lock` does NOT constrain it. A bare `@huggingface/
618
+ // transformers` (no version) would resolve npm `latest` at BUILD time: the day
619
+ // a newer transformers ships, the container would install it while the
620
+ // sharp/libvips pins below stay fixed, and the mismatched sharp platform
621
+ // packages crash at container import ("Could not load the sharp module using
622
+ // the linux-<arch> runtime"). Pinning the version closes that drift.
623
+ export const TRANSFORMERS_VERSION = '4.2.0'
624
+
625
+ // sharp + libvips versions are pinned to what @huggingface/transformers@4.2.0
626
+ // resolves. A future transformers bump that moves sharp must bump
627
+ // TRANSFORMERS_VERSION, `sharp@`, `@img/sharp-linux-*`, and
628
+ // `@img/sharp-libvips-linux-*` together — mismatched sharp/libvips platform
629
+ // packages are a known failure mode. `$TARGETARCH` is `arm64` or `amd64`; an
630
+ // empty value (bare `docker build` without buildx) falls back to x64 for
631
+ // determinism.
632
+ const LAYER_TRANSFORMERS_INSTALL = `# Layer 7: install @huggingface/transformers with its linux-native binaries.
633
+ # Installs the linux onnxruntime-node addon AND sharp's linux platform packages
634
+ # (@img/sharp-linux-*) into the image's /node_modules so they survive the
635
+ # runtime /agent bind mount and still win Node/Bun resolution from
636
+ # /agent/node_modules/sharp. The transformers version is pinned EXACT (this
637
+ # bun add has no lockfile) — see src/init/dockerfile.ts for the rationale.
638
+ WORKDIR /
639
+ RUN SHARP_ARCH="$(if [ "\${TARGETARCH:-amd64}" = "arm64" ]; then echo arm64; else echo x64; fi)" \\
640
+ && bun add \\
641
+ @huggingface/transformers@${TRANSFORMERS_VERSION} \\
642
+ sharp@0.34.5 \\
643
+ "@img/sharp-linux-\${SHARP_ARCH}@0.34.5" \\
644
+ "@img/sharp-libvips-linux-\${SHARP_ARCH}@1.2.4"
645
+ WORKDIR /agent`
646
+
576
647
  // Claude Code's official installer is `curl | bash`, not apt — can't live
577
648
  // in APT_FEATURES. Layer placed after the toggle apt install (so curl + ca-
578
649
  // certificates from the baseline are guaranteed present) and before the
@@ -1059,7 +1130,7 @@ const TYPECLAW_CX_GLOBAL_HOOKS = JSON.stringify({
1059
1130
  },
1060
1131
  })
1061
1132
 
1062
- function renderCodexCliInstallLayer(enabled: boolean): string {
1133
+ function renderCodexCliInstallLayer(enabled: boolean, buildKit: boolean): string {
1063
1134
  if (!enabled) return ''
1064
1135
  return `# Layer 5.7 (toggle): install OpenAI's Codex CLI. Opt-in via
1065
1136
  # typeclaw.json#docker.file.codexCli. The skill \`typeclaw-codex-cli\`
@@ -1069,8 +1140,7 @@ function renderCodexCliInstallLayer(enabled: boolean): string {
1069
1140
  # are pre-written at build time so the operator subagent never has to
1070
1141
  # construct that JSON itself — same load-bearing reason as the Claude
1071
1142
  # Code layer above.
1072
- RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \\
1073
- bun install -g @openai/codex \\
1143
+ RUN ${bunCacheMount(buildKit)}bun install -g @openai/codex \\
1074
1144
  && codex --version > /dev/null \\
1075
1145
  && cat > ${TYPECLAW_CX_SESSION_START_HOOK_PATH} <<'TYPECLAW_CX_SESSION_START_HOOK_EOF'
1076
1146
  ${TYPECLAW_CX_SESSION_START_HOOK_SCRIPT}TYPECLAW_CX_SESSION_START_HOOK_EOF
@@ -1155,15 +1225,19 @@ export function buildDockerfile(
1155
1225
  config: DockerfileConfig = defaultConfig(),
1156
1226
  options: BuildDockerfileOptions = {},
1157
1227
  ): string {
1228
+ // buildKit is the default; `start` passes false on hosts without buildx so the
1229
+ // generated Dockerfile omits the `# syntax=` pragma and every cache mount —
1230
+ // the legacy builder accepts the result as-is, no text post-processing needed.
1231
+ const buildKit = options.buildKit !== false
1158
1232
  const cjkFonts = resolveCjkFonts(config.cjkFonts, options.cjkFontsAuto ?? false)
1159
1233
  const toggleAptArgs = collectToggleAptArgs(config, cjkFonts)
1160
- const ghKeyringLayer = renderGhKeyringLayer(config.gh)
1234
+ const ghKeyringLayer = renderGhKeyringLayer(config.gh, buildKit)
1161
1235
  const cloudflaredLayer = renderCloudflaredLayer(config.cloudflared)
1162
1236
  const customLines = renderCustomDockerfileLines(config.append)
1163
1237
  const baseImageVersion = options.baseImageVersion ?? null
1164
1238
 
1165
1239
  const claudeCodeLayer = renderClaudeCodeInstallLayer(config.claudeCode)
1166
- const codexCliLayer = renderCodexCliInstallLayer(config.codexCli)
1240
+ const codexCliLayer = renderCodexCliInstallLayer(config.codexCli, buildKit)
1167
1241
  const fromAndHeavyLayers =
1168
1242
  baseImageVersion !== null
1169
1243
  ? renderVersionedHead(
@@ -1173,11 +1247,12 @@ export function buildDockerfile(
1173
1247
  cloudflaredLayer,
1174
1248
  claudeCodeLayer,
1175
1249
  codexCliLayer,
1250
+ buildKit,
1176
1251
  )
1177
- : renderInlineHead(ghKeyringLayer, toggleAptArgs, cloudflaredLayer, claudeCodeLayer, codexCliLayer)
1252
+ : renderInlineHead(ghKeyringLayer, toggleAptArgs, cloudflaredLayer, claudeCodeLayer, codexCliLayer, buildKit)
1178
1253
 
1179
- return `${BUILDKIT_HEADER}
1180
- # AUTOGENERATED by typeclaw — do not edit.
1254
+ const header = buildKit ? `${BUILDKIT_HEADER}\n` : ''
1255
+ return `${header}# AUTOGENERATED by typeclaw — do not edit.
1181
1256
  # This file is rewritten on every \`typeclaw start\` from src/init/dockerfile.ts
1182
1257
  # in the typeclaw repo. Local edits will be overwritten (and committed away if
1183
1258
  # the working tree is dirty). To change the template, edit dockerfile.ts there.
@@ -1223,8 +1298,9 @@ function renderVersionedHead(
1223
1298
  cloudflaredLayer: string,
1224
1299
  claudeCodeLayer: string,
1225
1300
  codexCliLayer: string,
1301
+ buildKit: boolean,
1226
1302
  ): string {
1227
- const toggleAptLayer = toggleAptArgs.length === 0 ? '' : `${renderToggleAptInstallLayer(toggleAptArgs)}\n\n`
1303
+ const toggleAptLayer = toggleAptArgs.length === 0 ? '' : `${renderToggleAptInstallLayer(toggleAptArgs, buildKit)}\n\n`
1228
1304
  const cloudflaredBlock = cloudflaredLayer === '' ? '' : `${cloudflaredLayer}\n\n`
1229
1305
  const claudeCodeBlock = claudeCodeLayer === '' ? '' : `${claudeCodeLayer}\n\n`
1230
1306
  const codexCliBlock = codexCliLayer === '' ? '' : `${codexCliLayer}\n\n`
@@ -1236,6 +1312,8 @@ ARG TARGETARCH
1236
1312
 
1237
1313
  ${ghKeyringLayer}${toggleAptLayer}${cloudflaredBlock}${claudeCodeBlock}${codexCliBlock}${renderEntrypointShimLayer()}
1238
1314
 
1315
+ ${LAYER_TRANSFORMERS_INSTALL}
1316
+
1239
1317
  `
1240
1318
  }
1241
1319
 
@@ -1249,6 +1327,7 @@ function renderInlineHead(
1249
1327
  cloudflaredLayer: string,
1250
1328
  claudeCodeLayer: string,
1251
1329
  codexCliLayer: string,
1330
+ buildKit: boolean,
1252
1331
  ): string {
1253
1332
  const baselineAndToggleArgs = [...BASELINE_APT_PACKAGES, ...toggleAptArgs]
1254
1333
  const cloudflaredBlock = cloudflaredLayer === '' ? '' : `${cloudflaredLayer}\n\n`
@@ -1274,9 +1353,7 @@ ${ghKeyringLayer}# Layer 2 (changes when the package list changes): the actual a
1274
1353
  #
1275
1354
  # No \`rm -rf /var/lib/apt/lists/*\` because the lists live on a cache mount
1276
1355
  # that is excluded from the image layer by definition.
1277
- RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
1278
- --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
1279
- apt-get update \\
1356
+ RUN ${aptCacheMount(buildKit)}apt-get update \\
1280
1357
  && apt-get install -y --no-install-recommends \\
1281
1358
  ${baselineAndToggleArgs.join(' ')} \\
1282
1359
  && if [ "$TARGETARCH" = "arm64" ]; then \\
@@ -1290,14 +1367,16 @@ ${LAYER_2_5_CURL_IMPERSONATE}
1290
1367
 
1291
1368
  ${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
1292
1369
 
1293
- ${LAYER_4_AGENT_BROWSER_INSTALL}
1370
+ ${renderAgentBrowserInstallLayer(buildKit)}
1294
1371
 
1295
1372
  ${LAYER_4_5_AGENT_BROWSER_HEADED_WRAPPER}
1296
1373
 
1297
- ${LAYER_5_CHROME_FOR_TESTING}
1374
+ ${renderChromeForTestingLayer(buildKit)}
1298
1375
 
1299
1376
  ${cloudflaredBlock}${claudeCodeBlock}${codexCliBlock}${renderEntrypointShimLayer()}
1300
1377
 
1378
+ ${LAYER_TRANSFORMERS_INSTALL}
1379
+
1301
1380
  `
1302
1381
  }
1303
1382
 
@@ -1317,13 +1396,11 @@ RUN ARCH_BIN="$(if [ "$TARGETARCH" = "arm64" ]; then echo arm64; else echo amd64
1317
1396
  `
1318
1397
  }
1319
1398
 
1320
- function renderToggleAptInstallLayer(toggleAptArgs: string[]): string {
1399
+ function renderToggleAptInstallLayer(toggleAptArgs: string[], buildKit: boolean): string {
1321
1400
  return `# Layer 1 (toggle apt install): packages requested via typeclaw.json
1322
1401
  # #docker.file toggles. Baseline + Chrome runtime libs are already in the
1323
1402
  # base image; this layer only adds gh/tmux/python/ffmpeg/cjkFonts if enabled.
1324
- RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
1325
- --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
1326
- apt-get update \\
1403
+ RUN ${aptCacheMount(buildKit)}apt-get update \\
1327
1404
  && apt-get install -y --no-install-recommends \\
1328
1405
  ${toggleAptArgs.join(' ')}`
1329
1406
  }
@@ -1353,9 +1430,7 @@ ${LAYER_0_APT_KEEP_CACHE}
1353
1430
  # packages (gh/python/tmux/ffmpeg) are intentionally NOT installed here —
1354
1431
  # they layer onto the base in the per-agent Dockerfile so users can opt in/
1355
1432
  # out via typeclaw.json without forcing a base-image rebuild.
1356
- RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
1357
- --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
1358
- apt-get update \\
1433
+ RUN ${aptCacheMount(true)}apt-get update \\
1359
1434
  && apt-get install -y --no-install-recommends \\
1360
1435
  ${BASELINE_APT_PACKAGES.join(' ')} \\
1361
1436
  && if [ "$TARGETARCH" = "arm64" ]; then \\
@@ -1369,13 +1444,15 @@ ${LAYER_2_5_CURL_IMPERSONATE}
1369
1444
 
1370
1445
  ${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
1371
1446
 
1372
- ${LAYER_4_AGENT_BROWSER_INSTALL}
1447
+ ${renderAgentBrowserInstallLayer(true)}
1373
1448
 
1374
1449
  ${LAYER_4_5_AGENT_BROWSER_HEADED_WRAPPER}
1375
1450
 
1376
- ${LAYER_5_CHROME_FOR_TESTING}
1451
+ ${renderChromeForTestingLayer(true)}
1377
1452
 
1378
1453
  ${renderEntrypointShimLayer()}
1454
+
1455
+ ${LAYER_TRANSFORMERS_INSTALL}
1379
1456
  `
1380
1457
  }
1381
1458
 
@@ -1386,6 +1463,19 @@ ${renderEntrypointShimLayer()}
1386
1463
 
1387
1464
  const BUILDKIT_HEADER = `# syntax=docker/dockerfile:1.7`
1388
1465
 
1466
+ // Cache-mount prefixes spliced between `RUN ` and the command. BuildKit honors
1467
+ // `--mount=type=cache` (apt .debs / bun packages reused across builds); the
1468
+ // legacy builder (hosts without buildx) has no equivalent, so legacy mode emits
1469
+ // an empty prefix — same image, just no cross-build cache. Generating the two
1470
+ // modes structurally is why typeclaw needs no Dockerfile text post-processing.
1471
+ const APT_CACHE_MOUNT =
1472
+ '--mount=type=cache,target=/var/cache/apt,sharing=locked \\\n' +
1473
+ ' --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\\n '
1474
+ const BUN_CACHE_MOUNT = '--mount=type=cache,target=/root/.bun/install/cache,sharing=locked \\\n '
1475
+
1476
+ const aptCacheMount = (buildKit: boolean): string => (buildKit ? APT_CACHE_MOUNT : '')
1477
+ const bunCacheMount = (buildKit: boolean): string => (buildKit ? BUN_CACHE_MOUNT : '')
1478
+
1389
1479
  const FROM_AND_WORKDIR = `FROM oven/bun:1-slim
1390
1480
 
1391
1481
  WORKDIR /agent
@@ -1429,10 +1519,10 @@ RUN if [ "$TARGETARCH" = "arm64" ]; then \\
1429
1519
  && printf '%s\\n' '{"executablePath":"/usr/bin/chromium"}' > /root/.agent-browser/config.json; \\
1430
1520
  fi`
1431
1521
 
1432
- const LAYER_4_AGENT_BROWSER_INSTALL = `# Layer 4 (volatile): install agent-browser globally so it survives the
1522
+ const renderAgentBrowserInstallLayer = (buildKit: boolean): string =>
1523
+ `# Layer 4 (volatile): install agent-browser globally so it survives the
1433
1524
  # runtime bind-mount over /agent/node_modules.
1434
- RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \\
1435
- bun install -g agent-browser`
1525
+ RUN ${bunCacheMount(buildKit)}bun install -g agent-browser@^0.27.0`
1436
1526
 
1437
1527
  // Layer 4.5: shim the agent-browser binary with a wrapper that calls
1438
1528
  // \`agent-browser close\` before \`open\`/\`goto\`/\`navigate\` when headed
@@ -1548,10 +1638,9 @@ TYPECLAW_AGENT_BROWSER_WRAPPER_EOF`
1548
1638
  // already installed in Layer 2; --with-deps is a defense-in-depth backstop
1549
1639
  // so a future agent-browser bump that adds new deps installs them
1550
1640
  // automatically (near-no-op when Layer 2 already covers them).
1551
- const LAYER_5_CHROME_FOR_TESTING = `# Layer 5 (heavy, amd64 only): Chrome for Testing download.
1552
- RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
1553
- --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
1554
- if [ "$TARGETARCH" != "arm64" ]; then \\
1641
+ const renderChromeForTestingLayer = (buildKit: boolean): string =>
1642
+ `# Layer 5 (heavy, amd64 only): Chrome for Testing download.
1643
+ RUN ${aptCacheMount(buildKit)}if [ "$TARGETARCH" != "arm64" ]; then \\
1555
1644
  agent-browser install --with-deps; \\
1556
1645
  fi`
1557
1646
 
@@ -1590,16 +1679,14 @@ function singlePackageArgs(name: string, toggle: DockerfileFeatureToggle): strin
1590
1679
  // (the most frequent change) does not re-fetch the GPG key over the network.
1591
1680
  // When `gh` is disabled, omit the layer entirely — both to skip the network
1592
1681
  // roundtrip on cold builds and to keep the package source registry clean.
1593
- function renderGhKeyringLayer(toggle: DockerfileFeatureToggle): string {
1682
+ function renderGhKeyringLayer(toggle: DockerfileFeatureToggle, buildKit: boolean): string {
1594
1683
  if (toggle === false) return ''
1595
1684
  return `# Layer 1 (rarely changes): register the GitHub CLI apt repository and trust
1596
1685
  # its keyring. Split from the package install below so editing the package
1597
1686
  # list (the most frequent change to this Dockerfile) does NOT re-fetch the
1598
1687
  # GPG key over the network. The cache mount on /var/cache/apt covers the
1599
1688
  # tiny gnupg/curl install we need to bootstrap the key fetch.
1600
- RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
1601
- --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
1602
- apt-get update \\
1689
+ RUN ${aptCacheMount(buildKit)}apt-get update \\
1603
1690
  && apt-get install -y --no-install-recommends curl ca-certificates gnupg \\
1604
1691
  && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \\
1605
1692
  | gpg --dearmor -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \\
@@ -9,7 +9,7 @@ export const TRULY_IGNORED_PATTERNS = [
9
9
  '.env.local',
10
10
  'secrets.json',
11
11
  'auth.json',
12
- '.typeclaw/home/',
12
+ '.typeclaw/',
13
13
  'node_modules/',
14
14
  'packages/*/node_modules/',
15
15
  'workspace/',
@@ -40,12 +40,12 @@ export function buildGitignore(config: GitignoreConfig = { append: [] }): string
40
40
  # as a safety net so an agent folder cloned from a pre-rename machine never
41
41
  # stages credentials by accident.
42
42
  #
43
- # .typeclaw/home/ is the persistent-$HOME overlay populated by the
44
- # entrypoint shim's \`link_persistent_home_files\` (see
45
- # src/init/dockerfile.ts). It mirrors selected files from the container's
46
- # $HOME (e.g. ~/.codex/auth.json) into the bind-mounted agent folder so
47
- # tool credentials survive container restarts. Always credentials; never
48
- # commit.
43
+ # .typeclaw/ is the agent's local-scratch dir. It holds the persistent-$HOME
44
+ # overlay (.typeclaw/home/, populated by the entrypoint shim's
45
+ # \`link_persistent_home_files\` — see src/init/dockerfile.ts, mirrors container
46
+ # $HOME files like ~/.codex/auth.json so tool credentials survive restarts) and
47
+ # the in-progress init checkpoint (.typeclaw/init-progress.json, non-secret
48
+ # wizard selections for resume). Local-only; never commit.
49
49
  ${TRULY_IGNORED_PATTERNS.join('\n')}
50
50
 
51
51
  # System-managed: gitignored by default so the agent never stages them by hand,
package/src/init/index.ts CHANGED
@@ -10,13 +10,15 @@ import {
10
10
  GWS_MULTI_ACCOUNT_PLUGIN_VERSION,
11
11
  migrateLegacyConfigShape,
12
12
  type Config,
13
+ type CustomModelMeta,
13
14
  } from '@/config'
14
15
  import {
15
16
  DEFAULT_MODEL_REF,
16
17
  KNOWN_PROVIDERS,
18
+ isKnownModelRef,
17
19
  providerForModelRef,
18
- type KnownModelRef,
19
20
  type KnownProviderId,
21
+ type ModelRef,
20
22
  } from '@/config/providers'
21
23
  import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
22
24
  import { commitSystemFile } from '@/git/system-commit'
@@ -171,7 +173,8 @@ export type InitOptions = {
171
173
  cwd: string
172
174
  // Selected `provider/model` ref written into typeclaw.json. Defaults to
173
175
  // DEFAULT_MODEL_REF when callers (or older test fixtures) omit it.
174
- model?: KnownModelRef
176
+ model?: ModelRef | string
177
+ modelMeta?: CustomModelMeta
175
178
  // How the agent will authenticate to the LLM provider. When omitted,
176
179
  // defaults to the api-key path with `apiKey` (legacy field, still
177
180
  // supported for backwards compat with the old `runInit` signature).
@@ -181,7 +184,8 @@ export type InitOptions = {
181
184
  // when both refer to the same provider; the wizard enforces this
182
185
  // pairing rule, so by the time we get here `visionAuth` is either
183
186
  // (a) absent, or (b) the right auth for `visionModel`'s provider.
184
- visionModel?: KnownModelRef
187
+ visionModel?: ModelRef | string
188
+ visionModelMeta?: CustomModelMeta
185
189
  visionAuth?: LLMAuth
186
190
  apiKey?: string
187
191
  discordBotToken?: string
@@ -224,7 +228,9 @@ export async function runInit({
224
228
  apiKey,
225
229
  llmAuth,
226
230
  model = DEFAULT_MODEL_REF,
231
+ modelMeta,
227
232
  visionModel,
233
+ visionModelMeta,
228
234
  visionAuth,
229
235
  discordBotToken,
230
236
  slackBotToken,
@@ -304,7 +310,9 @@ export async function runInit({
304
310
  emit({ step: 'scaffold', phase: 'start' })
305
311
  await scaffold(cwd, {
306
312
  model,
313
+ ...(modelMeta !== undefined ? { modelMeta } : {}),
307
314
  ...(visionModel !== undefined ? { visionModel } : {}),
315
+ ...(visionModelMeta !== undefined ? { visionModelMeta } : {}),
308
316
  withDiscord: wantsDiscord,
309
317
  withSlack: wantsSlack,
310
318
  withTelegram: wantsTelegram,
@@ -520,8 +528,10 @@ export async function isHatched(dir: string): Promise<boolean> {
520
528
  }
521
529
 
522
530
  export type ScaffoldOptions = {
523
- model?: KnownModelRef
524
- visionModel?: KnownModelRef
531
+ model?: ModelRef | string
532
+ modelMeta?: CustomModelMeta
533
+ visionModel?: ModelRef | string
534
+ visionModelMeta?: CustomModelMeta
525
535
  withDiscord?: boolean
526
536
  withSlack?: boolean
527
537
  withTelegram?: boolean
@@ -545,12 +555,14 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
545
555
  // `memory.*`) is omitted to keep the scaffold minimal — duplicating defaults
546
556
  // here would mean every schema change has to be mirrored in two places, and
547
557
  // users would feel obligated to maintain values they never set.
548
- const models: Record<string, KnownModelRef> = { default: options.model ?? DEFAULT_MODEL_REF }
558
+ const models: Record<string, string> = { default: options.model ?? DEFAULT_MODEL_REF }
549
559
  if (options.visionModel !== undefined) models.vision = options.visionModel
550
560
  const config: Record<string, unknown> = {
551
561
  $schema: './node_modules/typeclaw/typeclaw.schema.json',
552
562
  models,
553
563
  }
564
+ const customModels = collectCustomModels(options)
565
+ if (Object.keys(customModels).length > 0) config.customModels = customModels
554
566
  const channels: Record<string, Record<string, never>> = {}
555
567
  if (options.withDiscord) channels['discord-bot'] = {}
556
568
  if (options.withSlack) channels['slack-bot'] = {}
@@ -578,12 +590,32 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
578
590
  await writeFile(join(root, GITIGNORE_FILE), buildGitignore(), { flag: 'wx' }).catch(ignoreExists)
579
591
  }
580
592
 
593
+ function collectCustomModels(options: ScaffoldOptions): Record<string, CustomModelMeta> {
594
+ const customModels: Record<string, CustomModelMeta> = {}
595
+ addCustomModel(customModels, options.model ?? DEFAULT_MODEL_REF, options.modelMeta)
596
+ if (options.visionModel !== undefined) addCustomModel(customModels, options.visionModel, options.visionModelMeta)
597
+ return customModels
598
+ }
599
+
600
+ function addCustomModel(
601
+ customModels: Record<string, CustomModelMeta>,
602
+ ref: string,
603
+ meta: CustomModelMeta | undefined,
604
+ ): void {
605
+ if (isKnownModelRef(ref)) return
606
+ customModels[ref] = meta ?? {}
607
+ }
608
+
581
609
  // agent-browser ships in every agent: the bundled SKILL.md (src/skills/
582
610
  // agent-browser/SKILL.md) is a discovery stub that calls `agent-browser
583
611
  // skills get core` at runtime, so the CLI must be installed for the skill
584
612
  // to function. The Dockerfile pre-downloads Chromium too, so the agent
585
613
  // can drive a browser without any first-run setup.
586
- const AGENT_BROWSER_VERSION = '^0.26.0'
614
+ //
615
+ // Must match the Dockerfile Layer 4 global install (dockerfile.ts); they are
616
+ // two installs of the same CLI and a skew is silent. Enforced by a guard test
617
+ // in packagejson.test.ts.
618
+ export const AGENT_BROWSER_VERSION = '^0.27.0'
587
619
  function buildPackageJson(root: string, name: string): Record<string, unknown> {
588
620
  return {
589
621
  name,
@@ -738,10 +770,10 @@ export async function writeSecrets(
738
770
  slackAppToken,
739
771
  telegramBotToken,
740
772
  }: {
741
- model?: KnownModelRef
773
+ model?: ModelRef | string
742
774
  // Omitted on the OAuth path — credentials live in secrets.json via the OAuth runner.
743
775
  apiKey?: string
744
- visionModel?: KnownModelRef
776
+ visionModel?: ModelRef | string
745
777
  visionApiKey?: string
746
778
  discordBotToken?: string
747
779
  slackBotToken?: string