diff --git a/README.md b/README.md index 2be816f..6f6b70b 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,9 @@ are not orphaned onto init while still holding their memory. - **Dials, not config files** — provider lane, model tier, thinking depth, advisor level, plus the spark/fable toggles; every combination maps to a pre-computed routing. +- **Hosted or local** — an optional runtime broker can advertise only the local + targets this machine supports; selecting one delegates first-use setup and + launch without mixing cloud credentials into the session. - **Live preview** — see which model leads every role, and its fallback chain, before anything runs. - **One-shot overlays** — each launch is an ephemeral `--config`; your omp diff --git a/docs/configuration.md b/docs/configuration.md index 7cf37c7..7e50297 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,7 +11,7 @@ environment variable with a sane fallback. | `←` `→` | change the selected dial | | `d` | reset all dials to defaults | | `ctrl+o` | describe the task, let a local model set the dials | -| `enter` | launch oh-my-pi with the generated setup | +| `enter` | launch oh-my-pi with the selected hosted profile or local runtime | | `m` | launch plain managed omp (no overlay) | | `u` | launch through a sandboxed omp, if you have one | | `v` | manage broker account selections and presets | @@ -32,6 +32,7 @@ environment variable with a sane fallback. | `CODE_SESSION_STATE` | directory recording live sessions for `code ls` / `code session reap`; `off` disables recording | `$XDG_STATE_HOME/code/sessions` — note this one defaults to a path rather than to disabled, so the registry works without wrapper changes | | `CODE_OMP` | omp binary for trusted launches (`m` and `enter`) | `omp-managed`, then `omp` on PATH | | `CODE_OMP_UNTRUSTED` | sandboxed omp for the `u` key | `ompu` on PATH, else the key is hidden and inert | +| `CODE_RUNTIME_BROKER` | executable implementing `runtime list --json` and `runtime run TARGET -- ...`; applicable targets become a runtime dial | no runtime dial; hosted behavior is unchanged | | `OMP_AUTH_BROKER_URL` | central auth broker behind the usage panel and the account picker (`v`); inherited from your omp environment | no fetch — the usage panel has nothing to show | | `OMP_AUTH_BROKER_TOKEN` | bearer token for that broker | same: `code` only fetches when both the URL and the token are set | | `OMP_AUTH_BROKER_SNAPSHOT_CACHE` | broker snapshot cache path; `code` never reads it, it only forwards it to the omp it launches | forwarded empty | @@ -50,6 +51,13 @@ broker (`OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN`). Provider authentication is owned by OMP, not `code`. Authenticate with `omp auth-broker login` before launching `code`. +The runtime-broker boundary is deliberately narrow. `code` reads only +schema-version-1 targets marked `applicable`, then delegates the selected +target's complete lifecycle to the broker. It does not download weights, +create credentials, or assume a container engine. Local launches receive the +thinking dial and forwarded OMP arguments, but not cloud auth-broker variables; +the runtime broker owns its OMP profile, routing config, and fallback policy. + ## The `code generate` subcommand The dials are backed by a pre-rendered catalog. Building it is two steps, both diff --git a/main.go b/main.go index a050fe4..72ad4b4 100644 --- a/main.go +++ b/main.go @@ -1129,6 +1129,9 @@ func logScore(idx, lnLo, lnHi float64) int { // costScore rates the current config from 1 (cheap) to 5 (dear). func (m model) costScore() int { + if _, ok := m.selectedRuntime(); ok { + return 1 + } fast := m.sel["fast"] == "on" && m.sel["lane"] != "claude-only" var num, den float64 m.weightedModels(m.currentRows(), func(w float64, id, lvl string) { @@ -1155,6 +1158,9 @@ func (m model) costScore() int { // speedScore rates the current config from 1 (slow) to 5 (fast). func (m model) speedScore() int { + if _, ok := m.selectedRuntime(); ok { + return 3 + } fast := m.sel["fast"] == "on" && m.sel["lane"] != "claude-only" var num, den float64 m.weightedModels(m.currentRows(), func(w float64, id, lvl string) { @@ -1252,6 +1258,15 @@ func (m model) applyAdvisor(rows []string, level string) []string { // shows while fable is on (and the lane can host it at all). A dial this catalog // generated no combo for is dropped the same way — it is not a choice. func (m model) visibleFacets() []facet { + if _, local := m.selectedRuntime(); local { + var out []facet + for _, f := range m.facets { + if f.key == "runtime" || f.key == "thinking" { + out = append(out, f) + } + } + return out + } lane := m.sel["lane"] var out []facet for _, f := range m.facets { @@ -1758,11 +1773,12 @@ func (f *wheelInputFilter) Filter(app tea.Model, msg tea.Msg) tea.Msg { } type model struct { - generated map[string][]string - advisors map[string][]string // "level/ctx" → advisor model chain - facts map[string]modelFact // model id → cost ($/1M) + curated speed (tok/s) - avail availability - glyphs map[string]string + generated map[string][]string + advisors map[string][]string // "level/ctx" → advisor model chain + facts map[string]modelFact // model id → cost ($/1M) + curated speed (tok/s) + avail availability + glyphs map[string]string + runtimeTargets []runtimeTarget // Catalog capability, phrased as absence so the zero value keeps every dial: // a model with no catalog yet (the onboarding shell, tests) must behave as it @@ -1805,6 +1821,7 @@ type model struct { launchManaged bool // m: run CODE_OMP with no overlay (the managed defaults) launchUntrusted bool // u: run the CODE_OMP_UNTRUSTED sandbox + launchRuntime string // delegated local runtime target selected via CODE_RUNTIME_BROKER hasSandbox bool // a sandbox binary exists; gates the u key genConfig string // generated config YAML to launch omp with (generator Enter) firstPrompt string // prompt from the suggest box, forwarded as omp's first message @@ -2696,6 +2713,20 @@ func (m *model) syncPreviewAt(yoff int) { // generator list on the left, so the preview shows only what that selection // produces — the role → model routing itself. var b strings.Builder + if target, local := m.selectedRuntime(); local { + b.WriteString(lipgloss.NewStyle().Bold(true).Render(target.Label) + "\n") + b.WriteString(stDim.Render(target.statusLine()) + "\n\n") + b.WriteString("model " + target.Model + "\n") + if target.ContextWindow > 0 { + b.WriteString(fmt.Sprintf("context %dk tokens\n", target.ContextWindow/1000)) + } + b.WriteString("routing every role stays local\n") + b.WriteString("fallbacks disabled\n") + content := lipgloss.NewStyle().MaxWidth(m.vp.Width).Render(b.String()) + m.vp.SetContent(content) + m.vp.SetYOffset(yoff) + return + } id := comboID(m.sel) if base, ok := m.generated[id]; ok { _, roles := splitMeta(base) @@ -2976,6 +3007,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.relayout() // the taller/shorter footer changes the body height case "d": m.sel = defaultSel() + if len(m.runtimeTargets) > 0 { + m.sel["runtime"] = "hosted" + } m.clampSel() // the defaults assume a full catalog; this one may not be m.persistSelection() m.syncPreview() @@ -3018,6 +3052,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.launchManaged = true return m, tea.Quit case "enter": + if target, local := m.selectedRuntime(); local { + m.launchRuntime = target.Name + return m, tea.Quit + } // Enter always launches the generated profile for the current facets — // the untouched default combo is a generated profile like any other. // Never for a combo the catalog doesn't carry, though: genConfigYAML @@ -3185,6 +3223,9 @@ func (m model) previewPane(w, h int) string { // accent is the context colour — the selected lane in the generator. // Blue / purple / orange. func (m model) accent() string { + if _, local := m.selectedRuntime(); local { + return cGreen + } return laneColor(m.sel["lane"]) } @@ -3333,6 +3374,10 @@ func (m model) genLines() ([]string, int) { } row := fmt.Sprintf("%s%s%s%s", ptr, childPad, gly, stDim.Render(pad(label, 9-childW))) for _, v := range f.values { + display := v + if f.key == "runtime" { + display = m.runtimeValueLabel(v) + } switch { case v == m.sel[f.key]: col := acc @@ -3345,9 +3390,9 @@ func (m model) genLines() ([]string, int) { if onRow { // the cursor sits on the selected value of the focused row st = st.Background(lipgloss.Color(cSelBg)) } - row += " " + st.Render(" "+v+" ") + row += " " + st.Render(" "+display+" ") default: - row += " " + stDim.Render(v) + row += " " + stDim.Render(display) } } switch { @@ -3377,11 +3422,11 @@ func (m model) genLines() ([]string, int) { // was once wiped by an edit exactly because of that. CODE_FACET_GLYPHS may // override any entry (see main). // -// lane ⇄ (f127) model ⚙ (f085) thinking 💡 (f0eb) advisor 🧭 (f14e) +// runtime 🖥 (f108) lane ⇄ (f127) model ⚙ (f085) thinking 💡 (f0eb) advisor 🧭 (f14e) // spark 🚀 (f135) fable 📖 (f02d) default 🎯 (f140) fast ⚡ (f0e7) func defaultGlyphs() map[string]string { return map[string]string{ - "lane": "\uf127", "model": "\uf085", "thinking": "\uf0eb", "advisor": "\uf14e", + "runtime": "\uf108", "lane": "\uf127", "model": "\uf085", "thinking": "\uf0eb", "advisor": "\uf14e", "spark": "\uf135", "fable": "\uf02d", "main": "\uf140", "fast": "\uf0e7", } } @@ -3417,8 +3462,18 @@ func main() { broker := resolveBroker(os.Getenv("CODE_AUTH_VAULTS"), os.Getenv("CODE_AUTH_VAULTS_FILE")) accountState := os.Getenv("CODE_AUTH_ACCOUNT_STATE") accountSelections := loadAccountSelectionState(accountState) + runtimeTargets := loadRuntimeTargets() facets := facetDefs(glyphs) + if len(runtimeTargets) > 0 { + facets = append([]facet{runtimeFacet(glyphs["runtime"], runtimeTargets)}, facets...) + } selectionState := os.Getenv("CODE_SELECTION_STATE") + selection := loadSelectionState(selectionState, facets) + if len(runtimeTargets) > 0 { + if _, ok := selection["runtime"]; !ok { + selection["runtime"] = "hosted" + } + } // The u key only exists when a sandbox binary does — an explicit // CODE_OMP_UNTRUSTED or an ompu on PATH; otherwise hide it from the help // and ignore the keypress rather than dying on exec. @@ -3446,8 +3501,9 @@ func main() { spin: sp, help: clikit.NewHelp(), glyphs: glyphs, + runtimeTargets: runtimeTargets, facets: facets, - sel: loadSelectionState(selectionState, facets), + sel: selection, selectionState: selectionState, hasSandbox: hasSandbox, } @@ -3459,7 +3515,7 @@ func main() { // CODE_GENERATED is an operator config error and is left visible as the // usual empty routing panel instead). app := tea.Model(m) - if len(generated) == 0 && os.Getenv("CODE_GENERATED") == "" { + if len(generated) == 0 && os.Getenv("CODE_GENERATED") == "" && len(runtimeTargets) == 0 { app = newOnboarding(m) } // Cell-motion mouse reporting carries wheel events. Filter rejected @@ -3478,6 +3534,10 @@ func main() { status = withSession("sandbox", "CODE_OMP_UNTRUSTED", []string{"ompu"}, func() int { return runSandbox("CODE_OMP_UNTRUSTED", []string{"ompu"}, fm.firstPrompt) }) + case fm.launchRuntime != "": + status = withSession("runtime:"+fm.launchRuntime, "CODE_RUNTIME_BROKER", nil, func() int { + return runRuntimeTarget(fm.launchRuntime, fm.sel["thinking"], fm.firstPrompt) + }) case fm.launchManaged: status = withSession("managed", "CODE_OMP", []string{"omp-managed", "omp"}, func() int { return runTrusted("CODE_OMP", []string{"omp-managed", "omp"}, managedLaunchArgv, diff --git a/main_test.go b/main_test.go index afba52e..f50c0e6 100644 --- a/main_test.go +++ b/main_test.go @@ -466,14 +466,14 @@ func TestGenConfigYAMLAgentOverrides(t *testing.T) { // them all to empty strings without anything failing; this locks each value. func TestDefaultGlyphs(t *testing.T) { want := map[string]rune{ - "lane": 0xf127, "model": 0xf085, "thinking": 0xf0eb, "advisor": 0xf14e, + "runtime": 0xf108, "lane": 0xf127, "model": 0xf085, "thinking": 0xf0eb, "advisor": 0xf14e, "spark": 0xf135, "fable": 0xf02d, "main": 0xf140, "fast": 0xf0e7, } g := defaultGlyphs() if len(g) != len(want) { t.Errorf("defaultGlyphs has %d entries, want %d", len(g), len(want)) } - for _, f := range facetDefs(g) { + for _, f := range append([]facet{runtimeFacet(g["runtime"], nil)}, facetDefs(g)...) { r := []rune(g[f.key]) if len(r) != 1 { t.Errorf("glyph for %q is %d runes, want exactly 1", f.key, len(r)) diff --git a/runtime.go b/runtime.go new file mode 100644 index 0000000..444ccb6 --- /dev/null +++ b/runtime.go @@ -0,0 +1,173 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "sort" + "strings" + "time" +) + +const runtimeDiscoveryTimeout = 2 * time.Second + +// runtimeTarget is the small, versioned contract exposed by an external runtime +// broker. The broker owns hardware detection, provisioning, secrets, and the +// model server; code only discovers applicable targets and delegates launches. +type runtimeTarget struct { + SchemaVersion int `json:"schemaVersion"` + Name string `json:"name"` + Label string `json:"label"` + Phase string `json:"phase"` + Reason string `json:"reason"` + Model string `json:"model"` + ContextWindow int `json:"contextWindow"` + Applicable bool `json:"applicable"` + Provisioned bool `json:"provisioned"` + Running bool `json:"running"` + Healthy bool `json:"healthy"` + DiskBytes int64 `json:"diskBytes"` + EstimatedDiskBytes int64 `json:"estimatedDiskBytes"` +} + +func loadRuntimeTargets() []runtimeTarget { + broker := strings.TrimSpace(os.Getenv("CODE_RUNTIME_BROKER")) + if broker == "" { + return nil + } + path, err := exec.LookPath(broker) + if err != nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), runtimeDiscoveryTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, path, "runtime", "list", "--json").Output() + if err != nil { + return nil + } + return parseRuntimeTargets(out) +} + +func parseRuntimeTargets(data []byte) []runtimeTarget { + var targets []runtimeTarget + if json.Unmarshal(data, &targets) != nil { + return nil + } + out := targets[:0] + for _, target := range targets { + if target.SchemaVersion != 1 || strings.TrimSpace(target.Name) == "" || !target.Applicable { + continue + } + if target.Label == "" { + target.Label = target.Name + } + out = append(out, target) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Label < out[j].Label }) + return out +} + +func runtimeFacet(glyph string, targets []runtimeTarget) facet { + values := []string{"hosted"} + for _, target := range targets { + values = append(values, target.Name) + } + return facet{key: "runtime", values: values, glyph: glyph} +} + +func (m model) selectedRuntime() (runtimeTarget, bool) { + selected := m.sel["runtime"] + if selected == "" || selected == "hosted" { + return runtimeTarget{}, false + } + for _, target := range m.runtimeTargets { + if target.Name == selected { + return target, true + } + } + return runtimeTarget{}, false +} + +func (m model) runtimeValueLabel(value string) string { + if value == "hosted" { + return value + } + for _, target := range m.runtimeTargets { + if target.Name == value { + return target.Label + } + } + return value +} + +func runtimeLaunchArgv(path, target, thinking string, forwarded []string, prompt string) []string { + args := []string{"runtime", "run", target, "--", "--thinking", thinking} + args = append(args, stripRuntimeArgs(forwarded)...) + if prompt != "" { + args = append(args, prompt) + } + return append([]string{path}, args...) +} + +// stripRuntimeArgs removes caller-supplied routing flags. A local runtime owns +// both its OMP profile and config; arguments after -- remain literal prompt text. +func stripRuntimeArgs(args []string) []string { + clean := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" { + return append(clean, args[i:]...) + } + if arg == "--profile" || arg == "--config" { + if i+1 < len(args) { + i++ + } + continue + } + if strings.HasPrefix(arg, "--profile=") || strings.HasPrefix(arg, "--config=") { + continue + } + clean = append(clean, arg) + } + return clean +} + +func runRuntimeTarget(target, thinking, prompt string) int { + path, err := resolveLaunchPath("CODE_RUNTIME_BROKER", nil) + if err != nil { + fmt.Fprintln(os.Stderr, "code: runtime broker not found:", err) + return 1 + } + err = runChild(path, runtimeLaunchArgv(path, target, thinking, os.Args[1:], prompt), withoutAuthEnv(os.Environ())) + if err != nil { + fmt.Fprintln(os.Stderr, "code: local runtime:", err) + } + return childStatus(err) +} + +func formatBytes(n int64) string { + if n <= 0 { + return "" + } + const gib = int64(1024 * 1024 * 1024) + return fmt.Sprintf("%.0f GiB", float64(n)/float64(gib)) +} + +func (target runtimeTarget) statusLine() string { + switch { + case target.Healthy: + return "ready" + case target.Running: + return "starting" + case target.Provisioned: + return "installed · starts on launch" + default: + size := formatBytes(target.EstimatedDiskBytes) + if size != "" { + return "downloads on first launch · about " + size + } + return "downloads on first launch" + } +} diff --git a/runtime_test.go b/runtime_test.go new file mode 100644 index 0000000..992cc46 --- /dev/null +++ b/runtime_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "reflect" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestParseRuntimeTargetsFiltersUnsupportedAndUnknownSchema(t *testing.T) { + got := parseRuntimeTargets([]byte(`[ + {"schemaVersion":1,"name":"z-local","label":"Z local","applicable":true}, + {"schemaVersion":1,"name":"unsupported","applicable":false}, + {"schemaVersion":2,"name":"future","applicable":true}, + {"schemaVersion":1,"name":"a-local","label":"A local","applicable":true} +]`)) + if len(got) != 2 || got[0].Name != "a-local" || got[1].Name != "z-local" { + t.Fatalf("unexpected targets: %#v", got) + } +} + +func TestRuntimeFacetAndSelection(t *testing.T) { + targets := []runtimeTarget{{Name: "local-qwen", Label: "Local Qwen", Applicable: true}} + f := runtimeFacet("R", targets) + if !reflect.DeepEqual(f.values, []string{"hosted", "local-qwen"}) { + t.Fatalf("runtime values = %#v", f.values) + } + m := model{runtimeTargets: targets, sel: map[string]string{"runtime": "local-qwen"}} + target, ok := m.selectedRuntime() + if !ok || target.Name != "local-qwen" || m.runtimeValueLabel(target.Name) != "Local Qwen" { + t.Fatalf("selected target = %#v, %v", target, ok) + } +} + +func TestRuntimeLaunchArgvOwnsProfileAndThinking(t *testing.T) { + got := runtimeLaunchArgv("/bin/atyrode", "local-qwen", "high", + []string{"--profile", "cloud", "--config=old.yml", "--resume"}, "hello") + want := []string{"/bin/atyrode", "runtime", "run", "local-qwen", "--", "--thinking", "high", "--resume", "hello"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("argv = %#v; want %#v", got, want) + } +} + +func TestRuntimeStatusLine(t *testing.T) { + if got := (runtimeTarget{Healthy: true}).statusLine(); got != "ready" { + t.Fatalf("healthy status = %q", got) + } + if got := (runtimeTarget{EstimatedDiskBytes: 40 * 1024 * 1024 * 1024}).statusLine(); got != "downloads on first launch · about 40 GiB" { + t.Fatalf("first-launch status = %q", got) + } +} + +func TestLocalRuntimeHidesHostedDialsAndLaunchesWithoutCatalog(t *testing.T) { + targets := []runtimeTarget{{Name: "local-qwen", Label: "Local Qwen", Applicable: true}} + facets := append([]facet{runtimeFacet("R", targets)}, facetDefs(defaultGlyphs())...) + sel := defaultSel() + sel["runtime"] = "local-qwen" + m := model{runtimeTargets: targets, facets: facets, sel: sel, generated: map[string][]string{}} + + visible := m.visibleFacets() + if len(visible) != 2 || visible[0].key != "runtime" || visible[1].key != "thinking" { + t.Fatalf("local facets = %#v", visible) + } + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := next.(model) + if got.launchRuntime != "local-qwen" || got.genConfig != "" || cmd == nil { + t.Fatalf("local enter: target=%q config=%q quit=%v", got.launchRuntime, got.genConfig, cmd != nil) + } +}