diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index 046dbf1e5..0fbc6c031 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -347,6 +347,9 @@ 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 @@ -938,7 +941,7 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) { // engine's own nothing. The ask is built once and a client is made from it // per call, each billed to the judge's own seat. taskLanded := poolJudgeHook(settings, settings.ProfileDir, workspace, - config.CrewCatalog, poolJudgeAsk(settings, settings.ProfileDir), time.Now, "task") + config.CrewCatalog, poolJudgeAsk(proc.liveSettings(settings), settings.ProfileDir), time.Now, "task") // The runs a live process would have judged but a process death left unjudged, // and the headless doors that never had this hook: at start, on a goroutine // nobody waits on, judge the resumed session's own final-state nodes and the @@ -946,7 +949,7 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) { // process tracker cancels and joins it at close. poolErrandGoCtx(settings.ProfileDir, "pool/judge-sweep", func(ctx context.Context) { poolJudgeSweepRun(ctx, settings, settings.ProfileDir, found.Place.Tasks(), - config.CrewCatalog, poolJudgeAsk(settings, settings.ProfileDir), time.Now) + config.CrewCatalog, poolJudgeAsk(proc.liveSettings(settings), settings.ProfileDir), time.Now) }) cfg := session.Config{ diff --git a/cmd/codeaf/chatv3_media.go b/cmd/codeaf/chatv3_media.go index 62d6c13f8..db6104f56 100644 --- a/cmd/codeaf/chatv3_media.go +++ b/cmd/codeaf/chatv3_media.go @@ -236,7 +236,19 @@ func v3MediaPick(models *catalog.Catalog) func(string, string) (string, error) { // absence law is a plain nil check. Without it, a build that could not make a // client would put every generation tool on the belt and fail each one on its // first call — the belt that lies, which is the thing the law exists to stop. +// +// NO KEY IS ABSENCE, NOT A FAULT, AND ABSENCE SAYS NOTHING. A first launch +// builds its conversation before setup has asked for a key, so this door is +// reached keyless on every new install; the line it used to log landed on the +// person's terminal just before the surface took it, and was the first thing +// they read after quitting (the fresh-install check of 2026-09-25). The key +// asked about is the one the client would carry — [config.Config.ClientConfig] +// resolves it the way [config.Config.MediaClient] does — and only a failure +// with a key in hand is a fault worth a line. func v3MediaClient(settings config.Config) session.MediaGenerator { + if strings.TrimSpace(settings.ClientConfig(settings.Model).APIKey) == "" { + return nil + } client, err := settings.MediaClient() if err != nil || client == nil { if err != nil { diff --git a/cmd/codeaf/chatv3_process.go b/cmd/codeaf/chatv3_process.go index 93c78d8d3..6badc4182 100644 --- a/cmd/codeaf/chatv3_process.go +++ b/cmd/codeaf/chatv3_process.go @@ -842,3 +842,19 @@ func (s *v3Seam) anchor(agent interface { } return resolved, nil } + +// liveSettings answers a launch's settings as they stand NOW: the launch's own +// copy, with the account this process holds at the moment of asking laid over +// it. A launch copies the process's settings when it opens, which on a first +// launch is before setup has a key, and [v3Process.setAPIKey] reaches the +// process and its agents but never a copy something else kept. Anything that +// runs later on a launch's behalf and calls a model — the Model Pool's judge is +// the one — asks through this, so it carries the key the person has given +// rather than the one the boot did not have. +func (p *v3Process) liveSettings(base config.Config) func() config.Config { + return func() config.Config { + live := base + live.APIKey, live.Sources = p.currentAccount() + return live + } +} diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index 809f6d3f6..53d29f440 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -84,6 +84,11 @@ 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) }) diff --git a/cmd/codeaf/firstrun_quiet_test.go b/cmd/codeaf/firstrun_quiet_test.go new file mode 100644 index 000000000..d41761a50 --- /dev/null +++ b/cmd/codeaf/firstrun_quiet_test.go @@ -0,0 +1,169 @@ +package main + +// A first launch prints nothing a person did not ask for, and the minutes after +// a key is pasted into setup are the same install's first minutes: the fresh- +// install check of 2026-09-25 (dev 1194d4b8f) found a raw log line on the +// terminal before any key existed, and a Model Pool judge that went on asking +// with the key the boot did not have, failing every landing of the first +// session and marking each one judged so no later start ever scored it. + +import ( + "bytes" + "context" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/pool/judge" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// captureStandardLog points the standard logger at a buffer for one test and +// puts the writer that was there back afterwards; before the surface parks it, +// the standard logger IS the person's terminal. +func captureStandardLog(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + previous := log.Writer() + log.SetOutput(&buf) + t.Cleanup(func() { log.SetOutput(previous) }) + return &buf +} + +// Contract 3.1: with no key, the media client is an ABSENT capability — no +// generation tool on the belt and no line on the terminal. Nothing asked for +// it, so nothing says it is missing. +func TestAFirstLaunchWithNoKeyLeavesMediaAbsentWithoutALine(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + logged := captureStandardLog(t) + + settings := config.Config{Model: "vendor/model", BaseURL: "https://openrouter.ai/api/v1"} + if media := v3MediaClient(settings); media != nil { + t.Fatalf("a keyless install built a media client: %#v", media) + } + if logged.Len() != 0 { + t.Fatalf("a keyless first launch wrote %q to the terminal, want nothing", logged.String()) + } +} + +// Contract 3.1, the other half: a media client that fails for any reason +// other than the missing key is still a fault somebody may need to read, so +// that one keeps its line in the log. +func TestAMediaFailureThatIsNotTheMissingKeyStillLogs(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + logged := captureStandardLog(t) + + settings := config.Config{Model: "vendor/model", APIKey: "a-key", BaseURL: ""} + if media := v3MediaClient(settings); media != nil { + t.Fatalf("a media client with no base URL was built: %#v", media) + } + if !strings.Contains(logged.String(), "media: no generation endpoint") { + t.Fatalf("a real media fault left no line in the log: %q", logged.String()) + } +} + +// Contract 3.4: a judge with no key is an absent judge. It leaves no reason +// naming the missing key and, above all, no judged marker, so the landing is +// still there for the next start's sweep to score once a key exists. +func TestAJudgeWithNoKeyLeavesTheLandingForTheNextStart(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_MODEL_POOL", "on") + t.Setenv("CODEAF_MODEL_POOL_SUBMIT_URL", "http://127.0.0.1:1/submit") + profileDir := t.TempDir() + poolDir := config.ProfilePath(profileDir, "pool") + + asked := map[string]bool{} + noKey := func(model string) judge.Ask { + return func(context.Context, string, string) (string, error) { + asked[model] = true + return "", provider.ErrNoAPIKey + } + } + hook := poolJudgeHook(config.Config{}, profileDir, t.TempDir(), poolTestCatalog, noKey, time.Now, "task") + if hook == nil { + t.Fatal("a pool whose mode allows reading built no hook") + } + landing := poolTestLanding() + hook(landing) + + if alreadyJudged(poolDir, landing.ID, landing.Attempt) { + t.Fatal("a landing no judge could ask for want of a key was marked judged, so no later start will ever score it") + } + if last := readJudgeLast(poolDir); last != nil && strings.Contains(last.Reason, "no API key") { + t.Fatalf("the missing key was recorded as the judge's reason: %q", last.Reason) + } + if len(asked) > 1 { + t.Fatalf("the judge asked %d candidates with no key to ask with, want it to stop at the first", len(asked)) + } +} + +// Contract 3.3: the chat's judge is built when the conversation is, which on +// a first launch is before setup has a key. The key pasted into setup reaches +// the process ([v3Process.setAPIKey]), and the judge's next question carries +// it — the judge reads the process's settings when it asks, not the copy the +// boot held. +func TestTheChatsJudgeAsksWithTheKeyPastedAfterBoot(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + + var mu sync.Mutex + var bearer []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + mu.Lock() + bearer = append(bearer, r.Header.Get("Authorization")) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"x","object":"chat.completion","model":"other/judge","choices":[{"index":0,"message":{"role":"assistant","content":"{\"score\": 88, \"reason\": \"it does what was asked\"}"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}`)) + })) + defer server.Close() + + profileDir := t.TempDir() + boot := config.Config{BaseURL: server.URL, ProfileDir: profileDir} + proc := &v3Process{ProfileDir: profileDir, Settings: boot} + ask := poolJudgeAsk(proc.liveSettings(boot), profileDir) + + if err := proc.setAPIKey("key-pasted-in-setup"); err != nil { + t.Fatal(err) + } + answer, err := ask("other/judge")(context.Background(), "system", "user") + if err != nil { + t.Fatalf("the judge asked after setup failed: %v", err) + } + if !strings.Contains(answer, "88") { + t.Fatalf("the judge's answer = %q", answer) + } + mu.Lock() + defer mu.Unlock() + if len(bearer) == 0 || bearer[len(bearer)-1] != "Bearer key-pasted-in-setup" { + t.Fatalf("the judge asked with %q, want the key pasted in setup", bearer) + } +} + +// Contract 3.3, the wiring: the chat door builds its judge from the live +// settings, never from the boot's copy. The helper above is only as good as +// the one line that uses it. +func TestTheChatDoorBuildsItsJudgeFromTheLiveSettings(t *testing.T) { + source, err := os.ReadFile(filepath.Join(".", "chatv3.go")) + if err != nil { + t.Fatal(err) + } + text := string(source) + if strings.Contains(text, "poolJudgeAsk(settings,") { + t.Fatal("chatv3.go builds a judge from the boot's settings copy, which never learns the key pasted into setup") + } + if strings.Count(text, "poolJudgeAsk(proc.liveSettings(settings)") < 2 { + t.Fatal("chatv3.go does not build both its judges (the landing hook and the start sweep) from proc.liveSettings") + } +} diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index a1554d49d..26db7066f 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -35,6 +35,7 @@ import ( "github.com/Agent-Field/codeaf/internal/plan" "github.com/Agent-Field/codeaf/internal/plandb" "github.com/Agent-Field/codeaf/internal/router" + "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/telemetry" "github.com/Agent-Field/codeaf/internal/trace" codeupdate "github.com/Agent-Field/codeaf/internal/update" @@ -42,9 +43,22 @@ import ( func main() { home.Adopt(log.Printf) + registerRunningCLI() os.Exit(execute()) } +// registerRunningCLI tells the worker harness which binary a worker's `codeaf` +// reaches: THIS one, under whatever file name it was installed. A worker's shell +// is taught `codeaf patch`, and without this the word resolved on the machine's +// PATH — nothing on a devaf install, an older codeaf on the fresh-install check +// of 2026-09-25 (internal/session's [session.SetRunningCLI] says why it is +// registered here rather than probed there). +func registerRunningCLI() { + if self, err := os.Executable(); err == nil { + session.SetRunningCLI(self) + } +} + // surfaceMaxProcs is the GOMAXPROCS a surface runs under on a machine bigger // than this. It is not the machine's core count, and the number was chosen by // counting what the runtime does with the ones above it, not by taste — see diff --git a/cmd/codeaf/poolrecord.go b/cmd/codeaf/poolrecord.go index b8cbeedd9..3a6920d68 100644 --- a/cmd/codeaf/poolrecord.go +++ b/cmd/codeaf/poolrecord.go @@ -200,6 +200,15 @@ func poolJudgeLandingContext(ctx context.Context, settings config.Config, profil judgeID = candidate break } + // A JUDGE WITH NO KEY IS AN ABSENT JUDGE, NOT A FAILED ONE. Every + // candidate would meet the same missing key, so none is asked after this + // one, and the landing is left exactly as it was: no reason naming the + // key in judge-last.json and, above all, no judged marker — the marker is + // permanent, and a landing marked here was one no later start, key in + // hand, would ever score. The restart sweep judges it then. + if errors.Is(err, provider.ErrNoAPIKey) { + return + } // No seat at all came back: that is the judge's own failure — a 429, a // timeout, a refusal — and not a verdict on the run, so its reason is // said here and the next candidate is asked. @@ -308,9 +317,17 @@ func outboxPath(poolDir string) string { // poolJudgeAsk builds the maker of one judge's ask: a client per call, built // from the model's own config, the answer read as the judge's plain string, // and the call billed to the judge's own seat. -func poolJudgeAsk(settings config.Config, profileDir string) func(model string) judge.Ask { +// +// THE SETTINGS ARE READ WHEN THE JUDGE ASKS, NOT WHEN IT IS BUILT. The chat +// builds its judge with the conversation, and on a first launch that is before +// setup has a key: a judge holding the boot's copy asked every landing of the +// install's first session keyless, and failed each one. live is the door's +// answer at the moment of asking ([v3Process.liveSettings]), so a key pasted +// into setup, or changed in /settings, is the key the next judgment carries. +func poolJudgeAsk(live func() config.Config, profileDir string) func(model string) judge.Ask { return func(model string) judge.Ask { return func(ctx context.Context, system, user string) (string, error) { + settings := live() client, err := provider.NewClient(settings.ClientConfig(model)) if err != nil { return "", err diff --git a/cmd/codeaf/telemetry_lifecycle.go b/cmd/codeaf/telemetry_lifecycle.go index e230d8ffd..d0bd1072c 100644 --- a/cmd/codeaf/telemetry_lifecycle.go +++ b/cmd/codeaf/telemetry_lifecycle.go @@ -18,6 +18,7 @@ import ( "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/telemetry" "github.com/Agent-Field/codeaf/internal/trace" + "github.com/Agent-Field/codeaf/internal/tui3" ) // telemetryConfiguredOff is the config's one answer to the ladder, read the @@ -85,10 +86,25 @@ func telemetryBegin() telemetrySession { // the sentence that asks permission to send, and a pipe that will never // send has nobody to ask — and marking it shown would create the very // directory this run promised not to write. + // + // A CHAT OWES IT TO THE SURFACE INSTEAD. A chat on a terminal is about to + // hand that terminal to a full-screen surface, and a notice printed here + // sat on the normal screen underneath it: read only after quitting, and + // marked seen from the moment it was printed, so the first exit sent the + // counts at the instant the notice first became visible. So a chat marks + // nothing here. It owes the notice, [runSurface] hands it to the surface, + // and the surface marks it once a frame has drawn it; a chat that never + // draws one (`--once`) prints it on the road it does take + // ([payTelemetryNoticeOnStderr]). Until the mark, [telemetry.Flush] sends + // nothing. if telemetry.Enabled() && !telemetry.NoticeShown() && !telemetryHasJSON(args) && - (mode == telemetry.ModeTask || stderrIsTerminal()) { - telemetry.PrintNotice() - telemetry.MarkNoticeShown() + (mode == telemetry.ModeTask || noticeTerminal()) { + if mode == telemetry.ModeChat { + telemetryNoticeOwed = true + } else { + telemetry.PrintNotice() + telemetry.MarkNoticeShown() + } } // Both opening events go through SpoolSync, not the fire-and-forget Spool: // first_run must be on disk before session_started even exists, and a run's @@ -238,6 +254,40 @@ func telemetryStopReason(code int) string { // of the ladder's own — but the notice's rule is narrower than that, because // `codeaf do 2>/dev/null` in a person's own script is not CI and still must // not spend the one line the person will never read. +// noticeTerminal is [stderrIsTerminal] as the notice asks it, a seam so a test +// can stand a terminal behind a process whose stderr is a pipe. +var noticeTerminal = stderrIsTerminal + +// telemetryNoticeOwed says this chat's notice is owed to the surface rather +// than printed: set at the start ([telemetryBegin]) and paid by the frame that +// draws it ([runSurface]). +var telemetryNoticeOwed bool + +// payTelemetryNoticeOnStderr prints an owed notice on a chat road that draws no +// surface — `--once` writes its answer to the terminal as plain lines, so the +// notice printed ahead of it is read ahead of it — and marks it seen. +func payTelemetryNoticeOnStderr() { + if !telemetryNoticeOwed { + return + } + telemetryNoticeOwed = false + telemetry.PrintNotice() + telemetry.MarkNoticeShown() +} + +// telemetryNoticeForSurface lays an owed notice on the surface's options: the +// exact text, and the mark the surface calls after the frame that drew it. +func telemetryNoticeForSurface(options *tui3.Options) { + if !telemetryNoticeOwed { + return + } + options.TelemetryNotice = telemetry.Notice + options.TelemetryNoticeShown = func() { + telemetryNoticeOwed = false + telemetry.MarkNoticeShown() + } +} + func stderrIsTerminal() bool { return stdinIsTerminal(os.Stderr) } diff --git a/cmd/codeaf/telemetry_notice_surface_test.go b/cmd/codeaf/telemetry_notice_surface_test.go new file mode 100644 index 000000000..e21f98b0a --- /dev/null +++ b/cmd/codeaf/telemetry_notice_surface_test.go @@ -0,0 +1,87 @@ +package main + +// The door's half of the usage notice: a chat that will draw a full-screen +// surface does not print the notice onto the normal screen the surface is about +// to cover, and does not mark it seen. It owes it to the surface, and the notice +// is marked seen by the surface's own report that a frame drew it. Until then +// nothing is flushed, because [telemetry.Flush] sends nothing before the mark. + +import ( + "context" + "testing" + + "github.com/Agent-Field/codeaf/internal/telemetry" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// noticeOnATerminal stands a terminal behind stderr for one test and clears +// whatever the notice's bookkeeping was left at. +func noticeOnATerminal(t *testing.T) { + t.Helper() + previous := noticeTerminal + noticeTerminal = func() bool { return true } + t.Cleanup(func() { + noticeTerminal = previous + telemetryNoticeOwed = false + }) + telemetryNoticeOwed = false +} + +// Contract 2.1 and 2.2: a chat on a terminal leaves the notice unmarked at the +// start, hands the exact notice to the surface, and marks it seen only when the +// surface reports the frame that drew it. +func TestAChatHandsTheNoticeToTheSurfaceAndMarksItOnlyWhenDrawn(t *testing.T) { + telemetryLifecycleHome(t) + noticeOnATerminal(t) + restore := telemetryArgs("chat") + defer restore() + + session := telemetryBegin() + if session.mode != telemetry.ModeChat { + t.Fatalf("chat: mode=%q", session.mode) + } + if telemetry.NoticeShown() { + t.Fatal("the chat marked the notice seen before the surface could draw it") + } + + previous := runSurfaceProgram + t.Cleanup(func() { runSurfaceProgram = previous }) + var seen tui3.Options + runSurfaceProgram = func(_ context.Context, options tui3.Options) error { + seen = options + return nil + } + if err := runSurface(context.Background(), tui3.Options{Agent: &quietAgent{}, ProfileDir: t.TempDir()}); err != nil { + t.Fatal(err) + } + if seen.TelemetryNotice != telemetry.Notice { + t.Fatalf("the surface was handed %q, want the notice byte for byte", seen.TelemetryNotice) + } + if seen.TelemetryNoticeShown == nil { + t.Fatal("the surface was handed no way to say the notice was drawn") + } + if telemetry.NoticeShown() { + t.Fatal("the notice was marked seen before the surface said a frame drew it") + } + seen.TelemetryNoticeShown() + if !telemetry.NoticeShown() { + t.Fatal("the surface said the notice was drawn and it was not marked seen") + } +} + +// Contract 2.3: a task command still prints the notice and marks it at once, +// because a task runs unattended and draws no surface. +func TestATaskStillPrintsTheNoticeAndOwesTheSurfaceNothing(t *testing.T) { + telemetryLifecycleHome(t) + noticeOnATerminal(t) + restore := telemetryArgs("do", "fix the bug") + defer restore() + + telemetryBegin() + if !telemetry.NoticeShown() { + t.Fatal("a task command did not mark the notice it printed") + } + if telemetryNoticeOwed { + t.Fatal("a task command left the notice owed to a surface it never draws") + } +} diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 77f17cbc8..8dee6753f 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -123,8 +123,9 @@ curl -fsSL https://agentfield.ai/get/codeaf | VERSION= bash The script needs `curl` or `wget`, plus `sha256sum` or `shasum`. It downloads `checksums.txt` and refuses a sha256 mismatch. Unless `--no-modify-path` is set, it appends one `export PATH=… # codeaf installer` line to the applicable shell file. On a -normal run it prints three things and nothing else: `installed codeaf v… built … · -go… os/arch` (the installed file naming itself), the three-line telemetry notice, and, +normal run it prints two things and nothing else: `installed codeaf v… built … · +go… os/arch` (the installed file naming itself; an install under another name puts +that name first, `installed devaf · codeaf dev-… built …`), and, when the folder is not yet on `PATH`, the bare `export PATH=…` line to paste into the current shell, bold green on a terminal, last, with a blank line above and below. `--verbose` also reports the channel, the tag and the install path on stderr. The diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index fcc48f7d7..545f906bd 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -6,7 +6,7 @@ about you or your work ever leaves this machine. ## The notice -Before the first session's events are sent, codeaf prints this to stderr once: +Before the first session's events are sent, codeaf shows this once: ``` codeaf sends anonymous usage counts to AgentField. @@ -18,7 +18,12 @@ codeaf sends anonymous usage counts to AgentField. ``` The installer prints nothing about telemetry; the notice above arrives with the -first session, before anything is sent. +first session, before anything is sent. A chat shows it on the first +conversation's screen, dim, under the starting points, and it counts as shown +only once a frame has drawn it: a window too short to hold all six lines, or the +first-run setup standing in front, leaves it owed for the next launch. A task +command (`do`, `run`, `plan run`) and `chat --once` draw no screen, so they print +it to stderr before they start. Until it has been shown, nothing is sent. ## What is sent diff --git a/docs/changes/unreleased/1519-first-run-names.md b/docs/changes/unreleased/1519-first-run-names.md new file mode 100644 index 000000000..1e6702fa8 --- /dev/null +++ b/docs/changes/unreleased/1519-first-run-names.md @@ -0,0 +1,16 @@ +--- +kind: fixed +title: a devaf first run reaches its own binary, shows the usage notice on screen, prints no raw lines +pr: 1519 +surface: [build, chat, engine] +invalidates: + - "A bash worker's `codeaf …` (the page teaches `codeaf patch`) resolved on the machine's own PATH, because the run put only `bin/plandb` there: `command not found` on a devaf, stageaf or `--name` install, or an older codeaf answering `there is no \\`codeaf patch\\``. The run's folder now also holds `bin/codeaf`, which execs the running binary that codeaf registered at start (`session.SetRunningCLI`). A process that registered nothing gets a one-line refusal with exit 127, never another codeaf." + - "The worker's `plandb` was found by probing ` plandb status` beside the live store. That probe could meet `database is locked` and fall through to a PATH codeaf, or fail the task with `no plandb CLI found`. The registered running codeaf is now taken unprobed, right after `CODEAF_PLANDB_BIN`." + - "A chat printed the telemetry notice to stderr and marked it seen just before the full-screen surface covered it. The first conversation's screen now draws it, and it is marked seen only after a frame drew it; nothing is sent before that. Task commands and `chat --once` still print it to stderr." + - "A keyless first launch logged `media: no generation endpoint on this install: provider API key is required` onto the terminal. Keyless media is now absent without a line." + - "The Model Pool judge kept the boot's settings, so a landing in the first session after setup was judged with no key and marked judged for good. It now reads the live key when it asks, and a judge with no key leaves the landing for the next start." + - "A `--name devaf` install's receipt said `installed codeaf dev-…`. It now says `installed devaf · codeaf dev-… built …`. The installer never printed the telemetry notice, whatever the manual and GUIDE said." +--- + +Found by a fresh-install check of the published dev build, installed as `devaf` +through the real installer, with an older codeaf still on the machine's PATH. diff --git a/internal/manual/chat/adaptive-runs.md b/internal/manual/chat/adaptive-runs.md index d0f0d854b..ea6cdfe96 100644 --- a/internal/manual/chat/adaptive-runs.md +++ b/internal/manual/chat/adaptive-runs.md @@ -742,7 +742,8 @@ you named with `--db`. The default run engine refuses `--db`. - **Refused before work began:** the default run engine makes no record. The older engine can retain an empty folder when `--keep` or debug was requested. -A default run's folder holds `plandb.db`, the worker shim (`bin/plandb`), and +A default run's folder holds `plandb.db`, the worker shims (`bin/plandb`, and +`bin/codeaf`, which is the codeaf that is running under whatever name), and task transcripts and trajectories under `tasks//`. Read its plan with `codeaf plandb --db /plandb.db`. The older engine's folder holds `graph.db`; that engine can reopen it with `codeaf do --db /graph.db`. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index db0283a9b..49c033b58 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -94,9 +94,11 @@ exactly once, or what it fetched is not a shell script, it answers 502. Building from source needs nothing published: clone the repository, run `make build`, then run `bin/codeaf` from the checkout. -The installer writes `~/.codeaf/bin/codeaf` and prints three things: one line -naming the installed file's `version`, the three-line telemetry notice, and last, -when the folder is not yet on `PATH`, the bare `export PATH=…` line to paste. +The installer writes `~/.codeaf/bin/codeaf` and prints two things: one line +naming the installed file's `version` (`installed codeaf built …`), and last, +when the folder is not yet on `PATH`, the bare `export PATH=…` line to paste. It +prints nothing about telemetry; codeaf itself shows that notice before any count +is sent. `/update` in the chat or `codeaf update` in a terminal replaces it in place; running the install line again works too. @@ -113,6 +115,9 @@ That proxy serves the installer from the `dev` branch and rewrites exactly two default lines: the channel becomes dev and the installed name becomes devaf. It writes `~/.codeaf/bin/devaf` and leaves `~/.codeaf/bin/codeaf` untouched. On Windows the file is `devaf.exe`. `devaf version` still starts with `codeaf`. +The installer's receipt names the command to type first: +`installed devaf · codeaf dev-- built …`. Any `--name` install +reads the same way, with its own word first. The general spelling is `--name WORD` or `CODEAF_INSTALL_NAME=WORD`; the name may contain ASCII letters, digits, `.`, `_`, and `-`, and must begin with a @@ -787,7 +792,10 @@ wait in `outbox.jsonl` beside the sheet, to leave with the pool's other measurements; `read` keeps them local, and `off` asks no judge at all and writes nothing. The call itself is billed to the `judge` seat, so it shows up in the spend pages beside the crew seats rather than inside a task's own -cost. +cost. The judge asks with the key the install holds at that moment, so a task +that lands just after you paste a key into first-run setup is scored with that key. +A task that lands while there is no key at all is not judged. It is not marked +judged either, so the next start scores it once a key exists. With `model_pool` on, the rows leave for the relay after each judged run and once more at start-up, under this install's own nonce and nothing else. The @@ -1001,8 +1009,10 @@ usage counts go quiet and the Model Pool is capped at `read`, so it still picks from the index and sends nothing. The pool's own switch, `model_pool` in `/settings` or `CODEAF_MODEL_POOL`, adds `off`, which asks no judge at all. It reads and sends nothing of its own — it is a command about the counts, not a session. The -notice the first session prints names the bargain before the first byte leaves, and -`CODEAF_TELEMETRY=off` or `DO_NOT_TRACK=1` turns the counts off entirely. See +notice names the bargain before the first byte leaves. A chat shows it once, dim, +on the first conversation's screen under the starting points, and nothing is sent +until a frame has drawn it. A task command and `chat --once` print it to stderr +instead. `CODEAF_TELEMETRY=off` or `DO_NOT_TRACK=1` turns the counts off entirely. See docs/TELEMETRY.md for the whole contract. ## Reading a plan by hand — codeaf plan new, show, revise and run diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index fc8fa1084..d1c1d9ea6 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -581,6 +581,12 @@ same code path the tool runs, so the two cannot drift: - `codeaf web fetch URL` / `codeaf web search QUERY` — the belt's web verbs. - `codeaf image PROMPT --out PATH` — one picture the way `generate_image` makes one. +**`codeaf` in a worker's shell is always the codeaf that is running**, whatever +file name it was installed under — `devaf`, `stageaf`, or a `--name` word. The +run's own `bin/codeaf` sits first on the worker's PATH, so an older or different +codeaf elsewhere on the machine is never reached, and a devaf install does not +answer `command not found`. + A few hands a shell cannot be are kept too, and a worker calls them directly, one call per response exactly as it calls `bash` — the billed `read_document`, `jobs` (which reads and stops a job the worker started), `manual`, and the web, media and services diff --git a/internal/release/install_test.go b/internal/release/install_test.go index b21b49f08..aa0c11945 100644 --- a/internal/release/install_test.go +++ b/internal/release/install_test.go @@ -896,10 +896,18 @@ func TestV1InstallerName(t *testing.T) { if err != nil || string(kept) != string(original) { t.Fatalf("codeaf = %q, %v", kept, err) } - // A normal run's receipt is the installed file naming itself, and the - // last thing said is the line to paste; the path is --verbose's to say. - if !strings.Contains(run.output, "installed codeaf "+tag+" · fake") { - t.Fatalf("output does not carry the receipt:\n%s", run.output) + // A normal run's receipt names the COMMAND the person will type first, + // then the installed file naming its build; the last thing said is the + // line to paste, and the path is --verbose's to say. A devaf install that + // said "installed codeaf" sent the person to type a command this install + // never wrote (the fresh-install check of 2026-09-25). + if !strings.Contains(run.output, "installed devaf · codeaf "+tag+" · fake") { + t.Fatalf("output does not carry the receipt naming devaf:\n%s", run.output) + } + for _, line := range strings.Split(run.output, "\n") { + if strings.HasPrefix(line, "installed codeaf") { + t.Fatalf("a devaf install's receipt says %q:\n%s", line, run.output) + } } if got := strings.Split(strings.TrimSpace(run.output), "\n"); got[len(got)-1] != `export PATH="`+dir+`:$PATH"` { t.Fatalf("last line = %q:\n%s", got[len(got)-1], run.output) @@ -921,6 +929,9 @@ func TestV1InstallerName(t *testing.T) { if _, err := os.Stat(filepath.Join(dir, "devaf")); err != nil { t.Fatal(err) } + if !strings.Contains(fromEnv.output, "installed devaf · codeaf "+tag+" · fake") { + t.Fatalf("an install named from the environment does not name devaf in its receipt:\n%s", fromEnv.output) + } flag := runInstaller(t, github, []string{"--name", "mine", "--dev"}, "CODEAF_INSTALL_DIR="+dir, "CODEAF_INSTALL_NAME=ignored", "CODEAF_NO_MODIFY_PATH=1") if flag.code != 0 { diff --git a/internal/session/codeaf_shim_test.go b/internal/session/codeaf_shim_test.go new file mode 100644 index 000000000..68ec1c308 --- /dev/null +++ b/internal/session/codeaf_shim_test.go @@ -0,0 +1,179 @@ +package session + +// A WORKER'S `codeaf` IS THE CODEAF THAT IS RUNNING. The bash worker is taught +// to edit with `codeaf patch` (prompts/bashworker.md), and the only PATH entry +// the harness gave it was the run's `plandb` shim — so on any install whose +// file is not named codeaf (devaf, stageaf, a `--name` word) the call resolved +// on the machine's own PATH: `command not found` on a clean machine, and on the +// fresh-install check of 2026-09-25 an OLDER codeaf that answered `error: there +// is no \`codeaf patch\`` three times. These tests stand a stale `codeaf` that +// prints WRONG first on the process PATH and drive the real harness PATH setup +// ([NewBeltWorker], the run engine's worker seat, through the belt's own bash). + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/effort" + "github.com/Agent-Field/codeaf/internal/exec/bare" +) + +// writeScript writes one executable shell script and answers its path. +func writeScript(t *testing.T, dir, name, body string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } + return path +} + +// staleCodeafFirst puts an executable named codeaf that prints WRONG at the +// FRONT of the process PATH — the older codeaf that answered on the check +// machine — and answers its directory. +func staleCodeafFirst(t *testing.T, probeAnswers bool) string { + t.Helper() + dir := t.TempDir() + body := "echo WRONG; exit 3" + if probeAnswers { + // A codeaf that answers the plan CLI's probe but is not this process: + // the one shape [resolvePlanCLI] may still settle on for `plandb`. + body = `if [ "$1" = plandb ]; then exit 0; fi` + "\necho WRONG; exit 3" + } + writeScript(t, dir, "codeaf", body) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return dir +} + +// runningCodeafAs stands in for the running binary under another file name, +// registered the way cmd/codeaf registers itself ([SetRunningCLI]). It answers +// the plan CLI's probe and says RIGHT with its arguments for everything else. +func runningCodeafAs(t *testing.T, name string) string { + t.Helper() + path := writeScript(t, t.TempDir(), name, `if [ "$1" = plandb ]; then exit 0; fi`+"\necho RIGHT \"$@\"") + previous := runningCLI + SetRunningCLI(path) + t.Cleanup(func() { runningCLI = previous }) + return path +} + +// beltBash answers the belt's own bash hand on a worker. +func beltBash(t *testing.T, agent *Agent) bare.Tool { + t.Helper() + for _, tool := range agent.beltTools() { + if tool.Name == "bash" { + return tool + } + } + t.Fatal("the worker's belt carries no bash hand") + return bare.Tool{} +} + +// runBeltBash runs one command through the worker's bash hand and answers its +// text whether or not the command failed. +func runBeltBash(t *testing.T, bash bare.Tool, command string) string { + t.Helper() + args, err := json.Marshal(map[string]string{"command": command}) + if err != nil { + t.Fatal(err) + } + text, _, err := bash.Execute(context.Background(), args) + if err != nil { + t.Fatalf("the belt's bash refused %q: %v", command, err) + } + return text +} + +// Contract 1.1-1.4: whatever the running binary's file is called, and whatever +// else is on PATH first, a worker's `codeaf …` reaches the running binary — as +// the first word, after a `cd`, and when asked where `codeaf` is. +func TestAWorkersCodeafIsTheRunningBinaryWhateverItIsNamed(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv(planCLIBinEnv, "") + staleCodeafFirst(t, true) + runningCodeafAs(t, "devaf") + + agent := newRunBeltWorker(t, effort.None) + bash := beltBash(t, agent) + + first := runBeltBash(t, bash, "codeaf patch notes.md --old a --new b") + if strings.Contains(first, "WRONG") || !strings.Contains(first, "RIGHT patch notes.md --old a --new b") { + t.Fatalf("a worker's `codeaf patch` reached %q, want the running devaf", first) + } + chained := runBeltBash(t, bash, "mkdir -p sub && cd sub && codeaf patch ../notes.md --old a --new b") + if strings.Contains(chained, "WRONG") || !strings.Contains(chained, "RIGHT patch ../notes.md") { + t.Fatalf("a `codeaf` after a cd reached %q, want the running devaf", chained) + } + where := strings.TrimSpace(runBeltBash(t, bash, "command -v codeaf")) + if !strings.HasSuffix(where, string(filepath.Separator)+filepath.Join("bin", "codeaf")) || strings.Contains(where, "WRONG") { + t.Fatalf("command -v codeaf answered %q, want the run's own bin/codeaf", where) + } +} + +// Contract 1.5: when nothing names the running codeaf — a driver that answers +// only the plan CLI, or a test binary — the worker's `codeaf` refuses in one +// line and NEVER falls through to whatever codeaf the machine's PATH holds. +func TestAWorkersCodeafRefusesRatherThanReachAnotherCodeaf(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv(planCLIBinEnv, "") + staleCodeafFirst(t, true) + previous := runningCLI + runningCLI = "" + t.Cleanup(func() { runningCLI = previous }) + + agent := newRunBeltWorker(t, effort.None) + bash := beltBash(t, agent) + + said := runBeltBash(t, bash, "codeaf patch notes.md --old a --new b; echo exit=$?") + if strings.Contains(said, "WRONG") { + t.Fatalf("with no running codeaf named, the worker reached the machine's own codeaf: %q", said) + } + if !strings.Contains(said, codeafShimRefusal) || !strings.Contains(said, "exit=127") { + t.Fatalf("the worker's codeaf answered %q, want the refusal and exit 127", said) + } +} + +// Contract 1.x, the landing's side: the `codeaf` shim is the harness's own file, +// exactly as the `plandb` shim beside it is. A run whose store sits inside the +// working copy writes both into that copy's bin/, and a landing that counted +// bin/codeaf as the work would report a change nobody asked for — or keep a +// branch for it. +func TestTheCodeafShimIsTheHarnesssOwnFileNotTheWork(t *testing.T) { + for _, path := range []string{"bin/" + planShimFilename, "bin/" + codeafShimFilename} { + if !harnessWrote(path) { + t.Fatalf("%s is counted as the run's work, want it read as the harness's own shim", path) + } + } + if harnessWrote("bin/devaf") { + t.Fatal("a bin/devaf the work wrote was taken for a harness shim") + } +} + +// Contract 1.2 for the plan CLI too, and the race the hand check met: the +// running codeaf is the worker's `plandb` WITHOUT a probe. The probe runs +// ` plandb status` beside a store the run is writing, and on 2026-09-25 it +// met `database is locked (SQLITE_BUSY)` there, so the resolver fell through to +// whatever codeaf the machine's PATH held — an older version on the check +// machine, and on a clean devaf install nothing at all, which failed the task +// with `no plandb CLI found`. The codeaf command registered itself, which is +// the proof the probe was standing in for. +func TestTheRunningCodeafIsTheWorkersPlandbWithoutAProbe(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv(planCLIBinEnv, "") + staleCodeafFirst(t, true) + // The running codeaf, caught mid-write: its probe answers busy. + busy := writeScript(t, t.TempDir(), "devaf", `if [ "$1" = plandb ] && [ "$2" = status ]; then echo "database is locked" >&2; exit 1; fi`+"\necho RIGHT \"$@\"") + previous := runningCLI + SetRunningCLI(busy) + t.Cleanup(func() { runningCLI = previous }) + + agent := newRunBeltWorker(t, effort.None) + said := runBeltBash(t, beltBash(t, agent), "plandb task note t-root --note hi") + if strings.Contains(said, "WRONG") || !strings.Contains(said, "RIGHT plandb task note") { + t.Fatalf("a worker's plandb reached %q, want the running devaf", said) + } +} diff --git a/internal/session/plandb_plan.go b/internal/session/plandb_plan.go index c0e05ed26..d2ecd7d46 100644 --- a/internal/session/plandb_plan.go +++ b/internal/session/plandb_plan.go @@ -58,8 +58,9 @@ type planState struct { // path helper, the CLI's walk-up, and the store's own creation all spell it // the same way. const ( - planStoreFilename = "plandb.db" - planShimFilename = "plandb" + planStoreFilename = "plandb.db" + planShimFilename = "plandb" + codeafShimFilename = "codeaf" ) // planRootID is the store's root task. The reference loop's supervisor seeds @@ -686,21 +687,36 @@ func (p *planState) armShim() error { if err := os.MkdirAll(bin, 0o700); err != nil { return err } - shim := filepath.Join(bin, planShimFilename) words := make([]string, 0, len(argv)+1) for _, word := range argv { words = append(words, quoteShWord(word)) } - script := "#!/bin/sh\nexec " + strings.Join(words, " ") + " \"$@\"\n" - if existing, err := os.ReadFile(shim); err != nil || string(existing) != script { - if err := os.WriteFile(shim, []byte(script), 0o755); err != nil { - return err - } + if err := writeShim(filepath.Join(bin, planShimFilename), "#!/bin/sh\nexec "+strings.Join(words, " ")+" \"$@\"\n"); err != nil { + return err + } + // AND `codeaf` BESIDE IT, for the same reason and on the same PATH entry. The + // worker is taught to edit with `codeaf patch` (prompts/bashworker.md), and a + // `codeaf` resolved on the machine's PATH is a coin toss: nothing at all on an + // install whose file is devaf or stageaf, and on the fresh-install check of + // 2026-09-25 an older codeaf that answered `there is no \`codeaf patch\``. + // The shim makes the one word the page teaches mean the codeaf that is + // running, whatever its file is called ([codeafShimScript]). + if err := writeShim(filepath.Join(bin, codeafShimFilename), codeafShimScript()); err != nil { + return err } p.shimmed = true return nil } +// writeShim writes one shim executable, and leaves a file already holding the +// same script alone so a run's many workers do not rewrite it under each other. +func writeShim(path, script string) error { + if existing, err := os.ReadFile(path); err == nil && string(existing) == script { + return nil + } + return os.WriteFile(path, []byte(script), 0o755) +} + // shimDir is the directory the armed shim lives in — beside the store, the // one place both the walk-up and the belt's prefix agree on. Empty while the // shim never armed: a prefix into a directory that does not exist would @@ -716,7 +732,8 @@ func (p *planState) shimDir() string { // planBashPrefix is the assignment that puts the shim's directory FIRST on the // PATH of ONE command and binds that same command to the run's store — the // prefix a bash-belt worker's command carries, and the whole of the mechanism. -// THE LAW IT CARRIES: THE ONLY `plandb` A WORKER CAN REACH IS THE RUN'S OWN. +// THE LAW IT CARRIES: THE ONLY `plandb` A WORKER CAN REACH IS THE RUN'S OWN, AND +// THE ONLY `codeaf` IS THE ONE RUNNING. // // IT IS AN `export`, NOT A BARE COMMAND-PREFIX ASSIGNMENT, and that is the fix, // not decoration: `PATH=x:$PATH cmd` binds only the FIRST simple command of the @@ -759,6 +776,39 @@ func (g *TaskGraph) planBashPrefix() string { return prefix + "; " } +// runningCLI is the running codeaf binary, as the codeaf command registered it +// at its own start ([SetRunningCLI]), and empty in every process that is not +// the codeaf command: a bench driver that answers only `plandb`, a go test +// binary. It is what a worker's `codeaf` shim execs. +// +// IT IS REGISTERED, NOT PROBED, because the one thing this shim must never do is +// run a binary that is not codeaf with codeaf's words. The plan CLI's resolver +// may probe (resolvePlanCLI), since ` plandb status` is a read; there is no +// such harmless read for every verb a worker might type, and a bench driver +// handed `patch` would parse it as its own flags and start its grid. So the +// codeaf command says what it is, and anything that has not said is refused. +var runningCLI string + +// SetRunningCLI registers the path of the running codeaf binary — the one the +// person started, under whatever file name it was installed (codeaf, devaf, +// stageaf, a `--name` word). cmd/codeaf calls it once, at start. +func SetRunningCLI(path string) { runningCLI = strings.TrimSpace(path) } + +// codeafShimRefusal is the one line a worker's `codeaf` says when this process +// never registered a running codeaf. It refuses rather than falling through to +// the machine's PATH, where a different codeaf answers with the wrong verbs. +const codeafShimRefusal = "codeaf is not reachable from this shell in this run; edit with sed -i or a heredoc instead" + +// codeafShimScript is the `codeaf` shim's whole text: an exec of the running +// codeaf, or the refusal and exit 127 — the shell's own "not found" status — +// when there is none to exec. +func codeafShimScript() string { + if runningCLI == "" { + return "#!/bin/sh\necho " + quoteShWord(codeafShimRefusal) + " >&2\nexit 127\n" + } + return "#!/bin/sh\nexec " + quoteShWord(runningCLI) + " \"$@\"\n" +} + // planCLIBinEnv is the resolver's one override: it names a binary that // answers ` plandb …` — the codeaf-shaped door — and it wins unprobed, // because an override that needs a probe is a suggestion. @@ -779,7 +829,8 @@ const planCLIBinEnv = "CODEAF_PLANDB_BIN" // instead of answering. // // THE ORDER IS HOW WELL EACH CANDIDATE KNOWS ITSELF: the explicit override -// first; the running binary, only when it passes the probe (a driver that +// first; the running codeaf as it registered itself ([SetRunningCLI]), +// unprobed; the running binary, only when it passes the probe (a driver that // routes the door passes — the bench's own binary is how the in-process arm // gets a CLI at all); the sibling `plandb` beside the executable, whose own // name is the whole contract — cmd/plandb builds it beside bin/codeaf — so @@ -792,6 +843,15 @@ func resolvePlanCLI(storeDir string) []string { if override := strings.TrimSpace(env.Get(planCLIBinEnv)); override != "" { return []string{override, "plandb"} } + // THE RUNNING CODEAF WINS UNPROBED TOO, because it said what it is + // ([SetRunningCLI]) and the probe was only ever standing in for that. The + // probe is a read beside a store the run is writing, and on 2026-09-25 it met + // `database is locked` there: the resolver fell through to the codeaf on the + // machine's PATH — an older version, answering this run's plan — and on a + // clean devaf install to nothing, which failed the task with the error below. + if runningCLI != "" { + return []string{runningCLI, "plandb"} + } if self, err := os.Executable(); err == nil && !looksLikeTestBinary(self) { if planCLIProbes(self, storeDir) { return []string{self, "plandb"} diff --git a/internal/session/run_tree_changes.go b/internal/session/run_tree_changes.go index 54588480f..b173e2239 100644 --- a/internal/session/run_tree_changes.go +++ b/internal/session/run_tree_changes.go @@ -138,7 +138,8 @@ func (s RunTreeSnapshot) harnessOwns(path string) bool { // harnessWrote is THE ONE ANSWER to which paths inside a working copy are the // harness's own rather than the work: its folder of droppings, its plan store // under the name every road agrees on ([planStoreFilename], and the files the -// store's engine keeps beside it), and the shim it arms. It is read by the belt +// store's engine keeps beside it), and the two shims it arms (`plandb` and +// `codeaf`, plandb_plan.go's armShim). It is read by the belt // landing ([beltTreeWork]) and by an in-place run's account of what it changed // ([RunTreeSnapshot.Changed]), so the two cannot disagree about it. // @@ -156,7 +157,7 @@ func harnessWrote(path string) bool { strings.HasPrefix(path, planStoreFilename+"."), strings.HasPrefix(path, planStoreFilename+"-"): return true - case path == "bin/"+planShimFilename: + case path == "bin/"+planShimFilename, path == "bin/"+codeafShimFilename: return true } return false diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 6b488fb97..c224fe9c9 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -855,6 +855,18 @@ type ( ) type app struct { + // telemetryNotice is the usage notice still owed to the person, drawn on the + // first conversation's greeting ([app.welcomeNoticeRows]); empty when nothing + // is owed or once the greeting that showed it has gone. + telemetryNotice string + // telemetryNoticeShown is the door's "it was seen" record, and + // telemetryNoticeOnFrame is a frame's note that it drew the notice. The frame + // only notes; the update loop calls the door, once + // ([app.settleTelemetryNotice]), and telemetryNoticeSettled says it has. + telemetryNoticeShown func() + telemetryNoticeOnFrame bool + telemetryNoticeSettled bool + questionReplacement *questionReplacement discussionFeeds map[string]*discussionFeed @@ -2962,6 +2974,7 @@ func newApp(ctx context.Context, opts Options) *app { lastQuestionKey: time.Now(), questionReach: newQuestionDeliveryRule(), } + a.telemetryNotice, a.telemetryNoticeShown = opts.TelemetryNotice, opts.TelemetryNoticeShown // THE MEMOS ARE BUILT BEFORE ANYTHING ASKS THEM ANYTHING, because the frame's // door onto each is a memo lookup and nothing else: a memo with no reader // behind it answers "nobody has read that" forever (learned.go). They are @@ -3418,6 +3431,10 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } model, cmd := a.update(msg) + // A USAGE NOTICE THE LAST FRAME DREW IS RECORDED HERE, on the loop and once: + // the frame may only note that it drew it (view.go), because the door's + // record is a write to disk. + a.settleTelemetryNotice() // A JUMP TO A MESSAGE WAITING FOR ITS CONVERSATION lands here, on the first // message after that conversation is in front (teamjump.go). if a.traffic.jump.key != "" { diff --git a/internal/tui3/telemetrynotice_test.go b/internal/tui3/telemetrynotice_test.go new file mode 100644 index 000000000..208e13d2e --- /dev/null +++ b/internal/tui3/telemetrynotice_test.go @@ -0,0 +1,108 @@ +package tui3 + +// THE USAGE NOTICE IS READ ON THE SURFACE, BEFORE ANYTHING IT DESCRIBES HAPPENS. +// It used to be printed on the normal screen a moment before the full-screen +// surface covered it, and marked as seen at the same moment, so a new person met +// it only after quitting — by which time the exit had already sent the counts it +// describes (the fresh-install check of 2026-09-25). These tests hold the +// surface's half: the first conversation's screen draws the notice whole, and +// the door is told it was seen only after a frame has drawn it. + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/codeaf/internal/telemetry" +) + +// noticeWords is one notice line as the screen reads it: the fields rejoined, +// the same flattening [welcomeScreen] applies to the frame. +func noticeWords(line string) string { + return strings.Join(strings.Fields(line), " ") +} + +// owingApp is the first conversation's surface with the notice still owed and a +// counter standing where the door's "it was seen" write would be. +func owingApp(t *testing.T, height int) (*app, *int) { + t.Helper() + a := firstChatApp(t) + seen := 0 + a.telemetryNotice = telemetry.Notice + a.telemetryNoticeShown = func() { seen++ } + a.width, a.height = 120, height + a.touch() + return a, &seen +} + +// Contract 2.1 and 2.2: the first conversation's screen carries every line of +// the notice, and the door hears that it was seen only on the loop after a +// frame drew it — never before the first frame, and never twice. +func TestTheFirstConversationShowsTheUsageNoticeBeforeAnythingIsSent(t *testing.T) { + a, seen := owingApp(t, 44) + + pressSetup(a, tea.WindowSizeMsg{Width: 120, Height: 44}) + if *seen != 0 { + t.Fatalf("the notice was counted as seen %d times before any frame drew it", *seen) + } + screen := welcomeScreen(a) + for _, line := range strings.Split(telemetry.Notice, "\n") { + if !strings.Contains(screen, noticeWords(line)) { + t.Fatalf("the first conversation's screen is missing the notice line %q:\n%s", line, screen) + } + } + pressSetup(a, tea.WindowSizeMsg{Width: 120, Height: 44}) + if *seen != 1 { + t.Fatalf("after a frame drew the notice the door heard %d times, want once", *seen) + } + _ = welcomeScreen(a) + pressSetup(a, tea.WindowSizeMsg{Width: 120, Height: 44}) + if *seen != 1 { + t.Fatalf("a second frame told the door again: %d times, want once", *seen) + } +} + +// Contract 2.2: a frame with no room for the whole notice does not draw half of +// it and does not count it as seen, so it is still owed on the next launch. +func TestAFrameWithNoRoomForTheNoticeDoesNotCountItAsSeen(t *testing.T) { + a, seen := owingApp(t, 24) + screen := welcomeScreen(a) + if strings.Contains(screen, noticeWords(strings.Split(telemetry.Notice, "\n")[0])) { + t.Fatalf("the notice was drawn on a frame the test sized too short for it:\n%s", screen) + } + pressSetup(a, tea.WindowSizeMsg{Width: 120, Height: 24}) + if *seen != 0 { + t.Fatalf("a notice no frame drew was counted as seen %d times", *seen) + } +} + +// Contract 2.2: the first-run setup stands in front of the greeting, and a +// notice behind it is not on the screen, so it is not seen either. The options +// are the door's own, handed in the way the door hands them. +func TestTheUsageNoticeBehindTheSetupIsNotCountedAsSeen(t *testing.T) { + a, _, _ := setupApp(t, nil) + seen := 0 + b := newApp(t.Context(), Options{ + Agent: &fakeAgent{model: "openai/gpt-4.1-mini"}, + Workspace: "/tmp/lab", + ProfileDir: a.profileDir, + Setup: true, + ApplyAPIKey: func(string) error { return nil }, + TelemetryNotice: telemetry.Notice, + TelemetryNoticeShown: func() { seen++ }, + }) + b.width, b.height = 120, 44 + b.touch() + if !b.setup.open { + t.Fatal("the fixture's setup is not in front") + } + screen := welcomeScreen(b) + if strings.Contains(screen, noticeWords(strings.Split(telemetry.Notice, "\n")[0])) { + t.Fatalf("the setup screen drew the notice:\n%s", screen) + } + pressSetup(b, tea.WindowSizeMsg{Width: 120, Height: 44}) + if seen != 0 { + t.Fatalf("a notice behind the setup was counted as seen %d times", seen) + } +} diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index f0b638181..a4e94d421 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -453,6 +453,19 @@ type Options struct { UpdateArgs []string Restart *codeupdate.Plan + // TelemetryNotice is the anonymous usage counts' notice while this install + // still owes it to the person, and empty once it has been seen. The first + // conversation's screen draws it whole, beside the greeting, because the + // notice promises to be read BEFORE any count is sent, and a line printed on + // the normal screen just before this surface covered it was read only after + // quitting, by which time the exit had already sent (docs/TELEMETRY.md). + TelemetryNotice string + // TelemetryNoticeShown is the door's record that the notice was seen. It is + // called once, on the update loop, after a frame has drawn TelemetryNotice — + // never from the frame, which may not touch the disk — and never for a notice + // that no frame drew: a frame too short for it, or a setup standing in front. + TelemetryNoticeShown func() + // Memory is the durable memory store behind the memory place. Nil means the // place is unavailable; the live door passes the same store it gave the // session, wrapped so that the two READING methods are spelled the way this diff --git a/internal/tui3/view.go b/internal/tui3/view.go index ad1f5e4e1..9695b50aa 100644 --- a/internal/tui3/view.go +++ b/internal/tui3/view.go @@ -436,6 +436,12 @@ func (a *app) chatFrameLines(width, height int) ([]string, int, int) { } } chrome, chromeMarks, caretX, caretRow := a.chrome(width) + // The chrome just laid out is the frame's own, so what its greeting drew is + // what this frame shows: an owed usage notice among it is noted here, and the + // update loop tells the door ([app.settleTelemetryNotice]). + if a.welcome.noticeDrawn { + a.telemetryNoticeOnFrame = true + } // The welcome box rides at the top of the frame rather than at the bottom // with the chrome it is built with ([welcomeLift] states why). Splitting it // off here keeps [app.frameOut]'s law intact: what is left is still the tail, diff --git a/internal/tui3/welcome.go b/internal/tui3/welcome.go index fc7a67041..15f491b2c 100644 --- a/internal/tui3/welcome.go +++ b/internal/tui3/welcome.go @@ -156,6 +156,10 @@ type welcome struct { // move it (see [app.welcomeKey]). sel int recent []Session + // noticeDrawn says the last layout of this unit carried the whole usage + // notice ([app.welcomeNoticeRows]). It is the frame's own note, read by the + // frame that drew it (view.go) and by nothing that only measures. + noticeDrawn bool } func (w *welcome) animating() bool { return w.open && w.step < welcomeFrames } @@ -223,6 +227,12 @@ func (a *app) dismissWelcome() { return } a.welcome = welcome{spent: true} + // THE NOTICE GOES WITH THE GREETING THAT SHOWED IT. Once a frame has drawn it + // and the door has recorded it, the next greeting in this process — a `/new`, + // a second tab — is not owed it again. + if a.telemetryNoticeSettled { + a.telemetryNotice = "" + } a.noteLandingKeys() a.touch() } @@ -1090,6 +1100,25 @@ func (a *app) welcomeUnit(width int) ([]string, []welcomeMark, int, int) { add(pal.dim(line), welcomeMark{}) } + // THE USAGE NOTICE, WHOLE OR NOT AT ALL. An install that has not yet shown + // the anonymous usage counts' notice shows it here, dim, under the starting + // points, because this is the first screen a new person reads with the + // surface up, and the notice promises to be read before any count is sent + // (docs/TELEMETRY.md). Half a notice is not a notice, so a frame without the + // room draws none of it, and a notice no frame drew is not counted as seen + // ([app.settleTelemetryNotice]); it is still owed on the next launch. + w.noticeDrawn = false + if notice := a.welcomeNoticeRows(unit); len(notice) > 0 { + spare := a.welcomeRowsLeft() - a.statusHeight(width) - len(rows) - 1 + if len(notice)+1 <= spare { + add("", welcomeMark{}) + for _, line := range notice { + add(pal.dim(line), welcomeMark{}) + } + w.noticeDrawn = true + } + } + // THE SESSIONS TAKE ONLY THE ROOM THE WINDOW HAS LEFT. A twelve-row window // with four sessions to list would draw the last of them over the status row; // the list is cut to what fits with a row of slack to spare, and a list that @@ -1109,6 +1138,39 @@ func (a *app) welcomeUnit(width int) ([]string, []welcomeMark, int, int) { return rows, marks, caretX, caretRow } +// welcomeNoticeRows is the owed usage notice as the unit's rows, or nil when +// nothing is owed. A line wider than the unit is wrapped at its own indent +// rather than cut, because every word of the notice is part of what it says. +func (a *app) welcomeNoticeRows(unit int) []string { + if a.telemetryNotice == "" || unit <= 0 { + return nil + } + var rows []string + for _, line := range strings.Split(a.telemetryNotice, "\n") { + if ansi.StringWidth(line) <= unit { + rows = append(rows, line) + continue + } + body := strings.TrimLeft(line, " ") + indent := strings.Repeat(" ", len(line)-len(body)) + for _, part := range wrap(body, max(1, unit-len(indent))) { + rows = append(rows, indent+part) + } + } + return rows +} + +// settleTelemetryNotice tells the door, once, that a frame has drawn the owed +// notice. It runs on the update loop and never inside a frame, because the door +// writes the record to disk and the frame may not touch the disk. +func (a *app) settleTelemetryNotice() { + if !a.telemetryNoticeOnFrame || a.telemetryNoticeSettled || a.telemetryNoticeShown == nil { + return + } + a.telemetryNoticeSettled = true + a.telemetryNoticeShown() +} + // welcomeStarterKeysWord is the line under the three starting points: the two // keys that work on them, and the fact that typing is always an option. It says // `fills the box` on purpose — a person choosing off a list in a terminal expects diff --git a/scripts/install.sh b/scripts/install.sh index 84f690001..2781db5de 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -503,15 +503,22 @@ if [[ "$OS" != "windows" ]] && ! path_has_dir; then fi # The receipt is the installed binary naming itself: `codeaf version` is one -# line by law, so "installed " in front of it reads as one sentence. A file -# installed under another name (devaf) still says codeaf here, because the -# name is the file's and the product's is the sentence's. +# line by law, so "installed " in front of it reads as one sentence. A FILE +# INSTALLED UNDER ANOTHER NAME IS NAMED FIRST, because the receipt is the one +# line that tells a person what to type next: a devaf install that said +# "installed codeaf …" sent them to a command this install never wrote, or to +# an older codeaf that happened to be on their PATH. The version line after it +# stays whole, so the build is still named and codeaf is still the product. if [[ "$RUN_BOOT_ADOPTION" == "1" ]]; then version_line=$("$INSTALL_DIR/$INSTALL_NAME${extension}" version) else version_line=$(CODEAF_HOME="$STATE_ROOT" "$INSTALL_DIR/$INSTALL_NAME${extension}" version) fi -printf 'installed %s\n' "$version_line" +if [[ "$INSTALL_NAME" == "codeaf" ]]; then + printf 'installed %s\n' "$version_line" +else + printf 'installed %s · %s\n' "$INSTALL_NAME" "$version_line" +fi # The install marker lives under the state root, and a custom install outside # it must not create the login's state folders: the marker is written when the