// 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 // /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 }