diff --git a/docs/rashin.md b/docs/rashin.md index 8d7209393..5e2e63837 100644 --- a/docs/rashin.md +++ b/docs/rashin.md @@ -456,10 +456,17 @@ update whose health check found issues, and `ryoku doctor` itself, which offers A fix opens in the user's own agent harness, in a real terminal, not in a chat panel: the harness's own interface already streams its thinking, shows each command and its output, asks its own approvals, and switches models. The harness -is the chat agent picked in Rashin when it can open a repair (Oh My Pi, Hermes, -or Claude Code), otherwise the first of those installed. It starts in the vault, +is the one `fix.agent` names in `~/.config/ryoku/rashin.json`, else the chat +agent picked in Rashin, when it can open a repair (Hermes, Oh My Pi, Claude +Code, or Codex), otherwise the first of those installed. For Codex, `fix.model` +and `fix.effort` set the model and reasoning effort of that session only, for +example `"fix": {"agent": "codex", "model": "gpt-6.1-sol", "effort": "low"}`; +Codex's own config stays as it is. Run without a terminal (the Updates page +button), `ryoku-rashin fix` opens the terminal at once and answers with the +harness it will start; the health check and any error then show in that +terminal, which stays open on a failure. It starts in the vault, so the machine map is its working directory, with the Needle persona as extra -system prompt (Hermes, which takes none, gets it at the top of the message) and +system prompt (Hermes and Codex, which take none, get it at the top of the message) and the problem as its first message: a one-line `Fix with AI: ` and a brief that names the problem, the commands most relevant to it, and how to work: gather the evidence with `ryoku-rashin logs`, check `ryoku owner` before editing diff --git a/ryoku/CHANGELOG.md b/ryoku/CHANGELOG.md index e226776cb..ac29baba3 100644 --- a/ryoku/CHANGELOG.md +++ b/ryoku/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased ### Fixed +- Fix with AI can use Codex with a repair-specific model and reasoning effort. + Terminal launches now precede the health scan so progress and errors stay visible. - **Adaptive sync keeps its saved state in Displays.** Hyprland and niri now report the configured choice instead of whether the panel happens to be using adaptive sync at that moment, so a later Apply cannot turn it off. diff --git a/ryoku/hub/CHANGELOG.md b/ryoku/hub/CHANGELOG.md index e6189a0ae..d7b91cb46 100644 --- a/ryoku/hub/CHANGELOG.md +++ b/ryoku/hub/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased ### Fixed +- Updates acknowledges Fix with AI immediately, prevents duplicate pending launches, + and displays the terminal launch result or error. - **Displays reads the saved adaptive-sync choice.** An idle panel no longer makes an enabled setting look off, and applying another display change keeps the choice intact. diff --git a/ryoku/hub/quickshell/pages/UpdatesPage.qml b/ryoku/hub/quickshell/pages/UpdatesPage.qml index cea9c8667..842870138 100644 --- a/ryoku/hub/quickshell/pages/UpdatesPage.qml +++ b/ryoku/hub/quickshell/pages/UpdatesPage.qml @@ -249,10 +249,32 @@ Item { } // hand the fixing to Rashin: with no TTY here `ryoku-rashin fix doctor` opens - // the agent in a terminal that investigates and repairs. runApp keeps that - // terminal outside the Settings process tree. + // a terminal at once (in its own unit, outside the Settings process tree) + // that runs the health check and then the agent. Its answer or its error + // replaces the row's line, so a press is never silent. + property string fixState: "" // "" | opening | opened | failed + property string fixMsg: "" function fixWithAI() { - Spawn.runApp(["ryoku-rashin", "fix", "doctor"], { label: "ryoku-fix" }); + if (fixProc.running) + return; + pg.fixState = "opening"; + pg.fixMsg = I18n.tr("Opening a terminal for Fix with AI..."); + fixProc.running = true; + } + + Process { + id: fixProc + command: ["/bin/sh", "-c", 'exec ryoku-rashin "$@"', "sh", "fix", "doctor"] + environment: Spawn.env + stdout: StdioCollector { id: fixOut } + stderr: StdioCollector { id: fixErr } + onExited: code => { + const out = fixOut.text.trim().split("\n").pop(); + const err = fixErr.text.trim().replace(/^ryoku-rashin: /, ""); + pg.fixState = code === 0 ? "opened" : "failed"; + pg.fixMsg = code === 0 ? (out || I18n.tr("Opened Fix with AI in a terminal.")) + : (err || I18n.tr("Fix with AI could not start. Run `ryoku-rashin fix doctor` in a terminal to see why.")); + } } // the fill fraction for the shared progress track: the run's own reading @@ -416,6 +438,8 @@ Item { if (prev !== "idle" && pg.phase === "idle") { pg.runStartedAt = 0; pg.reportFresh = false; + pg.fixState = ""; + pg.fixMsg = ""; pg.showLog = false; Updates.check(); } @@ -445,6 +469,8 @@ Item { function dismiss() { Quickshell.execDetached(["sh", "-c", "printf '%s' '{\"phase\":\"idle\"}' > \"$1\"", "sh", pg.statePath]); pg.phase = "idle"; + pg.fixState = ""; + pg.fixMsg = ""; pg.runStartedAt = 0; pg.reportFresh = false; pg.showLog = false; @@ -552,14 +578,16 @@ Item { Text { width: fr.width - fixBtn.width - Tokens.s3 anchors.verticalCenter: parent.verticalCenter - text: I18n.tr("The health check found issues after this update.") - color: Tokens.inkMuted; font.family: Tokens.ui + text: pg.fixMsg !== "" ? pg.fixMsg : I18n.tr("The health check found issues after this update.") + color: pg.fixState === "failed" ? Tokens.ink : Tokens.inkMuted + font.family: Tokens.ui font.pixelSize: Tokens.fSmall; wrapMode: Text.WordWrap } Btn { id: fixBtn anchors.verticalCenter: parent.verticalCenter - text: I18n.tr("FIX WITH AI") + armed: pg.fixState !== "opening" + text: pg.fixState === "opening" ? I18n.tr("OPENING...") : I18n.tr("FIX WITH AI") primary: true onAct: pg.fixWithAI() } diff --git a/ryoku/rashin/backend/config.go b/ryoku/rashin/backend/config.go index ba600b4f6..9cddeab9d 100644 --- a/ryoku/rashin/backend/config.go +++ b/ryoku/rashin/backend/config.go @@ -23,8 +23,10 @@ type Config struct { // ChatAgent selects which agent drives the Super+S chat's interactive // session. Empty means the recommended default (hermes). Only agents with // an ACP adapter present can drive it; see chatBackends. - ChatAgent string `json:"chatAgent,omitempty"` - ProwlHarnesses []string `json:"prowlHarnesses,omitempty"` + ChatAgent string `json:"chatAgent,omitempty"` + // Fix picks the harness Fix with AI opens instead of the chat agent. + Fix FixSettings `json:"fix,omitzero"` + ProwlHarnesses []string `json:"prowlHarnesses,omitempty"` // Approvals is how the chat agent's tool calls are approved: "read-only" // (the default when empty) runs calls that only read the machine without // asking and asks for everything else; "ask" asks for every call the agent @@ -42,6 +44,14 @@ type Config struct { } `json:"habits,omitzero"` } +// FixSettings is the harness a repair opens and how it starts. An empty field +// keeps the harness's own default; only Codex takes Model and Effort. +type FixSettings struct { + Agent string `json:"agent,omitempty"` + Model string `json:"model,omitempty"` + Effort string `json:"effort,omitempty"` +} + func (c Config) HasProwlHarness(id string) bool { id = strings.TrimSpace(id) for _, connected := range c.ProwlHarnesses { diff --git a/ryoku/rashin/backend/fix_test.go b/ryoku/rashin/backend/fix_test.go index 60bc64754..a948e9f41 100644 --- a/ryoku/rashin/backend/fix_test.go +++ b/ryoku/rashin/backend/fix_test.go @@ -44,7 +44,7 @@ func TestHermesFixResumesOnlyItsOwnFirstTurn(t *testing.T) { hermes = h } } - argv := hermes.argv(bin, "persona", "Fix with AI: x\n\nbrief with 'quotes' and $(no expansion)") + argv := hermes.argv(bin, "persona", "Fix with AI: x\n\nbrief with 'quotes' and $(no expansion)", FixSettings{}) for _, c := range []struct { exit string want []string @@ -216,3 +216,78 @@ func TestFixEndpointRefusesOtherSites(t *testing.T) { } } } + +// Fix with AI runs the agent, model and effort the fix setting names, even +// when another harness is installed and the chat agent is unset. +func TestFixOpensTheConfiguredCodexModelAndEffort(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cfgHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", cfgHome) + bin := t.TempDir() + for _, name := range []string{"claude", "codex"} { + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", bin) + cfg := `{"enabled":true,"port":3600,"fix":{"agent":"codex","model":"gpt-6.1-sol","effort":"low"}}` + if err := os.MkdirAll(filepath.Join(cfgHome, "ryoku"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfgHome, "ryoku", "rashin.json"), []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + + l, err := planFix("Fix with AI: x", "brief") + if err != nil { + t.Fatal(err) + } + want := []string{filepath.Join(bin, "codex"), "-m", "gpt-6.1-sol", "-c", `model_reasoning_effort="low"`, needlePersona + "\n\nFix with AI: x\n\nbrief"} + if l.Harness != "Codex" || strings.Join(l.Argv, "\x00") != strings.Join(want, "\x00") { + t.Fatalf("launch = %s %q, want Codex %q", l.Harness, l.Argv, want) + } +} + +// Fix with AI pressed outside a terminal (the Settings button) must answer at +// once: it opens the terminal running the fix itself, so the slow health check +// and any error it hits happen on screen, not in a hidden process. +func TestFixOffATerminalOpensTheTerminalBeforeTheHealthCheck(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cfgHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", cfgHome) + bin := t.TempDir() + if err := os.WriteFile(filepath.Join(bin, "codex"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) + if err := os.MkdirAll(filepath.Join(cfgHome, "ryoku"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfgHome, "ryoku", "rashin.json"), []byte(`{"fix":{"agent":"codex"}}`), 0o644); err != nil { + t.Fatal(err) + } + oldTerm, oldLookPath, oldRun := onTerminal, terminalLookPath, terminalRun + t.Cleanup(func() { onTerminal, terminalLookPath, terminalRun = oldTerm, oldLookPath, oldRun }) + onTerminal = func() bool { return false } + terminalLookPath = func(name string) (string, error) { return "/test/bin/" + name, nil } + var got []string + terminalRun = func(_ string, args []string) ([]byte, error) { + got = append([]string(nil), args...) + return nil, nil + } + + if err := cmdFix([]string{"doctor"}); err != nil { + t.Fatalf("no ryoku on PATH, so a health check before the terminal would fail: %v", err) + } + self, _ := os.Executable() + tail := strings.Join(got, "\x00") + if !strings.HasSuffix(tail, "\x00"+self+"\x00fix\x00doctor") || !strings.Contains(tail, "/test/bin/ryoku-app\x00terminal\x00--\x00sh\x00-c") { + t.Fatalf("terminal args = %q, want the held fix command %s fix doctor", got, self) + } + + t.Setenv("PATH", t.TempDir()) + got = nil + if err := cmdFix([]string{"doctor"}); err == nil || got != nil { + t.Fatalf("with no harness installed: err %v, terminal %q; want an error and no terminal", err, got) + } +} diff --git a/ryoku/rashin/backend/fixcli.go b/ryoku/rashin/backend/fixcli.go index d5141f96b..e05e7d8bb 100644 --- a/ryoku/rashin/backend/fixcli.go +++ b/ryoku/rashin/backend/fixcli.go @@ -11,9 +11,9 @@ const fixUsage = `usage: ryoku-rashin fix doctor [finding name] ryoku-rashin fix tip ryoku-rashin fix app [what happened...] -Opens your agent (the chat agent picked in Rashin, Hermes by default) with the -problem as its first message: right here when run from a terminal, in a new -terminal window otherwise. The agent reads the logs itself, explains the +Opens your agent (the fix agent picked in Rashin, else the chat agent) with the +problem as its first message: right here when run from a terminal, otherwise in +a new terminal window that runs the check and opens the agent there. The agent reads the logs itself, explains the cause, and asks before it changes anything.` // fixKinds are the problems Fix with AI knows how to brief. @@ -51,6 +51,12 @@ func cmdFix(args []string) error { if err != nil { return err } + if !onTerminal() { + return openFixCommandInTerminal(args) + } + if req.Kind == "doctor" { + fmt.Println("Running the health check...") + } display, brief, err := buildFix(req) if err != nil { return err @@ -59,19 +65,33 @@ func cmdFix(args []string) error { if err != nil { return err } - if onTerminal() { - fmt.Printf("%s\nOpening %s...\n", display, launch.Harness) - return runFixHere(launch) + fmt.Printf("%s\nOpening %s...\n", display, launch.Harness) + return runFixHere(launch) +} + +// openFixCommandInTerminal answers a caller with no terminal (the Settings +// button) at once: the health check takes a minute or more, so it runs in the +// opened terminal with its progress and any error on screen, not behind a +// button that shows nothing. +func openFixCommandInTerminal(args []string) error { + h, _, _, err := pickConfiguredHarness() + if err != nil { + return err + } + self, err := os.Executable() + if err != nil { + return err } - if err := openFixTerminal(launch); err != nil { + argv := holdOnFailure("Fix with AI", append([]string{self, "fix"}, args...)) + if err := launchTerminal(VaultDir(), "ryoku-fix-", argv); err != nil { return err } - fmt.Printf("%s\nOpened %s in a terminal.\n", display, launch.Harness) + fmt.Printf("Opened %s in a terminal.\n", h.Name) return nil } // onTerminal: both ends are a terminal, so the harness can take it over. -func onTerminal() bool { +var onTerminal = func() bool { for _, f := range []*os.File{os.Stdin, os.Stdout} { st, err := f.Stat() if err != nil || st.Mode()&os.ModeCharDevice == 0 { diff --git a/ryoku/rashin/backend/fixterm.go b/ryoku/rashin/backend/fixterm.go index 10aebb54d..613c6acf2 100644 --- a/ryoku/rashin/backend/fixterm.go +++ b/ryoku/rashin/backend/fixterm.go @@ -23,7 +23,7 @@ type fixHarness struct { Name string bin func() (string, bool) // argv starts the harness interactively with prompt as the first message. - argv func(bin, persona, prompt string) []string + argv func(bin, persona, prompt string, s FixSettings) []string } func onPath(name string) func() (string, bool) { @@ -43,27 +43,42 @@ func fixHarnesses() []fixHarness { // runs as one query in this terminal, then the same session opens // for the rest of the conversation. A failed first query must not // resume some older session. - argv: func(bin, persona, prompt string) []string { + argv: func(bin, persona, prompt string, _ FixSettings) []string { return []string{"sh", "-c", `"$0" chat -q "$1" && exec "$0" chat --continue`, bin, persona + "\n\n" + prompt} }, }, { ID: "omp", Name: "Oh My Pi", bin: onPath("omp"), - argv: func(bin, persona, prompt string) []string { + argv: func(bin, persona, prompt string, _ FixSettings) []string { return []string{bin, "--append-system-prompt", persona, prompt} }, }, { ID: "claude", Name: "Claude Code", bin: onPath("claude"), - argv: func(bin, persona, prompt string) []string { + argv: func(bin, persona, prompt string, _ FixSettings) []string { return []string{bin, "--append-system-prompt", persona, prompt} }, }, + { + ID: "codex", Name: "Codex", bin: onPath("codex"), + // Codex takes no extra system prompt flag, so the persona leads + // the first message, as with Hermes. + argv: func(bin, persona, prompt string, s FixSettings) []string { + argv := []string{bin} + if s.Model != "" { + argv = append(argv, "-m", s.Model) + } + if s.Effort != "" { + argv = append(argv, "-c", fmt.Sprintf("model_reasoning_effort=%q", s.Effort)) + } + return append(argv, persona+"\n\n"+prompt) + }, + }, } } -// pickFixHarness prefers the agent the user chose for the chat, then the -// first installed harness. +// pickFixHarness prefers the agent the fix setting or the chat names, then +// the first installed harness. func pickFixHarness(want string, hs []fixHarness) (fixHarness, string, bool) { for _, h := range hs { if h.ID == want { @@ -86,12 +101,27 @@ type fixLaunch struct { Argv []string } -func planFix(display, brief string) (fixLaunch, error) { - h, bin, ok := pickFixHarness(LoadConfig().ChatAgent, fixHarnesses()) +// pickConfiguredHarness resolves the agent the fix setting, else the chat +// setting, names. +func pickConfiguredHarness() (fixHarness, string, Config, error) { + cfg := LoadConfig() + want := cfg.Fix.Agent + if want == "" { + want = cfg.ChatAgent + } + h, bin, ok := pickFixHarness(want, fixHarnesses()) if !ok { - return fixLaunch{}, errors.New("no agent harness to open; install Hermes or Oh My Pi") + return h, "", cfg, errors.New("no agent harness to open; install Codex, Claude Code, Hermes or Oh My Pi") + } + return h, bin, cfg, nil +} + +func planFix(display, brief string) (fixLaunch, error) { + h, bin, cfg, err := pickConfiguredHarness() + if err != nil { + return fixLaunch{}, err } - return fixLaunch{Harness: h.Name, Argv: h.argv(bin, needlePersona, display+"\n\n"+brief)}, nil + return fixLaunch{Harness: h.Name, Argv: h.argv(bin, needlePersona, display+"\n\n"+brief, cfg.Fix)}, nil } // holdOnFailure keeps the terminal open when the harness exits with an error,