v0.48.1 deaab59

build.go RawHistoryFolder

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

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close