Skip to content
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions cli/goa.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down
7 changes: 7 additions & 0 deletions cli/goa_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"} }},
}
Expand Down
75 changes: 75 additions & 0 deletions cli/goa_restart.go
Original file line number Diff line number Diff line change
@@ -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
}
21 changes: 21 additions & 0 deletions cli/goa_restart_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions cmd/enowx/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion cmd/enowx/mygo.json
Original file line number Diff line number Diff line change
@@ -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"],
Expand Down
90 changes: 90 additions & 0 deletions cmd/enowx/older_instances.go
Original file line number Diff line number Diff line change
@@ -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 })
}
34 changes: 34 additions & 0 deletions cmd/enowx/older_instances_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 8 additions & 0 deletions cmd/enowx/older_instances_unix.go
Original file line number Diff line number Diff line change
@@ -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) }
12 changes: 12 additions & 0 deletions cmd/enowx/older_instances_windows.go
Original file line number Diff line number Diff line change
@@ -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() }
7 changes: 7 additions & 0 deletions core/session/work.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
33 changes: 31 additions & 2 deletions daemon/goa.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading