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
@@ -1,4 +1,4 @@
1
- import { cancel, intro, isCancel, log, password, select } from '@clack/prompts'
1
+ import { autocomplete, cancel, intro, isCancel, log, password, select } from '@clack/prompts'
2
2
  import { defineCommand } from 'citty'
3
3
 
4
4
  import {
@@ -282,8 +282,9 @@ async function resolveProviderForAdd(input: string | undefined): Promise<KnownPr
282
282
 
283
283
  async function pickVendorToAdd(): Promise<KnownProviderVendorId> {
284
284
  const vendorIds = listKnownProviderVendorIds()
285
- const choice = await select<KnownProviderVendorId>({
285
+ const choice = await autocomplete<KnownProviderVendorId>({
286
286
  message: 'Pick a provider to add',
287
+ placeholder: 'Type to search…',
287
288
  options: vendorIds.map((id) => ({
288
289
  value: id,
289
290
  label: KNOWN_PROVIDER_VENDORS[id].name,
@@ -301,8 +302,9 @@ async function pickVendorToAdd(): Promise<KnownProviderVendorId> {
301
302
  async function pickVariantToAdd(vendorId: KnownProviderVendorId): Promise<KnownProviderId> {
302
303
  const variants = providerIdsForVendor(vendorId)
303
304
  if (variants.length === 1) return variants[0]!
304
- const choice = await select<KnownProviderId>({
305
+ const choice = await autocomplete<KnownProviderId>({
305
306
  message: `Pick a ${KNOWN_PROVIDER_VENDORS[vendorId].name} option`,
307
+ placeholder: 'Type to search…',
306
308
  options: variants.map((id) => {
307
309
  const hint = variantHint(vendorId, id)
308
310
  return hint !== undefined
@@ -1,9 +1,11 @@
1
+ import { confirm, isCancel } from '@clack/prompts'
1
2
  import { defineCommand } from 'citty'
2
3
 
3
4
  import { config, validateConfig } from '@/config'
4
5
  import { start, stop } from '@/container'
5
6
  import { findAgentDir, isInitialized } from '@/init'
6
7
 
8
+ import { guardIncompleteInit } from './incomplete-init'
7
9
  import { c, errorLine, renderStartSuccess, spinner } from './ui'
8
10
 
9
11
  export const restartCommand = defineCommand({
@@ -27,6 +29,28 @@ export const restartCommand = defineCommand({
27
29
  async run({ args }) {
28
30
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
29
31
 
32
+ // Runs before BOTH isInitialized and stop. A wizard abort persists a
33
+ // checkpoint before scaffold writes typeclaw.json, so a checkpoint-but-no-
34
+ // config dir is an incomplete init that should get resume guidance, not the
35
+ // generic config-missing error — and a half-init agent usually has no
36
+ // container to stop. A `continue` falls through to isInitialized, which
37
+ // still catches a truly uninitialized dir.
38
+ const guard = await guardIncompleteInit({
39
+ cwd,
40
+ interactive: Boolean(process.stdout.isTTY),
41
+ confirmContinue: async () => {
42
+ const proceed = await confirm({ message: 'Try restarting anyway?', initialValue: false })
43
+ return !isCancel(proceed) && proceed === true
44
+ },
45
+ })
46
+ if (guard.action === 'block') {
47
+ console.error(errorLine(guard.message))
48
+ process.exit(1)
49
+ }
50
+ if (guard.action === 'abort') {
51
+ process.exit(0)
52
+ }
53
+
30
54
  if (!isInitialized(cwd)) {
31
55
  console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first.'))
32
56
  process.exit(1)
package/src/cli/start.ts CHANGED
@@ -1,9 +1,11 @@
1
+ import { confirm, isCancel } from '@clack/prompts'
1
2
  import { defineCommand } from 'citty'
2
3
 
3
4
  import { config, validateConfig } from '@/config'
4
5
  import { start } from '@/container'
5
6
  import { findAgentDir, isInitialized } from '@/init'
6
7
 
8
+ import { guardIncompleteInit } from './incomplete-init'
7
9
  import { errorLine, renderStartSuccess, spinner } from './ui'
8
10
 
9
11
  export const startCommand = defineCommand({
@@ -27,6 +29,28 @@ export const startCommand = defineCommand({
27
29
  async run({ args }) {
28
30
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
29
31
 
32
+ // Runs BEFORE the isInitialized check: a wizard abort persists a checkpoint
33
+ // before scaffold writes typeclaw.json, so a checkpoint-but-no-config dir is
34
+ // an incomplete init, not a "never initialized" one. Guarding first means
35
+ // that case gets the resume guidance instead of the generic config-missing
36
+ // error. A `continue` (no incomplete checkpoint, or "try anyway") falls
37
+ // through to isInitialized, which still catches a truly uninitialized dir.
38
+ const guard = await guardIncompleteInit({
39
+ cwd,
40
+ interactive: Boolean(process.stdout.isTTY),
41
+ confirmContinue: async () => {
42
+ const proceed = await confirm({ message: 'Try starting anyway?', initialValue: false })
43
+ return !isCancel(proceed) && proceed === true
44
+ },
45
+ })
46
+ if (guard.action === 'block') {
47
+ console.error(errorLine(guard.message))
48
+ process.exit(1)
49
+ }
50
+ if (guard.action === 'abort') {
51
+ process.exit(0)
52
+ }
53
+
30
54
  if (!isInitialized(cwd)) {
31
55
  console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first.'))
32
56
  process.exit(1)
package/src/cli/tunnel.ts CHANGED
@@ -5,7 +5,7 @@ import { select, text, password, isCancel, cancel, log } from '@clack/prompts'
5
5
  import { defineCommand } from 'citty'
6
6
 
7
7
  import { loadConfigSync, validateConfig } from '@/config'
8
- import { resolveHostPort, resolveTuiToken } from '@/container'
8
+ import { CONTAINER_PORT, resolveHostPort, resolveTuiToken } from '@/container'
9
9
  import { appendOrReplaceEnvKey, findAgentDir, hasEnvKey, isInitialized } from '@/init'
10
10
  import type { ClientMessage, ServerMessage, TunnelLogsServerMessage, TunnelSnapshot } from '@/shared'
11
11
  import type { TunnelConfig, TunnelFor, TunnelProvider } from '@/tunnels'
@@ -921,13 +921,32 @@ async function withTuiSocket<T>(
921
921
  }
922
922
  }
923
923
 
924
- async function resolveWsUrl(cwd: string, input?: string, pathname = '/'): Promise<LiveResult<string>> {
924
+ async function resolveWsUrl(
925
+ cwd: string,
926
+ input?: string,
927
+ pathname = '/',
928
+ env: NodeJS.ProcessEnv = process.env,
929
+ ): Promise<LiveResult<string>> {
925
930
  try {
926
- const url = input === undefined ? new URL(`ws://127.0.0.1:${await resolveHostPort({ cwd })}`) : new URL(input)
927
- if (input === undefined) {
928
- const token = await resolveTuiToken({ cwd })
929
- if (token !== null) url.searchParams.set('token', token)
931
+ if (input !== undefined) {
932
+ const url = new URL(input)
933
+ url.pathname = pathname
934
+ return { ok: true, value: url.toString() }
930
935
  }
936
+ // In-container short-circuit: when the agent runs `typeclaw tunnel …` from
937
+ // inside its own container (the only way it CAN run it), `docker` is not on
938
+ // $PATH, so the host-side discovery below (resolveHostPort/resolveTuiToken,
939
+ // which both shell out to `docker`) fails and the websocket connect aborts
940
+ // with the opaque `[object ErrorEvent]`. typeclaw's `docker run` sets
941
+ // TYPECLAW_CONTAINER_NAME (always) and TYPECLAW_TUI_TOKEN (when configured),
942
+ // and the agent's WS server listens on CONTAINER_PORT on the container
943
+ // loopback — so we can dial directly without docker. Mirrors the same
944
+ // short-circuit in src/cron/bridge.ts (resolveInContainerUrl).
945
+ const inContainer = resolveInContainerWsUrl(env, pathname)
946
+ if (inContainer !== null) return { ok: true, value: inContainer }
947
+ const url = new URL(`ws://127.0.0.1:${await resolveHostPort({ cwd })}`)
948
+ const token = await resolveTuiToken({ cwd })
949
+ if (token !== null) url.searchParams.set('token', token)
931
950
  url.pathname = pathname
932
951
  return { ok: true, value: url.toString() }
933
952
  } catch (err) {
@@ -935,6 +954,17 @@ async function resolveWsUrl(cwd: string, input?: string, pathname = '/'): Promis
935
954
  }
936
955
  }
937
956
 
957
+ // Returns null on the host stage (TYPECLAW_CONTAINER_NAME unset), where the
958
+ // docker-based discovery in resolveWsUrl is the right path.
959
+ export function resolveInContainerWsUrl(env: NodeJS.ProcessEnv, pathname = '/'): string | null {
960
+ if (env.TYPECLAW_CONTAINER_NAME === undefined) return null
961
+ const url = new URL(`ws://127.0.0.1:${CONTAINER_PORT}`)
962
+ const token = env.TYPECLAW_TUI_TOKEN
963
+ if (token !== undefined && token !== '') url.searchParams.set('token', token)
964
+ url.pathname = pathname
965
+ return url.toString()
966
+ }
967
+
938
968
  function waitForOpen(ws: WebSocket, timeoutMs: number): Promise<void> {
939
969
  return new Promise((resolve, reject) => {
940
970
  const timer = setTimeout(() => reject(new Error('timed out connecting to agent websocket')), timeoutMs)
@@ -948,15 +978,30 @@ function waitForOpen(ws: WebSocket, timeoutMs: number): Promise<void> {
948
978
  )
949
979
  ws.addEventListener(
950
980
  'error',
951
- (err) => {
981
+ (event) => {
952
982
  clearTimeout(timer)
953
- reject(err)
983
+ reject(new Error(describeWsErrorEvent(event)))
954
984
  },
955
985
  { once: true },
956
986
  )
957
987
  })
958
988
  }
959
989
 
990
+ // A WebSocket 'error' listener fires with an ErrorEvent, NOT an Error. Passing
991
+ // it straight to a catch site that does `String(err)` yields the useless
992
+ // `[object ErrorEvent]`. Pull the real message out (`.message`, or the nested
993
+ // `.error`) so failures read like `Expected 101 status code` / connection
994
+ // refused instead.
995
+ function describeWsErrorEvent(event: unknown): string {
996
+ if (event instanceof Error) return event.message
997
+ if (typeof event === 'object' && event !== null) {
998
+ const { message, error } = event as { message?: unknown; error?: unknown }
999
+ if (typeof message === 'string' && message !== '') return message
1000
+ if (error instanceof Error && error.message !== '') return error.message
1001
+ }
1002
+ return 'websocket connection failed'
1003
+ }
1004
+
960
1005
  function waitForServerMessage(
961
1006
  ws: WebSocket,
962
1007
  timeoutMs: number,
@@ -2,7 +2,7 @@ import { accessSync, constants as fsConstants, readFileSync, statSync, writeFile
2
2
  import { homedir } from 'node:os'
3
3
  import { isAbsolute, join, posix, resolve } from 'node:path'
4
4
 
5
- import type { Model } from '@mariozechner/pi-ai'
5
+ import type { KnownApi, Model } from '@mariozechner/pi-ai'
6
6
  import { z } from 'zod'
7
7
 
8
8
  import { channelsSchema, SEEDED_GITHUB_EVENT_ALLOWLISTS } from '@/channels/schema'
@@ -13,9 +13,12 @@ import { secretFieldSchema } from '@/secrets/resolve'
13
13
  import {
14
14
  DEFAULT_MODEL_REF,
15
15
  KNOWN_PROVIDERS,
16
+ isKnownModelRef,
17
+ isModelRef,
16
18
  listKnownModelRefs,
17
19
  type KnownModelRef,
18
- type KnownProviderId,
20
+ type ModelRef,
21
+ providerForModelRef,
19
22
  } from './providers'
20
23
 
21
24
  const CONFIG_FILE = 'typeclaw.json'
@@ -574,16 +577,55 @@ const tunnelsArraySchema = z
574
577
  }
575
578
  })
576
579
 
577
- // `models` maps a profile name to one or more curated model refs. The
580
+ const customModelCostSchema = z
581
+ .object({
582
+ input: z.number().optional(),
583
+ output: z.number().optional(),
584
+ cacheRead: z.number().optional(),
585
+ cacheWrite: z.number().optional(),
586
+ })
587
+ .catchall(z.unknown())
588
+
589
+ export const customModelMetaSchema = z
590
+ .object({
591
+ name: z.string().min(1).optional(),
592
+ reasoning: z.boolean().optional(),
593
+ input: z.array(z.string().min(1)).optional(),
594
+ contextWindow: z.number().optional(),
595
+ maxTokens: z.number().optional(),
596
+ cost: customModelCostSchema.optional(),
597
+ })
598
+ .catchall(z.unknown())
599
+
600
+ export type CustomModelMeta = z.infer<typeof customModelMetaSchema>
601
+
602
+ export const customModelsSchema = z.record(z.string().min(1), customModelMetaSchema).default({})
603
+
604
+ export type CustomModels = z.infer<typeof customModelsSchema>
605
+
606
+ const customModelRefSchema = z.string().refine((ref) => isModelRef(ref), {
607
+ message: 'model ref must be "<known-provider>/<model-id>" with a known provider',
608
+ })
609
+
610
+ const singleModelRef = z.union([z.enum(knownModelRefs), customModelRefSchema])
611
+
612
+ function asModelRef(value: string): ModelRef {
613
+ if (isModelRef(value)) return value
614
+ throw new Error(`Invalid model ref: ${value}`)
615
+ }
616
+
617
+ // `models` maps a profile name to one or more model refs. Curated refs keep
618
+ // editor autocomplete through the enum branch, while custom refs are allowed
619
+ // when they target a known provider.
578
620
  // `default` profile is mandatory; every other profile is optional and falls
579
621
  // back to `default` at resolution time (see `resolveProfile`).
580
622
  //
581
- // Each value is either a single `KnownModelRef` or a non-empty array of refs
623
+ // Each value is either a single model ref or a non-empty array of refs
582
624
  // forming a fallback chain: when a turn against the first ref fails (hard
583
625
  // throw or a soft provider error), the runtime disposes the failed session
584
626
  // and replays the same prompt against the next ref. Schema accepts both
585
627
  // shapes for ergonomics; the parsed value is always normalised to a
586
- // non-empty array so downstream consumers read a uniform `KnownModelRef[]`.
628
+ // non-empty array so downstream consumers read a uniform `ModelRef[]`.
587
629
  //
588
630
  // Profile names are open strings; the runtime recognizes a handful of
589
631
  // well-known names by convention (`default`, `fast`, `deep`, `vision`) but
@@ -596,9 +638,9 @@ const tunnelsArraySchema = z
596
638
  // `persistMigratedConfig`), so every downstream consumer sees the new shape.
597
639
  const modelRefOrChainSchema = z
598
640
  .union([
599
- z.enum(knownModelRefs),
641
+ singleModelRef,
600
642
  z
601
- .array(z.enum(knownModelRefs))
643
+ .array(singleModelRef)
602
644
  .min(1)
603
645
  // Reject exact duplicates in a chain — retrying the same ref after the
604
646
  // same class of failure is almost certainly a config typo, and silently
@@ -609,7 +651,7 @@ const modelRefOrChainSchema = z
609
651
  message: 'models chain must not contain duplicate refs',
610
652
  }),
611
653
  ])
612
- .transform((value) => (Array.isArray(value) ? value : [value]))
654
+ .transform((value) => (Array.isArray(value) ? value : [value]).map((ref) => asModelRef(ref)))
613
655
  export const modelsSchema = z
614
656
  .record(z.string().min(1), modelRefOrChainSchema)
615
657
  .refine((m) => 'default' in m, { message: 'models.default is required' })
@@ -617,7 +659,7 @@ export const modelsSchema = z
617
659
  // Zod's `z.record(..., refine)` doesn't refine the inferred type. The
618
660
  // `default` key is schema-enforced, so we narrow it here to spare every
619
661
  // consumer the `T | undefined` assertion noise.
620
- export type Models = Record<string, KnownModelRef[]> & { default: KnownModelRef[] }
662
+ export type Models = Record<string, ModelRef[]> & { default: ModelRef[] }
621
663
 
622
664
  export const configSchema = z
623
665
  .object({
@@ -626,9 +668,10 @@ export const configSchema = z
626
668
  // `default(() => ...)` ensures every parsed config has at least
627
669
  // `models.default`. Direct `.default({ default: ... })` would short-circuit
628
670
  // the refinement, so we lean on the lazy thunk form. The default value is
629
- // shaped to match the post-transform output (always `KnownModelRef[]`),
671
+ // shaped to match the post-transform output (always `ModelRef[]`),
630
672
  // not the user-facing input shape.
631
- models: modelsSchema.default(() => ({ default: [DEFAULT_MODEL_REF] })) as unknown as z.ZodType<Models>,
673
+ models: modelsSchema.default(() => ({ default: [asModelRef(DEFAULT_MODEL_REF)] })) as unknown as z.ZodType<Models>,
674
+ customModels: customModelsSchema,
632
675
  // Defaults to `[]` so the field can be omitted from `typeclaw.json` (no
633
676
  // host paths exposed) without failing the whole config load. `typeclaw
634
677
  // init` omits this field so users don't see noise for the empty case.
@@ -656,12 +699,58 @@ export const configSchema = z
656
699
 
657
700
  export type Config = z.infer<typeof configSchema>
658
701
 
659
- export function resolveModel(ref: KnownModelRef): Model<'openai-completions'> | Model<'openai-responses'> {
660
- // Model IDs can contain '/', so split only on the first separator.
661
- const slash = ref.indexOf('/')
662
- const providerId = ref.slice(0, slash) as KnownProviderId
663
- const modelId = ref.slice(slash + 1)
664
- return KNOWN_PROVIDERS[providerId].models[modelId as never]
702
+ export function resolveModel(ref: KnownModelRef | ModelRef | string): Model<KnownApi> {
703
+ const providerId = providerForModelRef(ref)
704
+ const modelId = ref.slice(providerId.length + 1)
705
+ const provider = KNOWN_PROVIDERS[providerId]
706
+ if (isKnownModelRef(ref)) {
707
+ const model = (provider.models as Record<string, Model<KnownApi>>)[modelId]
708
+ if (model !== undefined) return model
709
+ }
710
+
711
+ if (!isModelRef(ref)) {
712
+ throw new Error(`Invalid model ref "${ref}". Expected "<known-provider>/<model-id>".`)
713
+ }
714
+
715
+ const templateModelId = Object.keys(provider.models)[0]
716
+ if (templateModelId === undefined) {
717
+ throw new Error(`Provider ${providerId} has no curated models to use as a transport template`)
718
+ }
719
+ const template = (provider.models as Record<string, Model<KnownApi>>)[templateModelId]
720
+ if (template === undefined) {
721
+ throw new Error(`Provider ${providerId} has no curated models to use as a transport template`)
722
+ }
723
+
724
+ const meta = getConfig().customModels[ref]
725
+ return {
726
+ id: modelId,
727
+ provider: providerId,
728
+ baseUrl: provider.baseUrl ?? template.baseUrl,
729
+ api: template.api,
730
+ name: meta?.name ?? modelId,
731
+ reasoning: meta?.reasoning ?? false,
732
+ input: resolveCustomModelInput(meta?.input),
733
+ contextWindow: meta?.contextWindow ?? template.contextWindow,
734
+ maxTokens: meta?.maxTokens ?? template.maxTokens,
735
+ cost: resolveCustomModelCost(meta?.cost),
736
+ }
737
+ }
738
+
739
+ function resolveCustomModelInput(input: readonly string[] | undefined): Model<KnownApi>['input'] {
740
+ if (input === undefined) return ['text']
741
+ const supported = input.filter(
742
+ (value): value is Model<KnownApi>['input'][number] => value === 'text' || value === 'image',
743
+ )
744
+ return supported.length > 0 ? supported : ['text']
745
+ }
746
+
747
+ function resolveCustomModelCost(cost: CustomModelMeta['cost']): Model<KnownApi>['cost'] {
748
+ return {
749
+ input: cost?.input ?? 0,
750
+ output: cost?.output ?? 0,
751
+ cacheRead: cost?.cacheRead ?? 0,
752
+ cacheWrite: cost?.cacheWrite ?? 0,
753
+ }
665
754
  }
666
755
 
667
756
  // Resolves a profile name (e.g. `fast`, `deep`, `vision`) to its fallback
@@ -672,8 +761,8 @@ export function resolveModel(ref: KnownModelRef): Model<'openai-completions'> |
672
761
  // session is created with first. Callers that don't implement fallback can
673
762
  // keep reading `ref`; fallback-aware callers iterate `refs`.
674
763
  export type ResolvedProfile = {
675
- ref: KnownModelRef
676
- refs: KnownModelRef[]
764
+ ref: ModelRef
765
+ refs: ModelRef[]
677
766
  profile: string
678
767
  fellBackToDefault: boolean
679
768
  }
@@ -790,6 +879,7 @@ export type FieldEffect = 'applied' | 'restart-required' | 'ignored'
790
879
  export const FIELD_EFFECTS: Record<string, FieldEffect> = {
791
880
  $schema: 'ignored',
792
881
  models: 'applied',
882
+ customModels: 'applied',
793
883
  port: 'restart-required',
794
884
  mounts: 'restart-required',
795
885
  mcpServers: 'restart-required',
@@ -885,6 +975,7 @@ export function extractPluginConfigs(raw: unknown): Record<string, unknown> {
885
975
  '$schema',
886
976
  'port',
887
977
  'models',
978
+ 'customModels',
888
979
  'mounts',
889
980
  'plugins',
890
981
  'alias',
@@ -2,6 +2,8 @@ export {
2
2
  buildConfigMigrationCommitMessage,
3
3
  config,
4
4
  configSchema,
5
+ customModelMetaSchema,
6
+ customModelsSchema,
5
7
  DEFAULT_PLUGINS,
6
8
  dockerSchema,
7
9
  dockerfileSchema,
@@ -29,6 +31,8 @@ export {
29
31
  type Config,
30
32
  type ConfigChange,
31
33
  type ConfigReloadDiff,
34
+ type CustomModelMeta,
35
+ type CustomModels,
32
36
  type DockerConfig,
33
37
  type DockerfileConfig,
34
38
  type GitConfig,
@@ -43,5 +47,5 @@ export {
43
47
  type ResolvedProfile,
44
48
  type ValidateConfigResult,
45
49
  } from './config'
46
- export { type KnownModelRef, type KnownProviderId } from './providers'
50
+ export { type KnownModelRef, type KnownProviderId, type ModelRef } from './providers'
47
51
  export { createConfigReloadable, type CreateConfigReloadableOptions } from './reloadable'
@@ -3,13 +3,16 @@ import { join } from 'node:path'
3
3
 
4
4
  import { commitSystemFileSync } from '@/git/system-commit'
5
5
 
6
- import { configSchema, loadConfigSyncOrDefaults, validateConfig } from './config'
6
+ import { configSchema, loadConfigSyncOrDefaults, validateConfig, type CustomModelMeta } from './config'
7
7
  import {
8
8
  KNOWN_PROVIDERS,
9
+ isKnownModelRef,
10
+ isModelRef,
9
11
  listKnownModelRefs,
10
12
  providerForModelRef,
11
13
  type KnownModelRef,
12
14
  type KnownProviderId,
15
+ type ModelRef,
13
16
  } from './providers'
14
17
  import { isProviderConfigured, listConfiguredProviders } from './providers-mutation'
15
18
 
@@ -20,8 +23,8 @@ export type ModelProfileEntry = {
20
23
  // Head of the fallback chain. Kept under the legacy `ref` name so callers
21
24
  // that only care about the active model (the common case) don't need to
22
25
  // dereference `refs[0]`. The chain itself is exposed as `refs`.
23
- ref: KnownModelRef
24
- refs: KnownModelRef[]
26
+ ref: ModelRef
27
+ refs: ModelRef[]
25
28
  providerId: KnownProviderId
26
29
  // Credential status for every provider referenced by the chain. The chain's
27
30
  // overall status is `available` only when every entry resolves; otherwise
@@ -67,7 +70,7 @@ export function listModelProfiles(cwd: string, env: NodeJS.ProcessEnv = process.
67
70
  return out
68
71
  }
69
72
 
70
- function uniqueProviders(refs: ReadonlyArray<KnownModelRef>): KnownProviderId[] {
73
+ function uniqueProviders(refs: ReadonlyArray<ModelRef>): KnownProviderId[] {
71
74
  const seen = new Set<KnownProviderId>()
72
75
  const out: KnownProviderId[] = []
73
76
  for (const r of refs) {
@@ -103,9 +106,7 @@ export function listRegisteredModelRefs(cwd: string, env: NodeJS.ProcessEnv = pr
103
106
  return listKnownModelRefs().filter((ref) => registered.has(providerForModelRef(ref)))
104
107
  }
105
108
 
106
- export function isKnownModelRef(value: string): value is KnownModelRef {
107
- return (listKnownModelRefs() as ReadonlyArray<string>).includes(value)
108
- }
109
+ export { isKnownModelRef }
109
110
 
110
111
  // `set` is the canonical mutation for both creating a new profile and updating
111
112
  // an existing one (mirrors how `models.<profile>` works in the schema).
@@ -115,6 +116,7 @@ export function isKnownModelRef(value: string): value is KnownModelRef {
115
116
  export type SetProfileOptions = {
116
117
  force?: boolean
117
118
  env?: NodeJS.ProcessEnv
119
+ meta?: CustomModelMeta
118
120
  }
119
121
 
120
122
  export function setProfile(
@@ -127,7 +129,7 @@ export function setProfile(
127
129
  if (trimmed.length === 0) {
128
130
  return { ok: false, reason: 'Profile name cannot be empty.' }
129
131
  }
130
- if (!isKnownModelRef(ref)) {
132
+ if (!isModelRef(ref)) {
131
133
  return {
132
134
  ok: false,
133
135
  reason: `Unknown model "${ref}". Run \`typeclaw model list --available\` to see valid options.`,
@@ -143,7 +145,8 @@ export function setProfile(
143
145
 
144
146
  const existingBefore = readModelsRaw(cwd)
145
147
  const verb = existingBefore !== null && trimmed in existingBefore ? 'set' : 'add'
146
- return writeProfile(cwd, trimmed, ref, `model: ${verb} ${trimmed} ${ref}`)
148
+ const customModel = !isKnownModelRef(ref) && options.meta !== undefined ? { ref, meta: options.meta } : undefined
149
+ return writeProfile(cwd, trimmed, ref, `model: ${verb} ${trimmed} → ${ref}`, customModel)
147
150
  }
148
151
 
149
152
  // `add` is just `set` with a uniqueness guard; users who want "update" should
@@ -189,19 +192,26 @@ export function removeProfile(cwd: string, profile: string): ModelMutationResult
189
192
  return writeModels(cwd, next, `model: remove ${profile}`)
190
193
  }
191
194
 
192
- function writeProfile(cwd: string, profile: string, ref: KnownModelRef, message: string): ModelMutationResult {
195
+ function writeProfile(
196
+ cwd: string,
197
+ profile: string,
198
+ ref: ModelRef,
199
+ message: string,
200
+ customModel?: { ref: ModelRef; meta: CustomModelMeta },
201
+ ): ModelMutationResult {
193
202
  const existing = readModelsRaw(cwd)
194
203
  const next: Record<string, string | string[]> = existing === null ? { default: ref } : { ...existing, [profile]: ref }
195
204
  if (existing === null && profile !== 'default') {
196
205
  next.default = ref
197
206
  }
198
- return writeModels(cwd, next, message)
207
+ return writeModels(cwd, next, message, customModel)
199
208
  }
200
209
 
201
210
  function writeModels(
202
211
  cwd: string,
203
212
  models: Record<string, string | string[]>,
204
213
  commitMessage: string,
214
+ customModel?: { ref: ModelRef; meta: CustomModelMeta },
205
215
  ): ModelMutationResult {
206
216
  const path = join(cwd, CONFIG_FILE)
207
217
  let parsed: Record<string, unknown>
@@ -215,6 +225,10 @@ function writeModels(
215
225
  return { ok: false, reason: `Failed to read ${CONFIG_FILE}: ${(error as Error).message}` }
216
226
  }
217
227
  parsed.models = models
228
+ if (customModel !== undefined) {
229
+ const existingCustomModels = isObjectRecord(parsed.customModels) ? parsed.customModels : {}
230
+ parsed.customModels = { ...existingCustomModels, [customModel.ref]: customModel.meta }
231
+ }
218
232
  const check = configSchema.safeParse(parsed)
219
233
  if (!check.success) {
220
234
  return {
@@ -244,6 +258,10 @@ function writeModels(
244
258
  return { ok: true }
245
259
  }
246
260
 
261
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
262
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
263
+ }
264
+
247
265
  // Returns the raw `models` block from disk in its on-disk shape: each value
248
266
  // is `string | string[]` (the user-facing schema). Writers preserve whichever
249
267
  // shape was already present for profiles they don't touch — converting a
@@ -5,7 +5,7 @@ import { providerKeyDefaultEnv } from '@/secrets/defaults'
5
5
  import type { ProviderCredential, Providers } from '@/secrets/schema'
6
6
 
7
7
  import { type Models, loadConfigSync } from './config'
8
- import { KNOWN_PROVIDERS, type KnownModelRef, type KnownProviderId, providerForModelRef } from './providers'
8
+ import { KNOWN_PROVIDERS, type KnownProviderId, type ModelRef, providerForModelRef } from './providers'
9
9
 
10
10
  // Where a configured credential resolves from at runtime. Reported by
11
11
  // `typeclaw provider list` so users can tell whether their key is coming from
@@ -230,7 +230,7 @@ function refTargetsProvider(ref: string, providerId: string): boolean {
230
230
  return ref.startsWith(`${providerId}/`)
231
231
  }
232
232
 
233
- function safeProviderForRef(ref: KnownModelRef): KnownProviderId | null {
233
+ function safeProviderForRef(ref: ModelRef): KnownProviderId | null {
234
234
  try {
235
235
  return providerForModelRef(ref)
236
236
  } catch {