v0.42.0 82578de

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.PlanToolName {
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		// hasRealOllamaCred tracks whether ResolveCredentialFull returned a
597		// genuine ollama credential (--api-key, OLLAMA_API_KEY, auth.json).
598		// It drives the cloud-vs-local endpoint default below; the dummy
599		// "ollama" credential never triggers the cloud default.
600		hasRealOllamaCred bool
601	)
602	if provName == "ollama" {
603		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
604		if credErr != nil {
605			// Local ollama needs no key: fall back to the dummy
606			// credential instead of failing resolution.
607			cred, method, accountID, credErr = "ollama", "apikey", "", nil
608		} else {
609			hasRealOllamaCred = true
610		}
611	} else {
612		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
613	}
614
615	// Persist --api-key for custom providers so subsequent runs don't
616	// need to pass it again.
617	if !isBuiltinProvider(provName) && args.APIKey != "" {
618		if store := AuthStoreFor(); store != nil {
619			_ = store.SetAPIKey(provName, args.APIKey)
620		}
621	}
622
623	// If the user did NOT explicitly pick a provider (neither via --provider
624	// nor by saving one in config.json) and the default one has no
625	// credentials, auto-fall-back to whichever provider is actually logged
626	// in. That way running plain `zut` after `/login` (any provider) never
627	// shows a "not logged in" banner.
628	//
629	// Critical: when the user HAS saved a provider in config.json (e.g.
630	// "deepseek" with "deepseek-v4-flash"), that choice is respected even
631	// if no credential is found — the tui will show the login dialog or
632	// surface a clear credential error rather than silently switching to
633	// a completely different provider and model.
634	// autoFellBack tracks whether the provider was changed by the
635	// auto-fallback loop (no explicit provider from CLI or config,
636	// and the default had no credentials). When false, the provider
637	// came from the user's explicit choice (--provider or config.json)
638	// and must be respected, including any model id that may belong
639	// to a different provider in the catalog — for example when using
640	// a gateway/router like OpenRouter with "deepseek/deepseek-v4-flash".
641	var autoFellBack bool
642	userPickedProvider := args.Provider != "" || cfg.Provider != ""
643	if credErr != nil && !userPickedProvider && provName != "ollama" {
644		autoFellBack = true
645		// Scan every known provider (not a hardcoded subset) so any
646		// env-based credential is discovered, e.g. an env-only
647		// amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
648		// IAM keys) when no config.json pins the provider, such as after
649		// pointing ZUT_HOME at a fresh home dir. Iteration order of
650		// knownProviders defines fallback priority. Local providers without a
651		// default model are skipped because selecting either one here would
652		// fail before the user can choose a model.
653		for _, other := range knownProviders {
654			if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID {
655				continue
656			}
657			if !CredentialAvailable(other) {
658				continue
659			}
660			// An explicit launch key belongs to the initially selected provider;
661			// automatic fallback must never reuse it for another provider.
662			c, m, a, err := ResolveCredentialFull(other, "")
663			provName = other
664			cred, method, accountID, credErr = c, m, a, err
665			break
666		}
667	}
668
669	var customProviderConfig *provider.CustomProviderConfig
670	if custom, ok := provider.CustomProviders()[provName]; ok {
671		customProviderConfig = &custom
672	}
673
674	// Normalize model ids at the resolver boundary without mutating the
675	// caller's Args value. Treat whitespace-only values as missing so a
676	// configured fallback can still be selected.
677	model := strings.TrimSpace(args.Model)
678	if model == "" {
679		model = strings.TrimSpace(cfg.Model)
680	}
681	if model == "" {
682		if provName == "ollama" || provName == provider.LlamaCPPProviderID {
683			return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName)
684		}
685		if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
686			// Keep model selection inside the runtime snapshot instead of
687			// consulting another runtime's mutable catalog.
688			model = defaultModelForResolve(provName, args)
689		} else {
690			model = defaultModelForProvider(provName)
691		}
692	}
693	// If the resolved model belongs to a different provider (e.g. config
694	// says gpt-5 but we auto-fell back to anthropic), pick that provider's
695	// default. This override only fires when the provider was auto-fallback-
696	// changed, NOT when the user explicitly configured the pair — gateway/
697	// router providers (openrouter, vercel-ai-gateway, etc.) can serve
698	// models from any provider in the catalog.
699	if autoFellBack && provName != "ollama" {
700		if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
701			if _, err := findModelForResolve(provName, model, args.modelCatalog); err != nil {
702				model = defaultModelForResolve(provName, args)
703			}
704		} else if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
705			model = defaultModelForProvider(provName)
706		}
707	}
708	resolvedModel, err := findModelForResolve(provName, model, args.modelCatalog)
709	if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) {
710		// Local providers are intentionally open-catalogue: any model id the
711		// configured server understands is valid, even if it is not cached.
712		resolvedModel = provider.Model{
713			Provider:      provName,
714			ID:            model,
715			DisplayName:   model,
716			ContextWindow: 128000,
717			MaxOutput:     16384,
718			BaseURL:       args.BaseURL,
719			Source:        provName,
720		}
721		err = nil
722	}
723	// OpenCode Go publishes its authoritative model list at runtime. Keep
724	// explicit model ids usable during the first refresh or while offline;
725	// the provider request will be the final authority if no catalog data is
726	// available yet.
727	if err != nil && acceptsUnlistedModel(provName, args) {
728		resolvedModel = provider.DynamicOpenCodeGoModel(provName, model, args.BaseURL)
729		err = nil
730	}
731	// Custom providers are open-catalogue like ollama: any model id the
732	// endpoint understands is valid. Use the provider-level base URL.
733	if err != nil && customProviderConfig != nil {
734		resolvedModel = provider.Model{
735			Provider:      provName,
736			ID:            model,
737			DisplayName:   model,
738			ContextWindow: 128000,
739			MaxOutput:     16384,
740			BaseURL:       customProviderConfig.BaseURL,
741			Source:        "user",
742		}
743		err = nil
744	}
745	if customProviderConfig != nil && err == nil && resolvedModel.BaseURL == "" && customProviderConfig.BaseURL != "" {
746		// Fall back to the provider-level base URL when the model does
747		// not define its own endpoint.
748		resolvedModel.BaseURL = customProviderConfig.BaseURL
749	}
750	if err != nil {
751		// The model the user (or persisted config) asked for is no
752		// longer in the active catalogue — they probably removed it
753		// from their models.json or upgraded zut and the id changed.
754		// Refusing to launch is the wrong move: it strands the user
755		// with no way to even open the TUI and pick a new model.
756		// Fall back to the provider's default, warn on stderr, and,
757		// when the stale id came from the persisted config (not an
758		// explicit --model flag), repair the config so the warning
759		// doesn't repeat on every launch.
760
761		// Gateway providers can accept route-qualified ids that are not in
762		// zut's local catalog yet, for example OpenRouter's
763		// "deepseek/deepseek-v4-flash". Preserve only route-qualified ids;
764		// plain unknown values are likely typos and should still fall back.
765		if isGatewayProvider(provName) && isGatewayRoutedModelID(model) {
766			resolvedModel = provider.Model{
767				Provider:      provName,
768				ID:            model,
769				DisplayName:   model,
770				ContextWindow: 1000000,
771				MaxOutput:     64000,
772				BaseURL:       "",
773				Source:        "gateway",
774			}
775			// Don't repair config — the routed model id may be valid upstream.
776		} else {
777			fallback := defaultModelForResolve(provName, args)
778			fm, ferr := findModelForResolve(provName, fallback, args.modelCatalog)
779			if ferr != nil {
780				// Even the provider default is gone (catastrophic
781				// catalogue trim). Last resort: any model on this
782				// provider, then the global DefaultModel. A scoped
783				// OpenCode Go runtime must stay inside its snapshot.
784				candidates := provider.ModelsForProvider(provName)
785				if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
786					candidates = args.modelCatalog
787				}
788				if len(candidates) > 0 {
789					fm = candidates[0]
790				} else {
791					fm = provider.DefaultModel
792				}
793			}
794			fmt.Fprintf(os.Stderr,
795				"zut: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
796				model, fm.ID)
797			if strings.TrimSpace(args.Model) == "" && strings.TrimSpace(cfg.Model) == model {
798				cfg.Model = fm.ID
799				repairConfig = true
800			}
801			resolvedModel = fm
802			model = fm.ID
803		}
804	}
805
806	if repairConfig {
807		// Repair the source favorite as well as legacy defaults, but never
808		// overwrite it because an explicit CLI override required fallback.
809		if profileSlot > 0 && args.Provider == "" && args.Model == "" {
810			cfg.Provider, cfg.Model = provName, model
811			cfg.Reasoning = provider.ClampReasoningForModel(resolvedModel, cfg.Reasoning)
812			cfg.QuickModelShortcuts[profileSlot-1] = QuickModelShortcut{
813				Provider: cfg.Provider, Model: cfg.Model, Reasoning: cfg.Reasoning,
814				FastMode: cfg.FastMode != nil && *cfg.FastMode,
815			}
816			cfg.ActiveModelProfile = profileSlot
817		}
818		_ = SaveConfig(cfg)
819	}
820
821	explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
822
823	// If the model defines a base URL (e.g. local ollama) and the
824	// user didn't pass --base-url, use the model's URL. For ollama,
825	// keep http://localhost:11434 as a fallback only after the model
826	// metadata has had a chance to provide a custom baseUrl.
827	if args.BaseURL == "" && resolvedModel.BaseURL != "" {
828		args.BaseURL = resolvedModel.BaseURL
829	}
830	if args.BaseURL == "" && provName == "ollama" {
831		// A real ollama credential with no explicit endpoint means
832		// Ollama Cloud; without one, stay on the local server.
833		// Explicit --base-url and model-level baseUrl (applied above)
834		// both win over this default.
835		if hasRealOllamaCred {
836			args.BaseURL = provider.OllamaCloudBaseURL
837		} else {
838			args.BaseURL = "http://localhost:11434"
839		}
840	}
841	if args.BaseURL == "" && provName == provider.LlamaCPPProviderID {
842		managementURL, _, configErr := ResolveLlamaCPPConfig()
843		if configErr != nil {
844			return Resolved{}, configErr
845		}
846		var baseErr error
847		args.BaseURL, baseErr = provider.LlamaCPPInferenceURL(managementURL)
848		if baseErr != nil {
849			return Resolved{}, baseErr
850		}
851	}
852
853	// Insecure TLS is intentionally scoped to explicit custom endpoints.
854	// Built-in provider base URLs, auth calls, and model discovery keep normal
855	// certificate verification even when --insecure is present.
856	insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
857
858	// If the model has a base URL, credentials are optional (local
859	// models like ollama don't need real API keys).
860	if resolvedModel.BaseURL != "" && credErr != nil {
861		cred = "ollama"
862		credErr = nil
863		requireCred = false
864	}
865
866	if credErr != nil && requireCred {
867		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zut` and /login",
868			credErr, envVarName(provName))
869	}
870
871	sandbox := tools.NewSandbox(args.CWD)
872	if cfg.JailByDefault != nil && *cfg.JailByDefault {
873		sandbox.Lock()
874	}
875	if args.PermissionSet != nil {
876		sandbox.SetPermissions(args.PermissionSet)
877	}
878	lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(false)
879	reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(false), cfg.LSPDiagnosticsOnEditEnabled(false))
880	maybeAddPythonTool(reg, args, args.CWD, sandbox, cfg.PythonInterpreter)
881
882	docsDir, _ := zutdocs.EnsureInstalled(ZutHome())
883
884	// Skill discovery: scan project + global locations + built-in
885	// skills shipped with the binary. If any are found, register
886	// the on-demand `skill` loader tool plus a system-prompt
887	// manifest so the model knows what's available.
888	//
889	// --no-skill bypasses the entire mechanism: no manifest in the
890	// system prompt, no `skill` tool in the registry. Useful for a
891	// clean-room run with zero extra context biasing the model.
892	var (
893		discovered    []*skills.Skill
894		skillTool     *skills.Tool
895		skillAddendum string
896		skillsEnabled bool
897	)
898	if !args.NoSkill {
899		skillsEnabled = true
900		homeDir, _ := os.UserHomeDir()
901		discovered, _ = skills.Discover(ZutHome(), args.CWD, homeDir, args.WithSkills)
902		if len(discovered) > 0 {
903			skillTool = skills.NewTool(discovered)
904			reg[skillTool.Name()] = skillTool
905			skillAddendum = skills.SystemPromptAddendum(discovered)
906		}
907	}
908	_ = skillTool
909
910	summaries := toolSummaries(reg, args)
911
912	contextFiles := []ContextFile(nil)
913	if !args.NoContextFiles {
914		contextFiles = loadAgentsContext(args.CWD, ZutHome())
915	}
916	append_ := []string(nil)
917	if cfg.PonytailModeEnabled() {
918		if ponytailAddendum := PonytailSystemAddendum(); ponytailAddendum != "" {
919			append_ = append(append_, ponytailAddendum)
920		}
921	}
922	append_ = append(append_, args.AppendSystemPrompt...)
923	if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
924		append_ = append(append_, agentsAddendum)
925	}
926	if skillAddendum != "" {
927		append_ = append(append_, skillAddendum)
928	}
929	interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
930	primaryInteractive := interactiveMode && !args.ResidentChild
931	primaryOrchestrator := args.Orchestrate && !args.ResidentChild
932	if (primaryInteractive || primaryOrchestrator) && autoSubagentsToolAllowed(args) {
933		homeDir, _ := os.UserHomeDir()
934		profiles, _ := subagents.Discover(args.CWD, homeDir)
935		if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
936			append_ = append(append_, subagentsAddendum)
937		}
938	}
939	if primaryOrchestrator {
940		// Headless orchestration owns its strict prompt contract rather than
941		// inheriting the interactive collaboration setting.
942		append_ = append(append_, StrictOrchestratorSystemAddendumFor(
943			autoSubagentsToolAllowed(args),
944			autoSubagentsStopToolAllowed(args),
945			autoSubagentsResumeToolAllowed(args),
946		))
947	} else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
948		append_ = append(append_, ProactiveSubagentsSystemAddendumFor(
949			autoSubagentsToolAllowed(args),
950			autoSubagentsStopToolAllowed(args),
951			autoSubagentsResumeToolAllowed(args),
952		))
953	} else if primaryInteractive && autoSubagentsAnyToolAllowed(args) {
954		append_ = append(append_, OnDemandSubagentsSystemAddendum)
955	}
956	// Custom system prompt resolution order:
957	//   1. --system-prompt flag (highest priority; ad-hoc per run)
958	//   2. $ZUT_HOME/SYSTEM.md (persistent user override)
959	//   3. built-in default (defaultIdentity + defaultGuidelines)
960	custom := args.SystemPrompt
961	if custom == "" {
962		custom = readUserSystemPrompt(ZutHome())
963	}
964
965	sys := BuildSystemPrompt(SystemPromptOpts{
966		CWD:        args.CWD,
967		Tools:      summaries,
968		Custom:     custom,
969		Append:     append_,
970		ZutDocsDir: docsDir,
971	})
972
973	reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
974	temperature := args.Temperature
975	if temperature == nil {
976		temperature = cfg.Temperature
977	}
978	fastMode := cfg.FastMode != nil && *cfg.FastMode
979	if args.FastModeSet {
980		fastMode = args.FastMode
981	}
982	max := args.MaxSteps // 0 = unlimited
983
984	var modelCatalog []provider.Model
985	if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
986		modelCatalog = append([]provider.Model{}, args.modelCatalog...)
987		found := false
988		for _, catalogModel := range modelCatalog {
989			if catalogModel.ID == model {
990				found = true
991				break
992			}
993		}
994		if !found {
995			modelCatalog = append(modelCatalog, resolvedModel)
996		}
997	}
998
999	return Resolved{
1000		Provider:                  provName,
1001		Model:                     model,
1002		Credential:                cred,
1003		AuthMethod:                method,
1004		AccountID:                 accountID,
1005		BaseURL:                   args.BaseURL,
1006		InsecureTLS:               insecureTLS,
1007		CWD:                       args.CWD,
1008		Reasoning:                 reasoning,
1009		Temperature:               temperature,
1010		FastMode:                  fastMode,
1011		WebSearchPolicy:           webSearchPolicy,
1012		ToolRegistry:              reg,
1013		ToolSummary:               summaries,
1014		SystemPrompt:              sys,
1015		MaxSteps:                  max,
1016		ContextWindow:             resolvedModel.ContextWindow,
1017		MaxOutput:                 resolvedModel.MaxOutput,
1018		modelCatalog:              modelCatalog,
1019		modelCatalogAuthoritative: args.modelCatalogAuthoritative,
1020		customProviderConfig:      customProviderConfig,
1021		Sandbox:                   sandbox,
1022		SkillTool:                 skillTool,
1023		skillsEnabled:             skillsEnabled,
1024		ContextFiles:              contextFiles,
1025		systemAppend:              append_,
1026		systemCustom:              custom,
1027		skillAddendum:             skillAddendum,
1028		toolDescriptions:          descMapFromSummaries(summaries),
1029	}, nil
1030}
1031
1032// readUserSystemPrompt looks for $ZUT_HOME/SYSTEM.md and returns its
1033// trimmed contents, or "" when the file is missing / unreadable /
1034// empty. Errors are intentionally swallowed: the file is optional,
1035// and any failure to read it should fall back to the built-in
1036// default system prompt rather than crash the run.
1037func readUserSystemPrompt(zutHome string) string {
1038	if zutHome == "" {
1039		return ""
1040	}
1041	path := filepath.Join(zutHome, "SYSTEM.md")
1042	raw, err := os.ReadFile(path)
1043	if err != nil {
1044		return ""
1045	}
1046	return strings.TrimSpace(string(raw))
1047}
1048
1049// loadAgentsContext loads optional AGENTS.md instruction files. No
1050// default file is created or required: zut only includes files that
1051// already exist. Global instructions ($ZUT_HOME/AGENTS.md) come first,
1052// followed by project instructions from the filesystem root down to cwd.
1053func loadAgentsContext(cwd, zutHome string) []ContextFile {
1054	var files []ContextFile
1055	seen := map[string]bool{}
1056	add := func(path string) {
1057		if path == "" {
1058			return
1059		}
1060		abs, err := filepath.Abs(path)
1061		if err == nil {
1062			path = abs
1063		}
1064		if seen[path] {
1065			return
1066		}
1067		raw, err := os.ReadFile(path)
1068		if err != nil {
1069			return
1070		}
1071		content := strings.TrimSpace(string(raw))
1072		if content == "" {
1073			return
1074		}
1075		seen[path] = true
1076		files = append(files, ContextFile{Path: path, Content: content})
1077	}
1078	addFirstFromDir := func(dir string) {
1079		if dir == "" {
1080			return
1081		}
1082		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
1083			path := filepath.Join(dir, name)
1084			if _, err := os.Stat(path); err == nil {
1085				add(path)
1086				return
1087			}
1088		}
1089	}
1090
1091	addFirstFromDir(zutHome)
1092
1093	if cwd != "" {
1094		abs, err := filepath.Abs(cwd)
1095		if err == nil {
1096			cwd = abs
1097		}
1098		var dirs []string
1099		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
1100			dirs = append(dirs, dir)
1101			parent := filepath.Dir(dir)
1102			if parent == dir {
1103				break
1104			}
1105		}
1106		for i := len(dirs) - 1; i >= 0; i-- {
1107			addFirstFromDir(dirs[i])
1108		}
1109	}
1110
1111	return files
1112}
1113
1114func formatAgentsContext(files []ContextFile) string {
1115	if len(files) == 0 {
1116		return ""
1117	}
1118	var sb strings.Builder
1119	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")
1120	for _, f := range files {
1121		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
1122	}
1123	return strings.TrimSpace(sb.String())
1124}
1125
1126// descMapFromSummaries indexes the human-readable descriptions for
1127// the renderToolsSection rebuild path.
1128func descMapFromSummaries(summaries []ToolSummary) map[string]string {
1129	out := make(map[string]string, len(summaries))
1130	for _, s := range summaries {
1131		out[s.Name] = s.Description
1132	}
1133	return out
1134}
1135
1136// NewClient returns a provider.Client for r, choosing the auth mode
1137// based on r.AuthMethod. Panics if no credential is present; callers
1138// must check HasCredential() first.
1139func (r Resolved) NewClient() provider.Client {
1140	if !r.HasCredential() {
1141		panic("NewClient called without credential; check HasCredential first")
1142	}
1143	wrap := r.withHTTPClient
1144	switch r.Provider {
1145	case "ollama":
1146		return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, ""))
1147	case provider.LlamaCPPProviderID:
1148		credential := r.Credential
1149		if credential == "local" {
1150			credential = ""
1151		}
1152		return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, ""))
1153	case "kimi":
1154		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
1155		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
1156		inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
1157		if r.AuthMethod == "oauth" {
1158			return r.wrapWithRefresh(inner)
1159		}
1160		return inner
1161	case "moonshotai":
1162		return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
1163	case "moonshotai-cn":
1164		return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
1165	case "deepseek":
1166		return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
1167	case "openai":
1168		return provider.NewModelRouter("openai",
1169			wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
1170			map[string]provider.Client{
1171				provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
1172			})
1173	case "openai-codex":
1174		inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
1175		return r.wrapWithRefresh(inner)
1176	case "openai-responses":
1177		// Public OpenAI Responses API (api.openai.com/v1/responses) via
1178		// API key. Separate provider from `openai` (Chat Completions) and
1179		// from `openai-codex` (ChatGPT subscription OAuth).
1180		return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
1181	case "google":
1182		return wrap(provider.NewGemini(r.Credential, r.BaseURL))
1183	case "cerebras":
1184		return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
1185	case "groq":
1186		return wrap(provider.NewGroq(r.Credential, r.BaseURL))
1187	case "xai":
1188		inner := r.newXAIClient(r.Credential)
1189		if r.AuthMethod == "oauth" {
1190			return r.wrapWithRefresh(inner)
1191		}
1192		return inner
1193	case "together":
1194		return wrap(provider.NewTogether(r.Credential, r.BaseURL))
1195	case "huggingface":
1196		return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
1197	case "openrouter":
1198		return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
1199	case "zai":
1200		return wrap(provider.NewZAI(r.Credential, r.BaseURL))
1201	case "xiaomi":
1202		return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
1203	case "xiaomi-token-plan-ams":
1204		return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
1205	case "xiaomi-token-plan-cn":
1206		return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
1207	case "xiaomi-token-plan-sgp":
1208		return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
1209	case "opencode":
1210		return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
1211	case provider.ProviderOpenCodeGo:
1212		return wrap(provider.NewOpenCodeGoWithModels(r.Credential, r.BaseURL, r.modelCatalog))
1213	case "minimax":
1214		return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
1215	case "minimax-cn":
1216		return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
1217	case "fireworks":
1218		return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
1219	case "vercel-ai-gateway":
1220		return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
1221	case "mistral":
1222		return wrap(provider.NewMistral(r.Credential, r.BaseURL))
1223	case "amazon-bedrock":
1224		return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
1225	case "google-vertex":
1226		return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
1227	case "azure-openai-responses":
1228		return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
1229	case "github-copilot":
1230		return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
1231	case "cloudflare-workers-ai":
1232		return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
1233	case "cloudflare-ai-gateway":
1234		return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
1235	default:
1236		// Custom providers: choose wire format from the models.json api field.
1237		customConfig, ok := provider.CustomProviders()[r.Provider]
1238		if r.customProviderConfig != nil {
1239			customConfig, ok = *r.customProviderConfig, true
1240		}
1241		if ok {
1242			switch customConfig.API {
1243			case provider.APIResponses:
1244				return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
1245			case "anthropic":
1246				return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
1247			default: // "openai"
1248				return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
1249			}
1250		}
1251		if r.AuthMethod == "oauth" {
1252			inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
1253			return r.wrapWithRefresh(inner)
1254		}
1255		return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
1256	}
1257}
1258
1259func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
1260	if !r.InsecureTLS {
1261		return c
1262	}
1263	return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
1264}
1265
1266func (r Resolved) newXAIClient(token string) provider.Client {
1267	return provider.NewModelRouter("xai",
1268		r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
1269		map[string]provider.Client{
1270			provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
1271		})
1272}
1273
1274// wrapWithRefresh wraps an OAuth client so the access token is
1275// refreshed automatically before each API call. Without this, long
1276// sessions (hours) silently fail when the 1-hour token expires.
1277func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
1278	provName := r.Provider
1279	tokenProvider := provName
1280	if provName == "openai-codex" {
1281		tokenProvider = "openai"
1282	}
1283	baseURL := r.BaseURL
1284	accountID := r.AccountID
1285
1286	refreshFn := func(ctx context.Context) (string, error) {
1287		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
1288		if err != nil {
1289			return "", err
1290		}
1291		return tok.AccessToken, nil
1292	}
1293
1294	factory := func(token string) provider.Client {
1295		switch provName {
1296		case "openai-codex":
1297			return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
1298		case "kimi":
1299			// anthropic-messages on api.kimi.com/coding.
1300			return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
1301		case "xai":
1302			return r.newXAIClient(token)
1303		default:
1304			return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
1305		}
1306	}
1307
1308	return provider.NewRefreshingClient(inner, refreshFn, factory)
1309}
1310
1311// UseSandbox replaces the sandbox pointer that every tool in r's
1312// registry references. Used to keep the /jail state stable across
1313// agent rebuilds (e.g. /login, /model switching providers).
1314func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1315	if s == nil || r == nil {
1316		return
1317	}
1318	if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1319		s.Permissions = r.Sandbox.Permissions
1320	}
1321	r.Sandbox = s
1322	for name, t := range r.ToolRegistry {
1323		switch v := t.(type) {
1324		case *tools.ReadTool:
1325			v.Sandbox = s
1326		case *tools.WriteTool:
1327			v.Sandbox = s
1328		case *tools.EditTool:
1329			v.Sandbox = s
1330		case *tools.BashTool:
1331			v.Sandbox = s
1332		case *tools.PythonTool:
1333			v.Sandbox = s
1334		case *tools.WorktreeTool:
1335			if v.Create != nil {
1336				v.Create.Sandbox = s
1337			}
1338			if v.Manage != nil {
1339				v.Manage.Sandbox = s
1340			}
1341		case *tools.LSPTool:
1342			v.Sandbox = s
1343		case *tools.GrepTool:
1344			v.Sandbox = s
1345		case *tools.GlobTool:
1346			v.Sandbox = s
1347		case *tools.ASTTool:
1348			v.Sandbox = s
1349		}
1350		_ = name
1351	}
1352}
1353
1354// NewAgent constructs a core.Agent from r. Requires a credential.
1355func (r Resolved) NewAgent() *core.Agent {
1356	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1357	a.MaxSteps = r.MaxSteps
1358	a.ContextWindow = r.ContextWindow
1359	a.MaxTokens = r.MaxOutput
1360	a.Reasoning = r.Reasoning
1361	a.Temperature = r.Temperature
1362	a.FastMode = r.FastMode
1363	if _, ok := r.ToolRegistry["worktree"].(*tools.WorktreeTool); ok {
1364		cwd := r.CWD
1365		a.HostContext = func(ctx context.Context) string {
1366			return tools.WorktreeInventoryContext(ctx, cwd)
1367		}
1368	}
1369	return a
1370}
1371
1372func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry {
1373	if args.NoTools {
1374		return core.Registry{}
1375	}
1376	var manager *lsp.Manager
1377	if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) {
1378		options := lsp.ManagerOptions{}
1379		if sandbox != nil {
1380			options.CheckWritePath = sandbox.CheckWritePath
1381		}
1382		manager = lsp.NewManagerWithOptions(options)
1383	}
1384	all := map[string]core.Tool{
1385		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1386		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1387		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1388		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1389		"worktree": &tools.WorktreeTool{
1390			Create: &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox, DefaultRoot: filepath.Join(ZutHome(), "worktrees")},
1391			Manage: &tools.ManageWorktreesTool{CWD: cwd, Sandbox: sandbox},
1392		},
1393		"grep":        &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1394		"glob":        &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1395		"ast":         &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1396		"update_goal": &tools.UpdateGoalTool{},
1397		"plan":        &tools.PlanTool{},
1398	}
1399	if webSearchAllowedForRegistry(args) {
1400		for name, tool := range tools.NewWebTools() {
1401			all[name] = tool
1402		}
1403	}
1404	if manager != nil {
1405		lspTool := tools.NewLSPTool(cwd, manager)
1406		lspTool.Sandbox = sandbox
1407		lspTool.LSPDiagnostics = diagnosticsOnEdit
1408		all["lsp"] = lspTool
1409	}
1410	reg := core.Registry{}
1411	if len(args.Tools) == 0 {
1412		for _, t := range all {
1413			reg[t.Name()] = t
1414		}
1415	} else {
1416		for _, name := range args.Tools {
1417			if name == "web_search" {
1418				for webName, tool := range all {
1419					if tools.IsWebCapabilityName(webName) {
1420						reg[webName] = tool
1421					}
1422				}
1423				continue
1424			}
1425			if t, ok := all[name]; ok {
1426				reg[name] = t
1427			}
1428		}
1429	}
1430	if orchestratorExplorationToolsAllowed(args) {
1431		reg["read"] = all["read"]
1432		reg["grep"] = all["grep"]
1433		reg["glob"] = all["glob"]
1434		if astTool, ok := all["ast"].(*tools.ASTTool); ok {
1435			astTool.ReadOnly = true
1436			reg["ast"] = astTool
1437		}
1438		if args.PermissionSet == nil {
1439			for name, tool := range tools.NewWebTools() {
1440				reg[name] = tool
1441			}
1442		}
1443	}
1444	return reg
1445}
1446
1447// pythonToolAllowed reports whether the python tool belongs in a registry
1448// built for args. Python requires the absence of a packaged-agent
1449// PermissionSet and either no explicit tool selection (the omitted --tools
1450// default) or explicit inclusion of "python" in a nonempty list. An
1451// explicitly empty selection enables no Python even though other built-in
1452// tools retain their historical empty-list behavior.
1453func pythonToolAllowed(args Args) bool {
1454	if args.NoTools || args.PermissionSet != nil {
1455		return false
1456	}
1457	if len(args.Tools) == 0 {
1458		return !args.ToolsSet
1459	}
1460	return toolListContains(args.Tools, "python")
1461}
1462
1463// maybeAddPythonTool registers the python tool with its configured
1464// interpreter. Kept outside buildToolRegistry so that helper's signature
1465// stays unchanged; the interpreter stays available across rebuilds because
1466// Resolve threads the persisted config value on every call. An unavailable
1467// interpreter never blocks startup: resolution is lazy and failures surface
1468// as actionable tool errors on invocation.
1469func maybeAddPythonTool(reg core.Registry, args Args, cwd string, sandbox *tools.Sandbox, interpreter string) {
1470	if reg == nil || !pythonToolAllowed(args) {
1471		return
1472	}
1473	reg["python"] = &tools.PythonTool{CWD: cwd, Sandbox: sandbox, Interpreter: interpreter}
1474}
1475
1476func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {
1477	if len(args.Tools) == 0 {
1478		return true
1479	}
1480	for _, name := range args.Tools {
1481		switch name {
1482		case "lsp":
1483			return true
1484		case "write":
1485			if diagnosticsOnWrite {
1486				return true
1487			}
1488		case "edit", "ast":
1489			if diagnosticsOnEdit {
1490				return true
1491			}
1492		}
1493	}
1494	return false
1495}
1496
1497func autoSubagentsToolAllowed(args Args) bool {
1498	return subagentActionAllowed(args, tools.SubagentActionSpawn)
1499}
1500
1501// orchestratorExplorationToolsAllowed keeps the primary orchestrator's
1502// read-only exploration tools available without enabling implementation tools.
1503func orchestratorExplorationToolsAllowed(args Args) bool {
1504	return args.Orchestrate && !args.NoTools
1505}
1506
1507func autoSubagentsStatusToolAllowed(args Args) bool {
1508	return subagentActionAllowed(args, tools.SubagentActionStatus)
1509}
1510
1511func autoSubagentsStopToolAllowed(args Args) bool {
1512	return subagentActionAllowed(args, tools.SubagentActionStop)
1513}
1514
1515func autoSubagentsResumeToolAllowed(args Args) bool {
1516	return subagentActionAllowed(args, tools.SubagentActionResume)
1517}
1518
1519func autoSubagentsAnyToolAllowed(args Args) bool {
1520	return autoSubagentsToolAllowed(args) ||
1521		autoSubagentsStatusToolAllowed(args) ||
1522		autoSubagentsStopToolAllowed(args) ||
1523		autoSubagentsResumeToolAllowed(args)
1524}
1525
1526// subagentActionAllowed reports whether the launch-time tool policy grants one
1527// subagent action. A bare "subagent" entry grants every action; a
1528// "subagent:<action>" entry grants that action alone. Unrecognized entries are
1529// ignored, matching how every other --tools name is treated.
1530func subagentActionAllowed(args Args, action string) bool {
1531	if args.NoTools || args.PermissionSet != nil {
1532		return false
1533	}
1534	if args.ToolsSet && len(args.Tools) == 0 {
1535		return false
1536	}
1537	if len(args.Tools) == 0 {
1538		return true
1539	}
1540	for _, name := range args.Tools {
1541		if name == tools.SubagentToolName || name == tools.SubagentToolName+":"+action {
1542			return true
1543		}
1544	}
1545	return false
1546}
1547
1548var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "plan"}
1549
1550func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1551	var out []ToolSummary
1552	for _, name := range nativeToolSummaryOrder {
1553		if t, ok := reg[name]; ok {
1554			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1555		}
1556	}
1557	return out
1558}
1559
1560func firstNonEmpty(vals ...string) string {
1561	for _, v := range vals {
1562		if v != "" {
1563			return v
1564		}
1565	}
1566	return ""
1567}
1568
1569func kimiCodeHeaders() map[string]string {
1570	host, _ := os.Hostname()
1571	if host == "" {
1572		host = "unknown"
1573	}
1574	deviceID := ""
1575	if home, err := os.UserHomeDir(); err == nil {
1576		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1577			deviceID = strings.TrimSpace(string(b))
1578		}
1579	}
1580	if deviceID == "" {
1581		deviceID = "zut"
1582	}
1583	return map[string]string{
1584		"User-Agent":         "KimiCLI/1.41.0",
1585		"X-Msh-Platform":     "kimi_cli",
1586		"X-Msh-Version":      "1.41.0",
1587		"X-Msh-Device-Name":  host,
1588		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1589		"X-Msh-Os-Version":   runtime.GOOS,
1590		"X-Msh-Device-Id":    deviceID,
1591	}
1592}
1593
1594func envVarName(provider string) string {
1595	switch provider {
1596	case "openai", "openai-codex", "openai-responses":
1597		return "OPENAI"
1598	case "kimi":
1599		return "KIMI"
1600	case "deepseek":
1601		return "DEEPSEEK"
1602	case "google":
1603		return "GEMINI"
1604	case "ollama":
1605		return "OLLAMA"
1606	case "llama.cpp":
1607		return "LLAMA"
1608	case "moonshotai", "moonshotai-cn":
1609		return "MOONSHOT"
1610	case "groq":
1611		return "GROQ"
1612	case "xai":
1613		return "XAI"
1614	case "cerebras":
1615		return "CEREBRAS"
1616	case "together":
1617		return "TOGETHER"
1618	case "huggingface":
1619		return "HF"
1620	case "openrouter":
1621		return "OPENROUTER"
1622	case "mistral":
1623		return "MISTRAL"
1624	case "zai":
1625		return "ZAI"
1626	case "xiaomi":
1627		return "XIAOMI"
1628	case "xiaomi-token-plan-ams":
1629		return "XIAOMI_TOKEN_PLAN_AMS"
1630	case "xiaomi-token-plan-cn":
1631		return "XIAOMI_TOKEN_PLAN_CN"
1632	case "xiaomi-token-plan-sgp":
1633		return "XIAOMI_TOKEN_PLAN_SGP"
1634	case "minimax":
1635		return "MINIMAX"
1636	case "minimax-cn":
1637		return "MINIMAX_CN"
1638	case "fireworks":
1639		return "FIREWORKS"
1640	case "vercel-ai-gateway":
1641		return "AI_GATEWAY"
1642	case "opencode", "opencode-go":
1643		return "OPENCODE"
1644	case "github-copilot":
1645		return "COPILOT_GITHUB_TOKEN"
1646	case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1647		return "CLOUDFLARE"
1648	case "amazon-bedrock":
1649		return "AWS"
1650	case "google-vertex":
1651		return "GOOGLE_CLOUD"
1652	case "azure-openai-responses":
1653		return "AZURE_OPENAI"
1654	default:
1655		return "ANTHROPIC"
1656	}
1657}

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 477

↑↓ moveenter openesc close