Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
99f2f4e
POC: hand a task to the person's own Claude Code as a crew worker
santoshkumarradha Nov 7, 2026
c075e4e
claude: a live Claude Code session per task, as T3 Code holds one
santoshkumarradha Nov 7, 2026
b818baa
Claude Code as a provider: conversations, crew members and small jobs…
santoshkumarradha Nov 7, 2026
049f3bc
change entry names its pull request
santoshkumarradha Nov 7, 2026
c122586
comment law: Claude Code may be named where codeaf integrates it, as …
santoshkumarradha Nov 7, 2026
c12bc2d
Claude Code adapter sits beside Codex's in the funnel law; status cac…
santoshkumarradha Nov 7, 2026
4e8515f
Claude Code: the provider list never waits for its sign-in; its guide…
santoshkumarradha Nov 7, 2026
e45bd11
Claude Code status read runs guarded and unlocks from a defer
santoshkumarradha Nov 7, 2026
9e09ebc
claude code: serve codeaf's tools to it over MCP, pass pictures
santoshkumarradha Nov 7, 2026
b9c4b66
claude code: record its steps as codeaf's, hand it codeaf's skills, f…
santoshkumarradha Nov 8, 2026
ce79011
claude code: hand it what the person said, connect it from the terminal
santoshkumarradha Nov 8, 2026
da4bea6
claude code: comment law
santoshkumarradha Nov 8, 2026
9615ada
claude code: a correction reaches it once
santoshkumarradha Nov 8, 2026
2c03364
claude code: first-run setup offers it only where it is installed
santoshkumarradha Nov 8, 2026
c6b458a
claude code: comment law
santoshkumarradha Nov 8, 2026
e05443e
claude code: the later add-a-provider menu offers it only where it is…
santoshkumarradha Nov 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions cmd/codeaf/appaccount.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 = 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.
Expand Down Expand Up @@ -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.claudeStatusNow()), nil
}
if !ok {
return appwire.KeyCheck{}, appwire.Errorf(appwire.CodeBadParams, "codeaf cannot connect %s with a key", params.Provider)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
144 changes: 144 additions & 0 deletions cmd/codeaf/appclaudecode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
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"
"github.com/Agent-Field/codeaf/internal/guard"
)

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

// claudeStatus is the cached reading of Claude Code's sign-in on the machine
// 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()
known := !claudeStatusCache.at.IsZero()
if known && time.Since(claudeStatusCache.at) < claudeStatusTTL {
return claudeStatusCache.status, true
}
if !claudeStatusCache.reading {
claudeStatusCache.reading = true
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() {
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 {
status := claudeagent.ReadStatus(context.Background())
claudeStatusCache.Lock()
defer claudeStatusCache.Unlock()
claudeStatusCache.status, claudeStatusCache.at = status, time.Now()
return status
}

func (b *appBackend) forgetClaudeStatus() {
claudeStatusCache.Lock()
defer claudeStatusCache.Unlock()
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 {
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
}
3 changes: 2 additions & 1 deletion cmd/codeaf/appproviders_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 29 additions & 1 deletion cmd/codeaf/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -95,6 +99,27 @@ func connectCodex(ctx context.Context, profileDir string, noBrowser bool) error
return nil
}

// 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 {
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 {
Expand Down Expand Up @@ -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])
}
Expand Down
30 changes: 30 additions & 0 deletions cmd/codeaf/connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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())
}
}
16 changes: 16 additions & 0 deletions docs/changes/unreleased/2505-claude-code-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
pr: 2505
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."
- "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:<name>`), 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)."
---
6 changes: 5 additions & 1 deletion internal/appwire/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading