From 65a42169878e9d2915c6cd5ea2e1a1bd44fb2719 Mon Sep 17 00:00:00 2001 From: enowdev Date: Tue, 10 Nov 2026 01:23:45 +0700 Subject: [PATCH 1/2] Read OpenCode's empty composer, so lines reach its GOD of Agents OpenCode has no hook to wake GOA, so a line for it is typed in only once its composer reads empty. OpenCode's composer is a box of bar rows with no prompt mark, which no pattern matched, so worker reports waited in goa-tell.json for good (#51). Its box now reads as empty when its rows are bare or its placeholder and its last row is its agent and model or its commands' hint, with no dialog open over it; a row with anything else is a draft. Screens are from OpenCode 1.18.35 on Linux, and the Windows rows the issue shows. Co-Authored-By: Claude Opus 5.5 Signed-off-by: enowdev --- core/agent/prompts.go | 2 +- core/agent/prompts_opencode.go | 58 +++++++++++++++++ core/agent/prompts_opencode_test.go | 65 +++++++++++++++++++ .../testdata/opencode-1.18-linux-dialog.txt | 39 +++++++++++ .../testdata/opencode-1.18-linux-draft.txt | 39 +++++++++++ .../testdata/opencode-1.18-linux-empty.txt | 39 +++++++++++ docs/goa.md | 2 +- 7 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 core/agent/prompts_opencode.go create mode 100644 core/agent/prompts_opencode_test.go create mode 100644 core/agent/testdata/opencode-1.18-linux-dialog.txt create mode 100644 core/agent/testdata/opencode-1.18-linux-draft.txt create mode 100644 core/agent/testdata/opencode-1.18-linux-empty.txt diff --git a/core/agent/prompts.go b/core/agent/prompts.go index 1d0bbfd5..b4bb4514 100644 --- a/core/agent/prompts.go +++ b/core/agent/prompts.go @@ -173,7 +173,7 @@ func PromptEmpty(screen string) bool { if lastMatch(lines, emptyPlaceholder) >= 0 { return !workingShown.MatchString(strings.Join(lines, "\n")) } - return bareMark(lines) >= 0 + return bareMark(lines) >= 0 || openCodeEmpty(lines) } // lastMatch is the last of lines re matches, -1 for none. diff --git a/core/agent/prompts_opencode.go b/core/agent/prompts_opencode.go new file mode 100644 index 00000000..aa9af7f2 --- /dev/null +++ b/core/agent/prompts_opencode.go @@ -0,0 +1,58 @@ +package agent + +import ( + "regexp" + "strings" +) + +// OpenCode's composer is a box whose rows start with a heavy bar, and has no prompt mark: +// +// ┃ +// ┃ Ask anything… "Fix a TODO in the codebase" (the placeholder, on its first screen) +// ┃ +// ┃ Build · Big Pickle OpenCode Zen (its agent and model) +// ╹▀▀▀▀▀▀▀▀ +// +// In a conversation the placeholder isn't shown, and on Windows the last row may read as +// its commands' hint. Only that box counts: a bar alone isn't a composer for other agents. + +// openCodePlaceholder is the composer's placeholder, OpenCode's exact words. +var openCodePlaceholder = regexp.MustCompile(`^┃\s*Ask anything(…|\.\.\.)(\s+"[^"]*")?$`) + +// openCodeDialog is a dialog's title over the screen, its close key at the right: what is +// typed then goes to the dialog (an update's, a model's pick), not the composer. +var openCodeDialog = regexp.MustCompile(`\s{2,}esc$`) + +// openCodeChrome is the box's last row: the agent and model it runs, or its commands' hint. +var openCodeChrome = regexp.MustCompile(`^┃\s+(\S.* · |.*ctrl\+p commands)`) + +// openCodeEmpty says lines end on OpenCode's composer with nothing typed in it: the last +// box of bar rows, its last row OpenCode's, every other row bare or the placeholder, and +// nothing at work nor asking to allow something. A row with anything else is a draft. +func openCodeEmpty(lines []string) bool { + end := -1 + for i := len(lines) - 1; i >= 0; i-- { + if strings.HasPrefix(lines[i], "┃") { + end = i + break + } + } + start := end + for start > 0 && strings.HasPrefix(lines[start-1], "┃") { + start-- + } + if end < 0 || end-start < 2 || !openCodeChrome.MatchString(lines[end]) { + return false + } + if lastMatch(lines, openCodeDialog) >= 0 { + return false + } + for _, row := range lines[start:end] { + if row != "┃" && !openCodePlaceholder.MatchString(row) { + return false + } + } + // Under the box are its hints and a tip, whose words change: only a prompt to allow + // something counts there. + return !workingShown.MatchString(strings.Join(lines, "\n")) && !permissionPrompts.MatchString(strings.Join(lines[end+1:], "\n")) +} diff --git a/core/agent/prompts_opencode_test.go b/core/agent/prompts_opencode_test.go new file mode 100644 index 00000000..3f3045cd --- /dev/null +++ b/core/agent/prompts_opencode_test.go @@ -0,0 +1,65 @@ +package agent + +import ( + "os" + "strings" + "testing" +) + +// openCodeOnWindows is GOD of Agents on OpenCode 1.18 in a conversation, idle, as its screen +// read on Windows (issue #51): the composer's rows are bars alone, then its commands' hint. +func openCodeOnWindows(draft string) string { + row := " ┃" + if draft != "" { + row = " ┃ " + draft + } + return " Done: the worker's branch is merged.\n\n" + + " ▣ Goa · wz/deepseek-v4.1-flash · 2m 10s\n\n" + + " ┃\n" + row + "\n" + + " ┃ ctrl+p commands • OpenCode 1.18.35\n" +} + +// OpenCode's composer with nothing typed reads as empty, so lines for GOD of Agents go in; +// with a draft it doesn't, nor while OpenCode works or asks to allow something. +func TestOpenCodeComposer(t *testing.T) { + read := func(name string) string { + t.Helper() + b, err := os.ReadFile("testdata/" + name) + if err != nil { + t.Fatal(err) + } + return string(b) + } + cases := []struct { + name string + screen string + empty bool + }{ + {"Linux, its placeholder", read("opencode-1.18-linux-empty.txt"), true}, + {"Linux, a draft", read("opencode-1.18-linux-draft.txt"), false}, + {"Linux, an update's dialog over it", read("opencode-1.18-linux-dialog.txt"), false}, + {"Windows, in a conversation", openCodeOnWindows(""), true}, + {"Windows, a draft", openCodeOnWindows("sekalian cek routes-nya"), false}, + {"Windows, a draft that starts as the placeholder", openCodeOnWindows("Ask anything about the routes"), false}, + {"at work", strings.Replace(openCodeOnWindows(""), "ctrl+p commands", "esc interrupt", 1), false}, + {"asking to allow something", openCodeOnWindows("") + "Permission required: bash rm -rf dist\n", false}, + } + for _, c := range cases { + if got := PromptEmpty(c.screen); got != c.empty { + t.Errorf("%s: PromptEmpty = %v, want %v", c.name, got, c.empty) + } + } +} + +// A bar alone, or a box of them that isn't OpenCode's, isn't an empty composer. +func TestABarAloneIsNoComposer(t *testing.T) { + for _, screen := range []string{ + "┃\n┃\n┃\n", + "some output\n ┃\n ┃\n ┃ total 3 files\n", + "│\n│ ❯ draft\n│\n", + } { + if PromptEmpty(screen) { + t.Errorf("read as an empty composer:\n%s", screen) + } + } +} diff --git a/core/agent/testdata/opencode-1.18-linux-dialog.txt b/core/agent/testdata/opencode-1.18-linux-dialog.txt new file mode 100644 index 00000000..9f3d1f1f --- /dev/null +++ b/core/agent/testdata/opencode-1.18-linux-dialog.txt @@ -0,0 +1,39 @@ + + + + + + + + + + + + Update Available esc + + A new release v1.19.5 is available. Would you like to + update now? + + + Skip Confirm + + ┃ + ┃ Ask anything… "Fix a TODO in the codebase" + ┃ + ┃ Build · Big Pickle OpenCode Zen + ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + tab agents ctrl+p commands + + + + ● Tip Run /connect to add an AI provider and start coding + + + + + + + + + + ~/code/shop 1.18.35 diff --git a/core/agent/testdata/opencode-1.18-linux-draft.txt b/core/agent/testdata/opencode-1.18-linux-draft.txt new file mode 100644 index 00000000..6a8708b7 --- /dev/null +++ b/core/agent/testdata/opencode-1.18-linux-draft.txt @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + ▄ + █▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█ + █ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀ + ▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ + + + ┃ + ┃ fix the totals please + ┃ + ┃ Build · Big Pickle OpenCode Zen + ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + tab agents ctrl+p commands + + + + ● Tip Run /connect to add an AI provider and start coding + + + + + + + + + + ~/code/shop 1.18.35 diff --git a/core/agent/testdata/opencode-1.18-linux-empty.txt b/core/agent/testdata/opencode-1.18-linux-empty.txt new file mode 100644 index 00000000..be6b2be0 --- /dev/null +++ b/core/agent/testdata/opencode-1.18-linux-empty.txt @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + ▄ + █▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█ + █ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀ + ▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ + + + ┃ + ┃ Ask anything… "Fix a TODO in the codebase" + ┃ + ┃ Build · Big Pickle OpenCode Zen + ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + tab agents ctrl+p commands + + + + ● Tip Run /connect to add an AI provider and start coding + + + + + + + + + + ~/code/shop 1.18.35 diff --git a/docs/goa.md b/docs/goa.md index 03b62f00..791209ff 100644 --- a/docs/goa.md +++ b/docs/goa.md @@ -137,7 +137,7 @@ 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. +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. OpenCode, which has no such hook, is read by its composer's box: its bar rows bare or its placeholder (`Ask anything…`), its last row its agent and model or its commands' hint, and no dialog open over it (`core/agent/prompts_opencode.go`). 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. ## Tasks from Project Docs From aa96ce2d1034624341b72969fc98a62e09da006d Mon Sep 17 00:00:00 2001 From: enowdev Date: Tue, 10 Nov 2026 01:26:32 +0700 Subject: [PATCH 2/2] Stop telling GOD of Agents again of a stall it hasn't read yet The stall watchdog told GOA of a task every three limits for as long as it stayed still, and didn't look at whether its session had reported. With OpenCode as GOA, whose lines never went in, a task that had reported done filled goa-tell.json with its stall lines, pushing real reports out (#51). A task whose session reported since it last moved, or whose report or turn's end waits for GOA, is no longer stalled, and no further stall line comes while the one before still waits. Co-Authored-By: Claude Opus 5.5 Signed-off-by: enowdev --- daemon/goa.go | 9 +++++ daemon/goa_stall.go | 27 +++++++++++++- daemon/goa_stall_test.go | 78 ++++++++++++++++++++++++++++++++++++++++ docs/goa.md | 2 +- 4 files changed, 114 insertions(+), 2 deletions(-) diff --git a/daemon/goa.go b/daemon/goa.go index 9d07d87d..ac556272 100644 --- a/daemon/goa.go +++ b/daemon/goa.go @@ -349,6 +349,15 @@ type goaLine struct { // Report: it holds what the session reported, which goes even once its task is // settled, said to be so: a report never goes without a word. Report bool `json:"report,omitempty"` + // Stall: it says its task isn't moving (goa_stall.go), told once while it waits. + Stall bool `json:"stall,omitempty"` +} + +// goaWaits says a line about task waits for GOA, of those match takes. +func (s *Server) goaWaits(task string, match func(goaLine) bool) bool { + s.goa.mu.Lock() + defer s.goa.mu.Unlock() + return slices.ContainsFunc(s.goa.tell, func(l goaLine) bool { return l.Task == task && match(l) }) } // goaSettledNote follows a report whose task was settled before GOA took it. diff --git a/daemon/goa_stall.go b/daemon/goa_stall.go index 2b215f60..f421704d 100644 --- a/daemon/goa_stall.go +++ b/daemon/goa_stall.go @@ -448,6 +448,14 @@ func (s *Server) stallCheck(w *stallWatch, t proto.GOATask, h stallHost, lim pro } return } + if s.stallReported(t, f) { + // GOA hears how its turn ended: that says more than its being still. + f.told, f.fired = time.Time{}, false + if t.Status == proto.GOATaskStalled { + s.stallMark(t.ID, proto.GOATaskStalled, proto.GOATaskWorking) + } + return + } if t.Status != proto.GOATaskStalled && !s.stallMark(t.ID, proto.GOATaskWorking, proto.GOATaskStalled) { return // settled meanwhile } @@ -457,6 +465,9 @@ func (s *Server) stallCheck(w *stallWatch, t proto.GOATask, h stallHost, lim pro if !f.told.IsZero() && now.Sub(f.told) < stallAgain*limit { return } + if s.goaWaits(t.ID, func(l goaLine) bool { return l.Stall }) { + return // the line before still waits for GOA: another says nothing new + } f.told = now line := fmt.Sprintf("%s, for %s", why, look.Still.Round(time.Second)) if look.Blank { @@ -472,7 +483,21 @@ func (s *Server) stallCheck(w *stallWatch, t proto.GOATask, h stallHost, lim pro if look.NoCPU { line += " (its host can't say what its processes use, so it may be a long build)" } - s.goaTellLine(goaLine{Text: stallLine(t, line, text), Task: t.ID, Session: t.Session}) + s.goaTellLine(goaLine{Text: stallLine(t, line, text), Task: t.ID, Session: t.Session, Stall: true}) +} + +// stallReportSlack is how long before its screen went still a report still tells of +// the turn that left it so: the report's own output shows after it. +const stallReportSlack = 2 * time.Minute + +// stallReported says task t's session reported how its turn ended since it last moved, +// or a report of it waits for GOA: a task that said it is done isn't stalled, though the +// board says working until GOA settles it. +func (s *Server) stallReported(t proto.GOATask, f *stallFollow) bool { + if t.Outcome != "" && time.UnixMilli(t.ReportAt).After(f.since.Add(-stallReportSlack)) { + return true + } + return s.goaWaits(t.ID, func(l goaLine) bool { return l.Turn != "" }) } // stallMark moves task id from one status to another, unless something else diff --git a/daemon/goa_stall_test.go b/daemon/goa_stall_test.go index 2c5ba0da..191ee008 100644 --- a/daemon/goa_stall_test.go +++ b/daemon/goa_stall_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/enowx/enowx/core/goa" + "github.com/enowx/enowx/core/session" "github.com/enowx/enowx/proto" ) @@ -193,3 +194,80 @@ func TestStallTellsAgainAfterThreeLimits(t *testing.T) { t.Fatal("no further line after three times the limit") } } + +// A task whose session reported how its turn ended isn't told of as stalled, nor one +// whose report waits for GOA; and a stall line waiting for GOA isn't followed by another +// for the same task, however long GOA takes (#51: hundreds of them filled goa-tell.json). +func TestStallLinesDontPileUp(t *testing.T) { + srv := startServer(t) + g, err := srv.sessions.Create(session.Spec{Command: []string{"sleep", "60"}, Dir: t.TempDir(), Meta: session.Meta{GOA: true}}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { srv.sessions.Kill(g.ID()) }) + lim := proto.GOAStall{Blank: 100, Idle: 100, Stuck: 100} + screen := "something\n" + h := stallHost{infos: map[string]proto.SessionInfo{"s1": {ID: "s1", Agent: "some-agent"}}, cpuKnown: true, + cpuOf: func(string) float64 { return 0 }, + screen: func(string) (string, error) { return screen, nil }} + stalls := func(task string) int { + srv.goa.mu.Lock() + defer srv.goa.mu.Unlock() + n := 0 + for _, l := range srv.goa.tell { + if l.Task == task && l.Stall { + n++ + } + } + return n + } + check := func(task proto.GOATask) (*stallFollow, *stallWatch) { + f := &stallFollow{screen: screen, since: time.Now().Add(-time.Hour)} + w := &stallWatch{follows: map[string]*stallFollow{task.ID: f}, sampled: map[string]bool{}} + srv.stallCheck(w, task, h, lim) + return f, w + } + + quiet, err := srv.goa.board.Add(proto.GOATask{Title: "quiet", Session: "s1", Status: proto.GOATaskWorking, Created: time.Now().Add(-2 * time.Hour)}) + if err != nil { + t.Fatal(err) + } + f, w := check(quiet) + if stalls(quiet.ID) != 1 { + t.Fatalf("a stalled task: %d lines", stalls(quiet.ID)) + } + quiet.Status = proto.GOATaskStalled + for range 5 { + f.told = time.Now().Add(-time.Hour) // long past three limits + srv.stallCheck(w, quiet, h, lim) + } + if n := stalls(quiet.ID); n != 1 { + t.Fatalf("%d stall lines for one task while the first still waits", n) + } + + reported, err := srv.goa.board.Add(proto.GOATask{Title: "done", Session: "s1", Status: proto.GOATaskWorking, Created: time.Now().Add(-2 * time.Hour), + Outcome: "done", ReportAt: time.Now().Add(-61 * time.Minute).UnixMilli()}) + if err != nil { + t.Fatal(err) + } + if check(reported); stalls(reported.ID) != 0 { + t.Fatal("a task that reported done was told of as stalled") + } + before, err := srv.goa.board.Add(proto.GOATask{Title: "again", Session: "s1", Status: proto.GOATaskWorking, Created: time.Now().Add(-5 * time.Hour), + Outcome: "done", ReportAt: time.Now().Add(-4 * time.Hour).UnixMilli()}) + if err != nil { + t.Fatal(err) + } + if check(before); stalls(before.ID) != 1 { + t.Fatal("a report from before its session last moved kept a stall from being told") + } + + waiting, err := srv.goa.board.Add(proto.GOATask{Title: "told", Session: "s1", Status: proto.GOATaskWorking, Created: time.Now().Add(-2 * time.Hour)}) + if err != nil { + t.Fatal(err) + } + srv.goaTellLine(goaLine{Text: "[enowX GOA] Task reports done", Task: waiting.ID, Session: "s1", Turn: goaLineReport}) + if check(waiting); stalls(waiting.ID) != 0 { + t.Fatal("a task whose report waits for GOA was told of as stalled") + } +} diff --git a/docs/goa.md b/docs/goa.md index 791209ff..c86bb17d 100644 --- a/docs/goa.md +++ b/docs/goa.md @@ -194,7 +194,7 @@ A session is also read for what stops it, every few seconds, whatever started it A task for a project goes to that project's own session, even busy, asking, hibernated or started with other permissions (`goa delegate --project`, `core/goa.PickSession`). A busy one has it queued, shown so on the board and kept over a daemon's restart, delivered at its turn's end, oldest first and one at a time; a queued task goes in only once the task before it has stopped running. Its turn's end is heard from its hooks, or for another host's session from the app. A session whose end went unheard (an interrupt, a daemon's restart, a turn over before the task came) gets it from the stall watch, once its screen has been still for 20 seconds with nothing running. A hibernated session is brought back first. If it can't go in, because its session ended or is gone, can't be brought back, or typing failed, the task fails, the board says why and GOA is told; a failed typing isn't tried again, as it may have gone in. A host that can't be reached keeps it queued, and GOA is told once. A new session starts only when the project has none, or on `--branch` or a named `--config`, what the user asked for, and delegate says so. Worktrees and shell tabs don't take a project's tasks. A queued task goes in when its session's turn ends, or, its turn's end unheard as after an interrupt, once its prompt is really free: hooks gone stale, its screen still for 20 seconds, nothing running, and it neither works nor asks by what it shows. -A task that isn't moving goes to `stalled` on the board until its screen moves, and GOA gets one line naming how, for how long and what the screen shows; a further one comes only after three times the limit, and again each time that passes while it stays stalled. The hook event `session.stalled` fires once per stall, with no hook needed for GOA to be told. +A task that isn't moving goes to `stalled` on the board until its screen moves, and GOA gets one line naming how, for how long and what the screen shows; a further one comes only after three times the limit, and again each time that passes while it stays stalled, never while the one before still waits for GOA. A task whose session reported how its turn ended since its screen last moved, or whose report or turn's end waits for GOA, isn't stalled: GOA hears that instead. The hook event `session.stalled` fires once per stall, with no hook needed for GOA to be told. | Case | What it is | Limit, by default | |---|---|---|