From 99f2f4e15cdba56f15b884112abab64db01b26ae Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 7 Nov 2026 14:15:04 -0500 Subject: [PATCH 01/16] POC: hand a task to the person's own Claude Code as a crew worker Adds the claude program (internal/claudecode): codeaf starts the installed, signed-in claude CLI in the run's worktree with stream-json output, turns its tool calls into steps and its result into the ending. codeaf holds no Claude credential and sends nothing to Anthropic itself. --- internal/claudecode/claudecode.go | 337 ++++++++++++++++++++++ internal/claudecode/claudecode_test.go | 69 +++++ internal/delegate/builtin/carried_unix.go | 6 +- internal/manual/chat/claude.md | 30 ++ internal/manual/chat/delegates.md | 8 +- 5 files changed, 444 insertions(+), 6 deletions(-) create mode 100644 internal/claudecode/claudecode.go create mode 100644 internal/claudecode/claudecode_test.go create mode 100644 internal/manual/chat/claude.md diff --git a/internal/claudecode/claudecode.go b/internal/claudecode/claudecode.go new file mode 100644 index 0000000000..cc8f9d6133 --- /dev/null +++ b/internal/claudecode/claudecode.go @@ -0,0 +1,337 @@ +// Package claudecode is the program that hands a whole task to the person's own +// Claude Code: `codeaf claude ` in a shell, `/claude ` in the chat, +// and a worker a task can name in `via`. +// +// CLAUDE IS SEATED AS AN AGENT, NEVER AS A MODEL. codeaf reaches OpenRouter and +// a ChatGPT plan as model services: it sends each request itself, and its own +// loop decides what happens next. Anthropic does not offer a Claude plan that +// way to another program, so this package does not try to. It starts the +// `claude` program the person installed and signed in to themselves, in the +// folder codeaf cut for the run, and Claude Code runs its own loop with its own +// tools on its own account. codeaf holds no Claude credential, sends no request +// to Anthropic, and reads only what the program prints: its stream-json events, +// which become this run's steps and its ending. +// +// That is the same arrangement an editor makes when it drives an agent over the +// Agent Client Protocol, and it is why the program is a delegate (a child that +// streams records and ends with one terminal record) rather than a model +// source: the seam already existed for senior-dev, and Claude Code is one more +// program on the far side of it. +package claudecode + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/env" +) + +// Name is the program's one word: the slash command, the shell verb and the +// manual page. +const Name = "claude" + +// EnvBinary names the Claude Code program to start in place of the first +// `claude` on PATH, for a machine whose `claude` is a wrapper or a shell +// function that a child process cannot reach. +const EnvBinary = "CODEAF_CLAUDE_BIN" + +// stageWork is the one stage a run reports. Claude Code plans, edits and +// checks inside its own loop, so codeaf has no phases of its own to name. +const stageWork = "work" + +// guide is what the conversation's model reads about the program on every +// turn, so it is held to delegate.GuideMax. +const guide = "Hands the task to the person's own Claude Code, which works in the folder with its own tools on " + + "their Claude plan and leaves its changes on the run's branch. Use it when they ask for Claude, or name " + + "Claude Code, to do the work. Its brief is the whole task in plain words." + +// Program is the definition codeaf carries (internal/delegate/builtin). +var Program = delegate.Delegate{ + Name: Name, + Summary: "hand a task to your own signed-in Claude Code", + Guide: guide, + Lands: delegate.LandsTree, + // A PERSON'S CREW SEAT IS HEARD WHEN IT IS A CLAUDE MODEL. A crew whose + // working seat is `anthropic/claude-opus-…` asked for Opus, and Claude Code + // answers to the family word; any other vendor's model is not something + // Claude Code can run, so it is left to Claude Code's own default. + CrewFlags: crewFlags, + StageWords: map[string]string{stageWork: "working in Claude Code"}, + Unattended: delegate.Ceilings{CostUSD: 10, Hours: 2}, + Default: "run", + Page: Name, + Commands: []delegate.Command{{Name: "run", Usage: "[flags] -- ", Summary: "run the brief in Claude Code", Bind: bindRun}}, +} + +// crewFlags turns the crew's working seat into Claude Code's model word. +func crewFlags(crew delegate.Crew) []string { + seat := crew.Hands + if len(crew.Asked) > 0 { + seat = crew.Asked[0] + } + if family := Family(seat); family != "" { + return []string{"--claude-model", family} + } + return nil +} + +// Family answers the Claude Code model word (`opus`, `sonnet`, `haiku`) for a +// model id from any service's spelling of it, or "" for a model that is not +// Claude. +func Family(model string) string { + lower := strings.ToLower(model) + if !strings.Contains(lower, "claude") && !strings.HasPrefix(lower, "anthropic/") { + return "" + } + for _, family := range []string{"opus", "sonnet", "haiku"} { + if strings.Contains(lower, family) { + return family + } + } + return "" +} + +func bindRun(fs *flag.FlagSet) delegate.Body { + model := fs.String("claude-model", "", "Claude Code model: opus, sonnet, haiku or a full name") + return func(ctx context.Context, host delegate.Host, args []string) error { + brief := strings.TrimSpace(strings.Join(args, " ")) + host.Hello([]string{stageWork}) + if brief == "" { + host.Terminal(delegate.Ending{Status: delegate.StatusFail, Message: "there was no brief to hand Claude Code"}) + return nil + } + binary, err := Binary() + if err != nil { + host.Terminal(delegate.Ending{Status: delegate.StatusFail, Message: err.Error()}) + return nil + } + ceilings := host.Ceilings() + if ceilings.Hours > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(ceilings.Hours*float64(time.Hour))) + defer cancel() + } + cmd := exec.CommandContext(ctx, binary, Args(brief, *model, ceilings)...) + cmd.Dir = host.Workspace() + cmd.Stderr = os.Stderr + out, err := cmd.StdoutPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + host.Terminal(delegate.Ending{Status: delegate.StatusFail, Message: "Claude Code did not start: " + err.Error()}) + return nil + } + end := Translate(out, host) + waitErr := cmd.Wait() + if !end.seen { + message := "Claude Code ended without saying how it went" + status := delegate.StatusCrashed + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + message, status = "Claude Code was stopped at the time ceiling", delegate.StatusBudget + } else if waitErr != nil { + message += ": " + waitErr.Error() + } + host.Terminal(delegate.Ending{Status: status, Message: message}) + return nil + } + host.Terminal(end.Ending) + return nil + } +} + +// Binary finds the Claude Code program: [EnvBinary] when it is set, else the +// first `claude` on PATH. The refusal says how to make it findable, because a +// person who has Claude Code installed and reads "not found" needs to know +// which of the two codeaf looked at. +func Binary() (string, error) { + if named := strings.TrimSpace(env.Get(EnvBinary)); named != "" { + if _, err := os.Stat(named); err != nil { + return "", fmt.Errorf("%s names %s, which is not there", EnvBinary, named) + } + return named, nil + } + path, err := exec.LookPath("claude") + if err != nil { + return "", fmt.Errorf("Claude Code is not installed here: install it, run `claude` once to sign in, or set %s to its path", EnvBinary) + } + return path, nil +} + +// Args is the command line Claude Code is started with. It is a pure function +// so a test can hold the flags without starting anything. +// +// THE RUN IS UNATTENDED, SO NOTHING MAY WAIT ON A PERMISSION PROMPT. codeaf +// starts a tree program on a branch it cut for the run, which is the same trust +// senior-dev's own shell gets; acceptEdits plus the named tools lets Claude Code +// edit and run commands without stopping to ask a person who is not there. +func Args(brief, model string, ceilings delegate.Ceilings) []string { + args := []string{ + "-p", brief, + "--output-format", "stream-json", "--verbose", + "--permission-mode", "acceptEdits", + "--allowedTools", "Read Edit Write Glob Grep Bash TodoWrite", + "--append-system-prompt", "You are running unattended for codeaf. Nobody can answer a question: " + + "decide, do the work, check it, and end with a short account of what you changed and how you checked it.", + } + if model = strings.TrimSpace(model); model != "" { + args = append(args, "--model", model) + } + if ceilings.CostUSD > 0 { + args = append(args, "--max-budget-usd", fmt.Sprintf("%.2f", ceilings.CostUSD)) + } + return args +} + +// ending is what the stream said at its end, and whether it said it at all. +type ending struct { + delegate.Ending + seen bool +} + +// event is the part of a Claude Code stream-json line this package reads. +type event struct { + Type string `json:"type"` + Subtype string `json:"subtype"` + Model string `json:"model"` + APIKeySource string `json:"apiKeySource"` + IsError bool `json:"is_error"` + Result string `json:"result"` + TotalCostUSD float64 `json:"total_cost_usd"` + NumTurns int `json:"num_turns"` + Message struct { + Content json.RawMessage `json:"content"` + } `json:"message"` +} + +// block is one item of a message's content. +type block struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` + ToolUseID string `json:"tool_use_id"` + Content json.RawMessage `json:"content"` + IsError bool `json:"is_error"` +} + +// Translate reads Claude Code's stream-json from r and reports it through host: +// a stage when the session starts, a step for every tool call once its result +// arrives, and the ending the result line says. It never writes the terminal +// record itself; the caller does, once, whatever the stream said. +func Translate(r io.Reader, host delegate.Host) ending { + var end ending + pending := map[string]block{} + subscription := false + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 64<<10), 16<<20) + for scanner.Scan() { + var ev event + if json.Unmarshal(scanner.Bytes(), &ev) != nil { + continue + } + switch ev.Type { + case "system": + if ev.Subtype != "init" { + continue + } + // A SESSION SIGNED IN TO A CLAUDE PLAN SPENDS NO MONEY OF CODEAF'S + // LEDGER. Claude Code reports what the run WOULD have cost at API + // prices either way; on a plan that figure is not a charge, so it + // is kept as a fact and never booked as spend. + subscription = ev.APIKeySource == "" || ev.APIKeySource == "none" + data, _ := json.Marshal(map[string]any{"models": []string{ev.Model}, "doing": "working"}) + host.Stage(delegate.StageRecord{Stage: stageWork, Status: "running", Data: data}) + case "assistant": + for _, b := range blocks(ev.Message.Content) { + if b.Type == "tool_use" { + pending[b.ID] = b + } + } + case "user": + for _, b := range blocks(ev.Message.Content) { + if b.Type != "tool_result" { + continue + } + call, ok := pending[b.ToolUseID] + if !ok { + continue + } + delete(pending, b.ToolUseID) + step := delegate.StepRecord{Tool: call.Name, Step: stageWork, Command: describe(call), Observation: resultText(b.Content)} + if call.Name == "Bash" { + exit := 0 + if b.IsError { + exit = 1 + } + step.Exit = &exit + } + host.Step(step) + } + case "result": + end.seen = true + end.Deliverable = strings.TrimSpace(ev.Result) + end.Claim = end.Deliverable + end.Extra = map[string]any{"claude_turns": ev.NumTurns, "claude_list_price_usd": ev.TotalCostUSD} + if !subscription { + end.CostUSD = ev.TotalCostUSD + } + switch { + case ev.Subtype == "error_max_budget_usd": + end.Status, end.Message = delegate.StatusBudget, "Claude Code stopped at the cost ceiling" + case ev.IsError || ev.Subtype != "success": + end.Status, end.Message = delegate.StatusFail, "Claude Code ended with "+strings.ReplaceAll(ev.Subtype, "_", " ") + default: + end.Status, end.Message = delegate.StatusPass, "Claude Code finished" + } + } + } + return end +} + +func blocks(raw json.RawMessage) []block { + var out []block + _ = json.Unmarshal(raw, &out) + return out +} + +// describe is the one line a step shows for a tool call: the command for a +// shell call, the file for a file tool, the pattern for a search. +func describe(call block) string { + var in map[string]any + _ = json.Unmarshal(call.Input, &in) + for _, key := range []string{"command", "file_path", "pattern", "path", "url"} { + if value, ok := in[key].(string); ok && value != "" { + return value + } + } + return call.Name +} + +// resultText flattens a tool result, which is a string or a list of blocks. +func resultText(raw json.RawMessage) string { + var text string + if json.Unmarshal(raw, &text) == nil { + return text + } + var parts []struct { + Type string `json:"type"` + Text string `json:"text"` + } + _ = json.Unmarshal(raw, &parts) + var b strings.Builder + for _, part := range parts { + b.WriteString(part.Text) + } + return b.String() +} diff --git a/internal/claudecode/claudecode_test.go b/internal/claudecode/claudecode_test.go new file mode 100644 index 0000000000..d3a6304329 --- /dev/null +++ b/internal/claudecode/claudecode_test.go @@ -0,0 +1,69 @@ +package claudecode + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +type fakeHost struct { + stages []delegate.StageRecord + steps []delegate.StepRecord +} + +func (h *fakeHost) Workspace() string { return "." } +func (h *fakeHost) Ceilings() delegate.Ceilings { return delegate.Ceilings{} } +func (h *fakeHost) Hello([]string) {} +func (h *fakeHost) Stage(s delegate.StageRecord) { h.stages = append(h.stages, s) } +func (h *fakeHost) Step(s delegate.StepRecord) { h.steps = append(h.steps, s) } +func (h *fakeHost) Terminal(delegate.Ending) {} +func (h *fakeHost) Models() delegate.ModelAPI { return delegate.ModelAPI{} } + +// The stream below is the shape Claude Code 2.1 printed for a real run, cut +// to the lines this package reads. +const stream = `{"type":"system","subtype":"hook_started"} +{"type":"system","subtype":"init","model":"claude-haiku-4-5","apiKeySource":"none"} +{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"echo hi > hi.txt"}}]}} +{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}} +{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t2","name":"Read","input":{"file_path":"hi.txt"}}]}} +{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t2","content":[{"type":"text","text":"1\thi"}]}]}} +{"type":"result","subtype":"success","is_error":false,"result":"done","total_cost_usd":0.007,"num_turns":3} +` + +func TestAPlanRunBecomesStepsAndAnEndingThatBooksNoSpend(t *testing.T) { + host := &fakeHost{} + end := Translate(strings.NewReader(stream), host) + if !end.seen || end.Status != delegate.StatusPass || end.Deliverable != "done" { + t.Fatalf("ending = %+v", end) + } + if end.CostUSD != 0 { + t.Fatalf("a Claude plan run booked $%v of spend", end.CostUSD) + } + if len(host.stages) != 1 || len(host.steps) != 2 { + t.Fatalf("stages %d steps %d", len(host.stages), len(host.steps)) + } + if host.steps[0].Command != "echo hi > hi.txt" || *host.steps[0].Exit != 0 || host.steps[1].Observation != "1\thi" { + t.Fatalf("steps = %+v", host.steps) + } +} + +func TestAnAPIKeyRunBooksClaudeCodesOwnFigure(t *testing.T) { + keyed := strings.Replace(stream, `"apiKeySource":"none"`, `"apiKeySource":"ANTHROPIC_API_KEY"`, 1) + if end := Translate(strings.NewReader(keyed), &fakeHost{}); end.CostUSD != 0.007 { + t.Fatalf("cost = %v", end.CostUSD) + } +} + +func TestTheCrewSeatIsHeardOnlyWhenItIsClaude(t *testing.T) { + for model, want := range map[string]string{ + "anthropic/claude-opus-4.5": "opus", "claude-sonnet-4-5": "sonnet", "openai/gpt-5": "", "vendor/hands": "", + } { + if got := Family(model); got != want { + t.Errorf("Family(%q) = %q, want %q", model, got, want) + } + } + if err := Program.Validate(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/delegate/builtin/carried_unix.go b/internal/delegate/builtin/carried_unix.go index e741a68ba8..4e2d832a00 100644 --- a/internal/delegate/builtin/carried_unix.go +++ b/internal/delegate/builtin/carried_unix.go @@ -3,6 +3,7 @@ package builtin import ( + "github.com/Agent-Field/codeaf/internal/claudecode" "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/praf" "github.com/Agent-Field/codeaf/internal/secaf" @@ -11,5 +12,6 @@ import ( // carried is every program this build carries on a unix: senior-dev, whose // engine lives in internal/seniordev; sec, sec-af's auditor, in -// internal/secaf; and review, pr-af's reviewer, in internal/praf. -var carried = []delegate.Delegate{seniordev.Program, secaf.Program, praf.Program} +// internal/secaf; review, pr-af's reviewer, in internal/praf; and claude, +// which hands a task to the person's own Claude Code, in internal/claudecode. +var carried = []delegate.Delegate{seniordev.Program, secaf.Program, praf.Program, claudecode.Program} diff --git a/internal/manual/chat/claude.md b/internal/manual/chat/claude.md new file mode 100644 index 0000000000..5f9eb7f589 --- /dev/null +++ b/internal/manual/chat/claude.md @@ -0,0 +1,30 @@ +# claude — hand a task to your own Claude Code + +## What /claude is — use Claude Code, use my Claude subscription, Claude plan, Claude as an agent + +`claude` is a program codeaf carries that hands a whole task to the Claude Code you installed and signed in to yourself. codeaf starts `claude` in the task's folder, Claude Code plans and edits with its own tools, and codeaf shows each tool call as a step and its last message as the answer. + +**Claude is a worker here, not a model.** codeaf cannot send Claude requests on your Claude plan the way it sends them to OpenRouter or a ChatGPT plan, and it holds no Claude credential. Claude Code runs on whatever account you signed it in to. Anthropic's terms for that account apply to the work. + +## How do I use Claude Code from codeaf — /claude, codeaf claude, via claude + +``` +/claude fix the flaky retry test in internal/provider +codeaf claude add a --json flag to the status command +``` + +Asking in words works too: "have Claude Code do this" makes the chat propose the task with `via: "claude"`. When your crew's working seat is a Claude model, Claude Code runs that family (`opus`, `sonnet` or `haiku`); any other model leaves Claude Code on its own default. + +## Where the work lands and what it costs — branch, spending, ceiling + +It edits files like senior-dev does: on a branch codeaf cut for the run, with whatever it left uncommitted committed there when it ends. A run starts under up to $10.00 and two hours unless the conversation set less. The cost ceiling is passed to Claude Code as `--max-budget-usd`; the hours are a hard stop. + +On a Claude plan, nothing is added to codeaf's spending, because the plan is not billed per call. Signed in with an API key instead, Claude Code's own figure is added. + +## Why /claude says Claude Code is not installed — CODEAF_CLAUDE_BIN, not found + +The run ends at once with `Claude Code is not installed here: install it, run `claude` once to sign in, or set CODEAF_CLAUDE_BIN to its path` when no `claude` program is on PATH. A `claude` that is only a shell alias or function does not count, because codeaf starts a program, not a shell. Set `CODEAF_CLAUDE_BIN` to the real file. + +## What /claude cannot do — questions, permissions, messages + +It cannot ask you anything while it works: it runs unattended, with edits and shell commands allowed and every other permission prompt refused. It does not read messages sent to its task while it runs. It does not use codeaf's models, skills or memory; it uses Claude Code's own. diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 82c53a86da..681009d3b4 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -2,16 +2,16 @@ ## What is a delegate -A delegate is a program built into codeaf that takes a whole task and works on it inside your conversation's time and spending limits. The delegates are senior-dev, sec and review. Give one a brief with its slash command or `codeaf `. Each delegate's own manual page describes its work and limits. +A delegate is a program built into codeaf that takes a whole task and works on it inside your conversation's time and spending limits. The delegates are senior-dev, sec, review and claude. Give one a brief with its slash command or `codeaf `. Each delegate's own manual page describes its work and limits. ## What a program codeaf carries is — a delegate, another coding agent, an agent of its own for a whole task, which programs there are codeaf carries programs of its own that take one whole task and do it alone, for as -long as an hour or more. People call them delegates. There are three: **senior-dev**, a +long as an hour or more. People call them delegates. There are four: **senior-dev**, a coding agent for one large, well-specified change; **sec**, which audits the repository or its changes for security problems and changes nothing; and **review**, -which reviews a GitHub pull request and changes and posts nothing (each one's own page -says how). You hand one a task the way codeaf hands a task to its own worker. A program +which reviews a GitHub pull request and changes and posts nothing; and **claude**, which +hands the task to your own signed-in Claude Code (each one's own page says how). You hand one a task the way codeaf hands a task to its own worker. A program that edits code, as senior-dev does, works in a private copy of its own in a git repository and leaves its work on a branch of its own when it ends; in a folder with no git history it works in the folder itself. One that answers, as sec and review do, From c075e4e9d7a75376a874c87c5a24b701e9636b4f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 7 Nov 2026 14:33:40 -0500 Subject: [PATCH 02/16] claude: a live Claude Code session per task, as T3 Code holds one internal/claudeagent speaks the Agent SDK's control protocol to the person's own claude CLI: stream-json both ways, initialize, can_use_tool, interrupt, set_model, resume. The claude program now hears the task's messages mid-run, interrupts on stop, refuses pushes and publishes even over a user hook, and ends when Claude Code has answered everything and gone quiet. --- internal/claudeagent/claudeagent.go | 404 ++++++++++++++++++++++++ internal/claudeagent/live_test.go | 78 +++++ internal/claudecode/claudecode.go | 414 ++++++++++++++++--------- internal/claudecode/claudecode_test.go | 26 +- internal/claudecode/fake_test.go | 228 ++++++++++++++ internal/manual/chat/claude.md | 12 +- 6 files changed, 1004 insertions(+), 158 deletions(-) create mode 100644 internal/claudeagent/claudeagent.go create mode 100644 internal/claudeagent/live_test.go create mode 100644 internal/claudecode/fake_test.go diff --git a/internal/claudeagent/claudeagent.go b/internal/claudeagent/claudeagent.go new file mode 100644 index 0000000000..ca7353c368 --- /dev/null +++ b/internal/claudeagent/claudeagent.go @@ -0,0 +1,404 @@ +// Package claudeagent holds a live conversation with the person's own Claude +// Code, the way an editor that embeds Claude Code does: one long-lived `claude` +// process speaking stream-json on both pipes, with the Agent SDK's control +// protocol on top of it. +// +// THIS IS THE ARRANGEMENT THE AGENT SDK ITSELF MAKES. The SDK does not call +// Anthropic; it starts the CLI with `--input-format stream-json +// --output-format stream-json --permission-prompt-tool stdio`, sends it an +// `initialize` control request, writes each person's message as a `user` line, +// answers the CLI's `can_use_tool` requests, and sends `interrupt` to stop a +// turn. T3 Code runs Claude Code exactly so (it sets CLAUDE_CODE_ENTRYPOINT to +// name itself). This package is that client in Go, so codeaf can seat Claude +// Code anywhere it seats a worker without a Node runtime and without holding a +// Claude credential: the CLI signs in on its own account. +// +// A SESSION IS MANY TURNS. A message sent while a turn is running is written at +// once and Claude Code takes it in; a turn ends with one `result` event, and +// the process stays up for the next message until [Session.Close]. +package claudeagent + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + "time" +) + +// Entrypoint is the name codeaf gives itself to Claude Code, which records it +// beside the session the way it records `t3-code` or `sdk-ts`. +const Entrypoint = "codeaf" + +// Options is how a session is started. +type Options struct { + // Binary is the `claude` program to start. + Binary string + // Dir is the folder Claude Code works in. + Dir string + // Model is a model word or name (`opus`, `claude-sonnet-4-5`); empty is + // Claude Code's own default. + Model string + // PermissionMode is Claude Code's mode: default, acceptEdits, plan or + // bypassPermissions. Empty is default, where every tool use that is not + // already allowed is asked of [Options.CanUseTool]. + PermissionMode string + // Resume continues an earlier Claude Code session by its id. + Resume string + // AppendSystemPrompt is added after Claude Code's own system prompt. + AppendSystemPrompt string + // Disallowed is Claude Code deny rules (`Bash(git push:*)`), which hold + // even over a hook that allows. + Disallowed []string + // MaxBudgetUSD stops the session at that much list-price spend. + MaxBudgetUSD float64 + // CanUseTool answers a permission request. Nil allows everything, which + // only a caller that set PermissionMode to bypassPermissions should want. + CanUseTool func(ctx context.Context, req PermissionRequest) Decision + // OnEvent is handed every event the CLI prints, in order, on the reading + // goroutine. It must not block. + OnEvent func(Event) + // Stderr receives the CLI's own diagnostics; nil discards them. + Stderr io.Writer + // Env is added to the inherited environment. + Env []string +} + +// PermissionRequest is Claude Code asking whether a tool may run. +type PermissionRequest struct { + ToolName string `json:"tool_name"` + Input json.RawMessage `json:"input"` + ToolUseID string `json:"tool_use_id"` + Title string `json:"title"` + Reason string `json:"decision_reason"` +} + +// Decision is the answer to a [PermissionRequest]. +type Decision struct { + Allow bool + // Message tells Claude Code why a tool was refused, so it can choose + // another road instead of asking again. + Message string +} + +// Event is one line Claude Code printed. Raw is the whole line; the named +// fields are the ones a caller needs to route it. +type Event struct { + Type string `json:"type"` + Subtype string `json:"subtype"` + SessionID string `json:"session_id"` + Raw json.RawMessage +} + +// Session is one live Claude Code process. +type Session struct { + opts Options + cmd *exec.Cmd + stdin io.WriteCloser + + writeMu sync.Mutex + mu sync.Mutex + waiting map[string]chan controlReply + session string + closed bool + + done chan struct{} + err error +} + +type controlReply struct { + ok bool + response json.RawMessage + err string +} + +// Start starts Claude Code and completes the SDK's initialize handshake. +func Start(ctx context.Context, opts Options) (*Session, error) { + if opts.Binary == "" { + return nil, errors.New("no Claude Code program was named") + } + cmd := exec.Command(opts.Binary, Args(opts)...) + cmd.Dir = opts.Dir + cmd.Env = append(append(os.Environ(), "CLAUDE_CODE_ENTRYPOINT="+Entrypoint), opts.Env...) + if opts.Stderr != nil { + cmd.Stderr = opts.Stderr + } + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("Claude Code did not start: %w", err) + } + s := &Session{opts: opts, cmd: cmd, stdin: stdin, waiting: map[string]chan controlReply{}, done: make(chan struct{})} + go s.read(stdout) + initCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + if _, err := s.control(initCtx, map[string]any{"subtype": "initialize"}); err != nil { + s.kill() + return nil, fmt.Errorf("Claude Code did not answer its handshake: %w", err) + } + return s, nil +} + +// Args is the command line a session starts Claude Code with. It is the line +// the Agent SDK builds, less the flags codeaf has no use for. +func Args(opts Options) []string { + args := []string{ + "--output-format", "stream-json", "--verbose", + "--input-format", "stream-json", + "--permission-prompt-tool", "stdio", + } + if opts.Model != "" { + args = append(args, "--model", opts.Model) + } + if opts.PermissionMode != "" { + args = append(args, "--permission-mode", opts.PermissionMode) + if opts.PermissionMode == "bypassPermissions" { + args = append(args, "--allow-dangerously-skip-permissions") + } + } + if opts.Resume != "" { + args = append(args, "--resume", opts.Resume) + } + if opts.AppendSystemPrompt != "" { + args = append(args, "--append-system-prompt", opts.AppendSystemPrompt) + } + if len(opts.Disallowed) > 0 { + args = append(args, "--disallowedTools", strings.Join(opts.Disallowed, ",")) + } + if opts.MaxBudgetUSD > 0 { + args = append(args, "--max-budget-usd", fmt.Sprintf("%.2f", opts.MaxBudgetUSD)) + } + return args +} + +// Send writes one person's message. It may be sent while a turn is running; +// Claude Code takes it in at its next chance. +func (s *Session) Send(text string) error { + return s.write(map[string]any{ + "type": "user", + "session_id": "", + "parent_tool_use_id": nil, + "message": map[string]any{"role": "user", "content": []map[string]any{{"type": "text", "text": text}}}, + }) +} + +// Interrupt stops the running turn. The session stays up for another message. +func (s *Session) Interrupt(ctx context.Context) error { + _, err := s.control(ctx, map[string]any{"subtype": "interrupt"}) + return err +} + +// SetModel switches the model for the turns after this one. +func (s *Session) SetModel(ctx context.Context, model string) error { + _, err := s.control(ctx, map[string]any{"subtype": "set_model", "model": model}) + return err +} + +// SetPermissionMode changes how tool use is asked about from now on. +func (s *Session) SetPermissionMode(ctx context.Context, mode string) error { + _, err := s.control(ctx, map[string]any{"subtype": "set_permission_mode", "mode": mode}) + return err +} + +// SessionID is Claude Code's id for this conversation, which [Options.Resume] +// takes to continue it later. It is empty until the CLI has said it. +func (s *Session) SessionID() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.session +} + +// Done is closed when the process has ended. +func (s *Session) Done() <-chan struct{} { return s.done } + +// Err is why the process ended, once [Session.Done] is closed. +func (s *Session) Err() error { <-s.done; return s.err } + +// Close ends the session the polite way: its input is closed, which Claude +// Code reads as the end of the conversation, and it is killed only if it has +// not gone within the grace. +func (s *Session) Close(grace time.Duration) error { + s.mu.Lock() + already := s.closed + s.closed = true + s.mu.Unlock() + if !already { + _ = s.stdin.Close() + } + select { + case <-s.done: + case <-time.After(grace): + s.kill() + <-s.done + } + return s.err +} + +func (s *Session) kill() { + if s.cmd.Process != nil { + _ = s.cmd.Process.Kill() + } +} + +func (s *Session) control(ctx context.Context, request map[string]any) (json.RawMessage, error) { + id := newID() + reply := make(chan controlReply, 1) + s.mu.Lock() + s.waiting[id] = reply + s.mu.Unlock() + defer func() { s.mu.Lock(); delete(s.waiting, id); s.mu.Unlock() }() + if err := s.write(map[string]any{"type": "control_request", "request_id": id, "request": request}); err != nil { + return nil, err + } + select { + case r := <-reply: + if !r.ok { + return nil, errors.New(r.err) + } + return r.response, nil + case <-s.done: + return nil, errors.New("Claude Code ended") + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (s *Session) write(v any) error { + line, err := json.Marshal(v) + if err != nil { + return err + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + _, err = s.stdin.Write(append(line, '\n')) + return err +} + +// incoming is the shape of every line, read once to route it. +type incoming struct { + Type string `json:"type"` + Subtype string `json:"subtype"` + SessionID string `json:"session_id"` + RequestID string `json:"request_id"` + Request json.RawMessage `json:"request"` + Response struct { + Subtype string `json:"subtype"` + RequestID string `json:"request_id"` + Response json.RawMessage `json:"response"` + Error string `json:"error"` + } `json:"response"` +} + +func (s *Session) read(stdout io.Reader) { + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64<<10), 32<<20) + for scanner.Scan() { + line := append([]byte(nil), scanner.Bytes()...) + var in incoming + if json.Unmarshal(line, &in) != nil { + continue + } + switch in.Type { + case "control_response": + s.mu.Lock() + reply, ok := s.waiting[in.Response.RequestID] + s.mu.Unlock() + if ok { + reply <- controlReply{ok: in.Response.Subtype == "success", response: in.Response.Response, err: in.Response.Error} + } + continue + case "control_request": + go s.answer(in.RequestID, in.Request) + continue + case "control_cancel_request": + continue + } + if in.SessionID != "" { + s.mu.Lock() + s.session = in.SessionID + s.mu.Unlock() + } + if s.opts.OnEvent != nil { + s.opts.OnEvent(Event{Type: in.Type, Subtype: in.Subtype, SessionID: in.SessionID, Raw: line}) + } + } + s.err = s.cmd.Wait() + close(s.done) +} + +// answer replies to a control request the CLI sent: a permission question, or +// anything else, which this client does not serve and says so. +func (s *Session) answer(id string, raw json.RawMessage) { + var head struct { + Subtype string `json:"subtype"` + } + _ = json.Unmarshal(raw, &head) + if head.Subtype != "can_use_tool" { + _ = s.write(map[string]any{"type": "control_response", "response": map[string]any{ + "subtype": "error", "request_id": id, "error": "codeaf does not serve " + head.Subtype, + }}) + return + } + var req PermissionRequest + _ = json.Unmarshal(raw, &req) + decision := Decision{Allow: true} + if s.opts.CanUseTool != nil { + decision = s.opts.CanUseTool(context.Background(), req) + } + response := map[string]any{"behavior": "deny", "message": decision.Message, "toolUseID": req.ToolUseID} + if decision.Allow { + input := req.Input + if len(input) == 0 { + input = json.RawMessage("{}") + } + response = map[string]any{"behavior": "allow", "updatedInput": input, "toolUseID": req.ToolUseID} + } + _ = s.write(map[string]any{"type": "control_response", "response": map[string]any{ + "subtype": "success", "request_id": id, "response": response, + }}) +} + +func newID() string { + var b [8]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +// Text is the plain text of an assistant or result event, for a caller that +// wants words rather than blocks. +func Text(ev Event) string { + var line struct { + Result string `json:"result"` + Message struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"message"` + } + _ = json.Unmarshal(ev.Raw, &line) + if ev.Type == "result" { + return line.Result + } + var b strings.Builder + for _, c := range line.Message.Content { + if c.Type == "text" { + b.WriteString(c.Text) + } + } + return b.String() +} diff --git a/internal/claudeagent/live_test.go b/internal/claudeagent/live_test.go new file mode 100644 index 0000000000..7b20b0c2f7 --- /dev/null +++ b/internal/claudeagent/live_test.go @@ -0,0 +1,78 @@ +package claudeagent + +import ( + "context" + "os" + "os/exec" + "strings" + "sync" + "testing" + "time" +) + +// TestLiveSessionTakesASecondMessageAndAsksPermission drives the real Claude +// Code on this machine. It costs a few cents of the person's plan, so it runs +// only when CODEAF_CLAUDE_LIVE is set. +func TestLiveSessionTakesASecondMessageAndAsksPermission(t *testing.T) { + if os.Getenv("CODEAF_CLAUDE_LIVE") == "" { + t.Skip("set CODEAF_CLAUDE_LIVE=1 to drive the real Claude Code") + } + binary, err := exec.LookPath("claude") + if err != nil { + t.Skip("no claude on PATH") + } + dir := t.TempDir() + var mu sync.Mutex + var asked []string + results := make(chan Event, 4) + s, err := Start(context.Background(), Options{ + Binary: binary, Dir: dir, Model: "haiku", Stderr: os.Stderr, PermissionMode: "default", + CanUseTool: func(_ context.Context, req PermissionRequest) Decision { + mu.Lock() + asked = append(asked, req.ToolName) + mu.Unlock() + if req.ToolName == "Bash" && strings.Contains(string(req.Input), "rm ") { + return Decision{Message: "removing files is not allowed in this run"} + } + return Decision{Allow: true} + }, + OnEvent: func(ev Event) { + if ev.Type == "result" { + results <- ev + } + }, + }) + if err != nil { + t.Fatal(err) + } + defer s.Close(5 * time.Second) + if err := s.Send("Write the word apple into a file named fruit.txt using the Write tool. Reply only 'ok'."); err != nil { + t.Fatal(err) + } + first := wait(t, results) + if err := s.Send("What word is in fruit.txt? Reply with only the word."); err != nil { + t.Fatal(err) + } + second := wait(t, results) + got, _ := os.ReadFile(dir + "/fruit.txt") + t.Logf("first=%q second=%q asked=%v session=%s", Text(first), Text(second), asked, s.SessionID()) + if !strings.Contains(string(got), "apple") || !strings.Contains(strings.ToLower(Text(second)), "apple") { + t.Fatalf("file %q, second answer %q", got, Text(second)) + } + if len(asked) == 0 { + t.Fatal("Claude Code never asked codeaf about a tool") + } + if s.SessionID() == "" { + t.Fatal("no session id to resume by") + } +} + +func wait(t *testing.T, results <-chan Event) Event { + select { + case ev := <-results: + return ev + case <-time.After(3 * time.Minute): + t.Fatal("no result within three minutes") + return Event{} + } +} diff --git a/internal/claudecode/claudecode.go b/internal/claudecode/claudecode.go index cc8f9d6133..90a6428dc1 100644 --- a/internal/claudecode/claudecode.go +++ b/internal/claudecode/claudecode.go @@ -5,33 +5,32 @@ // CLAUDE IS SEATED AS AN AGENT, NEVER AS A MODEL. codeaf reaches OpenRouter and // a ChatGPT plan as model services: it sends each request itself, and its own // loop decides what happens next. Anthropic does not offer a Claude plan that -// way to another program, so this package does not try to. It starts the -// `claude` program the person installed and signed in to themselves, in the -// folder codeaf cut for the run, and Claude Code runs its own loop with its own -// tools on its own account. codeaf holds no Claude credential, sends no request -// to Anthropic, and reads only what the program prints: its stream-json events, -// which become this run's steps and its ending. +// way to another program, so this package does not try to. It holds a live +// session with the `claude` program the person installed and signed in to +// themselves (internal/claudeagent, the same control protocol the Agent SDK +// and T3 Code speak), in the folder codeaf cut for the run. Claude Code runs +// its own loop with its own tools on its own account; codeaf holds no Claude +// credential and sends no request to Anthropic. // -// That is the same arrangement an editor makes when it drives an agent over the -// Agent Client Protocol, and it is why the program is a delegate (a child that -// streams records and ends with one terminal record) rather than a model -// source: the seam already existed for senior-dev, and Claude Code is one more -// program on the far side of it. +// IT IS A WORKER LIKE ANY OTHER. Its tool calls are the task's steps, the +// person's words on the task's page reach it while it works, stopping the task +// interrupts its turn, and every tool it is not already allowed is asked of +// codeaf first ([policy]). package claudecode import ( - "bufio" "context" "encoding/json" - "errors" "flag" "fmt" - "io" "os" "os/exec" + "regexp" "strings" + "sync" "time" + "github.com/Agent-Field/codeaf/internal/claudeagent" "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/env" ) @@ -49,18 +48,29 @@ const EnvBinary = "CODEAF_CLAUDE_BIN" // checks inside its own loop, so codeaf has no phases of its own to name. const stageWork = "work" +// idleGrace is how long a finished turn waits for one more message from the +// person before the run ends. A message that arrives later starts a new task. +var idleGrace = 8 * time.Second + // guide is what the conversation's model reads about the program on every // turn, so it is held to delegate.GuideMax. const guide = "Hands the task to the person's own Claude Code, which works in the folder with its own tools on " + "their Claude plan and leaves its changes on the run's branch. Use it when they ask for Claude, or name " + "Claude Code, to do the work. Its brief is the whole task in plain words." +// unattended is the system prompt added to Claude Code's own. +const unattended = "You are running as a codeaf task. The person may send you messages while you work; " + + "take them into account. Nobody answers questions mid-task: decide, do the work, check it, and end with a " + + "short account of what you changed and how you checked it. Pushing, publishing and opening pull requests " + + "are the person's to do; codeaf lands your work on this branch." + // Program is the definition codeaf carries (internal/delegate/builtin). var Program = delegate.Delegate{ Name: Name, Summary: "hand a task to your own signed-in Claude Code", Guide: guide, Lands: delegate.LandsTree, + Listens: true, // A PERSON'S CREW SEAT IS HEARD WHEN IT IS A CLAUDE MODEL. A crew whose // working seat is `anthropic/claude-opus-…` asked for Opus, and Claude Code // answers to the family word; any other vendor's model is not something @@ -103,57 +113,148 @@ func Family(model string) string { func bindRun(fs *flag.FlagSet) delegate.Body { model := fs.String("claude-model", "", "Claude Code model: opus, sonnet, haiku or a full name") + resume := fs.String("resume", "", "continue the Claude Code session with this id") return func(ctx context.Context, host delegate.Host, args []string) error { - brief := strings.TrimSpace(strings.Join(args, " ")) - host.Hello([]string{stageWork}) - if brief == "" { - host.Terminal(delegate.Ending{Status: delegate.StatusFail, Message: "there was no brief to hand Claude Code"}) - return nil - } - binary, err := Binary() - if err != nil { - host.Terminal(delegate.Ending{Status: delegate.StatusFail, Message: err.Error()}) - return nil - } - ceilings := host.Ceilings() - if ceilings.Hours > 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, time.Duration(ceilings.Hours*float64(time.Hour))) - defer cancel() - } - cmd := exec.CommandContext(ctx, binary, Args(brief, *model, ceilings)...) - cmd.Dir = host.Workspace() - cmd.Stderr = os.Stderr - out, err := cmd.StdoutPipe() - if err != nil { - return err - } - if err := cmd.Start(); err != nil { - host.Terminal(delegate.Ending{Status: delegate.StatusFail, Message: "Claude Code did not start: " + err.Error()}) - return nil - } - end := Translate(out, host) - waitErr := cmd.Wait() - if !end.seen { - message := "Claude Code ended without saying how it went" - status := delegate.StatusCrashed - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - message, status = "Claude Code was stopped at the time ceiling", delegate.StatusBudget - } else if waitErr != nil { - message += ": " + waitErr.Error() + run(ctx, host, strings.TrimSpace(strings.Join(args, " ")), *model, *resume) + return nil + } +} + +func run(ctx context.Context, host delegate.Host, brief, model, resume string) { + host.Hello([]string{stageWork}) + if brief == "" { + host.Terminal(delegate.Ending{Status: delegate.StatusFail, Message: "there was no brief to hand Claude Code"}) + return + } + binary, err := Binary() + if err != nil { + host.Terminal(delegate.Ending{Status: delegate.StatusFail, Message: err.Error()}) + return + } + ceilings := host.Ceilings() + if ceilings.Hours > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(ceilings.Hours*float64(time.Hour))) + defer cancel() + } + tr := newTranslator(host) + session, err := claudeagent.Start(ctx, claudeagent.Options{ + Binary: binary, Dir: host.Workspace(), Model: model, Resume: resume, + PermissionMode: "acceptEdits", AppendSystemPrompt: unattended, MaxBudgetUSD: ceilings.CostUSD, + Disallowed: outwardRules, + CanUseTool: func(_ context.Context, req claudeagent.PermissionRequest) claudeagent.Decision { + decision := policy(req) + if !decision.Allow { + host.Step(delegate.StepRecord{Tool: req.ToolName, Step: stageWork, Command: "refused: " + describeInput(req.Input, req.ToolName), Observation: decision.Message}) + } + return decision + }, + OnEvent: tr.handle, Stderr: os.Stderr, + }) + if err != nil { + host.Terminal(delegate.Ending{Status: delegate.StatusFail, Message: err.Error()}) + return + } + if err := session.Send(brief); err != nil { + _ = session.Close(5 * time.Second) + host.Terminal(delegate.Ending{Status: delegate.StatusCrashed, Message: "Claude Code would not take the brief: " + err.Error()}) + return + } + listener, _ := host.(delegate.Listener) + lastSend := time.Now() + tick := time.NewTicker(300 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + // STOPPING A TASK INTERRUPTS ITS TURN FIRST, so what Claude Code was + // doing ends at a tool boundary rather than mid-write, and only then + // is the process told the conversation is over. + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = session.Interrupt(stopCtx) + cancel() + _ = session.Close(5 * time.Second) + end := tr.ending(session.SessionID()) + end.Status, end.Message = delegate.StatusBudget, "Claude Code was stopped" + if ctx.Err() == context.DeadlineExceeded { + end.Message = "Claude Code was stopped at the time ceiling" + } + closeInbox(listener, "the task ended") + host.Terminal(end) + return + case <-session.Done(): + closeInbox(listener, "Claude Code ended") + end := tr.ending(session.SessionID()) + if !tr.seen() { + end.Status, end.Message = delegate.StatusCrashed, "Claude Code ended without saying how it went" + } + host.Terminal(end) + return + case <-tick.C: + if listener != nil { + if messages := listener.Messages(); len(messages) > 0 { + ids := make([]string, 0, len(messages)) + for _, m := range messages { + if session.Send(m.Text) == nil { + ids = append(ids, m.ID) + } + } + listener.Heard(ids) + lastSend = time.Now() + } + } + // THE RUN IS OVER WHEN CLAUDE CODE HAS ANSWERED EVERYTHING IT WAS + // SENT AND GONE QUIET. A message that arrives mid-turn is sometimes + // folded into that turn, which then ends with ONE result for two + // messages, and sometimes runs as a turn of its own after it, so + // results are never counted against messages: the last thing heard + // must be a result newer than the last message, and nothing may + // have stirred since for the grace. + if tr.settledSince(lastSend, idleGrace) { + closeInbox(listener, "the task finished") + _ = session.Close(10 * time.Second) + host.Terminal(tr.ending(session.SessionID())) + return } - host.Terminal(delegate.Ending{Status: status, Message: message}) - return nil } - host.Terminal(end.Ending) - return nil } } +func closeInbox(listener delegate.Listener, reason string) { + if listener != nil { + listener.CloseInbox(reason) + } +} + +// outward is a shell command that reaches past the run's branch: a push, a +// pull request, a release or a publish. Those are the person's to make. +var outward = regexp.MustCompile(`\bgit\s+push\b|\bgh\s+(pr\s+(create|merge)|release)\b|\b(npm|pnpm|yarn|cargo)\s+publish\b`) + +// outwardRules are the same refusals as Claude Code deny rules. The person's +// own Claude Code hooks can answer "allow" before codeaf is ever asked (a hook +// that rewrites shell commands does), and a deny rule holds even then, so the +// boundary is drawn twice: here for the hooks, in [policy] for everything else. +var outwardRules = []string{ + "Bash(git push:*)", "Bash(rtk git push:*)", "Bash(gh pr create:*)", "Bash(gh pr merge:*)", + "Bash(gh release:*)", "Bash(npm publish:*)", "Bash(pnpm publish:*)", "Bash(cargo publish:*)", +} + +// policy answers Claude Code's permission questions for an unattended task. +// +// THE RUN'S BRANCH IS THE BOUNDARY. codeaf cut it for this run, so anything +// Claude Code does inside it is as reversible as senior-dev's own work, and is +// allowed; what would reach somebody else — a push, a pull request, a publish — +// is refused with a sentence Claude Code reads, so it ends its turn with the +// work on the branch instead of asking again. +func policy(req claudeagent.PermissionRequest) claudeagent.Decision { + if req.ToolName == "Bash" && outward.MatchString(string(req.Input)) { + return claudeagent.Decision{Message: "Pushing, publishing and opening pull requests are the person's to do; leave the work on this branch."} + } + return claudeagent.Decision{Allow: true} +} + // Binary finds the Claude Code program: [EnvBinary] when it is set, else the -// first `claude` on PATH. The refusal says how to make it findable, because a -// person who has Claude Code installed and reads "not found" needs to know -// which of the two codeaf looked at. +// first `claude` on PATH. func Binary() (string, error) { if named := strings.TrimSpace(env.Get(EnvBinary)); named != "" { if _, err := os.Stat(named); err != nil { @@ -168,35 +269,23 @@ func Binary() (string, error) { return path, nil } -// Args is the command line Claude Code is started with. It is a pure function -// so a test can hold the flags without starting anything. -// -// THE RUN IS UNATTENDED, SO NOTHING MAY WAIT ON A PERMISSION PROMPT. codeaf -// starts a tree program on a branch it cut for the run, which is the same trust -// senior-dev's own shell gets; acceptEdits plus the named tools lets Claude Code -// edit and run commands without stopping to ask a person who is not there. -func Args(brief, model string, ceilings delegate.Ceilings) []string { - args := []string{ - "-p", brief, - "--output-format", "stream-json", "--verbose", - "--permission-mode", "acceptEdits", - "--allowedTools", "Read Edit Write Glob Grep Bash TodoWrite", - "--append-system-prompt", "You are running unattended for codeaf. Nobody can answer a question: " + - "decide, do the work, check it, and end with a short account of what you changed and how you checked it.", - } - if model = strings.TrimSpace(model); model != "" { - args = append(args, "--model", model) - } - if ceilings.CostUSD > 0 { - args = append(args, "--max-budget-usd", fmt.Sprintf("%.2f", ceilings.CostUSD)) - } - return args +// translator turns Claude Code's events into the task's stage, steps and +// ending. +type translator struct { + host delegate.Host + mu sync.Mutex + pending map[string]block + subscription bool + turns int + last event + gotResult bool + lastEvent time.Time + lastResult time.Time + listPrice float64 } -// ending is what the stream said at its end, and whether it said it at all. -type ending struct { - delegate.Ending - seen bool +func newTranslator(host delegate.Host) *translator { + return &translator{host: host, pending: map[string]block{}} } // event is the part of a Claude Code stream-json line this package reads. @@ -225,76 +314,95 @@ type block struct { IsError bool `json:"is_error"` } -// Translate reads Claude Code's stream-json from r and reports it through host: -// a stage when the session starts, a step for every tool call once its result -// arrives, and the ending the result line says. It never writes the terminal -// record itself; the caller does, once, whatever the stream said. -func Translate(r io.Reader, host delegate.Host) ending { - var end ending - pending := map[string]block{} - subscription := false - scanner := bufio.NewScanner(r) - scanner.Buffer(make([]byte, 64<<10), 16<<20) - for scanner.Scan() { - var ev event - if json.Unmarshal(scanner.Bytes(), &ev) != nil { - continue +func (t *translator) handle(raw claudeagent.Event) { + var ev event + if json.Unmarshal(raw.Raw, &ev) != nil { + return + } + t.mu.Lock() + defer t.mu.Unlock() + t.lastEvent = time.Now() + switch ev.Type { + case "system": + if ev.Subtype != "init" { + return } - switch ev.Type { - case "system": - if ev.Subtype != "init" { + // A SESSION SIGNED IN TO A CLAUDE PLAN SPENDS NO MONEY OF CODEAF'S + // LEDGER. Claude Code reports what the run WOULD have cost at API + // prices either way; on a plan that figure is not a charge, so it is + // kept as a fact and never booked as spend. + t.subscription = ev.APIKeySource == "" || ev.APIKeySource == "none" + data, _ := json.Marshal(map[string]any{"models": []string{ev.Model}, "doing": "working"}) + t.host.Stage(delegate.StageRecord{Stage: stageWork, Status: "running", Data: data}) + case "assistant": + for _, b := range blocks(ev.Message.Content) { + if b.Type == "tool_use" { + t.pending[b.ID] = b + } + } + case "user": + for _, b := range blocks(ev.Message.Content) { + if b.Type != "tool_result" { continue } - // A SESSION SIGNED IN TO A CLAUDE PLAN SPENDS NO MONEY OF CODEAF'S - // LEDGER. Claude Code reports what the run WOULD have cost at API - // prices either way; on a plan that figure is not a charge, so it - // is kept as a fact and never booked as spend. - subscription = ev.APIKeySource == "" || ev.APIKeySource == "none" - data, _ := json.Marshal(map[string]any{"models": []string{ev.Model}, "doing": "working"}) - host.Stage(delegate.StageRecord{Stage: stageWork, Status: "running", Data: data}) - case "assistant": - for _, b := range blocks(ev.Message.Content) { - if b.Type == "tool_use" { - pending[b.ID] = b - } + call, ok := t.pending[b.ToolUseID] + if !ok { + continue } - case "user": - for _, b := range blocks(ev.Message.Content) { - if b.Type != "tool_result" { - continue - } - call, ok := pending[b.ToolUseID] - if !ok { - continue - } - delete(pending, b.ToolUseID) - step := delegate.StepRecord{Tool: call.Name, Step: stageWork, Command: describe(call), Observation: resultText(b.Content)} - if call.Name == "Bash" { - exit := 0 - if b.IsError { - exit = 1 - } - step.Exit = &exit + delete(t.pending, b.ToolUseID) + step := delegate.StepRecord{Tool: call.Name, Step: stageWork, Command: describeInput(call.Input, call.Name), Observation: resultText(b.Content)} + if call.Name == "Bash" { + exit := 0 + if b.IsError { + exit = 1 } - host.Step(step) - } - case "result": - end.seen = true - end.Deliverable = strings.TrimSpace(ev.Result) - end.Claim = end.Deliverable - end.Extra = map[string]any{"claude_turns": ev.NumTurns, "claude_list_price_usd": ev.TotalCostUSD} - if !subscription { - end.CostUSD = ev.TotalCostUSD - } - switch { - case ev.Subtype == "error_max_budget_usd": - end.Status, end.Message = delegate.StatusBudget, "Claude Code stopped at the cost ceiling" - case ev.IsError || ev.Subtype != "success": - end.Status, end.Message = delegate.StatusFail, "Claude Code ended with "+strings.ReplaceAll(ev.Subtype, "_", " ") - default: - end.Status, end.Message = delegate.StatusPass, "Claude Code finished" + step.Exit = &exit } + t.host.Step(step) } + case "result": + t.turns++ + t.gotResult = true + t.last = ev + t.lastResult = t.lastEvent + // Claude Code's figure is the session's running total, not the turn's. + t.listPrice = ev.TotalCostUSD + } +} + +// settledSince says the last thing Claude Code printed was a result, that the +// result came after the message sent at sent, and that grace has passed since. +func (t *translator) settledSince(sent time.Time, grace time.Duration) bool { + t.mu.Lock() + defer t.mu.Unlock() + return !t.lastResult.IsZero() && t.lastResult.After(sent) && !t.lastEvent.After(t.lastResult) && time.Since(t.lastResult) >= grace +} + +func (t *translator) seen() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.gotResult +} + +// ending is what the last result said, with the session id a later run resumes +// by. +func (t *translator) ending(session string) delegate.Ending { + t.mu.Lock() + defer t.mu.Unlock() + ev := t.last + end := delegate.Ending{Deliverable: strings.TrimSpace(ev.Result)} + end.Claim = end.Deliverable + end.Extra = map[string]any{"claude_session": session, "claude_turns": t.turns, "claude_list_price_usd": t.listPrice} + if !t.subscription { + end.CostUSD = t.listPrice + } + switch { + case ev.Subtype == "error_max_budget_usd": + end.Status, end.Message = delegate.StatusBudget, "Claude Code stopped at the cost ceiling" + case ev.IsError || ev.Subtype != "success": + end.Status, end.Message = delegate.StatusFail, "Claude Code ended with "+strings.ReplaceAll(ev.Subtype, "_", " ") + default: + end.Status, end.Message = delegate.StatusPass, "Claude Code finished" } return end } @@ -305,17 +413,17 @@ func blocks(raw json.RawMessage) []block { return out } -// describe is the one line a step shows for a tool call: the command for a -// shell call, the file for a file tool, the pattern for a search. -func describe(call block) string { +// describeInput is the one line a step shows for a tool call: the command for +// a shell call, the file for a file tool, the pattern for a search. +func describeInput(input json.RawMessage, tool string) string { var in map[string]any - _ = json.Unmarshal(call.Input, &in) + _ = json.Unmarshal(input, &in) for _, key := range []string{"command", "file_path", "pattern", "path", "url"} { if value, ok := in[key].(string); ok && value != "" { return value } } - return call.Name + return tool } // resultText flattens a tool result, which is a string or a list of blocks. diff --git a/internal/claudecode/claudecode_test.go b/internal/claudecode/claudecode_test.go index d3a6304329..0cf1427976 100644 --- a/internal/claudecode/claudecode_test.go +++ b/internal/claudecode/claudecode_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/Agent-Field/codeaf/internal/claudeagent" "github.com/Agent-Field/codeaf/internal/delegate" ) @@ -33,8 +34,8 @@ const stream = `{"type":"system","subtype":"hook_started"} func TestAPlanRunBecomesStepsAndAnEndingThatBooksNoSpend(t *testing.T) { host := &fakeHost{} - end := Translate(strings.NewReader(stream), host) - if !end.seen || end.Status != delegate.StatusPass || end.Deliverable != "done" { + end := translate(stream, host) + if end.Status != delegate.StatusPass || end.Deliverable != "done" { t.Fatalf("ending = %+v", end) } if end.CostUSD != 0 { @@ -50,11 +51,30 @@ func TestAPlanRunBecomesStepsAndAnEndingThatBooksNoSpend(t *testing.T) { func TestAnAPIKeyRunBooksClaudeCodesOwnFigure(t *testing.T) { keyed := strings.Replace(stream, `"apiKeySource":"none"`, `"apiKeySource":"ANTHROPIC_API_KEY"`, 1) - if end := Translate(strings.NewReader(keyed), &fakeHost{}); end.CostUSD != 0.007 { + if end := translate(keyed, &fakeHost{}); end.CostUSD != 0.007 { t.Fatalf("cost = %v", end.CostUSD) } } +func translate(stream string, host *fakeHost) delegate.Ending { + tr := newTranslator(host) + for _, line := range strings.Split(strings.TrimSpace(stream), "\n") { + tr.handle(claudeagent.Event{Raw: []byte(line)}) + } + return tr.ending("s1") +} + +func TestAPushIsRefusedAndWorkOnTheBranchIsNot(t *testing.T) { + for input, allow := range map[string]bool{ + `{"command":"git push origin HEAD"}`: false, `{"command":"gh pr create --fill"}`: false, + `{"command":"npm publish"}`: false, `{"command":"go test ./..."}`: true, `{"command":"git commit -m x"}`: true, + } { + if got := policy(claudeagent.PermissionRequest{ToolName: "Bash", Input: []byte(input)}); got.Allow != allow { + t.Errorf("%s: allow = %v", input, got.Allow) + } + } +} + func TestTheCrewSeatIsHeardOnlyWhenItIsClaude(t *testing.T) { for model, want := range map[string]string{ "anthropic/claude-opus-4.5": "opus", "claude-sonnet-4-5": "sonnet", "openai/gpt-5": "", "vendor/hands": "", diff --git a/internal/claudecode/fake_test.go b/internal/claudecode/fake_test.go new file mode 100644 index 0000000000..9bfa237c58 --- /dev/null +++ b/internal/claudecode/fake_test.go @@ -0,0 +1,228 @@ +package claudecode + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// TestMain lets this test binary stand in for Claude Code: started with +// FAKE_CLAUDE set, it speaks the stream-json control protocol the way the CLI +// does — a handshake, one turn per message with one shell call that it asks +// permission for, and an exit when its input closes — so the whole run can be +// held without a network, a key or a plan. +func TestMain(m *testing.M) { + if os.Getenv("FAKE_CLAUDE") != "" { + fakeClaude() + return + } + os.Exit(m.Run()) +} + +func fakeClaude() { + out := json.NewEncoder(os.Stdout) + in := bufio.NewScanner(os.Stdin) + answers := make(chan map[string]any, 4) + users := make(chan string, 8) + go func() { + for in.Scan() { + var line map[string]any + _ = json.Unmarshal(in.Bytes(), &line) + switch line["type"] { + case "control_request": + out.Encode(map[string]any{"type": "control_response", "response": map[string]any{"subtype": "success", "request_id": line["request_id"], "response": map[string]any{}}}) + case "control_response": + answers <- line["response"].(map[string]any) + case "user": + content := line["message"].(map[string]any)["content"].([]any) + users <- content[0].(map[string]any)["text"].(string) + } + } + close(users) + }() + out.Encode(map[string]any{"type": "system", "subtype": "init", "model": "claude-fake", "apiKeySource": "none", "session_id": "fake-session"}) + turn := 0 + for text := range users { + turn++ + command := "go test ./..." + if strings.Contains(text, "push") { + command = "git push origin HEAD" + } + id := fmt.Sprintf("t%d", turn) + out.Encode(map[string]any{"type": "assistant", "session_id": "fake-session", "message": map[string]any{"content": []any{map[string]any{"type": "tool_use", "id": id, "name": "Bash", "input": map[string]any{"command": command}}}}}) + out.Encode(map[string]any{"type": "control_request", "request_id": "p" + id, "request": map[string]any{"subtype": "can_use_tool", "tool_name": "Bash", "input": map[string]any{"command": command}, "tool_use_id": id}}) + answer := <-answers + verdict := answer["response"].(map[string]any)["behavior"] + out.Encode(map[string]any{"type": "user", "session_id": "fake-session", "message": map[string]any{"content": []any{map[string]any{"type": "tool_result", "tool_use_id": id, "content": fmt.Sprint(verdict)}}}}) + // FOLDED: a message that arrived during the turn is taken into it, + // and the turn ends with one result for both, as the real CLI does. + if os.Getenv("FAKE_CLAUDE") == "fold" { + time.Sleep(time.Second) + for drained := false; !drained; { + select { + case more, ok := <-users: + if ok { + text += " + " + more + } + default: + drained = true + } + } + } + out.Encode(map[string]any{"type": "result", "subtype": "success", "session_id": "fake-session", "result": "did: " + text, "total_cost_usd": 0.01 * float64(turn), "num_turns": turn}) + } +} + +// listeningFake is a host a person can talk to while the run works. +type listeningFake struct { + mu sync.Mutex + stages []delegate.StageRecord + steps []delegate.StepRecord + ending *delegate.Ending + queued []delegate.Message + heard []string + closed string + onStep func() + stepped bool +} + +func (h *listeningFake) Workspace() string { return "." } +func (h *listeningFake) Ceilings() delegate.Ceilings { return delegate.Ceilings{Hours: 0.05} } +func (h *listeningFake) Hello([]string) {} +func (h *listeningFake) Models() delegate.ModelAPI { return delegate.ModelAPI{} } +func (h *listeningFake) Stage(s delegate.StageRecord) { + h.mu.Lock() + defer h.mu.Unlock() + h.stages = append(h.stages, s) +} +func (h *listeningFake) Step(s delegate.StepRecord) { + h.mu.Lock() + h.steps = append(h.steps, s) + first := !h.stepped + h.stepped = true + h.mu.Unlock() + if first && h.onStep != nil { + h.onStep() + } +} +func (h *listeningFake) Terminal(e delegate.Ending) { h.mu.Lock(); h.ending = &e; h.mu.Unlock() } +func (h *listeningFake) Messages() []delegate.Message { + h.mu.Lock() + defer h.mu.Unlock() + out := h.queued + h.queued = nil + return out +} +func (h *listeningFake) Heard(ids []string) { + h.mu.Lock() + h.heard = append(h.heard, ids...) + h.mu.Unlock() +} +func (h *listeningFake) CloseInbox(reason string) { h.mu.Lock(); h.closed = reason; h.mu.Unlock() } + +func fakeBinary(t *testing.T) { + t.Helper() + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + t.Setenv(EnvBinary, self) + t.Setenv("FAKE_CLAUDE", "1") + previous := idleGrace + idleGrace = 300 * time.Millisecond + t.Cleanup(func() { idleGrace = previous }) +} + +func TestAMessageFoldedIntoTheRunningTurnStillLetsTheRunEnd(t *testing.T) { + fakeBinary(t) + t.Setenv("FAKE_CLAUDE", "fold") + host := &listeningFake{} + host.onStep = func() { + host.mu.Lock() + host.queued = append(host.queued, delegate.Message{ID: "m1", From: delegate.FromPerson, Text: "also add Sub"}) + host.mu.Unlock() + } + done := make(chan struct{}) + go func() { run(context.Background(), host, "add Add", "", ""); close(done) }() + select { + case <-done: + case <-time.After(30 * time.Second): + t.Fatal("one result for two messages left the run waiting forever") + } + if host.ending == nil || host.ending.Deliverable != "did: add Add + also add Sub" { + t.Fatalf("ending = %+v", host.ending) + } +} + +func TestAMessageSentWhileItWorksIsTakenInBeforeTheRunEnds(t *testing.T) { + fakeBinary(t) + host := &listeningFake{} + host.onStep = func() { + host.mu.Lock() + host.queued = append(host.queued, delegate.Message{ID: "m1", From: delegate.FromPerson, Text: "also add Sub"}) + host.mu.Unlock() + } + done := make(chan struct{}) + go func() { run(context.Background(), host, "add Add", "", ""); close(done) }() + select { + case <-done: + case <-time.After(30 * time.Second): + t.Fatal("the run never ended") + } + if host.ending == nil || host.ending.Status != delegate.StatusPass { + t.Fatalf("ending = %+v", host.ending) + } + if host.ending.Deliverable != "did: also add Sub" { + t.Fatalf("the last turn answered %q, not the person's message", host.ending.Deliverable) + } + if len(host.heard) != 1 || host.heard[0] != "m1" || host.closed == "" { + t.Fatalf("heard %v, inbox closed %q", host.heard, host.closed) + } + if host.ending.Extra["claude_session"] != "fake-session" || host.ending.CostUSD != 0 { + t.Fatalf("extra %v cost %v", host.ending.Extra, host.ending.CostUSD) + } +} + +func TestAPushIsRefusedOnTheWireAndTheRunStillEnds(t *testing.T) { + fakeBinary(t) + host := &listeningFake{} + done := make(chan struct{}) + go func() { run(context.Background(), host, "push it", "", ""); close(done) }() + select { + case <-done: + case <-time.After(30 * time.Second): + t.Fatal("the run never ended") + } + var refused bool + for _, s := range host.steps { + refused = refused || strings.HasPrefix(s.Command, "refused: git push") + } + if !refused || host.ending == nil { + t.Fatalf("steps %+v ending %+v", host.steps, host.ending) + } +} + +func TestStoppingTheTaskInterruptsAndEnds(t *testing.T) { + fakeBinary(t) + host := &listeningFake{} + ctx, cancel := context.WithCancel(context.Background()) + host.onStep = cancel + done := make(chan struct{}) + go func() { run(ctx, host, "add Add", "", ""); close(done) }() + select { + case <-done: + case <-time.After(30 * time.Second): + t.Fatal("a stopped run never ended") + } + if host.ending == nil || host.ending.Message != "Claude Code was stopped" { + t.Fatalf("ending = %+v", host.ending) + } +} diff --git a/internal/manual/chat/claude.md b/internal/manual/chat/claude.md index 5f9eb7f589..89355df1d8 100644 --- a/internal/manual/chat/claude.md +++ b/internal/manual/chat/claude.md @@ -25,6 +25,14 @@ On a Claude plan, nothing is added to codeaf's spending, because the plan is not The run ends at once with `Claude Code is not installed here: install it, run `claude` once to sign in, or set CODEAF_CLAUDE_BIN to its path` when no `claude` program is on PATH. A `claude` that is only a shell alias or function does not count, because codeaf starts a program, not a shell. Set `CODEAF_CLAUDE_BIN` to the real file. -## What /claude cannot do — questions, permissions, messages +## Talking to Claude Code while it works — send a message, change direction, stop it -It cannot ask you anything while it works: it runs unattended, with edits and shell commands allowed and every other permission prompt refused. It does not read messages sent to its task while it runs. It does not use codeaf's models, skills or memory; it uses Claude Code's own. +Words you type on its task's page reach Claude Code while it works, the same as they reach senior-dev. Claude Code takes them into the turn it is on or starts a new turn for them, and the task ends only once it has answered everything it was sent and gone quiet for a few seconds. Stopping the task interrupts Claude Code's turn first, so it stops between tool calls rather than in the middle of writing a file. + +Each run keeps Claude Code's own session id, so `codeaf claude run --resume -- ` carries on that same conversation. + +## What /claude refuses and cannot do — git push, pull requests, publish, questions + +It may not push, open or merge a pull request, cut a release, or publish a package. Those commands are refused with `Pushing, publishing and opening pull requests are the person's to do; leave the work on this branch.`, and the refusal shows as a step. The rule holds even when your own Claude Code hooks would allow the command. Everything else inside the run's branch is allowed without asking. + +It cannot ask you a question and wait: nobody is assumed to be watching. It does not use codeaf's models, skills or memory; it uses Claude Code's own settings, hooks and MCP servers from your machine. From b818baaf333e0959f613cd16fca698970f97b0e0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 7 Nov 2026 17:11:35 -0500 Subject: [PATCH 03/16] Claude Code as a provider: conversations, crew members and small jobs on the person's own sign-in A conversation or crew member on claude-code/* is a live Claude Code session in its folder, driven over stream-json as T3 Code does: a correction typed mid-turn is written into the running turn instead of cutting it, the session id is kept so a restart resumes it, and a conversation moved off and back hands Claude Code what it missed. Toolless small jobs are one-shot claude -p calls, so a codeaf with only Claude Code connected still names and summarises. A Worker pinned to Claude Code hands tasks to the claude program. providers.list gains the agent kind; keys.check reports the version and plan. --- cmd/codeaf/appaccount.go | 27 + cmd/codeaf/appclaudecode.go | 100 ++++ .../unreleased/claude-code-provider.md | 9 + internal/appwire/wire.go | 6 +- internal/claudeagent/install.go | 217 +++++++++ internal/claudecode/claudecode.go | 43 +- internal/claudemodel/agent.go | 460 ++++++++++++++++++ internal/claudemodel/claudemodel.go | 245 ++++++++++ internal/claudemodel/claudemodel_test.go | 102 ++++ internal/config/config.go | 11 + internal/config/crew.go | 2 + internal/config/modelusable.go | 2 +- internal/config/settings.go | 4 + internal/config/sources.go | 68 +++ internal/config/taskmodelrows.go | 38 +- internal/manual/chat/claude-code-provider.md | 39 ++ internal/manual/chat/claude.md | 4 +- internal/modelsource/modelsource.go | 11 + internal/session/claudeseat.go | 40 ++ internal/session/clientdoor.go | 11 + internal/session/task.go | 4 + 21 files changed, 1402 insertions(+), 41 deletions(-) create mode 100644 cmd/codeaf/appclaudecode.go create mode 100644 docs/changes/unreleased/claude-code-provider.md create mode 100644 internal/claudeagent/install.go create mode 100644 internal/claudemodel/agent.go create mode 100644 internal/claudemodel/claudemodel.go create mode 100644 internal/claudemodel/claudemodel_test.go create mode 100644 internal/manual/chat/claude-code-provider.md create mode 100644 internal/session/claudeseat.go diff --git a/cmd/codeaf/appaccount.go b/cmd/codeaf/appaccount.go index 6d98353add..9d043d370d 100644 --- a/cmd/codeaf/appaccount.go +++ b/cmd/codeaf/appaccount.go @@ -46,6 +46,7 @@ import ( "time" "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/claudemodel" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/modelsource" @@ -149,6 +150,8 @@ func appProviderOf(source modelsource.Source) appProvider { p.check = strings.TrimRight(config.DefaultBaseURL, "/") case strings.EqualFold(source.ID, "codex"): p.kind, p.signIn = appwire.ProviderBrowser, true + case strings.EqualFold(source.ID, claudemodel.ID): + p.kind = appwire.ProviderAgent case source.AddressOwned || modelsource.IsCustomID(source.ID): p.kind = appwire.ProviderAddress } @@ -202,6 +205,15 @@ func (b *appBackend) providerView(p appProvider, sources modelsource.Set, rows m key = service.Key } switch p.kind { + case appwire.ProviderAgent: + // CONNECTED IS THE ROW BEING KEPT; THE PLAN IS CLAUDE CODE'S OWN WORD. + // Whether it is still signed in is read on the machine the engine runs + // on, which over the relay is not the machine the desk is drawn on. Its + // key is a sentinel, so no hint is drawn from it. + _, held := rows[p.id] + view.SignedIn = found && held + view.KeyOptional = true + view.Plan = claudeCodePlanWord(b.claudeStatus()) case appwire.ProviderBrowser: // The sign-in's key is a word naming where its tokens live, never a // key a person could recognise, so no hint is drawn from it. @@ -296,6 +308,9 @@ func (b *appBackend) CheckKey(provider, key string) (appwire.KeyCheck, error) { // the person gave. func (b *appBackend) CheckProvider(params appwire.KeyParams) (appwire.KeyCheck, error) { p, ok := b.appProviderNamed(params.Provider) + if ok && p.kind == appwire.ProviderAgent { + return claudeCodeCheck(b.claudeStatusFresh()), nil + } if !ok { return appwire.KeyCheck{}, appwire.Errorf(appwire.CodeBadParams, "codeaf cannot connect %s with a key", params.Provider) } @@ -332,6 +347,8 @@ func appKeyAsked(p appProvider, key string) error { switch { case p.kind == appwire.ProviderBrowser: return appwire.Errorf(appwire.CodeBadParams, "%s signs in through the browser and takes no key", p.name) + case p.kind == appwire.ProviderAgent && key != "": + return appwire.Errorf(appwire.CodeBadParams, "%s uses its own sign-in and takes no key", p.name) case p.kind == appwire.ProviderKey && key == "": return appwire.Errorf(appwire.CodeBadParams, "%s needs a key", p.name) } @@ -622,6 +639,16 @@ func (b *appBackend) SetProvider(params appwire.KeyParams) error { if err := appKeyAsked(p, key); err != nil { return err } + if p.kind == appwire.ProviderAgent { + if _, err := config.ConnectClaudeCode(context.Background(), config.ProfileDir()); err != nil { + return appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) + } + b.forgetClaudeStatus() + b.keysMoved(p.id) + b.providerChanged(p, "") + b.warmProviderModels() + return nil + } if p.id != modelsource.DefaultID { if err := b.keepConnection(p, params, key); err != nil { return err diff --git a/cmd/codeaf/appclaudecode.go b/cmd/codeaf/appclaudecode.go new file mode 100644 index 0000000000..90bfb5118e --- /dev/null +++ b/cmd/codeaf/appclaudecode.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/claudeagent" + "github.com/Agent-Field/codeaf/internal/claudemodel" +) + +// claudeStatusTTL is how long one reading of Claude Code's sign-in is shown +// before it is read again. Reading it starts the program, so a settings page +// drawn many times a minute does not start it every time. +const claudeStatusTTL = time.Minute + +var claudeStatusCache struct { + sync.Mutex + at time.Time + status claudeagent.Status +} + +// claudeStatus is the cached reading of Claude Code's sign-in on the machine +// this engine runs on. +func (b *appBackend) claudeStatus() claudeagent.Status { + claudeStatusCache.Lock() + defer claudeStatusCache.Unlock() + if !claudeStatusCache.at.IsZero() && time.Since(claudeStatusCache.at) < claudeStatusTTL { + return claudeStatusCache.status + } + claudeStatusCache.status = claudeagent.ReadStatus(context.Background()) + claudeStatusCache.at = time.Now() + return claudeStatusCache.status +} + +// claudeStatusFresh reads the sign-in again, for a person who has just asked. +func (b *appBackend) claudeStatusFresh() claudeagent.Status { + b.forgetClaudeStatus() + return b.claudeStatus() +} + +func (b *appBackend) forgetClaudeStatus() { + claudeStatusCache.Lock() + claudeStatusCache.at = time.Time{} + claudeStatusCache.Unlock() +} + +// claudeCodePlanWord is the provider row's plan: how the installed Claude Code +// is signed in, in the words a person uses for it. +func claudeCodePlanWord(status claudeagent.Status) string { + switch { + case !status.Installed: + return "not installed on this computer" + case !status.LoggedIn: + return "installed, not signed in" + case status.OnPlan() && status.Plan != "": + return "Claude " + strings.ToUpper(status.Plan[:1]) + status.Plan[1:] + " plan" + case status.OnPlan(): + // THE PLAN IS SAID ONLY WHEN CLAUDE CODE SAID IT: an unknown plan is + // nothing, never a guess. + return "" + default: + return "API key · billed per call" + } +} + +// claudeCodeCheck answers keys.check for Claude Code: whether it can be +// connected now, and the models it would bring. +func claudeCodeCheck(status claudeagent.Status) appwire.KeyCheck { + switch { + case !status.Installed: + _, err := claudeagent.Binary() + return appwire.KeyCheck{OK: false, Message: err.Error()} + case !status.LoggedIn: + return appwire.KeyCheck{OK: false, Message: "Claude Code is installed but not signed in: run `claude` once and sign in"} + } + found := "Found Claude Code" + if status.Version != "" { + found += " " + status.Version + } + check := appwire.KeyCheck{OK: true, Message: strings.Join(claudeWords(found, "signed in", claudeCodePlanWord(status)), " · ")} + for _, m := range claudemodel.Models() { + check.Models = append(check.Models, appwire.Model{ID: m.ID, Name: m.Name, Provider: claudemodel.ID}) + } + return check +} + +// claudeWords drops the empty words from a line, so a fact nobody reported leaves +// no stray separator behind. +func claudeWords(words ...string) []string { + out := words[:0] + for _, w := range words { + if strings.TrimSpace(w) != "" { + out = append(out, w) + } + } + return out +} diff --git a/docs/changes/unreleased/claude-code-provider.md b/docs/changes/unreleased/claude-code-provider.md new file mode 100644 index 0000000000..c052b4d40c --- /dev/null +++ b/docs/changes/unreleased/claude-code-provider.md @@ -0,0 +1,9 @@ +--- +kind: added +title: Claude Code as a provider, a task program and a crew seat +surface: [engine, app, chat] +invalidates: + - "The manual said Claude was a worker and never a model: codeaf could not send a request on a Claude plan. Now the person's own signed-in Claude Code is a provider (claude-code, kind agent, no key): a conversation or crew member on claude-code/opus|sonnet|haiku is a live Claude Code session in its folder, small toolless jobs are one-shot `claude -p` calls, and a Worker pinned to it hands tasks to the `claude` program." + - "A message typed into a running turn cut the model request; on Claude Code that would interrupt its tool call. The transport now leaves a cut turn running for three seconds and writes the correction into it, as T3 Code does; only a stop with nothing following interrupts." + - "providers.list kinds were key, browser and address. A fourth, agent, means a local program on its own sign-in: keys.check reports the version and plan, keys.set connects with no key, and a key sent for it is refused." +--- diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index a342ce47c8..ddd50f2cee 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -774,7 +774,7 @@ type Provider struct { // that predates this field assumes of every provider), a browser sign-in // and nothing else (browser), or where the service runs, with a key only if // it wants one (address). - Kind string `json:"kind,omitempty" enum:"key,browser,address"` + Kind string `json:"kind,omitempty" enum:"key,browser,address,agent"` // KeyOptional says the key may be left blank: the service answers with none. KeyOptional bool `json:"key_optional,omitempty"` // Address is an address provider's address: the one in use when it is @@ -804,6 +804,10 @@ const ( ProviderKey = "key" ProviderBrowser = "browser" ProviderAddress = "address" + // ProviderAgent is a program on the engine's machine that answers on its + // own sign-in, such as Claude Code: connecting it asks for nothing, and + // codeaf holds no credential for it. + ProviderAgent = "agent" ) // ProviderRegion is one of a provider's separate hosts. diff --git a/internal/claudeagent/install.go b/internal/claudeagent/install.go new file mode 100644 index 0000000000..777bd35c95 --- /dev/null +++ b/internal/claudeagent/install.go @@ -0,0 +1,217 @@ +package claudeagent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/env" +) + +// EnvBinary names the Claude Code program to start in place of the first +// `claude` on PATH, for a machine whose `claude` is a wrapper or a shell +// function that a child process cannot reach. +const EnvBinary = "CODEAF_CLAUDE_BIN" + +// Binary finds the Claude Code program: [EnvBinary] when it is set, else the +// first `claude` on PATH, else the place Claude Code's own installer puts it. +// The refusal says how to make it findable, because a person who has Claude +// Code installed and reads "not found" needs to know where codeaf looked. +// +// IT IS LOOKED FOR ON THE MACHINE THE ENGINE RUNS ON. A desk on a laptop that +// reaches an engine on another computer over the relay sees that computer's +// Claude Code, signed in there, because that is where every run starts. +func Binary() (string, error) { + if named := strings.TrimSpace(env.Get(EnvBinary)); named != "" { + if _, err := os.Stat(named); err != nil { + return "", fmt.Errorf("%s names %s, which is not there", EnvBinary, named) + } + return named, nil + } + if path, err := exec.LookPath("claude"); err == nil { + return path, nil + } + if home, err := os.UserHomeDir(); err == nil { + // An app launched from the Dock or a service manager does not inherit + // the login shell's PATH, so the installer's own place is looked at too. + for _, candidate := range []string{filepath.Join(home, ".local", "bin", "claude"), filepath.Join(home, ".claude", "local", "claude")} { + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate, nil + } + } + } + return "", fmt.Errorf("Claude Code is not installed here: install it, run `claude` once to sign in, or set %s to its path", EnvBinary) +} + +// Status is what `claude auth status` says about the sign-in, in the fields a +// settings row shows. Nothing that identifies the person is kept. +type Status struct { + Installed bool + Binary string + LoggedIn bool + // Method is how it is signed in: "claude.ai" for a Claude plan, or an API + // key method that is billed per call. + Method string + // Plan is the plan word Claude Code reports, such as "max" or "pro". + Plan string + // Version is the version Claude Code reports, such as "2.1.4". + Version string +} + +// OnPlan says the sign-in is a Claude plan, which is not billed per call. +func (s Status) OnPlan() bool { return s.LoggedIn && s.Method == "claude.ai" } + +// ReadStatus asks the installed Claude Code whether it is signed in. A missing +// program is not an error: it answers Installed false. +func ReadStatus(ctx context.Context) Status { + binary, err := Binary() + if err != nil { + return Status{} + } + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, binary, "auth", "status").Output() + status := Status{Installed: true, Binary: binary} + if raw, err := exec.CommandContext(ctx, binary, "--version").Output(); err == nil { + // "2.1.4 (Claude Code)": the first word is the version. + if fields := strings.Fields(string(raw)); len(fields) > 0 { + status.Version = fields[0] + } + } + if err != nil && len(out) == 0 { + return status + } + var said struct { + LoggedIn bool `json:"loggedIn"` + AuthMethod string `json:"authMethod"` + SubscriptionType string `json:"subscriptionType"` + } + if json.Unmarshal(out, &said) == nil { + status.LoggedIn, status.Method, status.Plan = said.LoggedIn, said.AuthMethod, said.SubscriptionType + } + return status +} + +// Ask is one answer from Claude with no tools: a title, a summary, a check. +type Ask struct { + Model string + System string + Prompt string +} + +// Answer is what one [Ask] came back with. +type Answer struct { + Text string + // InputTokens counts every token read, cached or not. + InputTokens int + OutputTokens int + // ListPriceUSD is what Claude Code says the call would cost at API prices. + ListPriceUSD float64 + // Billed says the figure is a charge: the sign-in is an API key. + Billed bool +} + +// emptyDir is where a one-shot call runs, so that no CLAUDE.md of any folder +// is read into a title or a summary. +func emptyDir() string { + dir := filepath.Join(os.TempDir(), "codeaf-claude-ask") + _ = os.MkdirAll(dir, 0o700) + return dir +} + +// Once asks Claude one question through the installed Claude Code, with no +// tools, none of the person's settings, hooks or MCP servers, and nothing kept +// on disk afterwards. +// +// THIS IS FOR THE SMALL JOBS AROUND A CONVERSATION, NEVER ITS TOOL LOOP. A +// call starts the program afresh (about three seconds before the first word), +// and Claude Code will not hand back a tool call for tools it does not hold, so +// work that needs tools seats Claude Code as an agent ([Start]) instead. +func Once(ctx context.Context, ask Ask) (Answer, error) { + binary, err := Binary() + if err != nil { + return Answer{}, err + } + args := []string{ + "-p", ask.Prompt, "--output-format", "json", + "--tools", "", "--setting-sources", "", "--strict-mcp-config", + "--no-session-persistence", "--disable-slash-commands", + } + if ask.System != "" { + args = append(args, "--system-prompt", ask.System) + } + if ask.Model != "" { + args = append(args, "--model", ask.Model) + } + cmd := exec.CommandContext(ctx, binary, args...) + cmd.Dir = emptyDir() + cmd.Env = append(os.Environ(), "CLAUDE_CODE_ENTRYPOINT="+Entrypoint) + out, runErr := cmd.Output() + var said struct { + Result string `json:"result"` + IsError bool `json:"is_error"` + Subtype string `json:"subtype"` + TotalCostUSD float64 `json:"total_cost_usd"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheReadTokens int `json:"cache_read_input_tokens"` + CacheCreationTokens int `json:"cache_creation_input_tokens"` + } `json:"usage"` + } + if err := json.Unmarshal(out, &said); err != nil { + if runErr != nil { + var exit *exec.ExitError + if errors.As(runErr, &exit) && len(exit.Stderr) > 0 { + return Answer{}, fmt.Errorf("Claude Code: %s", firstLine(string(exit.Stderr))) + } + return Answer{}, fmt.Errorf("Claude Code: %w", runErr) + } + return Answer{}, fmt.Errorf("Claude Code answered something that is not its JSON: %w", err) + } + if said.IsError { + return Answer{}, fmt.Errorf("Claude Code: %s", firstLine(first(said.Result, said.Subtype))) + } + return Answer{ + Text: said.Result, + InputTokens: said.Usage.InputTokens + said.Usage.CacheReadTokens + said.Usage.CacheCreationTokens, + OutputTokens: said.Usage.OutputTokens, + ListPriceUSD: said.TotalCostUSD, + }, nil +} + +func firstLine(s string) string { + line, _, _ := strings.Cut(strings.TrimSpace(s), "\n") + return line +} + +func first(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +// Family answers the Claude Code model word (`opus`, `sonnet`, `haiku`) for a +// model id from any service's spelling of it, or "" for a model that is not +// Claude. +func Family(model string) string { + lower := strings.ToLower(model) + if !strings.Contains(lower, "claude") && !strings.HasPrefix(lower, "anthropic/") { + return "" + } + for _, family := range []string{"opus", "sonnet", "haiku"} { + if strings.Contains(lower, family) { + return family + } + } + return "" +} diff --git a/internal/claudecode/claudecode.go b/internal/claudecode/claudecode.go index 90a6428dc1..3f92f12524 100644 --- a/internal/claudecode/claudecode.go +++ b/internal/claudecode/claudecode.go @@ -22,9 +22,7 @@ import ( "context" "encoding/json" "flag" - "fmt" "os" - "os/exec" "regexp" "strings" "sync" @@ -32,17 +30,14 @@ import ( "github.com/Agent-Field/codeaf/internal/claudeagent" "github.com/Agent-Field/codeaf/internal/delegate" - "github.com/Agent-Field/codeaf/internal/env" ) // Name is the program's one word: the slash command, the shell verb and the // manual page. const Name = "claude" -// EnvBinary names the Claude Code program to start in place of the first -// `claude` on PATH, for a machine whose `claude` is a wrapper or a shell -// function that a child process cannot reach. -const EnvBinary = "CODEAF_CLAUDE_BIN" +// EnvBinary names the Claude Code program to start (internal/claudeagent). +const EnvBinary = claudeagent.EnvBinary // stageWork is the one stage a run reports. Claude Code plans, edits and // checks inside its own loop, so codeaf has no phases of its own to name. @@ -95,21 +90,8 @@ func crewFlags(crew delegate.Crew) []string { return nil } -// Family answers the Claude Code model word (`opus`, `sonnet`, `haiku`) for a -// model id from any service's spelling of it, or "" for a model that is not -// Claude. -func Family(model string) string { - lower := strings.ToLower(model) - if !strings.Contains(lower, "claude") && !strings.HasPrefix(lower, "anthropic/") { - return "" - } - for _, family := range []string{"opus", "sonnet", "haiku"} { - if strings.Contains(lower, family) { - return family - } - } - return "" -} +// Family answers Claude Code's model word for a model id (internal/claudeagent). +func Family(model string) string { return claudeagent.Family(model) } func bindRun(fs *flag.FlagSet) delegate.Body { model := fs.String("claude-model", "", "Claude Code model: opus, sonnet, haiku or a full name") @@ -253,21 +235,8 @@ func policy(req claudeagent.PermissionRequest) claudeagent.Decision { return claudeagent.Decision{Allow: true} } -// Binary finds the Claude Code program: [EnvBinary] when it is set, else the -// first `claude` on PATH. -func Binary() (string, error) { - if named := strings.TrimSpace(env.Get(EnvBinary)); named != "" { - if _, err := os.Stat(named); err != nil { - return "", fmt.Errorf("%s names %s, which is not there", EnvBinary, named) - } - return named, nil - } - path, err := exec.LookPath("claude") - if err != nil { - return "", fmt.Errorf("Claude Code is not installed here: install it, run `claude` once to sign in, or set %s to its path", EnvBinary) - } - return path, nil -} +// Binary finds the Claude Code program (internal/claudeagent). +func Binary() (string, error) { return claudeagent.Binary() } // translator turns Claude Code's events into the task's stage, steps and // ending. diff --git a/internal/claudemodel/agent.go b/internal/claudemodel/agent.go new file mode 100644 index 0000000000..8d3161bbdc --- /dev/null +++ b/internal/claudemodel/agent.go @@ -0,0 +1,460 @@ +package claudemodel + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/claudeagent" +) + +// ── A TURN WITH TOOLS IS A TURN OF CLAUDE CODE ITSELF ────────────────────── +// +// A conversation or a crew member seated on Claude Code sends codeaf's turn +// loop through this transport like any model. Its request carries codeaf's +// tool belt, which Claude Code cannot be handed as tools it would hand back, +// so a request WITH tools is answered by a live Claude Code session held for +// that conversation, in that conversation's folder: the person's newest words +// go in, Claude Code works with its own tools, and its final message comes +// back as the assistant's answer with no tool calls, which ends codeaf's turn. +// +// What it does on the way is streamed as reasoning — each tool call as it +// starts, and a line while a long one runs — so the person watches it work +// and codeaf's stall guard, which ends a stream silent for 45 seconds, sees a +// stream that is alive. + +// heartbeat is how often a quiet turn says it is still working. +var heartbeat = 20 * time.Second + +// handoff is how long a turn whose request was cut keeps running, waiting for +// the request that carries the person's correction. +// +// A CORRECTION IS NOT A STOP. When a person types into a running turn, codeaf +// cuts the model request in flight and sends the next one at once with their +// words as the newest message. Claude Code works inside ONE request, so a cut +// read as a stop would interrupt its edit or its build halfway. T3 Code instead +// writes the words into the running session, and Claude Code takes them in at +// its next step. So a cut request leaves the turn running, and the request that +// follows within this window adopts it and writes the words in. Only a cut that +// no request follows (the person pressed stop) interrupts Claude Code. +var handoff = 3 * time.Second + +// settle is how long a turn that took a correction waits after a result for the +// turn the correction may still start: Claude Code answers a message folded +// into a running turn with that turn's one result, and a message that arrived +// too late for it with a turn of its own. +var settle = 2 * time.Second + +// outwardRules are refused even when the person's own hooks would allow them: +// pushing, opening or merging a pull request, a release, a publish. +var outwardRules = []string{ + "Bash(git push:*)", "Bash(rtk git push:*)", "Bash(gh pr create:*)", "Bash(gh pr merge:*)", + "Bash(gh release:*)", "Bash(npm publish:*)", "Bash(pnpm publish:*)", "Bash(cargo publish:*)", +} + +// seated is the prompt added to Claude Code's own when it is a conversation. +const seated = "You are seated in codeaf as this conversation's agent. Work in the folder with your own tools. " + + "Pushing, publishing and opening pull requests are the person's to do: say what to run instead." + +// live is one conversation's Claude Code session. +type live struct { + session *claudeagent.Session + model string + turn sync.Mutex // one turn at a time + mu sync.Mutex + sink chan claudeagent.Event + turns int + // seen is how many of the conversation's messages this session has been + // given, its own answer included, so a turn after another model had the + // conversation hands Claude Code what was said while it was away. + seen int + // kept is the file the session's id and seen are written to after each + // turn; "" keeps nothing. + kept string + // adoptable is a turn whose request was cut and that is still running, + // waiting for the next request to take it over; letGo interrupts it when + // none does. + adoptable bool + letGo *time.Timer +} + +var ( + livesMu sync.Mutex + lives = map[string]*live{} +) + +func liveKey(dir, conversation string) string { return dir + "\x00" + conversation } + +// liveFor answers the session for this conversation, starting one when there +// is none or the last one has ended. +func liveFor(ctx context.Context, dir, conversation, model, kept string) (*live, error) { + livesMu.Lock() + defer livesMu.Unlock() + key := liveKey(dir, conversation) + if l := lives[key]; l != nil { + select { + case <-l.session.Done(): + default: + if model != "" && model != l.model { + if err := l.session.SetModel(ctx, model); err == nil { + l.model = model + } + } + return l, nil + } + } + binary, err := claudeagent.Binary() + if err != nil { + return nil, err + } + l := &live{model: model, kept: kept} + options := claudeagent.Options{ + Binary: binary, Dir: dir, Model: model, PermissionMode: "acceptEdits", + AppendSystemPrompt: seated, Disallowed: outwardRules, Stderr: os.Stderr, + OnEvent: func(ev claudeagent.Event) { + l.mu.Lock() + sink := l.sink + l.mu.Unlock() + if sink != nil { + sink <- ev + } + }, + } + // A CONVERSATION THAT HAD A SESSION RESUMES IT, as T3 Code does, so + // Claude Code keeps what it read and did and is not handed the history + // as text. One that cannot be resumed (Claude Code forgot it) starts over. + if saved := readKept(kept); saved.Session != "" { + resumed := options + resumed.Resume = saved.Session + if session, err := claudeagent.Start(context.Background(), resumed); err == nil { + l.session, l.seen, l.turns = session, saved.Seen, 1 + lives[key] = l + return l, nil + } + } + session, err := claudeagent.Start(context.Background(), options) + if err != nil { + return nil, err + } + l.session = session + lives[key] = l + return l, nil +} + +// CloseAll ends every live session, for a process that is shutting down. +func CloseAll() { + livesMu.Lock() + defer livesMu.Unlock() + for key, l := range lives { + _ = l.session.Close(5 * time.Second) + delete(lives, key) + } +} + +// agentTurn answers one request with tools by one turn of the conversation's +// Claude Code session. +func (t transport) agentTurn(request *http.Request, chat chatRequest, conversation string) (*http.Response, error) { + dir := t.dir + if dir == "" { + return jsonResponse(request, http.StatusServiceUnavailable, errorBody("this conversation has no folder for Claude Code to work in")) + } + model := model(chat.Model) + l, err := liveFor(request.Context(), dir, conversation, model, t.kept) + if err != nil { + return jsonResponse(request, http.StatusServiceUnavailable, errorBody(err.Error())) + } + l.turn.Lock() + l.mu.Lock() + events, adopted := l.sink, l.adoptable + // A TURN THAT IS ADOPTED HAS SEEN EVERYTHING BUT THE CORRECTION, so only + // the newest words go in; a fresh session is given the whole history. + from := l.seen + switch { + case adopted: + from = len(chat.Messages) + case l.turns == 0: + from = 0 + } + prompt := turnPrompt(chat, from) + if prompt == "" { + l.mu.Unlock() + l.turn.Unlock() + return jsonResponse(request, http.StatusBadRequest, errorBody("there is nothing new to say to Claude Code")) + } + l.seen = len(chat.Messages) + 1 + if adopted { + l.letGo.Stop() + l.adoptable = false + } else { + events = make(chan claudeagent.Event, 256) + l.sink = events + } + l.mu.Unlock() + if err := l.session.Send(prompt); err != nil { + l.release() + return jsonResponse(request, http.StatusServiceUnavailable, errorBody("Claude Code would not take the message: "+err.Error())) + } + l.turns++ + reader, writer := io.Pipe() + go l.stream(request.Context(), writer, events, model, adopted) + return &http.Response{ + StatusCode: http.StatusOK, Status: "200 OK", Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: reader, ContentLength: -1, Request: request, + }, nil +} + +func (l *live) release() { + l.mu.Lock() + l.sink = nil + l.mu.Unlock() + l.turn.Unlock() +} + +// park leaves a cut turn running for [handoff], for the request that carries +// the correction to adopt, and interrupts it when none comes. +func (l *live) park() { + l.mu.Lock() + l.adoptable = true + l.letGo = time.AfterFunc(handoff, func() { + l.mu.Lock() + if !l.adoptable { + l.mu.Unlock() + return + } + l.adoptable, l.sink = false, nil + l.mu.Unlock() + stop, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = l.session.Interrupt(stop) + }) + l.mu.Unlock() + l.turn.Unlock() +} + +// stream writes the turn as server-sent chunks until Claude Code's result. +func (l *live) stream(ctx context.Context, w *io.PipeWriter, events <-chan claudeagent.Event, model string, steered bool) { + parked := false + defer func() { + if !parked { + l.release() + } + }() + id := fmt.Sprintf("claude-code-%d", time.Now().UnixNano()) + write := func(delta map[string]any, finish string, usage map[string]any) bool { + choice := map[string]any{"index": 0, "delta": delta} + if finish != "" { + choice["finish_reason"] = finish + } + chunk := map[string]any{"id": id, "object": "chat.completion.chunk", "model": model, "choices": []any{choice}} + if usage != nil { + chunk["usage"] = usage + } + line, _ := json.Marshal(chunk) + _, err := fmt.Fprintf(w, "data: %s\n\n", line) + return err == nil + } + think := func(text string) bool { return write(map[string]any{"reasoning": text}, "", nil) } + finish := func(content string, usage map[string]any) { + if content != "" { + write(map[string]any{"role": "assistant", "content": content}, "", nil) + } + write(map[string]any{}, "stop", usage) + _, _ = io.WriteString(w, "data: [DONE]\n\n") + _ = w.Close() + } + beat := time.NewTicker(heartbeat) + defer beat.Stop() + started := time.Now() + running, held := "", "" + // settled fires a while after a result in a turn that took a correction; + // until then that result waits in last, because the correction may yet + // start a turn of its own whose result is the real answer. + var settled <-chan time.Time + var last json.RawMessage + answer := func(raw json.RawMessage) { + var r struct { + Result string `json:"result"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheReadTokens int `json:"cache_read_input_tokens"` + CacheCreationTokens int `json:"cache_creation_input_tokens"` + } `json:"usage"` + } + _ = json.Unmarshal(raw, &r) + in := r.Usage.InputTokens + r.Usage.CacheReadTokens + r.Usage.CacheCreationTokens + finish(strings.TrimSpace(r.Result), map[string]any{"prompt_tokens": in, "completion_tokens": r.Usage.OutputTokens, "total_tokens": in + r.Usage.OutputTokens}) + } + for { + select { + case <-ctx.Done(): + if last == nil { + parked = true + l.park() + } + _ = w.CloseWithError(ctx.Err()) + return + case <-settled: + answer(last) + return + case <-l.session.Done(): + finish("Claude Code ended before it answered.", nil) + return + case <-beat.C: + if running != "" { + if !think(fmt.Sprintf("still %s (%s)\n", running, time.Since(started).Round(time.Second))) { + return + } + } + case ev := <-events: + if ev.Type == "assistant" { + settled = nil + } + switch ev.Type { + case "assistant": + for _, b := range contentBlocks(ev.Raw) { + switch b.Type { + case "text": + // TEXT IS HELD UNTIL WHAT FOLLOWS IT IS KNOWN: words + // before a tool call are Claude Code thinking aloud, + // and words that end the turn are its answer, which + // arrives once, as the reply. + held = strings.TrimSpace(b.Text) + case "tool_use": + if held != "" { + if !think(held + "\n") { + return + } + held = "" + } + running = b.Name + " " + describe(b.Input) + started = time.Now() + if !think("▸ " + running + "\n") { + return + } + } + } + case "user": + running = "" + case "result": + l.keep() + if steered { + last, settled = ev.Raw, time.After(settle) + continue + } + answer(ev.Raw) + return + } + } + } +} + +// keptSession is what a conversation keeps of its Claude Code session. +type keptSession struct { + Session string `json:"session"` + Seen int `json:"seen"` +} + +func readKept(path string) keptSession { + var k keptSession + if path == "" { + return k + } + if raw, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(raw, &k) + } + return k +} + +// keep writes the session's id and how much of the conversation it has seen. +func (l *live) keep() { + if l.kept == "" { + return + } + l.mu.Lock() + k := keptSession{Session: l.session.SessionID(), Seen: l.seen} + l.mu.Unlock() + if k.Session == "" { + return + } + raw, _ := json.Marshal(k) + _ = os.WriteFile(l.kept, raw, 0o600) +} + +// turnPrompt is what Claude Code is told this turn: the person's newest words, +// after whatever it has not yet been given of the conversation from message +// from on — the whole history on a session's first turn of a conversation +// that started elsewhere, or what another model said and was told while the +// person had moved the conversation off Claude Code and back. +func turnPrompt(chat chatRequest, from int) string { + last := -1 + for i := len(chat.Messages) - 1; i >= 0; i-- { + if chat.Messages[i].Role == "user" { + last = i + break + } + } + if last < 0 { + return "" + } + newest := strings.TrimSpace(contentText(chat.Messages[last].Content)) + if from < 0 { + from = 0 + } + var earlier []string + for i := from; i < last; i++ { + m := chat.Messages[i] + text := strings.TrimSpace(contentText(m.Content)) + if text == "" || m.Role == "system" || m.Role == "developer" { + continue + } + who := "User" + if m.Role == "assistant" { + who = "Assistant" + } else if m.Role == "tool" { + who = "Tool result" + } + earlier = append(earlier, who+": "+text) + } + if len(earlier) == 0 { + return newest + } + return "What was said in this conversation that you have not seen:\n\n" + strings.Join(earlier, "\n\n") + "\n\nThe person now says:\n\n" + newest +} + +type contentBlock struct { + Type string `json:"type"` + Text string `json:"text"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` +} + +func contentBlocks(raw json.RawMessage) []contentBlock { + var line struct { + Message struct { + Content []contentBlock `json:"content"` + } `json:"message"` + } + _ = json.Unmarshal(raw, &line) + return line.Message.Content +} + +func describe(input json.RawMessage) string { + var in map[string]any + _ = json.Unmarshal(input, &in) + for _, key := range []string{"command", "file_path", "pattern", "path", "url"} { + if value, ok := in[key].(string); ok && value != "" { + if len(value) > 120 { + value = value[:120] + "…" + } + return value + } + } + return "" +} diff --git a/internal/claudemodel/claudemodel.go b/internal/claudemodel/claudemodel.go new file mode 100644 index 0000000000..1a64e3d539 --- /dev/null +++ b/internal/claudemodel/claudemodel.go @@ -0,0 +1,245 @@ +// Package claudemodel lets the person's own Claude Code answer codeaf's small, +// toolless calls — titles, summaries, the light seat — the way a model service +// does, so a codeaf with no OpenRouter key and only a Claude plan still has +// something to name a conversation with. +// +// IT IS A TRANSPORT, LIKE CODEX'S. The provider client speaks OpenAI chat +// completions to whatever service a seat is on; for the Claude Code service the +// http.Client it is handed answers `/chat/completions` by running one +// `claude -p` (internal/claudeagent's Once) and `/models` from [Models]. No +// request leaves the machine except the one Claude Code itself makes on its +// own sign-in. +// +// A CALL WITH TOOLS IS NEVER SENT AS ONE. Claude Code will not hand back a +// call to a tool it does not hold (a schema-shaped answer was tried and it +// called the tool itself), so a request that carries codeaf's tool belt is a +// turn of a conversation or a crew member, and it is answered by that +// conversation's own live Claude Code session working with its own tools +// (agent.go). A request with no tools is one `claude -p`. +package claudemodel + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/claudeagent" +) + +// ID is the service's id and the word its persisted row is written with. +const ID = "claude-code" + +// Name is how every surface spells the service. +const Name = "Claude Code" + +// Address is the service's address. Nothing is ever dialled there: the +// transport answers every request itself. +const Address = "http://claude-code.local/v1" + +// Sentinel is the key the service's row carries. The sign-in is Claude Code's +// own and codeaf never holds it. +const Sentinel = "claude-code" + +// window is the context Claude Code's models take. +const window = 200_000 + +// Models is the service's catalog: the three words Claude Code answers to, +// each with no tools among its parameters. +func Models() []catalog.Model { + rows := make([]catalog.Model, 0, 3) + for _, row := range []struct{ id, name string }{{"opus", "Claude Opus"}, {"sonnet", "Claude Sonnet"}, {"haiku", "Claude Haiku"}} { + rows = append(rows, catalog.Model{ + ID: row.id, Name: row.name, ContextLength: window, PriceUnknown: true, + Parameters: []string{"max_tokens", "temperature", "stop", "tools", "tool_choice"}, + }) + } + return rows +} + +// Client is the http.Client the provider is handed for the Claude Code service +// where no conversation folder is known: titles, summaries, catalog reads. +func Client() *http.Client { return ClientIn("") } + +// ClientIn is the client for a conversation working in dir, whose turns with +// tools are turns of a Claude Code session in that folder. +func ClientIn(dir string) *http.Client { return ClientFor(dir, "") } + +// ClientFor is [ClientIn] for a conversation that keeps its Claude Code session +// in kept, so the conversation picks up the same session after codeaf restarts +// rather than starting Claude Code over with the history written out. +func ClientFor(dir, kept string) *http.Client { + return &http.Client{Transport: transport{ask: claudeagent.Once, dir: dir, kept: kept}} +} + +type transport struct { + ask func(context.Context, claudeagent.Ask) (claudeagent.Answer, error) + dir string + kept string +} + +func (t transport) RoundTrip(request *http.Request) (*http.Response, error) { + path := strings.TrimRight(request.URL.Path, "/") + switch { + case strings.HasSuffix(path, "/models"): + return jsonResponse(request, http.StatusOK, modelList()) + case strings.HasSuffix(path, "/chat/completions") && request.Method == http.MethodPost: + return t.complete(request) + } + return jsonResponse(request, http.StatusNotFound, errorBody("Claude Code answers chat completions and its model list only")) +} + +type chatRequest struct { + Model string `json:"model"` + Stream bool `json:"stream"` + Tools json.RawMessage `json:"tools"` + PromptCacheKey string `json:"prompt_cache_key"` + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"messages"` +} + +func (t transport) complete(request *http.Request) (*http.Response, error) { + raw, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + var chat chatRequest + if err := json.Unmarshal(raw, &chat); err != nil { + return jsonResponse(request, http.StatusBadRequest, errorBody("the request is not chat completions JSON")) + } + if tools := strings.TrimSpace(string(chat.Tools)); tools != "" && tools != "null" && tools != "[]" { + conversation := strings.TrimSpace(chat.PromptCacheKey) + if conversation == "" { + conversation = strings.TrimSpace(request.Header.Get("X-Session-Affinity")) + } + return t.agentTurn(request, chat, conversation) + } + ask := Compose(chat) + answer, err := t.ask(request.Context(), ask) + if err != nil { + // A 503 READS AS "THIS SERVICE IS DOWN, TRY ANOTHER", which is the + // truth of a Claude Code that is signed out or not installed. + return jsonResponse(request, http.StatusServiceUnavailable, errorBody(err.Error())) + } + id := fmt.Sprintf("claude-code-%d", time.Now().UnixNano()) + usage := map[string]any{"prompt_tokens": answer.InputTokens, "completion_tokens": answer.OutputTokens, "total_tokens": answer.InputTokens + answer.OutputTokens} + if chat.Stream { + var b bytes.Buffer + for _, chunk := range []map[string]any{ + {"id": id, "object": "chat.completion.chunk", "model": ask.Model, "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant", "content": answer.Text}}}}, + {"id": id, "object": "chat.completion.chunk", "model": ask.Model, "choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": "stop"}}, "usage": usage}, + } { + line, _ := json.Marshal(chunk) + b.WriteString("data: ") + b.Write(line) + b.WriteString("\n\n") + } + b.WriteString("data: [DONE]\n\n") + return &http.Response{ + StatusCode: http.StatusOK, Status: "200 OK", Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(&b), ContentLength: int64(b.Len()), Request: request, + }, nil + } + return jsonResponse(request, http.StatusOK, map[string]any{ + "id": id, "object": "chat.completion", "model": ask.Model, "usage": usage, + "choices": []any{map[string]any{"index": 0, "finish_reason": "stop", "message": map[string]any{"role": "assistant", "content": answer.Text}}}, + }) +} + +// Compose turns a chat request into one prompt for Claude Code: the system +// messages become its system prompt, and the rest is the conversation written +// out, ending on what the assistant is to answer. +func Compose(chat chatRequest) claudeagent.Ask { + var system []string + var turns []string + for _, m := range chat.Messages { + text := strings.TrimSpace(contentText(m.Content)) + if text == "" { + continue + } + switch m.Role { + case "system", "developer": + system = append(system, text) + case "assistant": + turns = append(turns, "Assistant: "+text) + case "tool": + turns = append(turns, "Tool result: "+text) + default: + turns = append(turns, "User: "+text) + } + } + prompt := "" + switch { + case len(turns) == 1 && strings.HasPrefix(turns[0], "User: "): + prompt = strings.TrimPrefix(turns[0], "User: ") + case len(turns) > 0: + prompt = "The conversation so far:\n\n" + strings.Join(turns, "\n\n") + "\n\nWrite the assistant's next message, and only that." + } + return claudeagent.Ask{Model: model(chat.Model), System: strings.Join(system, "\n\n"), Prompt: prompt} +} + +// model is the Claude Code model word for a seat's id: a service prefix is +// dropped (`claude-code/sonnet`), and a catalog spelling of a Claude model +// (`anthropic/claude-opus-4.6`) is read as its family. +func model(id string) string { + id = strings.TrimSpace(id) + if family := claudeagent.Family(id); family != "" { + return family + } + if _, rest, ok := strings.Cut(id, "/"); ok { + return rest + } + return id +} + +func contentText(raw json.RawMessage) string { + var text string + if json.Unmarshal(raw, &text) == nil { + return text + } + var parts []struct { + Type string `json:"type"` + Text string `json:"text"` + } + _ = json.Unmarshal(raw, &parts) + var b strings.Builder + for _, part := range parts { + if part.Type == "text" { + b.WriteString(part.Text) + } + } + return b.String() +} + +func modelList() map[string]any { + data := make([]any, 0, 3) + for _, m := range Models() { + data = append(data, map[string]any{"id": m.ID, "name": m.Name, "context_length": m.ContextLength, "supported_parameters": m.Parameters}) + } + return map[string]any{"object": "list", "data": data} +} + +func errorBody(message string) map[string]any { + return map[string]any{"error": map[string]any{"message": message, "type": "claude_code"}} +} + +func jsonResponse(request *http.Request, status int, body any) (*http.Response, error) { + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + return &http.Response{ + StatusCode: status, Status: fmt.Sprintf("%d %s", status, http.StatusText(status)), + Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(raw)), ContentLength: int64(len(raw)), Request: request, + }, nil +} diff --git a/internal/claudemodel/claudemodel_test.go b/internal/claudemodel/claudemodel_test.go new file mode 100644 index 0000000000..382658f414 --- /dev/null +++ b/internal/claudemodel/claudemodel_test.go @@ -0,0 +1,102 @@ +package claudemodel + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/claudeagent" +) + +func fake(reply string, seen *claudeagent.Ask) *http.Client { + return &http.Client{Transport: transport{ask: func(_ context.Context, ask claudeagent.Ask) (claudeagent.Answer, error) { + *seen = ask + return claudeagent.Answer{Text: reply, InputTokens: 12, OutputTokens: 3}, nil + }}} +} + +func TestATitleCallIsAnsweredByClaudeCodeAsChatCompletions(t *testing.T) { + var seen claudeagent.Ask + body := `{"model":"claude-code/haiku","messages":[{"role":"system","content":"You write titles."},{"role":"user","content":[{"type":"text","text":"fix the flaky test"}]}]}` + resp, err := fake("Fix flaky test", &seen).Post(Address+"/chat/completions", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + var out struct { + Choices []struct { + Message struct{ Content string } `json:"message"` + } `json:"choices"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + } `json:"usage"` + } + _ = json.NewDecoder(resp.Body).Decode(&out) + if out.Choices[0].Message.Content != "Fix flaky test" || out.Usage.PromptTokens != 12 { + t.Fatalf("answer = %+v", out) + } + if seen.Model != "haiku" || seen.System != "You write titles." || seen.Prompt != "fix the flaky test" { + t.Fatalf("asked = %+v", seen) + } +} + +func TestAStreamedCallEndsLikeAnyStream(t *testing.T) { + var seen claudeagent.Ask + body := `{"model":"sonnet","stream":true,"messages":[{"role":"user","content":"a"},{"role":"assistant","content":"b"},{"role":"user","content":"c"}]}` + resp, _ := fake("d", &seen).Post(Address+"/chat/completions", "application/json", strings.NewReader(body)) + raw, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(raw), `"content":"d"`) || !strings.HasSuffix(string(raw), "data: [DONE]\n\n") { + t.Fatalf("stream = %s", raw) + } + if !strings.Contains(seen.Prompt, "User: a\n\nAssistant: b\n\nUser: c") { + t.Fatalf("prompt = %q", seen.Prompt) + } +} + +func TestEveryRowTakesTools(t *testing.T) { + // A row without tools would have the provider strip the belt, and the turn + // would be sent as a one-shot call that cannot work in the folder. + for _, m := range Models() { + found := false + for _, p := range m.Parameters { + found = found || p == "tools" + } + if !found { + t.Fatalf("%s does not list tools", m.ID) + } + } +} + +func TestACatalogSpellingOfClaudeIsReadAsItsFamily(t *testing.T) { + for id, want := range map[string]string{"anthropic/claude-opus-4.6": "opus", "claude-code/sonnet": "sonnet", "haiku": "haiku"} { + if got := model(id); got != want { + t.Errorf("model(%q) = %q, want %q", id, got, want) + } + } +} + +func TestATurnBackOnClaudeCodeCarriesWhatAnotherModelSaid(t *testing.T) { + msg := func(role, text string) struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } { + raw, _ := json.Marshal(text) + return struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + }{role, raw} + } + chat := chatRequest{} + chat.Messages = append(chat.Messages, msg("system", "rules"), msg("user", "one"), msg("assistant", "claude answered"), msg("user", "two"), msg("assistant", "another model answered"), msg("user", "three")) + if got := turnPrompt(chat, 3); !strings.Contains(got, "User: two") || !strings.Contains(got, "Assistant: another model answered") || strings.Contains(got, "claude answered") || !strings.HasSuffix(got, "three") { + t.Fatalf("back on Claude Code: %q", got) + } + if got := turnPrompt(chat, len(chat.Messages)); got != "three" { + t.Fatalf("an adopted turn is given only the correction: %q", got) + } + if got := turnPrompt(chat, 0); strings.Contains(got, "rules") || !strings.Contains(got, "User: one") { + t.Fatalf("a fresh session: %q", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 957b3da6d5..d5258ceb1d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,7 @@ import ( "github.com/Agent-Field/codeaf/internal/calllog" "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/claudemodel" "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/ctxbudget" "github.com/Agent-Field/codeaf/internal/env" @@ -1052,6 +1053,16 @@ func ClientConfigFor(sources modelsource.Set, model string) provider.Config { configured.Billing = provider.BillingFact{} configured.ModelPrice = func(string) (float64, float64, bool) { return 0, 0, false } } + if strings.EqualFold(strings.TrimSpace(service.Source.ID), claudemodel.ID) { + // Claude Code answers through its own program on the person's own + // sign-in; a session that knows its folder replaces this client with + // one that works there (internal/session's clientConfig). + configured.APIKey = claudemodel.Sentinel + configured.HTTPClient = claudemodel.Client() + configured.BillingDoor = "" + configured.Billing = provider.BillingFact{} + configured.ModelPrice = func(string) (float64, float64, bool) { return 0, 0, false } + } if service.Overflow != nil { configured.PlanOverflow = service.Overflow.Address configured.PlanOverflowDoor = service.Overflow.Name diff --git a/internal/config/crew.go b/internal/config/crew.go index 699055e547..1e28325998 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -812,6 +812,8 @@ func CrewProvidersAt(profileDir string) []CrewProvider { case strings.EqualFold(source.ID, "codex"): p.Kind = crewroute.Plan p.Serves = []string{source.Preferred} + case strings.EqualFold(source.ID, "claude-code"): + p.Kind = crewroute.Plan case strings.EqualFold(source.ID, "ollama"): p.Kind = crewroute.Local case modelsource.IsCustomID(source.ID): diff --git a/internal/config/modelusable.go b/internal/config/modelusable.go index 2b186f00f4..969c3d7b92 100644 --- a/internal/config/modelusable.go +++ b/internal/config/modelusable.go @@ -16,7 +16,7 @@ func ModelUsable(sources modelsource.Set, model, providerID string) bool { for _, source := range modelsource.Vendored() { // Cloud vendor names can also be OpenRouter author prefixes. // Local runners and the sign-in transport cannot use that fallback. - if source.KeyOptional || source.ID == "codex" { + if source.KeyOptional || source.ID == "codex" || source.ID == "claude-code" { written = append(written, source.Written) } } diff --git a/internal/config/settings.go b/internal/config/settings.go index a495a850d5..617d0449fe 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1071,6 +1071,10 @@ var OperatorEnvPins = []string{ "CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "CODEAF_PROTOTYPE_TASK_STORAGE", "CODEAF_PROTOTYPE_ARCHIVE_BINARY", + // CODEAF_CLAUDE_BIN names the Claude Code program when `claude` on PATH is + // a shell alias a child process cannot start (internal/claudeagent). It is + // an executable path, so it is plumbing like the archive binary above. + "CODEAF_CLAUDE_BIN", // CODEAF_RELAY points a headless peer at the relay this host pairs through // (internal/pair). It is an address, so it is plumbing for the same reason // CODEAF_BASE_URL is. diff --git a/internal/config/sources.go b/internal/config/sources.go index 9725960243..8825746f79 100644 --- a/internal/config/sources.go +++ b/internal/config/sources.go @@ -11,6 +11,8 @@ import ( "strings" "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/claudeagent" + "github.com/Agent-Field/codeaf/internal/claudemodel" "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/modelsource" @@ -205,6 +207,12 @@ func SourceKeyAt(profileDir string, row PersistedSource, src modelsource.Source) } return "" } + if strings.EqualFold(strings.TrimSpace(src.ID), claudemodel.ID) { + // THE SIGN-IN IS CLAUDE CODE'S, so a connected row carries only the + // sentinel. Whether Claude Code is still installed and signed in is + // read when a call is made, and a signed-out one refuses that call. + return claudemodel.Sentinel + } return sourceKeyFromRow(row, src) } @@ -215,6 +223,9 @@ func CatalogHTTPClient(service modelsource.Connected) *http.Client { if strings.EqualFold(strings.TrimSpace(service.Source.ID), "codex") { return codexauth.Client(service.Home) } + if strings.EqualFold(strings.TrimSpace(service.Source.ID), claudemodel.ID) { + return claudemodel.Client() + } return nil } @@ -250,6 +261,10 @@ func sourceHomes(set modelsource.Set, profileDir string) modelsource.Set { services[index].Address = codexauth.Backend() services[index].Source.Address = services[index].Address } + if strings.EqualFold(services[index].Source.ID, claudemodel.ID) { + services[index].Address = claudemodel.Address + services[index].Source.Address = claudemodel.Address + } } return modelsource.NewSet(services...) } @@ -322,6 +337,9 @@ func resolvedSourceAddress(row PersistedSource, source modelsource.Source) strin if strings.EqualFold(strings.TrimSpace(source.ID), "codex") { return codexauth.Backend() } + if strings.EqualFold(strings.TrimSpace(source.ID), claudemodel.ID) { + return claudemodel.Address + } if modelsource.IsCustomID(source.ID) { return strings.TrimRight(strings.TrimSpace(row.Address), "/") } @@ -361,6 +379,56 @@ func CodexRememberedModels(service modelsource.Connected, profileDir string) []c return rows } +// ConnectClaudeCode connects the person's own Claude Code as a model service: +// its row is kept and the picker is seeded with the three models it answers to. +// +// THERE IS NO SIGN-IN HERE, because codeaf never holds one. It refuses when +// Claude Code is not installed or not signed in, with the sentence that says +// which, so a person is never shown a service that cannot answer. +func ConnectClaudeCode(ctx context.Context, profileDir string) (modelsource.Outcome, error) { + if err := ctx.Err(); err != nil { + return modelsource.Outcome{}, err + } + status := claudeagent.ReadStatus(ctx) + switch { + case !status.Installed: + _, err := claudeagent.Binary() + return modelsource.Outcome{}, err + case !status.LoggedIn: + return modelsource.Outcome{}, errors.New("Claude Code is installed but not signed in: run `claude` once and sign in") + } + listed := true + row := PersistedSource{ID: claudemodel.ID, Written: claudemodel.ID, Listed: &listed} + if !AppMode() { + row.Key = claudemodel.Sentinel + } + if err := persistConnectedSource(profileDir, row); err != nil { + return modelsource.Outcome{}, err + } + service, found := ResolveSources(profileDir, "", DefaultBaseURL).ByID(claudemodel.ID) + if !found { + _ = DisconnectService(profileDir, claudemodel.ID) + return modelsource.Outcome{}, errors.New("the Claude Code connection was not saved") + } + models := claudemodel.Models() + if err := catalog.Remember(CatalogOptionsFor(service, profileDir), models); err != nil { + _ = DisconnectService(profileDir, claudemodel.ID) + return modelsource.Outcome{}, err + } + // A PROFILE WITH NOTHING ELSE TO TALK WITH TALKS WITH CLAUDE. Connecting + // Claude Code as the first service moves new conversations onto it, the way + // a first connection of any service does; one that already has a key or a + // chosen model keeps it. + if strings.TrimSpace(APIKeyAt(profileDir)) == "" && ChatModelAt(profileDir) == "" { + _ = WriteChatModel(profileDir, claudemodel.ID+"/"+service.Source.Preferred) + } + outcome := modelsource.Outcome{Kind: modelsource.OutcomeConnected, Listed: true, Refreshed: true, Models: len(models)} + for _, m := range models { + outcome.ModelIDs = append(outcome.ModelIDs, m.ID) + } + return outcome, nil +} + // ConnectCodex keeps a completed browser sign-in, persists its service row and // seeds the picker from the account's own visible model list. // diff --git a/internal/config/taskmodelrows.go b/internal/config/taskmodelrows.go index 5eecd588c6..d2509a5028 100644 --- a/internal/config/taskmodelrows.go +++ b/internal/config/taskmodelrows.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/Agent-Field/codeaf/internal/claudemodel" "sort" "strings" "time" @@ -176,6 +177,15 @@ func taskModelRowsWith(ctx context.Context, profileDir string, health crewHealth if err := ctx.Err(); err != nil { return nil, ErrTaskModelsRead } + if p.ID == claudemodel.ID { + // CLAUDE CODE IS THREE CHOICES, NOT A COPY OF THE CATALOG. It + // answers to Opus, Sonnet and Haiku whatever dated ids the shared + // catalog lists, and it is offered whether or not that catalog was + // ever read, so a person with only a Claude plan still has rows. + // A pin seats it; the router never does on its own. + out = append(out, claudeCodeRows(p, rule, health)...) + continue + } for _, entry := range models { if built++; built%taskModelRowCheck == 0 && ctx.Err() != nil { return nil, ErrTaskModelsRead @@ -223,6 +233,32 @@ func taskModelRowsWith(ctx context.Context, profileDir string, health crewHealth return out, nil } +// claudeCodeRows are the Claude Code service's rows: one per model it answers +// to, seatable in every seat, on the person's plan and never weighed by Auto. +func claudeCodeRows(p CrewProvider, rule crewroute.Allowed, health crewHealth) []TaskModelRow { + var out []TaskModelRow + for _, m := range claudemodel.Models() { + model := crewroute.Model{ID: claudemodel.ID + "/" + m.ID, Context: m.ContextLength, Tools: true} + row := TaskModelRow{Model: model, Name: m.Name, Provider: p.ID, Kind: crewroute.Plan, Send: m.ID, Seats: map[crewroute.Seat]bool{}} + for _, seat := range crewroute.Seats { + row.Seats[seat] = true + } + row.Allowed = rule.AdmitsOn(model, p.ID) + providerDown := !p.On || health.disconnected[p.ID] + row.Available = !providerDown + switch { + case !row.Allowed: + row.Reason = crewroute.RejectNotAllowed + case providerDown: + row.Reason = crewroute.RejectProviderUnavailable + default: + row.Reason = crewroute.RejectRouteUnavailable + } + out = append(out, row) + } + return out +} + // taskCatalogModel is one model of the catalog, once per lineage. type taskCatalogModel struct { model crewroute.Model @@ -324,7 +360,7 @@ func taskModelProviders(profileDir string, rows []TaskModelRow, health crewHealt candidates := append([]modelsource.Source{modelsource.DefaultSource(DefaultBaseURL)}, modelsource.Vendored()...) for _, source := range candidates { id := strings.ToLower(source.ID) - if seen[id] || source.KeyOptional || modelsource.IsCustomID(source.ID) || id == "codex" { + if seen[id] || source.KeyOptional || modelsource.IsCustomID(source.ID) || id == "codex" || id == "claude-code" { continue } seen[id] = true diff --git a/internal/manual/chat/claude-code-provider.md b/internal/manual/chat/claude-code-provider.md new file mode 100644 index 0000000000..a9e1ea0072 --- /dev/null +++ b/internal/manual/chat/claude-code-provider.md @@ -0,0 +1,39 @@ +# Claude Code as a provider — conversations, crew members and small jobs on your Claude plan + +## Claude Code as a provider — use my Claude plan, no API key, connect Claude Code + +Settings › Models & providers lists **Claude Code** beside OpenRouter and ChatGPT. It asks for no key: Check finds the `claude` program and its sign-in (it says, for example, `Found Claude Code 2.1.319 · signed in · Claude Max plan`), and Connect adds three models — `claude-code/opus`, `claude-code/sonnet` and `claude-code/haiku`. + +Connected, the row reads `Signed in · Claude Max plan · no API key`; the plan is shown only when Claude Code reports it. Not installed, it reads `Not installed on this computer` and offers Check again, not Connect. First run leads with a Claude Code card only when Claude Code is installed and signed in. + +Nothing is billed per call on a Claude plan, so these models show `on your Claude plan` instead of a price, and they add nothing to codeaf's spending. + +## How a conversation on Claude Code works — Claude Code does the work with its own tools + +A conversation or crew member on a Claude Code model is a live Claude Code session working in that conversation's folder, held open between turns. Your message goes in; Claude Code reads, edits and runs commands with its own tools; each tool call shows in the thinking line as `▸ Bash …` or `▸ Edit …`, and its last message is the reply. A long command says `still …` every 20 seconds. + +Edits are accepted without asking. Pushing, opening or merging a pull request, releases and publishing are refused even when your own Claude Code hooks would allow them. + +## What Claude Code cannot do in a conversation — codeaf tools, propose_task, team tools, pictures + +In that seat Claude Code uses its own tools, not codeaf's: it cannot call `propose_task`, the team tools, `manual`, codeaf's memory or skills, and it reads Claude Code's own settings, hooks and MCP servers instead. Pictures attached to a message are not passed to it; only the words are. A crew member on Claude Code still reports finished to its crew like any member. + +## Talking to Claude Code mid-turn — send a message while it works + +Typing while Claude Code works does not stop it. The words are written into the running turn, the way T3 Code does it, and Claude Code takes them in at its next step, so a build or an edit in progress finishes. Pressing stop with nothing typed interrupts Claude Code a few seconds later, between tool calls. + +## Switching models in a conversation that used Claude Code — and after a restart + +Move a conversation onto Claude Code and its first turn is given what was said so far. Move it off and back, and Claude Code is given what was said while it was away. Claude Code's own session id is kept in the conversation's folder, so after codeaf restarts the same session resumes rather than starting over. + +## Small jobs with no OpenRouter key — titles, summaries, naming on Claude Code + +With only Claude Code connected, the small jobs — naming a conversation, titling a task, summaries — run as one-shot Claude Code calls with no tools, about three seconds each. Connecting Claude Code as the first provider puts new conversations on `claude-code/sonnet`. + +## Auto and Claude Code models — why Auto never picks them + +Auto never picks a Claude Code model for a task role; Task models says `Auto never picks these · pin one to a role`. Pin one: a Worker pinned to Claude Code hands each task to the `claude` program. + +## Claude Code on another computer — the relay, --host + +Claude Code runs where codeaf's engine runs. A desktop reaching an engine on another computer through the relay, or the terminal with `--host`, sees and uses that computer's Claude Code, signed in there; install and sign it in on that computer. diff --git a/internal/manual/chat/claude.md b/internal/manual/chat/claude.md index 89355df1d8..11bb8221b9 100644 --- a/internal/manual/chat/claude.md +++ b/internal/manual/chat/claude.md @@ -4,7 +4,7 @@ `claude` is a program codeaf carries that hands a whole task to the Claude Code you installed and signed in to yourself. codeaf starts `claude` in the task's folder, Claude Code plans and edits with its own tools, and codeaf shows each tool call as a step and its last message as the answer. -**Claude is a worker here, not a model.** codeaf cannot send Claude requests on your Claude plan the way it sends them to OpenRouter or a ChatGPT plan, and it holds no Claude credential. Claude Code runs on whatever account you signed it in to. Anthropic's terms for that account apply to the work. +codeaf holds no Claude credential: Claude Code runs on whatever account you signed it in to, and Anthropic's terms for that account apply to the work. To put a whole conversation or a crew member on Claude Code rather than one task, connect Claude Code as a provider — see "Claude Code as a provider". ## How do I use Claude Code from codeaf — /claude, codeaf claude, via claude @@ -15,6 +15,8 @@ codeaf claude add a --json flag to the status command Asking in words works too: "have Claude Code do this" makes the chat propose the task with `via: "claude"`. When your crew's working seat is a Claude model, Claude Code runs that family (`opus`, `sonnet` or `haiku`); any other model leaves Claude Code on its own default. +With Claude Code connected as a provider, pinning the Worker role to a Claude Code model in Task models sends every task to `claude` without naming it: the task is handed to Claude Code, not run by codeaf's own loop. + ## Where the work lands and what it costs — branch, spending, ceiling It edits files like senior-dev does: on a branch codeaf cut for the run, with whatever it left uncommitted committed there when it ends. A run starts under up to $10.00 and two hours unless the conversation set less. The cost ceiling is passed to Claude Code as `--max-budget-usd`; the hours are a hard stop. diff --git a/internal/modelsource/modelsource.go b/internal/modelsource/modelsource.go index 5e15b0be16..2e69dacd2f 100644 --- a/internal/modelsource/modelsource.go +++ b/internal/modelsource/modelsource.go @@ -561,6 +561,17 @@ func Vendored() []Source { ID: CustomID, Written: CustomID, Name: "Custom OpenAI-compatible API", Listing: ListingModels, Probe: listingProbe(), Preferred: "", }, + { + // CLAUDE CODE IS THE PERSON'S OWN PROGRAM, SIGNED IN ON THEIR OWN + // ACCOUNT. Nothing is dialled at this address: the service's + // transport (internal/claudemodel) answers every request by running + // the installed Claude Code, and its key is only a sentinel that + // says it is signed in. + ID: "claude-code", Written: "claude-code", Name: "Claude Code", ServedAs: "Claude Code", + Address: "http://claude-code.local/v1", + KeyShape: func(key string) bool { return strings.TrimSpace(key) == "claude-code" }, + Listing: ListingNone, Probe: Probe{}, Preferred: "sonnet", + }, } } diff --git a/internal/session/claudeseat.go b/internal/session/claudeseat.go new file mode 100644 index 0000000000..f7b68f126b --- /dev/null +++ b/internal/session/claudeseat.go @@ -0,0 +1,40 @@ +package session + +import ( + "strings" + + "github.com/Agent-Field/codeaf/internal/claudemodel" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" +) + +// claudeProgram is the program a task seated on Claude Code runs as +// (internal/claudecode). +const claudeProgram = "claude" + +// seatOnClaudeCode sends a task to the `claude` program when the person seated +// the worker on Claude Code and the proposal named no program of its own. +// +// THE SEAT DECIDES, NOT THE MODEL THAT PROPOSED. A conversation on any model +// may propose the task; who does it is the worker seat in Settings, and a +// worker seat on Claude Code means Claude Code doing the work in its own +// session, in its own folder on a branch, with the person's words reaching it +// while it works. Its model word rides along as the task's model so the program +// starts the Claude the person picked. +// +// It holds only where a program may be handed work at all: inside a task, and +// where no run road is linked, the task stays codeaf's own. +func (a *Agent) seatOnClaudeCode(spec taskSpec) taskSpec { + if spec.via != "" || !a.mayHandToProgram() { + return spec + } + if _, err := a.delegateFor(claudeProgram); err != nil { + return spec + } + pin, ok := config.CrewPinAt(a.config.ProfileDir, crewroute.Worker) + if !ok || !strings.EqualFold(pin.Provider, claudemodel.ID) && !strings.HasPrefix(pin.Model, claudemodel.ID+"/") { + return spec + } + spec.via = claudeProgram + return spec +} diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index de08894a3c..9dfe7989ec 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -3,11 +3,13 @@ package session import ( "context" "fmt" + "path/filepath" "strings" "sync" "time" "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/claudemodel" account "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/provider" @@ -901,6 +903,15 @@ func (c Config) clientConfig(model string, timeout time.Duration) provider.Confi configured.ModelPrice = c.ModelPrice } configured.RouteGate = c.routeGate(model, configured.Model) + if strings.EqualFold(strings.TrimSpace(c.serviceFor(model).Source.ID), claudemodel.ID) { + // A TURN ON CLAUDE CODE IS CLAUDE CODE WORKING IN THIS FOLDER, so the + // transport is told which one (internal/claudemodel's agent.go). + kept := "" + if c.SessionFile != "" { + kept = filepath.Join(filepath.Dir(c.SessionFile), "claude-code-session.json") + } + configured.HTTPClient = claudemodel.ClientFor(c.Workspace, kept) + } return configured } diff --git a/internal/session/task.go b/internal/session/task.go index 7ec774d474..43fa8f80a4 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -749,6 +749,10 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged // A PROGRAM'S RUN THAT ENDED DONE IS A DEPENDENCY MET, and the graph knows // no node by its id (program_depends.go). spec.dependsOn = a.withoutEndedProgramRuns(spec.dependsOn) + // A WORKER SEAT ON CLAUDE CODE HANDS THE TASK TO CLAUDE CODE ITSELF + // (claudeseat.go): the person chose who does the work, and on that seat + // it is an agent with its own tools rather than a model under codeaf's. + spec = a.seatOnClaudeCode(spec) // A DELEGATE IS RESOLVED BEFORE THE CARD, so a name this machine has no // delegate for is answered with the names it has and nobody is asked to // approve work that could not start (delegate_door.go). From 049f3bcb136d1e5e78076e537cb2615176da9b2e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 7 Nov 2026 17:12:39 -0500 Subject: [PATCH 04/16] change entry names its pull request --- .../{claude-code-provider.md => 2505-claude-code-provider.md} | 1 + 1 file changed, 1 insertion(+) rename docs/changes/unreleased/{claude-code-provider.md => 2505-claude-code-provider.md} (99%) diff --git a/docs/changes/unreleased/claude-code-provider.md b/docs/changes/unreleased/2505-claude-code-provider.md similarity index 99% rename from docs/changes/unreleased/claude-code-provider.md rename to docs/changes/unreleased/2505-claude-code-provider.md index c052b4d40c..ed2b7d68da 100644 --- a/docs/changes/unreleased/claude-code-provider.md +++ b/docs/changes/unreleased/2505-claude-code-provider.md @@ -1,4 +1,5 @@ --- +pr: 2505 kind: added title: Claude Code as a provider, a task program and a crew seat surface: [engine, app, chat] From c1225864fa5ea1faf59dfdd382272b035c762c31 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 7 Nov 2026 17:14:21 -0500 Subject: [PATCH 05/16] comment law: Claude Code may be named where codeaf integrates it, as Codex is --- internal/commentlaw/commentlaw_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/commentlaw/commentlaw_test.go b/internal/commentlaw/commentlaw_test.go index f8112f5d6e..ac5863b909 100644 --- a/internal/commentlaw/commentlaw_test.go +++ b/internal/commentlaw/commentlaw_test.go @@ -29,9 +29,10 @@ var exceptions = []struct { rule int terms map[string]struct{} }{ - {regexp.MustCompile(`\.go$`), regexp.MustCompile(`(?i)codex/gpt|CLI|provider|transport|codexauth|sign.in|model|catalog|account|credential|service|backend|profile|272k|window|context|skills?|plugin|agentskills\.io`), 3, map[string]struct{}{"codex": {}}}, // Codex is an authenticated model service. - {regexp.MustCompile(`^(internal/(skills|home|store|resident|session)/|cmd/codeaf/)`), regexp.MustCompile(`(?i)skills?|plugins?|agentskills\.io|registry|settings\.json`), 3, map[string]struct{}{"claude code": {}, "opencode": {}, "codex": {}}}, // Skill discovery reads other tools' file formats. - {regexp.MustCompile(`^(internal/exec/bare/|internal/session/|internal/tui3/toolstat\.go|cmd/harness-design/)`), regexp.MustCompile(`(?i)\bpi\b`), 3, map[string]struct{}{"pi": {}}}, // The tool library preserves compatible wire schemas and results. + {regexp.MustCompile(`\.go$`), regexp.MustCompile(`(?i)codex/gpt|CLI|provider|transport|codexauth|sign.in|model|catalog|account|credential|service|backend|profile|272k|window|context|skills?|plugin|agentskills\.io`), 3, map[string]struct{}{"codex": {}}}, // Codex is an authenticated model service. + {regexp.MustCompile(`^(internal/(claudeagent|claudemodel|claudecode)/|internal/session/(claudeseat|clientdoor|task)\.go|internal/config/(config|settings|sources|taskmodelrows)\.go|internal/modelsource/modelsource\.go|internal/appwire/wire\.go|internal/delegate/builtin/carried_unix\.go|cmd/codeaf/app(account|claudecode)\.go)`), regexp.MustCompile(`(?i)claude code`), 3, map[string]struct{}{"claude code": {}}}, // Claude Code is a connected service and a program codeaf starts. + {regexp.MustCompile(`^(internal/(skills|home|store|resident|session)/|cmd/codeaf/)`), regexp.MustCompile(`(?i)skills?|plugins?|agentskills\.io|registry|settings\.json`), 3, map[string]struct{}{"claude code": {}, "opencode": {}, "codex": {}}}, // Skill discovery reads other tools' file formats. + {regexp.MustCompile(`^(internal/exec/bare/|internal/session/|internal/tui3/toolstat\.go|cmd/harness-design/)`), regexp.MustCompile(`(?i)\bpi\b`), 3, map[string]struct{}{"pi": {}}}, // The tool library preserves compatible wire schemas and results. } func exempt(rule int, path, text, term string) bool { From c12bc2d6c38f20d42af3b9af861da258bfe2df75 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 7 Nov 2026 17:15:18 -0500 Subject: [PATCH 06/16] Claude Code adapter sits beside Codex's in the funnel law; status cache unlocks from a defer --- cmd/codeaf/appclaudecode.go | 2 +- internal/provider/funnel_law_test.go | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/codeaf/appclaudecode.go b/cmd/codeaf/appclaudecode.go index 90bfb5118e..0751bd4850 100644 --- a/cmd/codeaf/appclaudecode.go +++ b/cmd/codeaf/appclaudecode.go @@ -43,8 +43,8 @@ func (b *appBackend) claudeStatusFresh() claudeagent.Status { func (b *appBackend) forgetClaudeStatus() { claudeStatusCache.Lock() + defer claudeStatusCache.Unlock() claudeStatusCache.at = time.Time{} - claudeStatusCache.Unlock() } // claudeCodePlanWord is the provider row's plan: how the installed Claude Code diff --git a/internal/provider/funnel_law_test.go b/internal/provider/funnel_law_test.go index bbe2d554b8..9df2ce9c98 100644 --- a/internal/provider/funnel_law_test.go +++ b/internal/provider/funnel_law_test.go @@ -239,6 +239,10 @@ var funnelKnownSecondTransports = map[string]string{ // to provider.Client by config, and the provider funnel still owns the call, // its lane, its watch and its record. "internal/codexauth/transport.go": "the provider funnel's Codex wire adapter", + // The same is true of Claude Code's: config attaches it to provider.Client + // for the claude-code service, and it answers the funnel's own request by + // running the person's Claude Code rather than dialling anything. + "internal/claudemodel/claudemodel.go": "the provider funnel's Claude Code adapter", } // TestNothingOutsideTheFunnelTalksToAModelEndpoint is law (a). From 4e8515f3fdaa6f533736fe7c1aa04f5d8553d2fd Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 7 Nov 2026 17:40:09 -0500 Subject: [PATCH 07/16] Claude Code: the provider list never waits for its sign-in; its guide fits the fixed prefix Reading Claude Code's sign-in starts the program, and providers.list sits in front of models.list, so the reading now happens in the background and provider.changed redraws the row when it lands. The claude program's guide moves to programguide beside the others and is cut, with the sec guide's description, so four programs fit the prefix budget. --- cmd/codeaf/appaccount.go | 4 +- cmd/codeaf/appclaudecode.go | 65 +++++++++++++++++++++------ cmd/codeaf/appproviders_test.go | 3 +- internal/claudecode/claudecode.go | 9 +--- internal/programguide/claude.go | 6 +++ internal/programguide/sec.go | 3 +- internal/session/prefixbudget_test.go | 1 + 7 files changed, 66 insertions(+), 25 deletions(-) create mode 100644 internal/programguide/claude.go diff --git a/cmd/codeaf/appaccount.go b/cmd/codeaf/appaccount.go index 9d043d370d..1a9aae6c33 100644 --- a/cmd/codeaf/appaccount.go +++ b/cmd/codeaf/appaccount.go @@ -213,7 +213,7 @@ func (b *appBackend) providerView(p appProvider, sources modelsource.Set, rows m _, held := rows[p.id] view.SignedIn = found && held view.KeyOptional = true - view.Plan = claudeCodePlanWord(b.claudeStatus()) + view.Plan = claudeCodeRowPlan(b.claudeStatus()) case appwire.ProviderBrowser: // The sign-in's key is a word naming where its tokens live, never a // key a person could recognise, so no hint is drawn from it. @@ -309,7 +309,7 @@ func (b *appBackend) CheckKey(provider, key string) (appwire.KeyCheck, error) { func (b *appBackend) CheckProvider(params appwire.KeyParams) (appwire.KeyCheck, error) { p, ok := b.appProviderNamed(params.Provider) if ok && p.kind == appwire.ProviderAgent { - return claudeCodeCheck(b.claudeStatusFresh()), nil + return claudeCodeCheck(b.claudeStatusNow()), nil } if !ok { return appwire.KeyCheck{}, appwire.Errorf(appwire.CodeBadParams, "codeaf cannot connect %s with a key", params.Provider) diff --git a/cmd/codeaf/appclaudecode.go b/cmd/codeaf/appclaudecode.go index 0751bd4850..fe74f48bfa 100644 --- a/cmd/codeaf/appclaudecode.go +++ b/cmd/codeaf/appclaudecode.go @@ -18,27 +18,53 @@ const claudeStatusTTL = time.Minute var claudeStatusCache struct { sync.Mutex - at time.Time - status claudeagent.Status + at time.Time + status claudeagent.Status + reading bool } // claudeStatus is the cached reading of Claude Code's sign-in on the machine -// this engine runs on. -func (b *appBackend) claudeStatus() claudeagent.Status { +// this engine runs on, and whether there is one yet. +// +// A LISTING NEVER WAITS FOR CLAUDE CODE. Reading the sign-in starts the +// program, which takes seconds, and providers.list sits in front of +// models.list and every page that asks whether anything is connected. So a +// reading that is missing or stale is started in the background, the row is +// drawn from what is known now, and provider.changed redraws it when the +// reading lands. +func (b *appBackend) claudeStatus() (claudeagent.Status, bool) { claudeStatusCache.Lock() defer claudeStatusCache.Unlock() - if !claudeStatusCache.at.IsZero() && time.Since(claudeStatusCache.at) < claudeStatusTTL { - return claudeStatusCache.status + known := !claudeStatusCache.at.IsZero() + if known && time.Since(claudeStatusCache.at) < claudeStatusTTL { + return claudeStatusCache.status, true } - claudeStatusCache.status = claudeagent.ReadStatus(context.Background()) - claudeStatusCache.at = time.Now() - return claudeStatusCache.status + if !claudeStatusCache.reading { + claudeStatusCache.reading = true + go b.readClaudeStatus() + } + return claudeStatusCache.status, known } -// claudeStatusFresh reads the sign-in again, for a person who has just asked. -func (b *appBackend) claudeStatusFresh() claudeagent.Status { - b.forgetClaudeStatus() - return b.claudeStatus() +// readClaudeStatus takes one reading and says the row changed. +func (b *appBackend) readClaudeStatus() { + status := claudeagent.ReadStatus(context.Background()) + claudeStatusCache.Lock() + claudeStatusCache.status, claudeStatusCache.at, claudeStatusCache.reading = status, time.Now(), false + claudeStatusCache.Unlock() + if b.emit != nil { + b.providerIDChanged(claudemodel.ID, "") + } +} + +// claudeStatusNow reads the sign-in again and waits for it, for a person who +// has just pressed Check. +func (b *appBackend) claudeStatusNow() claudeagent.Status { + status := claudeagent.ReadStatus(context.Background()) + claudeStatusCache.Lock() + defer claudeStatusCache.Unlock() + claudeStatusCache.status, claudeStatusCache.at = status, time.Now() + return status } func (b *appBackend) forgetClaudeStatus() { @@ -47,6 +73,19 @@ func (b *appBackend) forgetClaudeStatus() { claudeStatusCache.at = time.Time{} } +// claudeCodeRowPlan is the row's plan before and after a reading: with none +// yet, a program that is not there is said at once (finding it starts +// nothing), and one that is there says nothing until its sign-in is read. +func claudeCodeRowPlan(status claudeagent.Status, known bool) string { + if known { + return claudeCodePlanWord(status) + } + if _, err := claudeagent.Binary(); err != nil { + return claudeCodePlanWord(claudeagent.Status{}) + } + return "" +} + // claudeCodePlanWord is the provider row's plan: how the installed Claude Code // is signed in, in the words a person uses for it. func claudeCodePlanWord(status claudeagent.Status) string { diff --git a/cmd/codeaf/appproviders_test.go b/cmd/codeaf/appproviders_test.go index 37fdf5132f..c5138b3de9 100644 --- a/cmd/codeaf/appproviders_test.go +++ b/cmd/codeaf/appproviders_test.go @@ -92,7 +92,8 @@ func TestAppListsEveryProviderTheTerminalOffers(t *testing.T) { for _, p := range providers { got = append(got, p.ID) } - if strings.Join(got, ",") != strings.Join(want, ",") || len(got) != 10 { + // Eleven since Claude Code joined as the last row. + if strings.Join(got, ",") != strings.Join(want, ",") || len(got) != 11 { t.Fatalf("providers = %v, want the terminal's %v", got, want) } byID := appProvidersByID(t, backend) diff --git a/internal/claudecode/claudecode.go b/internal/claudecode/claudecode.go index 3f92f12524..60f9615f82 100644 --- a/internal/claudecode/claudecode.go +++ b/internal/claudecode/claudecode.go @@ -30,6 +30,7 @@ import ( "github.com/Agent-Field/codeaf/internal/claudeagent" "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/programguide" ) // Name is the program's one word: the slash command, the shell verb and the @@ -47,12 +48,6 @@ const stageWork = "work" // person before the run ends. A message that arrives later starts a new task. var idleGrace = 8 * time.Second -// guide is what the conversation's model reads about the program on every -// turn, so it is held to delegate.GuideMax. -const guide = "Hands the task to the person's own Claude Code, which works in the folder with its own tools on " + - "their Claude plan and leaves its changes on the run's branch. Use it when they ask for Claude, or name " + - "Claude Code, to do the work. Its brief is the whole task in plain words." - // unattended is the system prompt added to Claude Code's own. const unattended = "You are running as a codeaf task. The person may send you messages while you work; " + "take them into account. Nobody answers questions mid-task: decide, do the work, check it, and end with a " + @@ -63,7 +58,7 @@ const unattended = "You are running as a codeaf task. The person may send you me var Program = delegate.Delegate{ Name: Name, Summary: "hand a task to your own signed-in Claude Code", - Guide: guide, + Guide: programguide.Claude, Lands: delegate.LandsTree, Listens: true, // A PERSON'S CREW SEAT IS HEARD WHEN IT IS A CLAUDE MODEL. A crew whose diff --git a/internal/programguide/claude.go b/internal/programguide/claude.go new file mode 100644 index 0000000000..96e5001ebb --- /dev/null +++ b/internal/programguide/claude.go @@ -0,0 +1,6 @@ +package programguide + +// Claude is the guide the claude program gives the conversation. It is read on +// every turn, so it is held to delegate.GuideMax, and the prefix measurement +// shares these exact bytes. +const Claude = "When the person asks Claude Code to do the work, on their own Claude plan. Its brief is the whole task." diff --git a/internal/programguide/sec.go b/internal/programguide/sec.go index 7be0a85a45..7eb7233a0b 100644 --- a/internal/programguide/sec.go +++ b/internal/programguide/sec.go @@ -4,6 +4,5 @@ package programguide // is read by the program itself (internal/secaf's scope reading), so the guide // spells the brief's few words exactly as the program reads them. const Sec = "For a security audit of code: the whole repository, or only the changes on this branch. " + - "It hunts for vulnerabilities, traces each from input to sink, sets aside what it cannot show exploitable, " + - "and reports the rest with file, line and fix; it changes no files. Its brief is `whole repository` or " + + "It reports exploitable vulnerabilities with file, line and fix; it changes no files. Its brief is `whole repository` or " + "`changes` (`changes since ` for another base), optionally with `quick` or `thorough`." diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index f5b0a0f7ec..217b1ebf71 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -90,6 +90,7 @@ const leanWindow = 16_000 // none, while this budget must still weigh the Unix shipping maximum. func prefixPrograms() []delegate.Delegate { return []delegate.Delegate{ + {Name: "claude", Guide: programguide.Claude, Lands: delegate.LandsTree}, {Name: "review", Guide: programguide.Review, Lands: delegate.LandsText}, {Name: "sec", Guide: programguide.Sec, Lands: delegate.LandsText}, {Name: "senior-dev", Guide: programguide.SeniorDev, Lands: delegate.LandsTree}, From e45bd1170aa048dfd4f66b78bacb691d4d052e3f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 7 Nov 2026 17:41:42 -0500 Subject: [PATCH 08/16] Claude Code status read runs guarded and unlocks from a defer --- cmd/codeaf/appclaudecode.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/cmd/codeaf/appclaudecode.go b/cmd/codeaf/appclaudecode.go index fe74f48bfa..93acfc09fa 100644 --- a/cmd/codeaf/appclaudecode.go +++ b/cmd/codeaf/appclaudecode.go @@ -9,6 +9,7 @@ import ( "github.com/Agent-Field/codeaf/internal/appwire" "github.com/Agent-Field/codeaf/internal/claudeagent" "github.com/Agent-Field/codeaf/internal/claudemodel" + "github.com/Agent-Field/codeaf/internal/guard" ) // claudeStatusTTL is how long one reading of Claude Code's sign-in is shown @@ -41,22 +42,26 @@ func (b *appBackend) claudeStatus() (claudeagent.Status, bool) { } if !claudeStatusCache.reading { claudeStatusCache.reading = true - go b.readClaudeStatus() + guard.Go("app/claude-code-status", b.readClaudeStatus) } return claudeStatusCache.status, known } // readClaudeStatus takes one reading and says the row changed. func (b *appBackend) readClaudeStatus() { - status := claudeagent.ReadStatus(context.Background()) - claudeStatusCache.Lock() - claudeStatusCache.status, claudeStatusCache.at, claudeStatusCache.reading = status, time.Now(), false - claudeStatusCache.Unlock() + keepClaudeStatus(claudeagent.ReadStatus(context.Background())) if b.emit != nil { b.providerIDChanged(claudemodel.ID, "") } } +// keepClaudeStatus stores one reading and ends the background read. +func keepClaudeStatus(status claudeagent.Status) { + claudeStatusCache.Lock() + defer claudeStatusCache.Unlock() + claudeStatusCache.status, claudeStatusCache.at, claudeStatusCache.reading = status, time.Now(), false +} + // claudeStatusNow reads the sign-in again and waits for it, for a person who // has just pressed Check. func (b *appBackend) claudeStatusNow() claudeagent.Status { From 9e09ebc08267f34c6b68cbf483200c2917e5f0b3 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 7 Nov 2026 18:19:44 -0500 Subject: [PATCH 09/16] claude code: serve codeaf's tools to it over MCP, pass pictures A conversation seated on Claude Code now holds codeaf's belt as mcp__codeaf__* tools answered through the control protocol, so it can propose a task, reach crew members and read the manual. Pictures on the newest message go to it as image blocks. The manual says what is still missing: approvals, effort, a tool list fixed at session start. --- .../unreleased/2505-claude-code-provider.md | 1 + internal/claudeagent/claudeagent.go | 133 +++++++++++- internal/claudemodel/agent.go | 203 +++++++++++++++++- internal/claudemodel/claudemodel.go | 11 +- internal/claudemodel/claudemodel_test.go | 26 ++- internal/manual/chat/claude-code-provider.md | 12 +- 6 files changed, 365 insertions(+), 21 deletions(-) diff --git a/docs/changes/unreleased/2505-claude-code-provider.md b/docs/changes/unreleased/2505-claude-code-provider.md index ed2b7d68da..5245a388fd 100644 --- a/docs/changes/unreleased/2505-claude-code-provider.md +++ b/docs/changes/unreleased/2505-claude-code-provider.md @@ -7,4 +7,5 @@ invalidates: - "The manual said Claude was a worker and never a model: codeaf could not send a request on a Claude plan. Now the person's own signed-in Claude Code is a provider (claude-code, kind agent, no key): a conversation or crew member on claude-code/opus|sonnet|haiku is a live Claude Code session in its folder, small toolless jobs are one-shot `claude -p` calls, and a Worker pinned to it hands tasks to the `claude` program." - "A message typed into a running turn cut the model request; on Claude Code that would interrupt its tool call. The transport now leaves a cut turn running for three seconds and writes the correction into it, as T3 Code does; only a stop with nothing following interrupts." - "providers.list kinds were key, browser and address. A fourth, agent, means a local program on its own sign-in: keys.check reports the version and plan, keys.set connects with no key, and a key sent for it is refused." + - "A conversation on Claude Code was a leaf: it had only its own tools and could not propose a task, reach crew members or read the manual. codeaf's belt is now served to it as an in-process MCP server (mcp__codeaf__*, less the tools it has its own of), each call runs in codeaf's turn loop with its own row, and pictures attached to a message are passed as image blocks." --- diff --git a/internal/claudeagent/claudeagent.go b/internal/claudeagent/claudeagent.go index ca7353c368..7e0c38fe66 100644 --- a/internal/claudeagent/claudeagent.go +++ b/internal/claudeagent/claudeagent.go @@ -70,6 +70,49 @@ type Options struct { Stderr io.Writer // Env is added to the inherited environment. Env []string + // Tools, when set, are tools this client serves to Claude Code inside the + // session, beside its own (see [ToolServer]). + Tools *ToolServer +} + +// ToolServer is a set of tools codeaf serves to Claude Code in-process, over +// the same control channel as everything else: Claude Code's SDK MCP servers. +// +// IT IS HOW CLAUDE CODE REACHES CODEAF'S OWN VERBS. The CLI is told of a +// server of type `sdk` and, instead of starting a process, sends each MCP +// message to this client as an `mcp_message` control request; the reply goes +// back as that request's answer. So propose_task, the team tools and the +// manual are tools Claude Code can call by name (`mcp____`) +// while it keeps its own Bash, Read and Edit. +type ToolServer struct { + // Name is the server's name; Claude Code sees its tools as mcp__Name__tool. + Name string + // List answers which tools there are. + List func() []Tool + // Call runs one call and may block for as long as the tool takes; Claude + // Code waits on it. + Call func(ctx context.Context, call ToolCall) ToolResult +} + +// Tool is one tool a [ToolServer] serves. +type Tool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema json.RawMessage `json:"inputSchema"` +} + +// ToolCall is Claude Code calling a served tool. +type ToolCall struct { + Name string + Arguments json.RawMessage + // ToolUseID is Claude Code's own id for the call. + ToolUseID string +} + +// ToolResult is what a served tool answered. +type ToolResult struct { + Text string + IsError bool } // PermissionRequest is Claude Code asking whether a tool may run. @@ -146,7 +189,11 @@ func Start(ctx context.Context, opts Options) (*Session, error) { go s.read(stdout) initCtx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() - if _, err := s.control(initCtx, map[string]any{"subtype": "initialize"}); err != nil { + initialize := map[string]any{"subtype": "initialize"} + if opts.Tools != nil { + initialize["sdkMcpServers"] = []string{opts.Tools.Name} + } + if _, err := s.control(initCtx, initialize); err != nil { s.kill() return nil, fmt.Errorf("Claude Code did not answer its handshake: %w", err) } @@ -182,17 +229,36 @@ func Args(opts Options) []string { if opts.MaxBudgetUSD > 0 { args = append(args, "--max-budget-usd", fmt.Sprintf("%.2f", opts.MaxBudgetUSD)) } + if opts.Tools != nil { + config, _ := json.Marshal(map[string]any{"mcpServers": map[string]any{opts.Tools.Name: map[string]any{"type": "sdk", "name": opts.Tools.Name}}}) + args = append(args, "--mcp-config", string(config)) + } return args } // Send writes one person's message. It may be sent while a turn is running; // Claude Code takes it in at its next chance. -func (s *Session) Send(text string) error { +func (s *Session) Send(text string) error { return s.SendWith(text, nil) } + +// Image is a picture sent with a message, as Claude's own content block takes +// one: its media type and its bytes in base64. +type Image struct { + MediaType string + Data string +} + +// SendWith is [Session.Send] with pictures, which go before the words. +func (s *Session) SendWith(text string, images []Image) error { + content := make([]map[string]any, 0, len(images)+1) + for _, image := range images { + content = append(content, map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": image.MediaType, "data": image.Data}}) + } + content = append(content, map[string]any{"type": "text", "text": text}) return s.write(map[string]any{ "type": "user", "session_id": "", "parent_tool_use_id": nil, - "message": map[string]any{"role": "user", "content": []map[string]any{{"type": "text", "text": text}}}, + "message": map[string]any{"role": "user", "content": content}, }) } @@ -347,6 +413,10 @@ func (s *Session) answer(id string, raw json.RawMessage) { Subtype string `json:"subtype"` } _ = json.Unmarshal(raw, &head) + if head.Subtype == "mcp_message" && s.opts.Tools != nil { + s.serveTool(id, raw) + return + } if head.Subtype != "can_use_tool" { _ = s.write(map[string]any{"type": "control_response", "response": map[string]any{ "subtype": "error", "request_id": id, "error": "codeaf does not serve " + head.Subtype, @@ -402,3 +472,60 @@ func Text(ev Event) string { } return b.String() } + +// serveTool answers one MCP message for the [ToolServer]: the handshake, the +// list, a call. Notifications carry no id and are acknowledged empty. +func (s *Session) serveTool(id string, raw json.RawMessage) { + var req struct { + Message struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } `json:"message"` + } + _ = json.Unmarshal(raw, &req) + msg := req.Message + var result any = map[string]any{} + switch msg.Method { + case "initialize": + var params struct { + ProtocolVersion string `json:"protocolVersion"` + } + _ = json.Unmarshal(msg.Params, ¶ms) + if params.ProtocolVersion == "" { + params.ProtocolVersion = "2024-11-05" + } + result = map[string]any{"protocolVersion": params.ProtocolVersion, "capabilities": map[string]any{"tools": map[string]any{}}, + "serverInfo": map[string]any{"name": s.opts.Tools.Name, "version": "1"}} + case "tools/list": + tools := []Tool{} + if s.opts.Tools.List != nil { + tools = append(tools, s.opts.Tools.List()...) + } + result = map[string]any{"tools": tools} + case "tools/call": + var params struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + Meta struct { + ToolUseID string `json:"claudecode/toolUseId"` + } `json:"_meta"` + } + _ = json.Unmarshal(msg.Params, ¶ms) + if len(params.Arguments) == 0 { + params.Arguments = json.RawMessage("{}") + } + answer := ToolResult{Text: "codeaf serves no tool calls here", IsError: true} + if s.opts.Tools.Call != nil { + answer = s.opts.Tools.Call(context.Background(), ToolCall{Name: params.Name, Arguments: params.Arguments, ToolUseID: params.Meta.ToolUseID}) + } + result = map[string]any{"content": []any{map[string]any{"type": "text", "text": answer.Text}}, "isError": answer.IsError} + } + reply := map[string]any{"jsonrpc": "2.0", "result": result} + if len(msg.ID) > 0 { + reply["id"] = msg.ID + } + _ = s.write(map[string]any{"type": "control_response", "response": map[string]any{ + "subtype": "success", "request_id": id, "response": map[string]any{"mcp_response": reply}, + }}) +} diff --git a/internal/claudemodel/agent.go b/internal/claudemodel/agent.go index 8d3161bbdc..126d8b74dd 100644 --- a/internal/claudemodel/agent.go +++ b/internal/claudemodel/agent.go @@ -60,8 +60,26 @@ var outwardRules = []string{ // seated is the prompt added to Claude Code's own when it is a conversation. const seated = "You are seated in codeaf as this conversation's agent. Work in the folder with your own tools. " + + "codeaf's own tools are yours too, named mcp__codeaf__*: propose_task for work that should run as its own task, " + + "the team tools to work with your crew, manual for anything about codeaf itself. " + "Pushing, publishing and opening pull requests are the person's to do: say what to run instead." +// native are codeaf tools Claude Code already has its own of, so they are not +// served to it twice: it reads, edits, searches and runs commands its own way. +var native = map[string]bool{ + "bash": true, "read": true, "write": true, "edit": true, "grep": true, "find": true, "ls": true, + "glob": true, "web_fetch": true, "web_search": true, "view_image": true, +} + +// pendingCall is one call Claude Code made to a codeaf tool, waiting for +// codeaf's turn loop to run it and send the result back in its next request. +type pendingCall struct { + id string + name string + args json.RawMessage + result chan claudeagent.ToolResult +} + // live is one conversation's Claude Code session. type live struct { session *claudeagent.Session @@ -82,6 +100,64 @@ type live struct { // none does. adoptable bool letGo *time.Timer + // belt is codeaf's tools as this conversation's latest request carried + // them, less the ones Claude Code has natively. + belt []claudeagent.Tool + // calls are Claude Code's calls to codeaf tools as they arrive; awaiting + // are the ones handed to codeaf and not yet answered. + calls chan *pendingCall + awaiting map[string]*pendingCall +} + +// tools is the served list. +func (l *live) tools() []claudeagent.Tool { + l.mu.Lock() + defer l.mu.Unlock() + return append([]claudeagent.Tool(nil), l.belt...) +} + +// call hands one call to the running turn and waits for codeaf's answer. +func (l *live) call(ctx context.Context, call claudeagent.ToolCall) claudeagent.ToolResult { + id := call.ToolUseID + if id == "" { + id = fmt.Sprintf("%d", time.Now().UnixNano()) + } + pc := &pendingCall{id: "call_" + id, name: call.Name, args: call.Arguments, result: make(chan claudeagent.ToolResult, 1)} + select { + case l.calls <- pc: + case <-l.session.Done(): + return claudeagent.ToolResult{Text: "the session ended", IsError: true} + } + select { + case answer := <-pc.result: + return answer + case <-l.session.Done(): + return claudeagent.ToolResult{Text: "the session ended", IsError: true} + } +} + +// beltOf reads the request's tools into the served list. +func beltOf(raw json.RawMessage) []claudeagent.Tool { + var tools []struct { + Function struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` + } `json:"function"` + } + _ = json.Unmarshal(raw, &tools) + out := make([]claudeagent.Tool, 0, len(tools)) + for _, t := range tools { + if t.Function.Name == "" || native[t.Function.Name] { + continue + } + schema := t.Function.Parameters + if len(schema) == 0 || string(schema) == "null" { + schema = json.RawMessage(`{"type":"object","properties":{}}`) + } + out = append(out, claudeagent.Tool{Name: t.Function.Name, Description: t.Function.Description, InputSchema: schema}) + } + return out } var ( @@ -93,7 +169,7 @@ func liveKey(dir, conversation string) string { return dir + "\x00" + conversati // liveFor answers the session for this conversation, starting one when there // is none or the last one has ended. -func liveFor(ctx context.Context, dir, conversation, model, kept string) (*live, error) { +func liveFor(ctx context.Context, dir, conversation, model, kept string, belt []claudeagent.Tool) (*live, error) { livesMu.Lock() defer livesMu.Unlock() key := liveKey(dir, conversation) @@ -113,10 +189,14 @@ func liveFor(ctx context.Context, dir, conversation, model, kept string) (*live, if err != nil { return nil, err } - l := &live{model: model, kept: kept} + l := &live{model: model, kept: kept, belt: belt, calls: make(chan *pendingCall, 64), awaiting: map[string]*pendingCall{}} options := claudeagent.Options{ Binary: binary, Dir: dir, Model: model, PermissionMode: "acceptEdits", AppendSystemPrompt: seated, Disallowed: outwardRules, Stderr: os.Stderr, + Tools: &claudeagent.ToolServer{Name: "codeaf", List: l.tools, Call: l.call}, + // A codeaf tool may wait on the person (a proposed task, a question), + // so Claude Code is told to wait as long as an hour for one. + Env: []string{"MCP_TOOL_TIMEOUT=3600000"}, OnEvent: func(ev claudeagent.Event) { l.mu.Lock() sink := l.sink @@ -165,12 +245,17 @@ func (t transport) agentTurn(request *http.Request, chat chatRequest, conversati return jsonResponse(request, http.StatusServiceUnavailable, errorBody("this conversation has no folder for Claude Code to work in")) } model := model(chat.Model) - l, err := liveFor(request.Context(), dir, conversation, model, t.kept) + belt := beltOf(chat.Tools) + l, err := liveFor(request.Context(), dir, conversation, model, t.kept, belt) if err != nil { return jsonResponse(request, http.StatusServiceUnavailable, errorBody(err.Error())) } l.turn.Lock() l.mu.Lock() + l.belt = belt + if len(l.awaiting) > 0 { + return l.resume(request, chat, model) + } events, adopted := l.sink, l.adoptable // A TURN THAT IS ADOPTED HAS SEEN EVERYTHING BUT THE CORRECTION, so only // the newest words go in; a fresh session is given the whole history. @@ -196,7 +281,7 @@ func (t transport) agentTurn(request *http.Request, chat chatRequest, conversati l.sink = events } l.mu.Unlock() - if err := l.session.Send(prompt); err != nil { + if err := l.session.SendWith(prompt, newestImages(chat)); err != nil { l.release() return jsonResponse(request, http.StatusServiceUnavailable, errorBody("Claude Code would not take the message: "+err.Error())) } @@ -209,6 +294,55 @@ func (t transport) agentTurn(request *http.Request, chat chatRequest, conversati }, nil } +// resume continues a turn that handed codeaf tool calls out: the results in +// this request answer them, and Claude Code carries on where it waited. Words +// the person added after the results go in as a correction. Runs with l.turn +// and l.mu held. +func (l *live) resume(request *http.Request, chat chatRequest, model string) (*http.Response, error) { + lastAssistant := -1 + for i := len(chat.Messages) - 1; i >= 0; i-- { + if chat.Messages[i].Role == "assistant" { + lastAssistant = i + break + } + } + var words []string + for _, m := range chat.Messages[lastAssistant+1:] { + switch m.Role { + case "tool": + if pc := l.awaiting[m.ToolCallID]; pc != nil { + pc.result <- claudeagent.ToolResult{Text: contentText(m.Content)} + delete(l.awaiting, m.ToolCallID) + } + case "user": + if text := strings.TrimSpace(contentText(m.Content)); text != "" { + words = append(words, text) + } + } + } + // A CALL CODEAF NEVER ANSWERED IS ANSWERED HERE, so Claude Code is not + // left waiting on a tool the person stopped. + for id, pc := range l.awaiting { + pc.result <- claudeagent.ToolResult{Text: "codeaf did not run this call", IsError: true} + delete(l.awaiting, id) + } + events := l.sink + l.seen = len(chat.Messages) + 1 + l.mu.Unlock() + if len(words) > 0 { + if err := l.session.Send(strings.Join(words, "\n\n")); err != nil { + l.release() + return jsonResponse(request, http.StatusServiceUnavailable, errorBody("Claude Code would not take the message: "+err.Error())) + } + } + reader, writer := io.Pipe() + go l.stream(request.Context(), writer, events, model, len(words) > 0) + return &http.Response{ + StatusCode: http.StatusOK, Status: "200 OK", Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: reader, ContentLength: -1, Request: request, + }, nil +} + func (l *live) release() { l.mu.Lock() l.sink = nil @@ -303,6 +437,35 @@ func (l *live) stream(ctx context.Context, w *io.PipeWriter, events <-chan claud case <-settled: answer(last) return + case pc := <-l.calls: + // CLAUDE CODE CALLED A CODEAF TOOL: the call goes back to codeaf's + // turn loop as the model's tool call, which runs it as it runs any + // model's, and the turn waits in Claude Code for the result. + // Calls made together are handed out together. + batch := []*pendingCall{pc} + for gathering := time.After(300 * time.Millisecond); gathering != nil; { + select { + case more := <-l.calls: + batch = append(batch, more) + case <-gathering: + gathering = nil + } + } + calls := make([]any, 0, len(batch)) + l.mu.Lock() + for i, call := range batch { + l.awaiting[call.id] = call + calls = append(calls, map[string]any{"index": i, "id": call.id, "type": "function", + "function": map[string]any{"name": call.name, "arguments": string(call.args)}}) + } + l.mu.Unlock() + write(map[string]any{"role": "assistant", "tool_calls": calls}, "", nil) + write(map[string]any{}, "tool_calls", nil) + _, _ = io.WriteString(w, "data: [DONE]\n\n") + _ = w.Close() + parked = true + l.turn.Unlock() + return case <-l.session.Done(): finish("Claude Code ended before it answered.", nil) return @@ -327,6 +490,10 @@ func (l *live) stream(ctx context.Context, w *io.PipeWriter, events <-chan claud // arrives once, as the reply. held = strings.TrimSpace(b.Text) case "tool_use": + if strings.HasPrefix(b.Name, "mcp__codeaf__") { + // codeaf draws its own tool's row when the call reaches it. + continue + } if held != "" { if !think(held + "\n") { return @@ -355,6 +522,34 @@ func (l *live) stream(ctx context.Context, w *io.PipeWriter, events <-chan claud } } +// newestImages are the pictures attached to the person's newest message, as +// codeaf sends them: data URLs in image_url parts. A picture held anywhere +// else (a URL Claude Code would have to fetch) is left out. +func newestImages(chat chatRequest) []claudeagent.Image { + for i := len(chat.Messages) - 1; i >= 0; i-- { + if chat.Messages[i].Role != "user" { + continue + } + var parts []struct { + Type string `json:"type"` + ImageURL struct { + URL string `json:"url"` + } `json:"image_url"` + } + _ = json.Unmarshal(chat.Messages[i].Content, &parts) + var images []claudeagent.Image + for _, part := range parts { + head, data, ok := strings.Cut(part.ImageURL.URL, ";base64,") + if part.Type != "image_url" || !ok || !strings.HasPrefix(head, "data:image/") { + continue + } + images = append(images, claudeagent.Image{MediaType: strings.TrimPrefix(head, "data:"), Data: data}) + } + return images + } + return nil +} + // keptSession is what a conversation keeps of its Claude Code session. type keptSession struct { Session string `json:"session"` diff --git a/internal/claudemodel/claudemodel.go b/internal/claudemodel/claudemodel.go index 1a64e3d539..f6fa5f7a32 100644 --- a/internal/claudemodel/claudemodel.go +++ b/internal/claudemodel/claudemodel.go @@ -99,10 +99,13 @@ type chatRequest struct { Stream bool `json:"stream"` Tools json.RawMessage `json:"tools"` PromptCacheKey string `json:"prompt_cache_key"` - Messages []struct { - Role string `json:"role"` - Content json.RawMessage `json:"content"` - } `json:"messages"` + Messages []chatMessage `json:"messages"` +} + +type chatMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + ToolCallID string `json:"tool_call_id,omitempty"` } func (t transport) complete(request *http.Request) (*http.Response, error) { diff --git a/internal/claudemodel/claudemodel_test.go b/internal/claudemodel/claudemodel_test.go index 382658f414..d9934e0e1a 100644 --- a/internal/claudemodel/claudemodel_test.go +++ b/internal/claudemodel/claudemodel_test.go @@ -78,15 +78,9 @@ func TestACatalogSpellingOfClaudeIsReadAsItsFamily(t *testing.T) { } func TestATurnBackOnClaudeCodeCarriesWhatAnotherModelSaid(t *testing.T) { - msg := func(role, text string) struct { - Role string `json:"role"` - Content json.RawMessage `json:"content"` - } { + msg := func(role, text string) chatMessage { raw, _ := json.Marshal(text) - return struct { - Role string `json:"role"` - Content json.RawMessage `json:"content"` - }{role, raw} + return chatMessage{Role: role, Content: raw} } chat := chatRequest{} chat.Messages = append(chat.Messages, msg("system", "rules"), msg("user", "one"), msg("assistant", "claude answered"), msg("user", "two"), msg("assistant", "another model answered"), msg("user", "three")) @@ -100,3 +94,19 @@ func TestATurnBackOnClaudeCodeCarriesWhatAnotherModelSaid(t *testing.T) { t.Fatalf("a fresh session: %q", got) } } + +func TestThePictureOnTheNewestMessageGoesToClaudeCode(t *testing.T) { + var chat chatRequest + _ = json.Unmarshal([]byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"what is this"},{"type":"image_url","image_url":{"url":"data:image/png;base64,AAAA"}},{"type":"image_url","image_url":{"url":"https://example.com/x.png"}}]}]}`), &chat) + images := newestImages(chat) + if len(images) != 1 || images[0].MediaType != "image/png" || images[0].Data != "AAAA" { + t.Fatalf("images = %+v", images) + } +} + +func TestCodeafToolsClaudeCodeHasItsOwnOfAreNotServedTwice(t *testing.T) { + belt := beltOf(json.RawMessage(`[{"type":"function","function":{"name":"bash","parameters":{"type":"object"}}},{"type":"function","function":{"name":"propose_task","description":"d","parameters":{"type":"object"}}},{"type":"function","function":{"name":"manual"}}]`)) + if len(belt) != 2 || belt[0].Name != "propose_task" || belt[1].Name != "manual" || string(belt[1].InputSchema) == "" { + t.Fatalf("belt = %+v", belt) + } +} diff --git a/internal/manual/chat/claude-code-provider.md b/internal/manual/chat/claude-code-provider.md index a9e1ea0072..4dab41694c 100644 --- a/internal/manual/chat/claude-code-provider.md +++ b/internal/manual/chat/claude-code-provider.md @@ -14,9 +14,17 @@ A conversation or crew member on a Claude Code model is a live Claude Code sessi Edits are accepted without asking. Pushing, opening or merging a pull request, releases and publishing are refused even when your own Claude Code hooks would allow them. -## What Claude Code cannot do in a conversation — codeaf tools, propose_task, team tools, pictures +## What Claude Code can do in a conversation — codeaf tools, propose_task, team tools, manual, pictures -In that seat Claude Code uses its own tools, not codeaf's: it cannot call `propose_task`, the team tools, `manual`, codeaf's memory or skills, and it reads Claude Code's own settings, hooks and MCP servers instead. Pictures attached to a message are not passed to it; only the words are. A crew member on Claude Code still reports finished to its crew like any member. +It keeps its own tools (Bash, Read, Edit, search) and is also handed codeaf's, as `mcp__codeaf__propose_task`, `mcp__codeaf__manual`, the team tools and the rest of the conversation's belt, less the ones it has its own of (`bash`, `read`, `write`, `edit`, `grep`, `find`, `ls`, `web_fetch`, `web_search`, `view_image`). A call to one of them runs in codeaf exactly as any model's would, with its own row on the page, and Claude Code waits for the result. So it can propose a task, start or message crew members, and read this manual. Pictures attached to your message are passed to it. + +## What Claude Code cannot do in a conversation — approvals, effort, new tools mid-session + +- **It does not ask before editing.** Edits and commands are accepted; only pushing, pull requests, releases and publishing are refused. +- **The effort setting does not reach it.** Claude Code thinks as its own settings say. +- **Its tool list is fixed when its session starts.** A codeaf tool that becomes available later in the conversation reaches it after codeaf restarts or the conversation moves off Claude Code and back. +- **Its own tool calls are not codeaf rows.** codeaf shows them as `▸ Bash …` lines and keeps only its answer, so another model the conversation moves to sees its words, not its edits (the files are on disk). +- It reads Claude Code's own settings, hooks, skills and MCP servers from your machine, not codeaf's skills or memory. ## Talking to Claude Code mid-turn — send a message while it works From b9c4b66b2607cb03504a2da003c38b56ab46061a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 7 Nov 2026 22:35:40 -0500 Subject: [PATCH 10/16] claude code: record its steps as codeaf's, hand it codeaf's skills, fix its context weight Each step Claude Code runs itself is handed up the stream when it finishes and recorded as codeaf's own call and result, so the page draws it, the conversation keeps it and the next model reads it. codeaf's skills reach it as a session plugin, so it makes canvases, and its commands carry the conversation's agent identity. Its input is reported as what the turn's last call read, not the turn's sum, which had made codeaf compact a three-line conversation. --- .../unreleased/2505-claude-code-provider.md | 3 + internal/claudeagent/claudeagent.go | 7 + internal/claudeagent/skills.go | 99 +++++++++ internal/claudeagent/skills_test.go | 40 ++++ internal/claudecode/claudecode.go | 10 + internal/claudemodel/agent.go | 87 +++++++- internal/claudemodel/claudemodel.go | 45 +++- internal/claudemodel/claudemodel_test.go | 39 ++++ internal/claudemodel/steps.go | 200 ++++++++++++++++++ internal/manual/chat/claude-code-provider.md | 16 +- internal/manual/chat/claude.md | 2 +- internal/manual/chat_test.go | 5 + internal/provider/client.go | 5 + internal/provider/sse.go | 3 + internal/provider/stepsran_test.go | 36 ++++ internal/provider/stream.go | 12 ++ internal/session/clientdoor.go | 2 +- internal/session/loop.go | 14 ++ internal/session/stepsran.go | 66 ++++++ internal/session/stepsran_test.go | 73 +++++++ 20 files changed, 743 insertions(+), 21 deletions(-) create mode 100644 internal/claudeagent/skills.go create mode 100644 internal/claudeagent/skills_test.go create mode 100644 internal/claudemodel/steps.go create mode 100644 internal/provider/stepsran_test.go create mode 100644 internal/session/stepsran.go create mode 100644 internal/session/stepsran_test.go diff --git a/docs/changes/unreleased/2505-claude-code-provider.md b/docs/changes/unreleased/2505-claude-code-provider.md index 5245a388fd..e400be0cf7 100644 --- a/docs/changes/unreleased/2505-claude-code-provider.md +++ b/docs/changes/unreleased/2505-claude-code-provider.md @@ -8,4 +8,7 @@ invalidates: - "A message typed into a running turn cut the model request; on Claude Code that would interrupt its tool call. The transport now leaves a cut turn running for three seconds and writes the correction into it, as T3 Code does; only a stop with nothing following interrupts." - "providers.list kinds were key, browser and address. A fourth, agent, means a local program on its own sign-in: keys.check reports the version and plan, keys.set connects with no key, and a key sent for it is refused." - "A conversation on Claude Code was a leaf: it had only its own tools and could not propose a task, reach crew members or read the manual. codeaf's belt is now served to it as an in-process MCP server (mcp__codeaf__*, less the tools it has its own of), each call runs in codeaf's turn loop with its own row, and pictures attached to a message are passed as image blocks." + - "Claude Code's own steps (Bash, Read, Edit, Write, Grep...) were reasoning lines only: codeaf kept just its final answer, so the page had no rows and the next model saw no account of the work. Each finished step is now handed up the stream (provider StreamStepsRan, `codeaf_steps` delta) and recorded as codeaf's own call and result (bash/read/edit/write/grep/find/ls/web_fetch/web_search), drawn, saved, and read by the next model; nothing is run twice." + - "Claude Code reported its whole turn's summed input as the request's input, so a four-step turn looked like a full 200k window and codeaf compacted a three-line conversation. It now reports what the turn's last call read." + - "Claude Code could not see codeaf's skills, so it could not make a canvas. Conversations and tasks on it now load the skills kept for codeaf and other programs as a session plugin (`codeaf:`), and its commands carry the conversation's CODEAF_AGENT_DID, so a canvas it makes is the conversation's." --- diff --git a/internal/claudeagent/claudeagent.go b/internal/claudeagent/claudeagent.go index 7e0c38fe66..1c514b0cf7 100644 --- a/internal/claudeagent/claudeagent.go +++ b/internal/claudeagent/claudeagent.go @@ -73,6 +73,10 @@ type Options struct { // Tools, when set, are tools this client serves to Claude Code inside the // session, beside its own (see [ToolServer]). Tools *ToolServer + // PluginDirs are Claude Code plugin folders loaded for this session only, + // beside the plugins the person installed: how a skill kept for codeaf + // reaches Claude Code without being copied into its own folders. + PluginDirs []string } // ToolServer is a set of tools codeaf serves to Claude Code in-process, over @@ -233,6 +237,9 @@ func Args(opts Options) []string { config, _ := json.Marshal(map[string]any{"mcpServers": map[string]any{opts.Tools.Name: map[string]any{"type": "sdk", "name": opts.Tools.Name}}}) args = append(args, "--mcp-config", string(config)) } + for _, dir := range opts.PluginDirs { + args = append(args, "--plugin-dir", dir) + } return args } diff --git a/internal/claudeagent/skills.go b/internal/claudeagent/skills.go new file mode 100644 index 0000000000..18f8a7da6b --- /dev/null +++ b/internal/claudeagent/skills.go @@ -0,0 +1,99 @@ +package claudeagent + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "sync" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/skills" +) + +// ── CODEAF'S SKILLS, HANDED TO CLAUDE CODE AS A PLUGIN ───────────────────── +// +// A model seated in codeaf is shown every skill the person keeps for codeaf or +// for another program, and fetches one when it applies. Claude Code reads only +// its own folders, so the canvas skill the app installs into .codeaf/skills — +// and anything kept for Codex, Cursor or the shared .agents folder — would be +// invisible to a conversation on Claude Code, which would then answer "make me +// a landing page" with a file nobody can see. +// +// So a session — a conversation's or a task's — starts with one extra plugin, built for this folder: a plugin +// manifest and a `skills` folder of LINKS to the skills where they already +// live. Claude Code lists them as `codeaf:` beside its own and loads one +// by meaning, as it does any skill. Nothing is copied, so a skill updated in +// place is the one Claude Code reads next session. +// +// THE SKILLS CLAUDE CODE ALREADY HAS ARE LEFT OUT: those in a .claude/skills +// folder and those inside a Claude Code plugin it loads itself. A skill another +// folder shadows is left out too, exactly as codeaf's own list leaves it out. + +// skillPluginName is the plugin's name, and so the prefix Claude Code shows +// on each skill. +const skillPluginName = "codeaf" + +// skillPluginMu keeps two conversations in one folder from rebuilding the same +// plugin folder over each other. +var skillPluginMu sync.Mutex + +// SkillPlugin builds the plugin folder for Claude Code working in dir and +// answers its path, or "" when there is no skill to hand over or the folder +// cannot be written. It never fails a session: a Claude Code without codeaf's +// skills still works. Pass it as [Options.PluginDirs]. +func SkillPlugin(dir string) string { + login, err := home.Login() + if err != nil { + return "" + } + found, err := skills.Discover(skills.Options{ProjectDir: dir, HomeDir: login, OmitSize: true}) + if err != nil { + return "" + } + links := map[string]string{} + for _, skill := range found { + if skill.Shadowed || skill.Plugin != "" || skill.Root == ".claude/skills" || skill.Root == skills.RootClaudePlugins { + continue + } + if _, taken := links[skill.Name]; !taken && skill.Name != "" { + links[skill.Name] = skill.Dir + } + } + if len(links) == 0 { + return "" + } + sum := sha256.Sum256([]byte(dir)) + root := home.Join("claude-code", "skills", hex.EncodeToString(sum[:6])) + skillPluginMu.Lock() + defer skillPluginMu.Unlock() + // THE FOLDER IS REBUILT EVERY TIME, so a skill removed since the last + // session is not still offered through a stale link. + if err := os.RemoveAll(filepath.Join(root, "skills")); err != nil { + return "" + } + if err := os.MkdirAll(filepath.Join(root, ".claude-plugin"), 0o700); err != nil { + return "" + } + if err := os.MkdirAll(filepath.Join(root, "skills"), 0o700); err != nil { + return "" + } + manifest, _ := json.Marshal(map[string]string{ + "name": skillPluginName, "version": "1.0.0", + "description": "The skills the person keeps for codeaf and other programs, handed to Claude Code by codeaf.", + }) + if err := os.WriteFile(filepath.Join(root, ".claude-plugin", "plugin.json"), manifest, 0o600); err != nil { + return "" + } + linked := 0 + for name, target := range links { + if os.Symlink(target, filepath.Join(root, "skills", name)) == nil { + linked++ + } + } + if linked == 0 { + return "" + } + return root +} diff --git a/internal/claudeagent/skills_test.go b/internal/claudeagent/skills_test.go new file mode 100644 index 0000000000..e0f203ca32 --- /dev/null +++ b/internal/claudeagent/skills_test.go @@ -0,0 +1,40 @@ +package claudeagent + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCodeafsOwnSkillsReachClaudeCodeAndItsOwnAreNotHandedTwice(t *testing.T) { + login := t.TempDir() + t.Setenv("CODEAF_HOME", login) + write := func(root, name string) { + dir := filepath.Join(login, root, name) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + body := "---\nname: " + name + "\ndescription: does " + name + "\n---\nbody\n" + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + write(".codeaf/skills", "workaf-canvas") + write(".agents/skills", "shared-one") + write(".claude/skills", "claudes-own") + root := SkillPlugin(t.TempDir()) + if root == "" { + t.Fatal("no plugin was built for skills codeaf has and Claude Code does not") + } + if _, err := os.Stat(filepath.Join(root, ".claude-plugin", "plugin.json")); err != nil { + t.Fatalf("the plugin has no manifest: %v", err) + } + for _, name := range []string{"workaf-canvas", "shared-one"} { + if _, err := os.Stat(filepath.Join(root, "skills", name, "SKILL.md")); err != nil { + t.Fatalf("%s is not linked: %v", name, err) + } + } + if _, err := os.Lstat(filepath.Join(root, "skills", "claudes-own")); err == nil { + t.Fatal("a skill Claude Code already reads was handed to it a second time") + } +} diff --git a/internal/claudecode/claudecode.go b/internal/claudecode/claudecode.go index 60f9615f82..ea5116c778 100644 --- a/internal/claudecode/claudecode.go +++ b/internal/claudecode/claudecode.go @@ -119,6 +119,8 @@ func run(ctx context.Context, host delegate.Host, brief, model, resume string) { Binary: binary, Dir: host.Workspace(), Model: model, Resume: resume, PermissionMode: "acceptEdits", AppendSystemPrompt: unattended, MaxBudgetUSD: ceilings.CostUSD, Disallowed: outwardRules, + // A TASK SEES THE SKILLS A CONVERSATION DOES, the canvas among them. + PluginDirs: pluginDirs(host.Workspace()), CanUseTool: func(_ context.Context, req claudeagent.PermissionRequest) claudeagent.Decision { decision := policy(req) if !decision.Allow { @@ -407,3 +409,11 @@ func resultText(raw json.RawMessage) string { } return b.String() } + +// pluginDirs is the plugin of codeaf's skills for dir, when there is one. +func pluginDirs(dir string) []string { + if root := claudeagent.SkillPlugin(dir); root != "" { + return []string{root} + } + return nil +} diff --git a/internal/claudemodel/agent.go b/internal/claudemodel/agent.go index 126d8b74dd..cb333c328c 100644 --- a/internal/claudemodel/agent.go +++ b/internal/claudemodel/agent.go @@ -107,6 +107,11 @@ type live struct { // are the ones handed to codeaf and not yet answered. calls chan *pendingCall awaiting map[string]*pendingCall + // started are Claude Code's own calls waiting for their results, by its + // id for them. Only the turn's stream reads or writes it, and one stream + // runs at a time, a cut one's calls carrying over to the stream that + // adopts its turn. + started map[string]startedStep } // tools is the served list. @@ -122,7 +127,7 @@ func (l *live) call(ctx context.Context, call claudeagent.ToolCall) claudeagent. if id == "" { id = fmt.Sprintf("%d", time.Now().UnixNano()) } - pc := &pendingCall{id: "call_" + id, name: call.Name, args: call.Arguments, result: make(chan claudeagent.ToolResult, 1)} + pc := &pendingCall{id: stepPrefix + id, name: call.Name, args: call.Arguments, result: make(chan claudeagent.ToolResult, 1)} select { case l.calls <- pc: case <-l.session.Done(): @@ -169,7 +174,7 @@ func liveKey(dir, conversation string) string { return dir + "\x00" + conversati // liveFor answers the session for this conversation, starting one when there // is none or the last one has ended. -func liveFor(ctx context.Context, dir, conversation, model, kept string, belt []claudeagent.Tool) (*live, error) { +func liveFor(ctx context.Context, dir, conversation, model, kept string, env []string, belt []claudeagent.Tool) (*live, error) { livesMu.Lock() defer livesMu.Unlock() key := liveKey(dir, conversation) @@ -189,14 +194,15 @@ func liveFor(ctx context.Context, dir, conversation, model, kept string, belt [] if err != nil { return nil, err } - l := &live{model: model, kept: kept, belt: belt, calls: make(chan *pendingCall, 64), awaiting: map[string]*pendingCall{}} + l := &live{model: model, kept: kept, belt: belt, calls: make(chan *pendingCall, 64), awaiting: map[string]*pendingCall{}, started: map[string]startedStep{}} options := claudeagent.Options{ Binary: binary, Dir: dir, Model: model, PermissionMode: "acceptEdits", AppendSystemPrompt: seated, Disallowed: outwardRules, Stderr: os.Stderr, - Tools: &claudeagent.ToolServer{Name: "codeaf", List: l.tools, Call: l.call}, + Tools: &claudeagent.ToolServer{Name: "codeaf", List: l.tools, Call: l.call}, + PluginDirs: pluginDirs(dir), // A codeaf tool may wait on the person (a proposed task, a question), // so Claude Code is told to wait as long as an hour for one. - Env: []string{"MCP_TOOL_TIMEOUT=3600000"}, + Env: append([]string{"MCP_TOOL_TIMEOUT=3600000"}, env...), OnEvent: func(ev claudeagent.Event) { l.mu.Lock() sink := l.sink @@ -227,6 +233,14 @@ func liveFor(ctx context.Context, dir, conversation, model, kept string, belt [] return l, nil } +// pluginDirs is the plugin of codeaf's skills for dir, when there is one. +func pluginDirs(dir string) []string { + if root := claudeagent.SkillPlugin(dir); root != "" { + return []string{root} + } + return nil +} + // CloseAll ends every live session, for a process that is shutting down. func CloseAll() { livesMu.Lock() @@ -246,7 +260,7 @@ func (t transport) agentTurn(request *http.Request, chat chatRequest, conversati } model := model(chat.Model) belt := beltOf(chat.Tools) - l, err := liveFor(request.Context(), dir, conversation, model, t.kept, belt) + l, err := liveFor(request.Context(), dir, conversation, model, t.kept, envFrom(request.Context()), belt) if err != nil { return jsonResponse(request, http.StatusServiceUnavailable, errorBody(err.Error())) } @@ -262,7 +276,7 @@ func (t transport) agentTurn(request *http.Request, chat chatRequest, conversati from := l.seen switch { case adopted: - from = len(chat.Messages) + from = len(spoken(chat.Messages)) case l.turns == 0: from = 0 } @@ -272,7 +286,7 @@ func (t transport) agentTurn(request *http.Request, chat chatRequest, conversati l.turn.Unlock() return jsonResponse(request, http.StatusBadRequest, errorBody("there is nothing new to say to Claude Code")) } - l.seen = len(chat.Messages) + 1 + l.seen = len(spoken(chat.Messages)) + 1 if adopted { l.letGo.Stop() l.adoptable = false @@ -327,7 +341,7 @@ func (l *live) resume(request *http.Request, chat chatRequest, model string) (*h delete(l.awaiting, id) } events := l.sink - l.seen = len(chat.Messages) + 1 + l.seen = len(spoken(chat.Messages)) + 1 l.mu.Unlock() if len(words) > 0 { if err := l.session.Send(strings.Join(words, "\n\n")); err != nil { @@ -406,6 +420,9 @@ func (l *live) stream(ctx context.Context, w *io.PipeWriter, events <-chan claud defer beat.Stop() started := time.Now() running, held := "", "" + // weight is how much the newest call of the turn read: its input, + // cached or not. + weight := 0 // settled fires a while after a result in a turn that took a correction; // until then that result waits in last, because the correction may yet // start a turn of its own whose result is the real answer. @@ -423,6 +440,15 @@ func (l *live) stream(ctx context.Context, w *io.PipeWriter, events <-chan claud } _ = json.Unmarshal(raw, &r) in := r.Usage.InputTokens + r.Usage.CacheReadTokens + r.Usage.CacheCreationTokens + // THE RESULT'S INPUT IS EVERY CALL OF THE TURN ADDED UP: a turn of four + // steps reads its context four times. codeaf reads a request's input as + // how full the conversation is, and a sum four times the context made it + // compact a three-line conversation as though it filled the window. So + // the input reported is what the turn's LAST call read, which is the + // conversation's actual weight. + if weight > 0 { + in = weight + } finish(strings.TrimSpace(r.Result), map[string]any{"prompt_tokens": in, "completion_tokens": r.Usage.OutputTokens, "total_tokens": in + r.Usage.OutputTokens}) } for { @@ -481,6 +507,9 @@ func (l *live) stream(ctx context.Context, w *io.PipeWriter, events <-chan claud } switch ev.Type { case "assistant": + if read := callContext(ev.Raw); read > 0 { + weight = read + } for _, b := range contentBlocks(ev.Raw) { switch b.Type { case "text": @@ -502,6 +531,9 @@ func (l *live) stream(ctx context.Context, w *io.PipeWriter, events <-chan claud } running = b.Name + " " + describe(b.Input) started = time.Now() + if b.ID != "" { + l.started[b.ID] = startedStep{name: b.Name, input: b.Input, at: started} + } if !think("▸ " + running + "\n") { return } @@ -509,6 +541,22 @@ func (l *live) stream(ctx context.Context, w *io.PipeWriter, events <-chan claud } case "user": running = "" + // EACH FINISHED STEP GOES UP AS CODEAF'S OWN CALL, so it is + // drawn and written down where every tool's is (steps.go). + var ran []ranStep + for _, result := range toolResults(ev.Raw) { + call, ok := l.started[result.id] + if !ok { + continue + } + delete(l.started, result.id) + if step, ok := stepFor(result.id, call, result.text, result.isError); ok { + ran = append(ran, step) + } + } + if len(ran) > 0 && !write(map[string]any{"codeaf_steps": ran}, "", nil) { + return + } case "result": l.keep() if steered { @@ -588,6 +636,9 @@ func (l *live) keep() { // that started elsewhere, or what another model said and was told while the // person had moved the conversation off Claude Code and back. func turnPrompt(chat chatRequest, from int) string { + // FROM COUNTS WHAT WAS SAID, not Claude Code's own steps codeaf wrote + // down: those are its session's own history (steps.go). + chat.Messages = spoken(chat.Messages) last := -1 for i := len(chat.Messages) - 1; i >= 0; i-- { if chat.Messages[i].Role == "user" { @@ -624,12 +675,30 @@ func turnPrompt(chat chatRequest, from int) string { } type contentBlock struct { + ID string `json:"id"` Type string `json:"type"` Text string `json:"text"` Name string `json:"name"` Input json.RawMessage `json:"input"` } +// callContext is how many tokens one of Claude Code's calls read, from the +// usage its assistant message carries, or 0 when it says none. +func callContext(raw json.RawMessage) int { + var line struct { + Message struct { + Usage struct { + InputTokens int `json:"input_tokens"` + CacheReadTokens int `json:"cache_read_input_tokens"` + CacheCreationTokens int `json:"cache_creation_input_tokens"` + } `json:"usage"` + } `json:"message"` + } + _ = json.Unmarshal(raw, &line) + u := line.Message.Usage + return u.InputTokens + u.CacheReadTokens + u.CacheCreationTokens +} + func contentBlocks(raw json.RawMessage) []contentBlock { var line struct { Message struct { diff --git a/internal/claudemodel/claudemodel.go b/internal/claudemodel/claudemodel.go index f6fa5f7a32..aab34c7c34 100644 --- a/internal/claudemodel/claudemodel.go +++ b/internal/claudemodel/claudemodel.go @@ -68,13 +68,43 @@ func Client() *http.Client { return ClientIn("") } // ClientIn is the client for a conversation working in dir, whose turns with // tools are turns of a Claude Code session in that folder. -func ClientIn(dir string) *http.Client { return ClientFor(dir, "") } +func ClientIn(dir string) *http.Client { return ClientAt(Seat{Dir: dir}) } -// ClientFor is [ClientIn] for a conversation that keeps its Claude Code session -// in kept, so the conversation picks up the same session after codeaf restarts -// rather than starting Claude Code over with the history written out. -func ClientFor(dir, kept string) *http.Client { - return &http.Client{Transport: transport{ask: claudeagent.Once, dir: dir, kept: kept}} +// Seat is where a conversation's Claude Code session works and what it carries. +type Seat struct { + // Dir is the conversation's folder. + Dir string + // Kept is the file the session's id is kept in, so the conversation picks + // up the same session after codeaf restarts rather than starting Claude + // Code over with the history written out; "" keeps nothing. + Kept string +} + +type envKey struct{} + +// WithEnv carries variables a request's turn wants in Claude Code's +// environment, and so in every command it runs: the conversation's agent +// identity, which a tool such as `canvas` records as who made a change, +// exactly as a command codeaf runs sees it. +// +// IT RIDES THE REQUEST, NOT THE CLIENT, because a conversation's first client +// is built before its identity is settled; the turn that sends the request +// always knows it. It is read when the conversation's session starts. +func WithEnv(ctx context.Context, env []string) context.Context { + if len(env) == 0 { + return ctx + } + return context.WithValue(ctx, envKey{}, env) +} + +func envFrom(ctx context.Context) []string { + env, _ := ctx.Value(envKey{}).([]string) + return env +} + +// ClientAt is the client for a conversation seated as seat says. +func ClientAt(seat Seat) *http.Client { + return &http.Client{Transport: transport{ask: claudeagent.Once, dir: seat.Dir, kept: seat.Kept}} } type transport struct { @@ -106,6 +136,9 @@ type chatMessage struct { Role string `json:"role"` Content json.RawMessage `json:"content"` ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls []struct { + ID string `json:"id"` + } `json:"tool_calls,omitempty"` } func (t transport) complete(request *http.Request) (*http.Response, error) { diff --git a/internal/claudemodel/claudemodel_test.go b/internal/claudemodel/claudemodel_test.go index d9934e0e1a..391c49c557 100644 --- a/internal/claudemodel/claudemodel_test.go +++ b/internal/claudemodel/claudemodel_test.go @@ -110,3 +110,42 @@ func TestCodeafToolsClaudeCodeHasItsOwnOfAreNotServedTwice(t *testing.T) { t.Fatalf("belt = %+v", belt) } } + +func TestClaudeCodesOwnStepsAreSpelledAsCodeafsCalls(t *testing.T) { + edit, ok := stepFor("t1", startedStep{name: "Edit", input: json.RawMessage(`{"file_path":"a.go","old_string":"x","new_string":"y"}`)}, "ok", false) + if !ok || edit.ID != "claude_code_t1" || edit.Name != "edit" || string(edit.Arguments) != `{"edits":[{"newText":"y","oldText":"x"}],"path":"a.go"}` { + t.Fatalf("edit = %+v %s", edit, edit.Arguments) + } + bash, ok := stepFor("t2", startedStep{name: "Bash", input: json.RawMessage(`{"command":"go test ./...","description":"run tests"}`)}, "FAIL", true) + if !ok || bash.Name != "bash" || string(bash.Arguments) != `{"command":"go test ./..."}` || !bash.IsError { + t.Fatalf("bash = %+v %s", bash, bash.Arguments) + } + if _, ok := stepFor("t3", startedStep{name: "TodoWrite", input: json.RawMessage(`{}`)}, "", false); ok { + t.Fatal("a step codeaf has no tool for was spelled as one") + } + long, _ := stepFor("t4", startedStep{name: "Read", input: json.RawMessage(`{"file_path":"big"}`)}, strings.Repeat("x", 40_000), false) + if len(long.Output) > stepOutputBudget+100 || !strings.Contains(long.Output, "bytes not kept") { + t.Fatalf("a long output was kept at %d bytes", len(long.Output)) + } +} + +func TestClaudeCodeIsNotToldItsOwnStepsAsSomethingItMissed(t *testing.T) { + text := func(s string) json.RawMessage { raw, _ := json.Marshal(s); return raw } + step := chatMessage{Role: "assistant", Content: text("")} + step.ToolCalls = append(step.ToolCalls, struct { + ID string `json:"id"` + }{ID: "claude_code_t1"}) + chat := chatRequest{Messages: []chatMessage{ + {Role: "system", Content: text("be codeaf")}, + {Role: "user", Content: text("make notes")}, + step, + {Role: "tool", ToolCallID: "claude_code_t1", Content: text("SECRET-STEP-OUTPUT")}, + {Role: "assistant", Content: text("done")}, + {Role: "user", Content: text("what changed?")}, + }} + // The session saw the system prompt, the request and its own answer. + prompt := turnPrompt(chat, 3) + if prompt != "what changed?" { + t.Fatalf("Claude Code was told %q", prompt) + } +} diff --git a/internal/claudemodel/steps.go b/internal/claudemodel/steps.go new file mode 100644 index 0000000000..6a9e375adb --- /dev/null +++ b/internal/claudemodel/steps.go @@ -0,0 +1,200 @@ +package claudemodel + +import ( + "encoding/json" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +// ── CLAUDE CODE'S OWN STEPS, WRITTEN DOWN IN CODEAF'S FORMAT ─────────────── +// +// Claude Code runs its own Bash, Read and Edit inside one request, so codeaf's +// turn loop never sees them as calls: left alone, a conversation on Claude +// Code would keep only Claude's final words, its page would show no rows for +// the work, and a model the conversation moved to next would read "done" with +// no account of what was done. +// +// So each of its steps, when its result arrives, is translated into the call +// codeaf's own belt would have made — `bash`, `read`, `edit`, `write`, `grep`, +// `find`, `ls`, `web_fetch`, `web_search`, with codeaf's argument names — and +// handed up the stream as a finished step (internal/provider's +// StreamStepsRan). The turn loop records the call and its result as it records +// any tool's, so the row is drawn, the conversation's file keeps it, and the +// next model reads it in its history as if codeaf had run it. +// +// A STEP WITH NO CODEAF COUNTERPART IS NOT RECORDED: a sub-agent, a to-do +// list, a skill being loaded. Its line still shows while it runs; what it +// changed on disk is on disk, and the next model can read it there. + +// stepPrefix starts the id of every call Claude Code made, its own and +// codeaf's alike, so a turn can tell the history it already holds from what +// was said while another model had the conversation. +const stepPrefix = "claude_code_" + +// stepOutputBudget bounds one step's recorded result. A step's whole output +// already reached Claude Code; this is the account of it that is kept, so a +// long file read is not kept twice at full size. +const stepOutputBudget = 16 * 1024 + +// ranStep is one finished step, as the stream hands it up. +type ranStep struct { + ID string `json:"id"` + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + Output string `json:"output"` + IsError bool `json:"is_error,omitempty"` + TookMS int64 `json:"took_ms"` +} + +// startedStep is a call of Claude Code's own that has not had its result yet. +type startedStep struct { + name string + input json.RawMessage + at time.Time +} + +// stepFor translates one of Claude Code's tool calls and its result into +// codeaf's spelling, or answers false for a tool codeaf has no counterpart of. +func stepFor(id string, started startedStep, output string, isError bool) (ranStep, bool) { + var in map[string]any + _ = json.Unmarshal(started.input, &in) + text := func(key string) string { + value, _ := in[key].(string) + return value + } + var name string + var args map[string]any + switch started.name { + case "Bash": + name, args = "bash", map[string]any{"command": text("command")} + case "Read": + name, args = "read", map[string]any{"path": text("file_path")} + for _, key := range []string{"offset", "limit"} { + if n, ok := in[key].(float64); ok { + args[key] = int(n) + } + } + case "Write": + name, args = "write", map[string]any{"path": text("file_path"), "content": text("content")} + case "Edit": + name, args = "edit", map[string]any{"path": text("file_path"), "edits": []map[string]string{{"oldText": text("old_string"), "newText": text("new_string")}}} + case "MultiEdit": + var edits []map[string]string + list, _ := in["edits"].([]any) + for _, item := range list { + if edit, ok := item.(map[string]any); ok { + old, _ := edit["old_string"].(string) + replacement, _ := edit["new_string"].(string) + edits = append(edits, map[string]string{"oldText": old, "newText": replacement}) + } + } + name, args = "edit", map[string]any{"path": text("file_path"), "edits": edits} + case "Grep": + name, args = "grep", map[string]any{"pattern": text("pattern")} + if path := text("path"); path != "" { + args["path"] = path + } + if glob := text("glob"); glob != "" { + args["glob"] = glob + } + case "Glob": + name, args = "find", map[string]any{"pattern": text("pattern")} + if path := text("path"); path != "" { + args["path"] = path + } + case "LS": + name, args = "ls", map[string]any{"path": text("path")} + case "WebFetch": + name, args = "web_fetch", map[string]any{"url": text("url")} + case "WebSearch": + name, args = "web_search", map[string]any{"query": text("query")} + default: + return ranStep{}, false + } + raw, err := json.Marshal(args) + if err != nil { + return ranStep{}, false + } + return ranStep{ + ID: stepPrefix + id, Name: name, Arguments: raw, + Output: clip(output), IsError: isError, TookMS: time.Since(started.at).Milliseconds(), + }, true +} + +// clip keeps the head and the tail of a long output, which is where a +// command's command line and its verdict are. +func clip(output string) string { + if len(output) <= stepOutputBudget { + return output + } + head, tail := output[:stepOutputBudget*3/4], output[len(output)-stepOutputBudget/4:] + for !utf8.ValidString(head) && len(head) > 0 { + head = head[:len(head)-1] + } + for !utf8.ValidString(tail) && len(tail) > 0 { + tail = tail[1:] + } + return head + "\n… (" + strconv.Itoa(len(output)-len(head)-len(tail)) + " bytes not kept) …\n" + tail +} + +// toolResult is one result in Claude Code's stream. +type toolResult struct { + id string + text string + isError bool +} + +// toolResults reads the results in a "user" event of Claude Code's stream: the +// id of the call each answers, its text, and whether it failed. +func toolResults(raw json.RawMessage) []toolResult { + var line struct { + Message struct { + Content []struct { + Type string `json:"type"` + ToolUseID string `json:"tool_use_id"` + Content json.RawMessage `json:"content"` + IsError bool `json:"is_error"` + } `json:"content"` + } `json:"message"` + } + _ = json.Unmarshal(raw, &line) + var out []toolResult + for _, block := range line.Message.Content { + if block.Type != "tool_result" || block.ToolUseID == "" { + continue + } + out = append(out, toolResult{block.ToolUseID, contentText(block.Content), block.IsError}) + } + return out +} + +// ownHistory says a message is one of Claude Code's own steps or codeaf +// calls, which its session already holds and must not be told again. +func ownHistory(m chatMessage) bool { + if m.Role == "tool" { + return strings.HasPrefix(m.ToolCallID, stepPrefix) + } + if m.Role != "assistant" || len(m.ToolCalls) == 0 { + return false + } + for _, call := range m.ToolCalls { + if !strings.HasPrefix(call.ID, stepPrefix) { + return false + } + } + return true +} + +// spoken is the conversation without Claude Code's own steps: what was SAID, +// which is what a session's count of what it has seen counts. +func spoken(messages []chatMessage) []chatMessage { + out := make([]chatMessage, 0, len(messages)) + for _, m := range messages { + if !ownHistory(m) { + out = append(out, m) + } + } + return out +} diff --git a/internal/manual/chat/claude-code-provider.md b/internal/manual/chat/claude-code-provider.md index 4dab41694c..92f83766d6 100644 --- a/internal/manual/chat/claude-code-provider.md +++ b/internal/manual/chat/claude-code-provider.md @@ -10,7 +10,11 @@ Nothing is billed per call on a Claude plan, so these models show `on your Claud ## How a conversation on Claude Code works — Claude Code does the work with its own tools -A conversation or crew member on a Claude Code model is a live Claude Code session working in that conversation's folder, held open between turns. Your message goes in; Claude Code reads, edits and runs commands with its own tools; each tool call shows in the thinking line as `▸ Bash …` or `▸ Edit …`, and its last message is the reply. A long command says `still …` every 20 seconds. +A conversation or crew member on a Claude Code model is a live Claude Code session working in that conversation's folder, held open between turns. Your message goes in; Claude Code reads, edits and runs commands with its own tools; each tool call shows in the thinking line as `▸ Bash …` or `▸ Edit …` while it runs, and its last message is the reply. A long command says `still …` every 20 seconds. + +## Claude Code's edits and commands in the conversation — rows, saved, seen by the next model + +When one of Claude Code's own steps finishes, codeaf writes it down in its own format: Bash becomes a `bash` row, Read `read`, Edit and MultiEdit `edit`, Write `write`, Grep `grep`, Glob `find`, LS `ls`, WebFetch `web_fetch`, WebSearch `web_search` — each with its result, as if codeaf had run it. They are saved in the conversation, drawn again when it is reopened, and read by any model the conversation moves to next. Nothing is run twice. A result longer than 16 KB is kept as its start and end. Steps with no codeaf counterpart — a sub-agent, a to-do list, loading a skill — show only while they run. Edits are accepted without asking. Pushing, opening or merging a pull request, releases and publishing are refused even when your own Claude Code hooks would allow them. @@ -18,13 +22,17 @@ Edits are accepted without asking. Pushing, opening or merging a pull request, r It keeps its own tools (Bash, Read, Edit, search) and is also handed codeaf's, as `mcp__codeaf__propose_task`, `mcp__codeaf__manual`, the team tools and the rest of the conversation's belt, less the ones it has its own of (`bash`, `read`, `write`, `edit`, `grep`, `find`, `ls`, `web_fetch`, `web_search`, `view_image`). A call to one of them runs in codeaf exactly as any model's would, with its own row on the page, and Claude Code waits for the result. So it can propose a task, start or message crew members, and read this manual. Pictures attached to your message are passed to it. +## Canvas and skills on Claude Code — make a canvas, codeaf:workaf-canvas + +The skills you keep for codeaf and other programs (`.codeaf/skills`, `.agents/skills`, Codex's, Cursor's) are handed to Claude Code as a plugin, so it lists them as `codeaf:` beside its own and loads one when it applies. Skills already in a `.claude/skills` folder or a Claude Code plugin are not handed over twice. So in the desktop app, where the `canvas` command and the `workaf-canvas` skill are installed, Claude Code makes and edits canvases ("mock up a landing page", "make a deck") exactly as other models do, and each canvas is recorded as this conversation's, because its commands carry the conversation's identity (`CODEAF_AGENT_DID`). The skill list is read when its session starts. + ## What Claude Code cannot do in a conversation — approvals, effort, new tools mid-session - **It does not ask before editing.** Edits and commands are accepted; only pushing, pull requests, releases and publishing are refused. - **The effort setting does not reach it.** Claude Code thinks as its own settings say. - **Its tool list is fixed when its session starts.** A codeaf tool that becomes available later in the conversation reaches it after codeaf restarts or the conversation moves off Claude Code and back. -- **Its own tool calls are not codeaf rows.** codeaf shows them as `▸ Bash …` lines and keeps only its answer, so another model the conversation moves to sees its words, not its edits (the files are on disk). -- It reads Claude Code's own settings, hooks, skills and MCP servers from your machine, not codeaf's skills or memory. +- **codeaf keeps no copy of the files it changed.** Its steps are rows in the conversation, but `/rewind` over them takes back the conversation, not Claude Code's edits; use git to put files back. +- It reads Claude Code's own settings, hooks and MCP servers from your machine, and codeaf's skills as above, not codeaf's memory. ## Talking to Claude Code mid-turn — send a message while it works @@ -40,7 +48,7 @@ With only Claude Code connected, the small jobs — naming a conversation, titli ## Auto and Claude Code models — why Auto never picks them -Auto never picks a Claude Code model for a task role; Task models says `Auto never picks these · pin one to a role`. Pin one: a Worker pinned to Claude Code hands each task to the `claude` program. +Auto never picks a Claude Code model for a task role; Task models says `Auto never picks these · pin one to a role · edits without asking`. Pin one: a Worker pinned to Claude Code hands each task to the `claude` program. ## Claude Code on another computer — the relay, --host diff --git a/internal/manual/chat/claude.md b/internal/manual/chat/claude.md index 11bb8221b9..3bd1f630de 100644 --- a/internal/manual/chat/claude.md +++ b/internal/manual/chat/claude.md @@ -37,4 +37,4 @@ Each run keeps Claude Code's own session id, so `codeaf claude run --resume It may not push, open or merge a pull request, cut a release, or publish a package. Those commands are refused with `Pushing, publishing and opening pull requests are the person's to do; leave the work on this branch.`, and the refusal shows as a step. The rule holds even when your own Claude Code hooks would allow the command. Everything else inside the run's branch is allowed without asking. -It cannot ask you a question and wait: nobody is assumed to be watching. It does not use codeaf's models, skills or memory; it uses Claude Code's own settings, hooks and MCP servers from your machine. +It cannot ask you a question and wait: nobody is assumed to be watching. It does not use codeaf's models or memory; it uses Claude Code's own settings, hooks and MCP servers from your machine, and is handed the skills you keep for codeaf and other programs as `codeaf:` (the canvas skill among them). diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 1ed97e7ce1..e655215608 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -34,6 +34,11 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { page string }{ {"how do I give you my API key safely", "giving-a-password-or-token"}, + {"can Claude Code make a canvas", "claude-code-provider"}, + {"does Claude Code see my codeaf skills", "claude-code-provider"}, + {"Claude Code's edits don't show as rows in the chat", "claude-code-provider"}, + {"can Claude Code propose a task", "claude-code-provider"}, + {"use my Claude plan with no API key", "claude-code-provider"}, {"where is the token I pasted kept and for how long", "giving-a-password-or-token"}, {"can the model see my password", "giving-a-password-or-token"}, {"can I send a message to the chat running on my other computer", "desktop-sync"}, diff --git a/internal/provider/client.go b/internal/provider/client.go index 2127a847cf..d3400684bc 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -2160,6 +2160,11 @@ func (c *Client) completeWithMessagesStreaming( } observer(StreamEvent{Kind: StreamReasoning, Delta: workingText, FromAnswer: true, Session: session}) } + // STEPS ANOTHER PROGRAM ALREADY RAN are handed up as they finish, + // in order with the words around them ([StreamStepsRan]). + if len(choice.Delta.Steps) > 0 { + observer(StreamEvent{Kind: StreamStepsRan, Delta: string(choice.Delta.Steps), Session: session}) + } for _, fragment := range choice.Delta.ToolCalls { // A response that asked for a call is a response that behaved, // however little it said in words, so the split must never read diff --git a/internal/provider/sse.go b/internal/provider/sse.go index a8d6813932..75895cc0b5 100644 --- a/internal/provider/sse.go +++ b/internal/provider/sse.go @@ -274,6 +274,9 @@ type streamDelta struct { ReasoningContent string `json:"reasoning_content,omitempty"` ReasoningText string `json:"reasoning_text,omitempty"` ReasoningDetails json.RawMessage `json:"reasoning_details,omitempty"` + // Steps are tool calls a model that runs its own tools has already run and + // finished ([StreamStepsRan]); no endpoint but such a transport sends them. + Steps json.RawMessage `json:"codeaf_steps,omitempty"` } // thinking reports that this delta carried thought rather than answer. diff --git a/internal/provider/stepsran_test.go b/internal/provider/stepsran_test.go new file mode 100644 index 0000000000..309cc120e5 --- /dev/null +++ b/internal/provider/stepsran_test.go @@ -0,0 +1,36 @@ +package provider + +import ( + "context" + "testing" +) + +// Steps a model's own program already ran ride the stream as one event each +// chunk, in order with its words, and never become calls to run. +func TestStepsAlreadyRanArriveAsTheirOwnEventAndAreNotCallsToRun(t *testing.T) { + steps := `[{"id":"claude_code_t1","name":"bash","arguments":{"command":"ls"},"output":"a.go","took_ms":3}]` + client := streamClientForTest(t, sseHandler( + `{"id":"one","choices":[{"index":0,"delta":{"role":"assistant","reasoning":"▸ Bash ls\n"}}]}`, + `{"id":"one","choices":[{"index":0,"delta":{"codeaf_steps":`+steps+`}}]}`, + `{"id":"one","choices":[{"index":0,"delta":{"content":"There is one file."},"finish_reason":"stop"}]}`, + )) + var ran []string + ctx := WithStreamObserver(context.Background(), func(event StreamEvent) { + if event.Kind == StreamStepsRan { + ran = append(ran, event.Delta) + } + }) + response, err := client.CompleteWithMessages(ctx, userMessages("what is here")) + if err != nil { + t.Fatal(err) + } + if len(ran) != 1 || ran[0] != steps { + t.Fatalf("steps events = %q", ran) + } + if calls := response.ToolCalls(); len(calls) != 0 { + t.Fatalf("a step already run came back as a call to run: %+v", calls) + } + if response.Text() != "There is one file." { + t.Fatalf("answer = %q", response.Text()) + } +} diff --git a/internal/provider/stream.go b/internal/provider/stream.go index 53a972c2f8..e7461c62ef 100644 --- a/internal/provider/stream.go +++ b/internal/provider/stream.go @@ -129,6 +129,18 @@ const ( // surface may receive one on a turn that goes on to produce no // StreamStarted at all. StreamRowNews + // StreamStepsRan carries, in Delta, a JSON array of tool calls the MODEL'S + // OWN PROGRAM has already run and finished — each {id, name, arguments, + // output, is_error, took_ms}, in codeaf's own tool names. It is raised only + // by a transport for a model that works with tools of its own, such as + // Claude Code (internal/claudemodel), whose edits and commands happen inside + // one request rather than being handed back for codeaf to run. + // + // THEY ARE FACTS, NOT INSTRUCTIONS. Nothing downstream may run them again: + // the turn loop records each as a call and its result, so the page draws + // them as rows and the next model the conversation moves to reads them in + // its history, exactly as if codeaf had run them. + StreamStepsRan ) // StreamEvent carries provider text as it arrives. Delta is populated only diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index 9dfe7989ec..6d6350d3dc 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -910,7 +910,7 @@ func (c Config) clientConfig(model string, timeout time.Duration) provider.Confi if c.SessionFile != "" { kept = filepath.Join(filepath.Dir(c.SessionFile), "claude-code-session.json") } - configured.HTTPClient = claudemodel.ClientFor(c.Workspace, kept) + configured.HTTPClient = claudemodel.ClientAt(claudemodel.Seat{Dir: c.Workspace, Kept: kept}) } return configured } diff --git a/internal/session/loop.go b/internal/session/loop.go index c24bd50e77..03233b7da0 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -81,7 +81,9 @@ import ( "time" "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/agentdid" "github.com/Agent-Field/codeaf/internal/approval" + "github.com/Agent-Field/codeaf/internal/claudemodel" "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/ctxbudget" @@ -719,6 +721,13 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) (f reached.drew() hub.send(formed) } + case provider.StreamStepsRan: + // STEPS THE MODEL'S OWN PROGRAM ALREADY RAN (Claude Code's Bash and + // Edit) are written down as calls and results, in order with the + // words around them, and drawn as rows. They are on the page, so + // they count as read exactly as a word of the answer does. + reached.drew() + a.recordStepsRan(ctx, hub, event.Delta) case provider.StreamToolCallReady: // ANNOUNCE FIRST, then decide whether it may start. The order is the // meaning: the person sees every call the moment the model finishes @@ -728,6 +737,11 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) (f } } ctx = provider.WithStreamObserver(ctx, turnObserver) + // A MODEL WHOSE OWN PROGRAM RUNS COMMANDS (Claude Code) is handed the + // conversation's identity for them, as the commands codeaf runs are. + if a.config.actorDID != "" { + ctx = claudemodel.WithEnv(ctx, []string{agentdid.AgentDIDEnv + "=" + a.config.actorDID}) + } // ONE MECHANISM ANSWERS ONE SILENCE, AND IT IS NOT THIS ONE. This loop used // to keep a hedge of its own here: an eight-second timer that, on a request // which had said nothing, sent the whole prompt a second time. The transport diff --git a/internal/session/stepsran.go b/internal/session/stepsran.go new file mode 100644 index 0000000000..a890f92b8b --- /dev/null +++ b/internal/session/stepsran.go @@ -0,0 +1,66 @@ +package session + +import ( + "context" + "encoding/json" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// ranStep is one tool call a model's own program already ran — Claude Code's +// Bash or Edit — as internal/provider's StreamStepsRan hands it up, already +// spelled in codeaf's tool names (internal/claudemodel's steps.go). +type ranStep struct { + ID string `json:"id"` + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + Output string `json:"output"` + IsError bool `json:"is_error"` + TookMS int64 `json:"took_ms"` +} + +// recordStepsRan writes down steps the model's own program ran, exactly as a +// call codeaf ran is written down: the call, its result, how long it took, and +// a row on the page. It is [Agent.runUserBash]'s recording half without the +// running, because the work is already done. +// +// NOTHING HERE RUNS ANYTHING. A step is a fact about the folder, not an +// instruction, so it never reaches the executor, the approval chain or the +// hooks: Claude Code asked its own permissions before it ran it. +// +// A TURN THAT WAS ABANDONED WRITES NOTHING MORE, for the reason the batch +// boundary in loop.go gives: a newer turn may already own the history. +func (a *Agent) recordStepsRan(ctx context.Context, hub *eventHub, raw string) { + var steps []ranStep + if json.Unmarshal([]byte(raw), &steps) != nil { + return + } + for _, step := range steps { + if abandoned(ctx) { + return + } + if step.ID == "" || step.Name == "" { + continue + } + args := string(step.Arguments) + if args == "" { + args = "{}" + } + call := ai.ToolCall{ID: step.ID, Type: "function", Function: ai.ToolCallFunction{Name: step.Name, Arguments: args}} + a.record(ai.Message{Role: "assistant", ToolCalls: []ai.ToolCall{call}}) + event := Event{Kind: EventToolBegin, Tool: step.Name, Hint: gloss(call), Args: argsText(call), CallID: call.ID} + hub.send(event) + result := textMessage("tool", step.Output) + result.ToolCallID = call.ID + a.record(result) + took := time.Duration(step.TookMS) * time.Millisecond + a.file.appendTook(call.ID, took) + hub.send(Event{Kind: EventToolFinished, Tool: step.Name, CallID: call.ID, Args: event.Args, Took: took}) + event.Kind, event.Hint, event.Output = EventToolEnd, "", displayToolOutput(call.ID, step.Output) + if step.IsError { + event.Kind = EventToolFailed + } + hub.send(event) + } +} diff --git a/internal/session/stepsran_test.go b/internal/session/stepsran_test.go new file mode 100644 index 0000000000..8ef768411a --- /dev/null +++ b/internal/session/stepsran_test.go @@ -0,0 +1,73 @@ +package session + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// A model whose own program runs its tools (Claude Code) hands its finished +// steps up the stream. Each is drawn as a row, kept in the conversation's +// file, and read by the next model as a call and its result — and nothing +// runs it a second time. +func TestStepsAnotherProgramRanAreDrawnKeptAndReadByTheNextModel(t *testing.T) { + c := &scriptedCompleter{} + file := filepath.Join(t.TempDir(), "chat.jsonl") + a, _ := newTestAgent(t, c, func(cfg *Config) { cfg.SessionFile = file }) + c.steps = []step{func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + provider.EmitEvent(ctx, provider.StreamEvent{Kind: provider.StreamStepsRan, + Delta: `[{"id":"claude_code_t1","name":"bash","arguments":{"command":"echo ran-elsewhere > proof.txt"},"output":"","took_ms":40},` + + `{"id":"claude_code_t2","name":"read","arguments":{"path":"proof.txt"},"output":"ran-elsewhere","took_ms":5}]`}) + return textResponse("done"), nil + }} + ch, err := a.Submit(t.Context(), "make the proof") + if err != nil { + t.Fatal(err) + } + var began, ended []string + for _, ev := range collect(t, ch) { + switch ev.Kind { + case EventToolBegin: + began = append(began, ev.Tool) + case EventToolEnd: + ended = append(ended, ev.Tool+":"+ev.Output) + } + } + if strings.Join(began, ",") != "bash,read" || strings.Join(ended, ",") != "bash:,read:ran-elsewhere" { + t.Fatalf("rows began %v ended %v", began, ended) + } + if err := a.Close(); err != nil { + t.Fatal(err) + } + replay, err := replaySessionFile(file) + if err != nil { + t.Fatal(err) + } + // user, two call/result pairs, the answer. + if len(replay.messages) != 6 || messageContentText(replay.messages[5]) != "done" { + t.Fatalf("the file kept %d messages: %+v", len(replay.messages), replay.messages) + } + c.steps = []step{func(_ context.Context, messages []ai.Message) (*ai.Response, error) { + for _, msg := range messages { + if msg.Role == "tool" && msg.ToolCallID == "claude_code_t2" && messageContentText(msg) == "ran-elsewhere" { + return textResponse("I can see what it did."), nil + } + } + t.Error("the next model's request lost the steps another program ran") + return textResponse("missing"), nil + }} + resumed, err := newAgent(a.config, c) + if err != nil { + t.Fatal(err) + } + defer resumed.Close() + ch, err = resumed.Submit(t.Context(), "What did it do?") + if err != nil { + t.Fatal(err) + } + collect(t, ch) +} From ce79011552aa96ca115134c7b55782c2abe58e96 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 7 Nov 2026 22:47:52 -0500 Subject: [PATCH 11/16] claude code: hand it what the person said, connect it from the terminal A turn now gives Claude Code every user-role message since the last answer, so codeaf's session note is no longer read as the person's words and their real message is no longer dropped. `codeaf connect claude-code` and the chat's /connect row run the app's connect: no key, three models with Claude's window. --- cmd/codeaf/connect.go | 30 ++++++++++++- cmd/codeaf/connect_test.go | 30 +++++++++++++ .../unreleased/2505-claude-code-provider.md | 2 + internal/claudemodel/agent.go | 41 +++++++++++++----- internal/claudemodel/claudemodel_test.go | 21 +++++++++ internal/manual/chat/claude-code-provider.md | 2 + internal/provider/stream.go | 4 +- internal/session/loop.go | 6 +-- internal/session/stepsran.go | 6 +-- internal/tui3/modelservices.go | 43 ++++++++++++++++++- 10 files changed, 165 insertions(+), 20 deletions(-) diff --git a/cmd/codeaf/connect.go b/cmd/codeaf/connect.go index 755681e6ea..8b64231b55 100644 --- a/cmd/codeaf/connect.go +++ b/cmd/codeaf/connect.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "github.com/Agent-Field/codeaf/internal/claudeagent" + "github.com/Agent-Field/codeaf/internal/claudemodel" "io" "net/url" "os" @@ -63,6 +65,8 @@ func runConnect(args []string) error { return connectCodex(context.Background(), config.ProfileDir(), *noBrowser) case modelsource.DefaultID: return connectOpenRouter(context.Background(), config.ProfileDir(), *noBrowser) + case claudemodel.ID: + return connectClaudeCode(context.Background(), config.ProfileDir()) default: return connectKeyService(context.Background(), config.ProfileDir(), service, strings.TrimSpace(*region)) } @@ -95,6 +99,27 @@ func connectCodex(ctx context.Context, profileDir string, noBrowser bool) error return nil } +// connectClaudeCode connects the person's own Claude Code. It asks for no key, +// because codeaf never holds its sign-in: it checks that Claude Code is here +// and signed in, keeps the row and seeds its three models, exactly as the +// app's Connect does. +func connectClaudeCode(ctx context.Context, profileDir string) error { + if _, err := config.ConnectClaudeCode(ctx, profileDir); err != nil { + return connectFailed(claudemodel.ID, err) + } + status := claudeagent.ReadStatus(ctx) + words := []string{claudemodel.ID + " is connected"} + if status.Version != "" { + words = append(words, "Claude Code "+status.Version) + } + if status.Plan != "" { + words = append(words, "Claude "+strings.ToUpper(status.Plan[:1])+status.Plan[1:]+" plan") + } + words = append(words, "models: claude-code/opus, claude-code/sonnet, claude-code/haiku") + fmt.Fprintln(usageOut, strings.Join(words, " · ")) + return nil +} + func callbackPort(address string) string { parsed, err := url.Parse(address) if err != nil { @@ -164,8 +189,11 @@ func listConnections(profileDir string) error { continue } method := "key" - if source.ID == "codex" { + switch source.ID { + case "codex": method = "browser" + case claudemodel.ID: + method = "your Claude Code sign-in" } line(source.Written, method, held[source.ID]) } diff --git a/cmd/codeaf/connect_test.go b/cmd/codeaf/connect_test.go index 7fad0afa35..3ccd7ea1a2 100644 --- a/cmd/codeaf/connect_test.go +++ b/cmd/codeaf/connect_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "strings" "testing" "time" @@ -285,3 +286,32 @@ func TestFirstRunStillBuildsItsOpenRouterBrowserRoad(t *testing.T) { t.Fatal("first-run OpenRouter browser connection disappeared") } } + +// `codeaf connect claude-code` asks for no key: it finds the signed-in Claude +// Code, keeps the row and seeds its three models with Claude's window, so a +// conversation on one is not measured against a stranger's default. +func TestConnectClaudeCodeAsksForNoKeyAndSeedsItsModels(t *testing.T) { + dir := t.TempDir() + t.Setenv(config.ProfileDirEnv, dir) + fake := filepath.Join(t.TempDir(), "claude") + script := "#!/bin/sh\ncase \"$1\" in\n--version) echo '2.1.9 (Claude Code)';;\nauth) echo '{\"loggedIn\":true,\"authMethod\":\"claude.ai\",\"subscriptionType\":\"max\"}';;\nesac\n" + if err := os.WriteFile(fake, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_CLAUDE_BIN", fake) + output, restore := captureConnect(t) + defer restore() + if err := runConnect([]string{"claude-code"}); err != nil { + t.Fatalf("connect: %v (%q)", err, output.String()) + } + if !strings.Contains(output.String(), "claude-code is connected · Claude Code 2.1.9 · Claude Max plan") { + t.Fatalf("connect said %q", output.String()) + } + output.Reset() + if err := runConnect(nil); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "claude-code · connected · your Claude Code sign-in") { + t.Fatalf("the list said %q", output.String()) + } +} diff --git a/docs/changes/unreleased/2505-claude-code-provider.md b/docs/changes/unreleased/2505-claude-code-provider.md index e400be0cf7..4e38b894f6 100644 --- a/docs/changes/unreleased/2505-claude-code-provider.md +++ b/docs/changes/unreleased/2505-claude-code-provider.md @@ -11,4 +11,6 @@ invalidates: - "Claude Code's own steps (Bash, Read, Edit, Write, Grep...) were reasoning lines only: codeaf kept just its final answer, so the page had no rows and the next model saw no account of the work. Each finished step is now handed up the stream (provider StreamStepsRan, `codeaf_steps` delta) and recorded as codeaf's own call and result (bash/read/edit/write/grep/find/ls/web_fetch/web_search), drawn, saved, and read by the next model; nothing is run twice." - "Claude Code reported its whole turn's summed input as the request's input, so a four-step turn looked like a full 200k window and codeaf compacted a three-line conversation. It now reports what the turn's last call read." - "Claude Code could not see codeaf's skills, so it could not make a canvas. Conversations and tasks on it now load the skills kept for codeaf and other programs as a session plugin (`codeaf:`), and its commands carry the conversation's CODEAF_AGENT_DID, so a canvas it makes is the conversation's." + - "A turn on Claude Code took the LAST user-role message as the person's words, which after the first turn is codeaf's own session note; Claude Code answered the note and called the person's real message something it had not seen. It is now given every user-role message since the last answer, notes included as the notes they say they are." + - "`codeaf connect claude-code` and the chat's /connect row treated Claude Code as a key service: a piped key saved a bare row with no models, the window fell to 128k and a local chat refused every turn. Both now run the same connect the app does (no key; three models with Claude's 200k window)." --- diff --git a/internal/claudemodel/agent.go b/internal/claudemodel/agent.go index cb333c328c..e28664a471 100644 --- a/internal/claudemodel/agent.go +++ b/internal/claudemodel/agent.go @@ -276,7 +276,9 @@ func (t transport) agentTurn(request *http.Request, chat chatRequest, conversati from := l.seen switch { case adopted: - from = len(spoken(chat.Messages)) + // The cut request's messages reached the running turn; no answer + // followed them, so the correction starts one short of seen. + from = l.seen - 1 case l.turns == 0: from = 0 } @@ -638,24 +640,43 @@ func (l *live) keep() { func turnPrompt(chat chatRequest, from int) string { // FROM COUNTS WHAT WAS SAID, not Claude Code's own steps codeaf wrote // down: those are its session's own history (steps.go). - chat.Messages = spoken(chat.Messages) - last := -1 - for i := len(chat.Messages) - 1; i >= 0; i-- { - if chat.Messages[i].Role == "user" { + messages := spoken(chat.Messages) + last, lastAnswer := -1, -1 + for i, m := range messages { + switch m.Role { + case "user": last = i - break + case "assistant": + lastAnswer = i } } if last < 0 { return "" } - newest := strings.TrimSpace(contentText(chat.Messages[last].Content)) if from < 0 { from = 0 } + // WHAT IS NEW IS EVERYTHING IN THE USER ROLE SINCE THE LAST ANSWER, not + // the last such message alone: codeaf lands its own notes in that role + // between an answer and the next request (where the work stands, what the + // crew is doing), and each opens by saying it is a note, not the person. + // Taking only the last one handed Claude Code a note as "the person now + // says" and dropped what the person actually typed. + begin := max(lastAnswer+1, from) + begin = min(begin, last) + var fresh []string + for _, m := range messages[begin:] { + if m.Role != "user" { + continue + } + if text := strings.TrimSpace(contentText(m.Content)); text != "" { + fresh = append(fresh, text) + } + } + newest := strings.Join(fresh, "\n\n") var earlier []string - for i := from; i < last; i++ { - m := chat.Messages[i] + for i := from; i < begin; i++ { + m := messages[i] text := strings.TrimSpace(contentText(m.Content)) if text == "" || m.Role == "system" || m.Role == "developer" { continue @@ -671,7 +692,7 @@ func turnPrompt(chat chatRequest, from int) string { if len(earlier) == 0 { return newest } - return "What was said in this conversation that you have not seen:\n\n" + strings.Join(earlier, "\n\n") + "\n\nThe person now says:\n\n" + newest + return "What was said in this conversation that you have not seen:\n\n" + strings.Join(earlier, "\n\n") + "\n\nNow:\n\n" + newest } type contentBlock struct { diff --git a/internal/claudemodel/claudemodel_test.go b/internal/claudemodel/claudemodel_test.go index 391c49c557..726efeed33 100644 --- a/internal/claudemodel/claudemodel_test.go +++ b/internal/claudemodel/claudemodel_test.go @@ -149,3 +149,24 @@ func TestClaudeCodeIsNotToldItsOwnStepsAsSomethingItMissed(t *testing.T) { t.Fatalf("Claude Code was told %q", prompt) } } + +func TestANoteCodeafLandsIsNotTakenForWhatThePersonSaid(t *testing.T) { + text := func(s string) json.RawMessage { raw, _ := json.Marshal(s); return raw } + note := "A note from the session, not from the person: where the work stands right now." + chat := chatRequest{Messages: []chatMessage{ + {Role: "system", Content: text("be codeaf")}, + {Role: "user", Content: text("make notes")}, + {Role: "assistant", Content: text("done")}, + {Role: "user", Content: text("now add a title")}, + {Role: "user", Content: text(note)}, + }} + prompt := turnPrompt(chat, 3) + if prompt != "now add a title\n\n"+note { + t.Fatalf("Claude Code was told %q", prompt) + } + // A correction to a running turn is only what arrived after the request + // the turn started on. + if got := turnPrompt(chatRequest{Messages: append(chat.Messages[:2:2], chatMessage{Role: "user", Content: text("in French")})}, 2); got != "in French" { + t.Fatalf("the correction was given as %q", got) + } +} diff --git a/internal/manual/chat/claude-code-provider.md b/internal/manual/chat/claude-code-provider.md index 92f83766d6..1876ec9347 100644 --- a/internal/manual/chat/claude-code-provider.md +++ b/internal/manual/chat/claude-code-provider.md @@ -6,6 +6,8 @@ Settings › Models & providers lists **Claude Code** beside OpenRouter and Chat Connected, the row reads `Signed in · Claude Max plan · no API key`; the plan is shown only when Claude Code reports it. Not installed, it reads `Not installed on this computer` and offers Check again, not Connect. First run leads with a Claude Code card only when Claude Code is installed and signed in. +In the terminal, `codeaf connect claude-code` does the same check and prints, for example, `claude-code is connected · Claude Code 2.1.319 · Claude Max plan · models: claude-code/opus, claude-code/sonnet, claude-code/haiku`; `codeaf connect` then lists `claude-code · connected · your Claude Code sign-in`. In the chat, `/connect` shows a Claude Code row tagged `your Claude Code sign-in · no key`, and choosing it connects without asking for a key. If Claude Code is missing or signed out, all three say so instead of connecting. + Nothing is billed per call on a Claude plan, so these models show `on your Claude plan` instead of a price, and they add nothing to codeaf's spending. ## How a conversation on Claude Code works — Claude Code does the work with its own tools diff --git a/internal/provider/stream.go b/internal/provider/stream.go index e7461c62ef..5fbba06488 100644 --- a/internal/provider/stream.go +++ b/internal/provider/stream.go @@ -132,8 +132,8 @@ const ( // StreamStepsRan carries, in Delta, a JSON array of tool calls the MODEL'S // OWN PROGRAM has already run and finished — each {id, name, arguments, // output, is_error, took_ms}, in codeaf's own tool names. It is raised only - // by a transport for a model that works with tools of its own, such as - // Claude Code (internal/claudemodel), whose edits and commands happen inside + // by a transport for a model that works with tools of its own (the + // internal/claudemodel transport), whose edits and commands happen inside // one request rather than being handed back for codeaf to run. // // THEY ARE FACTS, NOT INSTRUCTIONS. Nothing downstream may run them again: diff --git a/internal/session/loop.go b/internal/session/loop.go index 03233b7da0..162d870549 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -722,8 +722,8 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) (f hub.send(formed) } case provider.StreamStepsRan: - // STEPS THE MODEL'S OWN PROGRAM ALREADY RAN (Claude Code's Bash and - // Edit) are written down as calls and results, in order with the + // STEPS THE MODEL'S OWN PROGRAM ALREADY RAN (its own shell and + // edits) are written down as calls and results, in order with the // words around them, and drawn as rows. They are on the page, so // they count as read exactly as a word of the answer does. reached.drew() @@ -737,7 +737,7 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) (f } } ctx = provider.WithStreamObserver(ctx, turnObserver) - // A MODEL WHOSE OWN PROGRAM RUNS COMMANDS (Claude Code) is handed the + // A MODEL WHOSE OWN PROGRAM RUNS COMMANDS (internal/claudemodel) is handed the // conversation's identity for them, as the commands codeaf runs are. if a.config.actorDID != "" { ctx = claudemodel.WithEnv(ctx, []string{agentdid.AgentDIDEnv + "=" + a.config.actorDID}) diff --git a/internal/session/stepsran.go b/internal/session/stepsran.go index a890f92b8b..bb8306ee18 100644 --- a/internal/session/stepsran.go +++ b/internal/session/stepsran.go @@ -8,8 +8,8 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" ) -// ranStep is one tool call a model's own program already ran — Claude Code's -// Bash or Edit — as internal/provider's StreamStepsRan hands it up, already +// ranStep is one tool call a model's own program already ran — its own shell +// command or edit — as internal/provider's StreamStepsRan hands it up, already // spelled in codeaf's tool names (internal/claudemodel's steps.go). type ranStep struct { ID string `json:"id"` @@ -27,7 +27,7 @@ type ranStep struct { // // NOTHING HERE RUNS ANYTHING. A step is a fact about the folder, not an // instruction, so it never reaches the executor, the approval chain or the -// hooks: Claude Code asked its own permissions before it ran it. +// hooks: the program asked its own permissions before it ran it. // // A TURN THAT WAS ABANDONED WRITES NOTHING MORE, for the reason the batch // boundary in loop.go gives: a newer turn may already own the history. diff --git a/internal/tui3/modelservices.go b/internal/tui3/modelservices.go index 182d5274a4..418d64aa26 100644 --- a/internal/tui3/modelservices.go +++ b/internal/tui3/modelservices.go @@ -3,6 +3,7 @@ package tui3 import ( "context" "errors" + "github.com/Agent-Field/codeaf/internal/claudemodel" "net/url" "sort" "strings" @@ -215,6 +216,8 @@ func modelConnectionStatus(source modelsource.Source, held bool) connect.Status if source.ID == modelsource.DefaultID { need = "browser · key" } + case source.ID == claudemodel.ID: + need = "your Claude Code sign-in · no key" case modelsource.IsCustomID(source.ID): need = "address · key" case len(source.Regions) > 0: @@ -237,7 +240,7 @@ func modelConnectionStatus(source modelsource.Source, held bool) connect.Status ID: modelConnectionID(source.ID), Name: name, Blurb: need, Auth: connect.AuthKey, Category: "models", } - if source.ID == "ollama" { + if source.ID == "ollama" || source.ID == claudemodel.ID { service.Auth = "none" } else if source.ID == "codex" || source.ID == modelsource.DefaultID { service.Auth = connect.AuthBrowser @@ -325,6 +328,11 @@ func (a *app) startModelConnect(row connect.Status, fromSheet bool) tea.Cmd { a.connPanel.close() } return a.beginCodexConnect(*draft) + case source.ID == claudemodel.ID: + // NO KEY IS ASKED FOR: codeaf never holds Claude Code's sign-in, so + // connecting is checking it is here and signed in. + a.modelDraft = nil + return a.beginClaudeCodeConnect(*draft) default: draft.step = modelConnectKey a.showModelEntry(newModelEntry(row.ID, source.Name, "key", nil, true), fromSheet) @@ -449,6 +457,39 @@ func (a *app) adoptCodexFlow(msg codexFlowMsg) tea.Cmd { } } +// beginClaudeCodeConnect connects the person's own Claude Code through the +// same door the app and `codeaf connect claude-code` use, and keeps its three +// models with their window, so a conversation on one is measured against +// Claude's window and not a stranger's default. +func (a *app) beginClaudeCodeConnect(draft modelConnectDraft) tea.Cmd { + ctx := a.ctx + if ctx == nil { + ctx = context.Background() + } + if draft.setupAttempt != nil { + ctx = draft.setupAttempt.ctx + a.setup.providerBusy = true + a.touch() + } + dir := a.profileDir + return func() tea.Msg { + outcome, err := config.ConnectClaudeCode(ctx, dir) + var models []Model + if err == nil { + for _, m := range claudemodel.Models() { + models = append(models, Model{ID: m.ID, ContextLength: m.ContextLength}) + } + if connected, found := config.ResolveSources(dir, "", "").ByID(claudemodel.ID); found { + _ = WriteModelCacheFor(connected.Source.ID, connected.Address, models) + } + } + return modelConnectResultMsg{ + service: claudemodel.ID, name: claudemodel.Name, written: claudemodel.ID, + outcome: outcome, models: models, err: err, setupAttempt: draft.setupAttempt, + } + } +} + // codexConnectedModels is the Codex rows this surface keeps once a sign-in // lands: the catalog [config.ConnectCodex] just remembered, WINDOWS INCLUDED, // and the same rows written to this service's model cache so the next launch From da4bea6d458bb2f40a2d3579d1a35d25d61c71bb Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 7 Nov 2026 22:49:45 -0500 Subject: [PATCH 12/16] claude code: comment law --- cmd/codeaf/connect.go | 4 ++-- internal/tui3/modelservices.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/codeaf/connect.go b/cmd/codeaf/connect.go index 8b64231b55..dea1257a7d 100644 --- a/cmd/codeaf/connect.go +++ b/cmd/codeaf/connect.go @@ -99,8 +99,8 @@ func connectCodex(ctx context.Context, profileDir string, noBrowser bool) error return nil } -// connectClaudeCode connects the person's own Claude Code. It asks for no key, -// because codeaf never holds its sign-in: it checks that Claude Code is here +// connectClaudeCode connects the person's own `claude` program. It asks for no +// key, because codeaf never holds its sign-in: it checks the program is here // and signed in, keeps the row and seeds its three models, exactly as the // app's Connect does. func connectClaudeCode(ctx context.Context, profileDir string) error { diff --git a/internal/tui3/modelservices.go b/internal/tui3/modelservices.go index 418d64aa26..229f3ebbbe 100644 --- a/internal/tui3/modelservices.go +++ b/internal/tui3/modelservices.go @@ -329,8 +329,8 @@ func (a *app) startModelConnect(row connect.Status, fromSheet bool) tea.Cmd { } return a.beginCodexConnect(*draft) case source.ID == claudemodel.ID: - // NO KEY IS ASKED FOR: codeaf never holds Claude Code's sign-in, so - // connecting is checking it is here and signed in. + // NO KEY IS ASKED FOR: codeaf never holds the `claude` program's + // sign-in, so connecting is checking it is here and signed in. a.modelDraft = nil return a.beginClaudeCodeConnect(*draft) default: @@ -457,7 +457,7 @@ func (a *app) adoptCodexFlow(msg codexFlowMsg) tea.Cmd { } } -// beginClaudeCodeConnect connects the person's own Claude Code through the +// beginClaudeCodeConnect connects the person's own `claude` program through the // same door the app and `codeaf connect claude-code` use, and keeps its three // models with their window, so a conversation on one is measured against // Claude's window and not a stranger's default. From 9615adaa4b8461791058cecd5161b0209ff936ab Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 7 Nov 2026 22:55:13 -0500 Subject: [PATCH 13/16] claude code: a correction reaches it once A request that takes over a running turn says so at once, so codeaf's first-reply watch does not cut it during a long command; and one that carries nothing new only watches the turn again, rather than sending the person's newest message a second time. --- internal/claudemodel/agent.go | 53 +++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/internal/claudemodel/agent.go b/internal/claudemodel/agent.go index e28664a471..0e6713148d 100644 --- a/internal/claudemodel/agent.go +++ b/internal/claudemodel/agent.go @@ -233,6 +233,18 @@ func liveFor(ctx context.Context, dir, conversation, model, kept string, env []s return l, nil } +// adoptedOpening is the first line of a stream that took over a running turn: +// the person's words going in, or the turn simply being watched again. +func adoptedOpening(adopted bool, prompt string) string { + switch { + case !adopted: + return "" + case prompt != "": + return "taking in your message\n" + } + return "still working\n" +} + // pluginDirs is the plugin of codeaf's skills for dir, when there is one. func pluginDirs(dir string) []string { if root := claudeagent.SkillPlugin(dir); root != "" { @@ -282,13 +294,21 @@ func (t transport) agentTurn(request *http.Request, chat chatRequest, conversati case l.turns == 0: from = 0 } - prompt := turnPrompt(chat, from) - if prompt == "" { - l.mu.Unlock() - l.turn.Unlock() - return jsonResponse(request, http.StatusBadRequest, errorBody("there is nothing new to say to Claude Code")) + said := len(spoken(chat.Messages)) + // A TURN ADOPTED WITH NOTHING NEW IS ONLY WATCHED AGAIN. codeaf may cut + // the same correction's request twice (a second look at a running step + // cuts again); the request that follows carries no new words, and sending + // its newest message would hand Claude Code the correction a second time. + prompt := "" + if !adopted || said > from { + prompt = turnPrompt(chat, from) + if prompt == "" { + l.mu.Unlock() + l.turn.Unlock() + return jsonResponse(request, http.StatusBadRequest, errorBody("there is nothing new to say to Claude Code")) + } } - l.seen = len(spoken(chat.Messages)) + 1 + l.seen = said + 1 if adopted { l.letGo.Stop() l.adoptable = false @@ -297,13 +317,15 @@ func (t transport) agentTurn(request *http.Request, chat chatRequest, conversati l.sink = events } l.mu.Unlock() - if err := l.session.SendWith(prompt, newestImages(chat)); err != nil { - l.release() - return jsonResponse(request, http.StatusServiceUnavailable, errorBody("Claude Code would not take the message: "+err.Error())) + if prompt != "" { + if err := l.session.SendWith(prompt, newestImages(chat)); err != nil { + l.release() + return jsonResponse(request, http.StatusServiceUnavailable, errorBody("Claude Code would not take the message: "+err.Error())) + } } l.turns++ reader, writer := io.Pipe() - go l.stream(request.Context(), writer, events, model, adopted) + go l.stream(request.Context(), writer, events, model, adopted, adoptedOpening(adopted, prompt)) return &http.Response{ StatusCode: http.StatusOK, Status: "200 OK", Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: reader, ContentLength: -1, Request: request, @@ -352,7 +374,7 @@ func (l *live) resume(request *http.Request, chat chatRequest, model string) (*h } } reader, writer := io.Pipe() - go l.stream(request.Context(), writer, events, model, len(words) > 0) + go l.stream(request.Context(), writer, events, model, len(words) > 0, "still working\n") return &http.Response{ StatusCode: http.StatusOK, Status: "200 OK", Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: reader, ContentLength: -1, Request: request, @@ -388,7 +410,7 @@ func (l *live) park() { } // stream writes the turn as server-sent chunks until Claude Code's result. -func (l *live) stream(ctx context.Context, w *io.PipeWriter, events <-chan claudeagent.Event, model string, steered bool) { +func (l *live) stream(ctx context.Context, w *io.PipeWriter, events <-chan claudeagent.Event, model string, steered bool, opening string) { parked := false defer func() { if !parked { @@ -418,6 +440,13 @@ func (l *live) stream(ctx context.Context, w *io.PipeWriter, events <-chan claud _, _ = io.WriteString(w, "data: [DONE]\n\n") _ = w.Close() } + // A STREAM THAT JOINS A TURN ALREADY RUNNING SAYS SO AT ONCE. Claude Code + // may be inside a long command and print nothing for a minute, and codeaf + // cuts a request that shows nothing in its first seconds and asks again — + // which cut the request carrying a correction and sent it twice. + if opening != "" && !think(opening) { + return + } beat := time.NewTicker(heartbeat) defer beat.Stop() started := time.Now() From 2c0336452b59c6ac863ca6d6b6db4a2a0b3c517a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 7 Nov 2026 23:09:05 -0500 Subject: [PATCH 14/16] claude code: first-run setup offers it only where it is installed It needs no key, so on a machine without the claude program its row could only say it is missing; /connect still lists it everywhere. This keeps the setup list flat at 40x10. --- internal/manual/chat/claude-code-provider.md | 2 +- internal/tui3/firstrun_test.go | 3 +++ internal/tui3/onboardingproviders.go | 17 ++++++++++++ internal/tui3/onboardingproviders_test.go | 28 +++++++++++++++++++- 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/internal/manual/chat/claude-code-provider.md b/internal/manual/chat/claude-code-provider.md index 1876ec9347..e432561456 100644 --- a/internal/manual/chat/claude-code-provider.md +++ b/internal/manual/chat/claude-code-provider.md @@ -6,7 +6,7 @@ Settings › Models & providers lists **Claude Code** beside OpenRouter and Chat Connected, the row reads `Signed in · Claude Max plan · no API key`; the plan is shown only when Claude Code reports it. Not installed, it reads `Not installed on this computer` and offers Check again, not Connect. First run leads with a Claude Code card only when Claude Code is installed and signed in. -In the terminal, `codeaf connect claude-code` does the same check and prints, for example, `claude-code is connected · Claude Code 2.1.319 · Claude Max plan · models: claude-code/opus, claude-code/sonnet, claude-code/haiku`; `codeaf connect` then lists `claude-code · connected · your Claude Code sign-in`. In the chat, `/connect` shows a Claude Code row tagged `your Claude Code sign-in · no key`, and choosing it connects without asking for a key. If Claude Code is missing or signed out, all three say so instead of connecting. +In the terminal, `codeaf connect claude-code` does the same check and prints, for example, `claude-code is connected · Claude Code 2.1.319 · Claude Max plan · models: claude-code/opus, claude-code/sonnet, claude-code/haiku`; `codeaf connect` then lists `claude-code · connected · your Claude Code sign-in`. In the chat, `/connect` shows a Claude Code row tagged `your Claude Code sign-in · no key`, and choosing it connects without asking for a key. The chat's first-run provider list shows Claude Code only when the `claude` program is installed on this computer; `/connect` always lists it. If Claude Code is missing or signed out, all three say so instead of connecting. Nothing is billed per call on a Claude plan, so these models show `on your Claude plan` instead of a price, and they add nothing to codeaf's spending. diff --git a/internal/tui3/firstrun_test.go b/internal/tui3/firstrun_test.go index 142d1a553b..3ed6375f0f 100644 --- a/internal/tui3/firstrun_test.go +++ b/internal/tui3/firstrun_test.go @@ -2,6 +2,7 @@ package tui3 import ( "context" + "path/filepath" "strings" "testing" "time" @@ -41,6 +42,8 @@ func setupProviderApp(t *testing.T, seed func(dir string)) (*app, string, *[]str t.Setenv(pin, "") } t.Setenv("CODEAF_HOME", t.TempDir()) + // The machine's own Claude Code must not decide what setup lists. + t.Setenv("CODEAF_CLAUDE_BIN", filepath.Join(t.TempDir(), "no-claude-here")) dir := t.TempDir() if seed != nil { seed(dir) diff --git a/internal/tui3/onboardingproviders.go b/internal/tui3/onboardingproviders.go index 43a21602ed..adf68b5c35 100644 --- a/internal/tui3/onboardingproviders.go +++ b/internal/tui3/onboardingproviders.go @@ -2,6 +2,8 @@ package tui3 import ( "context" + "github.com/Agent-Field/codeaf/internal/claudeagent" + "github.com/Agent-Field/codeaf/internal/claudemodel" "strings" tea "charm.land/bubbletea/v2" @@ -50,12 +52,27 @@ func providerCatalog(catalog []modelsource.Source) []modelsource.Source { func (a *app) setupProviderRows() []setupProviderRow { var rows []setupProviderRow + claudeHere := claudeCodeHere() for _, source := range providerCatalog(a.modelCatalog) { + // CLAUDE CODE IS OFFERED ONLY WHERE IT IS INSTALLED, as the app's first + // run offers it: it needs no key, so on a machine without the `claude` + // program the row could only ever answer that it is missing. /connect + // still lists it everywhere, with that answer. + if source.ID == claudemodel.ID && !claudeHere { + continue + } rows = append(rows, setupProviderRow{source.ID, source.Name}) } return rows } +// claudeCodeHere says the `claude` program is on this machine. It looks for +// the program and runs nothing. +var claudeCodeHere = func() bool { + _, err := claudeagent.Binary() + return err == nil +} + func (a *app) setupProviderKey(msg tea.KeyPressMsg) tea.Cmd { s := &a.setup rows := a.setupProviderRows() diff --git a/internal/tui3/onboardingproviders_test.go b/internal/tui3/onboardingproviders_test.go index dacc639966..1c5a6d67ab 100644 --- a/internal/tui3/onboardingproviders_test.go +++ b/internal/tui3/onboardingproviders_test.go @@ -5,12 +5,15 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "reflect" "regexp" "strings" "testing" tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/claudemodel" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/modelsource" ) @@ -199,7 +202,9 @@ func TestProviderChooserShowsEverySupportedProviderInOneFlatList(t *testing.T) { a, _, _ := setupProviderApp(t, nil) pageCounter := regexp.MustCompile(`\bof \d+\b`) rows := a.setupProviderRows() - if len(rows) != len(modelsource.Vendored())+1 { + // Every registry provider and OpenRouter, less Claude Code, which is + // offered only where it is installed (the helper makes it absent). + if len(rows) != len(modelsource.Vendored()) { t.Fatalf("registry providers=%v", rows) } for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {40, 18}, {40, 12}, {40, 10}} { @@ -287,3 +292,24 @@ func TestAllBrowserWaitingCardsUseOneShortLinkWithTheCompleteTarget(t *testing.T } } } + +func TestSetupOffersClaudeCodeOnlyWhereItIsInstalled(t *testing.T) { + a, _, _ := setupProviderApp(t, nil) + for _, row := range a.setupProviderRows() { + if row.id == claudemodel.ID { + t.Fatal("setup offered Claude Code on a machine without it") + } + } + fake := filepath.Join(t.TempDir(), "claude") + if err := os.WriteFile(fake, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_CLAUDE_BIN", fake) + found := false + for _, row := range a.setupProviderRows() { + found = found || row.id == claudemodel.ID + } + if !found { + t.Fatal("setup hid Claude Code on a machine that has it") + } +} From c6b458a22f2c054b252c05436f0d1c856d2b9170 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 7 Nov 2026 23:10:24 -0500 Subject: [PATCH 15/16] claude code: comment law --- internal/tui3/onboardingproviders.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui3/onboardingproviders.go b/internal/tui3/onboardingproviders.go index adf68b5c35..38b541638a 100644 --- a/internal/tui3/onboardingproviders.go +++ b/internal/tui3/onboardingproviders.go @@ -54,7 +54,7 @@ func (a *app) setupProviderRows() []setupProviderRow { var rows []setupProviderRow claudeHere := claudeCodeHere() for _, source := range providerCatalog(a.modelCatalog) { - // CLAUDE CODE IS OFFERED ONLY WHERE IT IS INSTALLED, as the app's first + // THE CLAUDE PROVIDER IS OFFERED ONLY WHERE IT IS INSTALLED, as the app's first // run offers it: it needs no key, so on a machine without the `claude` // program the row could only ever answer that it is missing. /connect // still lists it everywhere, with that answer. From e05443e77efb6157d3e5d42391a3c199e33d0c8d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 8 Nov 2026 10:28:06 -0500 Subject: [PATCH 16/16] claude code: the later add-a-provider menu offers it only where it is installed, as setup does --- internal/manual/chat/claude-code-provider.md | 2 +- internal/tui3/addprovider.go | 8 ++++++++ internal/tui3/onboardingproviders.go | 5 +++-- internal/tui3/onboardingproviders_test.go | 17 +++++++++++++++++ internal/tui3/provideraccess_test.go | 6 ++++++ 5 files changed, 35 insertions(+), 3 deletions(-) diff --git a/internal/manual/chat/claude-code-provider.md b/internal/manual/chat/claude-code-provider.md index e432561456..676548813e 100644 --- a/internal/manual/chat/claude-code-provider.md +++ b/internal/manual/chat/claude-code-provider.md @@ -6,7 +6,7 @@ Settings › Models & providers lists **Claude Code** beside OpenRouter and Chat Connected, the row reads `Signed in · Claude Max plan · no API key`; the plan is shown only when Claude Code reports it. Not installed, it reads `Not installed on this computer` and offers Check again, not Connect. First run leads with a Claude Code card only when Claude Code is installed and signed in. -In the terminal, `codeaf connect claude-code` does the same check and prints, for example, `claude-code is connected · Claude Code 2.1.319 · Claude Max plan · models: claude-code/opus, claude-code/sonnet, claude-code/haiku`; `codeaf connect` then lists `claude-code · connected · your Claude Code sign-in`. In the chat, `/connect` shows a Claude Code row tagged `your Claude Code sign-in · no key`, and choosing it connects without asking for a key. The chat's first-run provider list shows Claude Code only when the `claude` program is installed on this computer; `/connect` always lists it. If Claude Code is missing or signed out, all three say so instead of connecting. +In the terminal, `codeaf connect claude-code` does the same check and prints, for example, `claude-code is connected · Claude Code 2.1.319 · Claude Max plan · models: claude-code/opus, claude-code/sonnet, claude-code/haiku`; `codeaf connect` then lists `claude-code · connected · your Claude Code sign-in`. In the chat, `/connect` shows a Claude Code row tagged `your Claude Code sign-in · no key`, and choosing it connects without asking for a key. The chat's first-run provider list and its later add-a-provider menu show Claude Code only when the `claude` program is installed on this computer; `/connect` always lists it. If Claude Code is missing or signed out, all three say so instead of connecting. Nothing is billed per call on a Claude plan, so these models show `on your Claude plan` instead of a price, and they add nothing to codeaf's spending. diff --git a/internal/tui3/addprovider.go b/internal/tui3/addprovider.go index eca80ddac6..c17860f317 100644 --- a/internal/tui3/addprovider.go +++ b/internal/tui3/addprovider.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/Agent-Field/codeaf/internal/claudemodel" "github.com/Agent-Field/codeaf/internal/modelsource" "charm.land/bubbletea/v2" @@ -228,7 +229,14 @@ func (p *addProviderPanel) rebuild(probes []LocalServerProbe, catalog []modelsou } } items = append(items, addProviderItem{heading: true, title: "providers"}) + claudeHere := claudeCodeHere() for _, source := range providerCatalog(catalog) { + // THIS MENU OFFERS WHAT FIRST-RUN SETUP OFFERS, by the same rule + // (setupProviderRows): a provider whose program is not on this machine + // is left off rather than listed to answer that it is missing. + if source.ID == claudemodel.ID && !claudeHere { + continue + } service, held := sources.ByID(source.ID) connected := held && service.HasCredentials() detail := "key" diff --git a/internal/tui3/onboardingproviders.go b/internal/tui3/onboardingproviders.go index 38b541638a..29a05b5462 100644 --- a/internal/tui3/onboardingproviders.go +++ b/internal/tui3/onboardingproviders.go @@ -56,8 +56,9 @@ func (a *app) setupProviderRows() []setupProviderRow { for _, source := range providerCatalog(a.modelCatalog) { // THE CLAUDE PROVIDER IS OFFERED ONLY WHERE IT IS INSTALLED, as the app's first // run offers it: it needs no key, so on a machine without the `claude` - // program the row could only ever answer that it is missing. /connect - // still lists it everywhere, with that answer. + // program the row could only ever answer that it is missing. The later + // add-a-provider menu follows the same rule; /connect still lists it + // everywhere, with that answer. if source.ID == claudemodel.ID && !claudeHere { continue } diff --git a/internal/tui3/onboardingproviders_test.go b/internal/tui3/onboardingproviders_test.go index 1c5a6d67ab..79ee73765d 100644 --- a/internal/tui3/onboardingproviders_test.go +++ b/internal/tui3/onboardingproviders_test.go @@ -295,11 +295,25 @@ func TestAllBrowserWaitingCardsUseOneShortLinkWithTheCompleteTarget(t *testing.T func TestSetupOffersClaudeCodeOnlyWhereItIsInstalled(t *testing.T) { a, _, _ := setupProviderApp(t, nil) + // laterOffers says the add-a-provider menu opened after setup lists it. + laterOffers := func() bool { + a.openAddProvider(false) + defer a.addPanel.close() + for _, item := range a.addPanel.items { + if item.sourceID == claudemodel.ID { + return true + } + } + return false + } for _, row := range a.setupProviderRows() { if row.id == claudemodel.ID { t.Fatal("setup offered Claude Code on a machine without it") } } + if laterOffers() { + t.Fatal("the add-a-provider menu offered Claude Code on a machine without it") + } fake := filepath.Join(t.TempDir(), "claude") if err := os.WriteFile(fake, []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatal(err) @@ -312,4 +326,7 @@ func TestSetupOffersClaudeCodeOnlyWhereItIsInstalled(t *testing.T) { if !found { t.Fatal("setup hid Claude Code on a machine that has it") } + if !laterOffers() { + t.Fatal("the add-a-provider menu hid Claude Code on a machine that has it") + } } diff --git a/internal/tui3/provideraccess_test.go b/internal/tui3/provideraccess_test.go index 708e5af047..83715782cd 100644 --- a/internal/tui3/provideraccess_test.go +++ b/internal/tui3/provideraccess_test.go @@ -40,6 +40,12 @@ func selectAddProvider(t *testing.T, a *app, id string) { func TestLaterProviderMenusShowTheInitialCatalogAndActualConnectionStatus(t *testing.T) { a, _ := providerAccessApp(t) + // The catalog is counted on a machine where every provider's program is + // present, so the count is the same on every machine the test runs on; + // TestSetupOffersClaudeCodeOnlyWhereItIsInstalled covers the other kind. + here := claudeCodeHere + claudeCodeHere = func() bool { return true } + t.Cleanup(func() { claudeCodeHere = here }) // A persisted row without credentials must not acquire a connected badge. services := append(a.sources.All(), modelsource.Connected{Source: testModelSource(t, "deepseek")}) a.sources = modelsource.NewSet(services...)