Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
93959a0
installer: the PATH line is the last thing printed, bare and bold green
ZeroPoint95 Sep 18, 2026
e0f31b7
docs/changes: entry for #1208
ZeroPoint95 Sep 18, 2026
ffbc8d1
telemetry: `codeaf telemetry show` prints the Model Pool's waiting ro…
ZeroPoint95 Sep 18, 2026
a51df87
pool: the telemetry off switch caps the Model Pool at read
ZeroPoint95 Sep 19, 2026
9cbba42
installer: a one-line receipt, a two-line telemetry notice, the expor…
ZeroPoint95 Sep 19, 2026
c1518e1
telemetry: `show` prints every field with this machine's values, not …
ZeroPoint95 Sep 19, 2026
f27b6d4
installer: the notice names the inspector and the switch
ZeroPoint95 Sep 19, 2026
d329ac6
Merge branch 'claude/codeaf-telemetry-disclaimer-5af1dd' into claude/…
ZeroPoint95 Sep 19, 2026
b81aacf
config: the model_pool row names ModelPoolAt as its reader
ZeroPoint95 Sep 19, 2026
bc465c9
telemetry: show prints the shape of the data, only what is sent
ZeroPoint95 Sep 19, 2026
cbf733a
telemetry: show no longer lists the count, dollar and duration bands
ZeroPoint95 Sep 19, 2026
82524c1
telemetry: info says what is collected, show is JSON of what is waiting
ZeroPoint95 Sep 19, 2026
6e5e102
telemetry: info opens on the fact that chat content is not collected
ZeroPoint95 Sep 20, 2026
fa8139e
telemetry: info numbers its two streams and indents the pool example
ZeroPoint95 Sep 20, 2026
59ccdf5
Merge branch 'dev' into claude/codeaf-install-message-b41040
ZeroPoint95 Sep 21, 2026
f8b5e2b
installer tests follow the quiet run; an empty endpoint caps the pool…
ZeroPoint95 Sep 21, 2026
1979732
Merge remote-tracking branch 'origin/dev' into claude/codeaf-install-…
ZeroPoint95 Sep 21, 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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ it ran (role, model, a number, which model judged, door, size bucket, day) under
a per-install nonce, never code,
prompts, paths or an identity, and `codeaf pool status` shows exactly what is
waiting to go. Turn it off with `model_pool = off` on the settings sheet or
`CODEAF_MODEL_POOL=off`; `read` uses the pool and sends nothing. The relay
`CODEAF_MODEL_POOL=off`; `read` uses the pool and sends nothing, and
`CODEAF_TELEMETRY=off` caps it at `read` along with the usage counts. The relay
publishes a signed index the crew picker reads under `picked from = learn`. The index is mirrored on the `model-pool` branch at
`pool/index.json`. The design is [Pareto Crewing](docs/design/model-pool/pareto-crewing.pdf);
the relay's code is under `relay/`, with a [runbook](docs/design/model-pool/RUNBOOK.md)
Expand Down Expand Up @@ -283,7 +284,7 @@ codeaf sends anonymous usage counts to AgentField.
Sent: version, OS, mode (chat or task), how many sessions, how many errors.
Never: anything about you or your work. No prompts, code, file names,
paths, repo names, keys, email, IP, or machine name.
See exactly what leaves: codeaf telemetry show
What is collected: codeaf telemetry info
Turn off: CODEAF_TELEMETRY=off
```

Expand Down
2 changes: 1 addition & 1 deletion cmd/codeaf/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ Look at what happened — read-only, no key, nothing spent
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, show, off, on
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
6 changes: 3 additions & 3 deletions cmd/codeaf/pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ func runPool(args []string) error {
// emptiness is the state root's own profile, never a directory called "pool"
// beside wherever the command happened to run.
func runPoolWith(args []string, output io.Writer, profileDir string, now func() time.Time, lookup func(string) (string, bool)) error {
cfg := poolcfg.Resolve(config.ModelPoolSettingAt(profileDir), config.ModelPoolPublicKeySettingAt(profileDir), lookup)
cfg := config.ModelPoolResolved(profileDir, lookup)
poolDir := config.ProfilePath(profileDir, "pool")
if len(args) == 0 {
args = []string{"show"}
Expand Down Expand Up @@ -180,8 +180,8 @@ func statusPool(args []string, output io.Writer, poolDir string, cfg poolcfg.Con
}

// printPool is the reading form's whole answer. The config first — every value
// beside the word saying where it came from, one of default, setting, env or
// ci — then the cached index with its age, its cells under --cells, then the
// beside the word saying where it came from, one of default, setting, env, ci
// or telemetry — then the cached index with its age, its cells under --cells, then the
// install's own sheet, then, for status, the outbox and the two doors the
// mode opens.
func printPool(output io.Writer, poolDir string, cfg poolcfg.Config, now time.Time, asJSON, withCells, withStatus bool, keys []ed25519.PublicKey) error {
Expand Down
285 changes: 274 additions & 11 deletions cmd/codeaf/telemetry.go
Original file line number Diff line number Diff line change
@@ -1,33 +1,43 @@
package main

import (
"bytes"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"

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

// The `telemetry` command: the person's door onto the anonymous-usage pipe.
// Four verbs, one per question a person arrives with — what is it doing, what
// exactly would leave, and the two ways of turning it off or back on. It
// EMITS NOTHING ITSELF: it is a command about telemetry, not a session, and
// the wiring in main.go's execute() looks at os.Args to make sure of it.
// Five verbs, one per question a person arrives with — what is it doing, what
// is collected, what is waiting to leave right now, and the two ways of
// turning it off or back on. It EMITS NOTHING ITSELF: it is a command about
// telemetry, not a session, and the wiring in main.go's execute() looks at
// os.Args to make sure of it.
func runTelemetry(args []string) error {
if len(args) == 0 {
return runTelemetryStatus(nil)
}
switch args[0] {
case "status":
return runTelemetryStatus(args[1:])
case "info":
return runTelemetryInfo(args[1:])
case "show":
return runTelemetryShow(args[1:])
case "on", "off":
return runTelemetrySet(args[0], args[1:])
default:
return fmt.Errorf("telemetry takes one of: status, show, on, off")
return fmt.Errorf("telemetry takes one of: status, info, show, on, off")
}
}

Expand Down Expand Up @@ -89,16 +99,269 @@ func telemetryInstallPrefix() string {
return hash
}

// runTelemetryShow prints exactly what is waiting to leave the machine, the
// package's own rendering so the command and `codeaf telemetry show` a person
// reads in the notice are one and the same thing.
// runTelemetryInfo prints what is collected — the shape of every row that can
// leave, in a person's words, for both streams. The notice promises "what is
// collected: codeaf telemetry info", and two streams leave: the anonymous
// usage counts this package spools, and the Model Pool's judged seat scores,
// which wait in the pool's own outbox under the profile and go to a different
// relay under a different switch. Until 2026-09-18 the only listing covered
// the first, so a person who read it and set CODEAF_TELEMETRY=off believed
// nothing more would leave while the pool went on sending. Both are described
// here, each under a line naming where it goes or why it does not.
func runTelemetryInfo(args []string) error {
flags := telemetryFlags("info")
if err := flags.Parse(args); err != nil {
return err
}
profileDir := config.ProfileDir()
telemetry.Configure(telemetryConfiguredOff())
fmt.Fprintln(usageOut, infoText(profileDir, os.LookupEnv))
return nil
}

// runTelemetryShow prints exactly what is waiting to leave the machine — ALL
// of it, from both streams — as one JSON object a person can read and a
// script can parse: a key per destination, and under each where it goes, why
// it is not sent when it is not, and the rows waiting in the bytes the relay
// would receive. Indented by two, because the person who runs this is reading
// it, not piping it; a pipe reads indented JSON just as well.
func runTelemetryShow(args []string) error {
flags := telemetryFlags("show")
if err := flags.Parse(args); err != nil {
return err
}
fmt.Fprintln(usageOut, telemetry.Show())
return nil
profileDir := config.ProfileDir()
telemetry.Configure(telemetryConfiguredOff())
report := waitingReport{
Usage: waitingStream{
Destination: telemetry.Endpoint(),
Off: telemetry.OffReason(),
Waiting: nonNil(telemetry.SpoolContents()),
},
}
cfg := config.ModelPoolResolved(profileDir, os.LookupEnv)
report.ModelPool = waitingStream{
Destination: cfg.SubmitURL,
Off: modelPoolOffReason(cfg),
Waiting: nonNil(poolRowsWaiting(config.ProfilePath(profileDir, "pool"))),
}
// The encoder, not json.MarshalIndent: a waiting row is written with
// HTML escaping off, as the outbox and the spool write it, so the bytes
// printed are the bytes a relay is sent.
enc := json.NewEncoder(usageOut)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
return enc.Encode(report)
}

// waitingReport is `codeaf telemetry show`'s whole answer: one entry per
// destination, keyed by the stream's short name, in the order the notice
// names them.
type waitingReport struct {
Usage waitingStream `json:"usage"`
ModelPool waitingStream `json:"model_pool"`
}

// waitingStream is one destination: where its rows go, the reason nothing is
// sent there when that is so, and the rows waiting to leave, oldest first.
type waitingStream struct {
Destination string `json:"destination"`
Off string `json:"off,omitempty"`
Waiting []json.RawMessage `json:"waiting"`
}

// nonNil renders an empty queue as `[]`, never `null`: a person reading the
// object should see an empty list where rows would be, not an absence.
func nonNil(rows []json.RawMessage) []json.RawMessage {
if rows == nil {
return []json.RawMessage{}
}
return rows
}

// modelPoolOffReason names why the pool sends nothing, or "" when it sends:
// `read` uses the pool and sends nothing, `off` asks no judge at all, and a
// cap that came from the telemetry off switch says so, because the person who
// set that switch is the one reading this.
func modelPoolOffReason(cfg poolcfg.Config) string {
if cfg.CanSend() {
return ""
}
reason := fmt.Sprintf("model_pool %s", cfg.Mode)
if cfg.Source.Mode == "telemetry" {
reason += " (capped by the telemetry off switch)"
}
return reason
}

// infoPreface is the first thing `codeaf telemetry info` says, before either
// stream: the one fact a person came to check. It is true of everything the
// binary sends to AgentField — the usage counts carry only the allowlisted
// fields below, and a Model Pool row carries model slugs, a score and a day.
// The judge that produces a score reads a clipped brief and deliverable, but
// that is a model call to your own provider, like any turn, and nothing it
// read rides in the row.
const infoPreface = `codeaf does NOT collect or share your chat. No prompts, replies, code, file names,
paths, repo names, keys, email, IP or machine name leave for AgentField. Only the
fields below do, as this machine would fill them.`

// infoText composes the two streams, in the order the notice names them: the
// usage counts first, the Model Pool second. Each sits under a heading naming
// where it goes or why it does not, then WHAT A ROW LOOKS LIKE: the fields
// with this machine's own values where they are known before a run, and one
// example row per event where they are not, spelled from the contract's own
// constants. Only what is sent is listed; the never lists live in the notice
// and docs/TELEMETRY.md, because a person reading a shape wants the shape,
// not a second disclaimer. What is waiting right now is `show`'s answer.
func infoText(profileDir string, lookup func(string) (string, bool)) string {
var out strings.Builder
out.WriteString(infoPreface)
out.WriteString("\n\n")
out.WriteString(usageCountsHeading())
out.WriteByte('\n')
writeUsageCountFields(&out)
out.WriteByte('\n')
cfg := config.ModelPoolResolved(profileDir, lookup)
out.WriteString(modelPoolHeading(cfg))
out.WriteByte('\n')
writeModelPoolFields(&out)
return strings.TrimRight(out.String(), "\n")
}

// showIndent is the two spaces every line under a stream heading starts with.
const showIndent = " "

// showKeyWidth is the column the values start in: the widest key any row
// carries is model_calls_failed, eighteen characters, and two for air.
const showKeyWidth = 20

// writeUsageCountFields prints the usage-count row as this machine would fill
// it — the six every-event props with their live values and the four
// envelope fields — then one example row per event, then the stop reasons.
// The bands themselves are not listed: the example rows show one of each,
// and docs/TELEMETRY.md spells the rest.
func writeUsageCountFields(out *strings.Builder) {
fmt.Fprintf(out, "%severy event, as this machine would send it now\n", showIndent)
for _, prop := range telemetry.CommonPropValues() {
writeField(out, prop.Name, prop.Value)
}
install := "sha256 of a random id, minted on the first send"
if hash, ok := telemetry.InstallIDHashIfMinted(); ok {
install = hash[:12] + "…"
}
writeField(out, "install_id_hash", install)
writeField(out, "session_id_hash", "sha256 of the run id, one per session; absent on first_run")
writeField(out, "event_id", "16 random bytes as hex, one per event")
writeField(out, "event_time", time.Now().UTC().Format(time.RFC3339))
out.WriteByte('\n')
fmt.Fprintf(out, "%swhat each event adds, for example\n", showIndent)
for _, event := range telemetry.AllowlistedEvents() {
names := telemetry.EventPropNames(event)
if len(names) == 0 {
writeField(out, event, "nothing; sent once per install")
continue
}
writeField(out, event, exampleRow(event, names))
}
writeField(out, "stop_reason", "one of "+strings.Join(telemetry.StopReasons(), " · "))
}

// exampleRow spells one event's props as key=value pairs in the doc's order,
// wrapped so a session_ended row does not run past the terminal's edge. The
// values are [telemetry.ExampleProp]'s, from the contract's constants.
func exampleRow(event string, names []string) string {
var pairs []string
for _, name := range names {
pairs = append(pairs, name+"="+telemetry.ExampleProp(event, name))
}
const perLine = 5
var lines []string
for len(pairs) > 0 {
n := perLine
if n > len(pairs) {
n = len(pairs)
}
lines = append(lines, strings.Join(pairs[:n], " "))
pairs = pairs[n:]
}
continuation := "\n" + showIndent + showIndent + strings.Repeat(" ", showKeyWidth+1)
return strings.Join(lines, continuation)
}

// writeModelPoolFields prints what one pool row looks like: an example row in
// the bytes a relay would receive, indented so it reads, then the two
// identities a batch travels under.
func writeModelPoolFields(out *strings.Builder) {
fmt.Fprintf(out, "%sone row per judged seat, after a task lands, for example\n", showIndent)
// The row is indented by two, the way `show` prints a waiting one, and
// set in under the heading; json.Indent keeps the bytes the row's own.
var row bytes.Buffer
if err := json.Indent(&row, []byte(record.ExampleRowJSON(time.Now())), showIndent+showIndent, " "); err == nil {
fmt.Fprintf(out, "%s%s%s\n", showIndent, showIndent, row.String())
}
writeField(out, "nonce", "16 random bytes as hex, one per row, so a resend is not a double count")
writeField(out, "X-Codeaf-Install", "a header: a random per-install id, minted on the first send; not the usage counts' id")
}

// writeField prints one field line: the key in its column and the value.
func writeField(out *strings.Builder, key, value string) {
fmt.Fprintf(out, "%s%s%-*s %s\n", showIndent, showIndent, showKeyWidth, key, value)
}

// usageCountsHeading names where the usage counts go, or the rung of the
// opt-out ladder that keeps them here. The two streams are numbered in the
// order the notice names them, so a person can say "the second one". It reads the same ladder `telemetry
// status` reads, so the two verbs cannot disagree about whether anything is
// sent.
func usageCountsHeading() string {
if reason := telemetry.OffReason(); reason != "" {
return fmt.Sprintf("1. Usage Counts (off: %s)", reason)
}
return fmt.Sprintf("1. Usage Counts (%s)", telemetry.Endpoint())
}

// modelPoolHeading names where the pool rows go, or the mode that keeps them
// here: `read` uses the pool and sends nothing, `off` asks no judge at all.
func modelPoolHeading(cfg poolcfg.Config) string {
if !cfg.CanSend() {
return fmt.Sprintf("2. Model Pool (model_pool %s, nothing is sent)", cfg.Mode)
}
return fmt.Sprintf("2. Model Pool (%s)", cfg.SubmitURL)
}

// poolRowsWaiting reads the pool outbox's pending rows the way
// telemetry.SpoolContents reads the spool: raw JSON rows, oldest first, nil
// when nothing waits. It reads the file by path and stats it first, like
// [pendingRows], because [outbox.Open] creates an absent outbox and a reading
// form must not write.
func poolRowsWaiting(poolDir string) []json.RawMessage {
path := filepath.Join(poolDir, "outbox.jsonl")
if _, err := os.Stat(path); err != nil {
return nil
}
box, err := outbox.Open(path)
if err != nil {
return nil
}
defer box.Close()
rows := box.Pending()
if len(rows) == 0 {
return nil
}
out := make([]json.RawMessage, 0, len(rows))
for _, row := range rows {
// The outbox stores a row compacted; encoding it again here, with
// HTML escaping off as the outbox writes it, answers the same bytes
// the relay is sent.
var line bytes.Buffer
enc := json.NewEncoder(&line)
enc.SetEscapeHTML(false)
if err := enc.Encode(row); err != nil {
continue
}
out = append(out, json.RawMessage(bytes.TrimRight(line.Bytes(), "\n")))
}
return out
}

// runTelemetrySet writes the settings row from internal/config: `telemetry off`
Expand All @@ -123,7 +386,7 @@ func runTelemetrySet(word string, args []string) error {
return err
}
if value {
fmt.Fprintln(usageOut, "telemetry on — anonymous usage counts are sent (see `codeaf telemetry show`)")
fmt.Fprintln(usageOut, "telemetry on — anonymous usage counts are sent (see `codeaf telemetry info`)")
return nil
}
fmt.Fprintln(usageOut, "telemetry off — nothing is sent; the session counters still count")
Expand Down
Loading
Loading