diff --git a/cmd/enowx/icon.go b/cmd/enowx/icon.go new file mode 100644 index 00000000..453bd5e9 --- /dev/null +++ b/cmd/enowx/icon.go @@ -0,0 +1,8 @@ +package main + +import _ "embed" + +// MyGo uses the same source for the executable's fallback notification icon. +// +//go:embed resources/icon.png +var applicationIcon []byte diff --git a/cmd/enowx/main.go b/cmd/enowx/main.go index bff31f05..64cef610 100644 --- a/cmd/enowx/main.go +++ b/cmd/enowx/main.go @@ -90,6 +90,7 @@ func main() { } } app := enowxui.New(enowxui.Options{ + TrayIcon: applicationIcon, Hosts: store, Start: start, DaemonCommand: []string{daemonExe(exe, dir), "daemon"}, diff --git a/core/agent/goa_process_windows.go b/core/agent/goa_process_windows.go new file mode 100644 index 00000000..66954460 --- /dev/null +++ b/core/agent/goa_process_windows.go @@ -0,0 +1,123 @@ +package agent + +import ( + "maps" + "net/url" + "path/filepath" + "strings" + + "github.com/enowx/enowx/core/procstat" +) + +// WindowsGOAProcesses recognizes only direct helpers of a tagged Codex and +// their console hosts. Shells and arbitrary helper descendants stay watched. +func WindowsGOAProcesses(agentID, sessionID string, list []procstat.Proc, args func(int) []string, + image func(int) string, tagged func(int) bool, localAppData, systemRoot string) map[int]bool { + out := map[int]bool{} + byPID := map[int]procstat.Proc{} + for _, p := range list { + byPID[p.PID] = p + } + if agentID != "codex" || sessionID == "" { + return out + } + parentOf := func(p procstat.Proc) (procstat.Proc, bool) { + parent, ok := byPID[p.PPID] + // Windows start times are decimal FILETIMEs. Compare numeric length + // first so a wrapped/reused PID cannot supply a younger parent. + older := len(parent.Start) < len(p.Start) || len(parent.Start) == len(p.Start) && parent.Start <= p.Start + return parent, ok && p.Start != "" && parent.Start != "" && older && parent.PID != p.PID + } + for _, p := range list { + if !strings.EqualFold(p.Name, "codex.exe") || !tagged(p.PID) { + continue + } + parent, ok := parentOf(p) + if !ok || !strings.EqualFold(filepath.Base(image(parent.PID)), "node.exe") || !tagged(parent.PID) { + continue + } + argv := args(parent.PID) + if len(argv) < 2 { + continue + } + script := filepath.Clean(filepath.FromSlash(argv[1])) + if !strings.EqualFold(filepath.Base(script), "codex.js") || !strings.EqualFold(filepath.Base(filepath.Dir(script)), "bin") { + continue + } + pkg := filepath.Dir(filepath.Dir(script)) + if !strings.EqualFold(filepath.Base(pkg), "codex") || !strings.EqualFold(filepath.Base(filepath.Dir(pkg)), "@openai") { + continue + } + // Resolve an npm junction before comparing the native binary's path. + if real, err := filepath.EvalSymlinks(pkg); err == nil { + pkg = real + } + rel, err := filepath.Rel(pkg, image(p.PID)) + out[parent.PID] = err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) + } + for _, p := range list { + name := strings.ToLower(p.Name) + if name != "codex-code-mode-host.exe" && name != "node.exe" && name != "node_repl.exe" { + continue + } + if !tagged(p.PID) { + continue + } + parent, ok := parentOf(p) + if !ok || !strings.EqualFold(filepath.Base(image(parent.PID)), "codex.exe") || !tagged(parent.PID) { + continue + } + path, argv := image(p.PID), args(p.PID) + if len(argv) == 0 { + continue + } + name = strings.ToLower(filepath.Base(path)) + switch name { + case "codex-code-mode-host.exe": + out[p.PID] = len(argv) == 1 && strings.EqualFold(filepath.Dir(path), filepath.Dir(image(parent.PID))) + case "node.exe", "node_repl.exe": + root := filepath.Join(localAppData, "OpenAI", "Codex", "runtimes", "cua_node") + rel, err := filepath.Rel(root, path) + parts := strings.Split(rel, string(filepath.Separator)) + if localAppData == "" || err != nil || len(parts) != 3 || parts[0] == ".." || !strings.EqualFold(parts[1], "bin") { + continue + } + if name == "node_repl.exe" { + out[p.PID] = len(argv) == 1 + } else { + // The CUA entry point is fixed; node -e and arbitrary scripts + // from the same runtime are still activity worth flagging. + entry := filepath.Join(filepath.Dir(path), "node_modules", "@oai", "cua-repl", "bin", "cua-repl.mjs") + out[p.PID] = (len(argv) == 2 || len(argv) == 4 && argv[1] == "--import" && cuaAccessHook(argv[2], localAppData)) && + strings.EqualFold(argv[len(argv)-1], entry) + } + } + } + helperParents := maps.Clone(out) + for _, p := range list { + if !strings.EqualFold(p.Name, "conhost.exe") { + continue + } + parent, ok := parentOf(p) + if !ok || !helperParents[parent.PID] || !tagged(p.PID) || systemRoot == "" || + !strings.EqualFold(image(p.PID), filepath.Join(systemRoot, "System32", "conhost.exe")) { + continue + } + argv := args(p.PID) + out[p.PID] = len(argv) == 2 && strings.EqualFold(argv[1], "0x4") + } + return out +} + +func cuaAccessHook(rawURL, localAppData string) bool { + u, err := url.Parse(rawURL) + if err != nil || u.Scheme != "file" || u.Host != "" || u.RawQuery != "" || u.Fragment != "" { + return false + } + path := filepath.FromSlash(strings.TrimPrefix(u.Path, "/")) + root := filepath.Join(filepath.Dir(filepath.Dir(localAppData)), ".codex", "plugins", "cache", "openai-bundled", "unified-computer-use") + rel, err := filepath.Rel(root, path) + parts := strings.Split(rel, string(filepath.Separator)) + return err == nil && len(parts) == 2 && parts[0] != ".." && parts[0] != "." && + strings.EqualFold(parts[1], "require-browser-access.mjs") +} diff --git a/core/agent/goa_process_windows_test.go b/core/agent/goa_process_windows_test.go new file mode 100644 index 00000000..04283fc9 --- /dev/null +++ b/core/agent/goa_process_windows_test.go @@ -0,0 +1,120 @@ +package agent + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/enowx/enowx/core/procstat" +) + +func TestGOACodexConsoleAncestry(t *testing.T) { + cli := `C:\tools\codex\bin` + runtime := `C:\fixture\AppData\Local\OpenAI\Codex\runtimes\cua_node\abc\bin` + images := map[int]string{ + 1: cli + `\codex.exe`, 2: cli + `\codex-code-mode-host.exe`, + 3: `C:\Windows\System32\conhost.exe`, 4: runtime + `\node_repl.exe`, + 5: runtime + `\node.exe`, 6: `C:\Windows\System32\conhost.exe`, + } + argv := map[int][]string{} + for pid, path := range images { + argv[pid] = []string{path} + } + argv[3] = []string{`\??\C:\Windows\System32\conhost.exe`, "0x4"} + argv[6] = argv[3] + argv[5] = []string{images[5], "--import", "file:///C:/fixture/.codex/plugins/cache/openai-bundled/unified-computer-use/26/require-browser-access.mjs", filepath.Join(runtime, "node_modules", "@oai", "cua-repl", "bin", "cua-repl.mjs")} + list := []procstat.Proc{ + {PID: 1, Start: "100"}, {PID: 2, PPID: 1, Start: "101"}, + {PID: 3, PPID: 2, Start: "102"}, {PID: 4, PPID: 1, Start: "103"}, + {PID: 5, PPID: 1, Start: "104"}, {PID: 6, PPID: 5, Start: "105"}, + } + for i := range list { + list[i].Name = filepath.Base(images[list[i].PID]) + } + classify := func(list []procstat.Proc, tagged func(int) bool) map[int]bool { + return WindowsGOAProcesses("codex", "goa", list, func(pid int) []string { return argv[pid] }, + func(pid int) string { return images[pid] }, tagged, `C:\fixture\AppData\Local`, `C:\Windows`) + } + allTagged := func(int) bool { return true } + for _, pid := range []int{2, 3, 4, 5, 6} { + if !classify(list, allTagged)[pid] { + t.Fatalf("known helper %d was flagged", pid) + } + } + for _, tc := range []struct { + name string + change func([]procstat.Proc) + }{ + {"unrelated console", func(ps []procstat.Proc) { ps[2].PPID = 1 }}, + {"missing parent", func(ps []procstat.Proc) { ps[2].PPID = 999 }}, + {"reused parent PID", func(ps []procstat.Proc) { ps[1].Start = "999" }}, + {"unknown start", func(ps []procstat.Proc) { ps[1].Start = "" }}, + {"missing console start", func(ps []procstat.Proc) { ps[2].Start = "" }}, + } { + t.Run(tc.name, func(t *testing.T) { + ps := append([]procstat.Proc(nil), list...) + tc.change(ps) + if classify(ps, allTagged)[3] { + t.Fatal("unproven console ancestry was exempted") + } + }) + } + for _, tc := range []struct { + name string + pid int + path string + args []string + }{ + {"copied console", 3, `C:\temp\codex\conhost.exe`, argv[3]}, + {"console with other command", 3, images[3], []string{images[3], "arbitrary"}}, + {"copied helper", 2, `C:\temp\codex-code-mode-host.exe`, []string{`C:\temp\codex-code-mode-host.exe`}}, + {"helper with command", 2, images[2], []string{images[2], "run", "powershell"}}, + {"arbitrary node script", 5, images[5], []string{images[5], `C:\work\task.js`}}, + {"arbitrary node import", 5, images[5], []string{images[5], "--import", "file:///C:/work/task.mjs", argv[5][3]}}, + {"lookalike runtime", 5, strings.Replace(images[5], "cua_node", "cua_node_evil", 1), argv[5]}, + } { + t.Run(tc.name, func(t *testing.T) { + path, a := images[tc.pid], argv[tc.pid] + defer func() { images[tc.pid], argv[tc.pid] = path, a }() + images[tc.pid], argv[tc.pid] = tc.path, tc.args + got := classify(list, allTagged) + if got[tc.pid] { + t.Fatal("unknown process exempted") + } + if tc.pid == 2 && got[3] || tc.pid == 5 && got[6] { + t.Fatal("unknown parent's console exempted") + } + }) + } + if classify(list, func(pid int) bool { return pid != 1 })[3] { + t.Fatal("another session's Codex accepted") + } + images[7], argv[7] = images[3], argv[3] + ps := append(append([]procstat.Proc(nil), list...), procstat.Proc{PID: 7, PPID: 3, Name: "conhost.exe", Start: "106"}) + if classify(ps, allTagged)[7] { + t.Fatal("console exemption propagated through descendants") + } + +} + +func TestGOACodexNpmLauncherAncestry(t *testing.T) { + pkg := `C:\npm\node_modules\@openai\codex` + images := map[int]string{1: `C:\npm\node.exe`, 2: pkg + `\vendor\bin\codex.exe`, 3: `C:\Windows\System32\conhost.exe`} + argv := map[int][]string{1: {images[1], pkg + `\bin\codex.js`, "--sandbox", "workspace-write"}, 2: {images[2]}, 3: {images[3], "0x4"}} + ps := []procstat.Proc{{PID: 1, Name: "node.exe", Start: "10"}, {PID: 2, PPID: 1, Name: "codex.exe", Start: "11"}, {PID: 3, PPID: 1, Name: "conhost.exe", Start: "12"}} + run := func() map[int]bool { + return WindowsGOAProcesses("codex", "goa", ps, func(pid int) []string { return argv[pid] }, func(pid int) string { return images[pid] }, func(int) bool { return true }, `C:\fixture\AppData\Local`, `C:\Windows`) + } + if got := run(); !got[1] || !got[3] { + t.Fatal("npm launcher or its console was flagged") + } + argv[1][1] = `C:\work\codex.js` + if got := run(); got[1] || got[3] { + t.Fatal("an unrelated Node script or its console was exempted") + } + argv[1][1] = pkg + `\bin\codex.js` + images[2] = `C:\other\codex.exe` + if run()[1] { + t.Fatal("an unrelated native binary vouched for the launcher") + } +} diff --git a/core/agent/goaguide.go b/core/agent/goaguide.go index e227fd97..0f571c10 100644 --- a/core/agent/goaguide.go +++ b/core/agent/goaguide.go @@ -29,7 +29,7 @@ How you work: Talk to the agent that knows the project: with --project and no --session or --branch, the project's own session takes it, even busy, asking, hibernated or started with other settings. A busy one has it queued (the board says queued), delivered when its turn ends; a stopped one is brought back first. A new session starts only when the project has none, or when the user asks: for a fork, or --branch, for parallel work they agreed to, or a --config they named. delegate says when one started, and why. If a session can't take it, delegate fails: don't work around it with a new one. Never your own session: it is refused. With no other session, start a new one. The judge picks each prompt's model and effort (cheaper near a budget): delegate prints them and why; tell the user in a line. When the user names a model, pass --model M --effort E (--config C for another provider); --no-route keeps the agent's own. -3. Don't poll. When a worker finishes, asks something or fails, enowX types a line starting with "[enowX GOA]" into this conversation. Then read what it did ("$ENOWX_BIN" chat ID --host H, changes ID --host H), check it against the task, and go on: the next task, a fix sent back, a review and merge (the user confirms), or a word to the user. +3. Don't poll. When a worker finishes, asks something or fails, enowX delivers a line starting with "[enowX GOA]" through a supported hook; without one, the report waits in Tasks under Waiting for GOA (goa board shows it). It never pastes reports into the terminal composer. Then read what it did ("$ENOWX_BIN" chat ID --host H, changes ID --host H), check it against the task, and go on: the next task, a fix sent back, a review and merge (the user confirms), or a word to the user. 4. Workers' permission prompts: the judge answers what it is sure of. The rest you answer after reading what is asked ("$ENOWX_BIN" answer ID --host H, then answer ID allow|deny --host H). Urgent ones (what can't be undone, money, publishing, secrets, the judge unsure) are the user's: they get a sound and a banner; don't answer those, say they wait on the user. 5. A turn's end isn't a task's: "turn-ended" on the board means the worker stopped, maybe mid-work. Only you settle it (goa task ID --status done|failed --result "its report"), and that report is what's remembered. Keep the board true: "$ENOWX_BIN" goa board shows it to you as the user sees it; goa task ID --status done|failed|stopped --result "…" settles one. A line saying a task "isn't moving" (never started, idle, stuck; "stalled" on the board) means nothing is happening: read its screen before anything else, restart or move the task, and tell the user. Never report a stalled task as under way, nor repeat a step that already failed. diff --git a/core/goa/goa_test.go b/core/goa/goa_test.go index 5ad8e209..5c58957c 100644 --- a/core/goa/goa_test.go +++ b/core/goa/goa_test.go @@ -84,20 +84,6 @@ func TestUrgent(t *testing.T) { } } -// Claude Code and Codex hold GOA to enowX's tools with their hooks; -// Claude Code also starts with them only; an agent with neither is watched. -func TestLevels(t *testing.T) { - if l, _ := LevelOf("claude"); l != proto.GOAEnforced { - t.Errorf("claude: %s", l) - } - if l, _ := LevelOf("codex"); l != proto.GOAEnforced { - t.Errorf("codex: %s", l) - } - if l, _ := LevelOf("aider"); l != proto.GOAWatched { - t.Errorf("aider: %s", l) - } -} - // Tasks keep their sessions and statuses on disk; a session's unsettled // tasks change together, settled ones don't. func TestBoard(t *testing.T) { diff --git a/core/goa/level_other_test.go b/core/goa/level_other_test.go new file mode 100644 index 00000000..1e2b2c1c --- /dev/null +++ b/core/goa/level_other_test.go @@ -0,0 +1,17 @@ +//go:build !windows + +package goa + +import ( + "testing" + + "github.com/enowx/enowx/proto" +) + +func TestLevels(t *testing.T) { + for id, want := range map[string]string{"claude": proto.GOAEnforced, "codex": proto.GOAEnforced, "aider": proto.GOAWatched} { + if got, _ := LevelOf(id); got != want { + t.Errorf("%s: got %s want %s", id, got, want) + } + } +} diff --git a/core/goa/level_windows_test.go b/core/goa/level_windows_test.go new file mode 100644 index 00000000..7c4569bb --- /dev/null +++ b/core/goa/level_windows_test.go @@ -0,0 +1,17 @@ +package goa + +import ( + "testing" + + "github.com/enowx/enowx/proto" +) + +func TestLevels(t *testing.T) { + // POSIX hook commands aren't installed on Windows. Tool restrictions + // alone limit Claude; Codex and agents without either remain watched. + for id, want := range map[string]string{"claude": proto.GOALimited, "codex": proto.GOAWatched, "aider": proto.GOAWatched} { + if got, _ := LevelOf(id); got != want { + t.Errorf("%s: got %s want %s", id, got, want) + } + } +} diff --git a/daemon/goa.go b/daemon/goa.go index bd848400..c6233c92 100644 --- a/daemon/goa.go +++ b/daemon/goa.go @@ -19,12 +19,15 @@ import ( // allows (its hooks refuse the rest here; goa_watch.go flags what an agent // without either does). Its board follows its workers on every host: this // host's through their updates, others' through the app (goa.worker); what -// changes is typed into GOA once it is idle, so it never polls. +// changes reaches GOA through hooks, or waits in Tasks without touching its draft. // goaState is what the daemon keeps of GOA. The board is safe on its own; // mu guards the rest. type goaState struct { board *goa.Board + // delivery serializes queue checkout, hook handoff and return. Never + // hold mu while waiting for it or while saving the queue. + delivery sync.Mutex mu sync.Mutex tell []goaLine // lines for GOA until it takes them, kept in tellFile @@ -228,7 +231,7 @@ func (s *Server) goaRanOn(info proto.SessionInfo) { } } -// goaTell has line typed into GOA once it is idle. +// goaTell queues a line for GOA's safe delivery channels. func (s *Server) goaTell(line string) { s.goaTellLine(goaLine{Text: line}) } // goaLine is a line waiting for GOA. One about a task under way (Task set: its report, @@ -255,14 +258,16 @@ func (s *Server) goaTellLine(line goaLine) { if !ok { return } + s.goa.delivery.Lock() s.goa.mu.Lock() - s.goa.tell = append(s.goa.tell, line) + s.goa.tell = uniqueGOALines(append(s.goa.tell, line)) if over := len(s.goa.tell) - goaTellMax; over > 0 { s.goa.tell = slices.Delete(s.goa.tell, 0, over) s.logger().Warn("goa: too much waits for GOA, the oldest goes", "lines", over) } s.goa.mu.Unlock() s.saveGOATell() + s.goa.delivery.Unlock() if !g.Exited && g.State != proto.StateWorking { s.goaFlush(g.ID) } @@ -300,12 +305,12 @@ func (s *Server) loadGOATell() { return } s.goa.mu.Lock() - s.goa.tell = lines + s.goa.tell = uniqueGOALines(lines) s.goa.mu.Unlock() } -// goaFlushIdle has an idle GOA get what still waits for it: what a failed typing -// put back, or what waited over a restart with no change of its session to come. +// goaFlushIdle offers an idle GOA's waiting lines to a documented hook. +// Without one, they stay in Tasks, including across a daemon restart. func (s *Server) goaFlushIdle() { s.goa.mu.Lock() waiting := len(s.goa.tell) > 0 diff --git a/daemon/goa_alert_test.go b/daemon/goa_alert_test.go new file mode 100644 index 00000000..5c58a63b --- /dev/null +++ b/daemon/goa_alert_test.go @@ -0,0 +1,54 @@ +package daemon + +import ( + "encoding/json" + "testing" + "time" + + "github.com/enowx/enowx/proto" +) + +func TestGOARepeatedFlagsKeepAuditButLimitNotifications(t *testing.T) { + srv := startServer(t) + events := make(chan proto.Event, 64) + connect(t, srv, func(e proto.Event) { events <- e }) + flag := proto.GOAFlag{Session: "goa", Command: "unknown.exe"} + for range 5 { + srv.goaFlag(flag) + } + srv.goaFlag(proto.GOAFlag{Session: "goa", Command: "another.exe"}) + srv.goaFlag(proto.GOAFlag{Session: "other-goa", Command: "unknown.exe"}) + // A quiet minute permits a fresh notification for the same activity. + srv.goa.mu.Lock() + for i := range srv.goa.flags { + srv.goa.flags[i].Time = time.Now().Add(-2 * time.Minute) + } + srv.goa.mu.Unlock() + srv.goaFlag(flag) + srv.goaFlag(proto.GOAFlag{Session: "barrier", Command: "barrier"}) + flags, alerts := 0, 0 + deadline := time.After(5 * time.Second) + for { + select { + case e := <-events: + switch e.Name { + case proto.EventGOAFlag: + flags++ + case proto.EventNotifySound: + var p proto.NotifySoundParams + if err := json.Unmarshal(e.Data, &p); err != nil { + t.Fatal(err) + } + alerts++ + if p.Session == "barrier" { + if flags != 9 || alerts != 5 || len(srv.goaFlags()) != 9 { + t.Fatalf("flags=%d alerts=%d kept=%d; want 9,5,9", flags, alerts, len(srv.goaFlags())) + } + return + } + } + case <-deadline: + t.Fatal("notification barrier never reached") + } + } +} diff --git a/daemon/goa_composer_test.go b/daemon/goa_composer_test.go new file mode 100644 index 00000000..0475c8a0 --- /dev/null +++ b/daemon/goa_composer_test.go @@ -0,0 +1,166 @@ +package daemon + +import ( + "encoding/json" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/enowx/enowx/core/session" + "github.com/enowx/enowx/proto" +) + +// A queued fixture has a headless terminal but starts no program or daemon. +// Its screen can look empty or misleading without touching a real composer. +func goaComposerFixture(t *testing.T, srv *Server, screen string) *session.Session { + t.Helper() + g, err := srv.sessions.Create(session.Spec{ID: "isolated-goa", Cols: 120, Rows: 40, + History: []byte(screen), Meta: session.Meta{Agent: "codex", GOA: true, Queued: true}}) + if err != nil { + t.Fatal(err) + } + return g +} + +func TestGOAComposerNeverReceivesBackgroundReports(t *testing.T) { + for name, screen := range map[string]string{ + "empty": "Done.\r\n› Ask Codex to do anything\r\n", + "draft": "› my unfinished sentence\r\nsecond line\r\n", + "misleading mark": "› my unfinished sentence\r\nquoted prompt:\r\n$ \r\n", + "mouse fragment": "› draft 5;38;39M;35;12;24M\r\n", + } { + t.Run(name, func(t *testing.T) { + srv := startServer(t) + g := goaComposerFixture(t, srv, screen) + before := g.Terminal().Text() + idleBefore := g.TypedFor(time.Now().Add(time.Minute)) + var wg sync.WaitGroup + for range 20 { + wg.Go(func() { + srv.goaTellLine(goaLine{Session: "worker-a", Text: "[enowX GOA] identical report"}) + srv.goaFlush(g.ID()) + }) + } + wg.Wait() + if got := g.Terminal().Text(); got != before { + t.Fatalf("draft changed: %q -> %q", before, got) + } + if g.Info().Prompt != "" || g.TypedFor(time.Now().Add(time.Minute)) < idleBefore-time.Second { + t.Fatal("background delivery typed or submitted input") + } + if got := srv.goaBoard().Waiting; len(got) != 1 || got[0] != "[enowX GOA] identical report" { + t.Fatalf("Tasks waiting: %v", got) + } + data, err := os.ReadFile(srv.goa.tellFile) + var kept []goaLine + if err != nil || json.Unmarshal(data, &kept) != nil || len(kept) != 1 { + t.Fatalf("queue wasn't preserved: %s (%v)", data, err) + } + }) + } +} + +func TestGOAReportsDeduplicateAcrossNewTimestamps(t *testing.T) { + srv := startServer(t) + g := goaComposerFixture(t, srv, "› my draft\r\n") + task, err := srv.goa.board.Add(proto.GOATask{Title: "Worker", Session: "worker-a"}) + if err != nil { + t.Fatal(err) + } + info := proto.SessionInfo{ID: "worker-a", Report: "The requested changes are ready for review.", ReportOutcome: "partly"} + for at := int64(1); at <= 5; at++ { + info.ReportAt = at + srv.goaReportSeen(info) + } + if got := srv.goaWaitingLines(); len(got) != 1 { + t.Fatalf("repeated reports: %v", got) + } + var decision proto.HookDecision + srv.goaHookLines(g, g.Info(), proto.HookEvent{Event: proto.HookPromptStart}, &decision) + if got := decision.Context; strings.Count(got, "[enowX GOA]") != 1 { + t.Fatalf("hook received %q", got) + } + info.ReportAt = 6 + srv.goaReportSeen(info) + if len(srv.goaWaitingLines()) != 0 { + t.Fatal("already delivered report was requeued with a newer timestamp") + } + info.ReportAt, info.Report = 7, "A changed report" + srv.goaReportSeen(info) + if got := srv.goaWaitingLines(); len(got) != 1 || !strings.Contains(got[0], "A changed report") { + t.Fatalf("changed report lost: %v", got) + } + for _, got := range srv.goa.board.List() { + if got.ID == task.ID && (got.ReportAt != 7 || got.Said != info.Report) { + t.Fatalf("board didn't retain latest report: %+v", got) + } + } +} + +func TestGOAQueueRestartAndCancelledWakePreserveIdentity(t *testing.T) { + srv := startServer(t) + g := goaComposerFixture(t, srv, "› draft\r\n") + one, _ := srv.goa.board.Add(proto.GOATask{Title: "one", Session: "worker-a"}) + two, _ := srv.goa.board.Add(proto.GOATask{Title: "two", Session: "other"}) + a := goaLine{Task: one.ID, Session: "worker-a", Text: "same report"} + b := goaLine{Task: two.ID, Session: "other", Text: "same report"} + legacy, _ := json.Marshal([]goaLine{a, a, b, a}) + if err := os.WriteFile(srv.goa.tellFile, legacy, 0o600); err != nil { + t.Fatal(err) + } + srv.loadGOATell() + if got := srv.goaWaitingLines(); len(got) != 2 { + t.Fatalf("restart: %v", got) + } + w := &goaWaker{text: make(chan string, 1)} + srv.goa.mu.Lock() + srv.goa.waker = w + srv.goa.mu.Unlock() + srv.goaFlush(g.ID()) + srv.goaReturnWake(w) // disconnected after handoff, before reading the channel + srv.goa.mu.Lock() + kept := append([]goaLine(nil), srv.goa.tell...) + srv.goa.mu.Unlock() + if len(kept) != 2 || kept[0] != a || kept[1] != b { + t.Fatalf("cancelled hook lost task metadata/order: %+v", kept) + } + if got := srv.goaTake(); strings.Count(got, "same report") != 2 { + t.Fatalf("hook delivery: %q", got) + } + if len(srv.goaWaitingLines()) != 0 { + t.Fatal("delivered lines still queued") + } +} + +func TestGOAConcurrentHookRoutesClaimEachReportOnce(t *testing.T) { + srv := startServer(t) + g := goaComposerFixture(t, srv, "› draft\r\n") + for _, text := range []string{"report-one", "report-two", "report-three"} { + srv.goaTellLine(goaLine{Text: text}) + } + w := &goaWaker{text: make(chan string, 1)} + srv.goa.mu.Lock() + srv.goa.waker = w + srv.goa.mu.Unlock() + context := make(chan string, 1) + var wg sync.WaitGroup + wg.Go(func() { srv.goaFlush(g.ID()) }) + wg.Go(func() { context <- srv.goaTake() }) + wg.Wait() + got := <-context + select { + case wake := <-w.text: + got += wake + default: + } + for _, text := range []string{"report-one", "report-two", "report-three"} { + if strings.Count(got, text) != 1 { + t.Fatalf("lost/duplicated report %s: %q", text, got) + } + } + if len(srv.goaWaitingLines()) != 0 { + t.Fatal("reports were requeued") + } +} diff --git a/daemon/goa_inject.go b/daemon/goa_inject.go index c258aa6a..3c11caaf 100644 --- a/daemon/goa_inject.go +++ b/daemon/goa_inject.go @@ -6,7 +6,6 @@ import ( "time" "github.com/enowx/enowx/client" - "github.com/enowx/enowx/core/agent" "github.com/enowx/enowx/core/hooks" "github.com/enowx/enowx/core/session" "github.com/enowx/enowx/proto" @@ -21,15 +20,12 @@ import ( // 2. At a turn's end: its Stop hook keeps the turn going to take them (both). // 3. Idle: Claude Code's background hook, which wakes it on exit code 2. Codex // documents no way in to an idle thread. -// 4. Idle, with none of those (Codex, an agent with no hooks, a hook gone): typed in, -// but only into a composer sure to be empty, with no keystroke for a few seconds. -// Otherwise the line waits, shown in the app, for the next turn or that moment. +// 4. Without one of those channels, reports stay in Tasks. A screen snapshot +// cannot reserve a composer's input against concurrent user typing. // // Nothing here changes what the user is typing, has sent or is editing. const ( - // goaTypedQuiet is how long since a keystroke before a line may be typed in. - goaTypedQuiet = 5 * time.Second // goaContextMost is how much goes at once in an agent's context, under what agents // take (Claude Code 10,000 characters, Codex about 2,500 tokens); the rest follows. goaContextMost = 8000 @@ -38,7 +34,10 @@ const ( func init() { slowMethods[proto.MethodGOAWake] = true } // it waits, on a connection of its own // goaWaker is a hook waiting to wake GOA's idle agent. -type goaWaker struct{ text chan string } +type goaWaker struct { + text chan string + lines []goaLine // original metadata, guarded by delivery; returned on cancellation +} // goaWake waits for lines for session id, for its agent's background hook, and takes // over from any hook before it: until lines come, a later hook comes, the connection @@ -49,6 +48,7 @@ func (s *Server) goaWake(id string, gone <-chan struct{}) proto.GOAWakeReply { return proto.GOAWakeReply{} } w := &goaWaker{text: make(chan string, 1)} + s.goa.delivery.Lock() s.goa.mu.Lock() old := s.goa.waker s.goa.waker = w @@ -56,6 +56,7 @@ func (s *Server) goaWake(id string, gone <-chan struct{}) proto.GOAWakeReply { if old != nil { old.text <- "" } + s.goa.delivery.Unlock() s.goaFlushIdle() // what waited for just this select { case text := <-w.text: @@ -63,8 +64,15 @@ func (s *Server) goaWake(id string, gone <-chan struct{}) proto.GOAWakeReply { case <-gone: case <-s.done: } + s.goaReturnWake(w) + return proto.GOAWakeReply{} +} + +// goaReturnWake puts back a handoff the disconnected hook did not consume. +func (s *Server) goaReturnWake(w *goaWaker) { + s.goa.delivery.Lock() + defer s.goa.delivery.Unlock() s.goa.mu.Lock() - defer s.goa.mu.Unlock() if s.goa.waker == w { s.goa.waker = nil } @@ -72,47 +80,39 @@ func (s *Server) goaWake(id string, gone <-chan struct{}) proto.GOAWakeReply { case text := <-w.text: // It was given lines as it went: they go back, not lost. if text != "" { - s.goa.tell = append([]goaLine{{Text: text}}, s.goa.tell...) + s.goa.tell = uniqueGOALines(append(w.lines, s.goa.tell...)) } default: } - return proto.GOAWakeReply{} + s.goa.mu.Unlock() + s.saveGOATell() } // goaFlush gives what waits for GOA (session id) to its idle agent: through the hook -// waiting to wake it, or typed only where that can't take anything of the user's. +// waiting to wake it. Without a safe channel, the queue stays visible in Tasks. func (s *Server) goaFlush(id string) { - sess, err := s.sessions.Get(id) + s.goa.delivery.Lock() + defer s.goa.delivery.Unlock() + _, err := s.sessions.Get(id) if err != nil { return } + s.goa.mu.Lock() + w := s.goa.waker + s.goa.mu.Unlock() + if w == nil { + return // never paste or press Enter in the user's composer + } lines := s.goaLinesToSay() if len(lines) == 0 { s.saveGOATell() // moot lines may have gone return } s.goa.mu.Lock() - w := s.goa.waker - if w != nil { - s.goa.waker = nil - } + s.goa.waker = nil + w.lines = lines s.goa.mu.Unlock() - switch { - case w != nil: - w.text <- joinLines(lines) - lines = nil - case goaCanType(sess, time.Now()): - if err := s.input(proto.SessionInputParams{ID: id, Text: joinLines(lines), Paste: true, Enter: true}); err != nil { - s.logger().Warn("goa: could not tell GOA", "session", id, "err", err) - } else { - lines = nil - } - } - if len(lines) > 0 { - s.goa.mu.Lock() - s.goa.tell = append(lines, s.goa.tell...) - s.goa.mu.Unlock() - } + w.text <- joinLines(lines) s.saveGOATell() } @@ -124,16 +124,10 @@ func joinLines(lines []goaLine) string { return strings.Join(texts, "\n") } -// goaCanType says typing into GOA's session can't touch anything of the user's: no -// key pressed for a few seconds, nothing at work or asked, a composer sure to be empty. -func goaCanType(sess *session.Session, now time.Time) bool { - info := sess.Info() - return !info.Exited && info.State != proto.StateWorking && info.State != proto.StateWaiting && - sess.TypedFor(now) >= goaTypedQuiet && agent.PromptEmpty(sess.Terminal().Text()) -} - // goaTake takes what fits an agent's context of what waits for GOA, the rest staying. func (s *Server) goaTake() string { + s.goa.delivery.Lock() + defer s.goa.delivery.Unlock() text, _ := s.goaTakeOf(s.goaLinesToSay()) return text } @@ -147,7 +141,7 @@ func (s *Server) goaTakeOf(lines []goaLine) (string, int) { } if n < len(lines) { s.goa.mu.Lock() - s.goa.tell = append(append([]goaLine{}, lines[n:]...), s.goa.tell...) + s.goa.tell = uniqueGOALines(append(append([]goaLine{}, lines[n:]...), s.goa.tell...)) s.goa.mu.Unlock() } s.saveGOATell() diff --git a/daemon/goa_process.go b/daemon/goa_process.go new file mode 100644 index 00000000..4bb11ec9 --- /dev/null +++ b/daemon/goa_process.go @@ -0,0 +1,8 @@ +package daemon + +// goaProcesses is a snapshot used by one watcher tick. Start times keep a +// reused PID from hiding a new process. Unknown ancestry is never exempted. +type goaProcesses struct { + known map[int]bool + starts map[int]string +} diff --git a/daemon/goa_process_other.go b/daemon/goa_process_other.go new file mode 100644 index 00000000..bfa7200c --- /dev/null +++ b/daemon/goa_process_other.go @@ -0,0 +1,27 @@ +//go:build !windows + +package daemon + +import ( + "os" + "path/filepath" + "slices" + "strings" + + "github.com/enowx/enowx/proto" +) + +func goaPlatformProcesses(_ proto.SessionInfo) goaProcesses { return goaProcesses{} } + +func (s *Server) goaOwnProcess(args []string, cmd string) bool { + line := strings.Join(args, " ") + if cmd != "" && strings.Contains(line, cmd) || s.HookBin != "" && strings.Contains(line, s.HookBin) { + return true + } + exe, _ := os.Executable() + if exe != "" && strings.Contains(line, exe) { + return true + } + name := strings.TrimPrefix(filepath.Base(args[0]), "-") + return len(args) == 1 && slices.Contains([]string{"sh", "bash", "zsh", "fish", "dash"}, name) +} diff --git a/daemon/goa_process_windows.go b/daemon/goa_process_windows.go new file mode 100644 index 00000000..7e30f026 --- /dev/null +++ b/daemon/goa_process_windows.go @@ -0,0 +1,66 @@ +package daemon + +import ( + "os" + "path/filepath" + "slices" + "strings" + + "github.com/enowx/enowx/core/agent" + "github.com/enowx/enowx/core/procstat" + "github.com/enowx/enowx/core/session" + "github.com/enowx/enowx/proto" + "golang.org/x/sys/windows" +) + +func (s *Server) goaOwnProcess(args []string, cmd string) bool { + if len(args) == 0 { + return false + } + // An argument mentioning Codex or enowX isn't the program itself. + exe, _ := os.Executable() + path := args[0] + name := strings.TrimSuffix(strings.ToLower(filepath.Base(path)), ".exe") + return cmd != "" && name == strings.TrimSuffix(strings.ToLower(filepath.Base(cmd)), ".exe") || + s.HookBin != "" && strings.EqualFold(path, s.HookBin) || exe != "" && strings.EqualFold(path, exe) +} + +func goaPlatformProcesses(info proto.SessionInfo) goaProcesses { + list, _ := procstat.Snapshot() + out := goaProcesses{starts: map[int]string{}} + for _, p := range list { + out.starts[p.PID] = p.Start + } + out.known = agent.WindowsGOAProcesses(agent.Base(info.Agent), info.ID, list, procstat.Args, processImage, + func(pid int) bool { return procstat.Env(pid, session.EnvSession) == info.ID }, + os.Getenv("LOCALAPPDATA"), os.Getenv("SystemRoot")) + for _, p := range list { + // Only the daemon's exact launch command is a wrapper. A shell + // started later by the agent isn't infrastructure. + if p.PPID == os.Getpid() && procstat.Env(p.PID, session.EnvSession) == info.ID && + matchesSessionCommand(procstat.Args(p.PID), info.Command) { + out.known[p.PID] = true + } + } + return out +} + +func matchesSessionCommand(argv, command []string) bool { + return len(argv) > 0 && len(command) > 0 && + strings.EqualFold(filepath.Base(argv[0]), filepath.Base(command[0])) && slices.Equal(argv[1:], command[1:]) +} + +// processImage queries the executable, rather than trusting argv[0]. +func processImage(pid int) string { + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return "" + } + defer windows.CloseHandle(h) + b := make([]uint16, 32768) + n := uint32(len(b)) + if windows.QueryFullProcessImageName(h, 0, &b[0], &n) != nil { + return "" + } + return windows.UTF16ToString(b[:n]) +} diff --git a/daemon/goa_process_windows_test.go b/daemon/goa_process_windows_test.go new file mode 100644 index 00000000..32f671d1 --- /dev/null +++ b/daemon/goa_process_windows_test.go @@ -0,0 +1,19 @@ +package daemon + +import "testing" + +func TestGOAWindowsCommandIdentity(t *testing.T) { + s := &Server{} + for _, argv := range [][]string{ + {`C:\temp\conhost.exe`, "codex"}, + {`C:\temp\codex\conhost.exe`, "0x4"}, + {"powershell.exe", "-Command", "codex --version"}, + } { + if s.goaOwnProcess(argv, "codex") { + t.Fatalf("argument or directory name bypassed watcher: %q", argv) + } + } + if matchesSessionCommand([]string{"pwsh.exe", "-Command", "other"}, []string{"pwsh.exe", "-Command", "codex"}) { + t.Fatal("an unrelated shell matched the launch wrapper") + } +} diff --git a/daemon/goa_queue.go b/daemon/goa_queue.go new file mode 100644 index 00000000..c034a31f --- /dev/null +++ b/daemon/goa_queue.go @@ -0,0 +1,15 @@ +package daemon + +// uniqueGOALines preserves order and task/session identity. A changed report, +// another task or a later permission prompt with different text remains news. +func uniqueGOALines(lines []goaLine) []goaLine { + seen := make(map[goaLine]bool, len(lines)) + out := make([]goaLine, 0, len(lines)) + for _, line := range lines { + if !seen[line] { + seen[line] = true + out = append(out, line) + } + } + return out +} diff --git a/daemon/goa_stall_delivery_test.go b/daemon/goa_stall_delivery_test.go new file mode 100644 index 00000000..0ca2d4cc --- /dev/null +++ b/daemon/goa_stall_delivery_test.go @@ -0,0 +1,70 @@ +package daemon + +import ( + "strings" + "testing" + "time" + + "github.com/enowx/enowx/proto" +) + +// A stall waits in Tasks without a hook, reaches the next hook once, and can +// remind again after three limits without ever entering the user's draft. +func TestGOAStallReportsUseHooksAndTasks(t *testing.T) { + srv := startServer(t) + c := connect(t, srv, nil) + g := goaComposerFixture(t, srv, "› unfinished draft\r\n") + before := g.Terminal().Text() + task, err := srv.goa.board.Add(proto.GOATask{Title: "quiet", Session: "worker", Status: proto.GOATaskWorking, + Created: time.Now().Add(-time.Hour)}) + if err != nil { + t.Fatal(err) + } + const screen = "compiling the thing\n" + h := stallHost{infos: map[string]proto.SessionInfo{"worker": {ID: "worker", State: proto.StateWorking}}, cpuKnown: true, + cpuOf: func(string) float64 { return 0 }, screen: func(string) (string, error) { return screen, nil }} + f := &stallFollow{screen: screen, since: time.Now().Add(-time.Hour)} + w := &stallWatch{follows: map[string]*stallFollow{task.ID: f}} + lim := proto.GOAStall{Blank: 100, Idle: 100, Stuck: 100} + srv.stallCheck(w, task, h, lim) + lines := stallWaitingOf(t, c, task.ID) + if len(lines) != 1 || !strings.Contains(lines[0], "stuck:") || stallStatus(t, c, task.ID) != proto.GOATaskStalled { + t.Fatalf("stall missing from Tasks: %q", lines) + } + take := func(event string) { + t.Helper() + var d proto.HookDecision + if err := c.Call(proto.MethodSessionHook, proto.SessionHookParams{ID: g.ID(), Event: proto.HookEvent{Event: event}}, &d); err != nil { + t.Fatal(err) + } + if strings.Count(d.Context, "[enowX GOA] Task "+task.ID) != 1 || !strings.Contains(d.Context, screen[:len(screen)-1]) { + t.Fatalf("hook did not receive the stall once: %+v", d) + } + if got := stallWaitingOf(t, c, task.ID); len(got) != 0 { + t.Fatalf("hook left delivered lines in Tasks: %q", got) + } + var next proto.HookDecision + if err := c.Call(proto.MethodSessionHook, proto.SessionHookParams{ID: g.ID(), Event: proto.HookEvent{Event: event}}, &next); err != nil { + t.Fatal(err) + } + if strings.Contains(next.Context, "[enowX GOA] Task "+task.ID) { + t.Fatalf("hook received the stall twice: %+v", next) + } + } + take(proto.HookPromptStart) + task.Status = proto.GOATaskStalled + f.told = time.Now().Add(-240 * time.Second) + srv.stallCheck(w, task, h, lim) + if got := stallWaitingOf(t, c, task.ID); len(got) != 0 { + t.Fatalf("reminded before three limits: %q", got) + } + f.told = time.Now().Add(-360 * time.Second) + srv.stallCheck(w, task, h, lim) + if got := stallWaitingOf(t, c, task.ID); len(got) != 1 { + t.Fatalf("no reminder after three limits: %q", got) + } + take(proto.HookToolAfter) + if got := g.Terminal().Text(); got != before || g.Info().Prompt != "" { + t.Fatalf("stall delivery changed the draft: %q", got) + } +} diff --git a/daemon/goa_stall_loop_test.go b/daemon/goa_stall_loop_test.go index 92092b0b..85983cba 100644 --- a/daemon/goa_stall_loop_test.go +++ b/daemon/goa_stall_loop_test.go @@ -15,9 +15,9 @@ import ( // The stand-in agent: what it does depends on words in its first prompt. It // doesn't echo what's typed, so a screen stays blank until it means otherwise, -// and writes down what it's sent, a line of GOA's wrapping on no screen. Started on -// none of those prompts, it's GOD of Agents, showing a composer with nothing in it: lines -// are typed only into one. Its loops of work end on their own, should anything outlive a test. +// and echoes a worker's input. Started on none of those prompts, it's GOD of Agents, +// showing a composer with nothing in it and recording any input as a safety failure. +// Its loops of work end on their own, should anything outlive a test. const stallAgent = `#!/bin/sh stty -echo 2>/dev/null work() { i=0; while [ $i -lt 300000000 ]; do i=$((i+1)); done; } @@ -31,14 +31,14 @@ case "$*" in esac while read -r line; do echo "heard: $line" - echo "$line" >>"$STALL_HEARD" + [ -z "$composer" ] || echo "$line" >>"$STALL_HEARD" [ -z "$composer" ] || echo ">" done ` -// stallStandIn puts the stand-in agent first on PATH, as claude, and says the -// file it writes what it hears in. -func stallStandIn(t *testing.T) string { +// stallStandIn puts the stand-in agent first on PATH, as claude, and checks that +// background reports never enter GOA's composer. +func stallStandIn(t *testing.T) { t.Helper() bin := t.TempDir() if err := os.WriteFile(filepath.Join(bin, "claude"), []byte(stallAgent), 0o755); err != nil { @@ -47,14 +47,20 @@ func stallStandIn(t *testing.T) string { t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) heard := filepath.Join(t.TempDir(), "heard") t.Setenv("STALL_HEARD", heard) - return heard + t.Cleanup(func() { + if data, err := os.ReadFile(heard); err == nil && len(data) != 0 { + t.Errorf("background reports entered GOA's composer: %q", data) + } else if err != nil && !os.IsNotExist(err) { + t.Error(err) + } + }) } -// stallLinesOf are the lines of GOA's heard so far about task id. -func stallLinesOf(heard, id string) []string { - data, _ := os.ReadFile(heard) +// stallWaitingOf reads the safe fallback for a GOA without a documented hook. +func stallWaitingOf(t *testing.T, c *client.Client, id string) []string { + t.Helper() var out []string - for _, line := range strings.Split(string(data), "\n") { + for _, line := range goaBoardOf(t, c).Waiting { if strings.Contains(line, "[enowX GOA] Task "+id) { out = append(out, line) } @@ -113,7 +119,7 @@ func stallStatus(t *testing.T, c *client.Client, id string) string { } // Each way of not moving, end to end with the daemon's own loop: the task shows -// as stalled, GOA hears one line naming the case, how long and the screen, the +// as stalled, Tasks keeps a line naming the case, how long and the screen, the // hook fires once, a further line comes only after three times the limit, and // the task goes back to working when its screen moves. A process at work, screen // blank or still, is left alone. @@ -121,7 +127,7 @@ func TestGOAStalledTasksEndToEnd(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses sh") } - heard := stallStandIn(t) + stallStandIn(t) srv := startServer(t) c := connect(t, srv, nil) fired := stallHookFile(t, srv) @@ -164,7 +170,7 @@ func TestGOAStalledTasksEndToEnd(t *testing.T) { done := true now := time.Now() for _, sc := range cases { - lines := stallLinesOf(heard, sc.task.ID) + lines := stallWaitingOf(t, c, sc.task.ID) if len(lines) >= 1 && sc.first.IsZero() { sc.first = now if st := stallStatus(t, c, sc.task.ID); st != proto.GOATaskStalled { @@ -198,14 +204,14 @@ func TestGOAStalledTasksEndToEnd(t *testing.T) { } for _, task := range working { if st := stallStatus(t, c, task.ID); st != proto.GOATaskWorking { - t.Fatalf("%q was taken for stalled: %s: %q", task.Title, st, stallLinesOf(heard, task.ID)) + t.Fatalf("%q was taken for stalled: %s: %q", task.Title, st, stallWaitingOf(t, c, task.ID)) } } if done { break } if now.After(waitFor2) { - t.Fatalf("no further line in time: %q", mustRead(heard)) + t.Fatalf("no further line in Tasks in time: %q", goaBoardOf(t, c).Waiting) } time.Sleep(100 * time.Millisecond) } @@ -240,11 +246,6 @@ func srvInfo(t *testing.T, c *client.Client, id string) proto.SessionInfo { return proto.SessionInfo{} } -func mustRead(path string) string { - data, _ := os.ReadFile(path) - return string(data) -} - // A task on another host is watched the same, through the app: stalled on GOA's // board, GOA told where and the hook fired on GOA's host, back to working when the // screen there moves, a process at work there left alone. Those looks stay out of @@ -253,7 +254,7 @@ func TestGOAStalledTaskOnAnotherHost(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses sh") } - heard := stallStandIn(t) + stallStandIn(t) a, b := startServer(t), startServer(t) ca, cb := connect(t, a, nil), connect(t, b, nil) standInApp(t, a, map[string]*client.Client{b.daemonID: cb}, func(proto.HostCallParams) bool { return true }) @@ -274,8 +275,8 @@ func TestGOAStalledTaskOnAnotherHost(t *testing.T) { t.Fatal(err) } - waitFor(t, "told", func() bool { return len(stallLinesOf(heard, blank.ID)) == 1 }) - line := stallLinesOf(heard, blank.ID)[0] + waitFor(t, "told", func() bool { return len(stallWaitingOf(t, ca, blank.ID)) == 1 }) + line := stallWaitingOf(t, ca, blank.ID)[0] for _, want := range []string{"on " + b.daemonID, "never started", "Its screen is blank.", "--host " + b.daemonID} { if !strings.Contains(line, want) { t.Errorf("no %q in: %s", want, line) @@ -290,8 +291,8 @@ func TestGOAStalledTaskOnAnotherHost(t *testing.T) { } // A host out of reach says nothing of its session: what GOA is told says that, once the // limit has passed, and nothing calls it not moving. - waitFor(t, "told of the host out of reach", func() bool { return len(stallLinesOf(heard, unreached.ID)) == 1 }) - if line := stallLinesOf(heard, unreached.ID)[0]; !strings.Contains(line, "can't be reached") || strings.Contains(line, "never started") { + waitFor(t, "told of the host out of reach", func() bool { return len(stallWaitingOf(t, ca, unreached.ID)) == 1 }) + if line := stallWaitingOf(t, ca, unreached.ID)[0]; !strings.Contains(line, "can't be reached") || strings.Contains(line, "never started") { t.Errorf("a host out of reach read as a stalled task: %s", line) } log, err := ca.ControlLog() @@ -315,7 +316,7 @@ func TestGOAStalledSessionHibernatedOrGone(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses sh") } - heard := stallStandIn(t) + stallStandIn(t) srv := startServer(t) c := connect(t, srv, nil) fired := stallHookFile(t, srv) @@ -336,15 +337,15 @@ func TestGOAStalledSessionHibernatedOrGone(t *testing.T) { if err := srv.deleteSession(gone.Session); err != nil { t.Fatal(err) } - for _, c := range []struct { + for _, tc := range []struct { task proto.GOATask says string }{{asleep, "idle: its session is hibernated"}, {gone, "idle: its session is gone"}} { - waitFor(t, c.says, func() bool { return len(stallLinesOf(heard, c.task.ID)) == 1 }) - if line := stallLinesOf(heard, c.task.ID)[0]; !strings.Contains(line, c.says) { + waitFor(t, tc.says, func() bool { return len(stallWaitingOf(t, c, tc.task.ID)) == 1 }) + if line := stallWaitingOf(t, c, tc.task.ID)[0]; !strings.Contains(line, tc.says) { t.Errorf("its line: %s", line) } - waitFor(t, c.says+": the hook", func() bool { return len(stallFired(t, fired, c.task.Session)) == 1 }) + waitFor(t, tc.says+": the hook", func() bool { return len(stallFired(t, fired, tc.task.Session)) == 1 }) } } @@ -353,7 +354,7 @@ func TestGOASettlingAStalledTask(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses sh") } - heard := stallStandIn(t) + stallStandIn(t) srv := startServer(t) c := connect(t, srv, nil) if err := c.Call(proto.MethodGOAStallSave, proto.GOAStall{Blank: 1, Idle: 1, Stuck: 1}, nil); err != nil { @@ -363,7 +364,7 @@ func TestGOASettlingAStalledTask(t *testing.T) { t.Fatal(err) } task := stallDelegate(t, c, proto.GOADelegateParams{Prompt: "STANDIN-SILENT"}) - waitFor(t, "told", func() bool { return len(stallLinesOf(heard, task.ID)) == 1 }) + waitFor(t, "told", func() bool { return len(stallWaitingOf(t, c, task.ID)) == 1 }) if err := c.Call(proto.MethodGOATask, proto.GOATaskParams{ID: task.ID, Status: proto.GOATaskFailed, Result: "gave up"}, nil); err != nil { t.Fatal(err) } @@ -371,7 +372,7 @@ func TestGOASettlingAStalledTask(t *testing.T) { if st := stallStatus(t, c, task.ID); st != proto.GOATaskFailed { t.Fatalf("settled, then %s", st) } - if lines := stallLinesOf(heard, task.ID); len(lines) != 1 { + if lines := stallWaitingOf(t, c, task.ID); len(lines) != 1 { t.Fatalf("still told of it: %q", lines) } } @@ -382,7 +383,7 @@ func TestGOASettledTaskRaisesNoStallLine(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses sh") } - heard := stallStandIn(t) + stallStandIn(t) srv := startServer(t) c := connect(t, srv, nil) if _, err := c.CreateSession(proto.SessionCreateParams{Agent: "claude", GOA: true, Cols: 200, Rows: 40}); err != nil { @@ -405,19 +406,19 @@ func TestGOASettledTaskRaisesNoStallLine(t *testing.T) { t.Fatalf("to say: %+v", got) } time.Sleep(2 * time.Second) - if lines := stallLinesOf(heard, snapshot.ID); len(lines) != 0 { + if lines := stallWaitingOf(t, c, snapshot.ID); len(lines) != 0 { t.Fatalf("told of a settled task: %q", lines) } } -// Over a restart, a stalled task still shows so; GOA, whose queue went with the -// daemon, hears again, and the hook isn't fired a second time for the same stall. +// Over a restart, a stalled task and its queued report persist; a further +// reminder follows, but the worker hook does not fire again for the same stall. // A session that ended meanwhile settles its task. func TestGOAStallAcrossARestart(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses sh") } - heard := stallStandIn(t) + stallStandIn(t) dir := t.TempDir() srv, err := NewServer(dir) if err != nil { @@ -432,8 +433,9 @@ func TestGOAStallAcrossARestart(t *testing.T) { t.Fatal(err) } task := stallDelegate(t, c, proto.GOADelegateParams{Prompt: "STANDIN-SILENT"}) - waitFor(t, "told", func() bool { return len(stallLinesOf(heard, task.ID)) == 1 }) + waitFor(t, "told", func() bool { return len(stallWaitingOf(t, c, task.ID)) == 1 }) waitFor(t, "the hook", func() bool { return len(stallFired(t, fired, task.Session)) == 1 }) + queued := stallWaitingOf(t, c, task.ID)[0] srv.Close() again, err := NewServer(dir) @@ -442,13 +444,17 @@ func TestGOAStallAcrossARestart(t *testing.T) { } defer again.Close() c2 := connect(t, again, nil) + lines := stallWaitingOf(t, c2, task.ID) + if len(lines) == 0 || lines[0] != queued { + t.Fatalf("queued report lost across restart: %q", lines) + } deadline := time.Now().Add(30 * time.Second) - for len(stallLinesOf(heard, task.ID)) < 2 { + for len(stallWaitingOf(t, c2, task.ID)) < 2 { if st := stallStatus(t, c2, task.ID); st != proto.GOATaskStalled { t.Fatalf("after a restart the board says %s", st) } if time.Now().After(deadline) { - t.Fatalf("GOA wasn't told again: %q", mustRead(heard)) + t.Fatalf("no further line in Tasks after restart: %q", goaBoardOf(t, c2).Waiting) } time.Sleep(100 * time.Millisecond) } diff --git a/daemon/goa_stop.go b/daemon/goa_stop.go index ff568f0d..19eced0d 100644 --- a/daemon/goa_stop.go +++ b/daemon/goa_stop.go @@ -24,6 +24,8 @@ func (s *Server) goaTellAbout(id, line string) { // goaTakeAtStop takes what may keep GOA's turn going, the rest staying for its next way in. func (s *Server) goaTakeAtStop() string { + s.goa.delivery.Lock() + defer s.goa.delivery.Unlock() lines := s.goaLinesToSay() var may, wait []goaLine s.goa.mu.Lock() @@ -34,7 +36,7 @@ func (s *Server) goaTakeAtStop() string { may = append(may, l) } } - s.goa.tell = append(wait, s.goa.tell...) + s.goa.tell = uniqueGOALines(append(wait, s.goa.tell...)) s.goa.mu.Unlock() text, n := s.goaTakeOf(may) s.goa.mu.Lock() diff --git a/daemon/goa_tasks.go b/daemon/goa_tasks.go index d69e4648..f8a5cbf3 100644 --- a/daemon/goa_tasks.go +++ b/daemon/goa_tasks.go @@ -401,16 +401,21 @@ func (s *Server) goaReportSeen(info proto.SessionInfo) { if info.ReportAt == 0 { return } + notify := map[string]bool{} changed := s.goa.board.Of(info.ID, func(t *proto.GOATask) bool { // A report belongs to the task under way when it was sent: not to one only // queued, nor to one given after it. if t.Status == proto.GOATaskQueued || t.ReportAt >= info.ReportAt { return false } + notify[t.ID] = t.Outcome != info.ReportOutcome || t.Said != info.Report t.Outcome, t.ReportAt, t.Said = info.ReportOutcome, info.ReportAt, info.Report return true }) for _, t := range changed { + if !notify[t.ID] { + continue + } where, hostFlag := goaTaskPlace(t) s.goaTellLine(goaLine{Task: t.ID, Session: t.Session, Text: fmt.Sprintf("[enowX GOA] Task %s %q (%s) reports %s: %s Check it (chat %s%s), then settle it: goa task %s --status done|failed --result \"…\".", t.ID, t.Title, where, t.Outcome, clip(oneLine(t.Said), goaResultMax), t.Session, hostFlag, t.ID)}) } diff --git a/daemon/goa_watch.go b/daemon/goa_watch.go index 02b043a6..8267dc8a 100644 --- a/daemon/goa_watch.go +++ b/daemon/goa_watch.go @@ -108,10 +108,11 @@ func baseOf(dir string) (projectBase, bool) { // goaLook flags what GOA session info did since the last look. func (s *Server) goaLook(info proto.SessionInfo, w *goaWatch) { preset, _ := agent.Find(info.Agent) + infrastructure := goaPlatformProcesses(info) for _, pid := range procstat.Tagged(session.EnvSession, info.ID) { - key := fmt.Sprint("pid:", pid) + key := fmt.Sprint("pid:", pid, ":", infrastructure.starts[pid]) args := procstat.Args(pid) - if w.flagged[key] || len(args) == 0 || s.goaOwnProcess(args, preset.Command) { + if w.flagged[key] || len(args) == 0 || infrastructure.known[pid] || s.goaOwnProcess(args, preset.Command) { continue } w.flagged[key] = true @@ -145,22 +146,6 @@ func (s *Server) goaLook(info proto.SessionInfo, w *goaWatch) { } } -// goaOwnProcess says whether a process (args) is GOA's agent itself (cmd, -// and the shell wrapping it), enowX's program, or the shell a session -// keeps once its agent ends. -func (s *Server) goaOwnProcess(args []string, cmd string) bool { - line := strings.Join(args, " ") - if cmd != "" && strings.Contains(line, cmd) || s.HookBin != "" && strings.Contains(line, s.HookBin) { - return true - } - exe, _ := os.Executable() - if exe != "" && strings.Contains(line, exe) { - return true - } - name := strings.TrimPrefix(filepath.Base(args[0]), "-") - return len(args) == 1 && slices.Contains([]string{"sh", "bash", "zsh", "fish", "dash"}, name) -} - // othersWorkIn says whether a session other than GOA works in folder dir. func (s *Server) othersWorkIn(dir string) bool { for _, sess := range s.sessions.List() { @@ -178,6 +163,15 @@ func (s *Server) goaFlag(f proto.GOAFlag) { _, _ = rand.Read(id) f.ID, f.Time = "f"+hex.EncodeToString(id), time.Now() s.goa.mu.Lock() + // Keep every flag for inspection, but don't make identical processes + // with new PIDs sound like new kinds of activity every five seconds. + notify := true + for _, before := range s.goa.flags { + if before.Session == f.Session && before.Command == f.Command && before.Project == f.Project && + before.File == f.File && before.Change == f.Change && f.Time.Sub(before.Time) < time.Minute { + notify = false + } + } s.goa.flags = append(s.goa.flags, f) if n := len(s.goa.flags); n > 200 { s.goa.flags = slices.Clone(s.goa.flags[n-200:]) @@ -188,6 +182,9 @@ func (s *Server) goaFlag(f proto.GOAFlag) { what = f.Change + " " + f.File + " in " + filepath.Base(f.Project) } s.broadcast(proto.EventGOAFlag, f) + if !notify { + return + } s.broadcast(proto.EventNotifySound, proto.NotifySoundParams{Kind: proto.NotifyInput, Session: f.Session, Text: "GOD of Agents " + what + " itself, not through a session. See its Sessions panel."}) } diff --git a/docs/goa.md b/docs/goa.md index abfe669e..3fd860d8 100644 --- a/docs/goa.md +++ b/docs/goa.md @@ -37,6 +37,13 @@ Leaving the mode asks first, whichever way: the title bar's **Sessions**, **Leav The app shows each agent's level when picking GOA's; `enowx goa agents` lists them. +On Windows, the POSIX hook commands are disabled: Claude Code is **limited** +by its tool restrictions and Codex is **watched**. The enforced levels above +apply where those hooks are available. The Windows watcher recognizes known +Codex helpers and their console hosts by executable path and same-session +ancestry; unrelated or unproven processes still raise flags. Identical flags +remain on the audit list but notify again only after a quiet minute. + No enowX command that runs a program of GOA's choosing works in its session, or in its MCP server: `creds use … -- COMMAND`, `decide trim -- COMMAND`, and `run` of a project command (listing them still works). Those would be a way around the tools it lacks. Checked with Codex itself, started as GOA: enowX's tools reached it over MCP as `mcp__enowx__…`, as its hooks name them; it read a project's file and wrote one in its own folder, and handed over a write outside it and a `go version`. Checked with Claude Code itself, started as GOA: it read a project's file and wrote one (in auto mode, unasked), and handed `go version` to a session, having no Bash. @@ -49,7 +56,7 @@ Checked with Codex itself, started as GOA: enowX's tools reached it over MCP as 2. **Its model and effort**: the judge's `decide route` ([decide.md](decide.md)) for the prompt: easy to a cheap model on low effort, hard to the strongest on high, and a step cheaper when a token budget is at 80% or more ([tokens.md](tokens.md)). A new session starts on them (`SessionCreateParams.Model`, `Effort`); a running one gets its `/model` and `/effort` typed first. `--model`, `--effort`, `--config` (another provider, [configs.md](configs.md)) choose by hand; `--no-route` keeps the agent's own. The choice and why are printed, kept on the board, and shown in Tasks, where the user can change them. 3. **The board** (`goa.board`, kept in `goa-board.json` beside the daemon's settings, the last 500): the task, its session and host, routing, status, result. -**GOA is told, never polls.** The board follows each worker: this host's through its session updates (its agent's `turn.done` hook, or its screen going quiet), other hosts' through the app. A turn's end sets a task `turn-ended`, with what its agent said in `said`: it may be mid-work, so it is no result, and only `goa task --status done|failed` settles it, or its agent ending. When a task's session finishes a turn, asks something, fails or ends, the board changes and a line starting `[enowX GOA]` is typed into GOA's conversation once it is idle: the result (the worker's last message, else its screen's end), what it asks and whose it is to answer. GOA then checks the work (`chat`, `changes`) and settles the task (`goa task ID --status done --result …`). +**GOA is told, never polls.** The board follows each worker: this host's through its session updates (its agent's `turn.done` hook, or its screen going quiet), other hosts' through the app. A turn's end sets a task `turn-ended`, with what its agent said in `said`: it may be mid-work, so it is no result, and only `goa task --status done|failed` settles it, or its agent ending. When a task's session finishes a turn, asks something, fails or ends, the board changes and a line starting `[enowX GOA]` reaches GOA through its hook when supported, or waits in Tasks: the result (the worker's last message, else its screen's end), what it asks and whose it is to answer. GOA then checks the work (`chat`, `changes`) and settles the task (`goa task ID --status done --result …`). There is no limit on workers; rate limits fall to accounts' fallback ([accounts.md](accounts.md)) and budgets. @@ -69,7 +76,7 @@ Routes, in order: What the session doesn't say, enowX says for it: its process exiting or crashing, with the exit code and its last words, marked `NoReport` on the board and never shown as at work; a turn interrupted; a limit its agent showed; a lost sign-in, as what it asks; going silent, as a stall line; a host that can't be reached, once, after the idle limit. -A line stays until it has gone in: kept in `goa-tell.json` while GOA is busy, hibernated or stopped and over a daemon's restart, put back if typing it fails, the newest 200 kept. Only a stall line of a task since settled is dropped, as moot; a report isn't. With no GOA on the host at all there is no one to tell. +A line stays until it has gone in: kept in `goa-tell.json` while GOA is busy, hibernated or stopped and over a daemon's restart, put back if a hook disconnects before taking it, the newest 200 kept. Only a stall line of a task since settled is dropped, as moot; a report isn't. With no GOA on the host at all there is no one to tell. Sent something again, a task whose turn had ended is at work again, so a screen with no hook can report the next turn too. @@ -92,7 +99,9 @@ Workers' reports never go through GOA's input box while anything of the user's c | At a turn's end | Its `Stop` hook's `additionalContext` keeps the turn going | `decision: "block"` with the lines as the reason | None | | Idle | A background hook (`asyncRewake`, 24 hours at most, armed at each start and turn's end) wakes it on exit code 2, the lines as a system reminder | No way in documented: below | Below | -Idle with no such route, or that hook gone: the line waits, and is typed only into a composer sure to be empty, with no key pressed for 5 seconds and nothing at work or asked. A prompt the screen can't be sure of counts as a draft, so the line goes on waiting, shown in GOA's Tasks panel under Waiting for GOA. It never sends with a draft in the prompt. A line waiting goes with the next turn, however that starts. Claude Code's channels also push into an idle session, but they are a research preview, a custom one needs a development flag at each start, and they need Anthropic sign-in, so they aren't used. +Without a documented hook channel, reports stay in GOA's Tasks panel under Waiting for GOA. enowX never pastes them or presses Enter in the terminal composer, even if the screen looks empty and no key was pressed for five seconds: those observations cannot reserve the draft against concurrent typing. This applies especially to watched Codex on Windows, whose hooks are disabled. Reading Tasks does not submit or erase the user's draft. Supported hooks can take waiting reports on a later turn. Claude Code's channels also push into an idle session, but they are a research preview, a custom one needs a development flag at each start, and they need Anthropic sign-in, so they aren't used. + +Identical queued lines are collapsed without merging different tasks or sessions. Repeated reports with the same outcome and text update the task's report timestamp without delivering the same report again. Queue checkout and hook handoff are serialized; a disconnected wake hook returns the original task/session metadata. ## Workers' permission prompts On the worker's host, before each tool call of a GOA worker (its agent's hooks): @@ -148,7 +157,7 @@ The limits are each host's, for its own GOA, from a second to a day: **Stall lim Tasks on other hosts are watched the same, through the app, which is what reaches them: every few seconds GOA's host reads their sessions, screens and CPU. With no app connected, or the host out of reach, their tasks stay as they are until it can look. Those reads aren't in `control log`, as no session made them. The hook runs on GOA's host, not the worker's. -Across a daemon restart, a stalled task stays stalled and GOA, whose waiting lines went with the daemon, hears again; the hook doesn't fire again for the same stall. A session that ended while the daemon was down settles its task as failed or stopped. +Across a daemon restart, a stalled task stays stalled and its waiting lines survive in Tasks until a supported hook takes them. Further reminders follow through the same safe channels; the worker's stalled hook doesn't fire again for the same stall. A session that ended while the daemon was down settles its task as failed or stopped. GOA's prompt is 5.2 KB, and goes into every turn; `goa skill setup` (4 KB), `goa skill features` (4 KB) and the docs (`goa doc`: a 10 KB list, each doc when asked for) are read when needed. With the prompt and those, a small model named a real command for each of the 29 features, and said who it is from the prompt alone. diff --git a/ui/app.go b/ui/app.go index b1ef803e..5341f220 100644 --- a/ui/app.go +++ b/ui/app.go @@ -309,6 +309,9 @@ type App struct { // Options configures the app. type Options struct { + // TrayIcon is the executable's icon. Windows uses it for both the tray + // and fallback notification identity; other platforms keep their template. + TrayIcon []byte // Hosts is the device's list of servers. Hosts *hosts.Store // Start is the host to connect to first. diff --git a/ui/tray.go b/ui/tray.go index c3d3c8a7..d8a4cf44 100644 --- a/ui/tray.go +++ b/ui/tray.go @@ -33,7 +33,7 @@ func (a *App) setUpTray() { if runtime.GOOS == "darwin" { ink = color.NRGBA{A: 255} // a template, which macOS tints } - t, err := mygo.NewTray(mygo.TrayOptions{ID: "enowx", Icon: icon.LogoPNG(36, ink), IconIsTemplate: runtime.GOOS == "darwin", ToolTip: "enowX"}) + t, err := mygo.NewTray(mygo.TrayOptions{ID: "enowx", Icon: a.trayIcon(icon.LogoPNG(36, ink)), IconIsTemplate: runtime.GOOS == "darwin", ToolTip: "enowX"}) if err != nil { log.Print("no tray icon: ", err) return diff --git a/ui/tray_icon_other.go b/ui/tray_icon_other.go new file mode 100644 index 00000000..aa04d545 --- /dev/null +++ b/ui/tray_icon_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package ui + +func (a *App) trayIcon(fallback []byte) []byte { return fallback } diff --git a/ui/tray_icon_windows.go b/ui/tray_icon_windows.go new file mode 100644 index 00000000..66594c5a --- /dev/null +++ b/ui/tray_icon_windows.go @@ -0,0 +1,8 @@ +package ui + +func (a *App) trayIcon(fallback []byte) []byte { + if len(a.opts.TrayIcon) != 0 { + return a.opts.TrayIcon + } + return fallback +}