v0.48.0 ad3af57

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	if cfg.PonytailModeEnabled() {
966		if ponytailAddendum := PonytailSystemAddendum(); ponytailAddendum != "" {
967			append_ = append(append_, ponytailAddendum)
968		}
969	}
970	append_ = append(append_, args.AppendSystemPrompt...)
971	if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
972		append_ = append(append_, agentsAddendum)
973	}
974	if skillAddendum != "" {
975		append_ = append(append_, skillAddendum)
976	}
977	interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
978	primaryInteractive := interactiveMode && !args.ResidentChild
979	primaryOrchestrator := args.Orchestrate && !args.ResidentChild
980	if (primaryInteractive || primaryOrchestrator) && autoSubagentsToolAllowed(args) {
981		homeDir, _ := os.UserHomeDir()
982		profiles, _ := subagents.Discover(args.CWD, homeDir)
983		if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
984			append_ = append(append_, subagentsAddendum)
985		}
986	}
987	if primaryOrchestrator {
988		// Headless orchestration owns its strict prompt contract rather than
989		// inheriting the interactive collaboration setting.
990		append_ = append(append_, StrictOrchestratorSystemAddendumFor(subagentActionsAllowed(args)))
991	} else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
992		append_ = append(append_, ProactiveSubagentsSystemAddendumFor(subagentActionsAllowed(args)))
993	} else if primaryInteractive && autoSubagentsAnyToolAllowed(args) {
994		append_ = append(append_, OnDemandSubagentsSystemAddendum)
995	}
996	// Custom system prompt resolution order:
997	//   1. --system-prompt flag (highest priority; ad-hoc per run)
998	//   2. $ZUT_HOME/SYSTEM.md (persistent user override)
999	//   3. built-in default (defaultIdentity + defaultGuidelines)
1000	custom := args.SystemPrompt
1001	if custom == "" {
1002		custom = readUserSystemPrompt(ZutHome())
1003	}
1004
1005	sys := BuildSystemPrompt(SystemPromptOpts{
1006		CWD:        args.CWD,
1007		Tools:      summaries,
1008		Custom:     custom,
1009		Append:     append_,
1010		ZutDocsDir: docsDir,
1011	})
1012
1013	reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
1014	temperature := args.Temperature
1015	if temperature == nil {
1016		temperature = cfg.Temperature
1017	}
1018	fastMode := cfg.FastMode != nil && *cfg.FastMode
1019	if args.FastModeSet {
1020		fastMode = args.FastMode
1021	}
1022	max := args.MaxSteps // 0 = unlimited
1023
1024	var modelCatalog []provider.Model
1025	if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
1026		modelCatalog = append([]provider.Model{}, args.modelCatalog...)
1027		found := false
1028		for _, catalogModel := range modelCatalog {
1029			if catalogModel.ID == model {
1030				found = true
1031				break
1032			}
1033		}
1034		if !found {
1035			modelCatalog = append(modelCatalog, resolvedModel)
1036		}
1037	}
1038
1039	return Resolved{
1040		Provider:                  provName,
1041		Model:                     model,
1042		Credential:                cred,
1043		AuthMethod:                method,
1044		AccountID:                 accountID,
1045		BaseURL:                   args.BaseURL,
1046		InsecureTLS:               insecureTLS,
1047		CWD:                       args.CWD,
1048		Reasoning:                 reasoning,
1049		Temperature:               temperature,
1050		FastMode:                  fastMode,
1051		WebSearchPolicy:           webSearchPolicy,
1052		ToolRegistry:              reg,
1053		ToolSummary:               summaries,
1054		SystemPrompt:              sys,
1055		MaxSteps:                  max,
1056		DisableRepetitionGuard:    args.DisableRepetitionGuard,
1057		ContextWindow:             resolvedModel.ContextWindow,
1058		MaxOutput:                 resolvedModel.MaxOutput,
1059		modelCatalog:              modelCatalog,
1060		modelCatalogAuthoritative: args.modelCatalogAuthoritative,
1061		customProviderConfig:      customProviderConfig,
1062		credentialOptional:        credentialOptional,
1063		Sandbox:                   sandbox,
1064		SkillTool:                 skillTool,
1065		skillsEnabled:             skillsEnabled,
1066		ContextFiles:              contextFiles,
1067		systemAppend:              append_,
1068		systemCustom:              custom,
1069		skillAddendum:             skillAddendum,
1070		toolDescriptions:          descMapFromSummaries(summaries),
1071	}, nil
1072}
1073
1074// readUserSystemPrompt looks for $ZUT_HOME/SYSTEM.md and returns its
1075// trimmed contents, or "" when the file is missing / unreadable /
1076// empty. Errors are intentionally swallowed: the file is optional,
1077// and any failure to read it should fall back to the built-in
1078// default system prompt rather than crash the run.
1079func readUserSystemPrompt(zutHome string) string {
1080	if zutHome == "" {
1081		return ""
1082	}
1083	path := filepath.Join(zutHome, "SYSTEM.md")
1084	raw, err := os.ReadFile(path)
1085	if err != nil {
1086		return ""
1087	}
1088	return strings.TrimSpace(string(raw))
1089}
1090
1091// loadAgentsContext loads optional AGENTS.md instruction files. No
1092// default file is created or required: zut only includes files that
1093// already exist. Global instructions ($ZUT_HOME/AGENTS.md) come first,
1094// followed by project instructions from the filesystem root down to cwd.
1095func loadAgentsContext(cwd, zutHome string) []ContextFile {
1096	var files []ContextFile
1097	seen := map[string]bool{}
1098	add := func(path string) {
1099		if path == "" {
1100			return
1101		}
1102		abs, err := filepath.Abs(path)
1103		if err == nil {
1104			path = abs
1105		}
1106		if seen[path] {
1107			return
1108		}
1109		raw, err := os.ReadFile(path)
1110		if err != nil {
1111			return
1112		}
1113		content := strings.TrimSpace(string(raw))
1114		if content == "" {
1115			return
1116		}
1117		seen[path] = true
1118		files = append(files, ContextFile{Path: path, Content: content})
1119	}
1120	addFirstFromDir := func(dir string) {
1121		if dir == "" {
1122			return
1123		}
1124		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
1125			path := filepath.Join(dir, name)
1126			if _, err := os.Stat(path); err == nil {
1127				add(path)
1128				return
1129			}
1130		}
1131	}
1132
1133	addFirstFromDir(zutHome)
1134
1135	if cwd != "" {
1136		abs, err := filepath.Abs(cwd)
1137		if err == nil {
1138			cwd = abs
1139		}
1140		var dirs []string
1141		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
1142			dirs = append(dirs, dir)
1143			parent := filepath.Dir(dir)
1144			if parent == dir {
1145				break
1146			}
1147		}
1148		for i := len(dirs) - 1; i >= 0; i-- {
1149			addFirstFromDir(dirs[i])
1150		}
1151	}
1152
1153	return files
1154}
1155
1156func formatAgentsContext(files []ContextFile) string {
1157	if len(files) == 0 {
1158		return ""
1159	}
1160	var sb strings.Builder
1161	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")
1162	for _, f := range files {
1163		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
1164	}
1165	return strings.TrimSpace(sb.String())
1166}
1167
1168// descMapFromSummaries indexes the human-readable descriptions for
1169// the renderToolsSection rebuild path.
1170func descMapFromSummaries(summaries []ToolSummary) map[string]string {
1171	out := make(map[string]string, len(summaries))
1172	for _, s := range summaries {
1173		out[s.Name] = s.Description
1174	}
1175	return out
1176}
1177
1178// NewClient returns a provider.Client for r, choosing the auth mode
1179// based on r.AuthMethod. Panics if no credential is present; callers
1180// must check HasCredential() first.
1181func (r Resolved) NewClient() provider.Client {
1182	if !r.HasCredential() {
1183		panic("NewClient called without credential or keyless endpoint; check HasCredential first")
1184	}
1185	wrap := r.withHTTPClient
1186	switch r.Provider {
1187	case "ollama":
1188		return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, ""))
1189	case provider.LlamaCPPProviderID:
1190		credential := r.Credential
1191		if credential == "local" {
1192			credential = ""
1193		}
1194		return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, ""))
1195	case "kimi":
1196		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
1197		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
1198		inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
1199		if r.AuthMethod == "oauth" {
1200			return r.wrapWithRefresh(inner)
1201		}
1202		return inner
1203	case "moonshotai":
1204		return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
1205	case "moonshotai-cn":
1206		return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
1207	case "deepseek":
1208		return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
1209	case "openai":
1210		return provider.NewModelRouter("openai",
1211			wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
1212			map[string]provider.Client{
1213				provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
1214			})
1215	case "openai-codex":
1216		inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
1217		return r.wrapWithRefresh(inner)
1218	case "openai-responses":
1219		// Public OpenAI Responses API (api.openai.com/v1/responses) via
1220		// API key. Separate provider from `openai` (Chat Completions) and
1221		// from `openai-codex` (ChatGPT subscription OAuth).
1222		return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
1223	case "google":
1224		return wrap(provider.NewGemini(r.Credential, r.BaseURL))
1225	case "cerebras":
1226		return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
1227	case "groq":
1228		return wrap(provider.NewGroq(r.Credential, r.BaseURL))
1229	case "xai":
1230		inner := r.newXAIClient(r.Credential)
1231		if r.AuthMethod == "oauth" {
1232			return r.wrapWithRefresh(inner)
1233		}
1234		return inner
1235	case "together":
1236		return wrap(provider.NewTogether(r.Credential, r.BaseURL))
1237	case "huggingface":
1238		return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
1239	case "openrouter":
1240		return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
1241	case "zai":
1242		return wrap(provider.NewZAI(r.Credential, r.BaseURL))
1243	case "xiaomi":
1244		return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
1245	case "xiaomi-token-plan-ams":
1246		return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
1247	case "xiaomi-token-plan-cn":
1248		return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
1249	case "xiaomi-token-plan-sgp":
1250		return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
1251	case "opencode":
1252		return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
1253	case provider.ProviderOpenCodeGo:
1254		return wrap(provider.NewOpenCodeGoWithModels(r.Credential, r.BaseURL, r.modelCatalog))
1255	case "minimax":
1256		return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
1257	case "minimax-cn":
1258		return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
1259	case "fireworks":
1260		return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
1261	case "vercel-ai-gateway":
1262		return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
1263	case "mistral":
1264		return wrap(provider.NewMistral(r.Credential, r.BaseURL))
1265	case "amazon-bedrock":
1266		return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
1267	case "google-vertex":
1268		return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
1269	case "azure-openai-responses":
1270		return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
1271	case "github-copilot":
1272		return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
1273	case "cloudflare-workers-ai":
1274		return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
1275	case "cloudflare-ai-gateway":
1276		return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
1277	default:
1278		// Custom providers: choose wire format from the models.json api field.
1279		customConfig, ok := provider.CustomProviders()[r.Provider]
1280		if r.customProviderConfig != nil {
1281			customConfig, ok = *r.customProviderConfig, true
1282		}
1283		if ok {
1284			switch customConfig.API {
1285			case provider.APIResponses:
1286				return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
1287			case "anthropic":
1288				return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
1289			default: // "openai"
1290				return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
1291			}
1292		}
1293		if r.AuthMethod == "oauth" {
1294			inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
1295			return r.wrapWithRefresh(inner)
1296		}
1297		return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
1298	}
1299}
1300
1301func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
1302	if !r.InsecureTLS {
1303		return c
1304	}
1305	return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
1306}
1307
1308func (r Resolved) newXAIClient(token string) provider.Client {
1309	return provider.NewModelRouter("xai",
1310		r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
1311		map[string]provider.Client{
1312			provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
1313		})
1314}
1315
1316// wrapWithRefresh wraps an OAuth client so the access token is
1317// refreshed automatically before each API call. Without this, long
1318// sessions (hours) silently fail when the 1-hour token expires.
1319func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
1320	provName := r.Provider
1321	tokenProvider := provName
1322	if provName == "openai-codex" {
1323		tokenProvider = "openai"
1324	}
1325	baseURL := r.BaseURL
1326	accountID := r.AccountID
1327
1328	refreshFn := func(ctx context.Context) (string, error) {
1329		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
1330		if err != nil {
1331			return "", err
1332		}
1333		return tok.AccessToken, nil
1334	}
1335
1336	factory := func(token string) provider.Client {
1337		switch provName {
1338		case "openai-codex":
1339			return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
1340		case "kimi":
1341			// anthropic-messages on api.kimi.com/coding.
1342			return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
1343		case "xai":
1344			return r.newXAIClient(token)
1345		default:
1346			return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
1347		}
1348	}
1349
1350	return provider.NewRefreshingClient(inner, refreshFn, factory)
1351}
1352
1353// UseSandbox replaces the sandbox pointer that every tool in r's
1354// registry references. Used to keep the /jail state stable across
1355// agent rebuilds (e.g. /login, /model switching providers).
1356func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1357	if s == nil || r == nil {
1358		return
1359	}
1360	if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1361		s.Permissions = r.Sandbox.Permissions
1362	}
1363	r.Sandbox = s
1364	for name, t := range r.ToolRegistry {
1365		switch v := t.(type) {
1366		case *tools.ReadTool:
1367			v.Sandbox = s
1368		case *tools.WriteTool:
1369			v.Sandbox = s
1370		case *tools.EditTool:
1371			v.Sandbox = s
1372		case *tools.BashTool:
1373			v.Sandbox = s
1374		case *tools.PythonTool:
1375			v.Sandbox = s
1376		case *tools.WorktreeTool:
1377			if v.Create != nil {
1378				v.Create.Sandbox = s
1379			}
1380			if v.Manage != nil {
1381				v.Manage.Sandbox = s
1382			}
1383		case *tools.LSPTool:
1384			v.Sandbox = s
1385		case *tools.GrepTool:
1386			v.Sandbox = s
1387		case *tools.GlobTool:
1388			v.Sandbox = s
1389		case *tools.ASTTool:
1390			v.Sandbox = s
1391		}
1392		_ = name
1393	}
1394}
1395
1396// NewAgent constructs a core.Agent from r. Requires a credential.
1397func (r Resolved) NewAgent() *core.Agent {
1398	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1399	a.MaxSteps = r.MaxSteps
1400	a.DisableRepetitionGuard = r.DisableRepetitionGuard
1401	a.ContextWindow = r.ContextWindow
1402	a.MaxTokens = r.MaxOutput
1403	a.Reasoning = r.Reasoning
1404	a.Temperature = r.Temperature
1405	a.FastMode = r.FastMode
1406	if _, ok := r.ToolRegistry["worktree"].(*tools.WorktreeTool); ok {
1407		cwd := r.CWD
1408		a.HostContext = func(ctx context.Context) string {
1409			return tools.WorktreeInventoryContext(ctx, cwd)
1410		}
1411	}
1412	return a
1413}
1414
1415func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry {
1416	if args.NoTools {
1417		return core.Registry{}
1418	}
1419	var manager *lsp.Manager
1420	if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) {
1421		options := lsp.ManagerOptions{}
1422		if sandbox != nil {
1423			options.CheckWritePath = sandbox.CheckWritePath
1424		}
1425		manager = lsp.NewManagerWithOptions(options)
1426	}
1427	all := map[string]core.Tool{
1428		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1429		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1430		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1431		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1432		"worktree": &tools.WorktreeTool{
1433			Create: &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox, DefaultRoot: filepath.Join(ZutHome(), "worktrees")},
1434			Manage: &tools.ManageWorktreesTool{CWD: cwd, Sandbox: sandbox},
1435		},
1436		"grep":        &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1437		"glob":        &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1438		"ast":         &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1439		"update_goal": &tools.UpdateGoalTool{},
1440		"plan":        &tools.PlanTool{},
1441	}
1442	if webSearchAllowedForRegistry(args) {
1443		for name, tool := range tools.NewWebTools() {
1444			all[name] = tool
1445		}
1446	}
1447	if manager != nil {
1448		lspTool := tools.NewLSPTool(cwd, manager)
1449		lspTool.Sandbox = sandbox
1450		lspTool.LSPDiagnostics = diagnosticsOnEdit
1451		all["lsp"] = lspTool
1452	}
1453	reg := core.Registry{}
1454	if len(args.Tools) == 0 {
1455		for _, t := range all {
1456			reg[t.Name()] = t
1457		}
1458	} else {
1459		for _, name := range args.Tools {
1460			if name == "web_search" {
1461				for webName, tool := range all {
1462					if tools.IsWebCapabilityName(webName) {
1463						reg[webName] = tool
1464					}
1465				}
1466				continue
1467			}
1468			if t, ok := all[name]; ok {
1469				reg[name] = t
1470			}
1471		}
1472	}
1473	if orchestratorExplorationToolsAllowed(args) {
1474		reg["read"] = all["read"]
1475		reg["grep"] = all["grep"]
1476		reg["glob"] = all["glob"]
1477		if astTool, ok := all["ast"].(*tools.ASTTool); ok {
1478			astTool.ReadOnly = true
1479			reg["ast"] = astTool
1480		}
1481		if args.PermissionSet == nil {
1482			for name, tool := range tools.NewWebTools() {
1483				reg[name] = tool
1484			}
1485		}
1486	}
1487	grepTool, hasGrep := reg["grep"].(*tools.GrepTool)
1488	astTool, hasAST := reg["ast"].(*tools.ASTTool)
1489	if hasGrep && hasAST {
1490		grepTool.ASTAvailable = sync.OnceValue(astTool.Available)
1491	}
1492	return reg
1493}
1494
1495// pythonToolAllowed reports whether the python tool belongs in a registry
1496// built for args. Python requires the absence of a packaged-agent
1497// PermissionSet and either no explicit tool selection (the omitted --tools
1498// default) or explicit inclusion of "python" in a nonempty list. An
1499// explicitly empty selection enables no Python even though other built-in
1500// tools retain their historical empty-list behavior.
1501func pythonToolAllowed(args Args) bool {
1502	if args.NoTools || args.PermissionSet != nil {
1503		return false
1504	}
1505	if len(args.Tools) == 0 {
1506		return !args.ToolsSet
1507	}
1508	return toolListContains(args.Tools, "python")
1509}
1510
1511// maybeAddPythonTool registers the python tool with its configured
1512// interpreter. Kept outside buildToolRegistry so that helper's signature
1513// stays unchanged; the interpreter stays available across rebuilds because
1514// Resolve threads the persisted config value on every call. An unavailable
1515// interpreter never blocks startup: resolution is lazy and failures surface
1516// as actionable tool errors on invocation.
1517func maybeAddPythonTool(reg core.Registry, args Args, cwd string, sandbox *tools.Sandbox, interpreter string) {
1518	if reg == nil || !pythonToolAllowed(args) {
1519		return
1520	}
1521	reg["python"] = &tools.PythonTool{CWD: cwd, Sandbox: sandbox, Interpreter: interpreter}
1522}
1523
1524func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {
1525	if len(args.Tools) == 0 {
1526		return true
1527	}
1528	for _, name := range args.Tools {
1529		switch name {
1530		case "lsp":
1531			return true
1532		case "write":
1533			if diagnosticsOnWrite {
1534				return true
1535			}
1536		case "edit", "ast":
1537			if diagnosticsOnEdit {
1538				return true
1539			}
1540		}
1541	}
1542	return false
1543}
1544
1545func autoSubagentsToolAllowed(args Args) bool {
1546	return subagentActionAllowed(args, tools.SubagentActionSpawn)
1547}
1548
1549// orchestratorExplorationToolsAllowed keeps the primary orchestrator's
1550// read-only exploration tools available without enabling implementation tools.
1551func orchestratorExplorationToolsAllowed(args Args) bool {
1552	return args.Orchestrate && !args.NoTools
1553}
1554
1555func autoSubagentsStatusToolAllowed(args Args) bool {
1556	return subagentActionAllowed(args, tools.SubagentActionStatus)
1557}
1558
1559func autoSubagentsStopToolAllowed(args Args) bool {
1560	return subagentActionAllowed(args, tools.SubagentActionStop)
1561}
1562
1563func autoSubagentsResumeToolAllowed(args Args) bool {
1564	return subagentActionAllowed(args, tools.SubagentActionResume)
1565}
1566
1567func autoSubagentsInterruptToolAllowed(args Args) bool {
1568	return subagentActionAllowed(args, tools.SubagentActionInterrupt)
1569}
1570
1571// subagentActionsAllowed collects the launch-time subagent action grants.
1572func subagentActionsAllowed(args Args) SubagentActions {
1573	return SubagentActions{
1574		Spawn:     autoSubagentsToolAllowed(args),
1575		Stop:      autoSubagentsStopToolAllowed(args),
1576		Resume:    autoSubagentsResumeToolAllowed(args),
1577		Interrupt: autoSubagentsInterruptToolAllowed(args),
1578	}
1579}
1580
1581func autoSubagentsAnyToolAllowed(args Args) bool {
1582	return autoSubagentsToolAllowed(args) ||
1583		autoSubagentsStatusToolAllowed(args) ||
1584		autoSubagentsStopToolAllowed(args) ||
1585		autoSubagentsResumeToolAllowed(args) ||
1586		autoSubagentsInterruptToolAllowed(args)
1587}
1588
1589// subagentActionAllowed reports whether the launch-time tool policy grants one
1590// subagent action. A bare "subagent" entry grants every action; a
1591// "subagent:<action>" entry grants that action alone. Unrecognized entries are
1592// ignored, matching how every other --tools name is treated.
1593func subagentActionAllowed(args Args, action string) bool {
1594	if args.NoTools || args.PermissionSet != nil {
1595		return false
1596	}
1597	if args.ToolsSet && len(args.Tools) == 0 {
1598		return false
1599	}
1600	if len(args.Tools) == 0 {
1601		return true
1602	}
1603	for _, name := range args.Tools {
1604		if name == tools.SubagentToolName || name == tools.SubagentToolName+":"+action {
1605			return true
1606		}
1607	}
1608	return false
1609}
1610
1611var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "plan"}
1612
1613func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1614	var out []ToolSummary
1615	for _, name := range nativeToolSummaryOrder {
1616		if t, ok := reg[name]; ok {
1617			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1618		}
1619	}
1620	return out
1621}
1622
1623func firstNonEmpty(vals ...string) string {
1624	for _, v := range vals {
1625		if v != "" {
1626			return v
1627		}
1628	}
1629	return ""
1630}
1631
1632func kimiCodeHeaders() map[string]string {
1633	host, _ := os.Hostname()
1634	if host == "" {
1635		host = "unknown"
1636	}
1637	deviceID := ""
1638	if home, err := os.UserHomeDir(); err == nil {
1639		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1640			deviceID = strings.TrimSpace(string(b))
1641		}
1642	}
1643	if deviceID == "" {
1644		deviceID = "zut"
1645	}
1646	return map[string]string{
1647		"User-Agent":         "KimiCLI/1.41.0",
1648		"X-Msh-Platform":     "kimi_cli",
1649		"X-Msh-Version":      "1.41.0",
1650		"X-Msh-Device-Name":  host,
1651		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1652		"X-Msh-Os-Version":   runtime.GOOS,
1653		"X-Msh-Device-Id":    deviceID,
1654	}
1655}
1656
1657func envVarName(provider string) string {
1658	switch provider {
1659	case "openai", "openai-codex", "openai-responses":
1660		return "OPENAI"
1661	case "kimi":
1662		return "KIMI"
1663	case "deepseek":
1664		return "DEEPSEEK"
1665	case "google":
1666		return "GEMINI"
1667	case "ollama":
1668		return "OLLAMA"
1669	case "llama.cpp":
1670		return "LLAMA"
1671	case "moonshotai", "moonshotai-cn":
1672		return "MOONSHOT"
1673	case "groq":
1674		return "GROQ"
1675	case "xai":
1676		return "XAI"
1677	case "cerebras":
1678		return "CEREBRAS"
1679	case "together":
1680		return "TOGETHER"
1681	case "huggingface":
1682		return "HF"
1683	case "openrouter":
1684		return "OPENROUTER"
1685	case "mistral":
1686		return "MISTRAL"
1687	case "zai":
1688		return "ZAI"
1689	case "xiaomi":
1690		return "XIAOMI"
1691	case "xiaomi-token-plan-ams":
1692		return "XIAOMI_TOKEN_PLAN_AMS"
1693	case "xiaomi-token-plan-cn":
1694		return "XIAOMI_TOKEN_PLAN_CN"
1695	case "xiaomi-token-plan-sgp":
1696		return "XIAOMI_TOKEN_PLAN_SGP"
1697	case "minimax":
1698		return "MINIMAX"
1699	case "minimax-cn":
1700		return "MINIMAX_CN"
1701	case "fireworks":
1702		return "FIREWORKS"
1703	case "vercel-ai-gateway":
1704		return "AI_GATEWAY"
1705	case "opencode", "opencode-go":
1706		return "OPENCODE"
1707	case "github-copilot":
1708		return "COPILOT_GITHUB_TOKEN"
1709	case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1710		return "CLOUDFLARE"
1711	case "amazon-bedrock":
1712		return "AWS"
1713	case "google-vertex":
1714		return "GOOGLE_CLOUD"
1715	case "azure-openai-responses":
1716		return "AZURE_OPENAI"
1717	default:
1718		return "ANTHROPIC"
1719	}
1720}

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