v0.32.0 362913c

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

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