v0.19.0 d7aa01a

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 and grep remain reserved native-tool names even when
101		// their capability policy excludes the current session. An extension
102		// must not turn a normal CLI opt-out into a differently implemented
103		// capability with the same model-visible name.
104		if info.Name == "web_search" || info.Name == "grep" || 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
427// resolveWebSearchPolicy applies capability precedence at the owning resolver
428// boundary. Permission, tool-list, and named-profile restrictions are ceilings
429// on every caller-provided policy.
430func resolveWebSearchPolicy(args Args, cfg Config, cfgErr error) subagents.WebSearchPolicy {
431	if args.NoTools || args.PermissionSet != nil {
432		return subagents.WebSearchDeny
433	}
434	if args.ToolsSet && !toolListContains(args.Tools, "web_search") {
435		return subagents.WebSearchDeny
436	}
437
438	switch args.WebSearchPolicy {
439	case subagents.WebSearchDeny:
440		return subagents.WebSearchDeny
441	case subagents.WebSearchAllow:
442		return subagents.WebSearchAllow
443	case subagents.WebSearchInherit:
444		// Continue through normal-mode defaults below. Inherit is not a valid
445		// propagated worker decision.
446	default:
447		return subagents.WebSearchDeny
448	}
449	if args.ToolsSet {
450		return subagents.WebSearchAllow
451	}
452	if cfgErr != nil {
453		return subagents.WebSearchDeny
454	}
455	if cfg.WebSearchEnabledForCLI() {
456		return subagents.WebSearchAllow
457	}
458	return subagents.WebSearchDeny
459}
460
461func toolListContains(names []string, target string) bool {
462	for _, name := range names {
463		if name == target {
464			return true
465		}
466	}
467	return false
468}
469
470// webSearchAllowedForRegistry is kept separate from the resolver so direct
471// registry tests and callers that build a registry without persisted config
472// still get the normal default-on behavior.
473func webSearchAllowedForRegistry(args Args) bool {
474	if args.NoTools || args.PermissionSet != nil {
475		return false
476	}
477	if args.ToolsSet && !toolListContains(args.Tools, "web_search") {
478		return false
479	}
480	switch args.WebSearchPolicy {
481	case subagents.WebSearchAllow:
482		return true
483	case subagents.WebSearchDeny:
484		return false
485	default:
486		return true
487	}
488}
489
490// Resolve merges args, config, and env into a Resolved set.
491//
492// Unlike the earlier version, Resolve NEVER returns an error for
493// missing credentials: the TUI can start without them and launch a
494// login flow. requireCred controls whether missing credentials are a
495// hard error (used by print/json modes).
496func Resolve(args Args, requireCred bool) (Resolved, error) {
497	cfg, cfgErr := LoadConfig()
498	// Programmatic callers historically supplied Tools without the parser's
499	// provenance bit. Treat a non-empty list as explicit before a named profile
500	// can contribute its defaults.
501	if len(args.Tools) > 0 {
502		args.ToolsSet = true
503	}
504	webSearchPolicy := resolveWebSearchPolicy(args, cfg, cfgErr)
505	args.WebSearchPolicy = webSearchPolicy
506
507	// User-requested provider (explicit > config > default).
508	// Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
509	// before validation so an alias is never mistaken for an unknown
510	// provider and silently downgraded to anthropic.
511	argProvider := canonicalProvider(args.Provider)
512	provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic")
513	if !isKnownProvider(provName) {
514		// Unknown provider (maybe removed or renamed). Fall back to
515		// the first provider that has credentials, or anthropic.
516		// Custom providers (from models.json) are already accepted
517		// by isKnownProvider, so we only reach here for truly unknown
518		// names.
519		provName = "anthropic"
520		if CredentialAvailable("openai") {
521			provName = "openai"
522		}
523		if CredentialAvailable("openai-codex") {
524			provName = "openai-codex"
525		}
526		if CredentialAvailable("kimi") {
527			provName = "kimi"
528		}
529		if CredentialAvailable("deepseek") {
530			provName = "deepseek"
531		}
532		if CredentialAvailable("anthropic") {
533			provName = "anthropic"
534		}
535		// Reset the saved config so this doesn't keep happening.
536		cfg.Provider = provName
537		cfg.Model = ""
538		_ = SaveConfig(cfg)
539	}
540
541	var (
542		cred      string
543		method    string
544		accountID string
545		credErr   error
546	)
547	if provName == "ollama" {
548		cred = firstNonEmpty(args.APIKey, "ollama")
549		method = "apikey"
550	} else {
551		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
552	}
553
554	// Persist --api-key for custom providers so subsequent runs don't
555	// need to pass it again.
556	if !isBuiltinProvider(provName) && args.APIKey != "" {
557		if store := AuthStoreFor(); store != nil {
558			_ = store.SetAPIKey(provName, args.APIKey)
559		}
560	}
561
562	// If the user did NOT explicitly pick a provider (neither via --provider
563	// nor by saving one in config.json) and the default one has no
564	// credentials, auto-fall-back to whichever provider is actually logged
565	// in. That way running plain `zut` after `/login` (any provider) never
566	// shows a "not logged in" banner.
567	//
568	// Critical: when the user HAS saved a provider in config.json (e.g.
569	// "deepseek" with "deepseek-v4-flash"), that choice is respected even
570	// if no credential is found — the tui will show the login dialog or
571	// surface a clear credential error rather than silently switching to
572	// a completely different provider and model.
573	// autoFellBack tracks whether the provider was changed by the
574	// auto-fallback loop (no explicit provider from CLI or config,
575	// and the default had no credentials). When false, the provider
576	// came from the user's explicit choice (--provider or config.json)
577	// and must be respected, including any model id that may belong
578	// to a different provider in the catalog — for example when using
579	// a gateway/router like OpenRouter with "deepseek/deepseek-v4-flash".
580	var autoFellBack bool
581	userPickedProvider := args.Provider != "" || cfg.Provider != ""
582	if credErr != nil && !userPickedProvider && provName != "ollama" {
583		autoFellBack = true
584		// Scan every known provider (not a hardcoded subset) so any
585		// env-based credential is discovered, e.g. an env-only
586		// amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
587		// IAM keys) when no config.json pins the provider, such as after
588		// pointing ZUT_HOME at a fresh home dir. Iteration order of
589		// knownProviders defines fallback priority. Local providers without a
590		// default model are skipped because selecting either one here would
591		// fail before the user can choose a model.
592		for _, other := range knownProviders {
593			if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID {
594				continue
595			}
596			if !CredentialAvailable(other) {
597				continue
598			}
599			// An explicit launch key belongs to the initially selected provider;
600			// automatic fallback must never reuse it for another provider.
601			c, m, a, err := ResolveCredentialFull(other, "")
602			provName = other
603			cred, method, accountID, credErr = c, m, a, err
604			break
605		}
606	}
607
608	model := firstNonEmpty(args.Model, cfg.Model)
609	if model == "" {
610		if provName == "ollama" || provName == provider.LlamaCPPProviderID {
611			return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName)
612		}
613		model = defaultModelForProvider(provName)
614	}
615	// If the resolved model belongs to a different provider (e.g. config
616	// says gpt-5 but we auto-fell back to anthropic), pick that provider's
617	// default. This override only fires when the provider was auto-fallback-
618	// changed, NOT when the user explicitly configured the pair — gateway/
619	// router providers (openrouter, vercel-ai-gateway, etc.) can serve
620	// models from any provider in the catalog.
621	if autoFellBack && provName != "ollama" {
622		if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
623			model = defaultModelForProvider(provName)
624		}
625	}
626	resolvedModel, err := provider.FindModel(provName, model)
627	if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) {
628		// Local providers are intentionally open-catalogue: any model id the
629		// configured server understands is valid, even if it is not cached.
630		resolvedModel = provider.Model{
631			Provider:      provName,
632			ID:            model,
633			DisplayName:   model,
634			ContextWindow: 128000,
635			MaxOutput:     16384,
636			BaseURL:       args.BaseURL,
637			Source:        provName,
638		}
639		err = nil
640	}
641	// Custom providers are open-catalogue like ollama: any model id the
642	// endpoint understands is valid. Use the provider-level base URL.
643	if err != nil {
644		if cfg, ok := provider.CustomProviders()[provName]; ok {
645			resolvedModel = provider.Model{
646				Provider:      provName,
647				ID:            model,
648				DisplayName:   model,
649				ContextWindow: 128000,
650				MaxOutput:     16384,
651				BaseURL:       cfg.BaseURL,
652				Source:        "user",
653			}
654			err = nil
655		}
656	}
657	if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
658		// Fall back to the provider-level base URL when the model does
659		// not define its own endpoint.
660		resolvedModel.BaseURL = cfg.BaseURL
661	}
662	if err != nil {
663		// The model the user (or persisted config) asked for is no
664		// longer in the active catalogue — they probably removed it
665		// from their models.json or upgraded zut and the id changed.
666		// Refusing to launch is the wrong move: it strands the user
667		// with no way to even open the TUI and pick a new model.
668		// Fall back to the provider's default, warn on stderr, and,
669		// when the stale id came from the persisted config (not an
670		// explicit --model flag), repair the config so the warning
671		// doesn't repeat on every launch.
672
673		// Gateway providers can accept route-qualified ids that are not in
674		// zut's local catalog yet, for example OpenRouter's
675		// "deepseek/deepseek-v4-flash". Preserve only route-qualified ids;
676		// plain unknown values are likely typos and should still fall back.
677		if isGatewayProvider(provName) && isGatewayRoutedModelID(model) {
678			resolvedModel = provider.Model{
679				Provider:      provName,
680				ID:            model,
681				DisplayName:   model,
682				ContextWindow: 1000000,
683				MaxOutput:     64000,
684				BaseURL:       "",
685				Source:        "gateway",
686			}
687			// Don't repair config — the routed model id may be valid upstream.
688		} else {
689			fallback := defaultModelForProvider(provName)
690			fm, ferr := provider.FindModel(provName, fallback)
691			if ferr != nil {
692				// Even the provider default is gone (catastrophic
693				// catalogue trim). Last resort: any model on this
694				// provider, then the global DefaultModel.
695				if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
696					fm = candidates[0]
697				} else {
698					fm = provider.DefaultModel
699				}
700			}
701			fmt.Fprintf(os.Stderr,
702				"zut: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
703				model, fm.ID)
704			if args.Model == "" && cfg.Model == model {
705				cfg.Model = fm.ID
706				_ = SaveConfig(cfg)
707			}
708			resolvedModel = fm
709			model = fm.ID
710		}
711	}
712
713	explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
714
715	// If the model defines a base URL (e.g. local ollama) and the
716	// user didn't pass --base-url, use the model's URL. For ollama,
717	// keep http://localhost:11434 as a fallback only after the model
718	// metadata has had a chance to provide a custom baseUrl.
719	if args.BaseURL == "" && resolvedModel.BaseURL != "" {
720		args.BaseURL = resolvedModel.BaseURL
721	}
722	if args.BaseURL == "" && provName == "ollama" {
723		args.BaseURL = "http://localhost:11434"
724	}
725	if args.BaseURL == "" && provName == provider.LlamaCPPProviderID {
726		managementURL, _, configErr := ResolveLlamaCPPConfig()
727		if configErr != nil {
728			return Resolved{}, configErr
729		}
730		var baseErr error
731		args.BaseURL, baseErr = provider.LlamaCPPInferenceURL(managementURL)
732		if baseErr != nil {
733			return Resolved{}, baseErr
734		}
735	}
736
737	// Insecure TLS is intentionally scoped to explicit custom endpoints.
738	// Built-in provider base URLs, auth calls, and model discovery keep normal
739	// certificate verification even when --insecure is present.
740	insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
741
742	// If the model has a base URL, credentials are optional (local
743	// models like ollama don't need real API keys).
744	if resolvedModel.BaseURL != "" && credErr != nil {
745		cred = "ollama"
746		credErr = nil
747		requireCred = false
748	}
749
750	if credErr != nil && requireCred {
751		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zut` and /login",
752			credErr, envVarName(provName))
753	}
754
755	sandbox := tools.NewSandbox(args.CWD)
756	if cfg.JailByDefault != nil && *cfg.JailByDefault {
757		sandbox.Lock()
758	}
759	if args.PermissionSet != nil {
760		sandbox.SetPermissions(args.PermissionSet)
761	}
762	lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(false)
763	reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(false), cfg.LSPDiagnosticsOnEditEnabled(false))
764
765	docsDir, _ := zutdocs.EnsureInstalled(ZutHome())
766
767	// Skill discovery: scan project + global locations + built-in
768	// skills shipped with the binary. If any are found, register
769	// the on-demand `skill` loader tool plus a system-prompt
770	// manifest so the model knows what's available.
771	//
772	// --no-skill bypasses the entire mechanism: no manifest in the
773	// system prompt, no `skill` tool in the registry. Useful for a
774	// clean-room run with zero extra context biasing the model.
775	var (
776		discovered    []*skills.Skill
777		skillTool     *skills.Tool
778		skillAddendum string
779		skillsEnabled bool
780	)
781	if !args.NoSkill {
782		skillsEnabled = true
783		homeDir, _ := os.UserHomeDir()
784		discovered, _ = skills.Discover(ZutHome(), args.CWD, homeDir, args.WithSkills)
785		if len(discovered) > 0 {
786			skillTool = skills.NewTool(discovered)
787			reg[skillTool.Name()] = skillTool
788			skillAddendum = skills.SystemPromptAddendum(discovered)
789		}
790	}
791	_ = skillTool
792
793	summaries := toolSummaries(reg, args)
794
795	contextFiles := []ContextFile(nil)
796	if !args.NoContextFiles {
797		contextFiles = loadAgentsContext(args.CWD, ZutHome())
798	}
799	append_ := []string(nil)
800	if cfg.PonytailModeEnabled() {
801		if ponytailAddendum := PonytailSystemAddendum(); ponytailAddendum != "" {
802			append_ = append(append_, ponytailAddendum)
803		}
804	}
805	append_ = append(append_, args.AppendSystemPrompt...)
806	if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
807		append_ = append(append_, agentsAddendum)
808	}
809	if skillAddendum != "" {
810		append_ = append(append_, skillAddendum)
811	}
812	interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
813	primaryInteractive := interactiveMode
814	primaryOrchestrator := args.Orchestrate
815	if (primaryInteractive || primaryOrchestrator) && autoSubagentsToolAllowed(args) {
816		homeDir, _ := os.UserHomeDir()
817		profiles, _ := subagents.Discover(args.CWD, homeDir)
818		if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
819			append_ = append(append_, subagentsAddendum)
820		}
821	}
822	if primaryOrchestrator {
823		// Headless orchestration owns its strict prompt contract rather than
824		// inheriting the interactive setting.
825		append_ = append(append_, AutoSubagentsSystemAddendumFor(
826			autoSubagentsToolAllowed(args),
827			autoSubagentsStopToolAllowed(args),
828			autoSubagentsResumeToolAllowed(args),
829		))
830	} else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
831		append_ = append(append_, AutoSubagentsSystemAddendumFor(
832			autoSubagentsToolAllowed(args),
833			autoSubagentsStopToolAllowed(args),
834			autoSubagentsResumeToolAllowed(args),
835		))
836	} else if primaryInteractive && autoSubagentsAnyToolAllowed(args) {
837		append_ = append(append_, OnDemandSubagentsSystemAddendum)
838	}
839	// Custom system prompt resolution order:
840	//   1. --system-prompt flag (highest priority; ad-hoc per run)
841	//   2. $ZUT_HOME/SYSTEM.md (persistent user override)
842	//   3. built-in default (defaultIdentity + defaultGuidelines)
843	custom := args.SystemPrompt
844	if custom == "" {
845		custom = readUserSystemPrompt(ZutHome())
846	}
847
848	sys := BuildSystemPrompt(SystemPromptOpts{
849		CWD:        args.CWD,
850		Tools:      summaries,
851		Custom:     custom,
852		Append:     append_,
853		ZutDocsDir: docsDir,
854	})
855
856	reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
857	temperature := args.Temperature
858	if temperature == nil {
859		temperature = cfg.Temperature
860	}
861	fastMode := cfg.FastMode != nil && *cfg.FastMode
862	if args.FastModeSet {
863		fastMode = args.FastMode
864	}
865	max := args.MaxSteps // 0 = unlimited
866
867	return Resolved{
868		Provider:         provName,
869		Model:            model,
870		Credential:       cred,
871		AuthMethod:       method,
872		AccountID:        accountID,
873		BaseURL:          args.BaseURL,
874		InsecureTLS:      insecureTLS,
875		CWD:              args.CWD,
876		Reasoning:        reasoning,
877		Temperature:      temperature,
878		FastMode:         fastMode,
879		WebSearchPolicy:  webSearchPolicy,
880		ToolRegistry:     reg,
881		ToolSummary:      summaries,
882		SystemPrompt:     sys,
883		MaxSteps:         max,
884		ContextWindow:    resolvedModel.ContextWindow,
885		MaxOutput:        resolvedModel.MaxOutput,
886		Sandbox:          sandbox,
887		SkillTool:        skillTool,
888		skillsEnabled:    skillsEnabled,
889		ContextFiles:     contextFiles,
890		systemAppend:     append_,
891		systemCustom:     custom,
892		skillAddendum:    skillAddendum,
893		toolDescriptions: descMapFromSummaries(summaries),
894	}, nil
895}
896
897// readUserSystemPrompt looks for $ZUT_HOME/SYSTEM.md and returns its
898// trimmed contents, or "" when the file is missing / unreadable /
899// empty. Errors are intentionally swallowed: the file is optional,
900// and any failure to read it should fall back to the built-in
901// default system prompt rather than crash the run.
902func readUserSystemPrompt(zutHome string) string {
903	if zutHome == "" {
904		return ""
905	}
906	path := filepath.Join(zutHome, "SYSTEM.md")
907	raw, err := os.ReadFile(path)
908	if err != nil {
909		return ""
910	}
911	return strings.TrimSpace(string(raw))
912}
913
914// loadAgentsContext loads optional AGENTS.md instruction files. No
915// default file is created or required: zut only includes files that
916// already exist. Global instructions ($ZUT_HOME/AGENTS.md) come first,
917// followed by project instructions from the filesystem root down to cwd.
918func loadAgentsContext(cwd, zutHome string) []ContextFile {
919	var files []ContextFile
920	seen := map[string]bool{}
921	add := func(path string) {
922		if path == "" {
923			return
924		}
925		abs, err := filepath.Abs(path)
926		if err == nil {
927			path = abs
928		}
929		if seen[path] {
930			return
931		}
932		raw, err := os.ReadFile(path)
933		if err != nil {
934			return
935		}
936		content := strings.TrimSpace(string(raw))
937		if content == "" {
938			return
939		}
940		seen[path] = true
941		files = append(files, ContextFile{Path: path, Content: content})
942	}
943	addFirstFromDir := func(dir string) {
944		if dir == "" {
945			return
946		}
947		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
948			path := filepath.Join(dir, name)
949			if _, err := os.Stat(path); err == nil {
950				add(path)
951				return
952			}
953		}
954	}
955
956	addFirstFromDir(zutHome)
957
958	if cwd != "" {
959		abs, err := filepath.Abs(cwd)
960		if err == nil {
961			cwd = abs
962		}
963		var dirs []string
964		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
965			dirs = append(dirs, dir)
966			parent := filepath.Dir(dir)
967			if parent == dir {
968				break
969			}
970		}
971		for i := len(dirs) - 1; i >= 0; i-- {
972			addFirstFromDir(dirs[i])
973		}
974	}
975
976	return files
977}
978
979func formatAgentsContext(files []ContextFile) string {
980	if len(files) == 0 {
981		return ""
982	}
983	var sb strings.Builder
984	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")
985	for _, f := range files {
986		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
987	}
988	return strings.TrimSpace(sb.String())
989}
990
991// descMapFromSummaries indexes the human-readable descriptions for
992// the renderToolsSection rebuild path.
993func descMapFromSummaries(summaries []ToolSummary) map[string]string {
994	out := make(map[string]string, len(summaries))
995	for _, s := range summaries {
996		out[s.Name] = s.Description
997	}
998	return out
999}
1000
1001// NewClient returns a provider.Client for r, choosing the auth mode
1002// based on r.AuthMethod. Panics if no credential is present; callers
1003// must check HasCredential() first.
1004func (r Resolved) NewClient() provider.Client {
1005	if !r.HasCredential() {
1006		panic("NewClient called without credential; check HasCredential first")
1007	}
1008	wrap := r.withHTTPClient
1009	switch r.Provider {
1010	case "ollama":
1011		return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, ""))
1012	case provider.LlamaCPPProviderID:
1013		credential := r.Credential
1014		if credential == "local" {
1015			credential = ""
1016		}
1017		return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, ""))
1018	case "kimi":
1019		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
1020		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
1021		inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
1022		if r.AuthMethod == "oauth" {
1023			return r.wrapWithRefresh(inner)
1024		}
1025		return inner
1026	case "moonshotai":
1027		return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
1028	case "moonshotai-cn":
1029		return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
1030	case "deepseek":
1031		return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
1032	case "openai":
1033		return provider.NewModelRouter("openai",
1034			wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
1035			map[string]provider.Client{
1036				provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
1037			})
1038	case "openai-codex":
1039		inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
1040		return r.wrapWithRefresh(inner)
1041	case "openai-responses":
1042		// Public OpenAI Responses API (api.openai.com/v1/responses) via
1043		// API key. Separate provider from `openai` (Chat Completions) and
1044		// from `openai-codex` (ChatGPT subscription OAuth).
1045		return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
1046	case "google":
1047		return wrap(provider.NewGemini(r.Credential, r.BaseURL))
1048	case "cerebras":
1049		return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
1050	case "groq":
1051		return wrap(provider.NewGroq(r.Credential, r.BaseURL))
1052	case "xai":
1053		inner := r.newXAIClient(r.Credential)
1054		if r.AuthMethod == "oauth" {
1055			return r.wrapWithRefresh(inner)
1056		}
1057		return inner
1058	case "together":
1059		return wrap(provider.NewTogether(r.Credential, r.BaseURL))
1060	case "huggingface":
1061		return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
1062	case "openrouter":
1063		return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
1064	case "zai":
1065		return wrap(provider.NewZAI(r.Credential, r.BaseURL))
1066	case "xiaomi":
1067		return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
1068	case "xiaomi-token-plan-ams":
1069		return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
1070	case "xiaomi-token-plan-cn":
1071		return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
1072	case "xiaomi-token-plan-sgp":
1073		return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
1074	case "opencode":
1075		return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
1076	case "opencode-go":
1077		return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
1078	case "minimax":
1079		return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
1080	case "minimax-cn":
1081		return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
1082	case "fireworks":
1083		return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
1084	case "vercel-ai-gateway":
1085		return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
1086	case "mistral":
1087		return wrap(provider.NewMistral(r.Credential, r.BaseURL))
1088	case "amazon-bedrock":
1089		return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
1090	case "google-vertex":
1091		return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
1092	case "azure-openai-responses":
1093		return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
1094	case "github-copilot":
1095		return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
1096	case "cloudflare-workers-ai":
1097		return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
1098	case "cloudflare-ai-gateway":
1099		return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
1100	default:
1101		// Custom providers: choose wire format from the models.json api field.
1102		if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
1103			switch cfg.API {
1104			case provider.APIResponses:
1105				return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
1106			case "anthropic":
1107				return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
1108			default: // "openai"
1109				return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
1110			}
1111		}
1112		if r.AuthMethod == "oauth" {
1113			inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
1114			return r.wrapWithRefresh(inner)
1115		}
1116		return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
1117	}
1118}
1119
1120func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
1121	if !r.InsecureTLS {
1122		return c
1123	}
1124	return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
1125}
1126
1127func (r Resolved) newXAIClient(token string) provider.Client {
1128	return provider.NewModelRouter("xai",
1129		r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
1130		map[string]provider.Client{
1131			provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
1132		})
1133}
1134
1135// wrapWithRefresh wraps an OAuth client so the access token is
1136// refreshed automatically before each API call. Without this, long
1137// sessions (hours) silently fail when the 1-hour token expires.
1138func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
1139	provName := r.Provider
1140	tokenProvider := provName
1141	if provName == "openai-codex" {
1142		tokenProvider = "openai"
1143	}
1144	baseURL := r.BaseURL
1145	accountID := r.AccountID
1146
1147	refreshFn := func(ctx context.Context) (string, error) {
1148		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
1149		if err != nil {
1150			return "", err
1151		}
1152		return tok.AccessToken, nil
1153	}
1154
1155	factory := func(token string) provider.Client {
1156		switch provName {
1157		case "openai-codex":
1158			return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
1159		case "kimi":
1160			// anthropic-messages on api.kimi.com/coding.
1161			return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
1162		case "xai":
1163			return r.newXAIClient(token)
1164		default:
1165			return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
1166		}
1167	}
1168
1169	return provider.NewRefreshingClient(inner, refreshFn, factory)
1170}
1171
1172// UseSandbox replaces the sandbox pointer that every tool in r's
1173// registry references. Used to keep the /jail state stable across
1174// agent rebuilds (e.g. /login, /model switching providers).
1175func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1176	if s == nil || r == nil {
1177		return
1178	}
1179	if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1180		s.Permissions = r.Sandbox.Permissions
1181	}
1182	r.Sandbox = s
1183	for name, t := range r.ToolRegistry {
1184		switch v := t.(type) {
1185		case *tools.ReadTool:
1186			v.Sandbox = s
1187		case *tools.WriteTool:
1188			v.Sandbox = s
1189		case *tools.EditTool:
1190			v.Sandbox = s
1191		case *tools.BashTool:
1192			v.Sandbox = s
1193		case *tools.CreateWorktreeTool:
1194			v.Sandbox = s
1195		case *tools.LSPTool:
1196			v.Sandbox = s
1197		case *tools.GrepTool:
1198			v.Sandbox = s
1199		}
1200		_ = name
1201	}
1202}
1203
1204// NewAgent constructs a core.Agent from r. Requires a credential.
1205func (r Resolved) NewAgent() *core.Agent {
1206	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1207	a.MaxSteps = r.MaxSteps
1208	a.ContextWindow = r.ContextWindow
1209	a.MaxTokens = r.MaxOutput
1210	a.Reasoning = r.Reasoning
1211	a.Temperature = r.Temperature
1212	a.FastMode = r.FastMode
1213	return a
1214}
1215
1216func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry {
1217	if args.NoTools {
1218		return core.Registry{}
1219	}
1220	var manager *lsp.Manager
1221	if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) {
1222		options := lsp.ManagerOptions{}
1223		if sandbox != nil {
1224			options.CheckWritePath = sandbox.CheckWritePath
1225		}
1226		manager = lsp.NewManagerWithOptions(options)
1227	}
1228	all := map[string]core.Tool{
1229		"read":            &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1230		"write":           &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1231		"edit":            &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1232		"bash":            &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1233		"create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1234		"grep":            &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1235		"update_goal":     &tools.UpdateGoalTool{},
1236	}
1237	if webSearchAllowedForRegistry(args) {
1238		all["web_search"] = tools.NewWebSearchTool()
1239	}
1240	if manager != nil {
1241		lspTool := tools.NewLSPTool(cwd, manager)
1242		lspTool.Sandbox = sandbox
1243		all["lsp"] = lspTool
1244	}
1245	reg := core.Registry{}
1246	if len(args.Tools) == 0 {
1247		for _, t := range all {
1248			reg[t.Name()] = t
1249		}
1250		return reg
1251	}
1252	for _, name := range args.Tools {
1253		if t, ok := all[name]; ok {
1254			reg[name] = t
1255		}
1256	}
1257	return reg
1258}
1259
1260func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {
1261	if len(args.Tools) == 0 {
1262		return true
1263	}
1264	for _, name := range args.Tools {
1265		switch name {
1266		case "lsp":
1267			return true
1268		case "write":
1269			if diagnosticsOnWrite {
1270				return true
1271			}
1272		case "edit":
1273			if diagnosticsOnEdit {
1274				return true
1275			}
1276		}
1277	}
1278	return false
1279}
1280
1281func autoSubagentsToolAllowed(args Args) bool {
1282	return autoSubagentsToolAllowedFor(args, "subagent_spawn")
1283}
1284
1285func autoSubagentsStatusToolAllowed(args Args) bool {
1286	return autoSubagentsToolAllowedFor(args, "subagent_status")
1287}
1288
1289func autoSubagentsStopToolAllowed(args Args) bool {
1290	return autoSubagentsToolAllowedFor(args, "subagent_stop")
1291}
1292
1293func autoSubagentsResumeToolAllowed(args Args) bool {
1294	return autoSubagentsToolAllowedFor(args, "subagent_resume")
1295}
1296
1297func autoSubagentsAnyToolAllowed(args Args) bool {
1298	return autoSubagentsToolAllowed(args) ||
1299		autoSubagentsStatusToolAllowed(args) ||
1300		autoSubagentsStopToolAllowed(args) ||
1301		autoSubagentsResumeToolAllowed(args)
1302}
1303
1304func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1305	if args.NoTools || args.PermissionSet != nil {
1306		return false
1307	}
1308	if args.ToolsSet && len(args.Tools) == 0 {
1309		return false
1310	}
1311	if len(args.Tools) == 0 {
1312		return true
1313	}
1314	for _, name := range args.Tools {
1315		if name == toolName {
1316			return true
1317		}
1318	}
1319	return false
1320}
1321
1322var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "bash", "create_worktree", "lsp", "web_search", "update_goal"}
1323
1324func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1325	var out []ToolSummary
1326	for _, name := range nativeToolSummaryOrder {
1327		if t, ok := reg[name]; ok {
1328			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1329		}
1330	}
1331	return out
1332}
1333
1334func firstNonEmpty(vals ...string) string {
1335	for _, v := range vals {
1336		if v != "" {
1337			return v
1338		}
1339	}
1340	return ""
1341}
1342
1343func kimiCodeHeaders() map[string]string {
1344	host, _ := os.Hostname()
1345	if host == "" {
1346		host = "unknown"
1347	}
1348	deviceID := ""
1349	if home, err := os.UserHomeDir(); err == nil {
1350		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1351			deviceID = strings.TrimSpace(string(b))
1352		}
1353	}
1354	if deviceID == "" {
1355		deviceID = "zut"
1356	}
1357	return map[string]string{
1358		"User-Agent":         "KimiCLI/1.41.0",
1359		"X-Msh-Platform":     "kimi_cli",
1360		"X-Msh-Version":      "1.41.0",
1361		"X-Msh-Device-Name":  host,
1362		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1363		"X-Msh-Os-Version":   runtime.GOOS,
1364		"X-Msh-Device-Id":    deviceID,
1365	}
1366}
1367
1368func envVarName(provider string) string {
1369	switch provider {
1370	case "openai", "openai-codex", "openai-responses":
1371		return "OPENAI"
1372	case "kimi":
1373		return "KIMI"
1374	case "deepseek":
1375		return "DEEPSEEK"
1376	case "google":
1377		return "GEMINI"
1378	case "ollama":
1379		return "OLLAMA"
1380	case "llama.cpp":
1381		return "LLAMA"
1382	case "moonshotai", "moonshotai-cn":
1383		return "MOONSHOT"
1384	case "groq":
1385		return "GROQ"
1386	case "xai":
1387		return "XAI"
1388	case "cerebras":
1389		return "CEREBRAS"
1390	case "together":
1391		return "TOGETHER"
1392	case "huggingface":
1393		return "HF"
1394	case "openrouter":
1395		return "OPENROUTER"
1396	case "mistral":
1397		return "MISTRAL"
1398	case "zai":
1399		return "ZAI"
1400	case "xiaomi":
1401		return "XIAOMI"
1402	case "xiaomi-token-plan-ams":
1403		return "XIAOMI_TOKEN_PLAN_AMS"
1404	case "xiaomi-token-plan-cn":
1405		return "XIAOMI_TOKEN_PLAN_CN"
1406	case "xiaomi-token-plan-sgp":
1407		return "XIAOMI_TOKEN_PLAN_SGP"
1408	case "minimax":
1409		return "MINIMAX"
1410	case "minimax-cn":
1411		return "MINIMAX_CN"
1412	case "fireworks":
1413		return "FIREWORKS"
1414	case "vercel-ai-gateway":
1415		return "AI_GATEWAY"
1416	case "opencode", "opencode-go":
1417		return "OPENCODE"
1418	case "github-copilot":
1419		return "COPILOT_GITHUB_TOKEN"
1420	case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1421		return "CLOUDFLARE"
1422	case "amazon-bedrock":
1423		return "AWS"
1424	case "google-vertex":
1425		return "GOOGLE_CLOUD"
1426	case "azure-openai-responses":
1427		return "AZURE_OPENAI"
1428	default:
1429		return "ANTHROPIC"
1430	}
1431}

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close