v0.44.0 efd47b3

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

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