// 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"
"github.com/johannSo/codey/internal/agent"
"github.com/johannSo/codey/internal/approval"
"github.com/johannSo/codey/internal/ask"
"github.com/johannSo/codey/internal/ccsettings"
"github.com/johannSo/codey/internal/claudecode"
"github.com/johannSo/codey/internal/config"
"github.com/johannSo/codey/internal/diag"
"github.com/johannSo/codey/internal/herdr"
"github.com/johannSo/codey/internal/hooks"
"github.com/johannSo/codey/internal/llm"
"github.com/johannSo/codey/internal/mcp"
"github.com/johannSo/codey/internal/metrics"
"github.com/johannSo/codey/internal/serve"
"github.com/johannSo/codey/internal/skills"
"github.com/johannSo/codey/internal/statedir"
"github.com/johannSo/codey/internal/subagent"
"github.com/johannSo/codey/internal/tools"
"github.com/johannSo/codey/internal/tui"
"github.com/johannSo/codey/internal/ui"
"github.com/johannSo/codey/internal/websearch"
)
// version is what the TUI's welcome block and --version report. A release
// build stamps it at link time with -ldflags "-X main.version=..."; left
// empty, it is filled in from the module version or the default in
// version.go, which is also where to bump it.
var version string
// 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")
claudeCodeAuth := flag.Bool("claude-code-auth", false, "with no API key or token set, borrow the OAuth login in ~/"+claudecode.Filename+" for this run — your own machine only; it is issued to Claude Code (config: \"claude_code_auth\": \"on\")")
noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "never borrow the OAuth login in ~/"+claudecode.Filename+", whatever -claude-code-auth or the config say")
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")
noMCP := flag.Bool("no-mcp", false, "do not start the MCP servers in the config, so their tools are not offered")
trustProject := flag.Bool(strings.TrimPrefix(trustFlag, "-"), false, "use this project's .mcp.json servers and the allow rules in its .claude/settings.json for one run, without asking")
// 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 login may have to rebuild the
// client with one more option — see borrowClaudeCode in auth.go.
opts := []llm.Option{
llm.WithModel(config.Or(*model, cfg.Model)),
llm.WithBaseURL(*baseURL),
llm.WithAPIKey(*apiKey),
llm.WithAuthToken(*authToken),
}
home, err := os.UserHomeDir()
if err != nil {
// No home directory is not fatal: an explicit credential still works.
home = ""
}
// Whether borrowing Claude Code's login is a question for this run at all,
// decided before anything is borrowed: the wizard asks it, and the errors
// mention it, only where the answer would change something.
ccAskable := claudeCodeAskable(llm.New(opts...), home)
// The login is borrowed only when asked for, by flag or config; see
// auth.go for why that is not the default.
b, err := borrowClaudeCode(opts, home, claudeCodeAllowed(*noClaudeCodeAuth, *claudeCodeAuth, cfg))
if err != nil {
return err
}
// 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. It reads the same flags and
// config the session does, so it cannot report a login the agent run
// would then refuse to use.
if inv.Kind == invModels {
printModels(os.Stdout, b.client, ccAskable)
return nil
}
// Someone upgrading who relied on the old silent fallback is asked once,
// at a terminal, rather than met with an error they have no context for.
tty := inv.Kind == invNormal && *prompt == "" && interactive()
if !b.client.HasCredentials() && shouldAskClaudeCode(cfg, cfgErr, ccAskable, *noClaudeCodeAuth, tty) {
var yes bool
yes, cfg = askClaudeCode(os.Stdin, os.Stderr, cfg, config.Save)
if yes {
if b, err = borrowClaudeCode(opts, home, true); err != nil {
return err
}
}
}
if !b.client.HasCredentials() {
return noCredentialsError(ccAskable, configPathLabel(home))
}
client := b.client
// 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.
clientOpts := b.opts
authNote := b.note
// 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.
ccSource := b.source
// 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
// ccInForce is what this session is running on, for the settings panel.
// It is on or nothing: a run where the question arose and the login was
// not borrowed has no credential and has already stopped above, and where
// it did not arise — a key was given, or the endpoint is not Anthropic's —
// the panel reads the file rather than claim a restart would change
// anything.
var ccInForce string
if ccSource != nil {
systemPrefix = agent.ClaudeCodeIdentity
ccInForce = config.ClaudeCodeAuthOn
}
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 MCP servers the config names, started once for the session and
// shared by every agent in it, subagents included, as Claude Code shares
// them: a server is a process, and one per agent would be four copies of
// the same database connection. They run in the root Codey was started
// in and stay there across /worktree, since a server's working directory
// is fixed when it starts. A server that will not start is reported and
// left out; the session goes on without its tools.
// Claude Code's configuration for this root: its MCP servers merged under
// Codey's own, and its permission rules. The project's committed files
// are used only if this checkout is trusted, which is asked here — on the
// terminal, before the TUI owns it — when there is a person to ask and
// nothing has been decided about these exact definitions. -p and agent
// runs are not asked; see ccconfig.go.
ccOpts := ccOptions{
Home: home,
Root: root,
Own: cfg.MCPServers,
Trust: *trustProject,
NoMCP: *noMCP,
Notice: notice,
}
if cfgPath, err := config.Path(); err == nil {
ccOpts.Store = ccsettings.DefaultTrustStore(cfgPath)
}
if inv.Kind == invNormal && *prompt == "" && interactive() {
ccOpts.Ask = askTrustOnTerminal(os.Stdin, os.Stderr)
}
cc := loadClaudeCode(ccOpts)
instructions := instructionsFor(home, notice)
var mcpTools []tools.Tool
if len(cc.MCP) > 0 && !*noMCP {
mcpCfg := cfg
mcpCfg.MCPServers = cc.MCP
ts, closer, errs := mcp.Connect(context.Background(), root, mcpCfg)
defer closer.Close()
for _, err := range errs {
notice("%v", err)
}
mcpTools = ts
}
// 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())
}
reg.Register(mcpTools...)
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)
policy.Rules = cc.Rules
// 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,
Instructions: instructions(root),
}
// 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,
Rules: rulesFor(ccOpts, root, cc),
Instructions: instructions,
})
}
// 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
pol.Rules = cc.Rules
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,
// Whether to ask about borrowing Claude Code's login: only where
// nothing else supplied a credential and there is a login to
// borrow. And what this session is actually running on, so the
// panel knows when a change waits for the next start.
OfferClaudeCodeAuth: ccAskable,
ClaudeCodeAuth: ccInForce,
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)
}
if rules := policy.RuleLines(); len(rules) > 0 {
fmt.Println("from Claude Code's settings:")
for _, r := range rules {
fmt.Println(" " + r)
}
}
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()
}
}
// 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
}