Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7c2d534
chat: onboarding answers the pointer and a second enter, no telemetry…
ZeroPoint95 Oct 1, 2026
0f511ef
changes: the entry for #1720
ZeroPoint95 Oct 1, 2026
85e0acc
changes: a title that reads as one line
ZeroPoint95 Oct 1, 2026
a228221
changes: a title the entry check accepts
ZeroPoint95 Oct 1, 2026
09f413e
chat: an expired OpenRouter key is a reading, and it warns under the …
ZeroPoint95 Oct 1, 2026
d1fd6ca
chat: the setup's example panel stands under the form, as wide as the…
ZeroPoint95 Oct 1, 2026
8db0ded
chat: the setup's example stands above the legend and form, carries i…
ZeroPoint95 Oct 1, 2026
580accb
chat: the setup is headed `Basic settings` with the keys line under i…
ZeroPoint95 Oct 1, 2026
f1fdf81
chat: the setup's examples go round, and a row's name is blue on it, …
ZeroPoint95 Oct 1, 2026
0c6c328
chat: the setup's model list is a flat list of exact ids
ZeroPoint95 Oct 1, 2026
b4ab76f
chat: the setup's review row is gone, and a dim note under the way ou…
ZeroPoint95 Oct 1, 2026
adbd11f
chat: the setup's explanations name /budget and /model, and the way o…
ZeroPoint95 Oct 1, 2026
b4dfddf
chat: the setup's examples turn on their own clock, held by any key, …
ZeroPoint95 Oct 1, 2026
6bb9d93
chat: the setup's limit detail explains the day's and the conversatio…
ZeroPoint95 Oct 1, 2026
4940ad0
chat: the model-name test reads the name on the field row and the id …
ZeroPoint95 Oct 1, 2026
1580bb7
chat: the expired key's line agrees with the keys line about esc, and…
ZeroPoint95 Oct 1, 2026
876172a
chat: the setup's limit detail is four sentences with its commands qu…
ZeroPoint95 Oct 2, 2026
bc25160
tui3: a command named in quotes is talked about, not run
AbirAbbas Oct 2, 2026
f547156
credits: refresh expired keys across the engine wire without switchin…
AbirAbbas Oct 2, 2026
6d15cc3
tui3: keep empty model lists open and hold one setup turn timer
AbirAbbas Oct 2, 2026
d89822a
telemetry: stop sending immediately when the settings switch is turne…
AbirAbbas Oct 2, 2026
3a7e1fa
manual: split setup topics and correct the onboarding change entry
AbirAbbas Oct 2, 2026
3138850
credits: an engine-road key refusal asks for the balance read
AbirAbbas Oct 2, 2026
f070cb5
pool: read live off switches before sending judged rows
AbirAbbas Oct 2, 2026
8c9c959
manual: name the empty-task foot and correct change-entry claims
AbirAbbas Oct 2, 2026
544f8e0
Merge origin/dev into zeropoint95/onboarding-ux-cleanups
AbirAbbas Oct 2, 2026
d9dab5c
pool: the live off check reads only the switches that change at run time
AbirAbbas Oct 2, 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
34 changes: 18 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,21 +294,23 @@ Harness, method and every table: [docs/benchmarks/performance](docs/benchmarks/p
- [Guide](docs/GUIDE.md): every flag, key, slash command and exit code.
- [docs/](docs/README.md): architecture, headless, remote, limits.

<details>
<summary>Telemetry: anonymous usage counts. <code>CODEAF_TELEMETRY=off</code> turns them off.</summary>

```text
codeaf sends anonymous usage counts to AgentField.
Sent: version, OS, mode, session counts, errors, and total tokens used.
Never: anything about you or your work. No prompts, code, file names,
paths, repo names, keys, email, IP, or machine name.
What is collected: codeaf telemetry info
Turn off: CODEAF_TELEMETRY=off
```

Counts and buckets only, never your work. [docs/TELEMETRY.md](docs/TELEMETRY.md)
lists every field and every way to turn it off.

</details>
## Telemetry

codeaf sends anonymous usage counts to AgentField, on by default.

- **Sent:** version, OS, mode (chat or task), session counts, errors, and total
tokens used — as counts and bands, under a random per-install id.
- **Never:** anything about you or your work. No prompts, code, file names,
paths, repo names, keys, email, IP address, machine name, or model names.
- **Turn off:** the `telemetry` switch in the chat's `/settings`,
`CODEAF_TELEMETRY=off`, or `DO_NOT_TRACK=1`. Any one of them stops every
stream, the Model Pool's rows included.
The `/settings` switch stops sending immediately; turning it back on takes
effect the next time codeaf starts.

This page and [docs/TELEMETRY.md](docs/TELEMETRY.md) are the whole disclosure:
the product itself prints no notice and has no telemetry command. The document
lists every field, when it is sent, and every way to turn it off, and a test
holds it to the code.

Built by the [AgentField](https://github.com/Agent-Field/agentfield) team.
3 changes: 0 additions & 3 deletions cmd/codeaf/chatv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,9 +347,6 @@ func openChatV3(name string, args []string, pickSession bool) error {
chosen, cfg := launch.Model, launch.Config

if text := strings.TrimSpace(*once); text != "" {
// A --once chat draws no surface, so an owed usage notice is printed
// here, ahead of the answer it would otherwise never be seen beside.
payTelemetryNoticeOnStderr()
// Nobody is watching a --once run, so nobody can answer a question. The
// policy's "prompt" therefore refuses the call with a result the model
// can act on (internal/session's consent.go), and a person who wants
Expand Down
5 changes: 0 additions & 5 deletions cmd/codeaf/chatv3_surface.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,6 @@ func runSurface(ctx context.Context, options tui3.Options) error {
wire, closeWire := v3Wire()
defer closeWire()
options.Output = wire
// THE USAGE NOTICE IS THE SURFACE'S TO SHOW when this chat still owes it
// (telemetry_lifecycle.go), because every door that draws a surface comes
// through here and a notice printed before the alt screen is a notice
// nobody reads until they quit.
telemetryNoticeForSurface(&options)
return withSurfaceLogger(options.ProfileDir, func() error {
return runSurfaceProgram(ctx, options)
})
Expand Down
10 changes: 1 addition & 9 deletions cmd/codeaf/exec_smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ func TestExecBinaryWithoutAKeyFailsCleanly(t *testing.T) {

// A stamped smoke run is a real usage-count source unless the environment
// says otherwise, so the closed environment above must hold: one task run
// leaves no telemetry directory behind and the status verb reports off.
// leaves no telemetry directory behind.
func TestSmokeBinaryNeverSendsUsageCounts(t *testing.T) {
binary := buildCodeafStamped(t, "v0.0.0-smoke")
server := fakeOpenRouter(t)
Expand All @@ -297,14 +297,6 @@ func TestSmokeBinaryNeverSendsUsageCounts(t *testing.T) {
if _, err := os.Stat(filepath.Join(home, "telemetry")); !os.IsNotExist(err) {
t.Fatalf("a telemetry directory was created by a smoke run")
}

stdout, stderr, code := runSmoke(t, binary, env, "", "telemetry", "status")
if code != 0 {
t.Fatalf("telemetry status exited %d\nstderr:\n%s", code, stderr)
}
if !strings.Contains(stdout, "telemetry off") {
t.Fatalf("status printed %q, want it to report telemetry off", stdout)
}
}

// THE KEPT BRANCH AND THE VERDICT SURVIVE THE DOOR. An exec run works in the
Expand Down
11 changes: 3 additions & 8 deletions cmd/codeaf/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,10 +371,6 @@ func run() error {
return runRebuild(os.Args[2:])
case "why":
return runWhy(os.Args[2:])
case "telemetry":
// The person's door onto the anonymous-usage pipe: what it is doing,
// exactly what would leave, and the switch. It emits nothing itself.
return runTelemetry(os.Args[2:])
case "manual":
// Everything codeaf knows about itself, read straight (manual.go). It
// is the same corpus the chat's manual tool reads, printed as it is
Expand Down Expand Up @@ -516,8 +512,6 @@ Look at what happened — read-only, no key, nothing spent
show today's self-spend receipts
codeaf why <task-id> [--db path]
what one piece of work did — its turns, tools, arguments, how it ended
codeaf telemetry
the anonymous usage counts: status, info, show, off, on
codeaf logs [--tail 40] [--follow] [--path] [--json] [--run id]
[--call id] [--tag t] [--model m] [--node n] [--body id]
every model call codeaf made — what was asked, which lane answered, what
Expand Down Expand Up @@ -695,8 +689,9 @@ than fighting your shell.
your prompts included. Off by default, and for one run
at a time.
CODEAF_TELEMETRY on (default). off turns the anonymous usage counts off;
` + "`codeaf telemetry`" + ` says what they are and what would
leave, DO_NOT_TRACK=1 does the same
docs/TELEMETRY.md in the repository says what they are,
DO_NOT_TRACK=1 does the same, and so does the telemetry
switch in the chat's /settings
CODEAF_TELEMETRY_ENDPOINT
where the usage counts go
(default https://agentfield.ai/api/oss/codeaf/telemetry);
Expand Down
146 changes: 146 additions & 0 deletions cmd/codeaf/pool_send_gate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package main

import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"

"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/pool/judge"
"github.com/Agent-Field/codeaf/internal/pool/record"
"github.com/Agent-Field/codeaf/internal/telemetry"
)

// Turning the switch off while the judge holds a sendable snapshot must keep
// both its new scores and the outbox's older rows from reaching the relay.
func TestSettingsOffStopsPoolRowsAlreadyBeingJudged(t *testing.T) {
t.Setenv("CODEAF_TELEMETRY", "")
t.Setenv("DO_NOT_TRACK", "")
t.Setenv("CODEAF_MODEL_POOL", "on")
t.Setenv("CODEAF_HOME", t.TempDir())
telemetry.Configure(false)
t.Cleanup(func() { telemetry.Configure(false) })
var requests atomic.Int64
relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(relay.Close)
t.Setenv("CODEAF_MODEL_POOL_SUBMIT_URL", relay.URL)
t.Setenv("CODEAF_TELEMETRY_ENDPOINT", relay.URL)
dir := seedOutbox(t)
before := config.ModelPoolAt(dir)
if !before.CanSend() {
t.Fatal("fixture cannot send before the setting changes")
}
row, ok := config.NewSettings(config.SettingsOptions{ProfileDir: dir}).Row(config.KeyTelemetry)
if !ok {
t.Fatal("telemetry row is absent")
}
var asked int
ask := func(string) judge.Ask {
return func(context.Context, string, string) (string, error) {
// The hook has resolved the pool before it asks its first judge,
// and no pool request has started when the switch changes here.
asked++
if err := row.Apply("off"); err != nil {
t.Fatal(err)
}
return `{"score": 88, "reason": "the delivered work answers the brief"}`, nil
}
}
poolJudgeLandingContext(context.Background(), config.Config{}, dir, poolTestCatalog, ask,
func() time.Time { return time.Unix(100, 0) }, "task", poolTestLanding())
if asked != 2 {
t.Fatalf("the local judge answered %d seats, want both", asked)
}
if telemetry.OffReason() != telemetry.OffConfig || config.ModelPoolAt(dir).CanSend() {
t.Fatal("off did not close the usage gate and a freshly resolved pool")
}
if got := requests.Load(); got != 0 {
t.Fatalf("settings off still allowed %d new pool POSTs from a retained configuration", got)
}
sheet, err := record.LoadSheet(record.OwnSheetPath(configuredPoolDir(dir)))
if err != nil {
t.Fatal(err)
}
if got := len(record.Cells(sheet)); got != 2 {
t.Fatalf("the local sheet holds %d cells, want both scored seats", got)
}
box, err := outboxOpenForTest(dir)
if err != nil {
t.Fatal(err)
}
defer box.Close()
if got := len(box.Pending()); got != 2 {
t.Fatalf("the quieted outbox holds %d pending rows, want the two older rows", got)
}
}

// A push can need more than one POST. The first request may finish after the
// switch goes off, but the rows still waiting must not start another request.
func TestPoolPushStopsLaterBatchesWhenSettingsTurnOff(t *testing.T) {
t.Setenv("CODEAF_TELEMETRY", "")
t.Setenv("DO_NOT_TRACK", "")
t.Setenv("CODEAF_MODEL_POOL", "on")
t.Setenv("CODEAF_HOME", t.TempDir())
telemetry.Configure(false)
t.Cleanup(func() { telemetry.Configure(false) })
dir := seedOutbox(t)
box, err := outboxOpenForTest(dir)
if err != nil {
t.Fatal(err)
}
// One row past a full batch makes the second POST observable without a
// timer or a race between the setting write and the next request.
for i := len(box.Pending()); i < 201; i++ {
if err := box.Append([]byte(`{"model":"crew/worker"}`)); err != nil {
t.Fatal(err)
}
}
if err := box.Close(); err != nil {
t.Fatal(err)
}
row, ok := config.NewSettings(config.SettingsOptions{ProfileDir: dir}).Row(config.KeyTelemetry)
if !ok {
t.Fatal("telemetry row is absent")
}
var requests atomic.Int64
applied := make(chan error, 1)
relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requests.Add(1) == 1 {
applied <- row.Apply("off")
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(relay.Close)
t.Setenv("CODEAF_MODEL_POOL_SUBMIT_URL", relay.URL)
t.Setenv("CODEAF_TELEMETRY_ENDPOINT", relay.URL)
before := config.ModelPoolAt(dir)
if !before.CanSend() {
t.Fatal("fixture cannot send before the setting changes")
}
poolPush(context.Background(), dir, before, poolPushBudget)
select {
case err := <-applied:
if err != nil {
t.Fatal(err)
}
default:
t.Fatal("the first batch did not reach the relay")
}
if got := requests.Load(); got != 1 {
t.Fatalf("settings off allowed %d pool POSTs, want only the request already on the wire", got)
}
box, err = outboxOpenForTest(dir)
if err != nil {
t.Fatal(err)
}
defer box.Close()
if got := len(box.Pending()); got != 1 {
t.Fatalf("the quieted outbox holds %d pending rows, want the unsent second batch", got)
}
}
23 changes: 22 additions & 1 deletion cmd/codeaf/poolinstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import (
"context"
cryptorand "crypto/rand"
"encoding/hex"
"errors"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
Expand Down Expand Up @@ -82,7 +84,7 @@ func installNonce(poolDir string) (string, error) {
// switch and never at a person. The rows a batch did not reach stay pending,
// where the next judged run and the next start-up try them again.
func poolPush(ctx context.Context, profileDir string, cfg poolcfg.Config, budget time.Duration) {
if !cfg.CanSend() {
if !cfg.CanSend() || config.PoolTelemetryRowsOffAt(profileDir) {
return
}
poolDir := config.ProfilePath(profileDir, "pool")
Expand All @@ -103,7 +105,26 @@ func poolPush(ctx context.Context, profileDir string, cfg poolcfg.Config, budget
}
box.Install = nonce
box.Budget = budget
box.Client = &http.Client{Transport: poolSendTransport{profileDir: profileDir, cfg: cfg}}
if _, err := box.Send(ctx, cfg.SubmitURL); err != nil && trace.Enabled() {
log.Printf("model pool: send: %v", err)
}
}

// poolSendTransport asks the live disk switches before EVERY REQUEST, because
// one outbox send can hold later batches and retries of refused rows. It keeps
// the caller's resolved configuration so its environment remains the caller's.
// A request refused here leaves its rows pending, as an unanswered relay does.
// The pool's own ladder decides this; the usage gate also closes on source
// builds, which have always been able to send pool scores.
type poolSendTransport struct {
profileDir string
cfg poolcfg.Config
}

func (transport poolSendTransport) RoundTrip(request *http.Request) (*http.Response, error) {
if !transport.cfg.CanSend() || config.PoolTelemetryRowsOffAt(transport.profileDir) {
return nil, errors.New("model pool sending is off")
}
return http.DefaultTransport.RoundTrip(request)
}
4 changes: 3 additions & 1 deletion cmd/codeaf/poolrecord.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,9 @@ func poolJudgeLandingContext(ctx context.Context, settings config.Config, profil
return
}
recorder := &record.Recorder{Sheet: sheet}
if pool.CanSend() {
// The judge may have outlived a settings change. A quieted install still
// keeps its own scores, but those scores never enter the outgoing rows.
if pool.CanSend() && !config.PoolTelemetryRowsOffAt(profileDir) {
ob, err := outbox.Open(outboxPath(poolDir))
if err != nil {
if trace.Enabled() {
Expand Down
Loading
Loading