Vectrum
Sign In
991 lines · 38.2 KB · Go
// Command codey is the CLI entry point: flag parsing, wiring, and the REPL.
package main

import (
	"bufio"
	"context"
	"errors"
	"flag"
	"fmt"
	"os"
	"os/signal"
	"path/filepath"
	"strings"
	"sync/atomic"
	"time"

	"codey/internal/agent"
	"codey/internal/approval"
	"codey/internal/ask"
	"codey/internal/claudecode"
	"codey/internal/config"
	"codey/internal/diag"
	"codey/internal/herdr"
	"codey/internal/hooks"
	"codey/internal/llm"
	"codey/internal/metrics"
	"codey/internal/serve"
	"codey/internal/skills"
	"codey/internal/statedir"
	"codey/internal/subagent"
	"codey/internal/tools"
	"codey/internal/tui"
	"codey/internal/ui"
	"codey/internal/websearch"
)

// version is what the TUI's welcome block reports. It is a constant rather
// than build-stamped: Codey is built from this tree, and a stamp that
// can disagree with the source is worse than no stamp.
const version = "v0.6.0"

// effectiveWebMode is the web-search mode this process actually ended up with.
// Asking for one and getting it are different things: -no-web says no, and a
// search command that is not installed is not a search command.
func effectiveWebMode(asked string, searcher websearch.Searcher, hosted []llm.ToolDef) string {
	switch {
	case asked == config.WebSearchClaude && len(hosted) > 0:
		return config.WebSearchClaude
	case asked == config.WebSearchCustom && searcher != nil:
		return config.WebSearchCustom
	}
	return config.WebSearchNone
}

// hostedSearchesPerTurn caps how many searches Anthropic's hosted tool may run
// for one request. There is no approval prompt in front of a hosted search —
// it happens inside a request already approved — so the cap is the only thing
// standing between a confused model and a bill.
const hostedSearchesPerTurn = 5

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, "codey:", err)
		os.Exit(1)
	}
}

func run() error {
	workdir := flag.String("C", ".", "working root the agent operates in")
	model := flag.String("model", "", "model id (default "+llm.DefaultModel+")")
	baseURL := flag.String("base-url", "", "API endpoint, for a proxy, gateway, or local server speaking the Messages API (env ANTHROPIC_BASE_URL, default "+llm.DefaultBaseURL+")")
	apiKey := flag.String("api-key", "", "credential sent as x-api-key (env ANTHROPIC_API_KEY). Visible to other processes; prefer the env var")
	authToken := flag.String("auth-token", "", "credential sent as Authorization: Bearer, for OAuth tokens and most gateways (env ANTHROPIC_AUTH_TOKEN)")
	effort := flag.String("effort", "", "output_config.effort: low|medium|high|xhigh|max (default: the API's, high)")
	maxTurns := flag.Int("max-turns", agent.NoTurnLimit, "turn cap per prompt; 0 is no cap, which is the default — worth setting for an unattended -p run")
	contextWindow := flag.Int("context-window", agent.DefaultContextWindow, "model context window, used to decide when to compact")
	compactAt := flag.Float64("compact-at", agent.DefaultCompactAt, "fraction of the window at which to compact")
	compactFloor := flag.Float64("compact-floor", agent.DefaultCompactFloor, "fraction past which compaction is forced, keeping only a short tail")
	noAutoCompact := flag.Bool("no-auto-compact", false, "never fold the conversation on its own; stop the turn when the window fills instead")
	noCache := flag.Bool("no-cache", false, "disable prompt cache breakpoints")
	noStream := flag.Bool("no-stream", false, "wait for each complete response instead of streaming")
	logPath := flag.String("metrics", metrics.DefaultPath, "JSONL event log path, relative to the working root unless absolute")
	prompt := flag.String("p", "", "run one prompt and exit, instead of starting the REPL")
	resume := flag.String("resume", "", "resume a saved session by id, or \"latest\"")
	verbose := flag.Bool("v", false, "print full tool output")
	yolo := flag.Bool("yolo", false, "skip every approval prompt. Only against a throwaway clone or inside a container")
	noTUI := flag.Bool("no-tui", false, "use the plain line-based REPL instead of the full-screen TUI")
	noWeb := flag.Bool("no-web", false, "do not offer web search, whatever the config says")
	noSkills := flag.Bool("no-skills", false, "skip discovering skills in ~/"+skills.Dirname+", ./"+skills.Dirname+" and Claude Code's own")
	noAgents := flag.Bool("no-agents", false, "do not offer the subagent tools, so the agent works alone")
	noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "do not fall back to the OAuth login in ~/"+claudecode.Filename)
	noHooks := flag.Bool("no-hooks", false, "do not run the hooks in the config, and do not report agent state to herdr")
	noDiagnostics := flag.Bool("no-diagnostics", false, "do not check an edited file with the language server or linter installed for it")

	// Read the line before the flag package does. Three invocations belong to
	// a program driving Codey rather than to a person — see agentcmd.go — and
	// two of them are not spelled in a grammar flag parsing can read.
	inv := scanArgs(os.Args[1:])
	if inv.Kind == invVersion {
		printVersion(os.Stdout)
		return nil
	}
	if err := flag.CommandLine.Parse(inv.Rest); err != nil {
		return err
	}

	root, err := filepath.Abs(*workdir)
	if err != nil {
		return err
	}

	// A clone last used under the old name keeps its sessions and its event
	// log. This runs before anything opens a file inside the state directory,
	// and is reported rather than fatal: a stale directory that cannot be
	// moved is a reason to say so, not a reason to refuse to start.
	stateMoved, stateErr := statedir.Migrate(root)
	if err := statedir.Ensure(root); err != nil {
		return err
	}

	// Saved preferences sit under the flags and over the built-in defaults. A
	// config that cannot be read is reported and then ignored: Codey
	// still starts, and the wizard can write a good one over it.
	cfg, _, cfgErr := config.Load()

	// Held as a slice because the Claude Code fallback below may have to
	// rebuild the client with one more option, and replaying the same list is
	// how the rebuilt client is guaranteed to be the one that passed the
	// endpoint check.
	opts := []llm.Option{
		llm.WithModel(config.Or(*model, cfg.Model)),
		llm.WithBaseURL(*baseURL),
		llm.WithAPIKey(*apiKey),
		llm.WithAuthToken(*authToken),
	}
	client := llm.New(opts...)
	// clientOpts is the list the live client was actually built from, kept so
	// a second client on another model can be built the same way. The Claude
	// Code branch below is the only thing that adds to it.
	clientOpts := opts

	home, err := os.UserHomeDir()
	if err != nil {
		// No home directory is not fatal: an explicit credential still works.
		home = ""
	}
	var authNote string
	// systemPrefix carries the Claude Code identity block. It is set only when
	// the credential was borrowed from Claude Code's own login: an OAuth
	// request that omits it is rejected (as a 429, not a 401 — it reads like a
	// quota problem and is not one).
	var systemPrefix string
	// ccSource is kept so its write-back failure hook can be wired once the
	// places a warning can go — the notice channel, the event log — exist.
	var ccSource *claudecode.Source
	if home != "" {
		src, note, err := resolveClaudeCode(client, home, *noClaudeCodeAuth)
		if err != nil {
			return err
		}
		if src != nil {
			ccSource = src
			clientOpts = append(opts, llm.WithTokenSource(src.Token))
			client = llm.New(clientOpts...)
			// Not expected to ever trigger: resolveClaudeCode already checked
			// the endpoint before we agreed to borrow this credential, but
			// that check ran against the client we had before this rebuild.
			// This is a guard against a future refactor reordering things so
			// the two clients quietly diverge, not a condition anyone should
			// see fire.
			if !client.IsDefaultEndpoint() {
				return errors.New("internal: discovered credential on a non-default endpoint")
			}
			authNote = note
			systemPrefix = agent.ClaudeCodeIdentity
		}
	}
	// The login-and-models probe answers here, where a borrowed Claude Code
	// credential already counts as being logged in, and before the check
	// below: "not logged in" is the useful answer to that question, not an
	// error exit that tells the asker nothing.
	if inv.Kind == invModels {
		printModels(os.Stdout, client)
		return nil
	}

	if !client.HasCredentials() {
		return errNoCredentials
	}

	effortSetting := config.Or(*effort, cfg.Effort)
	// An accept-all default is a decision about what the agent may do
	// unattended, so -yolo can turn it on but a config can only be the default
	// for a session where the flag said nothing.
	acceptAll := *yolo || cfg.Approvals == config.ApprovalsAcceptAll
	// Same direction: the flag can only turn the automatic fold off, and a
	// config that never mentioned it leaves it on.
	autoCompactOff := *noAutoCompact || !cfg.AutoCompactEnabled()

	session := time.Now().UTC().Format("20060102T150405Z")
	log, err := metrics.Open(metricsPath(root, *logPath), session)
	if err != nil {
		return err
	}
	defer log.Close()
	log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{
		"root": root, "model": client.Model(), "base_url": client.BaseURL,
	}})

	// The TUI takes over the screen, so anything worth saying before the loop
	// starts has to go into its scrollback rather than to stderr.
	useTUI := !*noTUI && *prompt == "" && interactive()
	var banner []string
	notice := func(format string, args ...any) {
		msg := fmt.Sprintf(format, args...)
		if useTUI {
			banner = append(banner, msg)
			return
		}
		fmt.Fprintln(os.Stderr, "codey: "+msg)
	}

	if cfgErr != nil {
		notice("%v — using defaults; /setup will write a fresh one", cfgErr)
	}
	if stateMoved {
		notice("moved %s to %s — sessions and metrics came with it", statedir.LegacyDir, statedir.Dir)
	}
	if stateErr != nil {
		notice("%v — starting a fresh %s; the old one is untouched", stateErr, statedir.Dir)
	}

	if !client.IsDefaultEndpoint() {
		// Worth saying out loud: the prompt and every file the agent reads go
		// to this endpoint, not to Anthropic.
		notice("sending requests to %s", client.BaseURL)
	}
	if authNote != "" {
		notice("%s", authNote)
	}
	if ccSource != nil {
		// A rotation we could not store means the credential on disk is dead
		// and its replacement dies with this process — the user's next `claude`
		// will ask them to log in again, for a reason nothing else would
		// explain. It goes to the event log unconditionally, because that
		// survives the session; stderr only outside the TUI, which owns the
		// screen once it starts.
		ccSource.OnWriteBackFailure = func(err error) {
			log.Log(metrics.Event{Type: "credential_writeback_failed", Extra: map[string]any{
				"error": err.Error(), "file": "~/" + claudecode.Filename,
			}})
			if !useTUI {
				fmt.Fprintf(os.Stderr, "codey: your Claude Code login was renewed but ~/%s could not be updated: %v\n", claudecode.Filename, err)
				fmt.Fprintln(os.Stderr, "codey: run `claude` when convenient — its stored login is now out of date.")
			}
		}
	}

	var found []skills.Skill
	// claudeFound is what Claude Code has on this machine: its own skills and
	// the ones its plugins ship. They are discovered even when the config says
	// to leave them alone, because the count is what decides whether the setup
	// wizard has a question to ask at all — and a wizard that never mentions
	// them is how someone with fourteen skills installed ends up with none.
	var claudeFound []skills.Skill
	// claudeMode is what this run is doing about them, which -no-skills can
	// make "off" whatever the config says.
	claudeMode := config.ClaudeSkillsOff
	if !*noSkills {
		var problems []error
		found, problems = skills.Discover(home, root)
		for _, p := range problems {
			notice("skill skipped: %v", p)
		}

		var claudeProblems []error
		claudeFound, claudeProblems = skills.DiscoverClaude(home, root)
		for _, p := range claudeProblems {
			notice("claude skill skipped: %v", p)
		}
		// Only "use" merges them in. "copy" is a thing the wizard did once,
		// and what it copied is already a personal skill that Discover found
		// above; reading Claude's directories again would offer the same
		// instructions twice under one name.
		if cfg.ClaudeSkillsMode() == config.ClaudeSkillsUse && len(claudeFound) > 0 {
			found = skills.Merge(found, claudeFound)
			claudeMode = config.ClaudeSkillsUse
			notice("using %d skill(s) from Claude Code", len(claudeFound))
		}
	}

	// Skill directories are readable by the search tools and by nothing else,
	// so a skill can read the files it ships with.
	var skillDirs []string
	for _, s := range found {
		skillDirs = append(skillDirs, s.Dir)
	}

	// Web access, if it was asked for. The mode lives in the config rather
	// than in a flag because it is a standing decision about what leaves this
	// machine; -no-web is the single-run override, and like -yolo's opposite
	// it can only take capability away.
	//
	// The hosted mode is a tool definition on the request and nothing local.
	// The custom mode is two ordinary tools, registered below — but only if
	// the search command is actually installed: an agent told it can search,
	// which discovers on its first attempt that nothing is there, has spent a
	// turn learning what this line already knows.
	webMode := cfg.WebSearchMode()
	if *noWeb {
		webMode = config.WebSearchNone
	}
	var (
		searcher    websearch.Searcher
		serverTools []llm.ToolDef
	)
	switch webMode {
	case config.WebSearchClaude:
		serverTools = []llm.ToolDef{llm.WebSearchTool(hostedSearchesPerTurn)}
	case config.WebSearchCustom:
		line := cfg.SearchCommandLine()
		s, err := websearch.NewCommand(line)
		switch {
		case err != nil:
			notice("web search is off: %v", err)
		default:
			if err := websearch.Available(line); err != nil {
				notice("web search is off: %v", err)
			} else {
				searcher = s
			}
		}
	}

	// Whatever this machine can say about a file the agent has just written.
	// A nil Runner is the off switch, and every call site takes one, so the
	// setting is a single branch here rather than a condition threaded through
	// the tools.
	//
	// One Runner serves every root, including the ones /worktree moves to: it
	// keys its language servers by the workspace it detects around the file,
	// not by the root it was built for, so moving the work does not strand a
	// warm gopls behind it.
	var checks *diag.Runner
	if cfg.DiagnosticsEnabled() && !*noDiagnostics {
		checks = diag.NewRunner()
		defer checks.Close()
	}

	// Background commands outlive the call that started them, so something
	// with the session's lifetime has to own them. Closing kills each process
	// group: a dev server that outlived the harness holds its port with
	// nothing left running to explain why.
	procs := tools.NewProcesses()
	defer procs.Close()

	// The working plan. It is the session's, not any one agent's: a subagent
	// rewriting the parent's list would be two hands on the same page.
	todos := tools.NewTodos()
	// What was delivered rather than what was asked for, for the settings
	// panel: -no-diagnostics leaves this off however the config reads.
	diagMode := config.DiagnosticsOff
	if checks != nil {
		diagMode = config.DiagnosticsOn
	}

	// One factory, used for the session's own agent and for every subagent:
	// a child that could reach further than its parent would be a way around
	// the parent's limits, and a child that could reach less would be a
	// surprise the model has no way to know about.
	//
	// The root is a parameter rather than a captured variable because
	// /worktree rebuilds the tool set against a different directory: each tool
	// closes over the root it is handed, so moving the work means building the
	// set again, and this is the only place that knows how.
	toolsIn := func(root string) func(*agent.State, *metrics.Logger) *tools.Registry {
		return func(state *agent.State, log *metrics.Logger) *tools.Registry {
			reg := tools.NewRegistry(log).Register(
				tools.NewRead(root, state, skillDirs...),
				tools.NewGlob(root, skillDirs...),
				tools.NewGrep(root, skillDirs...),
				tools.NewEdit(root, state, tools.WithDiagnostics(checks)),
				tools.NewWrite(root, state, tools.WithDiagnostics(checks)),
				tools.NewBash(root, tools.WithProcesses(procs)),
				tools.NewProcessOutput(procs),
				tools.NewProcessKill(procs),
			)
			if t := tools.NewSkill(found); t != nil {
				reg.Register(t)
			}
			// The pair go in together: a search result nothing can read is
			// half a capability, and the model would fetch by guessing at
			// bash and curl instead.
			if t := tools.NewWebSearch(searcher); t != nil {
				reg.Register(t, tools.NewWebFetch())
			} else if len(serverTools) > 0 {
				// Hosted search has the same gap and no local half to close
				// it: the model gets snippets it cannot follow, and a snippet
				// is never enough to write code against. The consent is
				// already given — this mode is the user asking for web access
				// — so the fetch half goes in on its own.
				reg.Register(tools.NewWebFetch())
			}
			return reg
		}
	}
	newTools := toolsIn(root)

	state := agent.NewState()
	reg := newTools(state, log)

	in := bufio.NewScanner(os.Stdin)
	in.Buffer(make([]byte, 0, 64*1024), 1024*1024)

	render := &ui.Renderer{W: os.Stdout, Verbose: *verbose}
	approver := &ui.Approver{In: in, Out: os.Stdout}
	policy := approval.New(approver.Prompt)

	// Questions go to the line front end until a TUI takes them over in
	// tui.Run. The tool belongs to the session's own agent and is registered
	// here rather than in newTools, so a subagent still cannot stop and ask:
	// its report goes to the parent, and the parent is not a person.
	asker := ask.NewAsker((&ui.Asker{In: in, Out: os.Stdout}).Prompt)
	reg.Register(ask.Tool(asker.Ask))
	// The plan belongs to the session agent for the same reason the question
	// does: a subagent keeping its own list would show the user a plan for
	// work they did not ask about.
	if t := tools.NewTodoWrite(todos); t != nil {
		reg.Register(t)
	}

	policy.YOLO = acceptAll
	if acceptAll && !useTUI {
		// The TUI says this itself, in the banner.
		fmt.Fprintln(os.Stderr, "codey: -yolo — no approval prompts. Use a throwaway clone or a container.")
	}
	base := agent.Options{
		MaxTurns:      *maxTurns,
		Effort:        effortSetting,
		ContextWindow: *contextWindow,
		CompactAt:     *compactAt,
		CompactFloor:  *compactFloor,
		NoAutoCompact: autoCompactOff,
		NoCache:       *noCache,
		Stream:        !*noStream,
		Skills:        found,
		ServerTools:   serverTools,
		SystemPrefix:  systemPrefix,
	}

	// An agent run hands the whole thing over to the protocol and never comes
	// back. It goes here, after everything a session needs has been built and
	// before the front ends that assume a person: no subagent supervisor,
	// because their reports need somewhere to be shown, and no question tool,
	// because the protocol has no box to put a question in.
	//
	// Nothing above this line has written to stdout. Nothing below it may.
	if inv.Kind == invAgent {
		if acceptAll || inv.Permission == serve.ModeFullAccess {
			inv.Permission = serve.ModeFullAccess
			notice("no approval prompts. Use a throwaway clone or a container.")
		}
		return serveACP(context.Background(), acpDeps{
			Permission: inv.Permission,
			NoAgents:   *noAgents,
			Client:     client,
			ClientOpts: clientOpts,
			Log:        log,
			Base:       base,
			Tools:      toolsIn,
		})
	}

	// Subagents are a TUI feature: their reports come back as messages the
	// front end puts into the conversation, and the line-based REPL has
	// nowhere to show four agents working at once. Offering the tools where
	// nothing can deliver the answers would be worse than not offering them.
	// The options a subagent runs under are the ones its parent had before the
	// model listing was added: a child never chooses a model for a child of
	// its own, and a paragraph about a choice it will not make is a paragraph
	// it pays for on every request. Held here because every tab builds a
	// supervisor of its own from it.
	subBase := base

	var sup *subagent.Supervisor
	if useTUI && !*noAgents {
		// What the endpoint serves, so the agent picks subagent models from a
		// real list rather than from memory. It is asked for once, here,
		// because the answer goes into the cached prompt prefix and must not
		// change mid-session. An endpoint that does not answer simply leaves
		// the listing out: no list beats a made-up one, and the agent is told
		// to inherit this session's model when it has nothing to choose from.
		listCtx, cancelList := context.WithTimeout(context.Background(), 10*time.Second)
		models, listErr := client.ListModels(listCtx)
		cancelList()
		if listErr != nil {
			notice("could not list the models this endpoint serves (%v) — subagents will run on %s unless the agent is told otherwise", listErr, client.Model())
		}
		sup = subagent.New(context.Background(), subagent.Options{
			Client: client,
			Log:    log,
			Policy: policy,
			Base:   base,
			Tools:  newTools,
		})
		defer sup.Shutdown()
		reg.Register(sup.Tools()...)
		// Only the agent that can spawn is told what there is to choose from:
		// the supervisor above was handed the options before this line, so a
		// subagent's prompt does not carry a paragraph about a choice it will
		// never make.
		base.Models = models
	}

	a := agent.New(client, reg, state, log, base)
	a.Observe = render.Handle
	a.Approve = policy
	// Registered after the agent because it reads that agent's own budget.
	// /worktree rebuilds the set below and puts it back for the same reason.
	reg.Register(tools.NewContextStatus(a.ContextReport))

	// newSessionTools is what /worktree calls to move the work: it builds the
	// session's whole tool set against another root and takes the supervisor
	// with it, because a child that kept the old root would be working in a
	// directory its parent can no longer see.
	newSessionTools := func(r string) *tools.Registry {
		next := toolsIn(r)
		built := next(state, log)
		built.Register(tools.NewContextStatus(a.ContextReport))
		// The session's own agent keeps the tool that asks you a question,
		// wherever the work has moved to. It is registered here rather than
		// in the factory for the same reason the spawn tools are: a subagent
		// reports to its parent, and the parent is not a person to ask.
		built.Register(ask.Tool(asker.Ask))
		if t := tools.NewTodoWrite(todos); t != nil {
			built.Register(t)
		}
		if sup != nil {
			sup.SetTools(next)
			built.Register(sup.Tools()...)
		}
		return built
	}

	sessionDir := filepath.Join(root, agent.SessionDir)

	// workRoot is where the tools work; root stays where the state lives. The
	// two are the same until /worktree moves the work, or until a session that
	// had moved is resumed below.
	workRoot, worktree := root, ""
	if *resume != "" {
		prev, err := agent.ResolveSession(sessionDir, *resume)
		if err != nil {
			return err
		}
		// Sessions belong to the directory they were recorded in. The path
		// above already scopes them there; this catches a file that was copied
		// or linked in from another checkout, whose conversation is about
		// paths that do not exist here.
		if !prev.BelongsTo(root) {
			return fmt.Errorf("session %s belongs to %s, not %s — resume it from there", prev.ID, prev.Root, root)
		}
		a.Restore(prev)
		session = prev.ID
		notice("resumed session %s (%d messages, %d turns)", prev.ID, len(prev.Messages), prev.Turns)

		// A session whose work had moved into a worktree goes back to it, so
		// resuming picks up where it left off rather than in the main checkout
		// with a conversation about files that are not there. A tree since
		// deleted is said out loud and then ignored: the conversation is still
		// worth having, just here.
		if prev.Worktree != "" {
			if fi, statErr := os.Stat(prev.Worktree); statErr == nil && fi.IsDir() {
				workRoot, worktree = prev.Worktree, prev.Worktree
				a.SetTools(newSessionTools(workRoot))
				notice("working in the worktree %s", workRoot)
			} else {
				notice("the worktree %s is gone — working in %s instead", prev.Worktree, root)
			}
		}
	}

	// Lifecycle hooks, wired here rather than where the agent was built
	// because a resumed session only just took its id, and a state report
	// that named the id this run would otherwise have had is a report nobody
	// can resume from.
	//
	// What listens is a bystander: a multiplexer drawing a sidebar, a script
	// that wants to know a turn finished. Nothing here can change what the
	// agent does, and a hook that fails is logged rather than raised.
	hk := hooks.New(session)
	hk.OnError(func(p hooks.Payload, err error) {
		log.Log(metrics.Event{Type: "hook_error", Err: err.Error(),
			Extra: map[string]any{"event": string(p.Event)}})
	})
	if !*noHooks {
		// Herdr owns the terminal Codey is running in and shows, across every
		// project, which agent is working and which stopped to ask something.
		// Detect returns nil unless this process really is inside one of its
		// panes, so this line costs nothing to anyone who does not use it.
		if reporter := herdr.Detect(); reporter != nil {
			hk.Add(reporter)
			notice("herdr detected — reporting agent state to pane %s", reporter.Pane())
		}
		runner, hookErr := hooks.NewRunner(cfg.Hooks, root)
		if hookErr != nil {
			// A misspelt event name is worth stopping for. The alternative is
			// a hook that silently never fires, which is the one failure mode
			// nobody debugs because there is nothing to see.
			return fmt.Errorf("config: %w", hookErr)
		}
		if runner != nil {
			hk.Add(runner)
		}
	}
	// Order matters on the way out: the session ends, and only then does the
	// last handler get to release whatever it claimed.
	defer hk.Close()
	defer hk.Fire(context.Background(), hooks.Payload{Event: hooks.SessionEnd})

	a.Hooks = hk
	policy.Hooks = hk
	asker.Hooks = hk
	hk.Fire(context.Background(), hooks.Payload{Event: hooks.SessionStart})

	// Every tab is a session of its own, so opening one means building the
	// whole stack a session stands on a second time: its own state, its own
	// tool set, its own approval policy and its own subagent supervisor. Only
	// the client, the event log and what was discovered at startup are shared,
	// because those are about the run rather than about a conversation.
	//
	// What a tab may do is what this run may do. The flags and the config were
	// read once, above, and every line here reads from that same decision —
	// a second session is not a way around a limit the first one has.
	taken := map[string]bool{session: true}
	newSession := func(sessRoot string) (tui.SessionSpec, error) {
		id, err := freshSessionID(sessionDir, taken)
		if err != nil {
			return tui.SessionSpec{}, err
		}

		st := agent.NewState()
		mk := toolsIn(sessRoot)
		tabReg := mk(st, log)

		// A policy of its own: what you have permanently allowed in one
		// conversation was allowed for that conversation, and the prompt has
		// to come up in the tab whose agent is asking.
		pol := approval.New(nil)
		pol.YOLO = acceptAll

		var tabSup *subagent.Supervisor
		if !*noAgents {
			tabSup = subagent.New(context.Background(), subagent.Options{
				Client: client,
				Log:    log,
				Policy: pol,
				Base:   subBase,
				Tools:  mk,
			})
			tabReg.Register(tabSup.Tools()...)
		}

		a := agent.New(client, tabReg, st, log, base)
		a.Approve = pol
		tabReg.Register(tools.NewContextStatus(a.ContextReport))
		// The same handlers, stamped with this tab's session id: a watcher
		// outside the process sees one pane, and what it needs to know is
		// whether anything in it is working or stuck — which is a question
		// about all the tabs at once, not about whichever one is on screen.
		tabHooks := hk.For(id)
		a.Hooks = tabHooks
		pol.Hooks = tabHooks
		log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{
			"root": sessRoot, "model": client.Model(), "base_url": client.BaseURL, "tab": id,
		}})
		tabHooks.Fire(context.Background(), hooks.Payload{Event: hooks.SessionStart})

		return tui.SessionSpec{
			Agent:     a,
			Policy:    pol,
			SessionID: id,
			ToolNames: toolNames(tabReg),
			NewTools: func(r string) *tools.Registry {
				next := toolsIn(r)
				built := next(st, log)
				if tabSup != nil {
					tabSup.SetTools(next)
					built.Register(tabSup.Tools()...)
				}
				return built
			},
			Agents: agentsOrNil(tabSup),
			Close: func() {
				if tabSup != nil {
					tabSup.Shutdown()
				}
				tabHooks.Fire(context.Background(), hooks.Payload{Event: hooks.SessionEnd})
			},
		}, nil
	}

	if useTUI {
		// No signal handler here: Bubble Tea puts the terminal in raw mode, so
		// ctrl-c arrives as a key and the model decides what it means.
		return tui.Run(context.Background(), tui.Options{
			Agent:        a,
			Policy:       policy,
			Asker:        asker,
			Client:       client,
			Root:         workRoot,
			HomeRoot:     root,
			Worktree:     worktree,
			SessionDir:   sessionDir,
			SessionID:    session,
			ToolNames:    toolNames(reg),
			NewTools:     newSessionTools,
			SystemPrefix: systemPrefix,
			Agents:       agentsOrNil(sup),
			Verbose:      *verbose,
			YOLO:         acceptAll,
			Skills:       found,

			Version:       version,
			Effort:        effortSetting,
			ContextWindow: *contextWindow,

			Banner: banner,

			// A config that does not say the setup was finished is what
			// "first run" means, and it is the only thing that opens the
			// wizard unasked. A flag-only invocation is still a first run:
			// the flags say what this run does, not what the next one should
			// default to. A config we could not read is not a first run
			// either — the wizard's answers would be saved over settings that
			// are probably still in there.
			Config:   cfg,
			FirstRun: !cfg.Done() && cfgErr == nil,
			// What was delivered, not what was asked for: a missing search
			// command or -no-web leaves the mode at none however the config
			// reads, and the screens need the truth to know when to say that
			// a change needs a restart.
			WebSearch: effectiveWebMode(webMode, searcher, serverTools),
			// Applying the answers is the TUI's own job; persisting them is
			// the only part that belongs out here.
			SaveConfig: config.Save,

			// What Claude Code has, so the wizard knows whether there is a
			// question to ask and can say how many. The copy is a one-off act
			// carried out on the way out of the wizard, which is why it is a
			// callback rather than a setting: "I said yes in March" in the
			// config file would be a standing instruction to copy again on
			// every launch, over whatever had been edited since.
			ClaudeSkills: len(claudeFound),
			// What this session actually started under, which -no-skills can
			// make "off" whatever the file says. The panel needs the truth to
			// know when a change is one the next start will act on.
			ClaudeSkillsMode: claudeMode,
			// Likewise: what is actually attached to the edit tool, which is
			// nothing on a machine with no checker installed for anything in
			// the tree, whatever the file says.
			Diagnostics: diagMode,
			CopyClaudeSkills: func() (int, error) {
				copied, problems, err := skills.CopyClaude(home, root)
				for _, p := range problems {
					log.Log(metrics.Event{Type: "skill_copy_problem", Err: p.Error()})
				}
				return len(copied), err
			},

			// The setup wizard offers herdr's own skill — the one that
			// teaches the agent to drive herdr, rather than merely report to
			// it — but only on a machine that has herdr, and only when there
			// is a home directory to install into.
			HerdrPresent: home != "" && herdr.Present(),
			InstallHerdrSkill: func(ctx context.Context) (string, error) {
				return herdr.InstallSkill(ctx, home)
			},

			// What ctrl+t needs to open a second conversation.
			NewSession: newSession,
		})
	}

	ctx, stopSignals := interruptible(context.Background())
	defer stopSignals()

	if *prompt != "" {
		return once(ctx, a, render, sessionDir, session, root, client, *prompt)
	}

	fmt.Printf("codey · %s · %s\ntools: %s\ntype a prompt, ctrl-c to interrupt a turn, ctrl-d to exit\n\n",
		client.Model(), root, strings.Join(toolNames(reg), ", "))

	for {
		fmt.Print("> ")
		if !in.Scan() {
			fmt.Println()
			return in.Err()
		}
		line := strings.TrimSpace(in.Text())
		switch {
		case line == "":
			continue
		case line == "/exit", line == "/quit":
			return nil
		case line == "/allowed":
			allowed := policy.Remembered()
			if len(allowed) == 0 {
				fmt.Println("nothing is permanently allowed this session")
			}
			for _, a := range allowed {
				fmt.Println("  " + a)
			}
			continue
		}
		if err := once(ctx, a, render, sessionDir, session, root, client, line); err != nil {
			render.Error(err)
		}
		fmt.Println()
	}
}

// freshSessionID names a new session after the moment it was opened, and makes
// sure the name is free. A second-resolution timestamp is a fine name for one
// session and a collision waiting to happen for two opened in the same breath,
// which is exactly what a second tab is — so both the directory and the ids
// already handed out this run are checked, and a suffix settles it.
// metricsPath anchors -metrics to the working root unless it is already
// absolute. Joining an absolute path onto root would bury the log under
// <root>/home/..., inside the very tree the agent is working in.
func metricsPath(root, flag string) string {
	if filepath.IsAbs(flag) {
		return flag
	}
	return filepath.Join(root, flag)
}

func freshSessionID(dir string, taken map[string]bool) (string, error) {
	stamp := time.Now().UTC().Format("20060102T150405Z")
	for n := 1; n < 1000; n++ {
		id := stamp
		if n > 1 {
			id = fmt.Sprintf("%s-%d", stamp, n)
		}
		if taken[id] {
			continue
		}
		_, err := os.Stat(filepath.Join(dir, id+".json"))
		switch {
		case os.IsNotExist(err):
			taken[id] = true
			return id, nil
		case err != nil:
			return "", err
		}
	}
	return "", errors.New("no free session name — that is a thousand sessions in one second")
}

// once runs a single prompt, prints the session totals, and saves the session.
func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, sessionDir, id, root string, client *llm.Client, prompt string) error {
	turnCtx, endTurn := beginTurn(ctx)
	_, runErr := a.Run(turnCtx, prompt)
	endTurn()

	render.Flush()
	usage, turns := a.State().Usage()
	render.Usage(usage, turns, client.Model())

	if err := a.Save(sessionDir, id, root, "", client.Model()); err != nil {
		fmt.Fprintln(os.Stderr, "codey: saving session:", err)
	}
	if errors.Is(runErr, context.Canceled) {
		// An interrupted turn is not an error: the point is to leave the
		// session intact so the next message can correct course.
		fmt.Println("interrupted — the session is intact; say what to do differently")
		return nil
	}
	return runErr
}

// interrupt plumbing. The first ctrl-c cancels the turn in progress; a second
// within two seconds, or one with nothing running, exits.
var (
	turnCancel atomic.Pointer[context.CancelFunc]
	lastSignal atomic.Int64
)

func interruptible(parent context.Context) (context.Context, func()) {
	ch := make(chan os.Signal, 1)
	signal.Notify(ch, os.Interrupt)
	go func() {
		for range ch {
			now := time.Now()
			prev := lastSignal.Swap(now.UnixNano())
			cancel := turnCancel.Load()

			if cancel == nil || (prev != 0 && now.Sub(time.Unix(0, prev)) < 2*time.Second) {
				fmt.Fprintln(os.Stderr, "\ncodey: exiting")
				os.Exit(130)
			}
			fmt.Fprintln(os.Stderr, "\ncodey: interrupting this turn (ctrl-c again to exit)")
			(*cancel)()
		}
	}()
	return parent, func() { signal.Stop(ch) }
}

// beginTurn makes the running turn cancellable by the signal handler.
func beginTurn(parent context.Context) (context.Context, func()) {
	ctx, cancel := context.WithCancel(parent)
	turnCancel.Store(&cancel)
	return ctx, func() {
		turnCancel.Store(nil)
		lastSignal.Store(0)
		cancel()
	}
}

// errNoCredentials spells out every way to supply one, because "unauthorized"
// against a gateway that ignores the header is a confusing failure.
var errNoCredentials = errors.New(`no credentials. Supply one of:
    export ANTHROPIC_API_KEY=sk-ant-...      (sent as x-api-key)
    export ANTHROPIC_AUTH_TOKEN=...          (sent as Authorization: Bearer)
    -api-key / -auth-token                   (flags override the environment)
    log in with Claude Code                  (borrowed automatically, Anthropic endpoint only)
To use a non-Anthropic endpoint, set ANTHROPIC_BASE_URL or -base-url as well.`)

// resolveClaudeCode returns a token source borrowed from Claude Code's login,
// or nil when one should not be used. The second return is a line for the
// user: a borrowed credential is worth announcing, the same way a non-default
// endpoint is.
//
// Two conditions gate it. The client must have no credential already, because
// we never override what the user typed. And the endpoint must be Anthropic's,
// because a credential we found on disk is not one the user chose to send —
// forwarding it to a -base-url someone else operates would leak it to a third
// party because a file happened to exist.
func resolveClaudeCode(client *llm.Client, home string, disabled bool) (*claudecode.Source, string, error) {
	if disabled || client.HasCredentials() || !client.IsDefaultEndpoint() {
		return nil, "", nil
	}
	creds, err := claudecode.Load(home)
	if errors.Is(err, claudecode.ErrNotFound) {
		return nil, "", nil
	}
	if err != nil {
		return nil, "", err
	}
	note := fmt.Sprintf("using your Claude Code login (%s plan) from ~/%s", creds.SubscriptionType, claudecode.Filename)
	// home is handed on so a rotated refresh token can be written back. That
	// is the only circumstance in which this process writes to Claude Code's
	// file, and not writing then is what would break its login.
	//
	// The refresh client is deliberately not Codey's: that one allows
	// ten minutes for a model to answer, which is the wrong budget for a token
	// exchange running under a lock. NewSource supplies a short-timeout client.
	return claudecode.NewSource(creds, nil, home), note, nil
}

// interactive reports whether both ends of the terminal are a real one. A
// piped stdin or a redirected stdout means the full-screen UI would be wrong,
// and the line-based REPL still works.
func interactive() bool {
	for _, f := range []*os.File{os.Stdin, os.Stdout} {
		info, err := f.Stat()
		if err != nil || info.Mode()&os.ModeCharDevice == 0 {
			return false
		}
	}
	return true
}

// agentsOrNil keeps a nil *Supervisor from arriving as a non-nil interface
// holding a nil pointer, which is the way the sidebar would appear for a
// session that has no supervisor at all.
func agentsOrNil(sup *subagent.Supervisor) tui.Agents {
	if sup == nil {
		return nil
	}
	return sup
}

func toolNames(reg *tools.Registry) []string {
	var names []string
	for _, t := range reg.List() {
		names = append(names, t.Name())
	}
	return names
}