diff --git a/cmd/codeaf/brief_test.go b/cmd/codeaf/brief_test.go index 5394ec5bfe..f3586e2f5a 100644 --- a/cmd/codeaf/brief_test.go +++ b/cmd/codeaf/brief_test.go @@ -140,3 +140,13 @@ func TestABriefMayArriveOnStandardInput(t *testing.T) { t.Fatalf("brief = %q, want the piped text", text) } } + +func TestReadTextRejectsAnAllBlankArgumentList(t *testing.T) { + want := noGoalGiven("do").Error() + for _, args := range [][]string{{""}, {" "}, {"", "\t"}} { + text, err := readText("do", args) + if text != "" || err == nil || err.Error() != want { + t.Errorf("readText(%q) = %q, %v; want noGoalGiven", args, text, err) + } + } +} diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index 9cbec89f49..5fc6b54c55 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -515,9 +515,13 @@ func openChatV3(name string, args []string, pickSession bool) error { // use, and one that has not resolved answers nil instead of waiting. // It reads the shelf, which ctrl+r in /model refills with today's list. Models: func() []tui3.Model { return v3Models(proc.Shelf) }, - RefreshModels: proc.Shelf.refresh, + RefreshModels: proc.refreshDefaultModels, ModelsForService: proc.Shelf.modelsForService, RefreshModelsForService: proc.Shelf.refreshService, + RefreshAllModels: proc.refreshAllModels, + WarmEmptyProviders: proc.warmEmptyProviders, + SubscribeServiceModels: proc.registerServiceNotice, + ProviderFetchError: proc.Shelf.fetchErrorFor, Sources: settings.Sources, // The same deliverables index the session's config carries, so the // surface's /export rows and the session's own land in one file. diff --git a/cmd/codeaf/chatv3_credits.go b/cmd/codeaf/chatv3_credits.go index fd85aaf705..81aa990ed8 100644 --- a/cmd/codeaf/chatv3_credits.go +++ b/cmd/codeaf/chatv3_credits.go @@ -37,7 +37,7 @@ func v3CreditReader(proc *v3Process) func(context.Context) (credits.Reading, err return func(ctx context.Context) (credits.Reading, error) { key, sources := proc.currentAccount() if strings.TrimSpace(key) == "" { - return credits.Reading{}, errors.New("no default-service key") + return credits.Reading{}, errors.New("no default provider key") } base := sources.Default().Address if base == "" { @@ -52,7 +52,7 @@ func v3LocalCreditReader(settings config.Config) func(context.Context) (credits. return func(ctx context.Context) (credits.Reading, error) { key := config.APIKeyAt(settings.ProfileDir) if key == "" { - return credits.Reading{}, errors.New("no default-service key") + return credits.Reading{}, errors.New("no default provider key") } base := settings.Sources.Default().Address if base == "" { diff --git a/cmd/codeaf/chatv3_host.go b/cmd/codeaf/chatv3_host.go index 194255e9c7..afae33f942 100644 --- a/cmd/codeaf/chatv3_host.go +++ b/cmd/codeaf/chatv3_host.go @@ -679,6 +679,7 @@ func hostOptions(fleet *engineFleet, welcome remote.Welcome, pick bool) (tui3.Op ContextWindow: v3Window(models, welcome.Model), Models: func() []tui3.Model { return v3Models(shelf) }, RefreshModels: shelf.refresh, + ProviderFetchError: shelf.fetchErrorFor, // /export writes on THIS machine (host.go's honesty table), so its row // goes in this machine's index — the same one the local launch spells. ArtifactsIndex: artifactsIndexPath(), @@ -859,6 +860,24 @@ func hostOptions(fleet *engineFleet, welcome remote.Welcome, pick bool) (tui3.Op // replaces a closure that named this session file and was left bound to it // through every switch. options.TaskIndex = farTaskRows(options.World, welcome.SessionFile) + if dest == "" { + // The linked engine and this window read the same profile, so its shelf + // can list every connected provider just as the in-process door does. + engineProfile := strings.TrimSpace(welcome.ProfileDir) + if engineProfile == "" { + engineProfile = profileDir + } + shelf.options.Dir = engineProfile + shelf.setSources(config.ResolveSources(engineProfile, settings.APIKey, settings.BaseURL)) + listing := &v3Process{Shelf: shelf} + options.RefreshModels = listing.refreshDefaultModels + options.ModelsForService = shelf.modelsForService + options.RefreshModelsForService = shelf.refreshService + options.RefreshAllModels = listing.refreshAllModels + options.WarmEmptyProviders = listing.warmEmptyProviders + options.SubscribeServiceModels = listing.registerServiceNotice + options.ApplyModelSources = shelf.setSources + } if !fleet.canBeside() { // A DOOR THAT CANNOT DIAL AGAIN REALLY DOES HOLD ONE CONVERSATION AT A // TIME, and says so ([tui3.Options.SharedAgent]) rather than letting @@ -1029,7 +1048,7 @@ func hostFollow(seams hostSeams) func() <-chan tui3.Following { // again. defer close(out) for turn := range seams.Follow() { - out <- tui3.Following{Said: turn.Said, Events: turn.Events} + out <- tui3.Following{Said: turn.Said, Events: turn.Events, Covered: turn.Covered, Replay: turn.Replay} } }) }) diff --git a/cmd/codeaf/chatv3_host_test.go b/cmd/codeaf/chatv3_host_test.go index 152a66626a..386c755281 100644 --- a/cmd/codeaf/chatv3_host_test.go +++ b/cmd/codeaf/chatv3_host_test.go @@ -208,6 +208,8 @@ func TestTheEngineDoorKeepsTheAmbientSideOnOverAConnection(t *testing.T) { t.Setenv("CODEAF_HOME", filepath.Join(home, "state")) t.Setenv("OPENROUTER_API_KEY", "test-key") t.Chdir(home) + // Close the engine owner, including catalog and pool writers, before this home is removed. + freshEngineProcess(t) engine, err := bootEngine(remote.Hello{Version: remote.Version}, "", "") if err != nil { @@ -709,6 +711,8 @@ func TestBringingAConversationBackCorrectsTheHeldWorldToo(t *testing.T) { func TestHostedWelcomeCarriesUnreadProfileKeysToSurface(t *testing.T) { workspace := t.TempDir() agent := v3TrackedAgent(t, workspace) + t.Cleanup(agent.SettleWrites) + t.Cleanup(func() { _ = agent.Close() }) loop, err := remote.Loopback(remote.Hello{Version: remote.Version, Workspace: workspace}, remote.Options{ Boot: func(remote.Hello) (*remote.Engine, error) { return &remote.Engine{Agent: agent, Workspace: workspace, UnreadProfileKeys: []string{"models"}}, nil @@ -717,7 +721,16 @@ func TestHostedWelcomeCarriesUnreadProfileKeysToSurface(t *testing.T) { if err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = loop.Close() }) + t.Cleanup(func() { + _ = loop.Close() + // Closing the client only starts shutdown; join the engine before its + // conversation and deferred writes lose their temporary directories. + select { + case <-loop.Served: + case <-time.After(5 * time.Second): + t.Error("hosted welcome engine did not finish shutdown") + } + }) options, _ := hostOptions(onePipeFleet("devbox", loop.Client), loop.Client.Welcome(), false) if got := options.UnreadProfileKeys; len(got) != 1 || got[0] != "models" { t.Fatalf("hosted unread keys = %v", got) diff --git a/cmd/codeaf/chatv3_local.go b/cmd/codeaf/chatv3_local.go index 863e720525..d6a143ae63 100644 --- a/cmd/codeaf/chatv3_local.go +++ b/cmd/codeaf/chatv3_local.go @@ -412,7 +412,11 @@ func localDoors(options *tui3.Options, welcome remote.Welcome, settings config.C // disk when this callback runs; setting the current model through the // existing wire door makes the engine re-read that disk without sending // a key or address through a second protocol. - options.ApplyModelSources = func(modelsource.Set) { + stock := options.ApplyModelSources + options.ApplyModelSources = func(sources modelsource.Set) { + if stock != nil { + stock(sources) + } options.Agent.SetModel(options.Agent.Model()) } } diff --git a/cmd/codeaf/chatv3_local_test.go b/cmd/codeaf/chatv3_local_test.go index 6919a4181f..274dbaf938 100644 --- a/cmd/codeaf/chatv3_local_test.go +++ b/cmd/codeaf/chatv3_local_test.go @@ -18,6 +18,7 @@ import ( "path/filepath" "strconv" "strings" + "sync/atomic" "testing" "time" @@ -281,6 +282,47 @@ func TestAPlainLaunchKeepsThisMachinesDoorsWhileAHostLaunchDoesNot(t *testing.T) } } +// The ordinary engine window uses the same provider shelf that its picker +// draws, so a cold direct provider can fill without a reconnect. +func TestPlainEngineModelDoorsRefreshEveryConnectedProvider(t *testing.T) { + profile := t.TempDir() + t.Setenv("CODEAF_HOME", profile) + t.Setenv("CODEAF_PROFILE_DIR", profile) + t.Setenv("HOME", t.TempDir()) + t.Setenv(config.APIKeyEnv, "default-key") + t.Cleanup(func() { stopPoolErrands(profile) }) + defaultServer := sourcestub.New("openai/gpt-4.1-mini") + defer defaultServer.Close() + t.Setenv("CODEAF_BASE_URL", defaultServer.URL()) + var listed atomic.Int32 + direct := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + listed.Add(1) + _, _ = w.Write([]byte(`{"data":[{"id":"direct-chat"}]}`)) + })) + defer direct.Close() + if err := config.WriteSources(profile, []config.PersistedSource{{ + ID: "custom", Written: "direct", Address: direct.URL, Key: "direct-key", Order: 1, + }}); err != nil { + t.Fatal(err) + } + client := hostedClient(t) + welcome := remote.Welcome{Version: remote.Version, ProfileDir: profile, Workspace: t.TempDir()} + options, settings := hostOptions(onePipeFleet("", client), welcome, false) + localDoors(&options, welcome, settings) + if options.ModelsForService == nil || options.RefreshModelsForService == nil || + options.RefreshAllModels == nil || options.WarmEmptyProviders == nil || options.SubscribeServiceModels == nil { + t.Fatal("plain engine launch has no provider listing doors") + } + options.WarmEmptyProviders(t.Context()) + if listed.Load() != 1 { + t.Fatalf("cold provider listed %d times at launch, want once", listed.Load()) + } + options.RefreshAllModels(t.Context()) + if listed.Load() != 2 { + t.Fatalf("refresh listed direct provider %d times, want twice", listed.Load()) + } +} + // A DAMAGED ACCOUNT STORE TAKES AWAY ONLY THE ACCOUNTS. Model services live in // the profile's config.json, so their group still draws and the connection // panel has a useful, non-panicking shape. diff --git a/cmd/codeaf/chatv3_modelshelf.go b/cmd/codeaf/chatv3_modelshelf.go index bc75c8c341..7db5277755 100644 --- a/cmd/codeaf/chatv3_modelshelf.go +++ b/cmd/codeaf/chatv3_modelshelf.go @@ -36,6 +36,10 @@ type v3ModelShelf struct { // sources is the service set the compartments were last aligned with, kept // so a model id can be taken to ITS service's rows ([v3ModelShelf.contextWindow]). sources modelsource.Set + // fetchErrors holds, per provider id, why its last listing attempt failed — + // the sentence the provider's group shows until a fetch lands. Written only + // from commands off the event loop, read on the draw path. + fetchErrors map[string]string } type serviceCompartment struct { @@ -243,16 +247,101 @@ func (s *v3ModelShelf) refreshService(ctx context.Context, service modelsource.C if len(seed) > 0 { return append([]tui3.Model(nil), seed...), nil } - return nil, v3FetchReason(err) + reason := v3FetchReason(err) + s.fetchError(strings.ToLower(strings.TrimSpace(service.Source.ID)), reason) + return nil, reason } rows := v3Models(fresh) id := strings.ToLower(strings.TrimSpace(service.Source.ID)) address, door := serviceCompartmentIdentity(service) s.stock(id, address, door, append([]tui3.Model(nil), rows...)) + s.fetchError(id, nil) _ = tui3.WriteModelCacheFor(service.Source.ID, service.Address, rows) return rows, nil } +// warmAll fetches every connected provider whose compartment is cold, through +// the same door the connect path uses ([v3ModelShelf.refreshService]). It is +// issue #1508's launch half: a profile whose model_sources rows survive but +// whose per-provider caches do not (a new machine, a cleaned profile, a +// hand-written row) used to open /model with nothing to offer and nothing +// coming — the only fetch was the one at connect time. +// +// IT IS THE CALLER'S GOROUTINE: this walks the network and must never run on +// the event loop (the same law the connect command keeps). The draw path keeps +// its lock discipline — the shelf takes its own lock and no other — and the +// reader sees each provider's group fill the moment its fetch stocks the +// compartment, without a reopen. +// +// A PROVIDER THAT CANNOT LIST IS NOT SKIPPED SILENTLY: the reason is recorded +// per provider ([v3ModelShelf.fetchErrors]) so the group can say why, and the +// next provider is still tried. ctrl+r shares this walk. +func (s *v3ModelShelf) warmAll(ctx context.Context, onlyCold bool, landed func(modelsource.Connected)) { + if s == nil { + return + } + for _, service := range s.sourcesNow().All()[1:] { + if strings.EqualFold(service.Source.ID, "codex") { + // A CODEX COMPARTMENT IS RE-READ FROM ITS REMEMBERED CATALOG, never + // from the wire; its rows arrive at setSources. Skipped here. + continue + } + id := strings.ToLower(strings.TrimSpace(service.Source.ID)) + if onlyCold { + held, rows, ok := s.compartment(id) + if ok && (len(rows) > 0 || held.address == "" && held.door == "") { + // Warm, or a compartment that says it cannot be listed at all. + continue + } + if len(rows) == 0 && !ok { + // Not kept by setSources: not a listing provider this run. + if service.Source.Listing != modelsource.ListingModels || len(service.Door.Models) > 0 { + continue + } + } + } + if service.Source.Listing != modelsource.ListingModels || len(service.Door.Models) > 0 { + // A provider that declares no listing (or vendors its catalog in + // the door) has nothing to fetch; its group is drawn from what the + // compartment or the vendored rows hold. + continue + } + _, _ = s.refreshService(ctx, service, nil) + if landed != nil { + landed(service) + } + } +} + +// fetchError records one provider's listing refusal, and fetchErrorFor reads it +// back for the group's status line. The maps are only ever touched under the +// shelf lock, from commands off the loop. +func (s *v3ModelShelf) fetchError(id string, err error) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.fetchErrors == nil { + s.fetchErrors = make(map[string]string) + } + if err == nil { + delete(s.fetchErrors, id) + return + } + s.fetchErrors[id] = err.Error() +} + +// fetchErrorFor is the recorded reason one provider last failed to list. +func (s *v3ModelShelf) fetchErrorFor(id string) string { + if s == nil { + return "" + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.fetchErrors[id] +} + // v3Rows is a list of catalog rows already in hand, asked the one question // [v3Models] asks of a catalog. type v3Rows []catalog.Model diff --git a/cmd/codeaf/chatv3_process.go b/cmd/codeaf/chatv3_process.go index 6badc4182e..f8b86d940e 100644 --- a/cmd/codeaf/chatv3_process.go +++ b/cmd/codeaf/chatv3_process.go @@ -34,6 +34,7 @@ import ( "path/filepath" "strings" "sync" + "time" "github.com/Agent-Field/codeaf/internal/catalog" "github.com/Agent-Field/codeaf/internal/config" @@ -69,6 +70,11 @@ type v3Process struct { // session readers that answer about a model somebody may have just picked // out of that list — can it see, may a task be handed to it — read here. Shelf *v3ModelShelf + // serviceNotices are the surfaces to tell when a provider's listing lands. + // Appended by each launch that opens the surface, never read on the draw + // path. + serviceNotices map[uint64]func(string, string) + serviceNoticeNext uint64 // Harnesses is the registry under the state root. The law is already written // at [openV3Launch]: two stores at one directory is how /harness and the // offer card come to name different harnesses. @@ -390,6 +396,90 @@ func (p *v3Process) setModelSources(sources modelsource.Set) { } } +// refreshAllModels is [tui3.Options.RefreshAllModels]: ctrl+r in /model walks +// the router's catalog AND every connected provider's listing (issue #1508). +// One provider's refusal never stops the walk: each fetch is its own call and +// its own error, and the group that could not list names its own reason +// ([v3ModelShelf.fetchErrors]). Runs as a command off the event loop. +func (p *v3Process) refreshAllModels(ctx context.Context) { + if p == nil || p.Shelf == nil { + return + } + _, _, _ = p.refreshDefaultModels(ctx) + p.Shelf.warmAll(ctx, false, p.noteServiceModels) +} + +// refreshDefaultModels publishes both success and failure before returning. +// The default-only menu and the all-provider walk share this delivery boundary. +func (p *v3Process) refreshDefaultModels(ctx context.Context) ([]tui3.Model, time.Time, error) { + rows, at, err := p.Shelf.refresh(ctx) + p.Shelf.fetchError(modelsource.DefaultID, err) + p.noteServiceModelsTo(modelsource.DefaultID, p.Shelf.options.BaseURL) + return rows, at, err +} + +// warmEmptyProviders is [tui3.Options.WarmEmptyProviders]: the launch half of +// issue #1508. Every connected provider that lists models and whose cache file +// is missing or empty is fetched once, off the loop, through the connect path's +// own door. It is called after setSources has filled what the caches could, +// so a warm provider costs nothing and a cold one fills its group without a +// reopen. +func (p *v3Process) warmEmptyProviders(ctx context.Context) { + if p == nil || p.Shelf == nil { + return + } + p.Shelf.warmAll(ctx, true, p.noteServiceModels) +} + +// registerServiceNotice subscribes one window and returns its removal function. +// Callbacks run outside the process lock. A snapshot already in flight may +// finish after removal; the surface owns and closes its notification desk. +func (p *v3Process) registerServiceNotice(tell func(source, address string)) func() { + p.mu.Lock() + defer p.mu.Unlock() + if p.serviceNotices == nil { + p.serviceNotices = make(map[uint64]func(string, string)) + } + p.serviceNoticeNext++ + id := p.serviceNoticeNext + p.serviceNotices[id] = tell + return func() { + p.mu.Lock() + defer p.mu.Unlock() + delete(p.serviceNotices, id) + } +} + +// noteServiceModelsTo is one surface's slice of the news: the drop of its memo +// and the restock of its open picker happen on ITS loop, through the callback +// the surface itself supplied — which is the only side allowed to touch the +// app's memos. +func (p *v3Process) serviceNoticesSnapshot() []func(string, string) { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]func(string, string), 0, len(p.serviceNotices)) + for _, tell := range p.serviceNotices { + out = append(out, tell) + } + return out +} + +func (p *v3Process) noteServiceModelsTo(source, address string) { + notify := p.serviceNoticesSnapshot() + for _, tell := range notify { + if tell != nil { + tell(source, address) + } + } +} + +// noteServiceModels tells every live surface one provider's listing changed, +// so its memo is dropped and an open picker restocks. The process holds the +// launch doors; each registers itself here when it opens the surface. +func (p *v3Process) noteServiceModels(service modelsource.Connected) { + p.noteServiceModelsTo(service.Source.ID, service.Address) +} + // refreshModelSources re-reads this process's own profile and makes that // answer live in every conversation it retains. // diff --git a/cmd/codeaf/chatv3_servicenotice_test.go b/cmd/codeaf/chatv3_servicenotice_test.go new file mode 100644 index 0000000000..e9cf0566bc --- /dev/null +++ b/cmd/codeaf/chatv3_servicenotice_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/modelsource" +) + +func TestProviderNoticesUnsubscribeWithoutHoldingTheProcessLock(t *testing.T) { + p := &v3Process{} + calls := 0 + var remove func() + remove = p.registerServiceNotice(func(string, string) { calls++; remove() }) + p.noteServiceModelsTo("service", "address") + p.noteServiceModelsTo("service", "address") + remove() + if calls != 1 || len(p.serviceNotices) != 0 { + t.Fatalf("calls=%d subscriptions=%d", calls, len(p.serviceNotices)) + } +} + +func TestDefaultProviderRefreshNotifiesOnSuccessAndFailure(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + for _, fail := range []bool{false, true} { + options := catalog.Options{BaseURL: "https://example.invalid/v1", Dir: t.TempDir(), HTTPClient: shelfRouter(`{"id":"openai/new","context_length":8192}`, nil)} + launch := catalog.Load(t.Context(), options) + if fail { + options.HTTPClient = shelfRouter("", errors.New("offline")) + } + p := &v3Process{Shelf: newV3ModelShelf(launch, options)} + p.Shelf.setSources(modelsource.NewSet(modelsource.Connected{Source: modelsource.DefaultSource(options.BaseURL), Address: options.BaseURL})) + calls := 0 + remove := p.registerServiceNotice(func(source, address string) { + calls++ + if source != modelsource.DefaultID || address != options.BaseURL { + t.Errorf("wrong landing %q %q", source, address) + } + }) + p.refreshAllModels(t.Context()) + remove() + if calls != 1 { + t.Fatalf("failure=%v: got%d notices", fail, calls) + } + if (p.Shelf.fetchErrorFor(modelsource.DefaultID) != "") != fail { + t.Fatal("default error state not delivered") + } + } +} + +func TestProviderWarmReportsFailureBeforeTheNextProviderFinishes(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "refused", http.StatusForbidden) })) + defer first.Close() + entered, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + once.Do(func() { close(entered) }) + <-release + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"fresh"}]}`)) + })) + defer second.Close() + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + defer unblock() + options := catalog.Options{Dir: t.TempDir(), HTTPClient: shelfRouter(`{"id":"default"}`, nil)} + shelf := newV3ModelShelf(catalog.Load(t.Context(), options), options) + makeService := func(id, address string) modelsource.Connected { + return modelsource.Connected{Source: modelsource.Source{ID: id, Written: id, Listing: modelsource.ListingModels}, Address: address, Key: "synthetic"} + } + shelf.setSources(modelsource.NewSet(makeService(modelsource.DefaultID, "https://example.invalid"), makeService("custom:first", first.URL), makeService("custom:second", second.URL))) + notices := make(chan string, 2) + p := &v3Process{Shelf: shelf} + remove := p.registerServiceNotice(func(source, address string) { notices <- source }) + defer remove() + done := make(chan struct{}) + go func() { p.warmEmptyProviders(context.Background()); close(done) }() + <-entered + select { + case got := <-notices: + if got != "custom:first" { + t.Fatalf("first notice %q", got) + } + default: + t.Fatal("failed provider waited for the next network request") + } + unblock() + <-done + if got := <-notices; got != "custom:second" { + t.Fatalf("second notice %q", got) + } + if shelf.fetchErrorFor("custom:first") == "" { + t.Fatal("failed provider's reason missing") + } +} diff --git a/cmd/codeaf/chatv3_standing.go b/cmd/codeaf/chatv3_standing.go index c53dd9429c..a3e2c0fe73 100644 --- a/cmd/codeaf/chatv3_standing.go +++ b/cmd/codeaf/chatv3_standing.go @@ -75,7 +75,7 @@ func v3Standing(profileDir string) *session.Standing { // The person's own daily budget is what the card quotes beside the // per-run cap. A profile that cannot be read quotes nothing rather than // a figure nobody set, which is the emptiness law applied to money. - DailyRailUSD: v3StandingDailyRail(profileDir), + DailyRail: func() float64 { return v3StandingDailyRail(profileDir) }, } } diff --git a/cmd/codeaf/chatv3_standing_test.go b/cmd/codeaf/chatv3_standing_test.go index c95259ec9c..11d2babde4 100644 --- a/cmd/codeaf/chatv3_standing_test.go +++ b/cmd/codeaf/chatv3_standing_test.go @@ -32,7 +32,8 @@ func TestStandingLivesUnderTheStateRoot(t *testing.T) { func TestStandingSeamOpensTheStoreAtThatPath(t *testing.T) { root := filepath.Join(t.TempDir(), "state") t.Setenv("CODEAF_HOME", root) - seam := v3Standing(t.TempDir()) + profile := t.TempDir() + seam := v3Standing(profile) if seam == nil || seam.Store == nil { t.Fatal("the door built no standing seam") } @@ -41,8 +42,16 @@ func TestStandingSeamOpensTheStoreAtThatPath(t *testing.T) { } // The daily rail is the person's own daily budget row and never a second // number invented for this. - if seam.DailyRailUSD != v3StandingDailyRail(t.TempDir()) { - t.Fatalf("the seam quotes %v as the daily rail", seam.DailyRailUSD) + if seam.DailyRail == nil { + t.Fatal("the seam must read the current daily rail") + } + for _, budget := range []string{"5", "7"} { + if err := os.WriteFile(filepath.Join(profile, "config.json"), []byte(`{"daily_budget_usd":`+budget+`}`), 0600); err != nil { + t.Fatal(err) + } + if got, want := seam.DailyRail(), v3StandingDailyRail(profile); got != want || got == 0 { + t.Fatalf("daily rail = %v, want %v", got, want) + } } } diff --git a/cmd/codeaf/connect.go b/cmd/codeaf/connect.go index b468b91d32..da32231aaa 100644 --- a/cmd/codeaf/connect.go +++ b/cmd/codeaf/connect.go @@ -55,7 +55,7 @@ func runConnect(args []string) error { return listConnections(config.ProfileDir()) } if flags.NArg() != 1 { - return wrongCall("codeaf connect takes one service name") + return wrongCall("codeaf connect takes one provider name") } service := strings.ToLower(strings.TrimSpace(flags.Arg(0))) switch service { @@ -175,7 +175,7 @@ func listConnections(profileDir string) error { } } if !any { - fmt.Fprintln(usageOut, "no model service is connected") + fmt.Fprintln(usageOut, "no provider is connected") } return nil } @@ -183,7 +183,7 @@ func listConnections(profileDir string) error { func connectKeyService(ctx context.Context, profileDir, name, region string) error { source, row, found := connectionSource(profileDir, name) if !found || source.ID == "codex" || source.ID == modelsource.DefaultID { - fmt.Fprintln(usageOut, name+" is not a model service this profile knows") + fmt.Fprintln(usageOut, name+" is not a provider this profile knows") return exitStatus(1) } if len(source.Regions) > 0 && region == "" { @@ -270,7 +270,7 @@ func runDisconnect(args []string) error { return err } if flags.NArg() != 1 { - return wrongCall("codeaf disconnect needs one service name") + return wrongCall("codeaf disconnect needs one provider name") } profileDir := config.ProfileDir() name := strings.ToLower(strings.TrimSpace(flags.Arg(0))) diff --git a/cmd/codeaf/connect_test.go b/cmd/codeaf/connect_test.go index f1de932844..7fad0afa35 100644 --- a/cmd/codeaf/connect_test.go +++ b/cmd/codeaf/connect_test.go @@ -197,7 +197,7 @@ func TestC8ConnectWithoutAServiceListsMethodsAndNeverDrawsNothing(t *testing.T) if err := runConnect(nil); err != nil { t.Fatal(err) } - for _, want := range []string{modelsource.DefaultID + " · not connected · browser or key", "codex · not connected · browser", "deepseek · not connected · key", "no model service is connected"} { + for _, want := range []string{modelsource.DefaultID + " · not connected · browser or key", "codex · not connected · browser", "deepseek · not connected · key", "no provider is connected"} { if !strings.Contains(output.String(), want) { t.Errorf("listing missing %q: %q", want, output.String()) } @@ -267,12 +267,12 @@ func TestC10DisconnectForgetsCodexAndRejectsAnUnknownService(t *testing.T) { func TestC11ConnectHelpIsLiftedFromTheEightyColumnTable(t *testing.T) { // C11: both terminal doors are present in the shared usage source. - for _, want := range []string{"codeaf connect", "codeaf connect [--no-browser] [--region intl|cn]", "codeaf disconnect "} { + for _, want := range []string{"codeaf connect", "codeaf connect [--no-browser] [--region intl|cn]", "codeaf disconnect "} { if !strings.Contains(usageText, want) { t.Errorf("usage is missing %q", want) } } - if page := usageForCommand("connect"); !strings.Contains(page, "list the model services") || !strings.Contains(page, "--no-browser") { + if page := usageForCommand("connect"); !strings.Contains(page, "list the providers") || !strings.Contains(page, "--no-browser") { t.Fatalf("connect help = %q", page) } } diff --git a/cmd/codeaf/do_engine_test.go b/cmd/codeaf/do_engine_test.go index f3384da34d..bbb7456805 100644 --- a/cmd/codeaf/do_engine_test.go +++ b/cmd/codeaf/do_engine_test.go @@ -393,7 +393,7 @@ func TestDoOnTheRunEngineSeatsEveryLaunchOnTheDoorsModels(t *testing.T) { if err := os.MkdirAll(profileDir, 0o700); err != nil { t.Fatal(err) } - beltModelPins(t, profileDir, map[string]string{ + beltProfileModels(t, profileDir, map[string]string{ config.KeyTierWorkerModel: "vendor/profile-worker", config.KeyTierMastermindModel: "vendor/profile-thinking", }) @@ -679,7 +679,7 @@ func TestDoOnTheRunEngineSeatsACheckOnTheCheckModel(t *testing.T) { if err := os.MkdirAll(profileDir, 0o700); err != nil { t.Fatal(err) } - beltModelPins(t, profileDir, map[string]string{ + beltProfileModels(t, profileDir, map[string]string{ config.KeyTierLowModel: "vendor/profile-small", config.KeyTierWorkerModel: "vendor/profile-worker", config.KeyTierHighModel: "vendor/profile-careful", @@ -772,7 +772,7 @@ func TestDoOnTheRunEngineSeatsAnUnpinnedCheckOnTheCrewsChecker(t *testing.T) { if err := os.MkdirAll(profileDir, 0o700); err != nil { t.Fatal(err) } - beltModelPins(t, profileDir, map[string]string{ + beltProfileModels(t, profileDir, map[string]string{ config.KeyTierWorkerModel: "vendor/profile-worker", config.KeyTierHighModel: "vendor/profile-careful", config.KeyTierMastermindModel: "vendor/profile-thinking", @@ -846,3 +846,33 @@ func TestDoOnTheRunEngineSeatsAnUnpinnedCheckOnTheCrewsChecker(t *testing.T) { // bound is a named slot count for a request, the way the flag would name one. func bound(n int) *int { return &n } + +// assertBeltMachineGateDisabled ensures model overrides retain the scripted +// fixture's isolation from the host's real load and memory. +func assertBeltMachineGateDisabled(t *testing.T, profile string) { + t.Helper() + settings := config.NewSettings(config.SettingsOptions{ProfileDir: profile}) + for _, key := range []string{config.KeyTaskMaxLoad, config.KeyTaskMinFreeMB} { + row, ok := settings.Row(key) + if !ok || row.Value() != "0" { + t.Fatalf("model overrides lost fixture setting %s: got %q, want 0", key, row.Value()) + } + } +} + +// beltProfileModels changes only the model rows: replacing the profile file +// would silently restore machine limits that beltRunEnv already disabled. +func beltProfileModels(t *testing.T, profile string, models map[string]string) { + t.Helper() + settings := config.NewSettings(config.SettingsOptions{ProfileDir: profile}) + for key, model := range models { + row, ok := settings.Row(key) + if !ok { + t.Fatalf("the %s row is absent", key) + } + if err := row.Apply(model); err != nil { + t.Fatalf("set %s: %v", key, err) + } + } + assertBeltMachineGateDisabled(t, profile) +} diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index 3645434aec..9974b41d3d 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -536,11 +536,11 @@ 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 connect - list the model services this profile knows and which are connected - codeaf connect [--no-browser] [--region intl|cn] + list the providers this profile knows and which are connected + codeaf connect [--no-browser] [--region intl|cn] connect one: openrouter and codex sign in in your browser; the others take a key on stdin, or ask for one without echo - codeaf disconnect + codeaf disconnect forget a service and the key or sign-in behind it codeaf update [--check] [--stable|--rc|--dev|--staging] [--version tag] check or install a release; this build's own channel is the default @@ -1099,11 +1099,15 @@ func emit(graph *plan.Graph, output string, asJSON bool) error { // lines, the environment included — for the sake of one missing quoted string, // and the one line that mattered scrolled off the top of the terminal. func readText(name string, args []string) (string, error) { - if len(args) == 1 && args[0] == "-" { - return readPipedText(name) - } if len(args) > 0 { - return strings.TrimSpace(strings.Join(args, " ")), nil + text := strings.TrimSpace(strings.Join(args, " ")) + if text == "" { + return "", noGoalGiven(name) + } + if len(args) == 1 && args[0] == "-" { + return readPipedText(name) + } + return text, nil } if stdinIsTerminal(os.Stdin) { return "", noGoalGiven(name) diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 8dee6753f0..1720b79601 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -264,7 +264,7 @@ capability that cannot work is left off it rather than offered and failing. Furrow watch. The five `gmail_*` and `calendar_*` tools arrive only with a connected Google account and the four `slack_*` only with Slack; `/connect` — `your connected accounts · connect another` — is the door, and any other keyed account brings one -`_request` instead, plus whatever the service names for itself. +`_request` instead, plus whatever the account names for itself. `view_image` needs a vision model. `edit_video` needs its local video binaries. Each media-generation tool needs both a media client and a resolved model for its modality. @@ -287,12 +287,12 @@ after the conversation. Memory keeps person-, project-, or machine-scoped record ## Models, keys, and spending -Key resolution for the default service is `OPENROUTER_API_KEY`, then +Key resolution for the default provider is `OPENROUTER_API_KEY`, then `OPENAI_API_KEY`, then `api_key` in the profile's `config.json`. With no credential, an interactive local launch opens a two-page setup that offers to connect OpenRouter in a browser or take a pasted key. First run is unchanged and does not offer Codex. -A non-interactive chat starts when the default service has a key or any connected -service holds its credential; a call to a service without one still fails when it is +A non-interactive chat starts when the default provider has a key or any connected +provider holds its credential; a call to an account without one still fails when it is made. With no credential anywhere it stops with `codeaf chat needs a model to talk with.`
@@ -301,11 +301,11 @@ made. With no credential anywhere it stops with `codeaf chat needs a model to ta Where a browser is reachable the first page is headed `connect openrouter`: ```text -sign in once in your browser. openrouter makes the default service's key for this profile; codeaf stores it on this machine. no prompt is sent and no model is called. +sign in once in your browser. openrouter makes the default provider's key for this profile; codeaf stores it on this machine. no prompt is sent and no model is called. ``` Where it is not, the same page is headed `your openrouter key` and reads `codeaf talks -to models on its default service through openrouter, on your key and your card. nothing +to models on its default provider through openrouter, on your key and your card. nothing is sent until you do.` Either way the foot takes a pasted key and `esc` skips setup. The second page is `Daily limit` and `Chat model`. @@ -314,13 +314,13 @@ The second page is `Daily limit` and `Chat model`. The chat model resolves from `--model`, then saved `model.talk`, then `CODEAF_MODEL`, then `~deepseek/deepseek-v4-flash-latest`. The last value is a floating alias. Besides OpenRouter, the connection screen supports DeepSeek, Z.ai, Moonshot, MiniMax, Alibaba -Qwen, Codex through a ChatGPT plan, Ollama, and a custom OpenAI-compatible service. -The same supported services can be managed without opening the chat with `codeaf +Qwen, Codex through a ChatGPT plan, Ollama, and a custom OpenAI-compatible provider. +The same supported providers can be managed without opening the chat with `codeaf connect` and `codeaf disconnect`; a qualified slug such as -`qwen/` selects its service. +`qwen/` selects its provider. -Provider routing defaults to `simple`: an unpinned OpenRouter call carries no provider -object, while a pinned call asks for exactly that lane. `latency` and `price` remain +Host routing defaults to `simple`: an unpinned OpenRouter call carries no host +object, while a pinned call asks for exactly that host. `latency` and `price` remain opt-in settings. The default daily rail is `$500`; setting that row to `0` removes it. First run asks for diff --git a/docs/STANDING-ORDERS.md b/docs/STANDING-ORDERS.md index 2fcf139cea..befb32afae 100644 --- a/docs/STANDING-ORDERS.md +++ b/docs/STANDING-ORDERS.md @@ -70,7 +70,11 @@ opened — words as the epigraph, brief as the mechanism. **D3 — Grant.** One prose sentence on `Item`: what acting on this order may do without asking. Empty means say-only (every pre-existing item). Wave 1 stores -and displays it; wave 3's judgment is bounded by it. +and displays it; wave 3's judgment is bounded by it. A task's separate +`Does.Isolate` setting selects a Git worktree and is displayed before approval. +The grant is permission prose, not an execution-mode parser. Each isolated +firing keeps its branch, working directory and recovery record, including +uncommitted files. See the chat manual's scheduled-branch section for limits. **D4 — Exceptions.** `[]Exception` on `Item`, each naming exactly one workspace OR one session. Made by the person only, from either direction (the diff --git a/docs/changes/unreleased/1500-traffic-age-and-jump.md b/docs/changes/unreleased/1500-traffic-age-and-jump.md new file mode 100644 index 0000000000..4268413690 --- /dev/null +++ b/docs/changes/unreleased/1500-traffic-age-and-jump.md @@ -0,0 +1,9 @@ +--- +kind: added +title: a Traffic row shows how long ago it happened, and a press opens that message +pr: 1500 +surface: [chat, docs] +invalidates: + - "A Traffic row had no age on it (it was on the hint line only) and only a press on a handle did anything. Each row now ends with a dim age, and a press anywhere on the row opens the conversation the message belongs to, at that message." + - "Jumping to a completed team post could locate its entry while leaving it hidden inside collapsed activity. A deliberate Traffic jump now opens the enclosing activity and call groups before scrolling; other history stays collapsed." +--- diff --git a/docs/changes/unreleased/1503-traffic-nits.md b/docs/changes/unreleased/1503-traffic-nits.md new file mode 100644 index 0000000000..aa4662656a --- /dev/null +++ b/docs/changes/unreleased/1503-traffic-nits.md @@ -0,0 +1,6 @@ +--- +kind: fixed +title: a reminder's time reads with a middle dot, and Traffic ages stay short past a month +pr: 1503 +surface: [chat] +--- diff --git a/docs/changes/unreleased/1513-providers-unification.md b/docs/changes/unreleased/1513-providers-unification.md new file mode 100644 index 0000000000..b780059e33 --- /dev/null +++ b/docs/changes/unreleased/1513-providers-unification.md @@ -0,0 +1,26 @@ +--- +kind: changed +title: unify provider vocabulary, background-list connected provider models, and ease adding providers +pr: 1513 +surface: + - chat + - docs +invalidates: + - "the entity serving models was called service or connection; it is now called provider everywhere." + - "the openrouter routing target was called provider; it is now called host." + - "external tools were called connections; they are now accounts." + - "/model only listed models from OpenRouter on launch; now every connected provider lists models in the background." +--- + +Unifies model-serving vocabulary across UI, settings, manual, and CLI on 'provider', +uses 'host' for OpenRouter routing destinations, warms cold provider caches at launch +off the event loop, enables ctrl+r multi-provider refresh, and adds loopback port +probing for local model servers. + +Provider refreshes now reach each open picker through a lifetime-scoped, synchronized +subscription, including default-provider and failed listings. The add-provider list +keeps its selected row visible in short terminals and keeps the choice stable when +local discovery finishes. Custom addresses are checked off the UI loop before the +name/key steps; cancelled or superseded checks cannot reopen an old entry, and only +an authentication refusal asks for a key. Existing credentials survive edits when +a server exposes its model list publicly. Provider menus offer supported actions. diff --git a/docs/changes/unreleased/1560-standing-branch-isolation.md b/docs/changes/unreleased/1560-standing-branch-isolation.md new file mode 100644 index 0000000000..40a7fd1c2c --- /dev/null +++ b/docs/changes/unreleased/1560-standing-branch-isolation.md @@ -0,0 +1,8 @@ +--- +kind: fixed +title: standing execution enforces git branch isolation and worktree validation +pr: 1560 +surface: [engine] +invalidates: + - "Standing task firings claiming branch isolation or granted permission only for branch commits could execute git commits directly onto the host workspace branch. The approved does.isolate setting now selects a dedicated Git branch and worktree. Permission and reply prose no longer decide isolation. Uncommitted work and its recovery record are retained. An unchanged isolated firing reports no changes instead of a landing; its retained copy is recorded before worker initialization so failed startup cannot lose its recovery identity. Isolated tasks cannot bypass the scheduled worktree through an ordinary-turn Once answer." +--- diff --git a/docs/changes/unreleased/1596-standing-scoping-budget-activity.md b/docs/changes/unreleased/1596-standing-scoping-budget-activity.md new file mode 100644 index 0000000000..fb2ffb9613 --- /dev/null +++ b/docs/changes/unreleased/1596-standing-scoping-budget-activity.md @@ -0,0 +1,10 @@ +--- +kind: fixed +title: standing order project scoping normalization, live rail allowance words, and task activity labels +pr: 1596 +surface: [chat, engine] +invalidates: + - "Standing orders with project altitude previously failed workspace equality checks when comparing cleaned and uncleaned paths. Path cleaning is now normalized for project-scoped orders." + - "Standing proposal cards previously quoted stale default budget allowances. The engine now resolves each proposal against the current daily spend rail. Rendering quotes that snapshot without disk reads or parsing display text; explicitly named limits retain their words." + - "Standing card history rows previously displayed 'said:' for task action firings. They now display 'task:' for task executions." +--- diff --git a/docs/changes/unreleased/1604-blank-brief-extraction.md b/docs/changes/unreleased/1604-blank-brief-extraction.md new file mode 100644 index 0000000000..79cc6df78c --- /dev/null +++ b/docs/changes/unreleased/1604-blank-brief-extraction.md @@ -0,0 +1,10 @@ +--- +kind: fixed +title: blank terminal briefs stop before planning or model work +pr: 1604 +surface: [engine, docs] +invalidates: + - "An empty quoted brief could previously start model work with no goal. Empty and whitespace-only briefs are now rejected at the shared text-input boundary before planning, execution, or saved-plan creation." +--- + +Focused extraction for issue #1566. No other broad audit behavior is included. diff --git a/docs/changes/unreleased/1612-standing-once-handoff.md b/docs/changes/unreleased/1612-standing-once-handoff.md new file mode 100644 index 0000000000..78caf7e959 --- /dev/null +++ b/docs/changes/unreleased/1612-standing-once-handoff.md @@ -0,0 +1,9 @@ +--- +kind: fixed +title: one-time standing approvals preserve the action and report approval truthfully +pr: 1612 +surface: [chat, engine] +invalidates: + - "Choosing Only now previously settled the card as done before work ran and returned a generic instruction. It now records approval without completion and hands the full approved action back with an explicit pending execution state." + - "A one-time scheduled task previously used reminder labels. Its card now says Run it then and describes running work; say-only reminders keep their existing wording." +--- diff --git a/docs/changes/unreleased/1619-task-recovery-focused.md b/docs/changes/unreleased/1619-task-recovery-focused.md index 5ecb18a0d2..6e304b880f 100644 --- a/docs/changes/unreleased/1619-task-recovery-focused.md +++ b/docs/changes/unreleased/1619-task-recovery-focused.md @@ -22,3 +22,7 @@ does not promise exactly-once replay of arbitrary external operations. Graph and run admission read current persisted limits when created and follow later changes from another process, including the ordinary default profile. Absent settings preserve the supplied startup limits. + +Recovered tasks now retain their accepted crew pins, fallback history and spend +ceilings/tallies. Old records without that policy, or an unresolved model call, +stay interrupted instead of silently rerouting or resetting a spending limit. diff --git a/docs/changes/unreleased/1629-home-task-target.md b/docs/changes/unreleased/1629-home-task-target.md new file mode 100644 index 0000000000..ce91397ddd --- /dev/null +++ b/docs/changes/unreleased/1629-home-task-target.md @@ -0,0 +1,8 @@ +--- +kind: fixed +title: Home tasks keep the project displayed before submission +pr: 1629 +surface: [chat] +invalidates: + - "A task typed on Home could follow another project's newer conversation after the command cleared its draft. Slash commands now capture the displayed project before rebuilding Home, sharing the ordinary message opening path." +--- diff --git a/docs/changes/unreleased/1632-hosted-replay-owner.md b/docs/changes/unreleased/1632-hosted-replay-owner.md new file mode 100644 index 0000000000..52f40d1147 --- /dev/null +++ b/docs/changes/unreleased/1632-hosted-replay-owner.md @@ -0,0 +1,16 @@ +--- +kind: fixed +title: A reopened hosted conversation draws a completed reply once +pr: 1632 +surface: [chat, engine] +--- + +A team member could finish while its tab was hidden. Opening it replayed the +saved answer, then drew the same turn again from the hosted connection's buffered +stream. Atomic history replay now carries turn ownership, so the authoritative +observer and a duplicate canonical stream cannot both render that turn. New +turns, identical wording in a later reply, and a replacement engine remain +separate. This addresses #1649. + +Invalidates: channel identity alone is enough to distinguish history replay from +an independent observer of the same hosted turn. diff --git a/docs/changes/unreleased/1632-reviewed-workflow-fixes.md b/docs/changes/unreleased/1632-reviewed-workflow-fixes.md new file mode 100644 index 0000000000..3573d9fed9 --- /dev/null +++ b/docs/changes/unreleased/1632-reviewed-workflow-fixes.md @@ -0,0 +1,49 @@ +--- +kind: fixed +title: Home tasks, standing work, and traffic keep their context and navigation +pr: 1632 +surface: [chat, engine] +invalidates: + - "Every codeaf, devaf or stageaf sharing `~/.codeaf` reads every standing order. An isolated order is standing schema 2, and a build from before isolation skips it." +--- + +Home task drafts bind to the project currently displayed. Standing work respects +project scope, shows its allowance and activity, and hands off an approved +one-time run without accidentally creating a recurring schedule. Explicit branch +isolation preserves unfinished changes in the worker copy, while ordinary +one-time work continues in place. Standing timers have a single owner and +approval receipts reflect whether scheduling succeeded. Empty extracted briefs +are rejected before execution. + +Traffic rows show compact ages and open their exact message, including completed +work hidden inside a collapsed group. Reminder labels separate their cadence +from their title. Unknown timestamps remain absent. + +Held tasks now notice changed machine limits and retain their accepted request across +engine restarts, reusing the focused recovery fix from #1619. A persistent engine +restart also retires old reply streams, so a follow-up no longer disappears when +its new stream number matches a completed reply from the previous engine. + +Held tasks follow changed admission limits and resume with their accepted folder, +crew pins, fallback history, and spending limits after a restart. Records without +complete recovery policy or with unresolved model calls remain interrupted. A +reopened conversation replaces stale connection streams when its owner changes, +so new replies and replay cursors belong to the current session. + +New-member Traffic roots now open their accepted start call, including calls +inside collapsed work. New start receipts carry the root message number; older +receipts use only an unambiguous current-team match. Delivered member briefs +remain navigable from the same root. + +An isolated standing order is written as standing schema 2, so an older codeaf, +devaf or stageaf sharing the same home skips it instead of firing it in the +person's own checkout; every other order stays at schema 1 and older builds keep +firing it. A Traffic row now opens its own team's message even when the same +conversation sent a `team_send` into another team that carries the same number. + +A closed conversation's sweep keeps a landed task copy that gained files, edits +or commits after its cleanup failed, instead of retiring it with that work. A +Traffic reply row opened on the default engine road waits for the member's +conversation to finish arriving before it lands. On the default engine road, +`ctrl+r` in `/model` refreshes every connected provider and a provider with an +empty cached list is fetched once at launch, as it already was under `--no-host`. diff --git a/docs/changes/unreleased/1651-furrow-task-retirement.md b/docs/changes/unreleased/1651-furrow-task-retirement.md new file mode 100644 index 0000000000..502869be96 --- /dev/null +++ b/docs/changes/unreleased/1651-furrow-task-retirement.md @@ -0,0 +1,14 @@ +--- +kind: fixed +title: Completed task forks retire their Furrow timelines before their directories +pr: 1651 +surface: [engine] +invalidates: + - "Task cleanup treated Furrow's keep-files option as timeline retirement. It retains history; completed task forks now retire before their files disappear." + - "A sweep previously removed the task checkpoint even when Furrow cleanup failed. It now preserves the copy and checkpoint for a later retry." +--- + +Work kept for review retains its fork and timeline. Failed cleanup after a +successful merge is reported and retried from the saved task record when the +conversation is closed. Previously orphaned timelines are not automatically +purged. diff --git a/docs/design/conversations-and-teams/DESIGN.md b/docs/design/conversations-and-teams/DESIGN.md index 5947b470ed..0a29f198be 100644 --- a/docs/design/conversations-and-teams/DESIGN.md +++ b/docs/design/conversations-and-teams/DESIGN.md @@ -379,19 +379,31 @@ them. (`+ /task`, standing, jobs, `ctrl+. earlier`) and a run's plan rows stay. - **Traffic in a manager's chat is work**: one row per thread (`teams.Threads`), and every row reads `from → to words`. The manager is `◆`, several recipients are `@scrape +2`, and the - words follow (`◆ → @scrape +2 Please provide a st… ▸`). The state its answers leave it in - and its message count give way to the words when the column is narrow; the hint says them. - The age is never on the row, at any width: the hint line says it before its click, and the - band says it the same way, so the arrow and the names keep their cells. A narrow column cuts - only the words, at a word, with `…`. A band row is `? @model → ◆ keep the old schema?`, - amber, and a task row in the band is still cut at a clause, never leaving ` · …`. `▸` - lays the replies open as `↳ @model → ◆ ✓ done, 3 files changed` (events folded into the - member's line, the clock on the hint), and every unthreaded line under one `General` thread, - its open lines in the same `from → to`. A thin `new` line - marks what arrived since the Traffic was last in front and holds still while it is read. A - handle opens its member at the message; the rest of a row brings the message into view in - the chat in front. **In a member's chat** it is the messages to or from that member, or to - everyone, one line each, and that member is `you` (`◆ → you`, `you → ◆`, `you → @gravity`). + words follow, and how long ago at the right (`◆ → @scrape +2 Please provide a st… ▸ 2m`). + The age is `now`, `2m`, `3h`, `1d`, the same ladder a task row and a home session use + (`sinceAt`) through a day, dim, on every kind of row: a thread, a `↳` reply, a band question, General, + and a member's own lines. Past thirty days a task row and a home session print a date + (`sinceAt`). A Traffic row stays compact (`trafficAgeAt`): `30d`, weeks from six weeks + (`6w`, `12w` at ninety days), then years (`1y` at four hundred days). It moves when the minute in the row cache moves, and nowhere + else. The state its answers leave it in and its message count give way to the words when + the column is narrow; the hint says them. The arrow and the names keep their cells, then + the age, and a narrow column cuts only the words, at a word, with `…`. A band row is + `? @model → ◆ keep the old schema? 3m`, amber on the question and dim on the age, and a + task row in the band is still cut at a clause, never leaving ` · …`. `▸` lays the replies + open as `↳ @model → ◆ ✓ done, 3 files changed 4m` (events folded into the member's line, + the age on the row), and every unthreaded line under one `General` thread, its open lines + in the same `from → to` with the same age. `▸` and `▾` stay the expand door. The rest of + the row is the jump. A thin `new` line marks what arrived since the Traffic was last in + front and holds still while it is read. A handle opens its member at the message. A press + anywhere else on the row opens the conversation the message belongs to, at that message, + lifted the way a jump already lifts one: a message the sender wrote opens the sender's + chat (`◆ → all` opens the manager at the directive, `you → ◆` in a member's chat scrolls + that chat), and a message to the person opens the manager's chat. Already in front, it + scrolls in place. It does not take the keyboard and it does not open a new window. The + hint is `Open ◆'s message · 2m ago · click`. The hover ground is the whole row, padding + included. **In a member's chat** it is the messages to or from that member, or to + everyone, one line each, and that member is `you` (`◆ → you 2m`, `you → ◆ now`, + `you → @gravity 3h`). - **Geometry.** The column is a quarter of the frame, 28 to 40 columns (30 at 120), from 100 columns up while the conversation keeps 56; `alt+w` adds 16 from 120 up. It is the same in both views and every kind of chat, so switching, folding, the band and new rows never move @@ -1518,7 +1530,7 @@ depth block is not shown and the store's own `SetParent` is the only check. A proposal card names its kind on the first line, then what it does, then when and what one time costs. The words live in `session.StandingOptions` so the conversation, home, `--host` and the recorded `labels` say the same thing. -- A reminder (`when.at`): `wants to remind you`. `Remind me `, `Change…`, `Don't remind me`. No once. +- A reminder (`when.at`): `wants to remind you`. `Remind me `, `Change…`, `Don't remind me`. No once. A distance from now is said back with the clock it landed on, joined by a middle dot: `Remind me in 1 minute · 07:35`. - A repeating check (`when.every`): `wants to set up a repeating check`. `Set it up · `, `Change…`, `Only now, don't repeat`, `Don't set it up`. - A watch (file, idle, probe): `wants to watch for something`. `Watch for it`, `Change…`, `Check once now`, `Don't watch`. - A rule (`when.hold`): `wants to keep a rule`. `Keep this rule`, `Change where…`, `Don't keep it`. No once. diff --git a/internal/config/custom_anonymous_test.go b/internal/config/custom_anonymous_test.go new file mode 100644 index 0000000000..e4240f989c --- /dev/null +++ b/internal/config/custom_anonymous_test.go @@ -0,0 +1,66 @@ +package config + +import ( + "context" + "testing" + + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/modelsource/sourcestub" +) + +func TestAnonymousCustomConnectionSurvivesReloadWithoutWeakeningVendors(t *testing.T) { + server := sourcestub.New("local-model") + defer server.Close() + dir := t.TempDir() + row := PrepareCustomSource(dir, server.URL(), "local") + row.KeyOptional = true + outcome, err := ConnectService(context.Background(), dir, row, vendoredSource(t, modelsource.CustomID), nil) + if err != nil || outcome.Kind != modelsource.OutcomeConnected { + t.Fatalf("connect = %+v, %v", outcome, err) + } + connected, ok := ResolveSources(dir, "", DefaultBaseURL).ByID(row.ID) + if !ok || !connected.Source.KeyOptional || connected.Key != "" { + t.Fatal("anonymous custom connection was not retained") + } + t.Setenv("ZHIPU_API_KEY", "") + outcome, err = ConnectService(context.Background(), t.TempDir(), PersistedSource{ID: "z-ai", Written: "z-ai", KeyOptional: true}, vendoredSource(t, "z-ai"), nil) + if err != nil || outcome.Kind != modelsource.OutcomeWrongShape { + t.Fatalf("vendor accepted anonymous override: %+v, %v", outcome, err) + } +} + +func TestAnonymousCustomFlagDoesNotChangeOlderRows(t *testing.T) { + server := sourcestub.New("local-model") + defer server.Close() + dir := t.TempDir() + row := PrepareCustomSource(dir, server.URL(), "local") + source := vendoredSource(t, modelsource.CustomID) + outcome, err := ConnectService(context.Background(), dir, row, source, nil) + if err != nil || outcome.Kind != modelsource.OutcomeWrongShape || len(PersistedSources(dir)) != 0 { + t.Fatal("old row implicitly became key optional") + } + row.Key = "existing-key" + if err := WriteSources(dir, []PersistedSource{row}); err != nil { + t.Fatal(err) + } + connected, ok := ResolveSources(dir, "", DefaultBaseURL).ByID(row.ID) + if !ok || connected.Source.KeyOptional || connected.Key != row.Key { + t.Fatal("legacy authenticated row changed on reload") + } +} + +func TestAnonymousCustomConnectionDoesNotSaveAnAuthorizationRefusal(t *testing.T) { + server := sourcestub.New("local-model") + defer server.Close() + server.Refuse(401, "key required") + dir := t.TempDir() + row := PrepareCustomSource(dir, server.URL(), "local") + row.KeyOptional = true + outcome, err := ConnectService(context.Background(), dir, row, vendoredSource(t, modelsource.CustomID), nil) + if err != nil { + t.Fatal(err) + } + if outcome.Kind == modelsource.OutcomeConnected || len(PersistedSources(dir)) != 0 { + t.Fatal("failed anonymous check persisted a connection") + } +} diff --git a/internal/config/settings.go b/internal/config/settings.go index 4d629920a4..5823d60d0f 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -2114,16 +2114,16 @@ func (s *Settings) build() []Setting { Setting{ Key: KeyRouting, Category: CategoryModels, Kind: SettingChoice, Label: "routing", Choices: RoutingModes, - Hint: "one model id is served by many providers, and they answer at very " + + Hint: "one model id is served by many hosts, and they answer at very " + "different speeds AND very different prices. Left alone — simple — codeaf " + - "sends no preference of its own at all: with no provider pinned the router's own " + - "default routing answers, and a provider you pinned is the whole request, that " + - "provider and no fallbacks. Choosing another word here changes that " + + "sends no preference of its own at all: with no host pinned the router's own " + + "default routing answers, and a host you pinned is the whole request, that " + + "host and no fallbacks. Choosing another word here changes that " + "everywhere: latency asks " + - "for the fastest provider for every call, capped at a quarter over the " + + "for the fastest host for every call, capped at a quarter over the " + "model's list price, and times every answer, demoting one that keeps being " + "slow; price asks for the cheapest for every call; off asks for nothing and " + - "measures nothing — and with nothing measured there is no provider to choose, " + + "measures nothing — and with nothing measured there is no host to choose, " + "no sheet of them to open and no speed guard. A change lands on " + "the next session.", read: func() string { return RoutingAt(dir) }, @@ -2142,7 +2142,7 @@ func (s *Settings) build() []Setting { "goes lean. lean takes one section off the page, leaves seven verbs one " + "load_capability call away, puts ask straight in the list, turns saved " + "memories off and cuts the project's own instructions to 2KiB. full sends " + - "everything. Choose one of those two when the provider reports a window its " + + "everything. Choose one of those two when the host reports a window its " + "model does not really have. A change lands the next time codeaf starts.", read: func() string { return PromptProfileAt(dir) }, write: func(raw string) error { return writeChoice(dir, KeyPromptProfile, raw, PromptProfileModes) }, @@ -2152,15 +2152,15 @@ func (s *Settings) build() []Setting { // to, for the person who has watched the numbers and knows. Setting{ Key: LaneSettingKey(LaneSlotTalk), Category: CategoryModels, Kind: SettingText, - Label: "provider", EmptyLabel: LaneAuto, - Hint: "which provider answers your model, for requests from this home. One model id is served by " + - "a dozen providers that differ by seven times on the wait before the first " + + Label: "host", EmptyLabel: LaneAuto, + Hint: "which host answers your model, for requests from this home. One model id is served by " + + "a dozen hosts that differ by seven times on the wait before the first " + "word, so this is often a bigger change than switching model. auto lets the router " + - "route — and codeaf takes over choosing the provider when its answers start coming " + + "route — and codeaf takes over choosing the host when its answers start coming " + "back refused or unusable, handing it back once it has been well for a while; " + "a name — `cloudflare` — pins it and nothing else is asked; " + "`pinned: cloudflare, borrow when slow` keeps the pin but lets " + - "a slow answer be rescued elsewhere; openrouter asks for no provider at all and " + + "a slow answer be rescued elsewhere; openrouter asks for no host at all and " + "lets the router balance on price, with no takeover. enter on this row opens them with what " + "has been measured of each, and so does → on a model row in the picker — " + "under /model and under `your model` in the settings panel alike.", @@ -2170,7 +2170,7 @@ func (s *Settings) build() []Setting { Setting{ Key: KeyLaneGuard, Category: CategoryModels, Kind: SettingBool, Label: "speed guard", - Hint: "when an answer takes much longer to start than that provider normally " + + Hint: "when an answer takes much longer to start than that host normally " + "does, the same question is asked of the next-best one and whichever replies " + "first is the one you read. It hedges at most one extra call, under a tenth of " + "spend; off under price routing.", @@ -2298,7 +2298,7 @@ func (s *Settings) build() []Setting { Label: "tasks at once", EmptyLabel: "no limit", Unit: UnitInLabel, Hint: "how many tasks may run at the same time. Blank is no limit, which is the " + "default: what actually runs out is this machine — the two rows below hold new " + - "tasks back when it is loaded — and the model provider's own rate limit, which " + + "tasks back when it is loaded — and the model host's own rate limit, which " + "codeaf already paces itself against. A cap is a queue, never a refusal.", read: func() string { if value := TaskParallelAt(dir); value > 0 { diff --git a/internal/config/sources.go b/internal/config/sources.go index 6229bc068d..f2a2465f3e 100644 --- a/internal/config/sources.go +++ b/internal/config/sources.go @@ -35,6 +35,9 @@ type PersistedSource struct { Address string `json:"address,omitempty"` Key string `json:"key,omitempty"` KeyEnv string `json:"key_env,omitempty"` + // KeyOptional records an explicitly checked anonymous custom endpoint. + // Vendored providers never inherit this per-instance choice. + KeyOptional bool `json:"key_optional,omitempty"` // Door is the billing road explicitly proved at connect time. Empty belongs // to a pre-door row and resolves to its old metered address, never to a new // subscription road that has not been proved for that key. @@ -85,6 +88,7 @@ func WriteSources(profileDir string, rows []PersistedSource) error { } if !modelsource.IsCustomID(row.ID) { row.Address = "" + row.KeyOptional = false } else { row.Address = strings.TrimSpace(row.Address) } @@ -194,6 +198,9 @@ func resolveSources(defaultKey, defaultBase string, rows []PersistedSource, keyA source, ok = template, true } source.ID = strings.TrimSpace(row.ID) + if modelsource.IsCustomID(source.ID) { + source.KeyOptional = row.KeyOptional + } if written := strings.TrimSpace(row.Written); written != "" { source.Written = written } @@ -381,6 +388,9 @@ func ConnectService(ctx context.Context, profileDir string, row PersistedSource, if written := strings.TrimSpace(row.Written); written != "" { src.Written = written } + if modelsource.IsCustomID(row.ID) && modelsource.IsCustomID(src.ID) { + src.KeyOptional = row.KeyOptional + } key := SourceKeyAt(profileDir, row, src) if key != "" { // THE SECRET ARRIVES BEFORE THE RESULT. A service may echo a submitted diff --git a/internal/e2e/tuiwords_test.go b/internal/e2e/tuiwords_test.go index 47cffaa190..46efffaf49 100644 --- a/internal/e2e/tuiwords_test.go +++ b/internal/e2e/tuiwords_test.go @@ -426,7 +426,7 @@ var tuiWords = map[string]tuiWord{ "an offer whose key was cut is a question nobody can answer", }, "phaseAllSlowWord": { - screen: "all providers slow", + screen: "all hosts slow", why: "every reachable provider is believed slow, so there is nowhere better to be", }, "phaseWaitingWord": { diff --git a/internal/furrow/retirement_test.go b/internal/furrow/retirement_test.go new file mode 100644 index 0000000000..f3a282e272 --- /dev/null +++ b/internal/furrow/retirement_test.go @@ -0,0 +1,75 @@ +package furrow + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDropForkChecksIdentityAndRemovalReceipt(t *testing.T) { + for _, mode := range []string{"ok", "mismatch", "missing", "symlink", "json-error", "retained", "invalid", "absent", "children"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + destination := filepath.Join(root, "child") + if mode != "missing" { + if err := os.Mkdir(destination, 0700); err != nil { + t.Fatal(err) + } + } + if mode == "symlink" { + os.Remove(destination) + if err := os.Symlink(t.TempDir(), destination); err != nil { + t.Fatal(err) + } + } + listed := destination + if mode == "mismatch" { + listed = t.TempDir() + } + row, _ := json.Marshal([]map[string]string{{"name": "task", "destination": listed}}) + if mode == "absent" { + row = []byte("[]") + } + t.Setenv("RETIREMENT_ROWS", string(row)) + t.Setenv("RETIREMENT_ROOT", root) + t.Setenv("RETIREMENT_MODE", mode) + binary := filepath.Join(root, "furrow") + script := `#!/bin/sh +case "$4" in +forks) + if [ "$2" != "$RETIREMENT_ROOT" ] && [ "$RETIREMENT_MODE" != "children" ]; then echo '[]'; exit 0; fi + printf '%s\n' "$RETIREMENT_ROWS" ;; +fork-rm) + printf '%s\n' "$*" > "$2/called" + case "$RETIREMENT_MODE" in + json-error) echo '{"error":"refused"}'; exit 1 ;; + retained) echo '{"files_removed":false}' ;; + invalid) echo '{}' ;; + *) echo '{"files_removed":true}' ;; + esac + ;; +esac +` + if err := os.WriteFile(binary, []byte(script), 0700); err != nil { + t.Fatal(err) + } + workspace := &Workspace{root: root, binary: binary} + err := workspace.DropFork(context.Background(), "task", destination) + wantError := mode != "ok" && mode != "absent" + if (err != nil) != wantError { + t.Fatalf("DropFork error = %v, want error %v", err, wantError) + } + call, readErr := os.ReadFile(filepath.Join(root, "called")) + if mode == "mismatch" || mode == "missing" || mode == "symlink" || mode == "absent" || mode == "children" { + if !os.IsNotExist(readErr) { + t.Fatalf("unsafe removal was called: %s", call) + } + } else if readErr != nil || strings.Contains(string(call), "--keep-files") { + t.Fatalf("retirement command = %q: %v", call, readErr) + } + }) + } +} diff --git a/internal/furrow/workspace.go b/internal/furrow/workspace.go index 7490ec5dd4..770c7dd036 100644 --- a/internal/furrow/workspace.go +++ b/internal/furrow/workspace.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "os" "path/filepath" "strconv" "strings" @@ -394,7 +395,7 @@ func (w *Workspace) Forks(ctx context.Context) ([]Fork, error) { defer cancel() stdout, stderr, err := w.run(ctx, "--json", "forks") - if err != nil && len(documents(stdout)) == 0 { + if err != nil { return nil, failure(stderr, err) } var rows []struct { @@ -684,32 +685,56 @@ func (w *Workspace) Fork(ctx context.Context, name, destination string) (Fork, e return fork, nil } -// DropFork tells furrow to forget one universe while LEAVING ITS FILES ALONE. -// -// The two halves are separated on purpose. Whoever asked for the fork owns the -// directory — for a task that is the session, which removes its own trees when -// the work has landed — and a furrow that deleted those files from under it -// would be a second owner of one directory. What furrow is asked to drop is the -// record and the timeline, so that `furrow forks` does not fill up with the -// universes of every task this machine has ever run. -// -// A FAILURE IS REPORTED AND NEVER DECIDED ABOUT HERE, because what one is worth -// depends entirely on who asked. A landing has already put the work in, so a -// record furrow would not drop costs it one line in a listing and it says -// nothing about it. The sweep that reaps a session nobody landed is the last -// thing on the machine that will ever know this fork's name, and the directory -// the record points at is about to go — so it writes the miss down -// (internal/session/sweep.go). One door, two readings, and neither of them is -// this package's to make. -func (w *Workspace) DropFork(ctx context.Context, name string) error { - if name = strings.TrimSpace(name); name == "" { - return nil +// DropFork retires a task's files and timeline together. Furrow's keep-files +// option retains the timeline too, so callers must retire before deleting files. +// The expected destination prevents a stale checkpoint from deleting another fork. +func (w *Workspace) DropFork(ctx context.Context, name, destination string) error { + if strings.TrimSpace(name) == "" || strings.TrimSpace(destination) == "" { + return fmt.Errorf("furrow: retiring a fork needs its name and destination") } - ctx, cancel := context.WithTimeout(ctx, readTimeout) - defer cancel() - stdout, stderr, err := w.run(ctx, "--json", "fork-rm", name, "--keep-files") - if err != nil && len(documents(stdout)) == 0 { - return failure(stderr, err) + forks, err := w.Forks(ctx) + if err != nil { + return err + } + for _, fork := range forks { + if fork.Name != name { + continue + } + if !filepath.IsAbs(destination) || filepath.Clean(fork.Path) != filepath.Clean(destination) { + return fmt.Errorf("furrow: fork %s has a different destination", name) + } + info, err := os.Lstat(destination) + if err != nil { + return fmt.Errorf("furrow: cannot retire fork %s: %w", name, err) + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("furrow: fork %s is not a directory", name) + } + // A retained child still needs this ground to retire its own timeline. + children, err := (&Workspace{root: destination, binary: w.binary}).Forks(ctx) + if err != nil { + return err + } + if len(children) != 0 { + return fmt.Errorf("furrow: fork %s still has child forks", name) + } + ctx, cancel := context.WithTimeout(ctx, wholeWorkspaceTimeout) + defer cancel() + stdout, stderr, err := w.run(ctx, "--json", "fork-rm", name) + if err != nil { + return failure(stderr, err) + } + var receipt struct { + FilesRemoved bool `json:"files_removed"` + } + if err := decodeLast(stdout, &receipt); err != nil { + return err + } + if !receipt.FilesRemoved { + return fmt.Errorf("furrow: fork %s was not retired", name) + } + return nil } + // A previous successful retirement is safe to retry. return nil } diff --git a/internal/manual/chat/accounts.md b/internal/manual/chat/accounts.md index 9216d6fdf7..2b7dff07aa 100644 --- a/internal/manual/chat/accounts.md +++ b/internal/manual/chat/accounts.md @@ -1,15 +1,15 @@ # Connected accounts codeaf can act on accounts you already hold — your mail, your calendar, your Notion -pages, a billing service you have a key for. This page covers what connecting one +pages, a billing account you have a key for. This page covers what connecting one gives codeaf, how to connect, what you can turn on and off per account, and where the keys are kept. A connected account gives codeaf tools it may use in your name; a -connected model service is a place models come from and is covered by the -[services page](services.md). +connected model provider is a place models come from and is covered by the +[providers page](accounts.md). ## What a connected account is -A connected account is a service codeaf holds a credential for. Connecting one does +A connected account is an account codeaf holds a credential for. Connecting one does two things: it stores the credential in your profile directory, and it puts that account's tools on codeaf's toolbelt so the model can call them. @@ -21,8 +21,8 @@ What arrives depends on the account: `slack_list_channels`, `slack_send`. - **A key account** brings exactly one tool, `_request` — `stripe_request`, for example — taking `method` (get, post, put, patch, delete; default get), `path`, - `query` and `body`. The path is always relative to the service's own address. An - absolute address is refused with `the path is relative to the service's own + `query` and `body`. The path is always relative to the account's own address. An + absolute address is refused with `the path is relative to the account's own address, not a whole address of its own`, because an absolute one would send your key to a host nobody vouched for. - **A tool server** brings whatever tools it serves. @@ -34,9 +34,9 @@ response bodies are read up to 8 MiB. An account with no tools in this build answers ` is connected, and this build has no tools for it. Do the work without it and say so plainly.` -## How many services can be connected +## How many accounts can be connected -**129 services register in this build: 99 are connected with a pasted key, and 30 are +**129 accounts register in this build: 99 are connected with a pasted key, and 30 are connected in a browser.** The 30 browser ones are **Google** (Gmail and Calendar), **Slack**, and the 28 tool servers @@ -44,18 +44,18 @@ The 30 browser ones are **Google** (Gmail and Calendar), **Slack**, and the 28 t Datadog, GitLab, Grafana, Heroku, Hugging Face, Klaviyo, LaunchDarkly, Linear, Miro, Neon, Netlify, Notion, PayPal, PostHog, Postman, Railway, Sanity, Sentry, Supabase** and **Todoist**. -The 99 key services come from the bundled connectors catalog and are filed under +The 99 key accounts come from the bundled connectors catalog and are filed under eleven categories: `crm`, `support`, `billing`, `marketing`, `sales & outreach`, `calls & meetings`, `analytics`, `hr & recruiting`, `developer`, `productivity`, -`communication`. A service with none of these is shown under `other`. +`communication`. An account with none of these is shown under `other`. Every menu is ordered by name, case-insensitive — never registration order. Google and Slack both ship with the application their browser sign-in needs, so both are listed on a fresh install. The `google_oauth_client` and `slack_oauth_client` settings replace those shipped applications for somebody who wants their own. A -browser service with no application configured is not listed at all: no greyed row, -no explanation. Key services and tool servers need nothing configured and are always +browser account with no application configured is not listed at all: no greyed row, +no explanation. Key accounts and tool servers need nothing configured and are always listed. ## The two ways to sign in: Google and Slack in a browser, or a pasted key @@ -67,7 +67,7 @@ connected, when, and that it was you — it does not carry the key, and neither does anything the model is sent, anything another window is told, or anything written to a log. -Browser accounts open the service's sign-in page; key accounts collect a key in the +Browser accounts open the account's sign-in page; key accounts collect a key in the message box without starting a browser trip. Neither route writes a credential into the conversation. @@ -115,7 +115,7 @@ read; searching, listing channels and posting are not under that limit. ## Tool-server and Datadog browser questions **The 28 tool servers listed below need nothing registered first.** codeaf introduces -itself to the service at connect time and is issued an identity on the spot, then +itself to the account at connect time and is issued an identity on the spot, then makes the same browser trip. Some accounts ask one thing before they open. **Datadog asks which Datadog site your @@ -127,12 +127,12 @@ and renewals return to the same site. ## Signing in with a pasted key Nothing opens and nothing renews; the key is as good as the day it was made. Most -services want one key and nothing else. A few whose address contains your own -workspace want the workspace, one space, then the key — the service's own line says +accounts want one key and nothing else. A few whose address contains your own +workspace want the workspace, one space, then the key — the account's own line says so. Where the catalog names a cheap health check, the key is proved before anything is stored, and a refusal fails with the far end's own words and writes nothing. -Trying a browser sign-in on a key service errors with +Trying a browser sign-in on a key account errors with ` is connected with a key, not in a browser`. ## Naming an environment variable instead of pasting a key @@ -164,9 +164,9 @@ Connected accounts come first, flat and with no heading; everything else is grou under one dim lowercase category word, alphabetical, with `other` last. A tick marks an account you hold, a dim dot marks one you do not. The right-hand tail carries one fact: the account address when held, otherwise `key` or `sign in` on a long list, or -the service's blurb on a short one. +the account's blurb on a short one. -Past 10 available services the box under the list becomes a filter and the list +Past 10 available accounts the box under the list becomes a filter and the list narrows as you type; the placeholder reads `filter · ↑↓ · enter connect · esc close`. Under ten there is no filter box. The filter matches name and category, so typing `billing` reaches Stripe, Chargebee and Recurly. Accounts you already hold stay @@ -196,10 +196,10 @@ so the command instead gives the longer `--host` sentence and leaves the panel o Until 2026-09-11, a plain launch wrongly inherited that remote absence from the engine road and said `connections are unavailable here`. It now keeps this machine's -store, connected rows, model-service group and browser door. +store, connected rows, model-provider group and browser door. If `credentials.json` is damaged, accounts are absent but the `models` group still -draws; model-service settings live separately in the profile's `config.json`. +draws; model-provider settings live separately in the profile's `config.json`. ## What each account may be used for: yes, ask first, off @@ -229,7 +229,7 @@ change mid-conversation takes effect on the next call. default. **Where it is stored:** `connections.json` in your profile directory, as -`{service: {capability: "yes"|"ask"|"off"}}`. Only what you actually said is written — +`{account: {capability: "yes"|"ask"|"off"}}`. Only what you actually said is written — setting a control back to its default forgets the row rather than writing the default down, and forgetting the last answer deletes the file. A damaged or absent file reads as all defaults, never as an error. @@ -237,7 +237,7 @@ as all defaults, never as an error. ## What "off" does — the tool is not there at all Off is not a refusal at the gate. It is absence. The tool is left **off the belt -entirely**, the account's line in the `services` listing stops advertising it, and the +entirely**, the account's line in the `accounts` listing stops advertising it, and the conversation never learns the capability exists. The reason is plain: a refused call costs a turn, teaches the model to try again in different words, and puts a question in front of somebody who already answered it. @@ -262,20 +262,20 @@ safeguard in the gate catches that, checked first. The model then reads: the work without it and say so plainly; calling again, or calling it another way, will not change their answer.` -## The services and use_service tools — does it ask permission to run services +## The accounts and use_service tools — does it ask permission to run accounts Two tools are always on the belt when an accounts layer exists. -- **`services`** — lists what can be connected and what is connected already, with the +- **`accounts`** — lists what can be connected and what is connected already, with the address each is held as. Connected ones are written out in full; the rest are a block of ids only. It takes an optional `filter` argument. - **`use_service`** — picks up one account's tools. An optional `tools` argument names a subset. -On the shipped default, neither `services` nor `use_service` raises a tool-approval -question. `services` only lists accounts. `use_service` owns the connect card below, +On the shipped default, neither `accounts` nor `use_service` raises a tool-approval +question. `accounts` only lists accounts. `use_service` owns the connect card below, which is the one question about connecting; every tool it brings is still judged when -it is called. An explicit `services:prompt` or `use_service:prompt` rule still asks, +it is called. An explicit `accounts:prompt` or `use_service:prompt` rule still asks, and the `deny` default still refuses. **The account's tools are in your tool list from your very next request, which is still @@ -316,7 +316,7 @@ writes an account credential. ## Answering a use_service connect question with a key -**A key service asks for the key in that same message box.** There is no yes step — +**A key account asks for the key in that same message box.** There is no yes step — a bare yes to one of these is read as a decline anyway — so the question arrives with what to type written under it and the box below it collecting the answer: @@ -334,7 +334,7 @@ whole paste arrived. `enter` sends it. `2` is the way out. `esc` is later, and w typed stays in the box. An empty box and `enter` answers nothing at all — it used to be a decline, and now the way out is the answer that says so. -Where a service names its own instruction — Chargebee's `Give the site name and then +Where a account names its own instruction — Chargebee's `Give the site name and then the key, one space between them.` — that sentence is what the card says over the box, in place of the generic paste hint. @@ -364,7 +364,7 @@ decided. An empty box and `enter` answers nothing either — `enter` sends what box, and there is nothing in it. **And silence leaves the account unconnected after 5 minutes.** The model is told that -you did not answer, never that you refused. See *The services and use_service tools* +you did not answer, never that you refused. See *The accounts and use_service tools* above for the exact distinction. ## Connecting while the conversation is idle @@ -382,7 +382,7 @@ can be used for in this conversation.` ## MCP: accounts that bring their own tools -Some services run a server whose whole job is to hand a program a list of tools and +Some accounts run a server whose whole job is to hand a program a list of tools and run one when asked. codeaf fetches that list per account at the moment the account is picked up. You connect "Notion" — no protocol, server or grant is ever named in front of you. A tool server appears in `/connect` as a browser connection like any other, is @@ -416,7 +416,7 @@ Twenty-eight ship, each at the address on the vendor's own page: | PayPal | `https://mcp.paypal.com/http` | your payments, invoices and payouts | | PostHog | `https://mcp.posthog.com/mcp` | your events, insights and feature flags | | Postman | `https://mcp.postman.com/minimal` | your collections, specs and environments | -| Railway | `https://mcp.railway.com/` | your projects, services and deployments | +| Railway | `https://mcp.railway.com/` | your projects, accounts and deployments | | Sanity | `https://mcp.sanity.io` | your content, datasets and schemas | | Sentry | `https://mcp.sentry.dev/mcp` | your issues, events and releases | | Supabase | `https://mcp.supabase.com/mcp` | your projects, tables and queries | @@ -427,30 +427,30 @@ Airtable's adds: "An enterprise admin may have to allow it first." Postman's adds: "Postman's EU workspaces cannot be reached this way." — Postman's EU address signs in with a key and nothing else, so it is deliberately not shipped. -**All 28 work with zero registration.** codeaf introduces itself to the service at +**All 28 work with zero registration.** codeaf introduces itself to the account at connect time and is issued an identity on the spot, kept in `toolservers.json`. Keys -minted for one service cannot be spent at another. +minted for one account cannot be spent at another. **GitHub is deliberately not shipped** — its sign-in does not let a program introduce itself, and its maintainers say that will not change, so it can return only with an application registered by hand in a later wave. Slack now signs in through a browser with the application codeaf ships; the Slack paragraph above describes that trip. Any -service whose sign-in refuses an introduction cannot be connected this way at all, and +account whose sign-in refuses an introduction cannot be connected this way at all, and codeaf says so in one sentence the moment you ask. -An identity is reused only when the service address, the issuer, the resource and the +An identity is reused only when the account address, the issuer, the resource and the loopback port all still match. The registration file survives a disconnect, so reconnecting is one browser trip and not a second registration. ## How MCP tool names are built, and how many can be armed -A served tool is named **`_`**. Notion's +A served tool is named **`_`**. Notion's `Create Page` becomes `notion_create_page`, and two accounts both serving `search` become `notion_search` and `linear_search`. Folding is lower case, letters, digits and single underscores; everything else reads as a word break, so `Create Page`, `create-page` and `create.page` all fold to `create_page`. The fold loses information -on purpose, so the service's own spelling is kept beside the belt name and the call is -always made with the service's spelling. Names are cut at **64 characters**. +on purpose, so the account's own spelling is kept beside the belt name and the call is +always made with the account's spelling. Names are cut at **64 characters**. Two names that fold to one are one name here: the first stands, the second is left off and named in the reply. A tool whose argument schema cannot be read is left off and @@ -461,11 +461,11 @@ arrives. Over it, **nothing does** — the reply names them all and tells the mo call `use_service` again with `tools` naming the few the work needs. Silent trimming was rejected outright: the model would plan around a list it was never told was cut. -The list a service gives is fetched once per run and remembered for the life of the -process, so a tool newly added at the service needs codeaf restarted. +The list a account gives is fetched once per run and remembered for the life of the +process, so a tool newly added at the account needs codeaf restarted. Each call opens a connection, does its one thing and closes it. The outer backstop is -2 minutes. A tool that refuses comes back as an error carrying the service's own +2 minutes. A tool that refuses comes back as an error carrying the account's own sentence. Images, sounds and resources come back named — `[an image]`, `[a sound]` — rather than as bytes. @@ -479,8 +479,8 @@ allow. That covers: verb: a `GET`, or no arguments at all, is a read; every other method acts. **An argument payload that cannot be read counts as one that acts**, because the safe reading of "I do not know" is the one that asks; -- any tool an MCP server serves that the service did not mark read-only. Absent means - false, and false is the stricter reading: a service that says nothing has not +- any tool an MCP server serves that the account did not mark read-only. Absent means + false, and false is the stricter reading: a account that says nothing has not promised its tool only looks. **No blanket setting turns this off.** Setting the approval default to `allow`, or @@ -517,10 +517,10 @@ again, and you decide what to do next. ## Where your keys are kept on disk -Account keys and model-service keys are different stores. An account adds tools codeaf -may use in your name and keeps its credential in `credentials.json`; a model service is a +Account keys and model-provider keys are different stores. An account adds tools codeaf +may use in your name and keeps its credential in `credentials.json`; a model account is a place models come from and keeps its key in the profile `config.json`. The -[services page](services.md) covers those model keys. +[accounts page](accounts.md) covers those model keys. Everything the accounts layer writes lives in your profile directory — `$CODEAF_PROFILE_DIR` when set, otherwise codeaf's state root `$CODEAF_HOME` or @@ -568,7 +568,7 @@ literally: connecting an account is not available over --host yet — the sign-in opens a browser here and the account belongs to the machine over there. accounts already connected on that machine keep working. ``` -When the model raises the connect question over `--host`, a browser service says +When the model raises the connect question over `--host`, a browser account says `connecting an account is not available over --host yet` as its reason and offers only `2 not now`. The `1 connect` answer is not drawn at all, rather than drawn as an affordance that answers as a failure. diff --git a/internal/manual/chat/asking-from-home.md b/internal/manual/chat/asking-from-home.md index f7d4405577..50cee1db55 100644 --- a/internal/manual/chat/asking-from-home.md +++ b/internal/manual/chat/asking-from-home.md @@ -307,7 +307,7 @@ on home says `? waiting on you` for as long as it does. **The card stays after you answer it.** It does not disappear — it settles in place, greys out, and its bottom edge carries what was decided in the same words a card in a conversation -uses: `Set it up · Mondays at 9am · set up`, `Only now, don't repeat · done now, nothing kept`, +uses: `Set it up · Mondays at 9am · set up`, `Only now, don't repeat · approved once, not scheduled`, `Change… · you asked for something different`, `not set up`, `ended · nothing was set up`. The answers go, so `1`, `3`, `0` and `o` are ordinary characters again and can be typed into a follow-up. The only card that ever diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 9830bad720..1def96b137 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -157,7 +157,7 @@ Canonical word, the other words it answers to, its argument form, and what it do | `/model` | — | — | opens the model picker | | `/model` | — | `` | switches the model to that slug | | `/settings` | `/set`, `/config` | — | opens the fullscreen settings panel (also ctrl+,) | -| `/connect` | `/connections` | — | opens the connection panel; its `models` group holds model services, followed by connected accounts | +| `/connect` | `/connections` | — | opens the connect panel; its `providers` group holds model providers, followed by connected accounts | | `/new` | `/clear`, `/clean`, `/reset` | — | closes this session and starts a fresh one | | `/resume` | `/sessions` | — | opens the earlier-conversations picker | | `/compact` | — | — | summarizes the conversation now | @@ -895,8 +895,8 @@ place with a short list of models under it. It is bottom-anchored, so the conver shrinks above it and nothing pops up over what you were reading. Pressing the model's name on the legend line above the box opens the same picker. -Models from connected services sit under their service's name as a dim heading, default -service first; a custom connection's heading is the name you gave it. +Models from connected providers sit under their provider's name as a dim heading, default +provider first; a custom provider's heading is the name you gave it. `/model ` switches straight to that slug: no list, no confirmation, and no check that the slug exists in any list. If the slug is in no known list, the context window is @@ -946,7 +946,7 @@ falls back to `filter`. moment you type — which is exactly when you have found your model and want its providers. The foot follows the cursor and always reads in one order — the keys that move the **cursor**, then the ones that change the **list**, then `enter`, then the one key that is about neither: -`→ providers · alt+s sort · enter switch · ctrl+t effort · esc` on a model, +`→ hosts · alt+s sort · enter switch · ctrl+t effort · esc` on a model, `← back · alt+s sort · enter choose · esc` inside its providers — and `← back · alt+s sort · enter unpin · esc` on the provider you are already pinned to, where the same key takes the pin off again. While you are @@ -963,8 +963,9 @@ model a remote session opens on is that machine's to resolve. ## What the model picker lists, and what it will not do -The picker **never fetches on its own** — it fetches only when you ask, with `ctrl+r` (see -"Refreshing the model list" below). The list is what is already known, tried in this order, +At launch, codeaf fetches a connected provider's model list once if it has an empty cached list. +After that, `ctrl+r` asks for a fresh list (see "Refreshing the model list" below). +The list is what is already known, tried in this order, each rung used only when the one above it came back empty after filtering: 1. the catalog handed in at launch, @@ -1018,11 +1019,13 @@ Limits: back. `/new` forgets it. - There is no mouse commit on the picker's rows. -## Refreshing the model list — a new model is not in /model, the list is out of date +## Refreshing the model list — a new model came out but it is not in /model; the model list is out of date The list `/model` shows is fetched from the router at most once a day, so a model a provider shipped this morning may not be in it yet. With the picker open, press -**`ctrl+r`** to fetch the newest list now. The placeholder names it — `ctrl+r refresh` — +**`ctrl+r`** to fetch the newest list now from the router and every connected provider +that lists models. Each provider's group fills as its answer arrives; one provider's +failure does not stop the others. The placeholder names it — `ctrl+r refresh` — and when your filter matches nothing the list says `no model matches · ctrl+r fetches the newest list`. Nothing on screen shows how old the list is; when in doubt, press it. @@ -1556,15 +1559,16 @@ status sheet. Change that machine's profile there. ## /connect — your connected accounts -`/connect` (or `/connections`) opens the connection panel. Its pinned `models` group -holds the six built-in model services plus every one already connected; the account +`/connect` (or `/connections`) opens the connect panel. Its pinned `providers` group +holds the six built-in model providers plus every one already connected; the account catalog groups follow it. The Codex row says `browser`; enter opens the sign-in road and -the waiting card keeps the address available to copy. The other listed services say what -they need. Pick a row and connect it. There is no argument form. **Custom OpenAI-compatible API** connects a custom service: it asks for a +the waiting card keeps the address available to copy. The other listed providers say what +they need. Pick a row and connect it. There is no argument form. **Custom OpenAI-compatible API** connects a custom provider: it asks for a base URL, then a name of your own with the host's own spelling pre-filled (`127.0.0.1` -becomes `127-0-0-1`), then a key. Several custom connections sit beside each other, -each under its name; once one is connected an `add custom connection` row appears and -the **Custom OpenAI-compatible API** row becomes that connection's edit door. The +becomes `127-0-0-1`). It asks for a key only if the model-list address answers 401 or 403. +Several custom providers sit beside each other, +each under its name; once one is connected a `+ add a provider` row appears and +the **Custom OpenAI-compatible API** row becomes that provider's edit door. The [services page](services.md) covers model keys, and the accounts page covers what each account can do once it is connected. @@ -1700,7 +1704,7 @@ Refusals inside the panel, exactly as written: ## config.json keys are not read — why codeaf says a setting I wrote is ignored codeaf reads the top-level keys of your profile's `config.json` that a settings row or -the model-service setup owns. A key nothing reads — a hand-written `models` object, a +the model-provider setup owns. A key nothing reads — a hand-written `models` object, a spelling from another tool — does nothing, and the defaults apply in its place. (A key codeaf itself retired is passed over quietly rather than named.) So the conversation says so once, as a note: @@ -1769,7 +1773,7 @@ moved to Spending. The ssh rows are here because "what may codeaf reach on your behalf" is this tab's own question, and a link to another machine is that question asked about a machine rather -than about a service. They are not on the tab named **Connections**: that one is the +than about an account. They are not on the tab named **Connections**: that one is the catalog of third-party accounts you sign in to, and it is built from the account list rather than from the settings registry. @@ -1885,12 +1889,12 @@ worker, checker and planner have no rows of their own here: the one **seats** ro `/crew` panel, and a seat is pinned there or with `/crew pin` — a pin may carry a thinking level, `/crew pin planner moonshotai/kimi-k3:high`, and the seat is then asked at that level. -The connected model services have their own section on the tab, each with its billing -door, the safe spelling of its key, its region and its order. The section ends with an -`add custom connection` row, and once a custom connection is connected an `active -connection` row follows it: it reads +The connected model providers have their own section on the tab, each with its billing +door, the safe spelling of its key, its region and its order. The section ends with a +`+ add a provider` row, and once a custom provider is connected an `active +provider` row follows it: it reads `answering on localhost · enter moves it to homelab`, and enter moves this conversation -onto the next connection, wrapping past the last back to the first. The +onto the next provider, wrapping past the last back to the first. The [services page](services.md) has the whole of it. **Connections** — the accounts this profile has connected and what each may do. Its rows diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index b5452ade66..115e0581b5 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -287,8 +287,8 @@ place on the tab strip is the manager's, pinned at the left: a quiet `+ Manager` one and `◆ Manager` after; `◆ Make this harbor's manager` in the team switcher or in a tile's Teams list makes an existing conversation the manager. The right-hand column of a chat in a team has two words, `Tasks` and `Traffic` (what passes in the team). A -Traffic row reads who it is from and who it is for, `◆ → @scrape +2 Please provide…`, and in -a member's chat that member is `you` (`◆ → you`, `you → ◆`). The +Traffic row reads who it is from and who it is for, then how long ago, `◆ → @scrape +2 Please provide… 2m`, and in +a member's chat that member is `you` (`◆ → you`, `you → ◆`). A press on the row opens that message in the chat it belongs to. The manager's opens on the Traffic, a member's on its tasks, `←` `→` switch them while the column has the keyboard, `alt+l` shows or hides it, and `alt+m` goes to the manager. What a manager can do, and how members talk to each other, is on the **team manager** page. The **teams page** (`/teams`, `alt+2`) lists every team as a tree diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index 7f3fddba5e..9e2f2ec2b7 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -16,7 +16,7 @@ The first time `codeaf` opens on a profile with nothing in it, the chat does not an empty prompt and a provider error. It opens in the chat itself, on **two screens** — under a minute, nothing else on the frame: -1. **connect openrouter** — the default service; `enter` signs in in your browser, and pasting an existing key also works +1. **connect openrouter** — the default provider; `enter` signs in in your browser, and pasting an existing key also works 2. **Models and spending** — one screen with two controls on it, **Daily limit** and **Chat model**, each already showing the value that is in force @@ -29,10 +29,10 @@ screen. Its heading is `Models and spending` and the line under it is again. `esc` on the controls screen goes **back** to the connection when there is one behind it, and skips when the controls are the whole of the setup. A skip leaves one dim line naming the doors onto what it walked past: `still yours to set · /budget sets what -codeaf may spend · /model and /crew pick the models`. If the default OpenRouter service is +codeaf may spend · /model and /crew pick the models`. If the default OpenRouter provider is still not connected and the conversation is using one of its models, its one-step screen returns on the next local interactive launch because that model cannot work without it. A -conversation on a connected direct service's model does not owe OpenRouter a key, so that +conversation on a connected direct provider's model does not owe OpenRouter a key, so that step stays away. Codex is deliberately not another first-run step. After setup, its browser sign-in is @@ -49,19 +49,19 @@ sentence at a time and never half of one. The two values, `Start a conversation` the keyboard line are never given up, so a sixteen-row window still shows a screen you can answer and leave. -## Set up my api key — the default service's openrouter key step, and what happens with no key +## Set up my api key — the default provider's openrouter key step, and what happens with no key -On a local interactive launch using codeaf's built-in default model service, the first step reads +On a local interactive launch using codeaf's built-in default model provider, the first step reads *connect openrouter*. Press `enter`: codeaf opens OpenRouter in your browser, waits on a random return address bound only to `127.0.0.1`, and uses an S256 proof key for the trip. -After you sign in and approve it, OpenRouter makes a user-controlled API key for the default service in this +After you sign in and approve it, OpenRouter makes a user-controlled API key for the default provider in this profile and sends the browser back to codeaf. The browser says it is connected, the screen continues, and the running conversation can use the key immediately. No prompt is sent and no model is called during the connection. The address is also written on the waiting screen. If the browser cannot be opened, select or click that address yourself. `esc` while waiting cancels the return listener and leaves -you on the default service's OpenRouter step; another `enter` tries again. +you on the default provider's OpenRouter step; another `enter` tries again. ## What the setup screen says when something goes wrong @@ -83,7 +83,7 @@ those are written for you to read. What is never shown is the operating system's a failure: a path inside codeaf's own storage with an errno after it tells you nothing you can act on. -## Paste an existing OpenRouter API key for the default service instead of connecting in the browser +## Paste an existing OpenRouter API key for the default provider instead of connecting in the browser Already have a key? Paste it on the same first screen instead of pressing `enter` on an empty box. The key is masked while it is typed, and the manual-key address remains on the @@ -94,34 +94,34 @@ not accept is discovered by the first message you send. One that fails the shape leaves this line under the box and stays on the step: `not the shape of an openrouter key — they start with sk-or-`. -What it writes for the default service: the `api_key` field of your profile's `config.json` (under `~/.codeaf`), +What it writes for the default provider: the `api_key` field of your profile's `config.json` (under `~/.codeaf`), owner-readable only. That is the same field the **openrouter key** row on the settings panel's Providers tab writes, and the one every later launch reads. The running conversation takes it at once — the next message rides it, no restart. -## Skip the default OpenRouter service, retry later, and keep the message I typed +## Skip the default OpenRouter provider, retry later, and keep the message I typed -`esc` on the idle step skips setup. When the conversation is using the default service, it +`esc` on the idle step skips setup. When the conversation is using the default provider, it then says one dim line: `openrouter is not connected · enter on your message connects in a browser, or export OPENROUTER_API_KEY`. Your draft is not sacrificed to a provider error: type it normally and press `enter`, and the one-step connection opens over the conversation before the draft is cleared. Connect, then press `enter` again to send those same words. -When the conversation is on a connected direct service's model, pressing `enter` sends +When the conversation is on a connected direct provider's model, pressing `enter` sends those words instead. The OpenRouter step does not open and the missing-OpenRouter line is -absent, because that turn already has a service that can answer. +absent, because that turn already has a provider that can answer. -This default-service step also opens over an existing or resumed conversation and over a profile +This default-provider step also opens over an existing or resumed conversation and over a profile whose first-run setup was already shown. It appears whenever all of these are true: the launch is local and interactive, the built-in OpenRouter endpoint is still the model provider for the conversation's model, and neither the shell nor the profile holds a key. -A connected direct service carrying the conversation, a custom `CODEAF_BASE_URL`, a +A connected direct provider carrying the conversation, a custom `CODEAF_BASE_URL`, a `--host` session, and a headless `--once` run are not offered an OpenRouter browser trip. -For a headless run using the default service, start bare `codeaf` once to connect in a terminal, or export +For a headless run using the default provider, start bare `codeaf` once to connect in a terminal, or export `OPENROUTER_API_KEY` (or `OPENAI_API_KEY`) before running it. -**If the default service's `OPENROUTER_API_KEY` is already set in your shell, this step is not shown at all.** +**If the default provider's `OPENROUTER_API_KEY` is already set in your shell, this step is not shown at all.** The environment outranks the file, always; the setup only asks for what nothing else has answered. @@ -195,7 +195,7 @@ Under 112 columns it is not drawn and the form is unchanged. The controls screen shows **once, ever**. The default OpenRouter prerequisite above is the only step that may return. -## What appears once — and why the default service's OpenRouter step can return +## What appears once — and why the default provider's OpenRouter step can return The **Models and spending screen** is shown once per profile. When the first-run screen closes — finished or skipped — `setup_seen_at` is written into `config.json` with the time, @@ -207,7 +207,7 @@ that marker. It returns as a one-step screen on a later eligible launch while th still missing. It can also return in the same launch when an unsent model message reaches `enter`; the draft stays in the box. -That prerequisite is only for the default service during first run. A second service is +That prerequisite is only for the default provider during first run. A second provider is not required; add one later through `/connect`, as described on the [services page](services.md). @@ -215,7 +215,7 @@ The once-only controls screen stays away from `--session `, `codeaf resume`, `--once`, `--host`, pipes, existing conversations, and profiles that have already seen them. If every answer already exists, the marker is written silently. -The default service's OpenRouter prerequisite follows a narrower rule of its own. A missing connection is +The default provider's OpenRouter prerequisite follows a narrower rule of its own. A missing connection is shown for local interactive `--session ` and `codeaf resume` launches too, because those conversations still need a model. It stays away from `--once`, `--host`, pipes, custom endpoints, and profiles whose shell or profile already supplies a key. @@ -234,7 +234,7 @@ Every answer went through a settings row, so every answer has a door: | What you answered | Where to change it later | | --- | --- | -| the default service's openrouter key | clear or remove it and the next local interactive launch offers **connect openrouter** again; `/settings`, Providers tab, the **openrouter key** row still accepts a pasted replacement | +| the default provider's openrouter key | clear or remove it and the next local interactive launch offers **connect openrouter** again; `/settings`, Providers tab, the **openrouter key** row still accepts a pasted replacement | | the crew | nothing was asked — it is auto. `/crew` shows it, and `/crew pin ` pins a seat | | the daily limit | `/budget` (also `/limits`), or `/settings` → **Spending**. `CODEAF_DAILY_BUDGET` in your shell outranks the row | | the model you talk to | `/model`, or the **Chat model** row on the setup screen — the same settings row either way | @@ -263,7 +263,7 @@ already written any of them down — it never claims your own settings are defau ``` Per-plan approval, the per-conversation ceiling, individual crew seats, reasoning, -routing, extra service keys, concurrency and appearance are all deliberately absent from +routing, extra provider keys, concurrency and appearance are all deliberately absent from the setup. They have doors — `/budget`, `/settings`, `/crew`, `/model` — and they are asked about at the moment they matter rather than before you have started. diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 986bb223f7..e3b169c47d 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -192,7 +192,7 @@ build if the two disagree), so a tip you saw is on it word for word. It is the only row that names two commands as a pair, because the two rows about keeping something used to be told apart by nothing: a standing order is a condition the work has to honour and a memory is a fact carried forward. -- `/connect links Notion, Slack and other services` — retired when the connect panel +- `/connect links Notion, Slack and other accounts` — retired when the connect panel is reached for. - `/autonomy sets how questions are handled while you are away` — after the first exchange. Retired when `/autonomy` is typed, bare or with a rule. (It took the seat diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index f8a214b474..d1fdeb7e22 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1345,6 +1345,11 @@ one behind your back. This is every fate, in the words the drop-up draws them in | **`a fresh conversation behind home`** | `/new` `/clear` `/clean` `/reset` | Replaces the conversation behind the screen and says `started a fresh conversation behind home`. It is not the same act as `enter`, which opens a conversation at the target. | | **`closes the conversation behind home`** | `/quit` `/exit` `/q` | Closes it and says `closed · `. When it was the last conversation this terminal was holding, codeaf leaves. | +**A task typed on home uses the project shown before you press enter.** Clearing the +command from the box does not retarget it to a newer conversation in another project. +An ordinary message and `/task ` open in that same displayed project, even when +another project's engine is still running. + **The fate is never the half that gets cut.** On a narrow window the command's own description gives way first, whole, and what `enter` will do stays on the row. @@ -1389,7 +1394,7 @@ same door: - **`/model`**, or **pressing the model's name on that rule**, opens the model list in home's own body — the same filterable list `/model` opens in a conversation. Type to narrow it, `↑↓` to walk it, `enter` to take the row, `esc` to leave it alone. The foot while it is up - follows the cursor and reads `↑↓ pick · → providers · alt+s sort · enter choose · ctrl+t + follows the cursor and reads `↑↓ pick · → hosts · alt+s sort · enter choose · ctrl+t effort · esc back` on a model, and `↑↓ pick · ← back · alt+s sort · enter choose · esc back` inside an open provider fold. `← back` stands beside `↑↓ pick` because both move the cursor. - **`/model `** typed into the box pins it straight away, with no list. diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index aeb8e7f754..a83bb39265 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -2625,13 +2625,13 @@ its own and does not spend any of those three (see *Models, context, and what it **Routing around a full pool.** Some *too many requests* answers name which upstream provider's pool is full — one machine room out of the several that can serve the same model. When that happens, codeaf remembers the name and asks the router to route new -calls around that provider for the next five minutes (or for the comeback time it named, +calls around that host for the next five minutes (or for the comeback time it named, if shorter), so fresh work lands on machines with room instead of queueing behind the full one. The call that drew the answer still waits its own wait — only calls sent after -it steer around. A model served by a single provider has nowhere else to go, and simply +it steer around. A model served by a single host has nowhere else to go, and simply waits as described above. -**Why things can stay slow afterwards.** codeaf watches how many calls the provider will +**Why things can stay slow afterwards.** codeaf watches how many calls the host will take at once and pulls that number in half when it is told *too many requests* — once per burst, not once per answer. It gives it back on the clock: after **20 seconds** with no further pacing, one call's worth returns every **5 seconds** until it is back where it @@ -3364,3 +3364,18 @@ A task cancelled by its supervisor stays cancelled. A late `plandb done` reports The refusal does not reopen the task or change its result. Active tasks still require their owner; an automatically completed composite's empty placeholder can still receive its final report. + +## Task copies and Furrow disk usage after a merge + +After a repository task lands, codeaf retires its Furrow copy and timeline +before removing the task directory. Furrow garbage collection can then reclaim +objects that no other timeline needs. This does not erase shared parent history. + +A cleanup failure does not undo a successful merge. The report names the copy +that remains, and a later sweep of the closed conversation retries using its +saved task record. If you add files, edit files, or commit new work in that copy +after the failure, the sweep keeps it and names why; only a copy with no work of +its own is retired. Failed tasks and work kept for review retain their copies +and timelines. Old temporary conversations still follow the usual seven-day +retention policy. Previously orphaned timelines are not automatically purged: +a missing directory alone does not prove that its history is disposable. diff --git a/internal/manual/chat/keeping-an-eye.md b/internal/manual/chat/keeping-an-eye.md index 0081a4458f..77ef193e36 100644 --- a/internal/manual/chat/keeping-an-eye.md +++ b/internal/manual/chat/keeping-an-eye.md @@ -119,7 +119,7 @@ that instant, then says back the moment it landed on. That is what the card shows: ``` -in 2 minutes — 06:54 +in 2 minutes · 06:54 ``` so you can check the time it settled on without reading a timestamp. @@ -214,7 +214,7 @@ been marked `expired`, and the only trace would be a line in its own log: `its time ran out — no longer watching`. The mistake it exists to catch is arithmetic, not carelessness: a reminder said -as "in 1 minute — 23:11" lands at 23:11:11, and an end taken from the same words +as "in 1 minute · 23:11" lands at 23:11:11, and an end taken from the same words lands at 23:11:00 — eleven seconds too early. That is why the refusal spells both stamps out to the second. @@ -602,7 +602,7 @@ needs a yes and nobody was able to say one. The item still records that it looke the count of what was examined is honest and the record of the pass carries one error. Set the default key — `/settings` → **openrouter key**, say "set up my api key", or use -the `models` group in `/connect` to add a service — and the +the `providers` group in `/connect` to add a provider — and the next pass judges normally. Nothing has to be re-made and nothing was lost while there was no key. @@ -866,8 +866,8 @@ same judgement: one you started **in a temp directory**, seven days after you last said anything to it. Everything it holds goes with it — the transcript, and the `work/` workspace if it owned one. If a task of that conversation was still running when you last closed it, furrow is told to forget the copy of your -folder that task was working in as well, so `furrow forks` is never left naming -a directory that has gone. Every other conversation under +folder that task was working in, including its timeline, before the files are +removed. If retirement fails, the conversation and copy stay for a later retry. Every other conversation under `~/.codeaf/v3/projects/` stays whatever its age. If you work in a temp directory and want to keep what a conversation makes, anchor it with `/workspace ` or copy the files out; the starting-codeaf page has both under *I deleted my chat @@ -950,10 +950,13 @@ piece of the ask still to do. `the card was left unanswered — nothing was set up`. This is the opposite of a task proposal, where silence starts the work: a task is bounded work somebody is watching, and a standing item spends money at times nobody chose. -- **A "do it once" answer sets nothing up.** It answers - `Do it now as an ordinary step and report what happened. The person chose not to repeat it. Do not set it up again unless they ask. Do not investigate codeaf.` - and codeaf does the thing in front of you instead. A **one-off reminder's card - does not offer that answer** — see "Why is there no once on my reminder card". +- **A "do it once" answer approves immediate work, not a schedule.** The settled + card says `approved once, not scheduled`. That is an approval receipt, not + proof that the work has finished. The approved action, workspace, watch probe, + acceptance and spending limits are handed back to the current conversation; + it uses its ordinary tools and permissions and reports the actual result or + a blocker. No standing item is saved. A **one-off reminder's card does not + offer that answer** — see "Why is there no once on my reminder card". - **It will not set a reminder for a moment that has already passed.** The stamp is refused with the current time in it, and codeaf is asked to work it out again from that. @@ -970,3 +973,45 @@ piece of the ask still to do. one: nothing that runs on its own may arm something else that runs on its own. - **Nothing is armed by a matcher.** Nothing runs because a phrase looked like a rule; every single one of these was a card you said yes to. + +## Which daily allowance does a new standing card quote? + +When you did not name a per-run limit, a new proposal quotes the current daily +budget. Changing the budget affects the next proposal, including in an already +open conversation. A card already being read keeps its proposal-time quote. +An explicit per-run limit keeps the words you supplied. Task firings in the +activity history are labelled `task:`; a spoken reminder is labelled `said:`. + +## Why does a one-time job say run it then instead of remind me? + +A one-time card that will perform work says `wants to schedule work once`. +Its approval is `Run it then ·