detached 4509ec7

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 || info.Name == tools.AskUserQuestionToolName {
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	addMCPTools(reg, args)
930
931	docsDir, _ := zutdocs.EnsureInstalled(ZutHome())
932
933	// Skill discovery: scan project + global locations + built-in
934	// skills shipped with the binary. If any are found, register
935	// the on-demand `skill` loader tool plus a system-prompt
936	// manifest so the model knows what's available.
937	//
938	// --no-skill bypasses the entire mechanism: no manifest in the
939	// system prompt, no `skill` tool in the registry. Useful for a
940	// clean-room run with zero extra context biasing the model.
941	var (
942		discovered    []*skills.Skill
943		skillTool     *skills.Tool
944		skillAddendum string
945		skillsEnabled bool
946	)
947	if !args.NoSkill {
948		skillsEnabled = true
949		homeDir, _ := os.UserHomeDir()
950		discovered, _ = skills.Discover(ZutHome(), args.CWD, homeDir, args.WithSkills)
951		if len(discovered) > 0 {
952			skillTool = skills.NewTool(discovered)
953			reg[skillTool.Name()] = skillTool
954			skillAddendum = skills.SystemPromptAddendum(discovered)
955		}
956	}
957	_ = skillTool
958
959	summaries := toolSummaries(reg, args)
960
961	contextFiles := []ContextFile(nil)
962	if !args.NoContextFiles {
963		contextFiles = loadAgentsContext(args.CWD, ZutHome())
964	}
965	append_ := []string(nil)
966	append_ = append(append_, args.AppendSystemPrompt...)
967	if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
968		append_ = append(append_, agentsAddendum)
969	}
970	if skillAddendum != "" {
971		append_ = append(append_, skillAddendum)
972	}
973	if mcpAddendum := mcpSystemAddendum(reg, args); mcpAddendum != "" {
974		append_ = append(append_, mcpAddendum)
975	}
976	interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
977	primaryInteractive := interactiveMode && !args.ResidentChild
978	primaryOrchestrator := args.Orchestrate && !args.ResidentChild
979	if (primaryInteractive || primaryOrchestrator) && autoSubagentsToolAllowed(args) {
980		homeDir, _ := os.UserHomeDir()
981		profiles, _ := subagents.Discover(args.CWD, homeDir)
982		if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
983			append_ = append(append_, subagentsAddendum)
984		}
985	}
986	if primaryOrchestrator {
987		// Headless orchestration owns its strict prompt contract rather than
988		// inheriting the interactive collaboration setting.
989		append_ = append(append_, StrictOrchestratorSystemAddendumFor(subagentActionsAllowed(args)))
990	} else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
991		append_ = append(append_, ProactiveSubagentsSystemAddendumFor(subagentActionsAllowed(args)))
992	} else if primaryInteractive && autoSubagentsAnyToolAllowed(args) {
993		append_ = append(append_, OnDemandSubagentsSystemAddendum)
994	}
995	// Custom system prompt resolution order:
996	//   1. --system-prompt flag (highest priority; ad-hoc per run)
997	//   2. $ZUT_HOME/SYSTEM.md (persistent user override)
998	//   3. built-in default (defaultIdentity + defaultGuidelines)
999	custom := args.SystemPrompt
1000	if custom == "" {
1001		custom = readUserSystemPrompt(ZutHome())
1002	}
1003
1004	sys := BuildSystemPrompt(SystemPromptOpts{
1005		CWD:        args.CWD,
1006		Tools:      summaries,
1007		Custom:     custom,
1008		Append:     append_,
1009		ZutDocsDir: docsDir,
1010	})
1011
1012	reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
1013	temperature := args.Temperature
1014	if temperature == nil {
1015		temperature = cfg.Temperature
1016	}
1017	fastMode := cfg.FastMode != nil && *cfg.FastMode
1018	if args.FastModeSet {
1019		fastMode = args.FastMode
1020	}
1021	max := args.MaxSteps // 0 = unlimited
1022
1023	var modelCatalog []provider.Model
1024	if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
1025		modelCatalog = append([]provider.Model{}, args.modelCatalog...)
1026		found := false
1027		for _, catalogModel := range modelCatalog {
1028			if catalogModel.ID == model {
1029				found = true
1030				break
1031			}
1032		}
1033		if !found {
1034			modelCatalog = append(modelCatalog, resolvedModel)
1035		}
1036	}
1037
1038	return Resolved{
1039		Provider:                  provName,
1040		Model:                     model,
1041		Credential:                cred,
1042		AuthMethod:                method,
1043		AccountID:                 accountID,
1044		BaseURL:                   args.BaseURL,
1045		InsecureTLS:               insecureTLS,
1046		CWD:                       args.CWD,
1047		Reasoning:                 reasoning,
1048		Temperature:               temperature,
1049		FastMode:                  fastMode,
1050		WebSearchPolicy:           webSearchPolicy,
1051		ToolRegistry:              reg,
1052		ToolSummary:               summaries,
1053		SystemPrompt:              sys,
1054		MaxSteps:                  max,
1055		DisableRepetitionGuard:    args.DisableRepetitionGuard,
1056		ContextWindow:             resolvedModel.ContextWindow,
1057		MaxOutput:                 resolvedModel.MaxOutput,
1058		modelCatalog:              modelCatalog,
1059		modelCatalogAuthoritative: args.modelCatalogAuthoritative,
1060		customProviderConfig:      customProviderConfig,
1061		credentialOptional:        credentialOptional,
1062		Sandbox:                   sandbox,
1063		SkillTool:                 skillTool,
1064		skillsEnabled:             skillsEnabled,
1065		ContextFiles:              contextFiles,
1066		systemAppend:              append_,
1067		systemCustom:              custom,
1068		skillAddendum:             skillAddendum,
1069		toolDescriptions:          descMapFromSummaries(summaries),
1070	}, nil
1071}
1072
1073// readUserSystemPrompt looks for $ZUT_HOME/SYSTEM.md and returns its
1074// trimmed contents, or "" when the file is missing / unreadable /
1075// empty. Errors are intentionally swallowed: the file is optional,
1076// and any failure to read it should fall back to the built-in
1077// default system prompt rather than crash the run.
1078func readUserSystemPrompt(zutHome string) string {
1079	if zutHome == "" {
1080		return ""
1081	}
1082	path := filepath.Join(zutHome, "SYSTEM.md")
1083	raw, err := os.ReadFile(path)
1084	if err != nil {
1085		return ""
1086	}
1087	return strings.TrimSpace(string(raw))
1088}
1089
1090// loadAgentsContext loads optional AGENTS.md instruction files. No
1091// default file is created or required: zut only includes files that
1092// already exist. Global instructions ($ZUT_HOME/AGENTS.md) come first,
1093// followed by project instructions from the filesystem root down to cwd.
1094func loadAgentsContext(cwd, zutHome string) []ContextFile {
1095	var files []ContextFile
1096	seen := map[string]bool{}
1097	add := func(path string) {
1098		if path == "" {
1099			return
1100		}
1101		abs, err := filepath.Abs(path)
1102		if err == nil {
1103			path = abs
1104		}
1105		if seen[path] {
1106			return
1107		}
1108		raw, err := os.ReadFile(path)
1109		if err != nil {
1110			return
1111		}
1112		content := strings.TrimSpace(string(raw))
1113		if content == "" {
1114			return
1115		}
1116		seen[path] = true
1117		files = append(files, ContextFile{Path: path, Content: content})
1118	}
1119	addFirstFromDir := func(dir string) {
1120		if dir == "" {
1121			return
1122		}
1123		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
1124			path := filepath.Join(dir, name)
1125			if _, err := os.Stat(path); err == nil {
1126				add(path)
1127				return
1128			}
1129		}
1130	}
1131
1132	addFirstFromDir(zutHome)
1133
1134	if cwd != "" {
1135		abs, err := filepath.Abs(cwd)
1136		if err == nil {
1137			cwd = abs
1138		}
1139		var dirs []string
1140		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
1141			dirs = append(dirs, dir)
1142			parent := filepath.Dir(dir)
1143			if parent == dir {
1144				break
1145			}
1146		}
1147		for i := len(dirs) - 1; i >= 0; i-- {
1148			addFirstFromDir(dirs[i])
1149		}
1150	}
1151
1152	return files
1153}
1154
1155func formatAgentsContext(files []ContextFile) string {
1156	if len(files) == 0 {
1157		return ""
1158	}
1159	var sb strings.Builder
1160	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")
1161	for _, f := range files {
1162		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
1163	}
1164	return strings.TrimSpace(sb.String())
1165}
1166
1167// descMapFromSummaries indexes the human-readable descriptions for
1168// the renderToolsSection rebuild path.
1169func descMapFromSummaries(summaries []ToolSummary) map[string]string {
1170	out := make(map[string]string, len(summaries))
1171	for _, s := range summaries {
1172		out[s.Name] = s.Description
1173	}
1174	return out
1175}
1176
1177// NewClient returns a provider.Client for r, choosing the auth mode
1178// based on r.AuthMethod. Panics if no credential is present; callers
1179// must check HasCredential() first.
1180func (r Resolved) NewClient() provider.Client {
1181	if !r.HasCredential() {
1182		panic("NewClient called without credential or keyless endpoint; check HasCredential first")
1183	}
1184	wrap := r.withHTTPClient
1185	switch r.Provider {
1186	case "ollama":
1187		return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, ""))
1188	case provider.LlamaCPPProviderID:
1189		credential := r.Credential
1190		if credential == "local" {
1191			credential = ""
1192		}
1193		return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, ""))
1194	case "kimi":
1195		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
1196		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
1197		inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
1198		if r.AuthMethod == "oauth" {
1199			return r.wrapWithRefresh(inner)
1200		}
1201		return inner
1202	case "moonshotai":
1203		return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
1204	case "moonshotai-cn":
1205		return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
1206	case "deepseek":
1207		return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
1208	case "openai":
1209		return provider.NewModelRouter("openai",
1210			wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
1211			map[string]provider.Client{
1212				provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
1213			})
1214	case "openai-codex":
1215		inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
1216		return r.wrapWithRefresh(inner)
1217	case "openai-responses":
1218		// Public OpenAI Responses API (api.openai.com/v1/responses) via
1219		// API key. Separate provider from `openai` (Chat Completions) and
1220		// from `openai-codex` (ChatGPT subscription OAuth).
1221		return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
1222	case "google":
1223		return wrap(provider.NewGemini(r.Credential, r.BaseURL))
1224	case "cerebras":
1225		return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
1226	case "groq":
1227		return wrap(provider.NewGroq(r.Credential, r.BaseURL))
1228	case "xai":
1229		inner := r.newXAIClient(r.Credential)
1230		if r.AuthMethod == "oauth" {
1231			return r.wrapWithRefresh(inner)
1232		}
1233		return inner
1234	case "together":
1235		return wrap(provider.NewTogether(r.Credential, r.BaseURL))
1236	case "huggingface":
1237		return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
1238	case "openrouter":
1239		return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
1240	case "zai":
1241		return wrap(provider.NewZAI(r.Credential, r.BaseURL))
1242	case "xiaomi":
1243		return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
1244	case "xiaomi-token-plan-ams":
1245		return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
1246	case "xiaomi-token-plan-cn":
1247		return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
1248	case "xiaomi-token-plan-sgp":
1249		return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
1250	case "opencode":
1251		return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
1252	case provider.ProviderOpenCodeGo:
1253		return wrap(provider.NewOpenCodeGoWithModels(r.Credential, r.BaseURL, r.modelCatalog))
1254	case "minimax":
1255		return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
1256	case "minimax-cn":
1257		return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
1258	case "fireworks":
1259		return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
1260	case "vercel-ai-gateway":
1261		return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
1262	case "mistral":
1263		return wrap(provider.NewMistral(r.Credential, r.BaseURL))
1264	case "amazon-bedrock":
1265		return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
1266	case "google-vertex":
1267		return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
1268	case "azure-openai-responses":
1269		return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
1270	case "github-copilot":
1271		return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
1272	case "cloudflare-workers-ai":
1273		return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
1274	case "cloudflare-ai-gateway":
1275		return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
1276	default:
1277		// Custom providers: choose wire format from the models.json api field.
1278		customConfig, ok := provider.CustomProviders()[r.Provider]
1279		if r.customProviderConfig != nil {
1280			customConfig, ok = *r.customProviderConfig, true
1281		}
1282		if ok {
1283			switch customConfig.API {
1284			case provider.APIResponses:
1285				return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
1286			case "anthropic":
1287				return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
1288			default: // "openai"
1289				return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
1290			}
1291		}
1292		if r.AuthMethod == "oauth" {
1293			inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
1294			return r.wrapWithRefresh(inner)
1295		}
1296		return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
1297	}
1298}
1299
1300func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
1301	if !r.InsecureTLS {
1302		return c
1303	}
1304	return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
1305}
1306
1307func (r Resolved) newXAIClient(token string) provider.Client {
1308	return provider.NewModelRouter("xai",
1309		r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
1310		map[string]provider.Client{
1311			provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
1312		})
1313}
1314
1315// wrapWithRefresh wraps an OAuth client so the access token is
1316// refreshed automatically before each API call. Without this, long
1317// sessions (hours) silently fail when the 1-hour token expires.
1318func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
1319	provName := r.Provider
1320	tokenProvider := provName
1321	if provName == "openai-codex" {
1322		tokenProvider = "openai"
1323	}
1324	baseURL := r.BaseURL
1325	accountID := r.AccountID
1326
1327	refreshFn := func(ctx context.Context) (string, error) {
1328		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
1329		if err != nil {
1330			return "", err
1331		}
1332		return tok.AccessToken, nil
1333	}
1334
1335	factory := func(token string) provider.Client {
1336		switch provName {
1337		case "openai-codex":
1338			return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
1339		case "kimi":
1340			// anthropic-messages on api.kimi.com/coding.
1341			return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
1342		case "xai":
1343			return r.newXAIClient(token)
1344		default:
1345			return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
1346		}
1347	}
1348
1349	return provider.NewRefreshingClient(inner, refreshFn, factory)
1350}
1351
1352// UseSandbox replaces the sandbox pointer that every tool in r's
1353// registry references. Used to keep the /jail state stable across
1354// agent rebuilds (e.g. /login, /model switching providers).
1355func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1356	if s == nil || r == nil {
1357		return
1358	}
1359	if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1360		s.Permissions = r.Sandbox.Permissions
1361	}
1362	r.Sandbox = s
1363	for name, t := range r.ToolRegistry {
1364		switch v := t.(type) {
1365		case *tools.ReadTool:
1366			v.Sandbox = s
1367		case *tools.WriteTool:
1368			v.Sandbox = s
1369		case *tools.EditTool:
1370			v.Sandbox = s
1371		case *tools.BashTool:
1372			v.Sandbox = s
1373		case *tools.PythonTool:
1374			v.Sandbox = s
1375		case *tools.WorktreeTool:
1376			if v.Create != nil {
1377				v.Create.Sandbox = s
1378			}
1379			if v.Manage != nil {
1380				v.Manage.Sandbox = s
1381			}
1382		case *tools.LSPTool:
1383			v.Sandbox = s
1384		case *tools.GrepTool:
1385			v.Sandbox = s
1386		case *tools.GlobTool:
1387			v.Sandbox = s
1388		case *tools.ASTTool:
1389			v.Sandbox = s
1390		}
1391		_ = name
1392	}
1393}
1394
1395// NewAgent constructs a core.Agent from r. Requires a credential.
1396func (r Resolved) NewAgent() *core.Agent {
1397	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1398	a.MaxSteps = r.MaxSteps
1399	a.DisableRepetitionGuard = r.DisableRepetitionGuard
1400	a.ContextWindow = r.ContextWindow
1401	a.MaxTokens = r.MaxOutput
1402	a.Reasoning = r.Reasoning
1403	a.Temperature = r.Temperature
1404	a.FastMode = r.FastMode
1405	if _, ok := r.ToolRegistry["worktree"].(*tools.WorktreeTool); ok {
1406		cwd := r.CWD
1407		a.HostContext = func(ctx context.Context) string {
1408			return tools.WorktreeInventoryContext(ctx, cwd)
1409		}
1410	}
1411	return a
1412}
1413
1414func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry {
1415	if args.NoTools {
1416		return core.Registry{}
1417	}
1418	var manager *lsp.Manager
1419	if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) {
1420		options := lsp.ManagerOptions{}
1421		if sandbox != nil {
1422			options.CheckWritePath = sandbox.CheckWritePath
1423		}
1424		manager = lsp.NewManagerWithOptions(options)
1425	}
1426	all := map[string]core.Tool{
1427		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1428		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1429		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1430		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1431		"worktree": &tools.WorktreeTool{
1432			Create: &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox, DefaultRoot: filepath.Join(ZutHome(), "worktrees")},
1433			Manage: &tools.ManageWorktreesTool{CWD: cwd, Sandbox: sandbox},
1434		},
1435		"grep":              &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1436		"glob":              &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1437		"ast":               &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1438		"update_goal":       &tools.UpdateGoalTool{},
1439		"plan":              &tools.PlanTool{},
1440		"ask_user_question": &tools.AskUserQuestionTool{},
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", "ask_user_question"}
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