v0.38.1 969e52a

build.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"path/filepath"
8	"runtime"
9	"sort"
10	"strings"
11
12	zutdocs "github.com/bnema/zut"
13	"github.com/bnema/zut/packages/agent/lsp"
14	"github.com/bnema/zut/packages/agent/skills"
15	"github.com/bnema/zut/packages/agent/subagents"
16	"github.com/bnema/zut/packages/agent/tools"
17	"github.com/bnema/zut/packages/core"
18	"github.com/bnema/zut/packages/provider"
19)
20
21// ContextFile is an instruction file loaded into the system prompt.
22type ContextFile struct {
23	Path    string
24	Content string
25}
26
27// Resolved is the effective configuration after merging CLI, config, defaults.
28type Resolved struct {
29	Provider        string
30	Model           string
31	Credential      string // api key or oauth access token
32	AuthMethod      string // "apikey" | "oauth" | "" (no credential yet)
33	AccountID       string // ChatGPT account id (for openai oauth), "" otherwise
34	BaseURL         string
35	InsecureTLS     bool
36	CWD             string
37	Reasoning       string
38	Temperature     *float32
39	FastMode        bool
40	WebSearchPolicy subagents.WebSearchPolicy
41
42	ToolRegistry core.Registry
43	ToolSummary  []ToolSummary
44	SystemPrompt string
45	MaxSteps     int
46	Sandbox      *tools.Sandbox
47
48	// ContextWindow and MaxOutput retain the effective model metadata,
49	// including metadata synthesized for valid open-catalog local and routed
50	// models that provider.FindModel cannot rediscover later.
51	ContextWindow int
52
53	// MaxOutput is the resolved model's maximum output-token budget
54	// (from the catalog). Passed to the agent so each turn requests
55	// the model's full output capacity instead of the provider's
56	// conservative default (e.g. Bedrock's 4096, which truncates
57	// long writes/edits with stopReason=length).
58	MaxOutput int
59
60	// modelCatalog is a runtime-owned snapshot for providers whose model
61	// metadata is account- or endpoint-scoped. It prevents a later SDK
62	// runtime from changing request shaping for an already-created runtime.
63	modelCatalog              []provider.Model
64	modelCatalogAuthoritative bool
65
66	// customProviderConfig snapshots user-provider transport settings so
67	// client construction does not reread a mutable global registry.
68	customProviderConfig *provider.CustomProviderConfig
69
70	// SkillTool is the on-demand skill loader registered with the
71	// agent's tool registry, or nil if no SKILL.md files were
72	// discovered. Exposed so the tui can list / preview skills.
73	SkillTool *skills.Tool
74
75	// ContextFiles records the AGENTS.md files appended to SystemPrompt,
76	// in effective load order. Interactive mode uses this metadata to make
77	// otherwise invisible startup context inspectable without adding fake
78	// messages to the provider transcript.
79	ContextFiles []ContextFile
80
81	// skillsEnabled is true when discovery ran, even when it found no
82	// skills. It is false only for an explicit clean-room disable.
83	skillsEnabled bool
84
85	// Bookkeeping for MergeExtensionTools. Captured at Resolve time
86	// so the system prompt can be rebuilt later without re-running
87	// resolve.
88	systemAppend     []string
89	systemCustom     string
90	skillAddendum    string
91	toolDescriptions map[string]string
92}
93
94// HasCredential reports whether a credential was resolved.
95func (r Resolved) HasCredential() bool { return r.Credential != "" }
96
97// ModelCatalogSnapshot returns the runtime-owned model metadata captured while
98// resolving. It is used by SDK clients whose provider catalog is scoped to a
99// credential or endpoint.
100func (r Resolved) ModelCatalogSnapshot() []provider.Model {
101	return append([]provider.Model(nil), r.modelCatalog...)
102}
103
104// ModelCatalogIsAuthoritative reports whether the runtime snapshot came from
105// a successful provider catalog that is authoritative for model IDs.
106func (r Resolved) ModelCatalogIsAuthoritative() bool {
107	return r.modelCatalogAuthoritative
108}
109
110// MergeExtensionTools folds every tool registered by an extension
111// into r's ToolRegistry and re-renders the system prompt's tool
112// summary so the model sees both built-in and extension tools.
113//
114// Idempotent: calling twice with the same manager state has no
115// effect on the second pass (existing names are preserved). Built-in
116// tools always win on conflict.
117func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
118	if mgr == nil {
119		return
120	}
121	changed := false
122	for _, info := range mgr.Tools() {
123		// Web capability and other native names remain reserved even when their
124		// policy excludes the current session. An extension must not turn a
125		// normal CLI opt-out into a differently implemented capability.
126		if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "glob" || info.Name == "ast" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.UpdatePlanToolName {
127			continue
128		}
129		if _, exists := r.ToolRegistry[info.Name]; exists {
130			continue
131		}
132		r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
133		changed = true
134	}
135
136	// A nil SkillTool can mean discovery ran and found nothing, so use the
137	// explicit disable flag rather than the discovery result as the gate.
138	if source, ok := mgr.(ExtensionSkillSource); ok && r.skillsEnabled {
139		if bundled := source.Skills(); len(bundled) > 0 {
140			merged := mergeExtensionSkills(r.SkillTool, bundled)
141			if r.SkillTool == nil {
142				r.SkillTool = skills.NewTool(merged)
143				r.ToolRegistry[r.SkillTool.Name()] = r.SkillTool
144			} else {
145				r.SkillTool.SetSkills(merged)
146			}
147			append_ := append([]string(nil), r.systemAppend...)
148			if r.skillAddendum != "" {
149				filtered := append_[:0]
150				for _, item := range append_ {
151					if item != r.skillAddendum {
152						filtered = append(filtered, item)
153					}
154				}
155				append_ = filtered
156			}
157			r.skillAddendum = skills.SystemPromptAddendum(merged)
158			if r.skillAddendum != "" {
159				append_ = append(append_, r.skillAddendum)
160			}
161			r.systemAppend = append_
162			changed = true
163		}
164	}
165	if !changed {
166		return
167	}
168	// Re-render the system prompt with the merged tool and skill lists.
169	r.ToolSummary = toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions)
170	r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
171		CWD:        r.CWD,
172		Tools:      r.ToolSummary,
173		Custom:     r.systemCustom,
174		Append:     r.systemAppend,
175		ZutDocsDir: filepath.Join(ZutHome(), "docs"),
176	})
177}
178
179func mergeExtensionSkills(tool *skills.Tool, bundled []*skills.Skill) []*skills.Skill {
180	var existing []*skills.Skill
181	if tool != nil {
182		existing = tool.Skills()
183	}
184	merged := make([]*skills.Skill, 0, len(existing)+len(bundled))
185	seen := map[string]bool{}
186	add := func(list []*skills.Skill) {
187		for _, skill := range list {
188			if skill == nil || skill.Name == "" || seen[skill.Name] {
189				continue
190			}
191			seen[skill.Name] = true
192			merged = append(merged, skill)
193		}
194	}
195	var user, builtins []*skills.Skill
196	for _, skill := range existing {
197		if skill == nil || strings.HasPrefix(skill.Source, "extension ") {
198			continue
199		}
200		if skill.Builtin {
201			builtins = append(builtins, skill)
202		} else {
203			user = append(user, skill)
204		}
205	}
206	add(user)
207	add(bundled)
208	add(builtins)
209	return merged
210}
211
212// ExtensionToolSource is the slice of the extension manager that
213// MergeExtensionTools needs. Lives here as an interface so the
214// build package doesn't import packages/agent/extensions (which
215// imports core, which imports... avoid the cycle).
216type ExtensionToolSource interface {
217	Tools() []ExtensionToolInfo
218	NewExtensionTool(info ExtensionToolInfo) core.Tool
219}
220
221// ExtensionSkillSource exposes the optional bundled-skill surface without
222// forcing older ExtensionToolSource implementations to add it.
223type ExtensionSkillSource interface {
224	Skills() []*skills.Skill
225}
226
227// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
228// ExtensionToolSource here without importing the extensions
229// package. The cli wires a tiny adapter to bridge them.
230type ExtensionToolInfo struct {
231	Extension   string
232	Name        string
233	Description string
234	Schema      []byte
235	Deferred    bool
236}
237
238// toolSummariesFromRegistry rebuilds the system-prompt tool list
239// from a (possibly extended) registry, using cached descriptions for
240// the human-readable summary text.
241func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
242	out := make([]ToolSummary, 0, len(reg))
243	seen := make(map[string]bool, len(nativeToolSummaryOrder))
244	appendSummary := func(name string) {
245		t, ok := reg[name]
246		if !ok {
247			return
248		}
249		seen[name] = true
250		if d, ok := t.(interface{ Deferred() bool }); ok && d.Deferred() {
251			return
252		}
253		desc := t.Description()
254		if d, ok := cached[name]; ok && d != "" {
255			desc = d
256		}
257		out = append(out, ToolSummary{Name: name, Description: desc})
258	}
259	for _, name := range nativeToolSummaryOrder {
260		appendSummary(name)
261	}
262	remaining := make([]string, 0, len(reg)-len(seen))
263	for name := range reg {
264		if !seen[name] {
265			remaining = append(remaining, name)
266		}
267	}
268	sort.Strings(remaining)
269	for _, name := range remaining {
270		appendSummary(name)
271	}
272	return out
273}
274
275const openCodeGoBootstrapModel = "kimi-k2.6"
276
277// defaultModelForProvider returns the model id zut prefers when the
278// caller didn't pick one. Mirrors the per-provider switch used at
279// multiple points in Resolve; centralised so the unknown-model
280// recovery path and the no-model-configured path can't drift.
281//
282// Returns the empty string for "ollama", which has no built-in
283// default — the caller is expected to special-case ollama and
284// error or use whatever the user passed.
285func defaultModelForProvider(prov string) string {
286	switch prov {
287	case provider.ProviderOpenAI:
288		return "gpt-5"
289	case provider.ProviderOpenAICodex:
290		return "gpt-5.5"
291	case provider.ProviderOpenAIResponses:
292		return "gpt-5"
293	case provider.ProviderKimi:
294		return "kimi-for-coding"
295	case provider.ProviderDeepSeek:
296		return "deepseek-v4-pro"
297	case provider.ProviderGoogle:
298		return "gemini-2.5-pro"
299	case provider.ProviderOllama, provider.ProviderLlamaCPP:
300		return ""
301	case provider.ProviderMoonshot, provider.ProviderMoonshotCN:
302		return "kimi-k2.6"
303	case provider.ProviderCerebras:
304		return "qwen-3-235b-a22b-instruct-2507"
305	case provider.ProviderGroq:
306		return "llama-3.3-70b-versatile"
307	case provider.ProviderXAI:
308		return "grok-4.5"
309	case provider.ProviderTogether:
310		return "Qwen/Qwen3-Coder-480B-A35B-Instruct"
311	case provider.ProviderHuggingFace:
312		return "moonshotai/Kimi-K2-Instruct"
313	case provider.ProviderOpenRouter:
314		return "anthropic/claude-sonnet-4.5"
315	case provider.ProviderMistral:
316		return "mistral-large-latest"
317	case provider.ProviderZAI:
318		return "glm-4.7"
319	case provider.ProviderXiaomi, provider.ProviderXiaomiTokenPlanAMS, provider.ProviderXiaomiTokenPlanCN, provider.ProviderXiaomiTokenPlanSGP:
320		return "mimo-v2.5"
321	case provider.ProviderMiniMax, provider.ProviderMiniMaxCN:
322		return "MiniMax-M2.7"
323	case provider.ProviderFireworks:
324		return "accounts/fireworks/models/kimi-k2p6"
325	case provider.ProviderVercelAIGateway:
326		return "anthropic/claude-sonnet-4.5"
327	case provider.ProviderOpenCode:
328		return "claude-sonnet-4-5"
329	case provider.ProviderOpenCodeGo:
330		// OpenCode Go is synchronized at runtime, so prefer the first
331		// currently served model and keep the former default only as a
332		// bootstrap while the first refresh is still in flight.
333		if models := provider.ModelsForProvider(prov); len(models) > 0 {
334			return models[0].ID
335		}
336		return openCodeGoBootstrapModel
337	case provider.ProviderAmazonBedrock:
338		return "anthropic.claude-sonnet-4-5-20250929-v1:0"
339	case provider.ProviderGoogleVertex:
340		return "gemini-2.5-pro"
341	case provider.ProviderAzureOpenAIResponses:
342		return "gpt-5"
343	case provider.ProviderGitHubCopilot:
344		return "claude-sonnet-4.5"
345	default:
346		// Custom providers: pick the first model from the catalog for
347		// that provider, or fall back to the global default.
348		if models := provider.ModelsForProvider(prov); len(models) > 0 {
349			return models[0].ID
350		}
351		return provider.DefaultModel.ID
352	}
353}
354
355// knownProviders is the set of provider ids zut recognises. Used by
356// Resolve to validate args.Provider, by extension-callers, and by the
357// auto-fallback logic that picks any logged-in provider when the user's
358// preferred one has no credentials.
359var knownProviders = provider.BuiltinProviderIDs()
360
361func isKnownProvider(name string) bool {
362	for _, p := range knownProviders {
363		if p == name {
364			return true
365		}
366	}
367	_, ok := provider.CustomProviders()[name]
368	return ok
369}
370
371// isBuiltinProvider reports whether name is in the hardcoded
372// knownProviders list (not a user-defined custom provider).
373func isBuiltinProvider(name string) bool {
374	for _, p := range knownProviders {
375		if p == name {
376			return true
377		}
378	}
379	return false
380}
381
382// providerAliases maps common short / alternate provider names to the
383// canonical id in knownProviders. Users (and other agents) reach for
384// "bedrock" or "vertex" far more naturally than the fully-qualified
385// "amazon-bedrock" / "google-vertex"; without this mapping an alias is
386// treated as an unknown provider and Resolve silently falls back to
387// anthropic, producing a misleading "no credential for anthropic" error
388// after the user explicitly picked, say, bedrock.
389var providerAliases = map[string]string{
390	"bedrock":      "amazon-bedrock",
391	"aws-bedrock":  "amazon-bedrock",
392	"amazon":       "amazon-bedrock",
393	"vertex":       "google-vertex",
394	"gcp-vertex":   "google-vertex",
395	"gemini":       "google",
396	"googleai":     "google",
397	"google-ai":    "google",
398	"azure":        "azure-openai-responses",
399	"azure-openai": "azure-openai-responses",
400	"copilot":      "github-copilot",
401	"github":       "github-copilot",
402	"codex":        "openai-codex",
403	"moonshot":     "moonshotai",
404	"kimi-code":    "kimi",
405	"ai-gateway":   "vercel-ai-gateway",
406	"vercel":       "vercel-ai-gateway",
407	"cloudflare":   "cloudflare-workers-ai",
408	"workers-ai":   "cloudflare-workers-ai",
409	"hf":           "huggingface",
410	"llama":        provider.LlamaCPPProviderID,
411	"llamacpp":     provider.LlamaCPPProviderID,
412}
413
414// canonicalProvider normalises a user-supplied provider name: trims
415// surrounding whitespace, lower-cases it, and resolves any known alias
416// to its canonical id. Unknown names are returned trimmed/lower-cased
417// and unchanged so the existing unknown-provider handling still runs.
418func canonicalProvider(name string) string {
419	n := strings.ToLower(strings.TrimSpace(name))
420	if n == "" {
421		return n
422	}
423	if canon, ok := providerAliases[n]; ok {
424		return canon
425	}
426	return n
427}
428
429func findSubagentProfile(cwd, name string) (*subagents.Profile, error) {
430	name = strings.TrimSpace(name)
431	if name == "" {
432		return nil, nil
433	}
434	home, _ := os.UserHomeDir()
435	profiles, discoveryErrors := subagents.Discover(cwd, home)
436	if len(discoveryErrors) > 0 {
437		return nil, fmt.Errorf("discover subagent profiles: %d profile discovery error(s)", len(discoveryErrors))
438	}
439	profile := subagents.Find(profiles, name)
440	if profile == nil {
441		return nil, fmt.Errorf("subagent profile %q not found (looked in ~/.agents/agents and configured ZUT_AGENT_PROFILES paths)", name)
442	}
443	return profile, nil
444}
445
446// resolveWebSearchPolicy applies capability precedence at the owning resolver
447// boundary. Permission, tool-list, and named-profile restrictions are ceilings
448// on every caller-provided policy.
449func resolveWebSearchPolicy(args Args, cfg Config, cfgErr error) subagents.WebSearchPolicy {
450	if args.NoTools || args.PermissionSet != nil {
451		return subagents.WebSearchDeny
452	}
453	if args.ToolsSet && !toolListContains(args.Tools, "web_search") {
454		return subagents.WebSearchDeny
455	}
456
457	switch args.WebSearchPolicy {
458	case subagents.WebSearchDeny:
459		return subagents.WebSearchDeny
460	case subagents.WebSearchAllow:
461		return subagents.WebSearchAllow
462	case subagents.WebSearchInherit:
463		// Continue through normal-mode defaults below. Inherit is not a valid
464		// propagated worker decision.
465	default:
466		return subagents.WebSearchDeny
467	}
468	if args.ToolsSet {
469		return subagents.WebSearchAllow
470	}
471	if cfgErr != nil {
472		return subagents.WebSearchDeny
473	}
474	if cfg.WebSearchEnabledForCLI() {
475		return subagents.WebSearchAllow
476	}
477	return subagents.WebSearchDeny
478}
479
480func toolListContains(names []string, target string) bool {
481	for _, name := range names {
482		if name == target {
483			return true
484		}
485	}
486	return false
487}
488
489// webSearchAllowedForRegistry is kept separate from the resolver so direct
490// registry tests and callers that build a registry without persisted config
491// still get the normal default-on behavior.
492func webSearchAllowedForRegistry(args Args) bool {
493	if args.NoTools || args.PermissionSet != nil {
494		return false
495	}
496	if args.ToolsSet && !toolListContains(args.Tools, "web_search") {
497		return false
498	}
499	switch args.WebSearchPolicy {
500	case subagents.WebSearchAllow:
501		return true
502	case subagents.WebSearchDeny:
503		return false
504	default:
505		return true
506	}
507}
508
509func findModelForResolve(providerName, modelID string, scopedCatalog []provider.Model) (provider.Model, error) {
510	if providerName == provider.ProviderOpenCodeGo && scopedCatalog != nil {
511		for _, model := range scopedCatalog {
512			if model.ID == modelID {
513				return model, nil
514			}
515		}
516		return provider.Model{}, fmt.Errorf("unknown model %q (provider=%q)", modelID, providerName)
517	}
518	return provider.FindModel(providerName, modelID)
519}
520
521func acceptsUnlistedModel(providerName string, args Args) bool {
522	if providerName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
523		return !args.modelCatalogAuthoritative
524	}
525	return provider.AcceptsUnlistedModels(providerName)
526}
527
528func defaultModelForResolve(providerName string, args Args) string {
529	if providerName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
530		if len(args.modelCatalog) > 0 {
531			return args.modelCatalog[0].ID
532		}
533		return openCodeGoBootstrapModel
534	}
535	return defaultModelForProvider(providerName)
536}
537
538// Resolve merges args, config, and env into a Resolved set.
539//
540// Unlike the earlier version, Resolve NEVER returns an error for
541// missing credentials: the TUI can start without them and launch a
542// login flow. requireCred controls whether missing credentials are a
543// hard error (used by print/json modes).
544func Resolve(args Args, requireCred bool) (Resolved, error) {
545	cfg, cfgErr := LoadConfig()
546	profileSlot := cfg.applyActiveModelProfile()
547	repairConfig := false
548	// Programmatic callers historically supplied Tools without the parser's
549	// provenance bit. Treat a non-empty list as explicit before a named profile
550	// can contribute its defaults.
551	if len(args.Tools) > 0 {
552		args.ToolsSet = true
553	}
554	webSearchPolicy := resolveWebSearchPolicy(args, cfg, cfgErr)
555	args.WebSearchPolicy = webSearchPolicy
556
557	// User-requested provider (explicit > config > default).
558	// Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
559	// before validation so an alias is never mistaken for an unknown
560	// provider and silently downgraded to anthropic.
561	argProvider := canonicalProvider(args.Provider)
562	provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic")
563	if !isKnownProvider(provName) {
564		// Unknown provider (maybe removed or renamed). Fall back to
565		// the first provider that has credentials, or anthropic.
566		// Custom providers (from models.json) are already accepted
567		// by isKnownProvider, so we only reach here for truly unknown
568		// names.
569		provName = "anthropic"
570		if CredentialAvailable("openai") {
571			provName = "openai"
572		}
573		if CredentialAvailable("openai-codex") {
574			provName = "openai-codex"
575		}
576		if CredentialAvailable("kimi") {
577			provName = "kimi"
578		}
579		if CredentialAvailable("deepseek") {
580			provName = "deepseek"
581		}
582		if CredentialAvailable("anthropic") {
583			provName = "anthropic"
584		}
585		// Reset the saved config so this doesn't keep happening.
586		cfg.Provider = provName
587		cfg.Model = ""
588		repairConfig = true
589	}
590
591	var (
592		cred      string
593		method    string
594		accountID string
595		credErr   error
596	)
597	if provName == "ollama" {
598		cred = firstNonEmpty(args.APIKey, "ollama")
599		method = "apikey"
600	} else {
601		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
602	}
603
604	// Persist --api-key for custom providers so subsequent runs don't
605	// need to pass it again.
606	if !isBuiltinProvider(provName) && args.APIKey != "" {
607		if store := AuthStoreFor(); store != nil {
608			_ = store.SetAPIKey(provName, args.APIKey)
609		}
610	}
611
612	// If the user did NOT explicitly pick a provider (neither via --provider
613	// nor by saving one in config.json) and the default one has no
614	// credentials, auto-fall-back to whichever provider is actually logged
615	// in. That way running plain `zut` after `/login` (any provider) never
616	// shows a "not logged in" banner.
617	//
618	// Critical: when the user HAS saved a provider in config.json (e.g.
619	// "deepseek" with "deepseek-v4-flash"), that choice is respected even
620	// if no credential is found — the tui will show the login dialog or
621	// surface a clear credential error rather than silently switching to
622	// a completely different provider and model.
623	// autoFellBack tracks whether the provider was changed by the
624	// auto-fallback loop (no explicit provider from CLI or config,
625	// and the default had no credentials). When false, the provider
626	// came from the user's explicit choice (--provider or config.json)
627	// and must be respected, including any model id that may belong
628	// to a different provider in the catalog — for example when using
629	// a gateway/router like OpenRouter with "deepseek/deepseek-v4-flash".
630	var autoFellBack bool
631	userPickedProvider := args.Provider != "" || cfg.Provider != ""
632	if credErr != nil && !userPickedProvider && provName != "ollama" {
633		autoFellBack = true
634		// Scan every known provider (not a hardcoded subset) so any
635		// env-based credential is discovered, e.g. an env-only
636		// amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
637		// IAM keys) when no config.json pins the provider, such as after
638		// pointing ZUT_HOME at a fresh home dir. Iteration order of
639		// knownProviders defines fallback priority. Local providers without a
640		// default model are skipped because selecting either one here would
641		// fail before the user can choose a model.
642		for _, other := range knownProviders {
643			if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID {
644				continue
645			}
646			if !CredentialAvailable(other) {
647				continue
648			}
649			// An explicit launch key belongs to the initially selected provider;
650			// automatic fallback must never reuse it for another provider.
651			c, m, a, err := ResolveCredentialFull(other, "")
652			provName = other
653			cred, method, accountID, credErr = c, m, a, err
654			break
655		}
656	}
657
658	var customProviderConfig *provider.CustomProviderConfig
659	if custom, ok := provider.CustomProviders()[provName]; ok {
660		customProviderConfig = &custom
661	}
662
663	// Normalize model ids at the resolver boundary without mutating the
664	// caller's Args value. Treat whitespace-only values as missing so a
665	// configured fallback can still be selected.
666	model := strings.TrimSpace(args.Model)
667	if model == "" {
668		model = strings.TrimSpace(cfg.Model)
669	}
670	if model == "" {
671		if provName == "ollama" || provName == provider.LlamaCPPProviderID {
672			return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName)
673		}
674		if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
675			// Keep model selection inside the runtime snapshot instead of
676			// consulting another runtime's mutable catalog.
677			model = defaultModelForResolve(provName, args)
678		} else {
679			model = defaultModelForProvider(provName)
680		}
681	}
682	// If the resolved model belongs to a different provider (e.g. config
683	// says gpt-5 but we auto-fell back to anthropic), pick that provider's
684	// default. This override only fires when the provider was auto-fallback-
685	// changed, NOT when the user explicitly configured the pair — gateway/
686	// router providers (openrouter, vercel-ai-gateway, etc.) can serve
687	// models from any provider in the catalog.
688	if autoFellBack && provName != "ollama" {
689		if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
690			if _, err := findModelForResolve(provName, model, args.modelCatalog); err != nil {
691				model = defaultModelForResolve(provName, args)
692			}
693		} else if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
694			model = defaultModelForProvider(provName)
695		}
696	}
697	resolvedModel, err := findModelForResolve(provName, model, args.modelCatalog)
698	if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) {
699		// Local providers are intentionally open-catalogue: any model id the
700		// configured server understands is valid, even if it is not cached.
701		resolvedModel = provider.Model{
702			Provider:      provName,
703			ID:            model,
704			DisplayName:   model,
705			ContextWindow: 128000,
706			MaxOutput:     16384,
707			BaseURL:       args.BaseURL,
708			Source:        provName,
709		}
710		err = nil
711	}
712	// OpenCode Go publishes its authoritative model list at runtime. Keep
713	// explicit model ids usable during the first refresh or while offline;
714	// the provider request will be the final authority if no catalog data is
715	// available yet.
716	if err != nil && acceptsUnlistedModel(provName, args) {
717		resolvedModel = provider.DynamicOpenCodeGoModel(provName, model, args.BaseURL)
718		err = nil
719	}
720	// Custom providers are open-catalogue like ollama: any model id the
721	// endpoint understands is valid. Use the provider-level base URL.
722	if err != nil && customProviderConfig != nil {
723		resolvedModel = provider.Model{
724			Provider:      provName,
725			ID:            model,
726			DisplayName:   model,
727			ContextWindow: 128000,
728			MaxOutput:     16384,
729			BaseURL:       customProviderConfig.BaseURL,
730			Source:        "user",
731		}
732		err = nil
733	}
734	if customProviderConfig != nil && err == nil && resolvedModel.BaseURL == "" && customProviderConfig.BaseURL != "" {
735		// Fall back to the provider-level base URL when the model does
736		// not define its own endpoint.
737		resolvedModel.BaseURL = customProviderConfig.BaseURL
738	}
739	if err != nil {
740		// The model the user (or persisted config) asked for is no
741		// longer in the active catalogue — they probably removed it
742		// from their models.json or upgraded zut and the id changed.
743		// Refusing to launch is the wrong move: it strands the user
744		// with no way to even open the TUI and pick a new model.
745		// Fall back to the provider's default, warn on stderr, and,
746		// when the stale id came from the persisted config (not an
747		// explicit --model flag), repair the config so the warning
748		// doesn't repeat on every launch.
749
750		// Gateway providers can accept route-qualified ids that are not in
751		// zut's local catalog yet, for example OpenRouter's
752		// "deepseek/deepseek-v4-flash". Preserve only route-qualified ids;
753		// plain unknown values are likely typos and should still fall back.
754		if isGatewayProvider(provName) && isGatewayRoutedModelID(model) {
755			resolvedModel = provider.Model{
756				Provider:      provName,
757				ID:            model,
758				DisplayName:   model,
759				ContextWindow: 1000000,
760				MaxOutput:     64000,
761				BaseURL:       "",
762				Source:        "gateway",
763			}
764			// Don't repair config — the routed model id may be valid upstream.
765		} else {
766			fallback := defaultModelForResolve(provName, args)
767			fm, ferr := findModelForResolve(provName, fallback, args.modelCatalog)
768			if ferr != nil {
769				// Even the provider default is gone (catastrophic
770				// catalogue trim). Last resort: any model on this
771				// provider, then the global DefaultModel. A scoped
772				// OpenCode Go runtime must stay inside its snapshot.
773				candidates := provider.ModelsForProvider(provName)
774				if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
775					candidates = args.modelCatalog
776				}
777				if len(candidates) > 0 {
778					fm = candidates[0]
779				} else {
780					fm = provider.DefaultModel
781				}
782			}
783			fmt.Fprintf(os.Stderr,
784				"zut: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
785				model, fm.ID)
786			if strings.TrimSpace(args.Model) == "" && strings.TrimSpace(cfg.Model) == model {
787				cfg.Model = fm.ID
788				repairConfig = true
789			}
790			resolvedModel = fm
791			model = fm.ID
792		}
793	}
794
795	if repairConfig {
796		// Repair the source favorite as well as legacy defaults, but never
797		// overwrite it because an explicit CLI override required fallback.
798		if profileSlot > 0 && args.Provider == "" && args.Model == "" {
799			cfg.Provider, cfg.Model = provName, model
800			cfg.Reasoning = provider.ClampReasoningForModel(resolvedModel, cfg.Reasoning)
801			cfg.QuickModelShortcuts[profileSlot-1] = QuickModelShortcut{
802				Provider: cfg.Provider, Model: cfg.Model, Reasoning: cfg.Reasoning,
803				FastMode: cfg.FastMode != nil && *cfg.FastMode,
804			}
805			cfg.ActiveModelProfile = profileSlot
806		}
807		_ = SaveConfig(cfg)
808	}
809
810	explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
811
812	// If the model defines a base URL (e.g. local ollama) and the
813	// user didn't pass --base-url, use the model's URL. For ollama,
814	// keep http://localhost:11434 as a fallback only after the model
815	// metadata has had a chance to provide a custom baseUrl.
816	if args.BaseURL == "" && resolvedModel.BaseURL != "" {
817		args.BaseURL = resolvedModel.BaseURL
818	}
819	if args.BaseURL == "" && provName == "ollama" {
820		args.BaseURL = "http://localhost:11434"
821	}
822	if args.BaseURL == "" && provName == provider.LlamaCPPProviderID {
823		managementURL, _, configErr := ResolveLlamaCPPConfig()
824		if configErr != nil {
825			return Resolved{}, configErr
826		}
827		var baseErr error
828		args.BaseURL, baseErr = provider.LlamaCPPInferenceURL(managementURL)
829		if baseErr != nil {
830			return Resolved{}, baseErr
831		}
832	}
833
834	// Insecure TLS is intentionally scoped to explicit custom endpoints.
835	// Built-in provider base URLs, auth calls, and model discovery keep normal
836	// certificate verification even when --insecure is present.
837	insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
838
839	// If the model has a base URL, credentials are optional (local
840	// models like ollama don't need real API keys).
841	if resolvedModel.BaseURL != "" && credErr != nil {
842		cred = "ollama"
843		credErr = nil
844		requireCred = false
845	}
846
847	if credErr != nil && requireCred {
848		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zut` and /login",
849			credErr, envVarName(provName))
850	}
851
852	sandbox := tools.NewSandbox(args.CWD)
853	if cfg.JailByDefault != nil && *cfg.JailByDefault {
854		sandbox.Lock()
855	}
856	if args.PermissionSet != nil {
857		sandbox.SetPermissions(args.PermissionSet)
858	}
859	lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(false)
860	reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(false), cfg.LSPDiagnosticsOnEditEnabled(false))
861	maybeAddPythonTool(reg, args, args.CWD, sandbox, cfg.PythonInterpreter)
862
863	docsDir, _ := zutdocs.EnsureInstalled(ZutHome())
864
865	// Skill discovery: scan project + global locations + built-in
866	// skills shipped with the binary. If any are found, register
867	// the on-demand `skill` loader tool plus a system-prompt
868	// manifest so the model knows what's available.
869	//
870	// --no-skill bypasses the entire mechanism: no manifest in the
871	// system prompt, no `skill` tool in the registry. Useful for a
872	// clean-room run with zero extra context biasing the model.
873	var (
874		discovered    []*skills.Skill
875		skillTool     *skills.Tool
876		skillAddendum string
877		skillsEnabled bool
878	)
879	if !args.NoSkill {
880		skillsEnabled = true
881		homeDir, _ := os.UserHomeDir()
882		discovered, _ = skills.Discover(ZutHome(), args.CWD, homeDir, args.WithSkills)
883		if len(discovered) > 0 {
884			skillTool = skills.NewTool(discovered)
885			reg[skillTool.Name()] = skillTool
886			skillAddendum = skills.SystemPromptAddendum(discovered)
887		}
888	}
889	_ = skillTool
890
891	summaries := toolSummaries(reg, args)
892
893	contextFiles := []ContextFile(nil)
894	if !args.NoContextFiles {
895		contextFiles = loadAgentsContext(args.CWD, ZutHome())
896	}
897	append_ := []string(nil)
898	if cfg.PonytailModeEnabled() {
899		if ponytailAddendum := PonytailSystemAddendum(); ponytailAddendum != "" {
900			append_ = append(append_, ponytailAddendum)
901		}
902	}
903	append_ = append(append_, args.AppendSystemPrompt...)
904	if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
905		append_ = append(append_, agentsAddendum)
906	}
907	if skillAddendum != "" {
908		append_ = append(append_, skillAddendum)
909	}
910	interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
911	primaryInteractive := interactiveMode && !args.ResidentChild
912	primaryOrchestrator := args.Orchestrate && !args.ResidentChild
913	if (primaryInteractive || primaryOrchestrator) && autoSubagentsToolAllowed(args) {
914		homeDir, _ := os.UserHomeDir()
915		profiles, _ := subagents.Discover(args.CWD, homeDir)
916		if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
917			append_ = append(append_, subagentsAddendum)
918		}
919	}
920	if primaryOrchestrator {
921		// Headless orchestration owns its strict prompt contract rather than
922		// inheriting the interactive collaboration setting.
923		append_ = append(append_, StrictOrchestratorSystemAddendumFor(
924			autoSubagentsToolAllowed(args),
925			autoSubagentsStopToolAllowed(args),
926			autoSubagentsResumeToolAllowed(args),
927		))
928	} else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
929		append_ = append(append_, ProactiveSubagentsSystemAddendumFor(
930			autoSubagentsToolAllowed(args),
931			autoSubagentsStopToolAllowed(args),
932			autoSubagentsResumeToolAllowed(args),
933		))
934	} else if primaryInteractive && autoSubagentsAnyToolAllowed(args) {
935		append_ = append(append_, OnDemandSubagentsSystemAddendum)
936	}
937	// Custom system prompt resolution order:
938	//   1. --system-prompt flag (highest priority; ad-hoc per run)
939	//   2. $ZUT_HOME/SYSTEM.md (persistent user override)
940	//   3. built-in default (defaultIdentity + defaultGuidelines)
941	custom := args.SystemPrompt
942	if custom == "" {
943		custom = readUserSystemPrompt(ZutHome())
944	}
945
946	sys := BuildSystemPrompt(SystemPromptOpts{
947		CWD:        args.CWD,
948		Tools:      summaries,
949		Custom:     custom,
950		Append:     append_,
951		ZutDocsDir: docsDir,
952	})
953
954	reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
955	temperature := args.Temperature
956	if temperature == nil {
957		temperature = cfg.Temperature
958	}
959	fastMode := cfg.FastMode != nil && *cfg.FastMode
960	if args.FastModeSet {
961		fastMode = args.FastMode
962	}
963	max := args.MaxSteps // 0 = unlimited
964
965	var modelCatalog []provider.Model
966	if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
967		modelCatalog = append([]provider.Model{}, args.modelCatalog...)
968		found := false
969		for _, catalogModel := range modelCatalog {
970			if catalogModel.ID == model {
971				found = true
972				break
973			}
974		}
975		if !found {
976			modelCatalog = append(modelCatalog, resolvedModel)
977		}
978	}
979
980	return Resolved{
981		Provider:                  provName,
982		Model:                     model,
983		Credential:                cred,
984		AuthMethod:                method,
985		AccountID:                 accountID,
986		BaseURL:                   args.BaseURL,
987		InsecureTLS:               insecureTLS,
988		CWD:                       args.CWD,
989		Reasoning:                 reasoning,
990		Temperature:               temperature,
991		FastMode:                  fastMode,
992		WebSearchPolicy:           webSearchPolicy,
993		ToolRegistry:              reg,
994		ToolSummary:               summaries,
995		SystemPrompt:              sys,
996		MaxSteps:                  max,
997		ContextWindow:             resolvedModel.ContextWindow,
998		MaxOutput:                 resolvedModel.MaxOutput,
999		modelCatalog:              modelCatalog,
1000		modelCatalogAuthoritative: args.modelCatalogAuthoritative,
1001		customProviderConfig:      customProviderConfig,
1002		Sandbox:                   sandbox,
1003		SkillTool:                 skillTool,
1004		skillsEnabled:             skillsEnabled,
1005		ContextFiles:              contextFiles,
1006		systemAppend:              append_,
1007		systemCustom:              custom,
1008		skillAddendum:             skillAddendum,
1009		toolDescriptions:          descMapFromSummaries(summaries),
1010	}, nil
1011}
1012
1013// readUserSystemPrompt looks for $ZUT_HOME/SYSTEM.md and returns its
1014// trimmed contents, or "" when the file is missing / unreadable /
1015// empty. Errors are intentionally swallowed: the file is optional,
1016// and any failure to read it should fall back to the built-in
1017// default system prompt rather than crash the run.
1018func readUserSystemPrompt(zutHome string) string {
1019	if zutHome == "" {
1020		return ""
1021	}
1022	path := filepath.Join(zutHome, "SYSTEM.md")
1023	raw, err := os.ReadFile(path)
1024	if err != nil {
1025		return ""
1026	}
1027	return strings.TrimSpace(string(raw))
1028}
1029
1030// loadAgentsContext loads optional AGENTS.md instruction files. No
1031// default file is created or required: zut only includes files that
1032// already exist. Global instructions ($ZUT_HOME/AGENTS.md) come first,
1033// followed by project instructions from the filesystem root down to cwd.
1034func loadAgentsContext(cwd, zutHome string) []ContextFile {
1035	var files []ContextFile
1036	seen := map[string]bool{}
1037	add := func(path string) {
1038		if path == "" {
1039			return
1040		}
1041		abs, err := filepath.Abs(path)
1042		if err == nil {
1043			path = abs
1044		}
1045		if seen[path] {
1046			return
1047		}
1048		raw, err := os.ReadFile(path)
1049		if err != nil {
1050			return
1051		}
1052		content := strings.TrimSpace(string(raw))
1053		if content == "" {
1054			return
1055		}
1056		seen[path] = true
1057		files = append(files, ContextFile{Path: path, Content: content})
1058	}
1059	addFirstFromDir := func(dir string) {
1060		if dir == "" {
1061			return
1062		}
1063		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
1064			path := filepath.Join(dir, name)
1065			if _, err := os.Stat(path); err == nil {
1066				add(path)
1067				return
1068			}
1069		}
1070	}
1071
1072	addFirstFromDir(zutHome)
1073
1074	if cwd != "" {
1075		abs, err := filepath.Abs(cwd)
1076		if err == nil {
1077			cwd = abs
1078		}
1079		var dirs []string
1080		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
1081			dirs = append(dirs, dir)
1082			parent := filepath.Dir(dir)
1083			if parent == dir {
1084				break
1085			}
1086		}
1087		for i := len(dirs) - 1; i >= 0; i-- {
1088			addFirstFromDir(dirs[i])
1089		}
1090	}
1091
1092	return files
1093}
1094
1095func formatAgentsContext(files []ContextFile) string {
1096	if len(files) == 0 {
1097		return ""
1098	}
1099	var sb strings.Builder
1100	sb.WriteString("Project context instructions loaded from AGENTS.md files. Follow them when working in this repository. Later files are more specific and may override earlier ones.\n")
1101	for _, f := range files {
1102		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
1103	}
1104	return strings.TrimSpace(sb.String())
1105}
1106
1107// descMapFromSummaries indexes the human-readable descriptions for
1108// the renderToolsSection rebuild path.
1109func descMapFromSummaries(summaries []ToolSummary) map[string]string {
1110	out := make(map[string]string, len(summaries))
1111	for _, s := range summaries {
1112		out[s.Name] = s.Description
1113	}
1114	return out
1115}
1116
1117// NewClient returns a provider.Client for r, choosing the auth mode
1118// based on r.AuthMethod. Panics if no credential is present; callers
1119// must check HasCredential() first.
1120func (r Resolved) NewClient() provider.Client {
1121	if !r.HasCredential() {
1122		panic("NewClient called without credential; check HasCredential first")
1123	}
1124	wrap := r.withHTTPClient
1125	switch r.Provider {
1126	case "ollama":
1127		return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, ""))
1128	case provider.LlamaCPPProviderID:
1129		credential := r.Credential
1130		if credential == "local" {
1131			credential = ""
1132		}
1133		return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, ""))
1134	case "kimi":
1135		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
1136		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
1137		inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
1138		if r.AuthMethod == "oauth" {
1139			return r.wrapWithRefresh(inner)
1140		}
1141		return inner
1142	case "moonshotai":
1143		return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
1144	case "moonshotai-cn":
1145		return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
1146	case "deepseek":
1147		return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
1148	case "openai":
1149		return provider.NewModelRouter("openai",
1150			wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
1151			map[string]provider.Client{
1152				provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
1153			})
1154	case "openai-codex":
1155		inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
1156		return r.wrapWithRefresh(inner)
1157	case "openai-responses":
1158		// Public OpenAI Responses API (api.openai.com/v1/responses) via
1159		// API key. Separate provider from `openai` (Chat Completions) and
1160		// from `openai-codex` (ChatGPT subscription OAuth).
1161		return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
1162	case "google":
1163		return wrap(provider.NewGemini(r.Credential, r.BaseURL))
1164	case "cerebras":
1165		return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
1166	case "groq":
1167		return wrap(provider.NewGroq(r.Credential, r.BaseURL))
1168	case "xai":
1169		inner := r.newXAIClient(r.Credential)
1170		if r.AuthMethod == "oauth" {
1171			return r.wrapWithRefresh(inner)
1172		}
1173		return inner
1174	case "together":
1175		return wrap(provider.NewTogether(r.Credential, r.BaseURL))
1176	case "huggingface":
1177		return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
1178	case "openrouter":
1179		return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
1180	case "zai":
1181		return wrap(provider.NewZAI(r.Credential, r.BaseURL))
1182	case "xiaomi":
1183		return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
1184	case "xiaomi-token-plan-ams":
1185		return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
1186	case "xiaomi-token-plan-cn":
1187		return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
1188	case "xiaomi-token-plan-sgp":
1189		return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
1190	case "opencode":
1191		return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
1192	case provider.ProviderOpenCodeGo:
1193		return wrap(provider.NewOpenCodeGoWithModels(r.Credential, r.BaseURL, r.modelCatalog))
1194	case "minimax":
1195		return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
1196	case "minimax-cn":
1197		return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
1198	case "fireworks":
1199		return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
1200	case "vercel-ai-gateway":
1201		return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
1202	case "mistral":
1203		return wrap(provider.NewMistral(r.Credential, r.BaseURL))
1204	case "amazon-bedrock":
1205		return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
1206	case "google-vertex":
1207		return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
1208	case "azure-openai-responses":
1209		return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
1210	case "github-copilot":
1211		return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
1212	case "cloudflare-workers-ai":
1213		return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
1214	case "cloudflare-ai-gateway":
1215		return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
1216	default:
1217		// Custom providers: choose wire format from the models.json api field.
1218		customConfig, ok := provider.CustomProviders()[r.Provider]
1219		if r.customProviderConfig != nil {
1220			customConfig, ok = *r.customProviderConfig, true
1221		}
1222		if ok {
1223			switch customConfig.API {
1224			case provider.APIResponses:
1225				return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
1226			case "anthropic":
1227				return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
1228			default: // "openai"
1229				return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
1230			}
1231		}
1232		if r.AuthMethod == "oauth" {
1233			inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
1234			return r.wrapWithRefresh(inner)
1235		}
1236		return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
1237	}
1238}
1239
1240func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
1241	if !r.InsecureTLS {
1242		return c
1243	}
1244	return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
1245}
1246
1247func (r Resolved) newXAIClient(token string) provider.Client {
1248	return provider.NewModelRouter("xai",
1249		r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
1250		map[string]provider.Client{
1251			provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
1252		})
1253}
1254
1255// wrapWithRefresh wraps an OAuth client so the access token is
1256// refreshed automatically before each API call. Without this, long
1257// sessions (hours) silently fail when the 1-hour token expires.
1258func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
1259	provName := r.Provider
1260	tokenProvider := provName
1261	if provName == "openai-codex" {
1262		tokenProvider = "openai"
1263	}
1264	baseURL := r.BaseURL
1265	accountID := r.AccountID
1266
1267	refreshFn := func(ctx context.Context) (string, error) {
1268		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
1269		if err != nil {
1270			return "", err
1271		}
1272		return tok.AccessToken, nil
1273	}
1274
1275	factory := func(token string) provider.Client {
1276		switch provName {
1277		case "openai-codex":
1278			return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
1279		case "kimi":
1280			// anthropic-messages on api.kimi.com/coding.
1281			return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
1282		case "xai":
1283			return r.newXAIClient(token)
1284		default:
1285			return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
1286		}
1287	}
1288
1289	return provider.NewRefreshingClient(inner, refreshFn, factory)
1290}
1291
1292// UseSandbox replaces the sandbox pointer that every tool in r's
1293// registry references. Used to keep the /jail state stable across
1294// agent rebuilds (e.g. /login, /model switching providers).
1295func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1296	if s == nil || r == nil {
1297		return
1298	}
1299	if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1300		s.Permissions = r.Sandbox.Permissions
1301	}
1302	r.Sandbox = s
1303	for name, t := range r.ToolRegistry {
1304		switch v := t.(type) {
1305		case *tools.ReadTool:
1306			v.Sandbox = s
1307		case *tools.WriteTool:
1308			v.Sandbox = s
1309		case *tools.EditTool:
1310			v.Sandbox = s
1311		case *tools.BashTool:
1312			v.Sandbox = s
1313		case *tools.PythonTool:
1314			v.Sandbox = s
1315		case *tools.CreateWorktreeTool:
1316			v.Sandbox = s
1317		case *tools.LSPTool:
1318			v.Sandbox = s
1319		case *tools.GrepTool:
1320			v.Sandbox = s
1321		case *tools.GlobTool:
1322			v.Sandbox = s
1323		case *tools.ASTTool:
1324			v.Sandbox = s
1325		}
1326		_ = name
1327	}
1328}
1329
1330// NewAgent constructs a core.Agent from r. Requires a credential.
1331func (r Resolved) NewAgent() *core.Agent {
1332	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1333	a.MaxSteps = r.MaxSteps
1334	a.ContextWindow = r.ContextWindow
1335	a.MaxTokens = r.MaxOutput
1336	a.Reasoning = r.Reasoning
1337	a.Temperature = r.Temperature
1338	a.FastMode = r.FastMode
1339	return a
1340}
1341
1342func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry {
1343	if args.NoTools {
1344		return core.Registry{}
1345	}
1346	var manager *lsp.Manager
1347	if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) {
1348		options := lsp.ManagerOptions{}
1349		if sandbox != nil {
1350			options.CheckWritePath = sandbox.CheckWritePath
1351		}
1352		manager = lsp.NewManagerWithOptions(options)
1353	}
1354	all := map[string]core.Tool{
1355		"read":            &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1356		"write":           &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1357		"edit":            &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1358		"bash":            &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1359		"create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1360		"grep":            &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1361		"glob":            &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1362		"ast":             &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1363		"update_goal":     &tools.UpdateGoalTool{},
1364		"update_plan":     &tools.UpdatePlanTool{},
1365	}
1366	if webSearchAllowedForRegistry(args) {
1367		for name, tool := range tools.NewWebTools() {
1368			all[name] = tool
1369		}
1370	}
1371	if manager != nil {
1372		lspTool := tools.NewLSPTool(cwd, manager)
1373		lspTool.Sandbox = sandbox
1374		lspTool.LSPDiagnostics = diagnosticsOnEdit
1375		all["lsp"] = lspTool
1376	}
1377	reg := core.Registry{}
1378	if len(args.Tools) == 0 {
1379		for _, t := range all {
1380			reg[t.Name()] = t
1381		}
1382	} else {
1383		for _, name := range args.Tools {
1384			if name == "web_search" {
1385				for webName, tool := range all {
1386					if tools.IsWebCapabilityName(webName) {
1387						reg[webName] = tool
1388					}
1389				}
1390				continue
1391			}
1392			if t, ok := all[name]; ok {
1393				reg[name] = t
1394			}
1395		}
1396	}
1397	if orchestratorExplorationToolsAllowed(args) {
1398		reg["read"] = all["read"]
1399		reg["grep"] = all["grep"]
1400		reg["glob"] = all["glob"]
1401		if astTool, ok := all["ast"].(*tools.ASTTool); ok {
1402			astTool.ReadOnly = true
1403			reg["ast"] = astTool
1404		}
1405		if args.PermissionSet == nil {
1406			for name, tool := range tools.NewWebTools() {
1407				reg[name] = tool
1408			}
1409		}
1410	}
1411	return reg
1412}
1413
1414// pythonToolAllowed reports whether the python tool belongs in a registry
1415// built for args. Python requires the absence of a packaged-agent
1416// PermissionSet and either no explicit tool selection (the omitted --tools
1417// default) or explicit inclusion of "python" in a nonempty list. An
1418// explicitly empty selection enables no Python even though other built-in
1419// tools retain their historical empty-list behavior.
1420func pythonToolAllowed(args Args) bool {
1421	if args.NoTools || args.PermissionSet != nil {
1422		return false
1423	}
1424	if len(args.Tools) == 0 {
1425		return !args.ToolsSet
1426	}
1427	return toolListContains(args.Tools, "python")
1428}
1429
1430// maybeAddPythonTool registers the python tool with its configured
1431// interpreter. Kept outside buildToolRegistry so that helper's signature
1432// stays unchanged; the interpreter stays available across rebuilds because
1433// Resolve threads the persisted config value on every call. An unavailable
1434// interpreter never blocks startup: resolution is lazy and failures surface
1435// as actionable tool errors on invocation.
1436func maybeAddPythonTool(reg core.Registry, args Args, cwd string, sandbox *tools.Sandbox, interpreter string) {
1437	if reg == nil || !pythonToolAllowed(args) {
1438		return
1439	}
1440	reg["python"] = &tools.PythonTool{CWD: cwd, Sandbox: sandbox, Interpreter: interpreter}
1441}
1442
1443func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {
1444	if len(args.Tools) == 0 {
1445		return true
1446	}
1447	for _, name := range args.Tools {
1448		switch name {
1449		case "lsp":
1450			return true
1451		case "write":
1452			if diagnosticsOnWrite {
1453				return true
1454			}
1455		case "edit", "ast":
1456			if diagnosticsOnEdit {
1457				return true
1458			}
1459		}
1460	}
1461	return false
1462}
1463
1464func autoSubagentsToolAllowed(args Args) bool {
1465	return autoSubagentsToolAllowedFor(args, "subagent_spawn")
1466}
1467
1468// orchestratorExplorationToolsAllowed keeps the primary orchestrator's
1469// read-only exploration tools available without enabling implementation tools.
1470func orchestratorExplorationToolsAllowed(args Args) bool {
1471	return args.Orchestrate && !args.NoTools
1472}
1473
1474func autoSubagentsStatusToolAllowed(args Args) bool {
1475	return autoSubagentsToolAllowedFor(args, "subagent_status")
1476}
1477
1478func autoSubagentsStopToolAllowed(args Args) bool {
1479	return autoSubagentsToolAllowedFor(args, "subagent_stop")
1480}
1481
1482func autoSubagentsResumeToolAllowed(args Args) bool {
1483	return autoSubagentsToolAllowedFor(args, "subagent_resume")
1484}
1485
1486func autoSubagentsAnyToolAllowed(args Args) bool {
1487	return autoSubagentsToolAllowed(args) ||
1488		autoSubagentsStatusToolAllowed(args) ||
1489		autoSubagentsStopToolAllowed(args) ||
1490		autoSubagentsResumeToolAllowed(args)
1491}
1492
1493func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1494	if args.NoTools || args.PermissionSet != nil {
1495		return false
1496	}
1497	if args.ToolsSet && len(args.Tools) == 0 {
1498		return false
1499	}
1500	if len(args.Tools) == 0 {
1501		return true
1502	}
1503	for _, name := range args.Tools {
1504		if name == toolName {
1505			return true
1506		}
1507	}
1508	return false
1509}
1510
1511var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}
1512
1513func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1514	var out []ToolSummary
1515	for _, name := range nativeToolSummaryOrder {
1516		if t, ok := reg[name]; ok {
1517			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1518		}
1519	}
1520	return out
1521}
1522
1523func firstNonEmpty(vals ...string) string {
1524	for _, v := range vals {
1525		if v != "" {
1526			return v
1527		}
1528	}
1529	return ""
1530}
1531
1532func kimiCodeHeaders() map[string]string {
1533	host, _ := os.Hostname()
1534	if host == "" {
1535		host = "unknown"
1536	}
1537	deviceID := ""
1538	if home, err := os.UserHomeDir(); err == nil {
1539		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1540			deviceID = strings.TrimSpace(string(b))
1541		}
1542	}
1543	if deviceID == "" {
1544		deviceID = "zut"
1545	}
1546	return map[string]string{
1547		"User-Agent":         "KimiCLI/1.41.0",
1548		"X-Msh-Platform":     "kimi_cli",
1549		"X-Msh-Version":      "1.41.0",
1550		"X-Msh-Device-Name":  host,
1551		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1552		"X-Msh-Os-Version":   runtime.GOOS,
1553		"X-Msh-Device-Id":    deviceID,
1554	}
1555}
1556
1557func envVarName(provider string) string {
1558	switch provider {
1559	case "openai", "openai-codex", "openai-responses":
1560		return "OPENAI"
1561	case "kimi":
1562		return "KIMI"
1563	case "deepseek":
1564		return "DEEPSEEK"
1565	case "google":
1566		return "GEMINI"
1567	case "ollama":
1568		return "OLLAMA"
1569	case "llama.cpp":
1570		return "LLAMA"
1571	case "moonshotai", "moonshotai-cn":
1572		return "MOONSHOT"
1573	case "groq":
1574		return "GROQ"
1575	case "xai":
1576		return "XAI"
1577	case "cerebras":
1578		return "CEREBRAS"
1579	case "together":
1580		return "TOGETHER"
1581	case "huggingface":
1582		return "HF"
1583	case "openrouter":
1584		return "OPENROUTER"
1585	case "mistral":
1586		return "MISTRAL"
1587	case "zai":
1588		return "ZAI"
1589	case "xiaomi":
1590		return "XIAOMI"
1591	case "xiaomi-token-plan-ams":
1592		return "XIAOMI_TOKEN_PLAN_AMS"
1593	case "xiaomi-token-plan-cn":
1594		return "XIAOMI_TOKEN_PLAN_CN"
1595	case "xiaomi-token-plan-sgp":
1596		return "XIAOMI_TOKEN_PLAN_SGP"
1597	case "minimax":
1598		return "MINIMAX"
1599	case "minimax-cn":
1600		return "MINIMAX_CN"
1601	case "fireworks":
1602		return "FIREWORKS"
1603	case "vercel-ai-gateway":
1604		return "AI_GATEWAY"
1605	case "opencode", "opencode-go":
1606		return "OPENCODE"
1607	case "github-copilot":
1608		return "COPILOT_GITHUB_TOKEN"
1609	case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1610		return "CLOUDFLARE"
1611	case "amazon-bedrock":
1612		return "AWS"
1613	case "google-vertex":
1614		return "GOOGLE_CLOUD"
1615	case "azure-openai-responses":
1616		return "AZURE_OPENAI"
1617	default:
1618		return "ANTHROPIC"
1619	}
1620}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close