From 970020ce14ade6a8bf9bb7184b956efa3219b4e1 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 10:04:24 -0400 Subject: [PATCH 01/10] install: --name installs the binary under another file name beside codeaf The installer takes --name WORD and CODEAF_INSTALL_NAME, validated against ^[A-Za-z0-9][A-Za-z0-9._-]*$ before anything is written, and writes $INSTALL_DIR/$NAME with a matching temporary file, a printed line naming the real file, and a closing version run of that file. INSTALL_NAME sits on one unindented line directly under CHANNEL so the website proxy can rewrite it the same way it rewrites the channel. Co-Authored-By: Claude Opus 5 (1M context) --- internal/release/install_test.go | 95 +++++++++++++++++++++++++++++++- scripts/install.sh | 34 ++++++++---- 2 files changed, 116 insertions(+), 13 deletions(-) diff --git a/internal/release/install_test.go b/internal/release/install_test.go index 7d4bb9c73..c4ffeb0e6 100644 --- a/internal/release/install_test.go +++ b/internal/release/install_test.go @@ -850,7 +850,98 @@ func TestInstallerKeepsTheWebsiteChannelSeam(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(raw), "\nCHANNEL=\"${CHANNEL:-stable}\"\n") { - t.Fatal("the website-rewritten CHANNEL line is missing") + const seams = "CHANNEL=\"${CHANNEL:-stable}\"\nINSTALL_NAME=\"${CODEAF_INSTALL_NAME:-codeaf}\"" + if strings.Count(string(raw), seams) != 1 || strings.Count(string(raw), "INSTALL_NAME=\"${CODEAF_INSTALL_NAME:-codeaf}\"") != 1 { + t.Fatal("the website-rewritten CHANNEL and INSTALL_NAME lines are not adjacent and unique") + } +} + +// V1: --name and CODEAF_INSTALL_NAME install the selected dev build under the +// requested file, leave codeaf untouched, and reject every invalid name before writing. +func TestV1InstallerName(t *testing.T) { + const tag = "dev-20260921-bbbbbbbbbbbb" + github := newInstallGitHub(t, tag) + + t.Run("flag", func(t *testing.T) { + dir := t.TempDir() + codeaf := filepath.Join(dir, "codeaf") + original := []byte("stable stays here") + if err := os.WriteFile(codeaf, original, 0o755); err != nil { + t.Fatal(err) + } + run := runInstaller(t, github, []string{"--name", "devaf", "--dev"}, + "CODEAF_INSTALL_DIR="+dir, "CODEAF_NO_MODIFY_PATH=1") + if run.code != 0 { + t.Fatalf("exit %d:\n%s", run.code, run.output) + } + devaf := filepath.Join(dir, "devaf") + if _, err := os.Stat(devaf); err != nil { + t.Fatal(err) + } + kept, err := os.ReadFile(codeaf) + if err != nil || string(kept) != string(original) { + t.Fatalf("codeaf = %q, %v", kept, err) + } + if !strings.Contains(run.output, "codeaf: installed "+devaf) { + t.Fatalf("output does not name %s:\n%s", devaf, run.output) + } + if got := strings.Split(strings.TrimSpace(run.output), "\n"); got[len(got)-1] != "codeaf "+tag+" · fake" { + t.Fatalf("last line = %q:\n%s", got[len(got)-1], run.output) + } + }) + + t.Run("environment and flag precedence", func(t *testing.T) { + dir := t.TempDir() + fromEnv := runInstaller(t, github, []string{"--dev"}, + "CODEAF_INSTALL_DIR="+dir, "CODEAF_INSTALL_NAME=devaf", "CODEAF_NO_MODIFY_PATH=1") + if fromEnv.code != 0 { + t.Fatalf("environment exit %d:\n%s", fromEnv.code, fromEnv.output) + } + if _, err := os.Stat(filepath.Join(dir, "devaf")); err != nil { + t.Fatal(err) + } + 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 { + t.Fatalf("flag exit %d:\n%s", flag.code, flag.output) + } + if _, err := os.Stat(filepath.Join(dir, "mine")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "ignored")); !os.IsNotExist(err) { + t.Fatalf("environment overrode flag: %v", err) + } + }) + + for _, invalid := range []string{"../x", "-x", ""} { + t.Run("invalid "+invalid, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "install") + run := runInstaller(t, github, []string{"--name", invalid, "--dev"}, + "CODEAF_INSTALL_DIR="+dir, "CODEAF_NO_MODIFY_PATH=1") + if run.code != 2 || !strings.Contains(run.output, "must match ^[A-Za-z0-9][A-Za-z0-9._-]*$") { + t.Fatalf("exit %d:\n%s", run.code, run.output) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("invalid name wrote install directory: %v", err) + } + }) + } +} + +// V2: The website name seam is the one exact line beneath CHANNEL, and the +// installer's help names both ways to choose it. +func TestV2InstallerNameSeamAndHelp(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(repositoryRoot(t), "scripts", "install.sh")) + if err != nil { + t.Fatal(err) + } + const nameLine = "INSTALL_NAME=\"${CODEAF_INSTALL_NAME:-codeaf}\"" + if strings.Count(string(raw), nameLine) != 1 || !strings.Contains(string(raw), "CHANNEL=\"${CHANNEL:-stable}\"\n"+nameLine+"\n") { + t.Fatal("the installer name seam is not unique and directly below CHANNEL") + } + github := newInstallGitHub(t, "v1.2.3") + help := runInstaller(t, github, []string{"--help"}) + if help.code != 0 || !strings.Contains(help.output, "--name WORD") || !strings.Contains(help.output, "CODEAF_INSTALL_NAME") { + t.Fatalf("help exit %d:\n%s", help.code, help.output) } } diff --git a/scripts/install.sh b/scripts/install.sh index ce18d35bf..40d0a00e6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -5,6 +5,7 @@ set -euo pipefail REPOSITORY="Agent-Field/codeaf" LEGACY_REPOSITORY="Agent-Field/aforge-v2" # Remove after the one-release repository fallback. # legacy-name CHANNEL="${CHANNEL:-stable}" +INSTALL_NAME="${CODEAF_INSTALL_NAME:-codeaf}" VERSION="${VERSION:-}" # The telemetry notice, verbatim from docs/TELEMETRY.md. # The installer only writes a local install marker and prints this text; it @@ -31,7 +32,7 @@ Install codeaf from a GitHub release. Usage: install.sh [--stable|--rc|--dev|--staging] [--version TAG] - [--dir PATH] [--no-modify-path] [--verbose] + [--name WORD] [--dir PATH] [--no-modify-path] [--verbose] Channels: --stable Latest stable release (default). @@ -41,13 +42,15 @@ Channels: Flags: --version TAG Install one named release tag. + --name WORD Install the binary with this file name. --dir PATH Install somewhere other than ~/.codeaf/bin. --no-modify-path Print the PATH line without editing a shell file. --verbose Print download details. --help Show this help. Environment: - CHANNEL, VERSION, CODEAF_INSTALL_DIR, CODEAF_NO_MODIFY_PATH, VERBOSE + CHANNEL, VERSION, CODEAF_INSTALL_NAME, CODEAF_INSTALL_DIR + CODEAF_NO_MODIFY_PATH, VERBOSE GITHUB_TOKEN or GH_TOKEN: GitHub answers anonymous API calls sixty times an hour per address; a token raises that. CODEAF_GITHUB_API and CODEAF_GITHUB_DOWNLOAD for mirrors and tests CODEAF_TELEMETRY=off, or DO_NOT_TRACK=1, turns the anonymous usage counts off @@ -126,6 +129,11 @@ while [[ $# -gt 0 ]]; do VERSION="$2" shift 2 ;; + --name) + [[ $# -ge 2 ]] || usage_error "--name needs a word" + INSTALL_NAME="$2" + shift 2 + ;; --dir) [[ $# -ge 2 ]] || usage_error "--dir needs a path" INSTALL_DIR="$2" @@ -143,6 +151,10 @@ case "$CHANNEL" in *) usage_error "CHANNEL must be stable, rc, dev, or staging" ;; esac +if [[ ! "$INSTALL_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + usage_error "--name / CODEAF_INSTALL_NAME must match ^[A-Za-z0-9][A-Za-z0-9._-]*$" +fi + if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then fail "curl or wget is required" fi @@ -444,12 +456,12 @@ case "$INSTALL_DIR/" in esac mkdir -p "$INSTALL_DIR" -INSTALL_TEMP="$INSTALL_DIR/.codeaf.tmp.$$" +INSTALL_TEMP="$INSTALL_DIR/.$INSTALL_NAME.tmp.$$" cp "$TMP_ROOT/$ASSET" "$INSTALL_TEMP" chmod 0755 "$INSTALL_TEMP" -mv -f "$INSTALL_TEMP" "$INSTALL_DIR/codeaf${extension}" +mv -f "$INSTALL_TEMP" "$INSTALL_DIR/$INSTALL_NAME${extension}" INSTALL_TEMP="" -printf 'codeaf: installed %s\n' "$INSTALL_DIR/codeaf${extension}" +printf 'codeaf: installed %s\n' "$INSTALL_DIR/$INSTALL_NAME${extension}" path_has_dir() { case ":${PATH}:" in @@ -498,12 +510,6 @@ if [[ "$OS" != "windows" ]] && ! path_has_dir; then fi fi -if [[ "$RUN_BOOT_ADOPTION" == "1" ]]; then - "$INSTALL_DIR/codeaf${extension}" version -else - CODEAF_HOME="$STATE_ROOT" "$INSTALL_DIR/codeaf${extension}" version -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 # install is inside the state root or the root already exists, and skipped @@ -512,3 +518,9 @@ if [[ "$RUN_BOOT_ADOPTION" == "1" || -d "$STATE_ROOT" ]]; then write_install_marker "$STATE_ROOT" fi print_telemetry_notice + +if [[ "$RUN_BOOT_ADOPTION" == "1" ]]; then + "$INSTALL_DIR/$INSTALL_NAME${extension}" version +else + CODEAF_HOME="$STATE_ROOT" "$INSTALL_DIR/$INSTALL_NAME${extension}" version +fi From d3a4d62bfad6dcea33d86256688879f1398aeddb Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 10:04:34 -0400 Subject: [PATCH 02/10] update: a channel build follows its own channel, and the curl line names this file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dev or staging build now asks its own channel at launch and gets the same one dim line when that channel has a newer build; /update and codeaf update with no channel word select it, while an explicit word or tag still wins. Two channel tags are ordered by publish moment first, then by the date in the tag, and a same-day tie with a moment missing goes to the release the API named, so a build ahead of the newest is never silently downgraded. The answer is cached per channel — update-check.dev.json and update-check.staging.json for one hour — so a devaf beside a codeaf cannot thrash one file, and stable keeps update-check.json and its 24 hours. CurlLine replaces the one constant everywhere a road back is offered: a file named devaf is told to reinstall from /get/devaf, a codeaf on a channel from /get/codeaf/, and any other name carries CODEAF_INSTALL_NAME. A source build's refusal still prints CurlCommand. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/codeaf/chatv3_surface.go | 22 ++-- cmd/codeaf/chatv3_surface_test.go | 7 +- cmd/codeaf/update.go | 56 ++++++--- cmd/codeaf/update_test.go | 144 +++++++++++++++++++++ internal/tui3/app.go | 2 + internal/tui3/tui3.go | 4 +- internal/tui3/updatecmd.go | 35 ++++-- internal/tui3/updatecmd_test.go | 110 ++++++++++++++++ internal/update/check.go | 87 ++++++++++--- internal/update/client.go | 57 ++++++++- internal/update/install.go | 9 +- internal/update/update_test.go | 200 +++++++++++++++++++++++++++++- internal/update/version.go | 47 +++++++ 13 files changed, 717 insertions(+), 63 deletions(-) diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index 0d6606933..18c85e7ea 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -15,10 +15,11 @@ var ( runSurfaceProgram = func(ctx context.Context, options tui3.Options) error { return tui3.Run(ctx, options) } - surfaceUpdateClient = codeupdate.NewClient - surfaceExecutable = codeupdate.ExecutableTarget - surfaceRevision = buildinfo.Revision - surfaceArguments = func() []string { return append([]string(nil), os.Args[1:]...) } + surfaceUpdateClient = codeupdate.NewClient + surfaceExecutable = codeupdate.ExecutableTarget + surfaceRunningExecutable = os.Executable + surfaceRevision = buildinfo.Revision + surfaceArguments = func() []string { return append([]string(nil), os.Args[1:]...) } ) // ── THE ONE WAY THE v3 SURFACE IS RUN ─────────────────────────────────────── @@ -43,6 +44,11 @@ var ( // reads this package's sources rather than trusting the next door to remember. func runSurface(ctx context.Context, options tui3.Options) error { revision := surfaceRevision() + executable, executableErr := surfaceRunningExecutable() + curl := codeupdate.CurlCommand + if executableErr == nil { + curl = codeupdate.CurlLine(executable, codeupdate.Kind(revision)) + } client := surfaceUpdateClient(revision, codeupdate.CheckTimeout) restart := options.Restart if restart == nil { @@ -50,11 +56,13 @@ func runSurface(ctx context.Context, options tui3.Options) error { options.Restart = restart } options.UpdateRunning = revision + options.UpdateCurl = curl options.UpdateArgs = surfaceArguments() options.UpdateCheck = func(check context.Context) (codeupdate.Available, bool) { return codeupdate.CheckLaunch(check, codeupdate.CheckOptions{ Running: revision, ProfileDir: options.ProfileDir, Client: client, - Disabled: internalenv.Get(codeupdate.NoUpdateCheckEnv) == "1", + Disabled: internalenv.Get(codeupdate.NoUpdateCheckEnv) == "1", + Executable: executable, }) } options.ResolveUpdate = client.Select @@ -62,11 +70,11 @@ func runSurface(ctx context.Context, options tui3.Options) error { // Resolution belongs to the off-frame installation command. A launch may // live on a slow mount, but its first frame never has to wait for that // mount merely because /update exists. - target, err := surfaceExecutable(os.Executable) + target, err := surfaceExecutable(func() (string, error) { return executable, executableErr }) if err != nil { return codeupdate.InstallResult{}, err } - return codeupdate.Install(install, codeupdate.InstallOptions{Client: client, Release: release, Target: target}) + return codeupdate.Install(install, codeupdate.InstallOptions{Client: client, Release: release, Target: target, Curl: curl}) } // The byte meter, off unless a developer named a log file (wire.go). It // measures what this surface DRAWS and is therefore as local as the terminal diff --git a/cmd/codeaf/chatv3_surface_test.go b/cmd/codeaf/chatv3_surface_test.go index aa7a2ca7d..cf4e35be2 100644 --- a/cmd/codeaf/chatv3_surface_test.go +++ b/cmd/codeaf/chatv3_surface_test.go @@ -77,10 +77,12 @@ func TestRunSurfaceWiresTheDeferredLaunchCheckAndInstallerThroughRealInit(t *tes } oldRun := runSurfaceProgram oldClient, oldExecutable := surfaceUpdateClient, surfaceExecutable + oldRunningExecutable := surfaceRunningExecutable oldRevision, oldArguments := surfaceRevision, surfaceArguments t.Cleanup(func() { runSurfaceProgram = oldRun surfaceUpdateClient, surfaceExecutable = oldClient, oldExecutable + surfaceRunningExecutable = oldRunningExecutable surfaceRevision, surfaceArguments = oldRevision, oldArguments }) frame := &surfaceFrameWriter{ready: make(chan struct{})} @@ -105,6 +107,7 @@ func TestRunSurfaceWiresTheDeferredLaunchCheckAndInstallerThroughRealInit(t *tes resolved++ return target, nil } + surfaceRunningExecutable = func() (string, error) { return target, nil } surfaceRevision = func() string { return "v0.1.1" } surfaceArguments = func() []string { return []string{"chat", "--model", "x"} } profile := t.TempDir() @@ -122,9 +125,9 @@ func TestRunSurfaceWiresTheDeferredLaunchCheckAndInstallerThroughRealInit(t *tes t.Fatal("runSurface did not wire every update door") } restartArgs := codeupdate.RestartArgs(seen.UpdateArgs, seen.SessionFile) - if strings.Join(restartArgs, " ") != "chat --model x --session /tmp/this.jsonl" || seen.UpdateRunning != "v0.1.1" { + if strings.Join(restartArgs, " ") != "chat --model x --session /tmp/this.jsonl" || seen.UpdateRunning != "v0.1.1" || seen.UpdateCurl != codeupdate.CurlCommand { cancel() - t.Fatalf("running %q restart args %q", seen.UpdateRunning, restartArgs) + t.Fatalf("running %q curl %q restart args %q", seen.UpdateRunning, seen.UpdateCurl, restartArgs) } select { case <-requestStarted: diff --git a/cmd/codeaf/update.go b/cmd/codeaf/update.go index c309fd320..40a09fb08 100644 --- a/cmd/codeaf/update.go +++ b/cmd/codeaf/update.go @@ -44,8 +44,14 @@ func runUpdate(args []string) error { if chosen > 1 { return fmt.Errorf("choose one of --stable, --rc, --dev, --staging, or --version") } + running := updateRevision() channel := "stable" + if kind := codeupdate.Kind(running); kind == "dev" || kind == "staging" { + channel = kind + } switch { + case *stable: + channel = "stable" case *rc: channel = "rc" case *dev: @@ -53,14 +59,18 @@ func runUpdate(args []string) error { case *staging: channel = "staging" } - choice := codeupdate.Choice{Channel: channel, Version: strings.TrimSpace(*version)} - running := updateRevision() + choice := codeupdate.Choice{Channel: channel, Version: strings.TrimSpace(*version), Running: running} var target string + curl := codeupdate.CurlCommand if !*check { - var err error - target, err = codeupdate.ExecutableTarget(updateExecutable) + executable, err := updateExecutable() + if err != nil { + return updateFailure(fmt.Errorf("find the running codeaf: %w", err), curl) + } + curl = codeupdate.CurlLine(executable, codeupdate.Kind(running)) + target, err = codeupdate.ExecutableTarget(func() (string, error) { return executable, nil }) if err != nil { - return updateFailure(err) + return updateFailure(err, curl) } if codeupdate.Kind(running) == "other" { shown := strings.TrimSpace(running) @@ -84,32 +94,37 @@ func runUpdate(args []string) error { fmt.Fprintln(updateErr, "codeaf: could not check for an update:", err) return exitStatus(1) } - return updateFailure(fmt.Errorf("could not select a release: %w", err)) + return updateFailure(fmt.Errorf("could not select a release: %w", err), curl) } if *check { - return sayUpdateCheck(running, release.Tag, choice) + return sayUpdateCheck(running, release, choice) } if choice.Version == "" { - if comparison, comparable := codeupdate.CompareSemverTags(running, release.Tag); comparable && comparison > 0 { + available := codeupdate.Available{ + Latest: release.Tag, Running: running, + LatestPublished: release.PublishedAt, RunningPublished: release.RunningPublishedAt, + } + if available.Ahead() { fmt.Fprintf(updateErr, "this codeaf is %s, ahead of the newest %s %s — pass --version %s to install it anyway\n", running, choice.Channel, release.Tag, release.Tag) return exitStatus(2) } } result, err := codeupdate.Install(context.Background(), codeupdate.InstallOptions{ - Client: client, Release: release, Target: target, + Client: client, Release: release, Target: target, Curl: curl, }) if err != nil { - return updateFailure(err) + return updateFailure(err, curl) } fmt.Fprintf(updateOut, "codeaf: installed %s at %s\n", result.Release.Tag, result.Path) if err := updateVersionLine(result.Path, updateOut, updateErr); err != nil { - return updateFailure(fmt.Errorf("run the installed codeaf: %w", err)) + return updateFailure(fmt.Errorf("run the installed codeaf: %w", err), curl) } return nil } -func sayUpdateCheck(running, selected string, choice codeupdate.Choice) error { +func sayUpdateCheck(running string, release codeupdate.Release, choice codeupdate.Choice) error { + selected := release.Tag shown := strings.TrimSpace(running) if shown == "" { shown = "an unstamped source build" @@ -128,6 +143,14 @@ func sayUpdateCheck(running, selected string, choice codeupdate.Choice) error { fmt.Fprintf(updateOut, "you are on the newest %s codeaf, %s\n", channel, selected) return nil } + available := codeupdate.Available{ + Latest: selected, Running: running, + LatestPublished: release.PublishedAt, RunningPublished: release.RunningPublishedAt, + } + if codeupdate.Kind(running) == channel && available.Ahead() { + fmt.Fprintf(updateOut, "the newest %s codeaf is %s · this codeaf is %s\n", channel, selected, shown) + return nil + } fmt.Fprintf(updateOut, "codeaf %s is available · you have %s\n", selected, shown) return exitStatus(3) } @@ -148,11 +171,14 @@ func sayUpdateCheck(running, selected string, choice codeupdate.Choice) error { return nil } -func updateFailure(err error) error { - if strings.Contains(err.Error(), codeupdate.CurlCommand) { +func updateFailure(err error, curl string) error { + if strings.TrimSpace(curl) == "" { + curl = codeupdate.CurlCommand + } + if strings.Contains(err.Error(), curl) { return err } - return fmt.Errorf("%w; install a release with: %s", err, codeupdate.CurlCommand) + return fmt.Errorf("%w; install a release with: %s", err, curl) } func runInstalledVersion(path string, stdout, stderr io.Writer) error { diff --git a/cmd/codeaf/update_test.go b/cmd/codeaf/update_test.go index 8fadd4f56..f0f58a02e 100644 --- a/cmd/codeaf/update_test.go +++ b/cmd/codeaf/update_test.go @@ -372,6 +372,150 @@ func TestTerminalDownloadFailureEndsWithTheCurlFallback(t *testing.T) { } } +// V6: codeaf update and --check default to a dev build's own channel; --check +// reports newer and equal dev builds with the documented exit codes. +func TestV6TerminalUpdateDefaultsToTheRunningDevChannel(t *testing.T) { + const ( + oldDev = "dev-20260918-aaaaaaaaaaaa" + newDev = "dev-20260921-bbbbbbbbbbbb" + ) + asset := []byte("new dev executable") + digest := sha256.Sum256(asset) + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + paths = append(paths, request.URL.RequestURI()) + switch { + case strings.HasSuffix(request.URL.Path, "/releases"): + fmt.Fprint(w, `[ + {"tag_name":"`+oldDev+`","published_at":"2026-09-18T12:00:00Z"}, + {"tag_name":"`+newDev+`","published_at":"2026-09-21T12:00:00Z"} + ]`) + case strings.HasSuffix(request.URL.Path, "/checksums.txt"): + fmt.Fprintf(w, "%x codeaf-%s-%s\n", digest, runtime.GOOS, runtime.GOARCH) + case strings.Contains(request.URL.Path, "/releases/download/"+newDev+"/"): + _, _ = w.Write(asset) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + + t.Run("check newer", func(t *testing.T) { + stdout, stderr := withUpdateDoor(t, oldDev, client, filepath.Join(t.TempDir(), "devaf")) + if got := updateExit(runUpdate([]string{"--check"})); got != 3 { + t.Fatalf("exit = %d; stdout %q stderr %q", got, stdout.String(), stderr.String()) + } + }) + t.Run("check equal", func(t *testing.T) { + stdout, stderr := withUpdateDoor(t, newDev, client, filepath.Join(t.TempDir(), "devaf")) + if got := updateExit(runUpdate([]string{"--check"})); got != 0 { + t.Fatalf("exit = %d; stdout %q stderr %q", got, stdout.String(), stderr.String()) + } + }) + t.Run("install", func(t *testing.T) { + target := filepath.Join(t.TempDir(), "devaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + stdout, stderr := withUpdateDoor(t, oldDev, client, target) + if err := runUpdate(nil); err != nil { + t.Fatalf("update: %v; stdout %q stderr %q", err, stdout.String(), stderr.String()) + } + if got, err := os.ReadFile(target); err != nil || string(got) != string(asset) { + t.Fatalf("installed = %q, %v", got, err) + } + }) + for _, path := range paths { + if strings.Contains(path, "/releases/latest") { + t.Fatalf("dev default asked for stable: %s", path) + } + } +} + +// V7: A terminal dev update refuses an implicit downgrade, --check calls it +// equal-or-ahead with exit 0, and --version still installs the named release. +func TestV7TerminalUpdateRefusesAnAheadDevUnlessTheTagIsNamed(t *testing.T) { + const ( + running = "dev-20260921-bbbbbbbbbbbb" + newest = "dev-20260918-aaaaaaaaaaaa" + ) + asset := []byte("named older dev") + digest := sha256.Sum256(asset) + downloads := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch { + case strings.HasSuffix(request.URL.Path, "/releases"): + fmt.Fprint(w, `[{"tag_name":"`+newest+`","published_at":"2026-09-18T12:00:00Z"}]`) + case strings.HasSuffix(request.URL.Path, "/checksums.txt"): + downloads++ + fmt.Fprintf(w, "%x codeaf-%s-%s\n", digest, runtime.GOOS, runtime.GOARCH) + case strings.Contains(request.URL.Path, "/releases/download/"+newest+"/"): + downloads++ + _, _ = w.Write(asset) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + + target := filepath.Join(t.TempDir(), "devaf") + if err := os.WriteFile(target, []byte("ahead"), 0o755); err != nil { + t.Fatal(err) + } + _, stderr := withUpdateDoor(t, running, client, target) + if got := updateExit(runUpdate(nil)); got != 2 { + t.Fatalf("implicit exit = %d, stderr %q", got, stderr.String()) + } + want := "this codeaf is " + running + ", ahead of the newest dev " + newest + " — pass --version " + newest + " to install it anyway\n" + if stderr.String() != want || downloads != 0 { + t.Fatalf("stderr = %q, downloads = %d", stderr.String(), downloads) + } + + stdout, stderr := withUpdateDoor(t, running, client, target) + if got := updateExit(runUpdate([]string{"--check"})); got != 0 { + t.Fatalf("check exit = %d, stderr %q", got, stderr.String()) + } + checkLine := "the newest dev codeaf is " + newest + " · this codeaf is " + running + "\n" + if stdout.String() != checkLine { + t.Fatalf("check = %q, want %q", stdout.String(), checkLine) + } + + stdout, stderr = withUpdateDoor(t, running, client, target) + if err := runUpdate([]string{"--version", newest}); err != nil { + t.Fatalf("named update: %v; stderr %q", err, stderr.String()) + } + if got, err := os.ReadFile(target); err != nil || string(got) != string(asset) { + t.Fatalf("named install = %q, %v", got, err) + } +} + +// V8: Terminal update failures use the curl road for the running executable +// and channel instead of silently handing a devaf user the stable codeaf line. +func TestV8TerminalFailureUsesTheRunningFilesCurlLine(t *testing.T) { + const running = "dev-20260918-aaaaaaaaaaaa" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if strings.HasSuffix(request.URL.Path, "/releases") { + fmt.Fprint(w, `[{"tag_name":"dev-20260921-bbbbbbbbbbbb","published_at":"2026-09-21T12:00:00Z"}]`) + return + } + http.NotFound(w, request) + })) + defer server.Close() + target := filepath.Join(t.TempDir(), "devaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + _, _ = withUpdateDoor(t, running, client, target) + err := runUpdate(nil) + const want = "install a release with: curl -fsSL https://agentfield.ai/get/devaf | bash" + if err == nil || !strings.Contains(err.Error(), want) || strings.Contains(err.Error(), "/get/codeaf/dev") { + t.Fatalf("failure = %v", err) + } +} + // TestC14RestartArgumentsPassTheRealChatFlagParser proves C14. func TestC14RestartArgumentsPassTheRealChatFlagParser(t *testing.T) { for _, original := range [][]string{nil, {"chat", "--model", "x"}, {"resume", "--session", "/tmp/this.jsonl"}} { diff --git a/internal/tui3/app.go b/internal/tui3/app.go index b2505acf8..7156c5556 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -866,6 +866,7 @@ type app struct { resolveUpdate func(context.Context, codeupdate.Choice) (codeupdate.Release, error) installUpdate func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) updateRunning string + updateCurl string updateArgs []string updateActive bool restart *codeupdate.Plan @@ -2686,6 +2687,7 @@ func newApp(ctx context.Context, opts Options) *app { resolveUpdate: opts.ResolveUpdate, installUpdate: opts.InstallUpdate, updateRunning: strings.TrimSpace(opts.UpdateRunning), + updateCurl: strings.TrimSpace(opts.UpdateCurl), updateArgs: append([]string(nil), opts.UpdateArgs...), restart: opts.Restart, models: opts.Models, diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index 77f227a44..460cd8a34 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -417,7 +417,8 @@ type Options struct { // machines, where this process's own build would be the wrong answer. Build string - // UpdateCheck is the silent launch look at the newest stable release. It is + // UpdateCheck is the silent launch look at the newest release in this + // build's channel. It is // a command rather than an opening read so the first frame never waits for // the network. Nil leaves the capability absent. UpdateCheck func(context.Context) (codeupdate.Available, bool) @@ -430,6 +431,7 @@ type Options struct { // decoration. UpdateArgs are its original arguments. Restart is the slot the // surface fills before quitting and the door reads after the terminal is back. UpdateRunning string + UpdateCurl string UpdateArgs []string Restart *codeupdate.Plan diff --git a/internal/tui3/updatecmd.go b/internal/tui3/updatecmd.go index f6cb1c84b..c2387643c 100644 --- a/internal/tui3/updatecmd.go +++ b/internal/tui3/updatecmd.go @@ -73,14 +73,14 @@ func (a *app) runUpdateCommand(argument string) tea.Cmd { return nil } if a.resolveUpdate == nil || a.installUpdate == nil || a.restart == nil { - a.note("this window cannot update codeaf here · install a release with: " + codeupdate.CurlCommand) + a.note("this window cannot update codeaf here · install a release with: " + a.updateCurlLine()) return nil } if codeupdate.Kind(a.updateRunning) == "other" { a.note("this codeaf was built from source · rebuild with make build, or install a release: " + codeupdate.CurlCommand) return nil } - choice := updateChoice(argument) + choice := updateChoice(argument, a.updateRunning) resolve := a.resolveUpdate // The mark is set before the command leaves the loop. A second /update or a // new turn can therefore never enter while release selection is off-frame. @@ -91,15 +91,21 @@ func (a *app) runUpdateCommand(argument string) tea.Cmd { } } -func updateChoice(argument string) codeupdate.Choice { +func updateChoice(argument, running string) codeupdate.Choice { argument = strings.TrimSpace(argument) switch argument { - case "", "stable": - return codeupdate.Choice{Channel: "stable"} + case "": + channel := codeupdate.Kind(running) + if channel != "dev" && channel != "staging" { + channel = "stable" + } + return codeupdate.Choice{Channel: channel, Running: running} + case "stable": + return codeupdate.Choice{Channel: "stable", Running: running} case "rc", "dev", "staging": - return codeupdate.Choice{Channel: argument} + return codeupdate.Choice{Channel: argument, Running: running} default: - return codeupdate.Choice{Version: argument} + return codeupdate.Choice{Version: argument, Running: running} } } @@ -114,7 +120,11 @@ func (a *app) tookUpdateResolve(message updateResolveMsg) tea.Cmd { return nil } if message.choice.Version == "" { - if comparison, comparable := codeupdate.CompareSemverTags(a.updateRunning, message.release.Tag); comparable && comparison > 0 { + available := codeupdate.Available{ + Latest: message.release.Tag, Running: a.updateRunning, + LatestPublished: message.release.PublishedAt, RunningPublished: message.release.RunningPublishedAt, + } + if available.Ahead() { channel := message.choice.Channel if channel == "" { channel = "stable" @@ -156,5 +166,12 @@ func (a *app) tookUpdateInstall(message updateInstallMsg) tea.Cmd { func (a *app) updateFailed(err error) { a.updateActive = false a.note("could not update codeaf: " + err.Error()) - a.note("install a release with: " + codeupdate.CurlCommand) + a.note("install a release with: " + a.updateCurlLine()) +} + +func (a *app) updateCurlLine() string { + if strings.TrimSpace(a.updateCurl) == "" { + return codeupdate.CurlCommand + } + return a.updateCurl } diff --git a/internal/tui3/updatecmd_test.go b/internal/tui3/updatecmd_test.go index a6594c0e2..f031b8f53 100644 --- a/internal/tui3/updatecmd_test.go +++ b/internal/tui3/updatecmd_test.go @@ -279,3 +279,113 @@ func TestC8UpdateRefusesWhileATurnOrTaskIsRunning(t *testing.T) { }) } } + +// V6: Bare /update follows dev and staging builds, stable and rc builds follow +// stable, and an explicit channel or tag overrides that default. +func TestV6UpdateChoiceFollowsTheRunningBuildUnlessOverridden(t *testing.T) { + for _, row := range []struct { + name, running, argument string + want codeupdate.Choice + }{ + {"dev default", "dev-20260921-aaaaaaaaaaaa", "", codeupdate.Choice{Channel: "dev", Running: "dev-20260921-aaaaaaaaaaaa"}}, + {"staging default", "staging-20260921-aaaaaaaaaaaa", "", codeupdate.Choice{Channel: "staging", Running: "staging-20260921-aaaaaaaaaaaa"}}, + {"stable default", "v0.3.0", "", codeupdate.Choice{Channel: "stable", Running: "v0.3.0"}}, + {"rc default", "v0.3.0-rc.1", "", codeupdate.Choice{Channel: "stable", Running: "v0.3.0-rc.1"}}, + {"explicit staging", "dev-20260921-aaaaaaaaaaaa", "staging", codeupdate.Choice{Channel: "staging", Running: "dev-20260921-aaaaaaaaaaaa"}}, + {"explicit stable", "dev-20260921-aaaaaaaaaaaa", "stable", codeupdate.Choice{Channel: "stable", Running: "dev-20260921-aaaaaaaaaaaa"}}, + {"explicit tag", "dev-20260921-aaaaaaaaaaaa", "dev-20260918-bbbbbbbbbbbb", codeupdate.Choice{Version: "dev-20260918-bbbbbbbbbbbb", Running: "dev-20260921-aaaaaaaaaaaa"}}, + } { + t.Run(row.name, func(t *testing.T) { + var got codeupdate.Choice + a := newApp(context.Background(), Options{ + Agent: &fakeAgent{model: "test/model"}, Workspace: "/tmp/lab", + UpdateRunning: row.running, Restart: &codeupdate.Plan{}, + ResolveUpdate: func(_ context.Context, choice codeupdate.Choice) (codeupdate.Release, error) { + got = choice + return codeupdate.Release{Tag: row.running}, nil + }, + InstallUpdate: func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) { + return codeupdate.InstallResult{}, nil + }, + }) + command := a.runUpdateCommand(row.argument) + if command == nil { + t.Fatal("update returned no command") + } + _ = command() + if got != row.want { + t.Fatalf("choice = %+v, want %+v", got, row.want) + } + }) + } +} + +// V7: A dev build ahead of the API-selected dev release refuses a bare +// downgrade, while naming the tag remains the deliberate install road. +func TestV7ChatUpdateRefusesAnAheadChannelBuildUnlessTheTagIsNamed(t *testing.T) { + const running = "dev-20260921-bbbbbbbbbbbb" + const newest = "dev-20260918-aaaaaaaaaaaa" + installed := 0 + newAppFor := func() *app { + return newApp(context.Background(), Options{ + Agent: &fakeAgent{model: "test/model"}, Workspace: "/tmp/lab", SessionFile: "/tmp/this.jsonl", + UpdateRunning: running, Restart: &codeupdate.Plan{}, + ResolveUpdate: func(_ context.Context, choice codeupdate.Choice) (codeupdate.Release, error) { + if choice.Version != "" { + return codeupdate.Release{Tag: choice.Version}, nil + } + return codeupdate.Release{Tag: newest}, nil + }, + InstallUpdate: func(_ context.Context, release codeupdate.Release) (codeupdate.InstallResult, error) { + installed++ + return codeupdate.InstallResult{Release: release, Path: "/tmp/devaf"}, nil + }, + }) + } + ahead := newAppFor() + drive(t, ahead, ahead.slash("/update")()) + want := "this codeaf is " + running + ", ahead of the newest dev " + newest + " — /update " + newest + " installs it anyway" + if installed != 0 || !strings.Contains(updateNotes(ahead), want) { + t.Fatalf("installed = %d notes:\n%s", installed, updateNotes(ahead)) + } + named := newAppFor() + drive(t, named, named.slash("/update "+newest)()) + if installed != 1 { + t.Fatalf("named tag installed %d times", installed) + } +} + +// V8: /update failures use the running file's curl road, while a source-build +// refusal keeps the stable codeaf CurlCommand. +func TestV8UpdateNotesUseTheRightCurlLine(t *testing.T) { + const devafCurl = "curl -fsSL https://agentfield.ai/get/devaf | bash" + failing := newApp(context.Background(), Options{ + Agent: &fakeAgent{model: "test/model"}, Workspace: "/tmp/lab", + UpdateRunning: "dev-20260918-aaaaaaaaaaaa", UpdateCurl: devafCurl, Restart: &codeupdate.Plan{}, + ResolveUpdate: func(context.Context, codeupdate.Choice) (codeupdate.Release, error) { + return codeupdate.Release{}, errors.New("release service is away") + }, + InstallUpdate: func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) { + return codeupdate.InstallResult{}, nil + }, + }) + drive(t, failing, failing.slash("/update")()) + if !strings.Contains(updateNotes(failing), "install a release with: "+devafCurl) { + t.Fatalf("failure notes:\n%s", updateNotes(failing)) + } + + source := newApp(context.Background(), Options{ + Agent: &fakeAgent{model: "test/model"}, Workspace: "/tmp/lab", + UpdateRunning: "deadbeef", UpdateCurl: devafCurl, Restart: &codeupdate.Plan{}, + ResolveUpdate: func(context.Context, codeupdate.Choice) (codeupdate.Release, error) { return codeupdate.Release{}, nil }, + InstallUpdate: func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) { + return codeupdate.InstallResult{}, nil + }, + }) + if command := source.slash("/update"); command != nil { + t.Fatal("source refusal returned a command") + } + if !strings.Contains(updateNotes(source), codeupdate.CurlCommand) || strings.Contains(updateNotes(source), devafCurl) { + t.Fatalf("source notes:\n%s", updateNotes(source)) + } +} diff --git a/internal/update/check.go b/internal/update/check.go index a1e44906b..66e3f3d89 100644 --- a/internal/update/check.go +++ b/internal/update/check.go @@ -12,15 +12,21 @@ import ( internalenv "github.com/Agent-Field/codeaf/internal/env" ) -const cacheLifetime = 24 * time.Hour +const ( + cacheLifetime = 24 * time.Hour + channelCacheLifetime = time.Hour +) -// Available is the stable release comparison shown at launch or by --check. +// Available is the release comparison shown at launch or by --check. type Available struct { - Latest string - Running string + Latest string + Running string + LatestPublished time.Time + RunningPublished time.Time + Curl string } -// Newer reports whether the selected stable release supersedes this build. +// Newer reports whether the selected release supersedes this build. func (a Available) Newer() bool { latest, latestOK := ParseStable(a.Latest) switch Kind(a.Running) { @@ -30,23 +36,43 @@ func (a Available) Newer() bool { case "rc": running, ok := ParseRC(a.Running) return latestOK && ok && latest.Compare(running.Base) >= 0 + case "dev", "staging": + comparison, ok := CompareChannelBuilds(a.Running, a.RunningPublished, a.Latest, a.LatestPublished) + return ok && comparison < 0 default: return false } } +// Ahead reports whether this build is newer than the release selected from its +// own channel. Source builds and incomparable channels are never called ahead. +func (a Available) Ahead() bool { + if Kind(a.Running) == "dev" || Kind(a.Running) == "staging" { + comparison, ok := CompareChannelBuilds(a.Running, a.RunningPublished, a.Latest, a.LatestPublished) + return ok && comparison > 0 + } + comparison, ok := CompareSemverTags(a.Running, a.Latest) + return ok && comparison > 0 +} + // Notice is the one launch line carrying both update roads. func (a Available) Notice() string { if !a.Newer() { return "" } - return "codeaf " + a.Latest + " is out · you have " + a.Running + " · /update installs it and restarts · or: " + CurlCommand + curl := a.Curl + if curl == "" { + curl = CurlCommand + } + return "codeaf " + a.Latest + " is out · you have " + a.Running + " · /update installs it and restarts · or: " + curl } type checkCache struct { - CheckedAt time.Time `json:"checked_at"` - Latest string `json:"latest"` - Running string `json:"running"` + CheckedAt time.Time `json:"checked_at"` + Latest string `json:"latest"` + Running string `json:"running"` + LatestPublished time.Time `json:"latest_published,omitempty"` + RunningPublished time.Time `json:"running_published,omitempty"` } // CheckOptions supplies the launch policy's disk, clock, and release client. @@ -56,36 +82,61 @@ type CheckOptions struct { Client *Client Now func() time.Time Disabled bool + Executable string } -// CheckLaunch silently checks the newest stable release when this build is a -// stable or release-candidate build. Every failure is absence at this surface. +// CheckLaunch silently checks the release channel this build follows. Every +// failure is absence at this surface. func CheckLaunch(ctx context.Context, options CheckOptions) (Available, bool) { running := strings.TrimSpace(options.Running) - if options.Disabled || internalenv.Get(NoUpdateCheckEnv) == "1" || (Kind(running) != "stable" && Kind(running) != "rc") { + kind := Kind(running) + if options.Disabled || internalenv.Get(NoUpdateCheckEnv) == "1" || kind == "other" { return Available{}, false } + channel := "stable" + if kind == "dev" || kind == "staging" { + channel = kind + } + curlChannel := kind + if curlChannel == "other" { + curlChannel = "stable" + } + cacheName, lifetime := "update-check.json", cacheLifetime + if channel == "dev" || channel == "staging" { + cacheName = "update-check." + channel + ".json" + lifetime = channelCacheLifetime + } + curl := CurlLine(options.Executable, curlChannel) now := time.Now if options.Now != nil { now = options.Now } - path := config.ProfilePath(options.ProfileDir, "update-check.json") + path := config.ProfilePath(options.ProfileDir, cacheName) if cached, ok := loadCheckCache(path); ok && cached.Running == running { age := now().Sub(cached.CheckedAt) - if age >= 0 && age < cacheLifetime { - answer := Available{Latest: cached.Latest, Running: running} + if age >= 0 && age < lifetime { + answer := Available{ + Latest: cached.Latest, Running: running, Curl: curl, + LatestPublished: cached.LatestPublished, RunningPublished: cached.RunningPublished, + } return answer, answer.Newer() } } if options.Client == nil { return Available{}, false } - release, err := options.Client.Check(ctx, Choice{Channel: "stable"}) + release, err := options.Client.Check(ctx, Choice{Channel: channel, Running: running}) if err != nil { return Available{}, false } - answer := Available{Latest: release.Tag, Running: running} - _ = saveCheckCache(path, checkCache{CheckedAt: now(), Latest: release.Tag, Running: running}) + answer := Available{ + Latest: release.Tag, Running: running, Curl: curl, + LatestPublished: release.PublishedAt, RunningPublished: release.RunningPublishedAt, + } + _ = saveCheckCache(path, checkCache{ + CheckedAt: now(), Latest: release.Tag, Running: running, + LatestPublished: release.PublishedAt, RunningPublished: release.RunningPublishedAt, + }) return answer, answer.Newer() } diff --git a/internal/update/client.go b/internal/update/client.go index 91fc583bf..7dd86e669 100644 --- a/internal/update/client.go +++ b/internal/update/client.go @@ -9,12 +9,16 @@ import ( "net" "net/http" "net/url" + "path/filepath" + "regexp" "strings" "time" internalenv "github.com/Agent-Field/codeaf/internal/env" ) +var installNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + const ( // CurlCommand is the independent installation road shown at launch beside // /update and after a failed or unavailable in-place update. IT IS THE @@ -29,6 +33,37 @@ const ( GitHubDownloadEnv = "CODEAF_GITHUB_DOWNLOAD" ) +// CurlLine returns the independent installation road for the running file and +// release channel. The product remains codeaf; only the destination file name +// changes when a differently named executable asks for its own road back. +func CurlLine(executable, channel string) string { + channel = strings.TrimSpace(channel) + switch channel { + case "dev", "staging", "rc", "stable": + default: + channel = "stable" + } + name := filepath.Base(strings.TrimSpace(executable)) + if len(name) >= 4 && strings.EqualFold(name[len(name)-4:], ".exe") { + name = name[:len(name)-4] + } + if !installNamePattern.MatchString(name) { + name = "codeaf" + } + if name == "devaf" { + return "curl -fsSL https://agentfield.ai/get/devaf | bash" + } + address := "https://agentfield.ai/get/codeaf" + if channel != "stable" { + address += "/" + channel + } + line := "curl -fsSL " + address + " | " + if name != "codeaf" { + line += "CODEAF_INSTALL_NAME=" + name + " " + } + return line + "bash" +} + const ( // CheckTimeout is the whole-exchange budget for a launch check and for // `codeaf update --check`. THE CHECK CLOCK NEVER COVERS AN INSTALL: a @@ -265,12 +300,15 @@ func spellDuration(duration time.Duration) string { type Choice struct { Channel string Version string + Running string } // Release is one selected GitHub release and the repository that answered. type Release struct { - Tag string - Repository string + Tag string + Repository string + PublishedAt time.Time + RunningPublishedAt time.Time } type apiRelease struct { @@ -302,7 +340,7 @@ func (c *Client) Select(ctx context.Context, choice Choice) (Release, error) { return Release{}, fmt.Errorf("channel must be stable, rc, dev, or staging") } for index, repository := range []string{primaryRepository, legacyRepository} { // legacy-name - release, err := c.selectRepository(ctx, repository, channel) + release, err := c.selectRepository(ctx, repository, channel, strings.TrimSpace(choice.Running)) if err == nil { return release, nil } @@ -313,7 +351,7 @@ func (c *Client) Select(ctx context.Context, choice Choice) (Release, error) { return Release{}, errors.New("no release repository answered") } -func (c *Client) selectRepository(ctx context.Context, repository, channel string) (Release, error) { +func (c *Client) selectRepository(ctx context.Context, repository, channel, running string) (Release, error) { suffix := "releases/latest" if channel != "stable" { suffix = "releases?per_page=100" @@ -331,17 +369,21 @@ func (c *Client) selectRepository(ctx context.Context, repository, channel strin if Kind(row.TagName) != "stable" { return Release{}, fmt.Errorf("the latest release did not name a stable codeaf tag") } - return Release{Tag: row.TagName, Repository: repository}, nil + return Release{Tag: row.TagName, Repository: repository, PublishedAt: row.stamp()}, nil } var rows []apiRelease if err := json.Unmarshal(body, &rows); err != nil { return Release{}, fmt.Errorf("read the release list: %w", err) } var newest apiRelease + var runningPublished time.Time for _, row := range rows { if Kind(row.TagName) != channel { continue } + if row.TagName == running { + runningPublished = row.stamp() + } if newest.TagName == "" || row.stamp().After(newest.stamp()) { newest = row } @@ -349,7 +391,10 @@ func (c *Client) selectRepository(ctx context.Context, repository, channel strin if newest.TagName == "" { return Release{}, fmt.Errorf("no %s build has been published yet", channel) } - return Release{Tag: newest.TagName, Repository: repository}, nil + return Release{ + Tag: newest.TagName, Repository: repository, PublishedAt: newest.stamp(), + RunningPublishedAt: runningPublished, + }, nil } func (c *Client) assetURL(release Release, name string) string { diff --git a/internal/update/install.go b/internal/update/install.go index c230e7089..eaecd1e65 100644 --- a/internal/update/install.go +++ b/internal/update/install.go @@ -20,6 +20,7 @@ type InstallOptions struct { Client *Client Release Release Target string + Curl string GOOS string GOARCH string } @@ -36,6 +37,10 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error) return InstallResult{}, errors.New("no release client is available") } target := strings.TrimSpace(options.Target) + curl := strings.TrimSpace(options.Curl) + if curl == "" { + curl = CurlCommand + } if target == "" { return InstallResult{}, errors.New("the running executable path is empty") } @@ -92,7 +97,7 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error) temporaryFile, err := os.CreateTemp(filepath.Dir(target), fmt.Sprintf(".codeaf.tmp.%d.", os.Getpid())) if err != nil { - return InstallResult{}, fmt.Errorf("cannot replace %s: %w; install a release with: %s", target, err, CurlCommand) + return InstallResult{}, fmt.Errorf("cannot replace %s: %w; install a release with: %s", target, err, curl) } temporary := temporaryFile.Name() defer os.Remove(temporary) @@ -108,7 +113,7 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error) return InstallResult{}, fmt.Errorf("make %s executable: %w", temporary, err) } if err := replaceExecutable(temporary, target); err != nil { - return InstallResult{}, fmt.Errorf("cannot replace %s: %w; install a release with: %s", target, err, CurlCommand) + return InstallResult{}, fmt.Errorf("cannot replace %s: %w; install a release with: %s", target, err, curl) } return InstallResult{Release: release, Path: target}, nil } diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 7d9e729b5..6d4a44d06 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -64,12 +64,12 @@ func TestC2ReleaseCandidateIsPromptedOnlyWhenItsLineIsStable(t *testing.T) { } } -// TestC3SourceAndChannelLaunchesNeverReachTheNetwork proves C3. -func TestC3SourceAndChannelLaunchesNeverReachTheNetwork(t *testing.T) { +// Source and unstamped launches never reach the release service. +func TestSourceLaunchesNeverReachTheNetwork(t *testing.T) { var hits atomic.Int64 server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { hits.Add(1) })) defer server.Close() - for _, running := range []string{"dev-20260915-abcdefabcdef", "staging-20260915-abcdefabcdef", "deadbeef", ""} { + for _, running := range []string{"deadbeef", ""} { answer, show := CheckLaunch(context.Background(), CheckOptions{ Running: running, ProfileDir: t.TempDir(), Client: releaseClient(server, running), }) @@ -82,6 +82,200 @@ func TestC3SourceAndChannelLaunchesNeverReachTheNetwork(t *testing.T) { } } +// V3: Channel builds check their own release list once and show exactly one +// executable-specific notice only when that channel has a newer build. +func TestV3LaunchNoticeForChannelBuilds(t *testing.T) { + const ( + oldDev = "dev-20260918-aaaaaaaaaaaa" + newDev = "dev-20260921-bbbbbbbbbbbb" + newStage = "staging-20260921-cccccccccccc" + ) + list := `[ + {"tag_name":"` + oldDev + `","published_at":"2026-09-18T12:00:00Z"}, + {"tag_name":"` + newDev + `","published_at":"2026-09-21T12:00:00Z"}, + {"tag_name":"` + newStage + `","published_at":"2026-09-21T13:00:00Z"} + ]` + for _, row := range []struct { + name, running, executable, wantLatest, wantPath string + show bool + }{ + {"older dev", oldDev, "/opt/codeaf/devaf", newDev, "/releases?per_page=100", true}, + {"older dev as codeaf", oldDev, "/opt/codeaf/codeaf", newDev, "/releases?per_page=100", true}, + {"newest dev", newDev, "/opt/codeaf/devaf", newDev, "/releases?per_page=100", false}, + {"staging", "staging-20260918-dddddddddddd", "/opt/codeaf/codeaf", newStage, "/releases?per_page=100", true}, + {"stable", "v0.1.0", "/opt/codeaf/codeaf", "v0.2.0", "/releases/latest", true}, + } { + t.Run(row.name, func(t *testing.T) { + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + paths = append(paths, request.URL.RequestURI()) + if strings.HasSuffix(request.URL.Path, "/releases/latest") { + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + return + } + fmt.Fprint(w, list) + })) + defer server.Close() + answer, show := CheckLaunch(context.Background(), CheckOptions{ + Running: row.running, Executable: row.executable, + ProfileDir: t.TempDir(), Client: releaseClient(server, row.running), + }) + if show != row.show || answer.Latest != row.wantLatest { + t.Fatalf("answer = %+v, show = %t", answer, show) + } + if len(paths) != 1 || !strings.HasSuffix(paths[0], row.wantPath) { + t.Fatalf("requests = %q, want one ending in %q", paths, row.wantPath) + } + if row.name == "older dev" { + want := "codeaf " + newDev + " is out · you have " + oldDev + " · /update installs it and restarts · or: curl -fsSL https://agentfield.ai/get/devaf | bash" + if got := answer.Notice(); got != want { + t.Fatalf("notice = %q, want %q", got, want) + } + } + if row.name == "older dev as codeaf" { + wantEnd := "or: curl -fsSL https://agentfield.ai/get/codeaf/dev | bash" + if got := answer.Notice(); !strings.HasSuffix(got, wantEnd) { + t.Fatalf("notice = %q, want suffix %q", got, wantEnd) + } + } + }) + } +} + +// V4: Channel ordering uses matching tags, publish moments, tag dates, and the +// API-selected same-day release in that order, including the mirror ahead cases. +func TestV4ChannelOrdering(t *testing.T) { + oldDay := "dev-20260918-aaaaaaaaaaaa" + newDay := "dev-20260921-bbbbbbbbbbbb" + sameDayA := "dev-20260921-aaaaaaaaaaaa" + sameDayB := "dev-20260921-bbbbbbbbbbbb" + early := time.Date(2026, 9, 21, 9, 0, 0, 0, time.UTC) + late := early.Add(time.Hour) + for _, row := range []struct { + name, running, selected string + runningAt, selectedAt time.Time + want int + }{ + {"same tag", sameDayA, sameDayA, late, early, 0}, + {"same day selected published later", sameDayA, sameDayB, early, late, -1}, + {"dates disagree with publication", newDay, oldDay, early, late, -1}, + {"running published later", sameDayA, sameDayB, late, early, 1}, + {"selected later date with one moment unknown", oldDay, newDay, early, time.Time{}, -1}, + {"running later date with one moment unknown", newDay, oldDay, time.Time{}, early, 1}, + {"same day unknown moment selects API release", sameDayA, sameDayB, time.Time{}, late, -1}, + } { + t.Run(row.name, func(t *testing.T) { + got, ok := CompareChannelBuilds(row.running, row.runningAt, row.selected, row.selectedAt) + if !ok || got != row.want { + t.Fatalf("comparison = %d, %t; want %d, true", got, ok, row.want) + } + available := Available{ + Latest: row.selected, Running: row.running, + LatestPublished: row.selectedAt, RunningPublished: row.runningAt, + } + if available.Newer() != (row.want < 0) || available.Ahead() != (row.want > 0) { + t.Fatalf("newer = %t ahead = %t for comparison %d", available.Newer(), available.Ahead(), row.want) + } + if available.Ahead() && available.Notice() != "" { + t.Fatalf("ahead build drew notice %q", available.Notice()) + } + }) + } +} + +// V5: Stable and channel builds use separate cache files and retain their +// unchanged 24-hour and one-hour lifetimes when launches alternate. +func TestV5ChannelCacheFilesAndLifetimes(t *testing.T) { + const runningDev = "dev-20260918-aaaaaaaaaaaa" + now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) + var hits atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + hits.Add(1) + if strings.HasSuffix(request.URL.Path, "/releases/latest") { + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + return + } + fmt.Fprint(w, `[ + {"tag_name":"`+runningDev+`","published_at":"2026-09-18T12:00:00Z"}, + {"tag_name":"dev-20260921-bbbbbbbbbbbb","published_at":"2026-09-21T12:00:00Z"} + ]`) + })) + defer server.Close() + profile := t.TempDir() + client := releaseClient(server, runningDev) + clock := func() time.Time { return now } + stable := CheckOptions{Running: "v0.1.0", ProfileDir: profile, Client: client, Now: clock} + dev := CheckOptions{Running: runningDev, ProfileDir: profile, Client: client, Now: clock} + devOnly := t.TempDir() + CheckLaunch(context.Background(), CheckOptions{Running: runningDev, ProfileDir: devOnly, Client: client, Now: clock}) + if _, err := os.Stat(filepath.Join(devOnly, "update-check.dev.json")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(devOnly, "update-check.json")); !os.IsNotExist(err) { + t.Fatalf("a dev launch wrote the stable cache: %v", err) + } + hits.Store(0) + CheckLaunch(context.Background(), stable) + CheckLaunch(context.Background(), dev) + CheckLaunch(context.Background(), stable) + CheckLaunch(context.Background(), dev) + if hits.Load() != 2 { + t.Fatalf("alternating launches made %d requests, want one per channel", hits.Load()) + } + for _, name := range []string{"update-check.json", "update-check.dev.json"} { + if _, err := os.Stat(filepath.Join(profile, name)); err != nil { + t.Fatalf("%s: %v", name, err) + } + } + if _, err := os.Stat(filepath.Join(profile, "update-check.staging.json")); !os.IsNotExist(err) { + t.Fatalf("unexpected staging cache: %v", err) + } + + now = now.Add(59 * time.Minute) + CheckLaunch(context.Background(), dev) + if hits.Load() != 2 { + t.Fatalf("fresh dev cache made %d requests", hits.Load()) + } + now = now.Add(2 * time.Minute) + CheckLaunch(context.Background(), dev) + if hits.Load() != 3 { + t.Fatalf("stale dev cache made %d requests, want 3 total", hits.Load()) + } + now = now.Add(22 * time.Hour) + CheckLaunch(context.Background(), stable) + if hits.Load() != 3 { + t.Fatalf("fresh stable cache made %d requests", hits.Load()) + } + now = now.Add(2 * time.Hour) + CheckLaunch(context.Background(), stable) + if hits.Load() != 4 { + t.Fatalf("stale stable cache made %d requests, want 4 total", hits.Load()) + } +} + +// V8: CurlLine preserves the stable road, follows named codeaf channels, gives +// devaf its proxy, and carries arbitrary executable names through the installer. +func TestV8CurlLineTable(t *testing.T) { + for _, row := range []struct{ executable, channel, want string }{ + {"codeaf", "stable", CurlCommand}, + {"codeaf.exe", "stable", CurlCommand}, + {"codeaf", "dev", "curl -fsSL https://agentfield.ai/get/codeaf/dev | bash"}, + {"codeaf", "staging", "curl -fsSL https://agentfield.ai/get/codeaf/staging | bash"}, + {"codeaf", "rc", "curl -fsSL https://agentfield.ai/get/codeaf/rc | bash"}, + {"devaf", "dev", "curl -fsSL https://agentfield.ai/get/devaf | bash"}, + {"devaf", "stable", "curl -fsSL https://agentfield.ai/get/devaf | bash"}, + {"devaf.exe", "dev", "curl -fsSL https://agentfield.ai/get/devaf | bash"}, + {"mine", "dev", "curl -fsSL https://agentfield.ai/get/codeaf/dev | CODEAF_INSTALL_NAME=mine bash"}, + {"mine", "stable", "curl -fsSL https://agentfield.ai/get/codeaf | CODEAF_INSTALL_NAME=mine bash"}, + {"", "dev", "curl -fsSL https://agentfield.ai/get/codeaf/dev | bash"}, + {"codeaf", "other", CurlCommand}, + } { + if got := CurlLine(row.executable, row.channel); got != row.want { + t.Errorf("CurlLine(%q, %q) = %q, want %q", row.executable, row.channel, got, row.want) + } + } +} + // TestC4OptOutAndFreshCacheAvoidRequestsWhileStaleFactsRefresh proves C4. func TestC4OptOutAndFreshCacheAvoidRequestsWhileStaleFactsRefresh(t *testing.T) { base := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) diff --git a/internal/update/version.go b/internal/update/version.go index 5bb1a954a..71e1d252f 100644 --- a/internal/update/version.go +++ b/internal/update/version.go @@ -6,6 +6,7 @@ import ( "regexp" "strconv" "strings" + "time" ) var ( @@ -166,6 +167,52 @@ func Kind(tag string) string { } } +// ParseChannel returns the channel and calendar date carried by a dev or +// staging tag. The date is kept as YYYYMMDD because that spelling sorts in the +// same order as the days it names. +func ParseChannel(tag string) (channel, date string, ok bool) { + channel = Kind(tag) + if channel != "dev" && channel != "staging" { + return "", "", false + } + parts := strings.Split(tag, "-") + if len(parts) != 3 { + return "", "", false + } + return channel, parts[1], true +} + +// CompareChannelBuilds compares a running build with the release selected by +// the API from the same channel. A negative result means the selected release +// is newer; a positive result means the running build is ahead. +func CompareChannelBuilds(running string, runningPublished time.Time, selected string, selectedPublished time.Time) (int, bool) { + if running == selected { + return 0, true + } + runningChannel, runningDate, runningOK := ParseChannel(running) + selectedChannel, selectedDate, selectedOK := ParseChannel(selected) + if !runningOK || !selectedOK || runningChannel != selectedChannel { + return 0, false + } + if !runningPublished.IsZero() && !selectedPublished.IsZero() { + switch { + case runningPublished.Before(selectedPublished): + return -1, true + case runningPublished.After(selectedPublished): + return 1, true + } + } + if runningDate < selectedDate { + return -1, true + } + if runningDate > selectedDate { + return 1, true + } + // THE API-NAMED RELEASE WINS A SAME-DAY TIE WHEN EITHER MOMENT IS + // UNKNOWN. It is the only remaining ordering fact the check has. + return -1, true +} + // ValidSHA reports whether a revision can form a channel tag. func ValidSHA(value string) bool { return shaPattern.MatchString(value) } From 4e3d7abe454dcb5dbcb5b855ac3b9a12c09283fd Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 10:04:39 -0400 Subject: [PATCH 03/10] manual: the terminal page says what devaf is and which channel a build follows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The update section no longer claims dev and staging launches are silent: it names the channel each build checks, the cache file and window it uses, the default channel of a bare /update, the ahead refusal, and the curl line that reinstalls this particular file. A new section answers "what is devaf" — the /get/devaf line, the file it writes beside codeaf, the general --name spelling, and the state both builds share. codeaf --help says this build's own channel is the default, and GUIDE.md carries the --name row. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/codeaf/main.go | 2 +- docs/GUIDE.md | 7 +- .../manual/chat/running-from-the-terminal.md | 104 +++++++++++------- internal/manual/chat_test.go | 28 +++++ 4 files changed, 101 insertions(+), 40 deletions(-) diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index 86390e43a..a93d86c80 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -488,7 +488,7 @@ Look at what happened — read-only, no key, nothing spent print the build this binary was cut from (--version and -v say the same) Housekeeping — changes state on disk or on the network codeaf update [--check] [--stable|--rc|--dev|--staging] [--version tag] - check for or install a release; stable is the default + check or install a release; this build's own channel is the default codeaf cache what the shared build cache holds, and how big it is codeaf cache clean [--yes] diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 9e1f2dae5..2ad661d0c 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -101,6 +101,7 @@ Recognizing a channel does not mean a matching release exists. ```bash curl -fsSL https://agentfield.ai/get/codeaf/dev | bash +curl -fsSL https://agentfield.ai/get/devaf | bash curl -fsSL https://agentfield.ai/get/codeaf/staging | bash curl -fsSL https://agentfield.ai/get/codeaf/rc | bash curl -fsSL https://agentfield.ai/get/codeaf | VERSION= bash @@ -112,6 +113,7 @@ curl -fsSL https://agentfield.ai/get/codeaf | VERSION= bash | `--dev` | Select the latest `dev-*` release. | | `--rc`, `--staging` | Select a matching channel build or stop if none has been published. | | `--version TAG` or `VERSION=` | Pin one release tag. | +| `--name WORD` or `CODEAF_INSTALL_NAME=WORD` | Choose the installed binary's file name. | | `--dir PATH` | Install somewhere other than `~/.codeaf/bin`. | | `--no-modify-path` | Print the PATH line without editing a shell file. | | `--verbose` | Print each GET. | @@ -120,8 +122,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. Its -last action is `codeaf version`. Release builds cover darwin, linux, and windows on -amd64 and arm64. +last action runs the installed file's `version`. The `/get/devaf` line selects the +dev channel and names the file `devaf`, installing it beside codeaf. Release builds +cover darwin, linux, and windows on amd64 and arm64. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index e2e1d66c4..5b76bdcb3 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -1,35 +1,45 @@ # Commands you type in a terminal -## Is there a newer version — how do I update codeaf — /update — codeaf update — why does it say this every time I start - -At launch, a stable release behind the newest stable gets one dim line naming both -versions and offering `/update`. That command downloads the release, checks its -sha256, replaces this executable and restarts the same conversation; `/upgrade` is -its alias. Finish a running turn or task first. A matching release says `you are on -the newest codeaf, ` and does not restart. - -Release candidates get the launch line once their stable line is published. A -stable build already newest or ahead gets no launch line. That silence does not -authorize a downgrade: if this build is `v0.3.0` and stable is `v0.2.0`, `/update` -refuses with `this codeaf is v0.3.0, ahead of the newest stable v0.2.0 — /update -v0.2.0 installs it anyway`. Naming the tag is the deliberate road and installs it. -Dev, staging, source and unstamped builds make no launch request. Set -`CODEAF_NO_UPDATE_CHECK=1` to skip only this check. Its answer is cached in -`update-check.json` beside `config.json` for 24 hours for the same running build; -a cached newer release is still shown at every launch. - -From a shell, `codeaf update --check` exits 3 for a newer selected stable or rc, -or a different selected dev or staging tag; 0 when a stable or rc build is equal -or ahead, or a channel tag is equal; and 1 when it could not check. `--version -` exits 0 only on that tag and 3 otherwise. Every answer names the selected -tag. `codeaf update` installs stable by default; -`--rc`, `--dev`, and `--staging` select another channel. On `v0.3.0` with stable -at `v0.2.0`, it exits 2 with `this codeaf is v0.3.0, ahead of the newest stable -v0.2.0 — pass --version v0.2.0 to install it anyway`. `--version v0.2.0` installs -what was named. A source build refuses and names its path: rebuild with `make -build`, or install a release with `curl -fsSL https://agentfield.ai/get/codeaf | bash`. -An unwritable target, failed download or bad checksum leaves the original in -place and offers that same line; codeaf never tries sudo. +## Is there a newer version — update a dev build — latest dev — /update — why does it say this every time I start + +At launch, a stable, dev or staging build behind the newest release of its own +channel gets one dim line: `codeaf is out · you have · /update +installs it and restarts · or: `. A release candidate gets that line +when its stable line is published. An equal or ahead build gets no line. Source +and unstamped builds make no launch request. + +`/update` downloads the release, checks its sha256, replaces this executable and +restarts the same conversation; `/upgrade` is its alias. With no channel word, +a dev or staging build selects its own channel; stable and rc select stable. An +explicit channel or tag wins. Finish a running turn or task first. An ahead dev +build refuses with `this codeaf is , ahead of the newest dev — +/update installs it anyway`; naming the tag is the deliberate downgrade. + +Set `CODEAF_NO_UPDATE_CHECK=1` to skip only the launch check. Dev and staging +answers are cached beside `config.json` for one hour in `update-check.dev.json` +and `update-check.staging.json`; stable and rc use `update-check.json` for 24 +hours. A cached newer release is still shown. The curl line reinstalls this file: +`devaf` gets `curl -fsSL https://agentfield.ai/get/devaf | bash`; a file named +`codeaf` gets its running build's channel under `/get/codeaf`. + +## codeaf update from a shell — --check — default channel — ahead of newest + +`codeaf update` and `codeaf update --check` default to dev on a `dev-*` build, +staging on a `staging-*` build, and stable on stable or rc. `--stable`, `--rc`, +`--dev`, `--staging`, or `--version ` overrides that choice. + +`--check` exits 3 when the selected release is newer and 0 when it is equal or +this build is ahead; it exits 1 when it could not check. `--version ` exits +0 only on that tag and 3 otherwise. Every answer names the selected tag. An +ahead channel build says `the newest dev codeaf is · this codeaf is +`. Installing without `--check` refuses an implicit downgrade with +`this codeaf is , ahead of the newest dev — pass --version + to install it anyway`; naming that tag installs it. + +A source build refuses and names its path: rebuild with `make build`, or install +a release with `curl -fsSL https://agentfield.ai/get/codeaf | bash`. An unwritable +target, failed download or bad checksum leaves the original in place and offers +the curl line for this executable and channel; codeaf never tries sudo. ## How do I install codeaf — the curl line, agentfield.ai/get/codeaf, dev, staging, rc and stable channels @@ -63,17 +73,37 @@ final pipe with `| VERSION= bash`; for example: curl -fsSL https://agentfield.ai/get/codeaf | VERSION=v0.2.0 bash ``` -For the bare address the proxy hands the script out unchanged; for a channel path it -rewrites the one line that sets the default channel. If it cannot find that line exactly -once, or what it fetched is not a shell script, it answers 502 rather than serve the -wrong thing. +For the bare address the proxy hands the script out unchanged; for a channel path +it rewrites the one line that sets the default channel. If it cannot find that line +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`; its last line is `codeaf version`. For a -newer build later, `/update` in the chat or `codeaf update` in a terminal replaces the -binary in place (the section above); running the line again works too. +The installer writes `~/.codeaf/bin/codeaf`; its last action runs that file's +`version`. `/update` in the chat or `codeaf update` in a terminal replaces it in +place; running the install line again works too. + +## What is devaf — dev build beside codeaf — side by side — two versions + +`devaf` is the file name for a codeaf dev-channel build, not another product. +Install the newest dev build beside codeaf with: + +```sh +curl -fsSL https://agentfield.ai/get/devaf | bash +``` + +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 general spelling is `--name WORD` or `CODEAF_INSTALL_NAME=WORD`; the name +may contain ASCII letters, digits, `.`, `_`, and `-`, and must begin with a +letter or digit. codeaf and devaf share `~/.codeaf`, including keys and +conversations, and one engine per workspace; opening a workspace with the other +build retires an idle host or joins a busy compatible one. A devaf launch checks +the dev channel hourly, and bare `/update` keeps following dev. ## Why codeaf do may download rtk — compressed shell output and how to turn it off diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 9e66a2cef..3132c0daa 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -2570,6 +2570,11 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { // TestTheChatManualMentionsEveryVerbTheCommandLineAnswersTo. {"can I run this without the chat", "running-from-the-terminal"}, {"how do I update codeaf to the latest version", "running-from-the-terminal"}, + {"how do I install the latest dev build beside my codeaf", "running-from-the-terminal"}, + {"what is devaf", "running-from-the-terminal"}, + {"devaf", "running-from-the-terminal"}, + {"can I run two versions of codeaf side by side", "running-from-the-terminal"}, + {"how do I keep my dev build up to date", "running-from-the-terminal"}, // C13: These are the words a person brings to the update section. {"is there a newer version", "running-from-the-terminal"}, {"how do I update codeaf", "running-from-the-terminal"}, @@ -2683,6 +2688,29 @@ func TestC13UpdateQuestionsReachTheNewManualSection(t *testing.T) { } } +// V9: The devaf, side-by-side, and dev-update questions reach the terminal +// manual page that documents those installation and update contracts. +func TestV9DevafQuestionsReachTheTerminalManual(t *testing.T) { + for _, asked := range []string{ + "how do I install the latest dev build beside my codeaf", + "what is devaf", + "devaf", + "can I run two versions of codeaf side by side", + "how do I keep my dev build up to date", + } { + var reached bool + for _, section := range Chat().Search(asked, DefaultResults) { + if section.Page == "running-from-the-terminal" { + reached = true + break + } + } + if !reached { + t.Errorf("%q does not reach running-from-the-terminal", asked) + } + } +} + func TestTheServicesPageNamesCustomListingDiscoveryAndDisconnectConfirmation(t *testing.T) { page, ok := Chat().Page("services") if !ok { From 95d39e9015c0ca962e64b1ccc1f1825d101245f6 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 10:04:44 -0400 Subject: [PATCH 04/10] release: dev release notes offer the road that installs this build as devaf A dev build's notes now carry curl -fsSL https://agentfield.ai/get/devaf | bash and one sentence saying it installs that dev channel as devaf beside codeaf. Only the dev channel gets the block. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 10 ++++++++++ internal/release/workflow_test.go | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 134c5508d..0eaa6f849 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -300,6 +300,16 @@ jobs: \`\`\` EOF + if [ "$CHANNEL" = "dev" ]; then + cat >> install.md <<'EOF' + This installs this dev channel as `devaf` beside codeaf: + + ```sh + curl -fsSL https://agentfield.ai/get/devaf | bash + ``` + + EOF + fi if [ "$CHANNEL" = "stable" ]; then # THE SECTION IS RENDERED FOR THE PAGE, NOT COPIED ONTO IT. GitHub # refuses a release body over its character ceiling, and a rolled-up diff --git a/internal/release/workflow_test.go b/internal/release/workflow_test.go index 56c03c544..7e37fe116 100644 --- a/internal/release/workflow_test.go +++ b/internal/release/workflow_test.go @@ -117,6 +117,24 @@ func TestReleaseWorkflowKeepsTheChannelContract(t *testing.T) { } } +// V10: Dev release notes offer the proxy that installs the dev channel as +// devaf beside codeaf, and no other channel enters that conditional block. +func TestV10DevReleaseNotesNameTheDevafInstaller(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(repositoryRoot(t), ".github", "workflows", "release.yml")) + if err != nil { + t.Fatal(err) + } + workflow := string(raw) + const line = "curl -fsSL https://agentfield.ai/get/devaf | bash" + if strings.Count(workflow, line) != 1 { + t.Fatalf("devaf install line count = %d, want 1", strings.Count(workflow, line)) + } + block := regexp.MustCompile(`(?ms)if \[ "\$CHANNEL" = "dev" \]; then\n(.*?)\n\s+fi`).FindStringSubmatch(workflow) + if block == nil || !strings.Contains(block[1], "installs this dev channel") || !strings.Contains(block[1], line) { + t.Fatalf("dev notes block does not describe and spell the devaf road:\n%v", block) + } +} + // releaseSurfacePrologue is the shell of the "Test release surface" step up to // its go test line, with the go test replaced by a line that prints the // arguments it would have been given. From bdf7e4878bd96d877f7dc399bf6c3675199c5e29 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 10:20:58 -0400 Subject: [PATCH 05/10] update: the manual says what --check really does across two channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review round caught the page overclaiming: it said exit 3 means the selected release is newer, when a dev or staging tag selected from a build of another channel also exits 3 — a channel tag and a version number cannot be ordered against each other — and a dev build asking --stable exits 0 for the same reason. The ahead line is spelled with the channel it names rather than dev alone. Two terminal tests now pin both sentences. The ordering table gains the cases the law left open: no moment at all, one moment twice on two days, and one moment twice on one day. A pair the law cannot rank — two channels, a channel tag beside a version, a tag that names no release — is neither newer nor ahead and draws no launch line. A terminal case proves a publish moment beats the date in the tag all the way through the door, an install failure proves it offers the road back to THIS file, and the installer refuses a bad name from the environment and a --name with no word. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/codeaf/update_test.go | 88 +++++++++++++++++++ .../manual/chat/running-from-the-terminal.md | 15 ++-- internal/release/install_test.go | 19 ++++ internal/update/update_test.go | 57 ++++++++++++ 4 files changed, 174 insertions(+), 5 deletions(-) diff --git a/cmd/codeaf/update_test.go b/cmd/codeaf/update_test.go index f0f58a02e..b9215417c 100644 --- a/cmd/codeaf/update_test.go +++ b/cmd/codeaf/update_test.go @@ -433,6 +433,94 @@ func TestV6TerminalUpdateDefaultsToTheRunningDevChannel(t *testing.T) { } } +// V6: An explicit channel still wins on a dev build, and a check across two +// channels says only what it can: a channel tag and a version number cannot be +// ordered, so the difference itself is the answer. +func TestV6CrossChannelChecksSayOnlyWhatTheyCanOrder(t *testing.T) { + const devTag = "dev-20260921-bbbbbbbbbbbb" + for _, row := range []struct { + name, running, path, want string + arguments []string + exit int + }{ + { + name: "a dev build asking for stable", running: "dev-20260918-aaaaaaaaaaaa", + arguments: []string{"--check", "--stable"}, path: "/releases/latest", + want: "the newest stable codeaf is v0.3.0 · this codeaf is dev-20260918-aaaaaaaaaaaa\n", + }, + { + name: "a stable build asking for dev", running: "v0.3.0", + arguments: []string{"--check", "--dev"}, path: "/releases", exit: 3, + want: "codeaf " + devTag + " is available · you have v0.3.0\n", + }, + } { + t.Run(row.name, func(t *testing.T) { + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + paths = append(paths, request.URL.Path) + if strings.HasSuffix(request.URL.Path, "/releases/latest") { + fmt.Fprint(w, `{"tag_name":"v0.3.0"}`) + return + } + fmt.Fprint(w, `[{"tag_name":"`+devTag+`","published_at":"2026-09-21T12:00:00Z"}]`) + })) + defer server.Close() + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + stdout, stderr := withUpdateDoor(t, row.running, client, filepath.Join(t.TempDir(), "devaf")) + if got := updateExit(runUpdate(row.arguments)); got != row.exit { + t.Fatalf("exit = %d, want %d; stderr %q", got, row.exit, stderr.String()) + } + if stdout.String() != row.want { + t.Fatalf("stdout = %q, want %q", stdout.String(), row.want) + } + if len(paths) != 1 || !strings.HasSuffix(paths[0], row.path) { + t.Fatalf("requests = %q, want one ending in %q", paths, row.path) + } + }) + } +} + +// V7 and D4: a publish moment outranks the date written into the tag. A build +// whose tag carries the later day but which was published FIRST is behind, so +// the terminal door installs rather than calling it a downgrade — which it can +// only get right by carrying both moments out of the release list. +func TestV7APublishMomentOutranksTheDateInTheTag(t *testing.T) { + const ( + running = "dev-20260921-aaaaaaaaaaaa" + selected = "dev-20260918-bbbbbbbbbbbb" + ) + asset := []byte("the later dev build") + digest := sha256.Sum256(asset) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch { + case strings.HasSuffix(request.URL.Path, "/releases"): + fmt.Fprint(w, `[ + {"tag_name":"`+running+`","published_at":"2026-09-18T10:00:00Z"}, + {"tag_name":"`+selected+`","published_at":"2026-09-18T12:00:00Z"} + ]`) + case strings.HasSuffix(request.URL.Path, "/checksums.txt"): + fmt.Fprintf(w, "%x codeaf-%s-%s\n", digest, runtime.GOOS, runtime.GOARCH) + case strings.Contains(request.URL.Path, "/releases/download/"+selected+"/"): + _, _ = w.Write(asset) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + target := filepath.Join(t.TempDir(), "devaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + stdout, stderr := withUpdateDoor(t, running, client, target) + if err := runUpdate(nil); err != nil { + t.Fatalf("update: %v; stdout %q stderr %q", err, stdout.String(), stderr.String()) + } + if got, err := os.ReadFile(target); err != nil || string(got) != string(asset) { + t.Fatalf("installed = %q, %v", got, err) + } +} + // V7: A terminal dev update refuses an implicit downgrade, --check calls it // equal-or-ahead with exit 0, and --version still installs the named release. func TestV7TerminalUpdateRefusesAnAheadDevUnlessTheTagIsNamed(t *testing.T) { diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 5b76bdcb3..ce74d5c83 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -28,11 +28,16 @@ hours. A cached newer release is still shown. The curl line reinstalls this file staging on a `staging-*` build, and stable on stable or rc. `--stable`, `--rc`, `--dev`, `--staging`, or `--version ` overrides that choice. -`--check` exits 3 when the selected release is newer and 0 when it is equal or -this build is ahead; it exits 1 when it could not check. `--version ` exits -0 only on that tag and 3 otherwise. Every answer names the selected tag. An -ahead channel build says `the newest dev codeaf is · this codeaf is -`. Installing without `--check` refuses an implicit downgrade with +`--check` exits 3 when the selected release is newer, and also whenever a dev or +staging tag is selected from a build of another channel, because a channel tag +and a version number cannot be ordered against each other. It exits 0 when the +selection is the tag already running, when this build is ahead of its own +channel, and when the two cannot be ordered at all — which is what a dev build +asking `--stable` gets. It exits 1 when it could not check. `--version ` +exits 0 only on that tag and 3 otherwise. Every answer names the selected tag. +A build ahead of its own channel says `the newest dev codeaf is · this +codeaf is `, with `staging` in place of `dev` on that channel. +Installing without `--check` refuses an implicit downgrade the same way: `this codeaf is , ahead of the newest dev — pass --version to install it anyway`; naming that tag installs it. diff --git a/internal/release/install_test.go b/internal/release/install_test.go index c4ffeb0e6..3f6d26a67 100644 --- a/internal/release/install_test.go +++ b/internal/release/install_test.go @@ -926,6 +926,25 @@ func TestV1InstallerName(t *testing.T) { } }) } + + t.Run("invalid from the environment", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "install") + run := runInstaller(t, github, []string{"--dev"}, + "CODEAF_INSTALL_DIR="+dir, "CODEAF_INSTALL_NAME=../x", "CODEAF_NO_MODIFY_PATH=1") + if run.code != 2 || !strings.Contains(run.output, "must match ^[A-Za-z0-9][A-Za-z0-9._-]*$") { + t.Fatalf("exit %d:\n%s", run.code, run.output) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("invalid environment name wrote install directory: %v", err) + } + }) + + t.Run("no word after the flag", func(t *testing.T) { + run := runInstaller(t, github, []string{"--name"}, "CODEAF_NO_MODIFY_PATH=1") + if run.code != 2 || !strings.Contains(run.output, "--name needs a word") { + t.Fatalf("exit %d:\n%s", run.code, run.output) + } + }) } // V2: The website name seam is the one exact line beneath CHANNEL, and the diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 6d4a44d06..078e94a8d 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -163,6 +163,13 @@ func TestV4ChannelOrdering(t *testing.T) { {"selected later date with one moment unknown", oldDay, newDay, early, time.Time{}, -1}, {"running later date with one moment unknown", newDay, oldDay, time.Time{}, early, 1}, {"same day unknown moment selects API release", sameDayA, sameDayB, time.Time{}, late, -1}, + {"no moment at all falls to the tag dates", newDay, oldDay, time.Time{}, time.Time{}, 1}, + // TWO RELEASES PUBLISHED IN THE SAME SECOND ARE NOT ORDERED BY THEIR + // MOMENTS. D4's second rule names a later publication, and neither is + // later, so the remaining rules answer: the tag date first, then the + // release the API named. + {"one moment, twice, falls to the tag dates", newDay, oldDay, early, early, 1}, + {"one moment, twice, on one day gives the API release", sameDayA, sameDayB, early, early, -1}, } { t.Run(row.name, func(t *testing.T) { got, ok := CompareChannelBuilds(row.running, row.runningAt, row.selected, row.selectedAt) @@ -183,6 +190,56 @@ func TestV4ChannelOrdering(t *testing.T) { } } +// V4: A pair the channel law cannot rank — two different channels, a channel +// tag beside a version number, or a tag that names no release at all — is +// neither newer nor ahead, and draws no launch line. +func TestV4UnrankablePairsAreNeitherNewerNorAhead(t *testing.T) { + for _, row := range []struct{ running, selected string }{ + {"dev-20260921-aaaaaaaaaaaa", "staging-20260921-bbbbbbbbbbbb"}, + {"dev-20260921-aaaaaaaaaaaa", "v0.3.0"}, + {"dev-20260921-aaaaaaaaaaaa", "not-a-tag"}, + {"staging-20260921-aaaaaaaaaaaa", "dev-20260921-bbbbbbbbbbbb"}, + } { + if _, ok := CompareChannelBuilds(row.running, time.Time{}, row.selected, time.Time{}); ok { + t.Errorf("CompareChannelBuilds(%q, %q) claimed an order", row.running, row.selected) + } + available := Available{Latest: row.selected, Running: row.running} + if available.Newer() || available.Ahead() || available.Notice() != "" { + t.Errorf("%q against %q: newer = %t, ahead = %t, notice = %q", + row.running, row.selected, available.Newer(), available.Ahead(), available.Notice()) + } + } +} + +// V8: An install that cannot replace the running file offers the road back to +// THAT file. A devaf told to reinstall from /get/codeaf would come back as a +// stable codeaf, which is the surprise this whole change exists to remove. +func TestV8InstallFailureCarriesTheCallersCurlLine(t *testing.T) { + asset := []byte("new codeaf") + digest := sha256.Sum256(asset) + server, _ := servedRelease(t, asset, hex.EncodeToString(digest[:])) + defer server.Close() + devafCurl := CurlLine("/home/x/.codeaf/bin/devaf", "dev") + unreachable := filepath.Join(t.TempDir(), "gone", "devaf") + _, err := Install(context.Background(), InstallOptions{ + Client: releaseClient(server, "dev-20260918-aaaaaaaaaaaa"), + Release: Release{Tag: "dev-20260921-bbbbbbbbbbbb", Repository: primaryRepository}, + Target: unreachable, Curl: devafCurl, + }) + if err == nil || !strings.Contains(err.Error(), "cannot replace "+unreachable) || + !strings.Contains(err.Error(), devafCurl) || strings.Contains(err.Error(), "get/codeaf") { + t.Fatalf("error = %v, want the devaf road", err) + } + // With no road supplied the stable constant is still the answer. + if _, bare := Install(context.Background(), InstallOptions{ + Client: releaseClient(server, "v0.1.1"), + Release: Release{Tag: "v0.2.0", Repository: primaryRepository}, + Target: unreachable, + }); bare == nil || !strings.Contains(bare.Error(), CurlCommand) { + t.Fatalf("bare error = %v", bare) + } +} + // V5: Stable and channel builds use separate cache files and retain their // unchanged 24-hour and one-hour lifetimes when launches alternate. func TestV5ChannelCacheFilesAndLifetimes(t *testing.T) { From 1b9eb958d30ded045a04352e5c3d736413f235eb Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 10:35:22 -0400 Subject: [PATCH 06/10] docs/changes: the devaf dev build beside codeaf (#1333) Co-Authored-By: Claude Opus 5 (1M context) --- .../1333-devaf-dev-build-beside-codeaf.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/changes/unreleased/1333-devaf-dev-build-beside-codeaf.md diff --git a/docs/changes/unreleased/1333-devaf-dev-build-beside-codeaf.md b/docs/changes/unreleased/1333-devaf-dev-build-beside-codeaf.md new file mode 100644 index 000000000..619d7872a --- /dev/null +++ b/docs/changes/unreleased/1333-devaf-dev-build-beside-codeaf.md @@ -0,0 +1,32 @@ +--- +kind: added +title: a dev build installs beside codeaf as devaf and follows the dev channel +pr: 1333 +surface: [build, chat, docs] +invalidates: + - A dev or staging build made no launch request and said nothing about being + out of date. It now asks its own channel and draws the same one dim line + when that channel has a newer build. + - "`/update` and `codeaf update` with no channel word installed stable on + every build. They now select the channel the running build came from, and + stable is only the default for a stable or rc build." + - The launch notice and every update failure ended with one constant curl + line for stable codeaf. They now end with the road that reinstalls the file + actually running, so a devaf is never told to reinstall itself as codeaf. + - The installer always wrote the file `codeaf` and had no way to choose + another name. It now takes `--name WORD` and `CODEAF_INSTALL_NAME`, so a + build can be installed beside codeaf instead of over it. + - There was one install address per channel under `agentfield.ai/get/codeaf`. + `agentfield.ai/get/devaf` now installs the newest dev build as `devaf`, and + it is served by a separate website change that fails closed until this one + is on `dev`. + - The launch check kept one answer in `update-check.json` for 24 hours. A dev + or staging build keeps its own `update-check.dev.json` or + `update-check.staging.json` for one hour instead, so two builds sharing a + profile cannot thrash one file. +--- + +Santosh asked for internal dev usage to be separated from the main install. A dev +build could already be installed, but only over `~/.codeaf/bin/codeaf`, and it +then quietly turned back into a stable binary at the next `/update`. `devaf` is a +file name, not a product: every sentence codeaf prints still says codeaf. From 806be77452c655cabdaa5b2d4f09fee086a6fa83 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 10:52:54 -0400 Subject: [PATCH 07/10] update: a release candidate is offered the stable road it is actually told about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An rc build follows stable — its launch line names the stable release ahead of it, and a bare /update or codeaf update installs stable — but the curl line under that line said /get/codeaf/rc, which installs a channel the build never selects. The notice named one release and offered another. FollowedChannel is now the single answer to "which channel is this build on": dev and staging follow themselves, everything else follows stable. The launch check, the surface and the terminal door all take the curl line from it, and the two places that spelled the same rule out longhand to pick a default channel now ask it instead. CurlLine keeps its whole table, because an explicit road to any channel is still a thing a person can ask for. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/codeaf/chatv3_surface.go | 2 +- cmd/codeaf/update.go | 7 +-- cmd/codeaf/update_test.go | 27 +++++++++++ .../manual/chat/running-from-the-terminal.md | 9 ++-- internal/tui3/updatecmd.go | 6 +-- internal/update/check.go | 15 +++--- internal/update/update_test.go | 48 +++++++++++++++++++ internal/update/version.go | 17 +++++++ 8 files changed, 108 insertions(+), 23 deletions(-) diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index 18c85e7ea..809f6d3f6 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -47,7 +47,7 @@ func runSurface(ctx context.Context, options tui3.Options) error { executable, executableErr := surfaceRunningExecutable() curl := codeupdate.CurlCommand if executableErr == nil { - curl = codeupdate.CurlLine(executable, codeupdate.Kind(revision)) + curl = codeupdate.CurlLine(executable, codeupdate.FollowedChannel(revision)) } client := surfaceUpdateClient(revision, codeupdate.CheckTimeout) restart := options.Restart diff --git a/cmd/codeaf/update.go b/cmd/codeaf/update.go index 40a09fb08..2d4636855 100644 --- a/cmd/codeaf/update.go +++ b/cmd/codeaf/update.go @@ -45,10 +45,7 @@ func runUpdate(args []string) error { return fmt.Errorf("choose one of --stable, --rc, --dev, --staging, or --version") } running := updateRevision() - channel := "stable" - if kind := codeupdate.Kind(running); kind == "dev" || kind == "staging" { - channel = kind - } + channel := codeupdate.FollowedChannel(running) switch { case *stable: channel = "stable" @@ -67,7 +64,7 @@ func runUpdate(args []string) error { if err != nil { return updateFailure(fmt.Errorf("find the running codeaf: %w", err), curl) } - curl = codeupdate.CurlLine(executable, codeupdate.Kind(running)) + curl = codeupdate.CurlLine(executable, codeupdate.FollowedChannel(running)) target, err = codeupdate.ExecutableTarget(func() (string, error) { return executable, nil }) if err != nil { return updateFailure(err, curl) diff --git a/cmd/codeaf/update_test.go b/cmd/codeaf/update_test.go index b9215417c..f378dd411 100644 --- a/cmd/codeaf/update_test.go +++ b/cmd/codeaf/update_test.go @@ -480,6 +480,33 @@ func TestV6CrossChannelChecksSayOnlyWhatTheyCanOrder(t *testing.T) { } } +// D6: a release candidate updates from stable, so a failure on one offers the +// stable road. Handing an rc user the /get/codeaf/rc line would reinstall a +// channel their own /update never selects. +func TestAFailedReleaseCandidateUpdateOffersTheStableRoad(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if strings.HasSuffix(request.URL.Path, "/releases/latest") { + fmt.Fprint(w, `{"tag_name":"v0.3.0"}`) + return + } + http.NotFound(w, request) + })) + defer server.Close() + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + _, _ = withUpdateDoor(t, "v0.3.0-rc.1", client, target) + err := runUpdate(nil) + if err == nil || !strings.Contains(err.Error(), "install a release with: "+codeupdate.CurlCommand) { + t.Fatalf("failure = %v, want the stable road", err) + } + if strings.Contains(err.Error(), "/get/codeaf/rc") { + t.Fatalf("failure offered an rc road: %v", err) + } +} + // V7 and D4: a publish moment outranks the date written into the tag. A build // whose tag carries the later day but which was published FIRST is behind, so // the terminal door installs rather than calling it a downgrade — which it can diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index ce74d5c83..ce5761ca6 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -18,9 +18,12 @@ build refuses with `this codeaf is , ahead of the newest dev Set `CODEAF_NO_UPDATE_CHECK=1` to skip only the launch check. Dev and staging answers are cached beside `config.json` for one hour in `update-check.dev.json` and `update-check.staging.json`; stable and rc use `update-check.json` for 24 -hours. A cached newer release is still shown. The curl line reinstalls this file: -`devaf` gets `curl -fsSL https://agentfield.ai/get/devaf | bash`; a file named -`codeaf` gets its running build's channel under `/get/codeaf`. +hours. A cached newer release is still shown. The curl line reinstalls this file +from the channel the build follows — dev and staging follow themselves, and a +stable, rc or source build follows stable. So `devaf` gets `curl -fsSL +https://agentfield.ai/get/devaf | bash`, a dev build named `codeaf` gets +`/get/codeaf/dev`, and a release candidate gets the plain `/get/codeaf`, the +same stable release its launch line just named. ## codeaf update from a shell — --check — default channel — ahead of newest diff --git a/internal/tui3/updatecmd.go b/internal/tui3/updatecmd.go index c2387643c..ab38fe560 100644 --- a/internal/tui3/updatecmd.go +++ b/internal/tui3/updatecmd.go @@ -95,11 +95,7 @@ func updateChoice(argument, running string) codeupdate.Choice { argument = strings.TrimSpace(argument) switch argument { case "": - channel := codeupdate.Kind(running) - if channel != "dev" && channel != "staging" { - channel = "stable" - } - return codeupdate.Choice{Channel: channel, Running: running} + return codeupdate.Choice{Channel: codeupdate.FollowedChannel(running), Running: running} case "stable": return codeupdate.Choice{Channel: "stable", Running: running} case "rc", "dev", "staging": diff --git a/internal/update/check.go b/internal/update/check.go index 66e3f3d89..8e7f9430f 100644 --- a/internal/update/check.go +++ b/internal/update/check.go @@ -93,20 +93,17 @@ func CheckLaunch(ctx context.Context, options CheckOptions) (Available, bool) { if options.Disabled || internalenv.Get(NoUpdateCheckEnv) == "1" || kind == "other" { return Available{}, false } - channel := "stable" - if kind == "dev" || kind == "staging" { - channel = kind - } - curlChannel := kind - if curlChannel == "other" { - curlChannel = "stable" - } + // THE LINE UNDER THE NOTICE REINSTALLS WHAT THE NOTICE IS ABOUT. Both the + // release this asks for and the road it offers come from the one channel + // this build follows, so a release candidate — which is told about the + // stable release ahead of it — is handed the stable line, not an rc one. + channel := FollowedChannel(running) cacheName, lifetime := "update-check.json", cacheLifetime if channel == "dev" || channel == "staging" { cacheName = "update-check." + channel + ".json" lifetime = channelCacheLifetime } - curl := CurlLine(options.Executable, curlChannel) + curl := CurlLine(options.Executable, channel) now := time.Now if options.Now != nil { now = options.Now diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 078e94a8d..53c01e7b3 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -190,6 +190,54 @@ func TestV4ChannelOrdering(t *testing.T) { } } +// D3 and D6: the channel a build FOLLOWS, which is the one its launch line +// speaks about and the one the road under that line must reinstall. A release +// candidate follows stable; only dev and staging follow themselves. +func TestTheChannelABuildFollowsIsStableUnlessItIsDevOrStaging(t *testing.T) { + for _, row := range []struct{ running, want string }{ + {"v0.3.0", "stable"}, + {"v0.3.0-rc.1", "stable"}, + {"dev-20260921-aaaaaaaaaaaa", "dev"}, + {"staging-20260921-aaaaaaaaaaaa", "staging"}, + {"deadbeefdead", "stable"}, + {"", "stable"}, + } { + if got := FollowedChannel(row.running); got != row.want { + t.Errorf("FollowedChannel(%q) = %q, want %q", row.running, got, row.want) + } + } +} + +// D6: a release candidate is told about the stable release ahead of it, so the +// road under that notice installs STABLE — an rc road would install something +// the notice never named. The file's own name still chooses the address. +func TestAReleaseCandidateNoticeOffersTheStableRoad(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"tag_name":"v0.3.0"}`) + })) + defer server.Close() + for _, row := range []struct{ name, executable, want string }{ + {"as codeaf", "/opt/codeaf/codeaf", CurlCommand}, + {"as devaf", "/opt/codeaf/devaf", "curl -fsSL https://agentfield.ai/get/devaf | bash"}, + } { + t.Run(row.name, func(t *testing.T) { + answer, show := CheckLaunch(context.Background(), CheckOptions{ + Running: "v0.3.0-rc.1", Executable: row.executable, + ProfileDir: t.TempDir(), Client: releaseClient(server, "v0.3.0-rc.1"), + }) + if !show { + t.Fatalf("an rc behind its stable line drew no notice: %+v", answer) + } + if got := answer.Notice(); !strings.HasSuffix(got, "or: "+row.want) { + t.Fatalf("notice = %q, want it to end in %q", got, row.want) + } + if strings.Contains(answer.Notice(), "/get/codeaf/rc") { + t.Fatalf("the rc notice offered an rc road: %q", answer.Notice()) + } + }) + } +} + // V4: A pair the channel law cannot rank — two different channels, a channel // tag beside a version number, or a tag that names no release at all — is // neither newer nor ahead, and draws no launch line. diff --git a/internal/update/version.go b/internal/update/version.go index 71e1d252f..99fc9227b 100644 --- a/internal/update/version.go +++ b/internal/update/version.go @@ -167,6 +167,23 @@ func Kind(tag string) string { } } +// FollowedChannel names the release channel a build takes its updates from. +// IT IS NOT ALWAYS THE CHANNEL THE TAG WAS CUT ON: a release candidate follows +// STABLE, because its launch line, its bare /update and its bare `codeaf +// update` all speak about the stable release its line is heading for. A build +// from source follows stable too, so the road it is offered is the ordinary +// one. Only dev and staging follow themselves. Every place that has to answer +// "which channel is this build on" asks here, so the launch notice and the +// curl line under it can never name two different roads. +func FollowedChannel(running string) string { + switch Kind(running) { + case "dev", "staging": + return Kind(running) + default: + return "stable" + } +} + // ParseChannel returns the channel and calendar date carried by a dev or // staging tag. The date is kept as YYYYMMDD because that spelling sorts in the // same order as the days it names. From 7326fd609c90d25f0fc8a62d760364486064cc41 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 11:08:02 -0400 Subject: [PATCH 08/10] config: the installer's name pin is registered as plumbing, not a setting CurlLine spells CODEAF_INSTALL_NAME into the road it offers a binary installed under another file name, and the registry law counts every CODEAF_* word in the tree. The word is the shell installer's and codeaf never reads it, so it lands on the operator allowlist rather than becoming a row: a row would persist a preference this binary cannot act on. The law only went red now because origin/dev moved under the branch and widened the touched-package set; the pin has been unregistered since it was written. Co-Authored-By: Claude Opus 5 (1M context) --- internal/config/settings.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/config/settings.go b/internal/config/settings.go index 450cba0a6..4e0c796a2 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1129,6 +1129,14 @@ var OperatorEnvPins = []string{ "CODEAF_NO_UPDATE_CHECK", "CODEAF_GITHUB_API", "CODEAF_GITHUB_DOWNLOAD", + // CODEAF_INSTALL_NAME belongs to the shell installer and not to this + // program: it chooses the file name an install writes, and codeaf never + // reads it. It is spelled in Go at all only because the curl line codeaf + // offers after a failed update has to be the command that reinstalls THIS + // file, and a file installed under another name needs that word in the + // line. Plumbing rather than a row for the plainest reason there is: a row + // would persist a preference this binary cannot act on. + "CODEAF_INSTALL_NAME", // The two pins on the model-call log (internal/calllog). CODEAF_CALL_LOG // switches it off or moves the file; CODEAF_CALL_LOG_BODIES adds the whole // request and response to every line. Plumbing rather than settings rows, From 4d5008f94f8d2ad3acf62c0cd6d9d42a63220ca6 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 11:42:56 -0400 Subject: [PATCH 09/10] =?UTF-8?q?manual:=20the=20quitting=20section=20answ?= =?UTF-8?q?ers=20in=20the=20word=20people=20use=20=E2=80=94=20exit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "how do I exit codeaf" stopped reaching the keys page once two new sections landed in one release — this branch's shell-update section and the worker harness's headless exit-code section on dev — and both of them are genuinely about exit CODES. The page that answers the question was written almost entirely in quit, leave and close, so it lost a question its own heading spells out. It now opens with the sentence a person asks for and says exit where it used to say leaves, which puts it back at the top of that search. Neither branch is red alone; the pair is. Fixing the page rather than the probe is the rule this corpus is held to. Co-Authored-By: Claude Opus 5 (1M context) --- internal/manual/chat/keys.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 3cfaa6b6a..f2cf55dff 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -434,15 +434,17 @@ process with it — but you no longer have to reach for that just to get your pr ## Quitting codeaf — how do I exit codeaf, how do I close codeaf, or why did ctrl+c not quit +To exit codeaf, press `ctrl+c` once. That is the whole gesture: there is no second +press to make, no window to beat, and nothing asking you to confirm it. + **`ctrl+c`, once.** With nothing running, the press that lands is the way out: codeaf -writes your draft to disk and exits. There is no second press to make, no window to -beat, and nothing asking you to confirm it. +writes your draft to disk and exits. **If `ctrl+c` did not quit, a turn was running.** Mid-turn that key is the interrupt — the same thing `esc` does — and the press is spent on the model. Press it again once the -answer has stopped and codeaf leaves. +answer has stopped and codeaf exits. -**Nothing you typed is lost by leaving.** The unsent sentence in the box goes to disk, +**Nothing you typed is lost when you exit.** The unsent sentence in the box goes to disk, with any message that was still waiting for an answer folded in underneath it, and the next launch puts them back in the box. See "What quitting saves and closes" below. From a6d98c5962e8529d9a52fc8112cf5a0348fac54d Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 12:15:16 -0400 Subject: [PATCH 10/10] manual: the terminal page says every curl form and ranks first for the asker's words The verifier's real-binary drive found four gaps between the page and the program. A build under any file name but codeaf or devaf prints its channel's line with CODEAF_INSTALL_NAME on the end, and the page never said so. The chat's own source-build refusal was unquoted; only the terminal's variant was. The reason a cross-channel --check exits 3 was written for a version number against a channel tag, which is not what two channel tags are. And "how do I keep my dev build up to date" reached the dev-server section first, so the update heading now carries the asker's words, as the devaf heading carries "different file name" and "--name". Co-Authored-By: Claude Fable 5.1 --- .../manual/chat/running-from-the-terminal.md | 16 ++++++++++------ internal/manual/chat_test.go | 1 + 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index ce5761ca6..69bb7eb70 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -1,12 +1,14 @@ # Commands you type in a terminal -## Is there a newer version — update a dev build — latest dev — /update — why does it say this every time I start +## Is there a newer version — update a dev build — keep a dev build up to date — latest dev — /update — why does it say this every time I start At launch, a stable, dev or staging build behind the newest release of its own channel gets one dim line: `codeaf is out · you have · /update installs it and restarts · or: `. A release candidate gets that line when its stable line is published. An equal or ahead build gets no line. Source -and unstamped builds make no launch request. +and unstamped builds make no launch request, and their `/update` answers `this +codeaf was built from source · rebuild with make build, or install a release: +curl -fsSL https://agentfield.ai/get/codeaf | bash`. `/update` downloads the release, checks its sha256, replaces this executable and restarts the same conversation; `/upgrade` is its alias. With no channel word, @@ -22,8 +24,9 @@ hours. A cached newer release is still shown. The curl line reinstalls this file from the channel the build follows — dev and staging follow themselves, and a stable, rc or source build follows stable. So `devaf` gets `curl -fsSL https://agentfield.ai/get/devaf | bash`, a dev build named `codeaf` gets -`/get/codeaf/dev`, and a release candidate gets the plain `/get/codeaf`, the -same stable release its launch line just named. +`/get/codeaf/dev`, a release candidate gets the plain `/get/codeaf`, the same +stable release its launch line just named, and a file under any other name gets +`| CODEAF_INSTALL_NAME= bash` on the end of its channel's line. ## codeaf update from a shell — --check — default channel — ahead of newest @@ -33,7 +36,8 @@ staging on a `staging-*` build, and stable on stable or rc. `--stable`, `--rc`, `--check` exits 3 when the selected release is newer, and also whenever a dev or staging tag is selected from a build of another channel, because a channel tag -and a version number cannot be ordered against each other. It exits 0 when the +cannot be ordered against a build from another channel; that answer reads +`codeaf is available · you have `. It exits 0 when the selection is the tag already running, when this build is ahead of its own channel, and when the two cannot be ordered at all — which is what a dev build asking `--stable` gets. It exits 1 when it could not check. `--version ` @@ -92,7 +96,7 @@ The installer writes `~/.codeaf/bin/codeaf`; its last action runs that file's `version`. `/update` in the chat or `codeaf update` in a terminal replaces it in place; running the install line again works too. -## What is devaf — dev build beside codeaf — side by side — two versions +## What is devaf — dev build beside codeaf — side by side — two versions — install under a different file name — --name `devaf` is the file name for a codeaf dev-channel build, not another product. Install the newest dev build beside codeaf with: diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 3132c0daa..be1575afc 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -2575,6 +2575,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"devaf", "running-from-the-terminal"}, {"can I run two versions of codeaf side by side", "running-from-the-terminal"}, {"how do I keep my dev build up to date", "running-from-the-terminal"}, + {"install codeaf with a different file name", "running-from-the-terminal"}, // C13: These are the words a person brings to the update section. {"is there a newer version", "running-from-the-terminal"}, {"how do I update codeaf", "running-from-the-terminal"},