From fcd909923b9e0bc64aa2cef78b185897df49d7dc Mon Sep 17 00:00:00 2001 From: enowdev Date: Sat, 7 Nov 2026 01:52:26 +0700 Subject: [PATCH 1/6] Keep two versions of the app from taking the daemon from each other After the update from 0.7.0 to 0.7.1 on Windows, two 0.7.0 apps stayed running. Each app replaced a daemon of any version but its own, so the old apps took it back to 0.7.0 and the new one to 0.7.1, every half minute (#49). An app now replaces only an older daemon, or another build of its own version, and leaves a newer one as it is, saying so; and on its version's first run the app ends its program's other apps still running, asking first. Co-Authored-By: Claude Opus 5.5 --- cmd/enowx/main.go | 1 + cmd/enowx/older_instances.go | 90 ++++++++++++++++++++++++++++ cmd/enowx/older_instances_test.go | 34 +++++++++++ cmd/enowx/older_instances_unix.go | 8 +++ cmd/enowx/older_instances_windows.go | 12 ++++ docs/updates.md | 4 ++ hostconn/fits_test.go | 32 ++++++++++ hostconn/hostconn.go | 31 +++++++++- ui/daemon_behind.go | 33 +++++++++- version/compare.go | 46 ++++++++++++++ version/compare_test.go | 18 ++++++ 11 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 cmd/enowx/older_instances.go create mode 100644 cmd/enowx/older_instances_test.go create mode 100644 cmd/enowx/older_instances_unix.go create mode 100644 cmd/enowx/older_instances_windows.go create mode 100644 hostconn/fits_test.go create mode 100644 version/compare.go create mode 100644 version/compare_test.go diff --git a/cmd/enowx/main.go b/cmd/enowx/main.go index a54c45d1..3488c953 100644 --- a/cmd/enowx/main.go +++ b/cmd/enowx/main.go @@ -68,6 +68,7 @@ func main() { if !takeLinks() { return // the app already running got this launch's link } + endOlderInstances() // an update's leftovers, before the daemon is reached memtune.Start(nil) // for the app; a daemon of this program starts its own in Run remote := flag.String("remote", "", "open the server at `user@host[:port]` over SSH") diff --git a/cmd/enowx/older_instances.go b/cmd/enowx/older_instances.go new file mode 100644 index 00000000..e60a461b --- /dev/null +++ b/cmd/enowx/older_instances.go @@ -0,0 +1,90 @@ +package main + +import ( + "log" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/enowx/enowx/core/procstat" + "github.com/enowx/enowx/transport" + "github.com/enowx/enowx/version" +) + +// An update starts the new app, and an app of the version before can stay running: on +// Windows two did, and each took the daemon back to its own version, over and over +// (issue #49). On its version's first run, the app ends the others of its program left +// running, asking first and forcing after endWait. + +// endWait is how long an older app has to quit when asked, before it is ended. +const endWait = 5 * time.Second + +// endOlderInstances ends this program's other apps, on this version's first run here. +func endOlderInstances() { + dir, err := transport.Dir() + if err != nil { + return + } + marker := filepath.Join(dir, "app-version") + if prev, err := os.ReadFile(marker); err == nil && strings.TrimSpace(string(prev)) == version.String { + return // this version ran here before: others are the user's + } + _ = os.WriteFile(marker, []byte(version.String+"\n"), 0o600) + exe, err := os.Executable() + if err != nil { + return + } + procs, err := procstat.Snapshot() + if err != nil { + return + } + pids := otherApps(procs, os.Getpid(), filepath.Base(exe), procstat.Args) + for _, pid := range pids { + log.Printf("enowx: ending an app left running by the update (pid %d)", pid) + askToQuit(pid) + } + deadline := time.Now().Add(endWait) + for _, pid := range pids { + for stillRuns(pid) && time.Now().Before(deadline) { + time.Sleep(100 * time.Millisecond) + } + if stillRuns(pid) { + if p, err := os.FindProcess(pid); err == nil { + _ = p.Kill() + } + } + } +} + +// otherApps are the processes of program name that are its app, other than self: run +// with no argument, or only flags. Its daemon, hooks and command line take a word. +func otherApps(procs []procstat.Proc, self int, name string, args func(int) []string) []int { + var out []int + for _, p := range procs { + if p.PID == self || !strings.EqualFold(p.Name, name) { + continue + } + argv := args(p.PID) + if len(argv) == 0 || !strings.EqualFold(programName(argv[0]), name) { + continue // can't tell what it is: left alone + } + if len(argv) > 1 && !strings.HasPrefix(argv[1], "-") { + continue + } + out = append(out, p.PID) + } + slices.Sort(out) + return out +} + +// programName is a command line's program without its folder, whichever separator it +// uses. +func programName(path string) string { return path[strings.LastIndexAny(path, `/\`)+1:] } + +// stillRuns says process pid is there. +func stillRuns(pid int) bool { + procs, err := procstat.Snapshot() + return err == nil && slices.ContainsFunc(procs, func(p procstat.Proc) bool { return p.PID == pid }) +} diff --git a/cmd/enowx/older_instances_test.go b/cmd/enowx/older_instances_test.go new file mode 100644 index 00000000..821defde --- /dev/null +++ b/cmd/enowx/older_instances_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "slices" + "testing" + + "github.com/enowx/enowx/core/procstat" +) + +// Only this program's other apps are ended: not itself, its daemon, a hook or the +// command line, nor another program, nor one whose arguments can't be read. +func TestOtherAppsAreThisProgramsApps(t *testing.T) { + procs := []procstat.Proc{ + {PID: 1, Name: "enowX.exe"}, // this app + {PID: 2, Name: "enowX.exe"}, // an older app + {PID: 3, Name: "enowx.exe"}, // an older app, another case + {PID: 4, Name: "enowX.exe"}, // its command line + {PID: 5, Name: "enowx-daemon.exe"}, // the daemon + {PID: 6, Name: "enowX.exe"}, // a hook + {PID: 7, Name: "notepad.exe"}, // another program + {PID: 8, Name: "enowX.exe"}, // arguments unknown + {PID: 9, Name: "enowX.exe"}, // an app opened with a flag + } + args := map[int][]string{ + 1: {`C:\enowX\enowX.exe`}, 2: {`C:\enowX\enowX.exe`}, 3: {`C:\enowX\enowx.exe`}, + 4: {`C:\enowX\enowX.exe`, "ls"}, 5: {`C:\enowX\enowx-daemon.exe`, "serve"}, + 6: {`C:\enowX\enowX.exe`, "hook", "--event", "x"}, 7: {`notepad.exe`}, + 9: {`C:\enowX\enowX.exe`, "-remote", "u@h"}, + } + got := otherApps(procs, 1, "enowX.exe", func(pid int) []string { return args[pid] }) + if want := []int{2, 3, 9}; !slices.Equal(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} diff --git a/cmd/enowx/older_instances_unix.go b/cmd/enowx/older_instances_unix.go new file mode 100644 index 00000000..08492657 --- /dev/null +++ b/cmd/enowx/older_instances_unix.go @@ -0,0 +1,8 @@ +//go:build !windows + +package main + +import "syscall" + +// askToQuit asks app pid to quit, as the system does at logout. +func askToQuit(pid int) { _ = syscall.Kill(pid, syscall.SIGTERM) } diff --git a/cmd/enowx/older_instances_windows.go b/cmd/enowx/older_instances_windows.go new file mode 100644 index 00000000..e86c61fc --- /dev/null +++ b/cmd/enowx/older_instances_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package main + +import ( + "strconv" + + "github.com/enowx/enowx/internal/quietcmd" +) + +// askToQuit asks app pid to close its windows, as closing them by hand does. +func askToQuit(pid int) { _ = quietcmd.Command("taskkill", "/PID", strconv.Itoa(pid)).Run() } diff --git a/docs/updates.md b/docs/updates.md index 610422fb..c6405909 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -19,6 +19,10 @@ GitHub Releases of `enowdev/enowx` (`updates.github` in `cmd/enowx/mygo.json`): The secret half of that key signs every release. It is the repository secret `MYGO_UPDATER_PRIVATE_KEY`, and a copy is kept by the maintainer outside the repository: installed apps accept only updates signed with it, so losing it strands them, and whoever has it can ship code to every user. Never commit it. +### Two versions at once + +An app older than the daemon it reaches leaves the daemon as it is and says so in a card ("This enowX is older than enowxd…"): only an app at least as new as its daemon replaces it, so an app the update left running can't take the daemon back to its version, nor the new app take it again, over and over (issue #49). On its version's first run on a device, the app ends the other apps of its program still running, asking each to quit and ending it after 5 seconds: their daemon, hooks and command line, run with a word after the program's name, are left alone. + ## Releasing 1. Set the version in `cmd/enowx/mygo.json`, and add its section to `CHANGELOG.md` (`## x.y.z`): it is what the update dialog shows. diff --git a/hostconn/fits_test.go b/hostconn/fits_test.go new file mode 100644 index 00000000..c4ba6778 --- /dev/null +++ b/hostconn/fits_test.go @@ -0,0 +1,32 @@ +package hostconn + +import ( + "testing" + + "github.com/enowx/enowx/proto" +) + +// A newer app replaces an older daemon, and another build of its own version; an older +// app leaves a newer daemon alone, so two versions left running can't take the daemon +// from each other in turn (issue #49). +func TestDaemonFitsTheApp(t *testing.T) { + for _, c := range []struct { + name string + daemon, build string + want int + }{ + {"same", "0.7.1", "b1", daemonSame}, + {"older daemon, newer app", "0.7.0", "b0", daemonOther}, + {"another build of this version", "0.7.1", "b2", daemonOther}, + {"newer daemon, older app", "0.7.2", "b3", daemonNewer}, + {"a daemon that says no version", "", "", daemonOther}, + } { + if got := daemonFits(proto.DaemonInfo{Version: c.daemon, Build: c.build}, "0.7.1", "b1"); got != c.want { + t.Errorf("%s: %d, want %d", c.name, got, c.want) + } + } + // The other side of the same pair: the 0.7.0 app leaves the 0.7.1 daemon. + if got := daemonFits(proto.DaemonInfo{Version: "0.7.1", Build: "b1"}, "0.7.0", "b0"); got != daemonNewer { + t.Errorf("an older app took a newer daemon: %d", got) + } +} diff --git a/hostconn/hostconn.go b/hostconn/hostconn.go index 46048b32..cc65bd4f 100644 --- a/hostconn/hostconn.go +++ b/hostconn/hostconn.go @@ -60,6 +60,9 @@ type Options struct { // Kept, if set, is told a daemon of another version was left running, and what // replacing it would stop. Kept func(info proto.DaemonInfo, plan proto.RestartPlan) + // Newer, if set, is told the daemon is of a newer version than this app, left as it + // is: an app the update left running mustn't take it back (issue #49). + Newer func(info proto.DaemonInfo) // Password gives the SSH password of hosts with PasswordAuth. Password func(hosts.Host) (string, error) // Passphrase is asked for the passphrase of an SSH key the server @@ -139,7 +142,15 @@ func connectLocal(opts Options) (*client.Client, error) { c.Close() return nil, err } - if info.Version == version.String && info.Build == version.Build() { + switch daemonFits(info, version.String, version.Build()) { + case daemonSame: + return c, nil + case daemonNewer: + // An app older than the daemon steps aside: replacing it would undo an update, + // and the newer app would replace it back, over and over. + if opts.Newer != nil { + opts.Newer(info) + } return c, nil } if len(opts.DaemonCommand) == 0 { @@ -165,6 +176,24 @@ func connectLocal(opts Options) (*client.Client, error) { return dial() } +// How a running daemon fits this app. +const ( + daemonSame = iota // this version and build: use it + daemonOther // older, or another build of this version: replace it + daemonNewer // newer than this app: leave it +) + +// daemonFits says how a daemon of info fits an app of version and build. +func daemonFits(info proto.DaemonInfo, v, build string) int { + switch { + case info.Version == v && info.Build == build: + return daemonSame + case version.Older(v, info.Version): + return daemonNewer + } + return daemonOther +} + // keepBusyDaemon says the daemon c reaches stays, replacing it being something to ask // about first. A daemon that can't say is left alone too: nothing dies unasked. func keepBusyDaemon(c *client.Client, info proto.DaemonInfo, opts Options) bool { diff --git a/ui/daemon_behind.go b/ui/daemon_behind.go index 0a200454..6037725a 100644 --- a/ui/daemon_behind.go +++ b/ui/daemon_behind.go @@ -14,6 +14,7 @@ import ( "github.com/enowx/enowx/ui/icon" "github.com/enowx/enowx/ui/theme" "github.com/enowx/enowx/ui/widget" + "github.com/enowx/enowx/version" ) // An update replaces the app at once, and a host's daemon only when that stops nothing: @@ -29,6 +30,8 @@ type daemonBehind struct { plan proto.RestartPlan // waiting: replace it as soon as that stops nothing. hidden: don't show the card. waiting, hidden bool + // newer: the daemon is newer than this app, which leaves it as it is. + newer bool } // daemonBehindOptions has a connection to h say so if it leaves a daemon behind, and @@ -41,6 +44,14 @@ func (a *App) daemonBehindOptions(opts *hostconn.Options, h hosts.Host, gen int) } waiting := a.daemonBehind != nil && a.daemonBehind.host == h.ID && a.daemonBehind.waiting a.daemonBehind = nil + opts.Newer = func(info proto.DaemonInfo) { + a.diag.Event("daemon.newer", "host", h.ID, "version", info.Version) + a.onMain(func() { + if gen == a.gen { + a.daemonBehind = &daemonBehind{host: h.ID, name: h.Name, version: info.Version, newer: true} + } + }) + } opts.Kept = func(info proto.DaemonInfo, plan proto.RestartPlan) { a.diag.Event("daemon.kept", "host", h.ID, "version", info.Version, "agents", len(plan.Agents), "commands", len(plan.Commands)) a.onMain(func() { @@ -70,7 +81,9 @@ func (a *App) followDaemonBehind(c *client.Client, h hosts.Host, gen int) { // Only a daemon left behind has a plan to keep current: with none, asking every // half minute looked at every process, for nothing. behind := make(chan bool, 1) - a.quietly(func() { behind <- gen == a.gen && a.daemonBehind != nil && a.daemonBehind.host == h.ID }) + a.quietly(func() { + behind <- gen == a.gen && a.daemonBehind != nil && a.daemonBehind.host == h.ID && !a.daemonBehindNewer() + }) select { case <-c.Done(): return @@ -98,6 +111,10 @@ func (a *App) followDaemonBehind(c *client.Client, h hosts.Host, gen int) { } } +// daemonBehindNewer says the daemon left as it is runs a newer version than this app: +// there's no plan to keep current, it isn't this app's to replace. On the main thread. +func (a *App) daemonBehindNewer() bool { return a.daemonBehind != nil && a.daemonBehind.newer } + // showsDaemonBehindCard says the card has something to show. func (a *App) showsDaemonBehindCard() bool { return a.daemonBehind != nil && !a.daemonBehind.hidden && a.daemonBehind.host == a.host.ID @@ -110,6 +127,20 @@ func (a *App) daemonBehindCard(c *ui.Context) { return } b := a.daemonBehind + if b.newer { + widget.Float(ui.Column(c).FillWidth().Padding(theme.S3).Gap(theme.S2).Radius(theme.RadiusCard). + Background(theme.Dialog).Border(1, theme.BorderStrong)).Transition(widget.OverlayMotion).Children(func() { + widget.Running(c, "This enowX ("+version.String+") is older than enowxd on "+b.name+" ("+b.version+")", widget.RunStyle{Weight: 500}) + ui.Text(c, "A newer enowX runs it, so this one leaves it as it is. Quit this one and use the newer app."). + FontSize(theme.TextLabel).TextColor(theme.TextSoft) + ui.Row(c).FillWidth().Justify(ui.End).Children(func() { + if widget.GhostButton(c, "Hide").Clicked() { + b.hidden = true + } + }) + }) + return + } widget.Float(ui.Column(c).FillWidth().Padding(theme.S3).Gap(theme.S2).Radius(theme.RadiusCard). Background(theme.Dialog).Border(1, theme.BorderStrong)).Transition(widget.OverlayMotion).Children(func() { widget.Running(c, "enowxd on "+b.name+" stays at "+b.version+" for now", widget.RunStyle{Weight: 500}) diff --git a/version/compare.go b/version/compare.go new file mode 100644 index 00000000..dbea5372 --- /dev/null +++ b/version/compare.go @@ -0,0 +1,46 @@ +package version + +import ( + "strconv" + "strings" +) + +// Older says version a is older than b: their numbers compared in turn, what follows a +// "-" or "+" (a development build's mark) left out. A version that isn't one of numbers +// is older than none, and none older than it, as nothing can be told. +func Older(a, b string) bool { + x, okA := numbers(a) + y, okB := numbers(b) + if !okA || !okB { + return false + } + for i := range max(len(x), len(y)) { + var p, q int + if i < len(x) { + p = x[i] + } + if i < len(y) { + q = y[i] + } + if p != q { + return p < q + } + } + return false +} + +func numbers(v string) ([]int, bool) { + v = strings.TrimPrefix(v, "v") + if i := strings.IndexAny(v, "-+"); i >= 0 { + v = v[:i] + } + var out []int + for _, part := range strings.Split(v, ".") { + n, err := strconv.Atoi(part) + if err != nil { + return nil, false + } + out = append(out, n) + } + return out, len(out) > 0 +} diff --git a/version/compare_test.go b/version/compare_test.go new file mode 100644 index 00000000..d7a64e59 --- /dev/null +++ b/version/compare_test.go @@ -0,0 +1,18 @@ +package version + +import "testing" + +func TestOlder(t *testing.T) { + for _, c := range []struct { + a, b string + want bool + }{ + {"0.7.0", "0.7.1", true}, {"0.7.1", "0.7.0", false}, {"0.7.1", "0.7.1", false}, + {"0.6.10", "0.7.0", true}, {"0.7.9", "0.7.10", true}, {"0.7", "0.7.1", true}, + {"0.7.1-dev", "0.7.1", false}, {"v0.7.0", "0.7.1", true}, {"dev", "0.7.1", false}, {"0.7.1", "dev", false}, + } { + if got := Older(c.a, c.b); got != c.want { + t.Errorf("Older(%q, %q) = %v", c.a, c.b, got) + } + } +} From 6e2d447ebf064cb9ed44cc82454fe7b31e57d866 Mon Sep 17 00:00:00 2001 From: enowdev Date: Sat, 7 Nov 2026 01:58:24 +0700 Subject: [PATCH 2/6] Let a new GOD of Agents start beside one that ended Starting GOA was refused as already running on a host whose old GOA had ended (#49). The check already skipped a session marked ended, so the old one must have looked alive: most likely its agent had ended and its fallback shell kept the session up, which the check took as GOA at work. A GOA now counts only at work with its agent there, or asleep and able to come back, saying how to wake it; one that ended stays as history, and can't come back while another GOA lives, so a host never has two at work. Co-Authored-By: Claude Opus 5.5 --- daemon/goa.go | 33 +++++++++++++++++++-- daemon/goa_exited_test.go | 60 +++++++++++++++++++++++++++++++++++++++ daemon/persist.go | 5 ++++ docs/goa.md | 2 +- 4 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 daemon/goa_exited_test.go diff --git a/daemon/goa.go b/daemon/goa.go index ff65589d..72541dd7 100644 --- a/daemon/goa.go +++ b/daemon/goa.go @@ -94,13 +94,42 @@ func (s *Server) goaSession() (proto.SessionInfo, bool) { return found, ok } +// goaLive is a GOA session other than except that keeps another from running, and why: +// one at work, its agent there, or one asleep that comes back on its conversation. One +// that ended, or whose agent ended leaving only its fallback shell in the terminal, is +// history: it holds nothing (issue #49), and comes back only once no other GOA lives +// (goaResumeAllowed). A host never has two GOAs at work. +func (s *Server) goaLive(except string) (proto.SessionInfo, string, bool) { + for _, sess := range s.sessions.List() { + info := sess.Info() + if !info.GOA || info.ID == except { + continue + } + switch { + case !info.Exited && !s.agentGone(info.ID): + return info, "already runs", true + case info.Exited && info.Hibernated && goa.ComesBack(info): + return info, "is asleep (hibernated): wake it with `wake " + info.ID + "`, or close it to start another,", true + } + } + return proto.SessionInfo{}, "", false +} + +// goaResumeAllowed says GOA session id may come back: not while another GOA lives. +func (s *Server) goaResumeAllowed(id string) error { + if g, why, ok := s.goaLive(id); ok { + return fmt.Errorf("goa: another GOD of Agents %s on this host: session %s; this one stays as it is", why, g.ID) + } + return nil +} + // goaCreate makes p and meta GOA's: its folder, no project or branch, // every feature, its permissions asked (enowX's tools need none) unless its agent // always skips them, as Claude Code does: there its hook's refusals hold GOA. One GOA // runs on a host at a time. func (s *Server) goaCreate(p *proto.SessionCreateParams, meta *session.Meta) (string, error) { - if g, ok := s.goaSession(); ok && !g.Exited { - return "", fmt.Errorf("goa: GOD of Agents already runs on this host: session %s", g.ID) + if g, why, ok := s.goaLive(""); ok { + return "", fmt.Errorf("goa: GOD of Agents %s on this host: session %s", why, g.ID) } if _, err := s.goaAgentReady(p.Agent); err != nil { return "", err diff --git a/daemon/goa_exited_test.go b/daemon/goa_exited_test.go new file mode 100644 index 00000000..f0209346 --- /dev/null +++ b/daemon/goa_exited_test.go @@ -0,0 +1,60 @@ +package daemon + +import ( + "runtime" + "strings" + "testing" + + "github.com/enowx/enowx/core/session" + "github.com/enowx/enowx/proto" +) + +// A GOA session that ended is history: a new GOA starts beside it, and is the one the +// host's GOA is, while the old one can't come back as a second. One running, or asleep +// and able to come back, still keeps a second from starting (issue #49). +func TestGOAStartsBesideAnEndedOne(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses sh") + } + fakeAgents(t, "claude") + srv := startServer(t) + c := connect(t, srv, nil) + old, err := srv.sessions.Create(session.Spec{Command: []string{"sh", "-c", "exit 0"}, Dir: srv.goaDir(), Cols: 80, Rows: 24, + Exited: true, Meta: session.Meta{Agent: "claude", GOA: true, Relaunch: true}}) + if err != nil { + t.Fatal(err) + } + g, err := c.CreateSession(proto.SessionCreateParams{Agent: "claude", GOA: true, Cols: 80, Rows: 24}) + if err != nil { + t.Fatalf("a GOA beside one that ended: %v", err) + } + if now, ok := srv.goaSession(); !ok || now.ID != g.ID { + t.Fatalf("the host's GOA is %s, want %s", now.ID, g.ID) + } + if _, err := c.ContinueSession(old.ID()); err == nil || !strings.Contains(err.Error(), "another GOD of Agents") { + t.Fatalf("the old one came back beside it: %v", err) + } + if _, err := c.CreateSession(proto.SessionCreateParams{Agent: "claude", GOA: true}); err == nil || !strings.Contains(err.Error(), "already runs") { + t.Fatalf("a second GOA beside one running: %v", err) + } +} + +// A GOA asleep comes back on its conversation: a new one waits until it is woken or +// closed. +func TestGOAAsleepKeepsASecondFromStarting(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses sh") + } + fakeAgents(t, "claude") + srv := startServer(t) + c := connect(t, srv, nil) + asleep, err := srv.sessions.Create(session.Spec{Command: []string{"sh", "-c", "exit 0"}, Dir: srv.goaDir(), Cols: 80, Rows: 24, + Exited: true, Meta: session.Meta{Agent: "claude", GOA: true, Relaunch: true, Hibernated: true}}) + if err != nil { + t.Fatal(err) + } + _, err = c.CreateSession(proto.SessionCreateParams{Agent: "claude", GOA: true}) + if err == nil || !strings.Contains(err.Error(), "asleep") || !strings.Contains(err.Error(), asleep.ID()) { + t.Fatalf("a GOA beside one asleep: %v", err) + } +} diff --git a/daemon/persist.go b/daemon/persist.go index bae69089..54416540 100644 --- a/daemon/persist.go +++ b/daemon/persist.go @@ -425,6 +425,11 @@ func (s *Server) resumeSessionIn(id, convo, mode string) (*session.Session, erro if !info.Exited { return nil, errors.New(errStillRunning) } + if meta.GOA { + if err := s.goaResumeAllowed(id); err != nil { + return nil, err + } + } keepBypass(&meta) if sess.QuickExits() >= quickExitsMax { return nil, fmt.Errorf("it ended right after starting %d times in a row, so enowX won't start it again by itself: %s", quickExitsMax, info.ExitWhy) diff --git a/docs/goa.md b/docs/goa.md index 6b8deb13..648c0a1a 100644 --- a/docs/goa.md +++ b/docs/goa.md @@ -21,7 +21,7 @@ Leaving the mode asks first, whichever way: the title bar's **Sessions**, **Leav ## Its session -`session.create` with `goa: true` (the app's start, or `enowx goa start --agent A`): one GOA per host at a time. +`session.create` with `goa: true` (the app's start, or `enowx goa start --agent A`): one GOA per host at a time. What counts is a GOA at work, with its agent there, or one asleep (hibernated), which comes back on its conversation: `wake` it, or close it, to start another. One that ended, or whose agent ended leaving only its shell, is kept as history and doesn't count; the new GOA is the host's, and the old one can't come back while another lives. - **A folder of its own**, outside every project (`/goa`), with no branch. - **Every feature on**, its guide `agent.GOAGuide` first: delegate, don't work; don't poll; who answers which permission; keep the board and its collection. From 0f3cdc30d2ec7ea26da7dd922c822b8df65b0bb9 Mon Sep 17 00:00:00 2001 From: enowdev Date: Sat, 7 Nov 2026 02:02:14 +0700 Subject: [PATCH 3/6] Show the terminal for an agent without a chat A chat chosen while Claude Code ran GOA was kept after a switch to OpenCode, which has none: the header hid the toggle, but the body still drew the chat, leaving an empty view with no way back (#49). A session whose agent has no chat now shows its terminal whatever was chosen, and a switch to such an agent forgets the chat chosen before. Co-Authored-By: Claude Opus 5.5 --- docs/chat-view.md | 3 ++ ui/app.go | 4 +++ ui/chat.go | 27 +++++++++++++++-- ui/chat_fallback_test.go | 65 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 ui/chat_fallback_test.go diff --git a/docs/chat-view.md b/docs/chat-view.md index c56f48e7..f8eeb9aa 100644 --- a/docs/chat-view.md +++ b/docs/chat-view.md @@ -97,3 +97,6 @@ The context meter beside the send button is the tokens of Claude Code's last ans A project's menu has **History…** and the command palette **Past conversations**: what Claude Code and Codex talked about in the project's folder and its worktrees, newest first (`agent.history`, read on the project's host from the same files as the chat: `core/transcript/history.go`), each with its first message, branch, message count, model and age. **Resume** starts a session of that agent going on with it (`SessionCreateParams.Resume`: `claude --resume `, `codex resume `, the ID being the transcript's name for Claude Code and the rollout's `session_meta` ID for Codex), in the folder it ran in: the project's, or its branch's worktree. The New session dialog's first field, **Session**, lists the same: the project's sessions (running, idle or hibernated) and its agents' past conversations, the latest first, to search by name or branch. Picking one goes on with it instead of starting a new session: **Open session** shows a running one, **Wake up** wakes a hibernated one, **Resume** resumes a past conversation where it ran; what the message box holds is sent to it (typed into a resumed conversation once its agent is back). + +**An agent without a chat shows its terminal,** whatever view was chosen for the session before: a switch to it (`switch`, GOA's change of agent) forgets a chat chosen while another agent ran it, and the view never draws a chat for an agent that has none, so it can't end on an empty one with the toggle back hidden (issue #49). + diff --git a/ui/app.go b/ui/app.go index 6d97d83e..07bfff97 100644 --- a/ui/app.go +++ b/ui/app.go @@ -715,6 +715,10 @@ func (a *App) upsert(info proto.SessionInfo) { // A program that starts again (continued, or a queued agent's) // runs in a new terminal of the daemon: the old stream shows the // last one, so attach anew. + // Another agent now (a switch, GOA's own): views it hasn't go. + if old := a.sessions[i]; old.Agent != info.Agent && a.forgetViewsOf(info) { + a.saveState() + } if old := a.sessions[i]; viewOutdated(old, info) { if p := a.panes[info.ID]; p != nil { p.close() diff --git a/ui/chat.go b/ui/chat.go index da01d276..88bb4bb3 100644 --- a/ui/chat.go +++ b/ui/chat.go @@ -51,11 +51,32 @@ func (a *App) toggleChat(s proto.SessionInfo) { // showsChat says session id shows as a chat: as the user last chose, and an agent whose // chat is its main face from the start. func (a *App) showsChat(id string) bool { - if on, chose := a.state.Chats[id]; chose { + s, ok := a.sessionByID(id) + on, chose := a.state.Chats[id] + return chatShown(s, ok, on, chose) +} + +// chatShown says session s (known: it is in the list) shows its chat: as the user chose +// (on, chose), else its agent's way. Never for an agent without a chat: one chosen while +// another agent ran it showed an empty view, the toggle back hidden with it (issue #49). +func chatShown(s proto.SessionInfo, known, on, chose bool) bool { + switch { + case known && !canChat(s): + return false + case chose: return on } - s, ok := a.sessionByID(id) - return ok && chatview.OwnChat(s.Agent) + return known && chatview.OwnChat(s.Agent) +} + +// forgetViewsOf drops what was chosen for session info's views that its agent, new to +// it, doesn't have: a chat. Its terminal stays. It reports whether anything went. +func (a *App) forgetViewsOf(info proto.SessionInfo) bool { + if _, chose := a.state.Chats[info.ID]; !chose || canChat(info) { + return false + } + delete(a.state.Chats, info.ID) + return true } // chatView draws the chat in place of the terminal. diff --git a/ui/chat_fallback_test.go b/ui/chat_fallback_test.go new file mode 100644 index 00000000..8303a1c1 --- /dev/null +++ b/ui/chat_fallback_test.go @@ -0,0 +1,65 @@ +package ui + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "github.com/enowx/enowx/proto" +) + +// A chat chosen while Claude Code ran a session isn't shown once OpenCode, with none, +// runs it: the terminal is, as the header's toggle back is hidden (issue #49). +func TestChatFallsBackToTheTerminal(t *testing.T) { + claude := proto.SessionInfo{ID: "g", Agent: "claude"} + opencode := proto.SessionInfo{ID: "g", Agent: "opencode"} + for _, c := range []struct { + name string + s proto.SessionInfo + known, on, chose bool + want bool + }{ + {"chosen on an agent with a chat", claude, true, true, true, true}, + {"chosen, then the agent has none", opencode, true, true, true, false}, + {"left on an agent with a chat", claude, true, false, true, false}, + {"not chosen, an agent with its own chat", proto.SessionInfo{ID: "p", Agent: "claude-platform"}, true, false, false, true}, + {"not in the list yet, chosen", proto.SessionInfo{}, false, true, true, true}, + } { + if got := chatShown(c.s, c.known, c.on, c.chose); got != c.want { + t.Errorf("%s: %v", c.name, got) + } + } +} + +// Switching a session to an agent without a chat forgets its chat, kept or not; to one +// with a chat keeps it. The body then draws the terminal's place, not an empty chat. +func TestSwitchToAnAgentWithoutAChatForgetsIt(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + app := New(Options{}) + app.state.Chats = map[string]bool{"g": true, "h": true} + app.upsert(proto.SessionInfo{ID: "g", Agent: "claude"}) + app.upsert(proto.SessionInfo{ID: "h", Agent: "claude"}) + app.upsert(proto.SessionInfo{ID: "g", Agent: "opencode"}) + app.upsert(proto.SessionInfo{ID: "h", Agent: "codex"}) + if _, kept := app.state.Chats["g"]; kept { + t.Error("OpenCode's session kept the chat chosen on Claude Code") + } + if !app.state.Chats["h"] { + t.Error("a switch to an agent with a chat lost it") + } + g, _ := app.sessionByID("g") + if app.currentView(g) != viewTerminal { + t.Fatalf("view: %s", app.currentView(g)) + } + // Chosen again by something that doesn't know the agent changed: still the terminal. + app.state.Chats["g"] = true + tt := ui.NewTester(func(c *ui.Context) { app.sessionBody(c, g) }, 800, 500) + tt.Frame() + shot(t, tt, "chat-fallback-opencode.png") + if !tt.HasText("Connecting…") { + t.Errorf("not the terminal's place: %q", tt.Texts()) + } + if app.showsChat("g") || app.currentView(g) != viewTerminal { + t.Fatal("the chat shows on an agent without one") + } +} From d3ef3eab6b9c806a05084ec3da6661a26eece943 Mon Sep 17 00:00:00 2001 From: enowdev Date: Sat, 7 Nov 2026 02:15:01 +0700 Subject: [PATCH 4/6] Add a restart for GOD of Agents that goes on with its conversation The user can restart GOA on the agent and config it runs on (Settings, goa restart, MCP goa_restart) through the config change's path, so what holds it stays. A conversation that can't be gone on with is reported, and a new one starts only when asked. A restart also clears the quick-exit count that held continues and changes back for 30 seconds after a failed start. GOA isn't given it, as it would end its own turn. Co-Authored-By: Claude Opus 5.5 --- cli/goa.go | 3 + cli/goa_mcp.go | 7 +++ cli/goa_restart.go | 75 ++++++++++++++++++++++ cli/goa_restart_test.go | 21 +++++++ core/session/work.go | 7 +++ daemon/goa_restart.go | 74 ++++++++++++++++++++++ daemon/goa_restart_test.go | 123 +++++++++++++++++++++++++++++++++++++ daemon/goa_switch.go | 36 ++++++++--- daemon/rpc_goa.go | 7 ++- docs/goa-tools.md | 2 +- docs/goa.md | 6 +- proto/goa.go | 8 +++ proto/testdata/api.txt | 2 + ui/goa_settings.go | 34 ++++++++++ 14 files changed, 390 insertions(+), 15 deletions(-) create mode 100644 cli/goa_restart.go create mode 100644 cli/goa_restart_test.go create mode 100644 daemon/goa_restart.go create mode 100644 daemon/goa_restart_test.go diff --git a/cli/goa.go b/cli/goa.go index 7e153934..0d73225d 100644 --- a/cli/goa.go +++ b/cli/goa.go @@ -50,6 +50,7 @@ const goaUsage = `usage: enowx-app goa COMMAND flags what a GOA only watched did itself start [--agent A] start GOD of Agents on this host switch [--agent A] [--config NAME|official] [--check] [--new-conversation] [--force] + restart [--check] [--force] [--new-conversation] GOA again on its agent and config, its conversation going on what GOA runs on; change it, restarting GOA: a config keeps its conversation (or GOA stays as it was), an agent starts a new one` // goaReport is a session reporting how its turn ended, to its own host's daemon. @@ -120,6 +121,8 @@ func (r runner) goaCmd(args []string) error { return r.goaFlags() case "start": return r.goaStart(rest) + case "restart": + return r.goaRestart(rest) case "switch": return r.goaSwitch(rest) } diff --git a/cli/goa_mcp.go b/cli/goa_mcp.go index f2629b24..dfd7626d 100644 --- a/cli/goa_mcp.go +++ b/cli/goa_mcp.go @@ -76,6 +76,13 @@ var goaTools = []mcpTool{ } return withFlag(args, a, "new_conversation", "new-conversation") }}, + {name: "goa_restart", description: "Restart GOD of Agents on its agent and config, going on with its conversation, for the user (GOA can't restart itself). At work it needs force; a conversation it can't go on with, new_conversation, and says why.", + props: map[string]mcpProp{"check": {"boolean", "Only say what the restart would do"}, "force": {"boolean", "The user agreed to cut off what GOA is at"}, + "new_conversation": {"boolean", "The user agreed that GOA starts on a new conversation"}}, + args: func(a map[string]any) []string { + args := withFlag(withFlag([]string{"restart"}, a, "check", "check"), a, "force", "force") + return withFlag(args, a, "new_conversation", "new-conversation") + }}, {name: "goa_agents", description: "The agents GOD of Agents can run on here, and how far each holds it to delegating: enforced, limited or watched.", args: func(map[string]any) []string { return []string{"agents"} }}, } diff --git a/cli/goa_restart.go b/cli/goa_restart.go new file mode 100644 index 00000000..04f5462f --- /dev/null +++ b/cli/goa_restart.go @@ -0,0 +1,75 @@ +package cli + +import ( + "errors" + "fmt" + "os" + + "github.com/enowx/enowx/client" + "github.com/enowx/enowx/proto" +) + +// goaRestartUsage is `goa restart`'s line of help. +const goaRestartUsage = "usage: enowx-app goa restart [--check] [--force] [--new-conversation]" + +// goaRestart restarts GOA on its agent and config, going on with its conversation +// (docs/goa.md). Never from GOA's own session: it ends the turn asking for it. +func (r runner) goaRestart(args []string) error { + fs := flagSet("goa restart") + check := fs.Bool("check", false, "") + fresh := fs.Bool("new-conversation", false, "") + force := fs.Bool("force", false, "") + if words, err := parseAll(fs, args); err != nil || len(words) > 0 { + return errors.New(goaRestartUsage) + } + p := proto.GOASwitchParams{Check: *check, NewConversation: *fresh, Force: *force, From: os.Getenv(EnvSession)} + if !p.Check && os.Getenv(proto.EnvGOA) != "" { + return errors.New("GOD of Agents can't restart itself, as that ends the turn it asks in: tell the user to restart it from GOA's header, or with `goa restart` in a terminal") + } + var res proto.GOASwitchResult + var err error + if p.Check { + err = r.c.Call(proto.MethodGOARestart, p, &res) + } else { + err = r.guarded(proto.MethodGOARestart, p, &res) // an agent asking: the user's yes + } + if client.IsUnknownMethod(err) { + return errors.New("this host's enowX is too old to restart GOD of Agents this way: update it, or change its config and back in Settings") + } + if err != nil { + return err + } + if !p.Check && !res.Restarted { + return goaRestartNeeds(res) + } + fmt.Fprintln(r.out, goaRestartText(res, p.Check)) + return nil +} + +// goaRestartNeeds is the yes res lacks, nothing done. +func goaRestartNeeds(res proto.GOASwitchResult) error { + if res.Unresumable != "" { + return fmt.Errorf("GOD of Agents can't go on with its conversation (%s): restart it on a new one with --new-conversation", res.Unresumable) + } + return errors.New("GOD of Agents is at work, and restarting cuts that off: run it again with --force") +} + +// goaRestartText says what a restart did, or would do. +func goaRestartText(res proto.GOASwitchResult, check bool) string { + conversation := "its conversation goes on" + switch { + case res.Conversation == proto.GOAConversationNew && res.Unresumable != "": + conversation = "on a new conversation, as it can't go on with its own (" + res.Unresumable + ")" + case res.Conversation == proto.GOAConversationNew: + conversation = "on a new conversation" + } + on := res.Agent + " on " + nonEmpty(res.Config, "the official config") + if res.Restarted { + return "GOD of Agents restarted: " + on + ", " + conversation + } + text := "It would restart: " + on + ", " + conversation + if res.AtWork { + text += ". It is at work, which that cuts off (--force)" + } + return text +} diff --git a/cli/goa_restart_test.go b/cli/goa_restart_test.go new file mode 100644 index 00000000..83974635 --- /dev/null +++ b/cli/goa_restart_test.go @@ -0,0 +1,21 @@ +package cli + +import ( + "slices" + "strings" + "testing" + + "github.com/enowx/enowx/proto" +) + +// GOA can't restart itself, from its session or over its MCP server, which doesn't offer it. +func TestGOACantRestartItself(t *testing.T) { + t.Setenv(proto.EnvGOA, "1") + err := runner{}.goaRestart(nil) + if err == nil || !strings.Contains(err.Error(), "can't restart itself") { + t.Fatalf("got %v", err) + } + if slices.Contains(goaOfferedTools, "goa_restart") { + t.Fatal("offered to GOA") + } +} diff --git a/core/session/work.go b/core/session/work.go index f28b384d..dcbcb3f4 100644 --- a/core/session/work.go +++ b/core/session/work.go @@ -169,6 +169,13 @@ func (s *Session) QuickExits() int { return s.quickExits } +// ForgetQuickExits lets session s start again whatever ended before: the user asked for it. +func (m *Manager) ForgetQuickExits(s *Session) { + s.mu.Lock() + s.quickExits = 0 + s.mu.Unlock() +} + // SetModel records what session s's agent last said it runs on. func (m *Manager) SetModel(s *Session, model, effort, started string, moved bool) { s.mu.Lock() diff --git a/daemon/goa_restart.go b/daemon/goa_restart.go new file mode 100644 index 00000000..63f700ae --- /dev/null +++ b/daemon/goa_restart.go @@ -0,0 +1,74 @@ +package daemon + +import ( + "errors" + "fmt" + + "github.com/enowx/enowx/proto" +) + +// Restarting GOD of Agents on its agent and config, going on with its conversation: the +// way of a change of config (goa_switch.go), the same session and all it holds. A GOA +// whose agent ended, leaving its shell, isn't "ended" to continue or wake, and one that +// ended right after starting is refused them for a while: restarting stops it whatever it +// is, and is the user's to ask for (docs/goa.md). + +func (s *Server) goaRestart(p proto.GOASwitchParams) (proto.GOASwitchResult, error) { + g, ok := s.goaSession() + if !ok { + return proto.GOASwitchResult{}, errors.New("goa: no GOD of Agents on this host: start it first (goa start)") + } + if p.From == g.ID && !p.Check { + return proto.GOASwitchResult{}, errors.New("goa: GOD of Agents can't restart itself, as that ends the turn it asks in: tell the user to restart it from GOA's header, or with `goa restart` in a terminal") + } + sess, err := s.sessions.Get(g.ID) + if err != nil { + return proto.GOASwitchResult{}, err + } + meta := sess.Meta() + // Its agent and config, checked as a change to them would be; then its conversation. + plan, err := s.goaPlanRun(sess, proto.GOASwitchParams{Agent: meta.Agent, Config: nonEmpty(meta.Account, proto.GOAConfigOfficial)}, true) + if err != nil { + return proto.GOASwitchResult{}, err + } + unresumable := "" + plan.conversation, plan.convo, err = s.goaConversationFor(sess, plan.account) + switch { + case err != nil && !errors.Is(err, errGOAConversation): + return proto.GOASwitchResult{}, err + case err != nil: + unresumable = err.Error() + case plan.conversation == proto.GOAConversationNone: + unresumable = "goa: it has no conversation of its own yet to go on with" + } + plan.same = false + if unresumable != "" || p.NewConversation { + // A new conversation, on the same agent: as GOA starts. + plan.toAgent, plan.conversation, plan.convo = true, proto.GOAConversationNew, "" + } + res := goaSwitchResultOf(g, plan) + res.Unresumable = unresumable + if p.Check { + return res, nil + } + if res.AtWork && !p.Force || unresumable != "" && !p.NewConversation { + return res, nil // not a word of it done: the yes it needs first + } + if err := s.goaKeyReadable(g.ID, plan.account); err != nil { + return proto.GOASwitchResult{}, fmt.Errorf("goa: GOD of Agents keeps running as it was: %w", err) + } + if !s.goaHoldLines() { + return proto.GOASwitchResult{}, errors.New("goa: GOD of Agents is being changed already") + } + defer s.goaReleaseLines() + s.sessions.ForgetQuickExits(sess) // the user asks it to start again + started, err := s.goaRunSwitch(sess, plan) + if err != nil { + if plan.conversation == proto.GOAConversationSame { + err = fmt.Errorf("%w. To start it on a new conversation instead: goa restart --new-conversation", err) + } + return proto.GOASwitchResult{}, err + } + res.Session, res.Restarted, res.Mode = started.Info(), true, started.Meta().Mode + return res, nil +} diff --git a/daemon/goa_restart_test.go b/daemon/goa_restart_test.go new file mode 100644 index 00000000..b5188e39 --- /dev/null +++ b/daemon/goa_restart_test.go @@ -0,0 +1,123 @@ +package daemon + +import ( + "os" + "slices" + "strings" + "testing" + + "github.com/enowx/enowx/core/session" + "github.com/enowx/enowx/proto" +) + +// restart asks for a restart of GOA, then what its agent started with: nil unless it did. +func (r *goaSwitchRig) restart(p proto.GOASwitchParams) (proto.GOASwitchResult, []string, error) { + r.t.Helper() + for _, ext := range []string{".args", ".env"} { + _ = os.Remove(r.out + "/" + r.goa.ID + ext) + } + var res proto.GOASwitchResult + if err := r.c.Call(proto.MethodGOARestart, p, &res); err != nil { + return res, nil, err + } + if !res.Restarted { + return res, nil, nil + } + args, _ := started(r.t, r.out, r.goa.ID) + return res, args, nil +} + +// GOA restarts on its agent and config, going on with its conversation by its ID, in the +// same session; it can't restart itself. +func TestGOARestartGoesOnWithItsConversation(t *testing.T) { + r := newGOASwitchRig(t, "claude") + r.said() + if _, _, err := r.restart(proto.GOASwitchParams{From: r.goa.ID}); err == nil || !strings.Contains(err.Error(), "can't restart itself") { + t.Fatalf("GOA restarted itself: %v", err) + } + res, args, err := r.restart(proto.GOASwitchParams{}) + if err != nil { + t.Fatal(err) + } + if !res.Restarted || res.Conversation != proto.GOAConversationSame || res.Session.ID != r.goa.ID { + t.Fatalf("restart: %+v", res) + } + if i := slices.Index(args, "--resume"); i < 0 || i+1 >= len(args) || args[i+1] != goaSwitchConvo { + t.Fatalf("not resumed by its ID: %q", args) + } +} + +// A GOA whose agent ended right after starting, again and again, is refused continuing for +// a while; the user's restart isn't. +func TestGOARestartAfterQuickExits(t *testing.T) { + r := newGOASwitchRig(t, "claude") + r.said() + sess, err := r.srv.sessions.Get(r.goa.ID) + if err != nil { + t.Fatal(err) + } + if _, err := r.srv.sessions.Replace(session.Spec{ID: r.goa.ID, Dir: r.goa.Dir, Command: []string{"sh", "-c", "exit 1"}, Meta: sess.Meta()}); err != nil { + t.Fatal(err) + } + waitFor(t, "it ended", func() bool { s, err := r.srv.sessions.Get(r.goa.ID); return err == nil && s.Info().Exited }) + for range quickExitsMax { + s, _ := r.srv.sessions.Get(r.goa.ID) + if _, err := r.srv.sessions.Replace(session.Spec{ID: r.goa.ID, Dir: r.goa.Dir, Command: []string{"sh", "-c", "exit 1"}, Meta: s.Meta()}); err != nil { + t.Fatal(err) + } + waitFor(t, "it ended", func() bool { s, err := r.srv.sessions.Get(r.goa.ID); return err == nil && s.Info().Exited }) + } + res, _, err := r.restart(proto.GOASwitchParams{}) + if err != nil || !res.Restarted { + t.Fatalf("restart after quick exits: %+v %v", res, err) + } +} + +// At work, a restart needs the user's yes; with it, it goes. +func TestGOARestartAtWorkAsksFirst(t *testing.T) { + r := newGOASwitchRig(t, "claude") + r.said() + sess, err := r.srv.sessions.Get(r.goa.ID) + if err != nil { + t.Fatal(err) + } + if err := r.srv.sessions.SetState(sess, proto.StateWorking); err != nil { + t.Fatal(err) + } + before := r.info() + res, _, err := r.restart(proto.GOASwitchParams{}) + if err != nil || res.Restarted || !res.AtWork { + t.Fatalf("at work, without force: %+v %v", res, err) + } + r.untouched(before) + if res, _, err = r.restart(proto.GOASwitchParams{Force: true}); err != nil || !res.Restarted { + t.Fatalf("with force: %+v %v", res, err) + } +} + +// A conversation it can't go on with is said, nothing done; a new one starts only when +// asked for, never by itself. +func TestGOARestartWithItsConversationGone(t *testing.T) { + r := newGOASwitchRig(t, "claude") + path := r.said() + if res, _, err := r.restart(proto.GOASwitchParams{}); err != nil || !res.Restarted { + t.Fatalf("first restart: %+v %v", res, err) // it records the conversation it is on + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + r.srv.transcripts.forget(r.goa.ID) + before := r.info() + res, _, err := r.restart(proto.GOASwitchParams{}) + if err != nil || res.Restarted || res.Unresumable == "" || !strings.Contains(res.Unresumable, goaSwitchConvo) { + t.Fatalf("its conversation gone: %+v %v", res, err) + } + r.untouched(before) + res, args, err := r.restart(proto.GOASwitchParams{NewConversation: true}) + if err != nil || !res.Restarted || res.Conversation != proto.GOAConversationNew { + t.Fatalf("on a new conversation: %+v %v", res, err) + } + if slices.Contains(args, "--resume") || slices.Contains(args, "--continue") { + t.Fatalf("a new conversation went on with one: %q", args) + } +} diff --git a/daemon/goa_switch.go b/daemon/goa_switch.go index 0575dd44..ac2699c9 100644 --- a/daemon/goa_switch.go +++ b/daemon/goa_switch.go @@ -112,6 +112,17 @@ func (s *Server) goaReleaseLines() { // goaPlanSwitch settles what p changes and says if it can't be done. It changes nothing, // and asks the vault for nothing, as it is also what the app asks at each pick. func (s *Server) goaPlanSwitch(sess *session.Session, p proto.GOASwitchParams) (goaSwitchPlan, error) { + plan, err := s.goaPlanRun(sess, p, false) + if err != nil || plan.toAgent { + return plan, err + } + plan.conversation, plan.convo, err = s.goaConversationFor(sess, plan.account) + return plan, err +} + +// goaPlanRun is goaPlanSwitch but for the conversation: what it runs on, checked. restart: +// the user asks it to start again, whatever ended right after starting before. +func (s *Server) goaPlanRun(sess *session.Session, p proto.GOASwitchParams, restart bool) (goaSwitchPlan, error) { meta := sess.Meta() id := nonEmpty(p.Agent, meta.Agent) preset, err := s.goaAgentReady(id) @@ -134,19 +145,24 @@ func (s *Server) goaPlanSwitch(sess *session.Session, p proto.GOASwitchParams) ( } plan.next = goaNextMeta(meta, preset.ID, plan.account, s.accounts) plan.note = goaConfigNote(plan.account) - if sess.QuickExits() >= quickExitsMax { + if !restart && sess.QuickExits() >= quickExitsMax { return goaSwitchPlan{}, fmt.Errorf("goa: it ended right after starting %d times in a row: start it again first", quickExitsMax) } - if plan.toAgent { - return plan, nil - } - if !meta.Relaunch { + if !plan.toAgent && !meta.Relaunch { return goaSwitchPlan{}, errors.New("goa: this GOD of Agents' command can't be started again") } - plan.conversation, plan.convo, err = s.goaConversationFor(sess, plan.account) - return plan, err + return plan, nil } +// errGOAConversation is what a conversation GOA can't go on with is: a new one gets around it. +var errGOAConversation = errors.New("goa: its conversation can't be gone on with") + +// goaConversationError is an error of errGOAConversation, saying why. +type goaConversationError struct{ text string } + +func (e goaConversationError) Error() string { return e.text } +func (e goaConversationError) Is(target error) bool { return target == errGOAConversation } + // goaConfigOf is the config asked for of agent id: by ID or name, the official one by that // word; none asked, the one it runs on (the same agent) or the preferred (another). func (s *Server) goaConfigOf(id, asked string, meta session.Meta, toAgent bool) (proto.Account, error) { @@ -232,12 +248,12 @@ func (s *Server) goaConversationFor(sess *session.Session, c proto.Account) (how path := s.transcriptPath(sess.ID()) if path == "" { if meta.Conversation != "" { - return "", "", fmt.Errorf("goa: its conversation %s can't be found, so no config can go on with it: GOD of Agents keeps running as it was", meta.Conversation) + return "", "", goaConversationError{fmt.Errorf("goa: its conversation %s can't be found, so no config can go on with it: GOD of Agents keeps running as it was", meta.Conversation).Error()} } return proto.GOAConversationNone, "", nil } if convo = transcript.ForkID(meta.Agent, path); convo == "" { - return "", "", fmt.Errorf("goa: %s doesn't say which conversation it is on, so another config can't go on with it: GOD of Agents keeps running as it was", agentName(meta.Agent)) + return "", "", goaConversationError{fmt.Errorf("goa: %s doesn't say which conversation it is on, so another config can't go on with it: GOD of Agents keeps running as it was", agentName(meta.Agent)).Error()} } if err := s.goaConfigReaches(meta.Agent, c, path); err != nil { return "", "", err @@ -272,7 +288,7 @@ func (s *Server) goaConfigReaches(id string, c proto.Account, path string) error return nil } if _, err := os.Stat(filepath.Join(home, rel)); err != nil { - return fmt.Errorf("goa: %s keeps conversations apart and doesn't have this one, so it can't go on with it: GOD of Agents keeps running as it was", c.Name) + return goaConversationError{fmt.Errorf("goa: %s keeps conversations apart and doesn't have this one, so it can't go on with it: GOD of Agents keeps running as it was", c.Name).Error()} } return nil } diff --git a/daemon/rpc_goa.go b/daemon/rpc_goa.go index 0fa35189..e971758c 100644 --- a/daemon/rpc_goa.go +++ b/daemon/rpc_goa.go @@ -41,8 +41,9 @@ var goaHandlers = map[string]handler{ } return r, err }), - proto.MethodGOASwitch: with(func(c *conn, p proto.GOASwitchParams) (any, error) { return c.srv.goaSwitch(p) }), - proto.MethodGOAStall: bare(func(c *conn) (any, error) { return c.srv.goaStall(), nil }), + proto.MethodGOASwitch: with(func(c *conn, p proto.GOASwitchParams) (any, error) { return c.srv.goaSwitch(p) }), + proto.MethodGOARestart: with(func(c *conn, p proto.GOASwitchParams) (any, error) { return c.srv.goaRestart(p) }), + proto.MethodGOAStall: bare(func(c *conn) (any, error) { return c.srv.goaStall(), nil }), proto.MethodGOAStallSave: with(func(c *conn, p proto.GOAStall) (any, error) { return c.srv.saveGOAStall(p) }), @@ -58,7 +59,7 @@ func init() { handlers[m] = h } // Delegating waits on another host through the app, and on the judge. - for _, m := range []string{proto.MethodGOADelegate, proto.MethodGOAOverride, proto.MethodGOARecall, proto.MethodGOARevert, proto.MethodGOASwitch} { + for _, m := range []string{proto.MethodGOARestart, proto.MethodGOADelegate, proto.MethodGOAOverride, proto.MethodGOARecall, proto.MethodGOARevert, proto.MethodGOASwitch} { slowMethods[m] = true } } diff --git a/docs/goa-tools.md b/docs/goa-tools.md index 345788b3..0022bc01 100644 --- a/docs/goa-tools.md +++ b/docs/goa-tools.md @@ -163,7 +163,7 @@ This follows the agent's mode; it isn't a boundary against the agent. A command | Its own RAG collection, projects' read-only | `goa recall` (`goa_recall`) | `goa remember` (`goa_remember`) | projects' through the app | ✅ | | When a task counts as stalled | `goa stall` (`goa_stall`) | `goa stall --blank\|--idle\|--stuck` (`goa_stall`) | this host | ✅ | | The agents it runs on, their level | `goa agents` (`goa_agents`) | | this host | ✅ | -| What it runs on itself | `goa switch` (`goa_switch`, not offered to it) | the user's: Settings in its header, or `goa switch`, which it is refused ([goa.md](goa.md#changing-what-it-runs-on)) | `--host` | — the user's | +| What it runs on itself | `goa switch`, `goa restart` (`goa_switch`, `goa_restart`, not offered to it) | the user's: Settings in its header, or `goa switch` / `goa restart`, which it is refused ([goa.md](goa.md#changing-what-it-runs-on)) | `--host` | — the user's | | What a watched GOA did itself | `goa flags` | reverting: the user's, in the app | this host | — the user's | ## Built-in skills diff --git a/docs/goa.md b/docs/goa.md index 6b8deb13..3aa65641 100644 --- a/docs/goa.md +++ b/docs/goa.md @@ -64,13 +64,17 @@ Checked with Codex itself, started as GOA: enowX's tools reached it over MCP as - **At work** (working, or asking something), the app asks first, as a restart cuts that off (`--force`). This is the daemon's word, not the app's, so a line reaching GOA as the user presses counts. - **If it doesn't take:** an agent ending within 5 seconds of starting (`goaSwitchSettle`) puts GOA back on what it was, on its conversation, and the error says what the screen last said. What only shows later, as a key an API refuses at its first request, its screen says. +**Restarting it as it is.** "Restart as it is" in Settings (shown while nothing is picked), `enowx goa restart [--force] [--new-conversation]` and MCP `goa_restart` (`goa.restart`) restart GOA on the agent and config it runs on, going on with its conversation (`--resume` with its ID), by the same path as a config change: the same checks, the same settle, what stays below. At work it asks first (`--force`). It also lets GOA start again after it ended right after starting several times (the quick-exit guard holds a continue or a change back for 30 seconds; a restart is the user asking). If the conversation can't be gone on with (no ID recorded, its file gone, the config can't reach it), the daemon says why (`GOASwitchResult.Unresumable`) and leaves GOA as it is; it starts on a new conversation only with `--new-conversation`, which the app offers. If the agent refuses the resume or ends within the settle, GOA is put back and the error says to restart it on a new conversation. Never a new conversation unasked. + +**Why a plain continue could fail where a change worked.** A continue or wake of GOA goes through `resumeSession`, which refuses a session that isn't ended: a GOA whose agent ended leaving its fallback shell still runs that shell, so it is refused "still running", while a change stops it first. A continue with no conversation recorded starts the agent on a new one without saying, and isn't watched for ending at once. And after ends right after starting, the quick-exit count carries over a restart for 30 seconds, so continues and changes were refused a while. A restart stops GOA first, passes the conversation's ID, watches the settle and clears that count. + **What stays.** The change happens in GOA's own session, same ID: its tabs, the board (`goa-board.json`), its collection, its rules and its waiting lines (`goa-tell.json`). Its workers are sessions of their own and keep running. While it changes, no line goes to GOA: the hook waiting to wake it ends with nothing, and what comes waits for the agent that follows. **What holds it stays,** on every path: the restart goes through what `goa start` does, and a config can't undo it. On GOA a config's flags are only `--model`, `-m` and `--effort`, and its variables none of `ENOWX_*`, `ENABLE_TOOL_SEARCH`, `OPENCODE_CONFIG*` or `OPENCODE_PERMISSION` (`goa.ConfigArgs`, `goa.ConfigEnv`); the panel says so of a config that has others. A model API's own variables (its `OPENCODE_CONFIG`, say) go on as they are, under what holds OpenCode's GOA. A limit's fallback to another agent is refused for GOA: an agent only changes by this. **Its permission mode.** On Claude Code, bypass, whatever the change. On another agent, another config keeps the mode GOA runs in (`Meta.Mode`, passed as its agent's flag), including after a mode set by the user; another agent starts in what the user chose for that agent's new sessions (on a config, not that), else its own default. -**GOA can't change itself.** A change ends the turn asking for it, and for an agent loses its conversation, unasked. So GOA isn't offered `goa_switch`, and `goa switch` through its `enowx` tool only reads: it is told to ask the user. Asked by another session's agent, a change is a `confirm` method of control: the user says yes first ([goa-tools.md](goa-tools.md#when-the-user-is-asked)). An older daemon answers as not knowing the method, which the app and the command say, GOA going on as it was. +**GOA can't change itself.** A change ends the turn asking for it, and for an agent loses its conversation, unasked. So GOA isn't offered `goa_switch` or `goa_restart`, and `goa switch` or `goa restart` through its `enowx` tool only reads: it is told to ask the user. Asked by another session's agent, a change is a `confirm` method of control: the user says yes first ([goa-tools.md](goa-tools.md#when-the-user-is-asked)). An older daemon answers as not knowing the method, which the app and the command say, GOA going on as it was. ## Tasks diff --git a/proto/goa.go b/proto/goa.go index 4f5406a3..f11e8845 100644 --- a/proto/goa.go +++ b/proto/goa.go @@ -224,8 +224,16 @@ type GOASwitchResult struct { Mode string `json:"mode,omitempty"` // Note is what the user should know of the config: what GOA doesn't take of it. Note string `json:"note,omitempty"` + // Unresumable says why a restart can't go on with the conversation GOA was on: it + // starts on a new one only with NewConversation (MethodGOARestart). + Unresumable string `json:"unresumable,omitempty"` } +// MethodGOARestart restarts this host's GOA on its agent and config, going on with its +// conversation: GOASwitchParams (Agent and Config unused) → GOASwitchResult. At work it +// needs Force; a conversation it can't go on with, NewConversation, Unresumable saying why. +const MethodGOARestart = "goa.restart" + // MethodGOAWake waits for lines for GOD of Agents, for its idle agent's background hook: // GOAWakeParams → GOAWakeReply, Text "" when a later hook took over or the session ended. const MethodGOAWake = "goa.wake" diff --git a/proto/testdata/api.txt b/proto/testdata/api.txt index f0dd5a37..3304d96c 100644 --- a/proto/testdata/api.txt +++ b/proto/testdata/api.txt @@ -1239,6 +1239,7 @@ field GOASwitchResult.note string field GOASwitchResult.restarted bool field GOASwitchResult.same bool field GOASwitchResult.session SessionInfo +field GOASwitchResult.unresumable string field GOATask.agent string field GOATask.asks string field GOATask.asleep bool @@ -3202,6 +3203,7 @@ method goa.override method goa.recall method goa.remember method goa.report +method goa.restart method goa.revert method goa.rules method goa.rules.save diff --git a/ui/goa_settings.go b/ui/goa_settings.go index eeaa84eb..93abc0a3 100644 --- a/ui/goa_settings.go +++ b/ui/goa_settings.go @@ -124,6 +124,32 @@ func (a *App) goaSwitchRun(f *goaSettingsForm, s proto.SessionInfo, yes bool) { }) } +// goaRestartRun restarts GOA on its agent and config, its conversation going on; what +// needs a yes (at work, a conversation it can't go on with) comes back undone and is asked. +func (a *App) goaRestartRun(f *goaSettingsForm, s proto.SessionInfo, force, fresh bool) { + p := proto.GOASwitchParams{Force: force, NewConversation: fresh} + var res proto.GOASwitchResult + f.restarting, f.err = true, "" + a.goaCallQuiet(proto.MethodGOARestart, p, &res, func(err error) { + f.restarting = false + switch { + case err != nil: + f.err = goaSwitchErrorText(err) + case !res.Restarted && res.Unresumable != "": + a.confirm("Restart GOD of Agents on a new conversation?", + goaSentence(res.Unresumable)+" Its board, collection, rules and waiting lines stay, and workers go on.", + "Start a new one", func() { a.goaRestartRun(f, s, true, true) }) + case !res.Restarted: + a.confirm("Restart GOD of Agents while it works?", "What it is doing now is cut off. It goes on with the same conversation.", + "Restart it", func() { a.goaRestartRun(f, s, true, fresh) }) + default: + f.open = false + a.upsert(res.Session) + a.notify(a.goaSwitchDone(res)) + } + }) +} + // goaSwitchAsk asks for the yes res lacks, saying what would be lost. func (a *App) goaSwitchAsk(f *goaSettingsForm, s proto.SessionInfo, res proto.GOASwitchResult) { title, msg := "Restart GOD of Agents while it works?", "What it is doing now is cut off. It goes on with the same conversation on the new config." @@ -176,6 +202,14 @@ func (a *App) goaSettingsDialog(c *ui.Context) { widget.Notice(c, icon.Alert, theme.Error, f.err, "", nil) } }, func() { + if !changed { + r := widget.GhostButton(c, "Restart as it is") + if f.restarting || a.goa.err != "" { + r.Disabled(true) + } else if r.Clicked() { + a.goaRestartRun(f, s, false, false) + } + } label := "Restart GOA" if f.restarting { label = "Restarting…" From 31d5d08875c9046338bd1c8e27a778cfb4b336b3 Mon Sep 17 00:00:00 2001 From: enowdev Date: Sat, 7 Nov 2026 02:28:08 +0700 Subject: [PATCH 5/6] Let the Browser's page dock in the side panel or open as a page A button on the Browser's bar moves the page between a tab of its own (the default, as before) and the side panel, beside the session's terminal or chat, for every session; the choice is kept with the app's state and previews, Traffic and agents' pages open where it is. It is the same web view placed elsewhere, so the page doesn't reload. Docked, the page's tools take a second row so a narrow panel keeps the address usable. Co-Authored-By: Claude Opus 5.5 --- docs/preview.md | 11 ++- ui/browser.go | 45 +++++++++++- ui/browser_dock_test.go | 63 +++++++++++++++++ ui/browserview/browserview.go | 7 +- ui/browserview/dock_test.go | 71 +++++++++++++++++++ ui/browserview/draw.go | 125 ++++++++++++++++++++++------------ ui/browserview/look_test.go | 4 +- ui/browserview/view_test.go | 2 +- ui/sidepanel.go | 8 ++- ui/state.go | 15 ++-- ui/traffic.go | 2 +- 11 files changed, 291 insertions(+), 62 deletions(-) create mode 100644 ui/browser_dock_test.go create mode 100644 ui/browserview/dock_test.go diff --git a/docs/preview.md b/docs/preview.md index 13bf47e9..5e06bd42 100644 --- a/docs/preview.md +++ b/docs/preview.md @@ -1,6 +1,6 @@ # Preview -The **Browser** tool of the side panel (`⌘⇧P`, or **Preview** on a port's card and in the Ports tool) shows what the selected session's dev server serves, in a web view of this device: WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux. The plan behind it, and what comes next, is in [plans/preview.md](plans/preview.md). +The **Browser** (`⌘⇧P`, or **Preview** on a port's card and in the Ports tool), in a tab of its own or docked in the side panel, shows what the selected session's dev server serves, in a web view of this device: WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux. The plan behind it, and what comes next, is in [plans/preview.md](plans/preview.md). ## How it works @@ -14,6 +14,15 @@ The **Browser** tool of the side panel (`⌘⇧P`, or **Preview** on a port's ca Back, forward, reload; the address, as in a browser: a path (`/settings`) of the dev server shown, a host (`google.com` gets https, `localhost:3000` http) or a URL, opened even before the session has a dev server (another site loads from this device, not the session's host; a dev server starting meanwhile waits in the port picker); the page's width (Fit, or a laptop's, a tablet's or a phone's, centered); a screenshot of the page, typed into the session's prompt as a file for its agent to look at (uploaded to a server's session first); DevTools; and **Open in browser**. +## In the side panel or as a page + +The page shows in one of two places, which the button at the right of the Browser's bar switches at any time, for every session: + +- **Open as a page** (the default, as before): the page has a tab of its own in the center, and the side panel's Browser lists the session's pages, each opening there. +- **Show in the side panel:** the page fills the side panel's Browser, next to the session's terminal or chat, so the agent and the page show at once. The panel keeps its width rules (dragged between 280 and 1100, 480 until dragged); its bar keeps back, forward, reload, the address and the way out on the first row, and the port, the page's width, picking, the screenshot, the canvas and Unslop on a second, so a narrow panel loses none of them. + +The choice is kept with the app's state (`browserInPanel`), and whatever opens a page opens it there: a preview from a port's card or the Ports tool, Traffic, and an agent's `enowx browser open` (which only marks the Browser when it isn't in front). Moving it doesn't reload the page: it is the same web view, only placed elsewhere in the window, so its scroll, form and state stay. A session showing the Browser as its view (below) keeps it there; docking from there moves it to the panel. GOD of Agents' sessions behave the same. + ## As the session's view **Browser** in a session's Terminal · Chat · Browser toggle shows the preview in the session's own place, for a wide window or a whole day of it; the side panel's Browser then says where it is, as one page shows in one place. diff --git a/ui/browser.go b/ui/browser.go index bbba3321..e0af5749 100644 --- a/ui/browser.go +++ b/ui/browser.go @@ -100,8 +100,10 @@ func (a *App) browserOptions(s proto.SessionInfo) browserview.Options { } return a.selected == s.ID && (a.panelShows(panelBrowser) || a.centerShows(s.ID, centerBrowser)) }, - OpenTab: func() { a.openCenter(s, centerBrowser) }, + OpenTab: func() { a.bringBrowser(s) }, Shown: func() bool { return a.centerShows(s.ID, centerBrowser) }, + Docked: a.browserDocked(s), + Dock: func(docked bool) { a.dockBrowser(s, docked) }, NewPage: func(url string, done func(browserview.Page, error)) { win := a.win if win == nil { @@ -174,6 +176,9 @@ func (a *App) browserTab(c *ui.Context, s proto.SessionInfo) { // browserTabBehind opens session s's page in a tab that stays behind the one in // front: a page opened by itself doesn't take the window. func (a *App) browserTabBehind(s proto.SessionInfo) { + if a.state.BrowserInPanel { + return // the panel's Browser gets its mark (showBrowserSoon) + } s = a.parentOf(s) t := a.centerOf(s.ID) if !slices.Contains(t.tabs, centerBrowser) { @@ -184,10 +189,44 @@ func (a *App) browserTabBehind(s proto.SessionInfo) { // previewPort shows port p of session s in its browser. func (a *App) previewPort(s proto.SessionInfo, p proto.Port) { a.selectSession(s) - if !a.state.BrowserViews[s.ID] { + a.bringBrowser(s) + a.browserOf(s.ID).Show(a.browserOptions(s), p) +} + +// browserDocked says session s's page shows in the side panel: the user's +// choice, unless the session shows the Browser as its view. +func (a *App) browserDocked(s proto.SessionInfo) bool { + return a.state.BrowserInPanel && !a.state.BrowserViews[s.ID] +} + +// bringBrowser brings session s's page to the front where the user keeps it: +// its view, the side panel, or a tab of its own. +func (a *App) bringBrowser(s proto.SessionInfo) { + switch { + case a.state.BrowserViews[s.ID]: + case a.state.BrowserInPanel: + a.showTool(panelBrowser) + default: a.openCenter(s, centerBrowser) } - a.browserOf(s.ID).Show(a.browserOptions(s), p) +} + +// dockBrowser moves the page, for every session, into the side panel or out +// to a tab of its own, and shows it there. The web view is the same, so its +// page stays as it is. +func (a *App) dockBrowser(s proto.SessionInfo, docked bool) { + a.state.BrowserInPanel = docked + delete(a.state.BrowserViews, s.ID) + if !docked { + a.openCenter(s, centerBrowser) + return + } + for id, t := range a.center { + if slices.Contains(t.tabs, centerBrowser) { + a.closeCenter(id, centerBrowser) + } + } + a.showTool(panelBrowser) } // attachElement pastes what an element picked in the page is into session diff --git a/ui/browser_dock_test.go b/ui/browser_dock_test.go new file mode 100644 index 00000000..da90f838 --- /dev/null +++ b/ui/browser_dock_test.go @@ -0,0 +1,63 @@ +package ui + +import ( + "path/filepath" + "slices" + "testing" + + "github.com/enowx/enowx/client" + "github.com/enowx/enowx/proto" +) + +// The page shows where the user put it, docked in the side panel or in a tab +// of its own; the choice holds for every session, survives reopening the +// app, and previews asked for open there. +func TestBrowserPlaceIsKept(t *testing.T) { + file := filepath.Join(t.TempDir(), "state.json") + a := New(Options{StateFile: file}) + a.host = LocalHost() + a.client = &client.Client{} + s := proto.SessionInfo{ID: "s1", Project: "/code/app"} + other := proto.SessionInfo{ID: "s2", Project: "/code/app"} + a.sessions = []proto.SessionInfo{s, other} + a.selected = s.ID + if a.state.BrowserInPanel { + t.Fatal("docked by default: a page opens in a tab of its own") + } + a.previewPort(s, proto.Port{Port: 5173, Addr: "127.0.0.1"}) + a.browserTabBehind(other) + + a.dockBrowser(s, true) + if !a.panelShows(panelBrowser) || !a.browserDocked(s) || !a.browserDocked(other) { + t.Fatalf("docking: panel %q, docked %v", a.panelTab(), a.state.BrowserInPanel) + } + for _, id := range []string{s.ID, other.ID} { + if slices.Contains(a.centerOf(id).tabs, centerBrowser) { + t.Fatalf("%s kept its page's tab: the page shows in one place", id) + } + } + a.openPanel(panelChanges) + a.previewPort(s, proto.Port{Port: 5173, Addr: "127.0.0.1"}) + a.browserTabBehind(other) + if !a.panelShows(panelBrowser) || a.centerFront(s) != "" || len(a.centerOf(other.ID).tabs) != 0 { + t.Fatalf("a preview docked: panel %q, front %q, other's tabs %v", a.panelTab(), a.centerFront(s), a.centerOf(other.ID).tabs) + } + a.saveState() + if reopened := New(Options{StateFile: file}); !reopened.state.BrowserInPanel { + t.Fatal("docking didn't survive reopening the app") + } + + // As the session's view, the page is there, whatever the choice. + a.state.BrowserViews = map[string]bool{s.ID: true} + if a.browserDocked(s) { + t.Fatal("the session shows the page as its view") + } + a.dockBrowser(s, false) + if a.state.BrowserInPanel || a.state.BrowserViews[s.ID] || a.centerFront(s) != centerBrowser { + t.Fatalf("popping out: docked %v, view %v, front %q", a.state.BrowserInPanel, a.state.BrowserViews[s.ID], a.centerFront(s)) + } + a.saveState() + if reopened := New(Options{StateFile: file}); reopened.state.BrowserInPanel { + t.Fatal("popping out didn't survive reopening the app") + } +} diff --git a/ui/browserview/browserview.go b/ui/browserview/browserview.go index ef382ef8..63448550 100644 --- a/ui/browserview/browserview.go +++ b/ui/browserview/browserview.go @@ -56,6 +56,11 @@ type Options struct { // it is there. OpenTab func() Shown func() bool + // Docked says the page shows in the side panel, not a tab of its own; + // Dock moves it there (true) or out to a page (false), nil hides the + // button. + Docked bool + Dock func(docked bool) // Supported says this device has a web view; without, the view offers // the browser. Supported bool @@ -141,7 +146,7 @@ type View struct { var widths = []struct { width int name string -}{{0, "Fill the tab"}, {1280, "Desktop, 1280"}, {768, "Tablet, 768"}, {390, "Phone, 390"}} +}{{0, "Fill the room"}, {1280, "Desktop, 1280"}, {768, "Tablet, 768"}, {390, "Phone, 390"}} // New returns a view with nothing open. func New() *View { return &View{poke: make(chan struct{}, 1)} } diff --git a/ui/browserview/dock_test.go b/ui/browserview/dock_test.go new file mode 100644 index 00000000..e51cb371 --- /dev/null +++ b/ui/browserview/dock_test.go @@ -0,0 +1,71 @@ +package browserview + +import ( + "strconv" + "strings" + "testing" + + "github.com/egoist/mygo/ui" + + "github.com/enowx/enowx/proto" + "github.com/enowx/enowx/ui/theme" +) + +// Docked in the side panel, at its narrowest and when wide, the bar keeps +// back, forward, reload, the address and the way out inside the panel, none +// over another, and the page's tools on a row of their own; the button +// moves the page. +func TestDockedBrowserFitsThePanel(t *testing.T) { + for _, width := range []float32{280, 480, 900} { + page := &fakePage{} + o := options(page, new(string)) + o.ToCanvas = func([]byte, string, int) {} + o.Unslop = func() {} + docked := true + o.Docked = true + o.Dock = func(d bool) { docked = d } + v := New() + v.loaded = true + v.setPorts(o, []proto.Port{{Port: 5173, Process: "node"}, {Port: 8080}}) + page.url = "http://localhost:5173/" + strings.Repeat("a-very-long-path/", 12) + tt := ui.NewTester(func(c *ui.Context) { + c.SetTheme(theme.Window()) + ui.Row(c).Width(width).Height(600).Background(theme.Frame).TextColor(theme.Text).Children(func() { + ui.Column(c).FillWidth().FillHeight().Children(func() { v.Draw(c, o) }) + }) + }, int(width), 600) + tt.Frame() + tt.Frame() + var seen []ui.Rect + for _, label := range []string{"Back", "Forward", "Reload", "Address", "Open as a page", "More for the page", "Page width: Fill the room", "Screenshot"} { + r, ok := tt.Find(label) + if !ok { + t.Fatalf("%v: no %q in %q", width, label, tt.Texts()) + } + if r.X < 0 || r.X+r.W > width+0.5 || r.W <= 0 { + t.Errorf("%v: %q out of the panel: %+v", width, label, r) + } + for _, q := range seen { + if r.X < q.X+q.W && q.X < r.X+r.W && r.Y < q.Y+q.H && q.Y < r.Y+r.H { + t.Errorf("%v: %q over another control: %+v %+v", width, label, r, q) + } + } + seen = append(seen, r) + } + if r, _ := tt.Find("Address"); r.W < 80 { + t.Errorf("%v: the address is %v wide", width, r.W) + } + back, _ := tt.Find("Back") + shot, _ := tt.Find("Screenshot") + if shot.Y <= back.Y { + t.Errorf("%v: the page's tools share the address's row", width) + } + lookShot(t, tt, "browser-docked-"+strconv.Itoa(int(width))) + if err := tt.Click("Open as a page"); err != nil { + t.Fatal(err) + } + if docked { + t.Fatalf("%v: the button didn't pop the page out", width) + } + } +} diff --git a/ui/browserview/draw.go b/ui/browserview/draw.go index d8ee3fc5..4953c94e 100644 --- a/ui/browserview/draw.go +++ b/ui/browserview/draw.go @@ -14,7 +14,8 @@ import ( "github.com/enowx/enowx/ui/widget" ) -// Draw shows the toolbar, then the page or why there is none, for a center tab. +// Draw shows the toolbar, then the page or why there is none: in a center +// tab, as the session's view, or docked in the side panel (Options.Docked). func (v *View) Draw(c *ui.Context, o Options) { v.follow(o) ui.Column(c).Grow(1).FillWidth().MinHeight(0).Children(func() { @@ -74,11 +75,12 @@ func (v *View) note(c *ui.Context, o Options, text string) { // toolbar is back, forward, reload, the address, the port, the page's width, // and what to do with the page. What can't fit gives way: the width's frame -// shrinks and the rest is in its menu. +// shrinks and the rest is in its menu. Docked in the side panel, the +// address keeps the first row and the page's tools take a second. func (v *View) toolbar(c *ui.Context, o Options) { + page := v.page ui.Row(c.Key("page-bar")).Height(44).Shrink(0).FillWidth().PaddingX(theme.S2).Gap(theme.S1).Clip(). BorderWidth(0, 0, theme.Rule, 0).BorderColor(theme.Border).Children(func() { - page := v.page if navButton(c, icon.ArrowLeft, "Back", page != nil && page.CanBack()) { page.Back() } @@ -92,54 +94,87 @@ func (v *View) toolbar(c *ui.Context, o Options) { if page != nil && page.Loading() { widget.Spinner(c, 12) } - if len(v.ports) > 1 { - v.portPicker(c, o) + if !o.Docked { + v.pageTools(c, o, page) } - v.sizeMenu(c) - v.pickButton(c, o, page) - if navButton(c, icon.Camera, "Screenshot", page != nil && o.Attach != nil) { - if png, err := page.Capture(); err != nil { - v.err = "Couldn't take a screenshot: " + err.Error() - } else { - o.Attach(png) - } + v.dockButton(c, o) + v.moreMenu(c, o, page) + }) + if o.Docked { + ui.Row(c.Key("page-tools")).Height(40).Shrink(0).FillWidth().PaddingX(theme.S2).Gap(theme.S1).Clip(). + BorderWidth(0, 0, theme.Rule, 0).BorderColor(theme.Border).Children(func() { + v.pageTools(c, o, page) + }) + } +} + +// dockButton moves the page between the side panel and a tab of its own. +func (v *View) dockButton(c *ui.Context, o Options) { + if o.Dock == nil { + return + } + svg, label := icon.PanelRight, "Show in the side panel" + if o.Docked { + svg, label = icon.AppWindow, "Open as a page" + } + if widget.IconButton(c.Key("dock"), svg, label, false).Clicked() { + o.Dock(!o.Docked) + } +} + +// pageTools are the port, the page's width, picking, screenshots and unslop. +func (v *View) pageTools(c *ui.Context, o Options, page Page) { + if len(v.ports) > 1 { + v.portPicker(c, o) + } + v.sizeMenu(c) + v.pickButton(c, o, page) + if navButton(c, icon.Camera, "Screenshot", page != nil && o.Attach != nil) { + if png, err := page.Capture(); err != nil { + v.err = "Couldn't take a screenshot: " + err.Error() + } else { + o.Attach(png) } - if o.ToCanvas != nil && navButton(c, icon.Canvas, "Put on a canvas", page != nil) { - if png, err := page.Capture(); err != nil { - v.err = "Couldn't take a screenshot: " + err.Error() - } else { - go func(u string) { - w := 0 - if ev, ok := page.(Evaluator); ok { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - if n, err := ev.Eval(ctx, "window.innerWidth"); err == nil { - if f, ok := n.(float64); ok { - w = int(f) - } + } + if o.ToCanvas != nil && navButton(c, icon.Canvas, "Put on a canvas", page != nil) { + if png, err := page.Capture(); err != nil { + v.err = "Couldn't take a screenshot: " + err.Error() + } else { + go func(u string) { + w := 0 + if ev, ok := page.(Evaluator); ok { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + if n, err := ev.Eval(ctx, "window.innerWidth"); err == nil { + if f, ok := n.(float64); ok { + w = int(f) } - cancel() } - o.ToCanvas(png, u, w) - }(page.URL()) - } - } - v.unslopButton(c, o, page) - more := widget.IconButton(c.Key("more"), icon.More, "More for the page", false) - v.more.OpenBelow(more, "more", theme.ControlHeight) - v.more.Show(c, more, "more", func(done func()) { - if page != nil && widget.MenuItem(c, icon.Code, "DevTools", false) { - page.ToggleDevTools() - done() - } - if (page != nil || v.port != 0) && widget.MenuItem(c, icon.External, "Open in browser", false) { - u := v.shown() - if u == "" { - u = v.origin + "/" + cancel() } - o.OpenURL(u) - done() + o.ToCanvas(png, u, w) + }(page.URL()) + } + } + v.unslopButton(c, o, page) +} + +// moreMenu holds DevTools and Open in browser. +func (v *View) moreMenu(c *ui.Context, o Options, page Page) { + more := widget.IconButton(c.Key("more"), icon.More, "More for the page", false) + v.more.OpenBelow(more, "more", theme.ControlHeight) + v.more.Show(c, more, "more", func(done func()) { + if page != nil && widget.MenuItem(c, icon.Code, "DevTools", false) { + page.ToggleDevTools() + done() + } + if (page != nil || v.port != 0) && widget.MenuItem(c, icon.External, "Open in browser", false) { + u := v.shown() + if u == "" { + u = v.origin + "/" } - }) + o.OpenURL(u) + done() + } }) } diff --git a/ui/browserview/look_test.go b/ui/browserview/look_test.go index 8b2f5cb3..0018a0a7 100644 --- a/ui/browserview/look_test.go +++ b/ui/browserview/look_test.go @@ -58,8 +58,8 @@ func TestBrowserStatesInAPanelAndATab(t *testing.T) { v := New() v.loaded = true v.setPorts(o, two) - draw("page", v, o, "Pages", "Open in a tab", "localhost:5173", "node", "localhost:8080", "Fill the tab") - if err := tt.Click("Page width: Fill the tab"); err != nil { + draw("page", v, o, "Pages", "Open in a tab", "localhost:5173", "node", "localhost:8080", "Fill the room") + if err := tt.Click("Page width: Fill the room"); err != nil { t.Fatal(err) } tt.Frame() diff --git a/ui/browserview/view_test.go b/ui/browserview/view_test.go index ca0b0c7e..ae0e273f 100644 --- a/ui/browserview/view_test.go +++ b/ui/browserview/view_test.go @@ -140,7 +140,7 @@ func TestPageWidthsAsDevices(t *testing.T) { v.setPorts(o, []proto.Port{{Port: 5173}}) tt := ui.NewTester(func(c *ui.Context) { v.Draw(c, o) }, 900, 500) tt.Frame() - if err := tt.Click("Page width: Fill the tab"); err != nil { + if err := tt.Click("Page width: Fill the room"); err != nil { t.Fatal(err) } tt.Frame() diff --git a/ui/sidepanel.go b/ui/sidepanel.go index c870c322..58505137 100644 --- a/ui/sidepanel.go +++ b/ui/sidepanel.go @@ -114,6 +114,7 @@ type sidePanel struct { const ( defaultPanelWidth = 320 goaPanelWidth = 420 + browserPanelWidth = 480 minPanelWidth = 280 maxPanelWidth = 1100 panelGrip = 6 // the strip along its left edge that resizes it @@ -217,8 +218,11 @@ func (a *App) sidePanelView(c *ui.Context) { width := a.state.Panel.Width if width <= 0 { width = defaultPanelWidth - if t := a.panelTab(); t == panelSessions || t == panelTasks { + switch t := a.panelTab(); { + case t == panelSessions || t == panelTasks: width = goaPanelWidth // GOA's own lists hold a task and its button on a row + case t == panelBrowser && a.state.BrowserInPanel: + width = browserPanelWidth // a page wants room } } widget.Bento(ui.Column(c.Key("side-panel")).Width(width).MinWidth(minPanelWidth).Shrink(1).FillHeight(), theme.Surface(theme.Fade(theme.Panel, 0.94))). @@ -249,6 +253,8 @@ func (a *App) sidePanelView(c *ui.Context) { case tool == panelBrowser && a.state.BrowserViews[s.ID]: // A web view shows in one place: the session shows it. panelNote(c, "The preview shows as “"+a.sessionName(s)+"”'s view. Switch it to Terminal or Chat to have it here.") + case tool == panelBrowser && a.state.BrowserInPanel: + a.browserTab(c.Key("browser:"+s.ID), s) case tool == panelBrowser: a.browserPages(c.Key("browser:"+s.ID), s) case tool == panelAndroid: diff --git a/ui/state.go b/ui/state.go index c2f0042e..0ed6a7d9 100644 --- a/ui/state.go +++ b/ui/state.go @@ -56,13 +56,14 @@ type appState struct { ChatRead map[string]string `json:"chatRead,omitempty"` // the last message read of each team's chat Discord discordPrefs `json:"discord,omitempty"` Updates updatePrefs `json:"updates,omitempty"` - Editor string `json:"editor,omitempty"` // the editor last opened (editor.id) - Previews map[string]preview `json:"previews,omitempty"` // session ID → where its browser was - BrowserViews map[string]bool `json:"browserViews,omitempty"` // session IDs showing the Browser as their view - TrafficViews map[string]bool `json:"trafficViews,omitempty"` // session IDs showing Traffic as their view - AutoPreview map[string][]int `json:"autoPreview,omitempty"` // " " → ports previewed when opened - QuietPorts bool `json:"quietPorts,omitempty"` // no card when a session opens a port - CloseQuits bool `json:"closeQuits,omitempty"` // closing the window quits, rather than keeping the app in the tray + Editor string `json:"editor,omitempty"` // the editor last opened (editor.id) + Previews map[string]preview `json:"previews,omitempty"` // session ID → where its browser was + BrowserViews map[string]bool `json:"browserViews,omitempty"` // session IDs showing the Browser as their view + BrowserInPanel bool `json:"browserInPanel,omitempty"` // the page shows docked in the side panel, not in a tab of its own + TrafficViews map[string]bool `json:"trafficViews,omitempty"` // session IDs showing Traffic as their view + AutoPreview map[string][]int `json:"autoPreview,omitempty"` // " " → ports previewed when opened + QuietPorts bool `json:"quietPorts,omitempty"` // no card when a session opens a port + CloseQuits bool `json:"closeQuits,omitempty"` // closing the window quits, rather than keeping the app in the tray // What New session starts with (Settings): an agent, the project // folder rather than a new branch, a time budget in minutes. DefaultAgent string `json:"defaultAgent,omitempty"` diff --git a/ui/traffic.go b/ui/traffic.go index 49b50b7b..4894669b 100644 --- a/ui/traffic.go +++ b/ui/traffic.go @@ -240,6 +240,6 @@ func (a *App) trafficOpenInBrowser(s proto.SessionInfo) func(string) { a.status = err.Error() return } - a.openCenter(s, centerBrowser) + a.bringBrowser(s) } } From a4c1097ce2852a80d532dc86596e917ecaee79ed Mon Sep 17 00:00:00 2001 From: enowdev Date: Sat, 7 Nov 2026 02:29:19 +0700 Subject: [PATCH 6/6] Release 0.7.2 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 +++++++++++++++ cmd/enowx/mygo.json | 2 +- version/version.go | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f529475..640a21be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ Each version's section is also what the app's update dialog shows. +## 0.7.2 + +Three fixes from @sicknewbie's report on Windows 11 after updating to 0.7.1 ([#49](https://github.com/enowdev/enowx/issues/49)), a way to restart GOD of Agents on its own conversation, and the Browser docked in the side panel. + +- **Two versions of the app no longer take the daemon from each other.** After the update from 0.7.0 to 0.7.1, old apps still running each replaced the daemon with their own version, and the new app replaced it back, about every half minute. An app now replaces only an older daemon (or another build of its own version) and leaves a newer one as it is, saying so. On a version's first run, the app also offers to end its other copies still running. Apps already installed at 0.7.0 or 0.7.1 don't have this fix: an old copy left running can still downgrade the daemon until it is quit or updated. +- **A new GOD of Agents can start beside one that ended.** Starting GOA was refused as "already running" when the old one had ended but its session stayed up. GOA now counts as running only when its agent is there, or when it is hibernated and can come back (the message says how to wake it). +- **No empty Chat view for an agent without a chat.** A chat chosen while Claude Code ran a session was kept after switching it to OpenCode, which has none, leaving a blank view with no way back. Such a session now shows its terminal, and a switch to such an agent forgets the chat chosen before. +- **Restart GOD of Agents as it is.** "Restart as it is" in GOA's Settings, `enowx goa restart` and the MCP tool `goa_restart` restart GOA on the same agent and config and go on with the same conversation (`--resume`), keeping its board, collection, rules, waiting lines and session. While GOA works it asks first (`--force`). If the conversation can't be resumed, it says why and offers a new conversation instead of starting one silently (`--new-conversation`). GOA can't restart itself; it is the user's action. A restart also clears the count of quick exits that kept continues and config changes refused for 30 seconds after a failed start. +- **The Browser can show in the side panel.** A button on the Browser's bar moves the page between a tab of its own (the default, as before) and the right side panel, next to the session's terminal or chat. The choice is remembered, and previews, Traffic and agents' `enowx browser open` follow it. It is the same web view, so moving it doesn't reload the page. Docked, the address and back, forward and reload stay on the first row and the page's other tools take a second row. + +### What has been tested + +- Each change has tests that run on Linux and in CI on macOS, Windows and Linux. +- **The update ping-pong fix is verified only in tests, not on a real Windows machine with two app versions running.** The GOA restart and the Browser's docking were checked in tests and the UI test harness, not by hand in the app; moving the page without a reload follows from it being the same web view, not from a run on each platform. + ## 0.7.1 Three fixes from @sicknewbie's reports on Windows 11 with OpenCode: a terminal drawn in a corner after another view, GOD of Agents queueing tasks for an idle session, and enowX's reads of OpenCode's database. diff --git a/cmd/enowx/mygo.json b/cmd/enowx/mygo.json index 9a648c36..d995ead5 100644 --- a/cmd/enowx/mygo.json +++ b/cmd/enowx/mygo.json @@ -1,7 +1,7 @@ { "name": "enowX", "identifier": "ai.enowx.app", - "version": "0.7.1", + "version": "0.7.2", "copyright": "© 2026 enowX contributors", "out": "../../build", "urlSchemes": ["enowx"], diff --git a/version/version.go b/version/version.go index 019234bc..e2eb5ddd 100644 --- a/version/version.go +++ b/version/version.go @@ -3,4 +3,4 @@ package version // String is the release version. The app replaces daemons of any other. -const String = "0.7.1" +const String = "0.7.2"