v0.16.0 042538b

build.go RawHistoryFolder

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

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