Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
30be1f6
crew: the checker's spend ceiling holds whatever model the other seat…
AbirAbbas Sep 25, 2026
90a5136
crew: at the daily cap a call the guard cannot price is not sent either
AbirAbbas Sep 25, 2026
8a18a13
crew: a headless crew line says (pinned) instead of a literal emoji
AbirAbbas Sep 25, 2026
0332099
manual: the crew's per-task limit and the checker's ceiling come from…
AbirAbbas Sep 25, 2026
5f3c092
crew: a route pin lives in its own row, so an older codeaf never send…
AbirAbbas Sep 25, 2026
af3ead9
crew: a call that costs nothing is never stopped by a dollar line
AbirAbbas Sep 25, 2026
3b3c6a9
tui3: a task's landing crew line is said where the task lands
AbirAbbas Sep 25, 2026
2858622
tui3: /crew's model list says its prices are per million tokens
AbirAbbas Sep 25, 2026
87de6f5
crew: the crew's daily cap is named apart from the daily limit
AbirAbbas Sep 25, 2026
e1ba17c
crew: a model named for a task, or the task model row, seats its worker
AbirAbbas Sep 25, 2026
aa2b044
manual: the chat answers the worker, privacy and cost questions a new…
AbirAbbas Sep 25, 2026
2463cdd
Merge remote-tracking branch 'origin/dev' into fix/crew-followups
AbirAbbas Sep 25, 2026
eb6862b
changes: the crew follow-ups entry (#1518)
AbirAbbas Sep 25, 2026
cc3899a
changes: keep the crew follow-ups title to one line
AbirAbbas Sep 25, 2026
da0ab24
changes: the crew follow-ups title under a hundred characters
AbirAbbas Sep 25, 2026
014e351
tui3: the first-run daily limit line keeps to two rows
AbirAbbas Sep 25, 2026
de4b8a8
Merge origin/dev (#1515, #1516, #1517) into fix/crew-followups
AbirAbbas Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/codeaf/do.go
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,7 @@ func doErrand(request doRequest) error {
settled = router.CrewAccepted
}
config.LogCrewOutcome(profileDir, call, *seats.Crew, repo, crewTitle(request.task), settled, outcome.Spend)
fmt.Fprintln(request.stderr, "crew: "+seats.Crew.Line(config.PinMark, outcome.Spend))
fmt.Fprintln(request.stderr, "crew: "+seats.Crew.Line("", outcome.Spend))
}
// THE RUN NAMES ITSELF ON EVERY PATH, including the one where nothing
// worked: the id is what joins this object to the rows the model-call log
Expand Down
10 changes: 9 additions & 1 deletion cmd/codeaf/seats_wiring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ func TestAnErrandWithNoFlagsRunsARoutedCrewAndSaysSo(t *testing.T) {
if err != nil {
t.Fatalf("the errand did not settle cleanly: %v\nstderr:\n%s", err, stderr.String())
}
if !strings.Contains(stderr.String(), "crew: ") || !strings.Contains(stderr.String(), config.PinMark+" strong") {
if !strings.Contains(stderr.String(), "crew: ") || !strings.Contains(stderr.String(), "checker strong (pinned)") || strings.Contains(stderr.String(), "📌") {
t.Fatalf("the run never said its crew with the pinned checker marked:\n%s", stderr.String())
}

Expand All @@ -166,6 +166,14 @@ func TestAnErrandWithNoFlagsRunsARoutedCrewAndSaysSo(t *testing.T) {
if fields["check_model"] != "vendor/strong" || fields["check_model_source"] != "pinned" {
t.Fatalf("--json named the checker %v (%v), want the pin", fields["check_model"], fields["check_model_source"])
}
crew, ok := fields["crew"].(map[string]any)
if !ok {
t.Fatalf("--json crew is %T", fields["crew"])
}
checker, ok := crew["checker"].(map[string]any)
if !ok || checker["pinned"] != true {
t.Fatalf("--json checker pin is %v", crew["checker"])
}
if fields["model_source"] != "routed" {
t.Fatalf("--json named the worker's rung %v, want routed", fields["model_source"])
}
Expand Down
6 changes: 3 additions & 3 deletions docs/HEADLESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,12 @@ travels whole and the level is applied per call by the role ladder, exactly as
it is in the chat; the slug sent to the provider is the model alone.

**Every run says which rung answered**, on stderr, before anything else, and
under it the crew line — the class, the worker and its route, the checker (📌
on a pinned seat) and the estimate:
under it the crew line — the class, the worker and its route, the checker
(`(pinned)` after a pinned model) and the estimate:

```
models: worker z-ai/glm-5.3-flash (routed) · planner z-ai/glm-5.3-flash (routed) · checker moonshotai/kimi-k3 (pinned)
crew: bugfix · worker glm-5.3-flash (openrouter) · checker 📌 kimi-k3 · est $0.023
crew: bugfix · worker glm-5.3-flash (openrouter) · checker kimi-k3 (pinned) · est $0.023
```

When the run ends the `crew:` line is said again with the actual beside the
Expand Down
2 changes: 1 addition & 1 deletion docs/LIMITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ watching, so it is the one pocket that always has a bottom.

**The crew's two limits live on `/crew`, not on this tab.** The **per task** row
here reads the per-task limit and opens nothing new: it is set with
`/crew cap task <dollars>` or on the panel's cap row (`per task $5 · daily none`).
`/crew cap task <dollars>` or on the panel's cap row (`per task $5 · crew daily cap none`).
It is the second rail where `0` is not "no limit" — a task always has a limit, so
`0` and `none` are refused and an emptied box is $5 again. The crew's **daily
cap** beside it is unset until somebody sets it with `/crew cap <dollars>`, and
Expand Down
17 changes: 17 additions & 0 deletions docs/changes/unreleased/1518-crew-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
kind: fixed
title: crew follow-ups — checker ceiling by seat, route pins beside the tier row, landing line at the end
pr: 1518
surface: [chat, engine, docs]
invalidates:
- "The checker's spend ceiling was keyed by model and dropped whenever another seat used the same model, so a fresh profile's one-model narrow fix had none. Spend is now attributed by seat (session.SeatCompleter, marked by run.CrewFactory), and the ceiling holds whatever the other seats run."
- "At the crew's daily cap, a call to a model the catalog could not price was sent anyway. A call whose price is unknown is now refused at the cap, helpers included. A known price of nothing (a free pool, a local model, a subscription plan, via config.CrewCallPriceAt) is never stopped by a dollar line."
- "codeaf do's crew line drew a pinned seat as a pushpin emoji, and config.PinMark held it. The line now says `checker kimi-k3 (pinned)`, and PinMark is gone."
- "A `model@provider` pin was stored inside the tier row (models.tiers.worker|mastermind|high). The row now holds the model alone, and the route lives in models.crew.route.<seat>. MigrateCrew splits old rows silently, once."
- "A task's landing crew line (`$… (est …) · not right? /redo stronger`) rewrote the start line in place, far up the thread. It now moves to the end of the thread, beside the landing, still one line per task."
- "/crew's cap row said `per task $5 · daily none` beside the first-run screen's `Daily limit $500`. It now says `crew daily cap`, and names the daily limit on everything codeaf spends under it."
- "On the run road, a model named in the ask and the `task model` row never reached the router, and the crew's routed worker ran instead. Both now seat the worker as a one-task pin. Blank, the row reads `the crew's worker`, not `follows the conversation`."
- "/crew's model list drew prices as bare `$0.15/$0.50`. It now uses the model picker's `$0.15/$0.5 per M`."
---

Follow-ups to the Pareto crew (#1436). The manual answers three new questions: how to change a task's worker, whether code goes anywhere that logs it (including the crew's move onto free routes when every paid route is out of reach), and which model is used and what a task costs.
5 changes: 5 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,11 @@ var nonSettingProfileFields = []string{
// turns on and off.
KeyCrewFreeRoutes,
KeyCrewProvidersOff,
// Route pins are read beside the model-only tier rows and have no
// settings row of their own, so the unread-key notice must know them.
KeyCrewRouteWorker,
KeyCrewRoutePlanner,
KeyCrewRouteChecker,
}

// retiredProfileKeys are top-level config.json keys that a shipped version once
Expand Down
50 changes: 45 additions & 5 deletions internal/config/crew.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@ import (
// /crew panel says what is allowed and it persists; the words in an ask say
// how hard to try that one task; nothing else sticks.
//
// THE PINS LIVE IN THE TIER ROWS THE CREW HAS ALWAYS LIVED IN. The worker
// THE PINNED MODELS LIVE IN THE TIER ROWS THE CREW HAS ALWAYS LIVED IN. The worker
// seat is the `worker` tier row, the planner is the `mastermind` row, the
// checker is the `high` row — the rows the role ladder already reads, so the
// auxiliary calls that ride those tiers (the brief a task is shaped into, an
// image read for a model that cannot see one, the plan of an adaptive run)
// follow a pin without a second place to write it. An unwritten row is `auto`:
// follow a pin. A named provider route lives beside that row, so an older
// build reading the tier still sends a model id. An unwritten row is `auto`:
// the seat is routed. The reflex and small-work rows are not crew seats and
// keep their shipped defaults.

Expand All @@ -70,8 +71,30 @@ const (
// (crewroute's providers.go says why), and PROFILE-ONLY for the allowed
// rule's reason.
KeyCrewProvidersOff = "models.crew.providers.off"
// KeyCrewRouteWorker, KeyCrewRoutePlanner and KeyCrewRouteChecker hold a
// seat's pinned ROUTE, as the whole pin `model@provider`, beside the tier
// row that holds the model alone. The route used to be written into the
// tier row itself, and a build from before routed crews reads that row as
// a model id and would send `model@provider` to the provider; kept apart,
// an older build reads a valid id and simply takes its default route.
// The whole pin is kept so a route is applied only to the model it was
// chosen for ([CrewPinAt]). PROFILE-ONLY, like the rows beside them.
KeyCrewRouteWorker = "models.crew.route.worker"
KeyCrewRoutePlanner = "models.crew.route.planner"
KeyCrewRouteChecker = "models.crew.route.checker"
)

// crewRouteKey is the row a seat's pinned route is kept in.
func crewRouteKey(seat crewroute.Seat) string {
switch seat {
case crewroute.Planner:
return KeyCrewRoutePlanner
case crewroute.Checker:
return KeyCrewRouteChecker
}
return KeyCrewRouteWorker
}

// CrewAuto is the word a seat reads when it is not pinned.
const CrewAuto = "auto"

Expand Down Expand Up @@ -131,7 +154,7 @@ type CrewPin struct {
Provider string
}

// String is the pin the way it is written and stored: `model[@provider]`.
// String is the pin the way a person writes and reads it: `model[@provider]`.
func (p CrewPin) String() string {
if p.Provider == "" {
return p.Model
Expand Down Expand Up @@ -177,6 +200,16 @@ func CrewPinAt(profileDir string, seat crewroute.Seat) (CrewPin, bool) {
if auto || err != nil {
return CrewPin{}, false
}
// THE ROUTE IS READ FROM ITS OWN ROW, and only for the model it was pinned
// with: an older build that rewrote the tier row to another model left the
// route behind, and a route chosen for one model says nothing about another.
if pin.Provider == "" {
if route, held := persistedString(profileDir, crewRouteKey(seat)); held {
if routed, auto, err := ParseCrewPin(route); err == nil && !auto && routed.Model == pin.Model {
pin.Provider = routed.Provider
}
}
}
return pin, true
}

Expand Down Expand Up @@ -217,7 +250,12 @@ func SetCrewPin(profileDir string, seat crewroute.Seat, raw string) error {
if err := CrewPinAllowed(profileDir, pin); err != nil {
return err
}
values := map[string]any{tierKeyFor(CrewSeatTier(seat)): pin.String()}
// THE TIER ROW HOLDS THE MODEL ALONE and the route goes in its own row
// ([KeyCrewRouteWorker] says why); a pin with no route clears the old one.
values := map[string]any{tierKeyFor(CrewSeatTier(seat)): pin.Model, crewRouteKey(seat): removeProfileKey}
if pin.Provider != "" {
values[crewRouteKey(seat)] = pin.String()
}
// A profile carrying retired rows is migrated in the same write.
for key, value := range legacyCrewClearing(profileDir) {
if _, set := values[key]; !set {
Expand All @@ -231,6 +269,7 @@ func SetCrewPin(profileDir string, seat crewroute.Seat, raw string) error {
func ClearCrewPin(profileDir string, seat crewroute.Seat) error {
values := legacyCrewClearing(profileDir)
values[tierKeyFor(CrewSeatTier(seat))] = removeProfileKey
values[crewRouteKey(seat)] = removeProfileKey
return writeProfileValues(profileDir, values)
}

Expand All @@ -239,6 +278,7 @@ func ClearCrewPins(profileDir string) error {
values := legacyCrewClearing(profileDir)
for _, seat := range crewroute.Seats {
values[tierKeyFor(CrewSeatTier(seat))] = removeProfileKey
values[crewRouteKey(seat)] = removeProfileKey
}
return writeProfileValues(profileDir, values)
}
Expand Down Expand Up @@ -995,7 +1035,7 @@ type CrewState struct {
func crewStateKeys() []string {
keys := []string{KeyCrewAllowed, KeyCrewCap, KeyCrewTaskCap, KeyCrewProvidersOff, KeyCrewFreeRoutes}
for _, seat := range crewroute.Seats {
keys = append(keys, tierKeyFor(CrewSeatTier(seat)))
keys = append(keys, tierKeyFor(CrewSeatTier(seat)), crewRouteKey(seat))
}
return keys
}
Expand Down
156 changes: 156 additions & 0 deletions internal/config/crew_test.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,37 @@
package config

import (
"encoding/json"
"errors"
"os"
"slices"
"strings"
"testing"
"time"

"github.com/Agent-Field/codeaf/internal/catalog"
"github.com/Agent-Field/codeaf/internal/crewroute"
)

// rawCrewRow is one row of the profile file as an older build reads it: the
// stored string itself, not this build's reading of it.
func rawCrewRow(t *testing.T, dir, key string) string {
t.Helper()
raw, err := os.ReadFile(BudgetConfigPath(dir))
if err != nil {
t.Fatal(err)
}
var rows map[string]json.RawMessage
if err := json.Unmarshal(raw, &rows); err != nil {
t.Fatal(err)
}
var value string
if err := json.Unmarshal(rows[key], &value); err != nil {
t.Fatal(err)
}
return value
}

// crewProfile is a profile with an OpenRouter key and a small catalog, the
// ordinary state a crew is routed in. Every variable a seat or a key reads
// is cleared so the machine running the test cannot answer for it.
Expand Down Expand Up @@ -143,6 +165,12 @@ func TestPinsRoundTripAndRefuseWhatTheRuleLeavesOut(t *testing.T) {
if got := mustRow(t, registry(t, dir), KeyTierWorkerModel).Value(); got != "z-ai/glm-5.3-flash@openrouter" {
t.Errorf("the worker row reads %q", got)
}
if got := rawCrewRow(t, dir, KeyTierWorkerModel); got != "z-ai/glm-5.3-flash" {
t.Errorf("the older build's tier row reads %q", got)
}
if got := rawCrewRow(t, dir, "models.crew.route.worker"); got != "z-ai/glm-5.3-flash@openrouter" {
t.Errorf("the route row reads %q", got)
}
if err := SetCrewAllowed(dir, "open"); err != nil {
t.Fatal(err)
}
Expand All @@ -162,11 +190,115 @@ func TestPinsRoundTripAndRefuseWhatTheRuleLeavesOut(t *testing.T) {
if _, ok := CrewPinAt(dir, crewroute.Worker); ok {
t.Error("`auto` did not unpin the worker")
}
if _, held := persistedValue(dir, "models.crew.route.worker"); held {
t.Error("unpin left the route row")
}
if got := mustRow(t, registry(t, dir), KeyTierWorkerModel).Value(); got != CrewAuto {
t.Errorf("an unpinned worker row reads %q, want auto", got)
}
}

// A ROUTE PIN THIS BUILD WROTE INTO A TIER ROW IS SPLIT ONCE, SILENTLY: the
// row keeps the model an older build can send, the route moves to its own row,
// a second start writes nothing, undo restores the route, and a route left
// behind by an older build that changed the model is not applied to the new one.
func TestRoutedPinMigrationAndStaleRoute(t *testing.T) {
dir := crewProfile(t)
if err := writeProfileValues(dir, map[string]any{KeyTierHighModel: "moonshotai/kimi-k3@openrouter"}); err != nil {
t.Fatal(err)
}
if pin, ok := CrewPinAt(dir, crewroute.Checker); !ok || pin.String() != "moonshotai/kimi-k3@openrouter" {
t.Fatalf("a pin before migration: %+v, %v", pin, ok)
}
if line, err := MigrateCrew(dir); err != nil || line != "" {
t.Fatalf("split migration: %q, %v", line, err)
}
if got := rawCrewRow(t, dir, KeyTierHighModel); got != "moonshotai/kimi-k3" {
t.Errorf("model row: %q", got)
}
if got := rawCrewRow(t, dir, "models.crew.route.checker"); got != "moonshotai/kimi-k3@openrouter" {
t.Errorf("route row: %q", got)
}
before, err := os.ReadFile(BudgetConfigPath(dir))
if err != nil {
t.Fatal(err)
}
oldTime := time.Unix(1, 0)
if err := os.Chtimes(BudgetConfigPath(dir), oldTime, oldTime); err != nil {
t.Fatal(err)
}
if line, err := MigrateCrew(dir); err != nil || line != "" {
t.Fatalf("second migration: %q, %v", line, err)
}
after, err := os.ReadFile(BudgetConfigPath(dir))
if err != nil {
t.Fatal(err)
}
if string(before) != string(after) {
t.Error("second migration rewrote the profile")
}
info, err := os.Stat(BudgetConfigPath(dir))
if err != nil {
t.Fatal(err)
}
if !info.ModTime().Equal(oldTime) {
t.Error("second migration touched the profile")
}
saved := CrewStateAt(dir)
if err := SetCrewPin(dir, crewroute.Checker, "moonshotai/kimi-k3"); err != nil {
t.Fatal(err)
}
if _, held := persistedValue(dir, "models.crew.route.checker"); held {
t.Error("a model-only pin left the route row")
}
if err := RestoreCrewState(dir, saved); err != nil {
t.Fatal(err)
}
if pin, ok := CrewPinAt(dir, crewroute.Checker); !ok || pin.Provider != "openrouter" {
t.Fatalf("undo did not restore the route: %+v, %v", pin, ok)
}
if err := writeProfileValues(dir, map[string]any{KeyTierHighModel: "vendor/new"}); err != nil {
t.Fatal(err)
}
if pin, ok := CrewPinAt(dir, crewroute.Checker); !ok || pin.Model != "vendor/new" || pin.Provider != "" {
t.Fatalf("a stale route followed a new model: %+v, %v", pin, ok)
}
if err := ClearCrewPins(dir); err != nil {
t.Fatal(err)
}
if _, held := persistedValue(dir, "models.crew.route.checker"); held {
t.Error("unpin all left the route row")
}
}

// The route rows are rows this build reads, so the unread-key notice is quiet
// about them.
func TestCrewRouteRowsAreReadProfileKeys(t *testing.T) {
dir := crewProfile(t)
values := map[string]json.RawMessage{}
for _, key := range []string{"models.crew.route.worker", "models.crew.route.planner", "models.crew.route.checker"} {
values[key] = json.RawMessage(`"vendor/model@openrouter"`)
}
if unread := warnUnreadProfileKeys(dir, values); len(unread) != 0 {
t.Fatalf("route keys are reported unread: %v", unread)
}
}

// A HEADLESS CREW LINE SAYS A PIN IN WORDS: `(pinned)`, the word the models
// line above it uses, and never a pictograph a plain terminal or a script
// cannot read.
func TestHeadlessCrewReportNamesAPinInWords(t *testing.T) {
d := crewroute.Decision{Crew: []crewroute.Pick{
{Seat: crewroute.Worker, Model: "z-ai/glm-5.3-flash", Send: "z-ai/glm-5.3-flash"},
{Seat: crewroute.Planner, Model: "z-ai/glm-5.3-flash", Send: "z-ai/glm-5.3-flash"},
{Seat: crewroute.Checker, Model: "moonshotai/kimi-k3", Send: "moonshotai/kimi-k3", Pinned: true},
}}
report := (Seats{Crew: &d}).Report()
if !strings.Contains(report, "checker kimi-k3 (pinned)") || strings.Contains(report, "📌") {
t.Fatalf("headless crew report: %q", report)
}
}

func TestTheAllowedRuleNarrowsTheCandidates(t *testing.T) {
dir := crewProfile(t)
ids := func() []string { return crewroute.Names(CrewCandidatesAt(dir)) }
Expand Down Expand Up @@ -608,3 +740,27 @@ func TestAutoInAHelperRowReadsTheDefaultAndMigrates(t *testing.T) {
t.Error("a cleared small-work row was deleted")
}
}

// A PRICE OF NOTHING IS A PRICE: a free pool, and a call sent through a
// subscription plan or a model on this machine, bills nothing per token and is
// priced at nothing — known — while a model the catalog has no price for stays
// unknown. The spend guard stops only the unknown at a dollar line.
func TestACallThatBillsNothingIsPricedAtNothing(t *testing.T) {
dir := crewProfile(t)
if err := writeProfileValue(dir, keyModelSources, []PersistedSource{{ID: "z-ai", Written: "z-ai", Key: "zai-key-0123456789", Door: "coding-plan", Order: 1}}); err != nil {
t.Fatal(err)
}
if prompt, completion, _, ok := CrewCallPrice("z-ai/glm-5.3-flash:free"); !ok || prompt != 0 || completion != 0 {
t.Errorf("a free pool is priced %v/%v, known %v — want nothing, known", prompt, completion, ok)
}
price := CrewCallPriceAt(dir)
if prompt, completion, _, ok := price("z-ai/glm-5.3-flash"); !ok || prompt != 0 || completion != 0 {
t.Errorf("a call through the coding plan is priced %v/%v, known %v — want nothing, known", prompt, completion, ok)
}
if prompt, _, _, ok := price("moonshotai/kimi-k3"); !ok || prompt <= 0 {
t.Errorf("a metered catalog model is priced %v, known %v", prompt, ok)
}
if _, _, _, ok := price("vendor/nobody-prices-this"); ok {
t.Error("a model nobody prices reads as known")
}
}
Loading
Loading