Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions docs/rashin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <problem>` 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
Expand Down
2 changes: 2 additions & 0 deletions ryoku/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions ryoku/hub/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 34 additions & 6 deletions ryoku/hub/quickshell/pages/UpdatesPage.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()
}
Expand Down
14 changes: 12 additions & 2 deletions ryoku/rashin/backend/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
77 changes: 76 additions & 1 deletion ryoku/rashin/backend/fix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
38 changes: 29 additions & 9 deletions ryoku/rashin/backend/fixcli.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ const fixUsage = `usage: ryoku-rashin fix doctor [finding name]
ryoku-rashin fix tip <id>
ryoku-rashin fix app <name> [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.
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
50 changes: 40 additions & 10 deletions ryoku/rashin/backend/fixterm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand All @@ -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,
Expand Down
Loading