From c734ec7ba23920f3a53544792e3e143f355cf257 Mon Sep 17 00:00:00 2001 From: Kayzel Date: Thu, 5 Nov 2026 09:26:50 +0100 Subject: [PATCH 1/3] [ryoku] rashin: let Fix with AI open Codex with its own model Fix with AI knew only Hermes, Oh My Pi and one other harness, so a machine with Codex installed never opened Codex. A fix setting in rashin.json now names the harness apart from the chat agent and, for Codex, the model and reasoning effort of that one session, passed as -m and -c model_reasoning_effort so Codex's own config stays untouched. Note: New: Fix with AI can open Codex, with the model and effort set under "fix" in rashin.json --- docs/rashin.md | 10 ++++++--- ryoku/rashin/backend/config.go | 14 +++++++++++-- ryoku/rashin/backend/fix_test.go | 33 ++++++++++++++++++++++++++++- ryoku/rashin/backend/fixterm.go | 36 +++++++++++++++++++++++++------- 4 files changed, 79 insertions(+), 14 deletions(-) diff --git a/docs/rashin.md b/docs/rashin.md index 8d72093935..8bcb5074bb 100644 --- a/docs/rashin.md +++ b/docs/rashin.md @@ -456,10 +456,14 @@ 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. 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/rashin/backend/config.go b/ryoku/rashin/backend/config.go index ba600b4f61..9cddeab9d7 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 60bc647548..e66b717b07 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,34 @@ 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) + } +} diff --git a/ryoku/rashin/backend/fixterm.go b/ryoku/rashin/backend/fixterm.go index 10aebb54db..a46005fbf3 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 { @@ -87,11 +102,16 @@ type fixLaunch struct { } func planFix(display, brief string) (fixLaunch, error) { - h, bin, ok := pickFixHarness(LoadConfig().ChatAgent, fixHarnesses()) + 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 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, From 45264d0cc091384f389a3d6ec67e11f98ae431ef Mon Sep 17 00:00:00 2001 From: Kayzel Date: Thu, 5 Nov 2026 09:56:01 +0100 Subject: [PATCH 2/3] [ryoku] rashin: answer Fix with AI at once and show its result The Updates page button ran the full health check (50 to 94 s, with a 90 s cap) before any terminal opened, and threw away the command's output, so a press showed nothing and a failure left no trace. Off a terminal the fix now opens the terminal at once and runs the check there, held open on an error, and the button shows OPENING, then the command's answer or its error. Note: Fixed: Fix with AI on the Updates page now reacts at once and shows why when it cannot open --- docs/rashin.md | 5 ++- ryoku/hub/quickshell/pages/UpdatesPage.qml | 38 ++++++++++++++++--- ryoku/rashin/backend/fix_test.go | 44 ++++++++++++++++++++++ ryoku/rashin/backend/fixcli.go | 38 ++++++++++++++----- ryoku/rashin/backend/fixterm.go | 14 ++++++- 5 files changed, 121 insertions(+), 18 deletions(-) diff --git a/docs/rashin.md b/docs/rashin.md index 8bcb5074bb..5e2e638375 100644 --- a/docs/rashin.md +++ b/docs/rashin.md @@ -461,7 +461,10 @@ 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. It starts in the vault, +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 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 diff --git a/ryoku/hub/quickshell/pages/UpdatesPage.qml b/ryoku/hub/quickshell/pages/UpdatesPage.qml index cea9c86671..2ac6060437 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: ["ryoku-rashin", "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(); } @@ -552,14 +576,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/fix_test.go b/ryoku/rashin/backend/fix_test.go index e66b717b07..a948e9f416 100644 --- a/ryoku/rashin/backend/fix_test.go +++ b/ryoku/rashin/backend/fix_test.go @@ -247,3 +247,47 @@ func TestFixOpensTheConfiguredCodexModelAndEffort(t *testing.T) { 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 d5141f96bd..e05e7d8bb0 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 a46005fbf3..613c6acf24 100644 --- a/ryoku/rashin/backend/fixterm.go +++ b/ryoku/rashin/backend/fixterm.go @@ -101,7 +101,9 @@ type fixLaunch struct { Argv []string } -func planFix(display, brief string) (fixLaunch, error) { +// 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 == "" { @@ -109,7 +111,15 @@ func planFix(display, brief string) (fixLaunch, error) { } 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, cfg.Fix)}, nil } From 168a54d060d82167681806aeea1679b12fb7e8ac Mon Sep 17 00:00:00 2001 From: Kayzel Date: Thu, 5 Nov 2026 10:00:53 +0100 Subject: [PATCH 3/3] [ryoku] hub: reset repair feedback and report missing executable Use a shell so a missing Rashin executable returns a collected error. Clear the previous repair response when dismissing an update. Note: Fixed: Fix with AI shows missing-command errors and clears old feedback --- ryoku/hub/quickshell/pages/UpdatesPage.qml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ryoku/hub/quickshell/pages/UpdatesPage.qml b/ryoku/hub/quickshell/pages/UpdatesPage.qml index 2ac6060437..842870138f 100644 --- a/ryoku/hub/quickshell/pages/UpdatesPage.qml +++ b/ryoku/hub/quickshell/pages/UpdatesPage.qml @@ -264,7 +264,7 @@ Item { Process { id: fixProc - command: ["ryoku-rashin", "fix", "doctor"] + command: ["/bin/sh", "-c", 'exec ryoku-rashin "$@"', "sh", "fix", "doctor"] environment: Spawn.env stdout: StdioCollector { id: fixOut } stderr: StdioCollector { id: fixErr } @@ -469,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;