From 1f9e0866e1d0cc7c89a707a7d0b5568232732d51 Mon Sep 17 00:00:00 2001 From: Ahimsa Jenar Bramsaifstyo Kusuma Date: Mon, 9 Nov 2026 09:51:21 +0700 Subject: [PATCH 1/6] Wait for GOA's agent to start before a switch fails A switch watched GOA's restarted session for its agent ending, and took a wrapper running alone as the agent gone. On Windows the wrapper is PowerShell staying on (-NoExit), which takes over a second to start the agent, so every switch there failed within a second and GOA was put back, to Claude Code and to Codex alike. Now a switch fails when the session ends, or when the agent was seen under its wrapper and then is gone. An agent not seen yet is waited for until the settle is up, then fails if it still isn't running, so one that ends between two looks is still caught. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ahimsa Jenar Bramsaifstyo Kusuma --- daemon/goa_switch.go | 60 ++++++++++++++++++++++++----- daemon/goa_switch_settle_test.go | 65 ++++++++++++++++++++++++++++++++ daemon/thenshell_windows_test.go | 35 +++++++++++++++++ docs/goa.md | 2 +- 4 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 daemon/goa_switch_settle_test.go diff --git a/daemon/goa_switch.go b/daemon/goa_switch.go index ac2699c9..91c3f478 100644 --- a/daemon/goa_switch.go +++ b/daemon/goa_switch.go @@ -24,7 +24,8 @@ import ( // before it stops, and what fails after that puts it back as it was. // goaSwitchSettle is how long a restarted agent is watched: one that can't take the -// conversation or can't start at all says so and ends within this. +// conversation or can't start at all says so and ends within this. An agent not seen yet +// is waited for up to this, its wrapper (PowerShell, on Windows) being slow to start it. var goaSwitchSettle = 5 * time.Second // goaSwitchPlan is a change that can be made. @@ -405,18 +406,57 @@ func (s *Server) goaStartOnAgent(id string, plan goaSwitchPlan) (*session.Sessio // or start ends, leaving the shell after it. What it can't tell yet (a key an API refuses // at its first request) is its screen's to say. func (s *Server) goaStaysUp(id string) error { - deadline := time.Now().Add(goaSwitchSettle) - for time.Now().Before(deadline) { - time.Sleep(200 * time.Millisecond) - sess, err := s.sessions.Get(id) - if err != nil { - return err + sess, err := s.sessions.Get(id) + if err != nil { + return err + } + look := func() startLook { + if now, err := s.sessions.Get(id); err != nil || now.Info().Exited { + return agentExited } - if info := sess.Info(); info.Exited || s.agentGone(id) { - return fmt.Errorf("its agent ended right after starting: %s", lastScreenLine(sess.Terminal().Text())) + if s.agentGone(id) { + return agentNotRunning } + return agentRunning } - return nil + if agentTakes(goaSwitchSettle, 200*time.Millisecond, look) { + return nil + } + if now, err := s.sessions.Get(id); err == nil { + sess = now + } + return fmt.Errorf("its agent ended right after starting: %s", lastScreenLine(sess.Terminal().Text())) +} + +// startLook is what one look at a starting session's processes saw. +type startLook int + +const ( + agentRunning startLook = iota // the agent runs under its wrapper + agentNotRunning // the wrapper runs alone: the agent not started yet, or ended + agentExited // the session itself has ended +) + +// agentTakes watches a session starting its agent for settle, looking every so often, and says +// it took. A wrapper alone is no failure until its agent was seen: PowerShell on Windows takes +// over a second to start the program it wraps. An agent never seen fails only when settle is +// up, so one that ends between two looks is still caught. +func agentTakes(settle, every time.Duration, look func() startLook) bool { + seen := false + for deadline := time.Now().Add(settle); time.Now().Before(deadline); { + time.Sleep(every) + switch look() { + case agentExited: + return false + case agentRunning: + seen = true + case agentNotRunning: + if seen { + return false + } + } + } + return seen || look() == agentRunning } // lastScreenLine is what a screen ends on, cut, and what reads as a key masked. diff --git a/daemon/goa_switch_settle_test.go b/daemon/goa_switch_settle_test.go new file mode 100644 index 00000000..ca517a56 --- /dev/null +++ b/daemon/goa_switch_settle_test.go @@ -0,0 +1,65 @@ +package daemon + +import ( + "testing" + "time" +) + +// looks plays back what a starting session shows, one look at a time, the last one after. +func looks(seq ...startLook) (func() startLook, *int) { + n := 0 + return func() startLook { + l := seq[min(n, len(seq)-1)] + n++ + return l + }, &n +} + +// PowerShell on Windows runs alone for over a second before it starts the agent it wraps: +// that isn't the agent ending. +func TestGOASwitchWaitsForAWrapperToStartItsAgent(t *testing.T) { + look, _ := looks(agentNotRunning, agentNotRunning, agentNotRunning, agentNotRunning, agentNotRunning, agentRunning) + if !agentTakes(300*time.Millisecond, 10*time.Millisecond, look) { + t.Fatal("an agent its wrapper hadn't started yet was taken as ended") + } +} + +// An agent seen running that then ends didn't take, and that is known at once. +func TestGOASwitchAgentSeenThenGoneFails(t *testing.T) { + look, n := looks(agentNotRunning, agentRunning, agentRunning, agentNotRunning) + start := time.Now() + if agentTakes(5*time.Second, 10*time.Millisecond, look) { + t.Fatal("an agent that ended was taken as started") + } + if took := time.Since(start); took > time.Second || *n != 4 { + t.Fatalf("known after %d looks, %s", *n, took) + } +} + +// An agent never seen, one ending between two looks say, fails once the wait is up. +func TestGOASwitchAgentNeverSeenFailsWhenSettleIsUp(t *testing.T) { + look, _ := looks(agentNotRunning) + start := time.Now() + if agentTakes(200*time.Millisecond, 10*time.Millisecond, look) { + t.Fatal("an agent never seen was taken as started") + } + if took := time.Since(start); took < 200*time.Millisecond { + t.Fatalf("gave up after %s, before the wait was up", took) + } +} + +// A session that has itself ended fails at once. +func TestGOASwitchSessionExitedFails(t *testing.T) { + look, n := looks(agentNotRunning, agentExited) + if agentTakes(5*time.Second, 10*time.Millisecond, look) || *n != 2 { + t.Fatalf("an ended session: %d looks", *n) + } +} + +// An agent that starts and stays takes. +func TestGOASwitchAgentThatStaysTakes(t *testing.T) { + look, _ := looks(agentRunning) + if !agentTakes(100*time.Millisecond, 10*time.Millisecond, look) { + t.Fatal("an agent that stayed up was taken as ended") + } +} diff --git a/daemon/thenshell_windows_test.go b/daemon/thenshell_windows_test.go index a2cb3055..20cf2d77 100644 --- a/daemon/thenshell_windows_test.go +++ b/daemon/thenshell_windows_test.go @@ -9,6 +9,7 @@ import ( "slices" "strings" "testing" + "time" "github.com/enowx/enowx/core/agent" ) @@ -93,3 +94,37 @@ func TestAgentThroughCmdShim(t *testing.T) { t.Fatalf("the guide's file: %d bytes of %d, %v", len(data), len(guide), err) } } + +// GOA's switch on Windows: PowerShell staying on (-NoExit) runs alone before it starts the +// agent, which reads as gone; the switch waits for the agent, and fails once it has ended. +func TestGOASwitchWaitsForPowerShellToStartTheAgent(t *testing.T) { + const root, agentPID = 100, 101 + args := func(pid int) []string { + if pid == root { + return []string{"pwsh.exe", "-NoLogo", "-NoExit", "-Command", psLegacy + "& 'claude' '--strict-mcp-config'"} + } + return []string{"claude.exe"} + } + // What each look sees: PowerShell alone while it starts, then the agent under it. + tables := []map[int][]int{{}, {}, {}, {}, {root: {agentPID}}} + n := 0 + look := func() startLook { + kids := tables[min(n, len(tables)-1)] + n++ + if programGone(root, kids, args) { + return agentNotRunning + } + return agentRunning + } + if !programGone(root, map[int][]int{}, args) { + t.Fatal("PowerShell alone doesn't read as its program gone, so this tests nothing") + } + if !agentTakes(200*time.Millisecond, 10*time.Millisecond, look) { + t.Fatal("an agent PowerShell hadn't started yet was taken as ended") + } + // The agent seen, then PowerShell alone again: it ended. + tables, n = []map[int][]int{{}, {root: {agentPID}}, {}}, 0 + if agentTakes(5*time.Second, 10*time.Millisecond, look) || n != 3 { + t.Fatalf("an agent that ended after starting: taken, or known only after %d looks", n) + } +} diff --git a/docs/goa.md b/docs/goa.md index 280e5801..9f081931 100644 --- a/docs/goa.md +++ b/docs/goa.md @@ -62,7 +62,7 @@ Checked with Codex itself, started as GOA: enowX's tools reached it over MCP as - **Another config, the same agent:** GOA restarts on the new config and goes on with the same conversation (`--resume` with its ID). Before GOA stops, the daemon checks the config can reach that conversation and start: an account's home has it (conversations are shared by links; one with a `projects` folder of its own, or a variable moving its home, doesn't), the account is signed in on this host, the vault lends a model API's key. If not, the user is told, and GOA goes on untouched. A GOA with no conversation yet just restarts. - **Another agent:** a conversation doesn't carry over to it, so the app says so and asks first (`--new-conversation`), then GOA restarts on a new one, its config the one picked (left out, the agent's preferred). Going back to the first agent later is a new conversation too: its session doesn't take conversations from before the change (`Meta.ConvoSince`). - **At work** (working, or asking something), the app asks first, as a restart cuts that off (`--force`). This is the daemon's word, not the app's, so a line reaching GOA as the user presses counts. -- **If it doesn't take:** an agent ending within 5 seconds of starting (`goaSwitchSettle`) puts GOA back on what it was, on its conversation, and the error says what the screen last said. What only shows later, as a key an API refuses at its first request, its screen says. +- **If it doesn't take:** an agent ending within 5 seconds of starting (`goaSwitchSettle`) puts GOA back on what it was, on its conversation, and the error says what the screen last said. Its wrapper running alone is no failure until the agent was seen under it (PowerShell on Windows takes over a second to start it): an agent never seen fails when the 5 seconds are up, one seen and gone at once, and a session that ended at once. What only shows later, as a key an API refuses at its first request, its screen says. **Restarting it as it is.** "Restart as it is" in Settings (shown while nothing is picked), `enowx goa restart [--force] [--new-conversation]` and MCP `goa_restart` (`goa.restart`) restart GOA on the agent and config it runs on, going on with its conversation (`--resume` with its ID), by the same path as a config change: the same checks, the same settle, what stays below. At work it asks first (`--force`). It also lets GOA start again after it ended right after starting several times (the quick-exit guard holds a continue or a change back for 30 seconds; a restart is the user asking). If the conversation can't be gone on with (no ID recorded, its file gone, the config can't reach it), the daemon says why (`GOASwitchResult.Unresumable`) and leaves GOA as it is; it starts on a new conversation only with `--new-conversation`, which the app offers. If the agent refuses the resume or ends within the settle, GOA is put back and the error says to restart it on a new conversation. Never a new conversation unasked. From 455a49c510c962b175b30f0aff967eaf24759ca3 Mon Sep 17 00:00:00 2001 From: Ahimsa Jenar Bramsaifstyo Kusuma Date: Mon, 9 Nov 2026 12:20:05 +0700 Subject: [PATCH 2/6] Keep shell scripts LF in a Windows checkout hostconn embeds prepare.sh and linger.sh and runs them on servers with bash. Built on Windows from a checkout with core.autocrlf=true (Git for Windows' default), they get CRLF and adding a server fails with "syntax error near unexpected token `$'do\r''". Release builds run on Ubuntu and are not affected. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ahimsa Jenar Bramsaifstyo Kusuma --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..a164b3ab --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Shell scripts run on Linux and macOS, some embedded in the app (hostconn): a Windows +# checkout must not give them CRLF line endings. +*.sh text eol=lf From 92132fd874c0f602d2035bb41de78e37ad7579d9 Mon Sep 17 00:00:00 2001 From: Ahimsa Jenar Bramsaifstyo Kusuma Date: Mon, 9 Nov 2026 12:26:56 +0700 Subject: [PATCH 3/6] Follow the conversation an agent's process says, without hooks The chat finds a Claude Code session's conversation by the id its SessionStart hook names, else by a file begun after the session started. Claude Code runs no hooks on Windows, so a session whose agent went on with a conversation begun before it (claude typed by hand in its shell, or /resume) showed nothing: that file reads as another session's. That is what GOD of Agents' chat showed after its switch to Claude Code. Claude Code records, for each of its processes, the conversation it is on (~/.claude/sessions/.json). Where an agent gets no hooks, the session's processes are looked at from its root down and the first such record names its conversation, which is kept as the session's. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ahimsa Jenar Bramsaifstyo Kusuma --- core/transcript/claude_running.go | 31 ++++++++++++++++ core/transcript/claude_running_test.go | 44 +++++++++++++++++++++++ daemon/transcript.go | 7 +++- daemon/transcript_running.go | 50 ++++++++++++++++++++++++++ daemon/transcript_running_test.go | 42 ++++++++++++++++++++++ docs/chat-view.md | 2 +- 6 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 core/transcript/claude_running.go create mode 100644 core/transcript/claude_running_test.go create mode 100644 daemon/transcript_running.go create mode 100644 daemon/transcript_running_test.go diff --git a/core/transcript/claude_running.go b/core/transcript/claude_running.go new file mode 100644 index 00000000..679127c7 --- /dev/null +++ b/core/transcript/claude_running.go @@ -0,0 +1,31 @@ +package transcript + +import ( + "encoding/json" + "os" + "path/filepath" + "strconv" +) + +// ClaudeRunning is the conversation one of pids is on, as Claude Code records it for each of +// its processes in claudeHome/sessions/.json, "" when none of them says. The first of pids +// that says wins: callers list a session's processes from its root down, so the agent comes +// before what it started. A conversation moved to by /resume shows there too, which is what +// says it when no hook does (Claude Code's hooks don't run on Windows). +func ClaudeRunning(claudeHome string, pids []int) string { + for _, pid := range pids { + b, err := os.ReadFile(filepath.Join(claudeHome, "sessions", strconv.Itoa(pid)+".json")) + if err != nil { + continue + } + var rec struct { + PID int `json:"pid"` + SessionID string `json:"sessionId"` + } + if json.Unmarshal(b, &rec) != nil || rec.PID != pid || rec.SessionID == "" { + continue // half written, or not this process's + } + return rec.SessionID + } + return "" +} diff --git a/core/transcript/claude_running_test.go b/core/transcript/claude_running_test.go new file mode 100644 index 00000000..e73d8e02 --- /dev/null +++ b/core/transcript/claude_running_test.go @@ -0,0 +1,44 @@ +package transcript + +import ( + "os" + "path/filepath" + "testing" +) + +// Claude Code says, for each of its processes, the conversation it is on: the first of a +// session's processes that says it names the session's. +func TestClaudeRunningReadsWhatTheProcessSays(t *testing.T) { + home := t.TempDir() + dir := filepath.Join(home, "sessions") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + write := func(name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + write("20.json", `{"pid":20,"sessionId":"the-agents"}`) + write("30.json", `{"pid":30,"sessionId":"a-child-claude"}`) + write("40.json", `{"pid":41,"sessionId":"another-process"}`) + write("50.json", `{"pid":50,"sess`) + + cases := []struct { + pids []int + want string + }{ + {[]int{10, 20, 30}, "the-agents"}, // the wrapper says nothing; the agent comes before its children + {[]int{30, 20}, "a-child-claude"}, + {[]int{40}, ""}, // a file naming another process + {[]int{50}, ""}, // half written + {[]int{60}, ""}, + {nil, ""}, + } + for _, c := range cases { + if got := ClaudeRunning(home, c.pids); got != c.want { + t.Errorf("ClaudeRunning(%v) = %q, want %q", c.pids, got, c.want) + } + } +} diff --git a/daemon/transcript.go b/daemon/transcript.go index 9776113d..a3d98049 100644 --- a/daemon/transcript.go +++ b/daemon/transcript.go @@ -292,7 +292,8 @@ func (s *Server) findTranscript(id string, info proto.SessionInfo, home string, // being the one it follows. Agents keep conversations by folder, and an older session's in // the same folder keeps changing, so a file's being new says nothing of whose it is: // - in the session's own folder (Preset.SessionDir), the newest is its own; -// - a conversation known by id is that file alone, written or not; +// - a conversation known by id is that file alone, written or not: one its agent's hook +// named, else the one its agent's process says it is on (no hooks), else the one kept; // - otherwise, a conversation that began after the session did and no other session is on. // // What it finds is kept as the session's, so that resuming and a restart go on with it. @@ -317,6 +318,10 @@ func (s *Server) ownTranscript(id string, info proto.SessionInfo, home, current return stored[0].Path } want := s.transcripts.toldOf(id) + if want == "" { + // What its agent says it runs: begun before the session or not, it is its own. + want = runningConversation(id, info.Agent, home) + } if want == "" { want = meta.Conversation } diff --git a/daemon/transcript_running.go b/daemon/transcript_running.go new file mode 100644 index 00000000..bd950849 --- /dev/null +++ b/daemon/transcript_running.go @@ -0,0 +1,50 @@ +package daemon + +import ( + "os" + + "github.com/enowx/enowx/core/agent" + "github.com/enowx/enowx/core/procstat" + "github.com/enowx/enowx/core/transcript" +) + +// runningConversation is the conversation session id's agent says it is on, for an agent whose +// hooks can't say it (Claude Code on Windows). Without it a session whose agent went on with a +// conversation begun before the session, typed by hand or by /resume, showed nothing in its chat: +// that file reads as another session's. A variable, for tests to stand in for real processes. +var runningConversation = func(id, agentID, home string) string { + if agent.Base(agentID) != "claude" || len(agent.HookArgs(agentID)) > 0 { + return "" + } + return transcript.ClaudeRunning(transcript.ClaudeHome(home), sessionProcesses(id)) +} + +// sessionProcesses are the processes session id runs, from its root down, each level in turn. +func sessionProcesses(id string) []int { + list, err := procstat.Snapshot() + if err != nil { + return nil + } + kids := make(map[int][]int, len(list)) + for _, p := range list { + kids[p.PPID] = append(kids[p.PPID], p.PID) + } + var out, queue []int + for _, root := range kids[os.Getpid()] { + if sessionOf(root) == id { + queue = append(queue, root) + } + } + seen := map[int]bool{} + for len(queue) > 0 { + pid := queue[0] + queue = queue[1:] + if seen[pid] { + continue + } + seen[pid] = true + out = append(out, pid) + queue = append(queue, kids[pid]...) + } + return out +} diff --git a/daemon/transcript_running_test.go b/daemon/transcript_running_test.go new file mode 100644 index 00000000..11bd3044 --- /dev/null +++ b/daemon/transcript_running_test.go @@ -0,0 +1,42 @@ +package daemon + +import ( + "testing" + "time" + + "github.com/enowx/enowx/core/session" +) + +// An agent without hooks (Claude Code on Windows) that goes on with a conversation begun before +// its session, resumed by hand or by /resume, shows it in the chat: its process says it is on +// it. Without that word, such a conversation still reads as another session's. +func TestChatFollowsTheConversationItsAgentProcessRuns(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("CLAUDE_CONFIG_DIR", "") + running := "" + old := runningConversation + runningConversation = func(id, agentID, home string) string { return running } + t.Cleanup(func() { runningConversation = old }) + + srv := startServer(t) + dir := t.TempDir() + sess, err := srv.sessions.Create(session.Spec{Command: []string{"sleep", "30"}, Dir: dir, Meta: session.Meta{Agent: "claude"}}) + if err != nil { + t.Fatal(err) + } + earlier := claudeConversation(t, home, dir, "74fdbd63", time.Now().Add(-12*time.Hour), "from last night") + + if got := srv.transcriptPath(sess.ID()); got != "" { + t.Fatalf("with nothing saying it is its own, it follows %q", got) + } + running = "74fdbd63" + srv.transcripts.forget(sess.ID()) + if got := srv.transcriptPath(sess.ID()); got != earlier { + t.Fatalf("its agent runs 74fdbd63, it follows %q", got) + } + if convo := sess.Meta().Conversation; convo != "74fdbd63" { + t.Fatalf("kept %q: a restart wouldn't go on with it", convo) + } +} diff --git a/docs/chat-view.md b/docs/chat-view.md index f8eeb9aa..27c41d24 100644 --- a/docs/chat-view.md +++ b/docs/chat-view.md @@ -19,7 +19,7 @@ The daemon reads the transcript the agent records on its own host (`session.tran A session shows its own conversation and nothing else, however many sessions share its folder. An older session's file keeps changing, so a file being new says nothing of whose it is. - **In its own folder:** an agent given one (`Preset.SessionDir`: Claude Platform, OMP) keeps its conversations there, and the newest is the session's. With none there yet, it shows nothing. -- **Known by id:** Claude Code names its conversation as it starts (`SessionStart`'s `session_id`), even before writing it, so the session shows nothing until that file appears, and a new id (`/clear`, `/resume`) moves it there. A session resumed, imported or found earlier has its id too (`Meta.Conversation`), which outlasts a restart. +- **Known by id:** Claude Code names its conversation as it starts (`SessionStart`'s `session_id`), even before writing it, so the session shows nothing until that file appears, and a new id (`/clear`, `/resume`) moves it there. A session resumed, imported or found earlier has its id too (`Meta.Conversation`), which outlasts a restart. Where Claude Code runs no hooks (Windows), what its process records says the id: Claude Code keeps `~/.claude/sessions/.json` with its `sessionId` for each of its processes, and the session's processes are looked at from its root down (`transcript.ClaudeRunning`). A conversation it goes on with that began before the session (`claude` typed by hand, `/resume`) is then still its own, and kept as `Meta.Conversation`. - **Otherwise (Codex, Claude Agent Teams, no hooks):** a conversation whose first record is after the session's start (`transcript.Started`; a file with no time, by when it changed) and that no other session is on. Once found, it stays: `/clear` moves it only where the agent's hook says so (`source` `clear`), so on Windows, which has no hooks, the chat stays on the old conversation after `/clear`. Resume, fork, transfer, handoffs and the model shown all go by this choice. From 96547ea6c9186ef236eff4956558256f43668e9b Mon Sep 17 00:00:00 2001 From: Ahimsa Jenar Bramsaifstyo Kusuma Date: Mon, 9 Nov 2026 13:15:46 +0700 Subject: [PATCH 4/6] Read a composer joined to its rule on Windows On Windows, ConPTY writes a line as wide as the screen with no line break after it, so the terminal keeps the next row as that line's soft-wrapped rest and its text joins them. Claude Code's composer then reads as a rule followed by its prompt mark, which no bare mark matches, so PromptEmpty never saw an empty composer. With no hooks on Windows, lines for GOD of Agents are typed in only into an empty composer, so a worker's report waited until GOA was asked to look, and was dropped once it settled the task. tailLines now cuts a rule drawn across the screen from what was written right after it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ahimsa Jenar Bramsaifstyo Kusuma --- core/agent/prompts.go | 34 ++++++++++++++++++-- core/agent/prompts_wrap_test.go | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 core/agent/prompts_wrap_test.go diff --git a/core/agent/prompts.go b/core/agent/prompts.go index 8e1072eb..46a54256 100644 --- a/core/agent/prompts.go +++ b/core/agent/prompts.go @@ -4,6 +4,7 @@ import ( "regexp" "slices" "strings" + "unicode/utf8" ) // What a screen shows an agent is stopped on, for any agent and wherever it @@ -269,14 +270,41 @@ func deadlineIn(lines []string) string { return "" } -// tailLines are screen's last n lines with text, trimmed, oldest first. +// tailLines are screen's last n lines with text, trimmed, oldest first, a rule cut from +// a row wrapped onto it. func tailLines(screen string, n int) []string { var out []string all := strings.Split(screen, "\n") for i := len(all) - 1; i >= 0 && len(out) < n; i-- { - if t := strings.TrimSpace(all[i]); t != "" { - out = append([]string{t}, out...) + parts := splitWrappedRule(strings.TrimSpace(all[i])) + for j := len(parts) - 1; j >= 0 && len(out) < n; j-- { + if parts[j] != "" { + out = append([]string{parts[j]}, out...) + } } } return out } + +// ruleChars draw a rule across the screen. +const ruleChars = "─━═" + +// ruleRunMin is how long a run of ruleChars at a line's start must be to be a rule drawn +// across the screen, not a dash or two of what a line says. +const ruleRunMin = 8 + +// splitWrappedRule cuts a rule drawn across the screen from what was written right after +// it. ConPTY (Windows) writes a line as wide as the screen with no line break after it, so +// the terminal keeps the next row as its soft-wrapped rest, and its text joins them: +// Claude Code's composer reads as "────❯", which no prompt mark alone matches. +func splitWrappedRule(line string) []string { + rest := strings.TrimLeft(line, ruleChars) + rule := line[:len(line)-len(rest)] + if utf8.RuneCountInString(rule) < ruleRunMin { + return []string{line} + } + if rest = strings.TrimSpace(rest); rest == "" { + return []string{line} + } + return []string{rule, rest} +} diff --git a/core/agent/prompts_wrap_test.go b/core/agent/prompts_wrap_test.go new file mode 100644 index 00000000..2b4100af --- /dev/null +++ b/core/agent/prompts_wrap_test.go @@ -0,0 +1,57 @@ +package agent + +import ( + "strings" + "testing" +) + +// claudeOnConPTY is the end of Claude Code's screen in a session's terminal on Windows, as +// the terminal's text reads it. ConPTY writes a rule as wide as the screen with no line +// break after it, so the rule and the next row read as one soft-wrapped line: the composer +// comes right after the rule above it, and the status line after the rule below. +func claudeOnConPTY(draft string) string { + rule := strings.Repeat("─", 98) + return "● Homeserver sudah tersambung.\n\n" + + "✻ Worked for 23s · done 1:10 PM\n\n" + + rule + "❯\u00a0" + draft + "\n" + + strings.Repeat("─", 77) + " \u00a0~\\.enowx-app\\goa\u00a0\ue0b0\u00a0⎇\u00a0no\u00a0git\n" + + "Opus\u00a05.5\u00a0\ue0b0\u00a0Ctx\u00a0Used:\u00a013.0%\u00a0\ue0b0\u00a05h:\u00a00.0%\n" + + " ⏵⏵ bypass permissions on (shift+tab to cycle)\n" +} + +// A composer read joined to the rule above it is still a composer: empty when nothing +// follows its mark, and holding a draft when something does. Lines for GOD of Agents are +// typed in only into an empty one, so with the join read as a draft they never went. +func TestPromptEmptyUnderARuleWrappedOntoIt(t *testing.T) { + if !PromptEmpty(claudeOnConPTY("")) { + t.Error("an empty composer joined to its rule reads as holding a draft") + } + if PromptEmpty(claudeOnConPTY("sekalian kita update")) { + t.Error("a draft joined to its rule reads as an empty composer") + } + if !TurnOverShown(claudeOnConPTY("")) { + t.Error("the turn's end isn't seen") + } +} + +// A rule's run is cut from what follows it, and nothing else is: a line that only starts +// with a dash or two, as a turn's time drawn between rules, stays whole. +func TestSplitWrappedRule(t *testing.T) { + rule := strings.Repeat("─", 40) + cases := []struct { + line string + want []string + }{ + {rule + "❯", []string{rule, "❯"}}, + {rule + " ~\\goa", []string{rule, "~\\goa"}}, + {rule, []string{rule}}, + {"─ Worked for 1m 03s ─", []string{"─ Worked for 1m 03s ─"}}, + {"──── note", []string{"──── note"}}, + {"❯ hello", []string{"❯ hello"}}, + } + for _, c := range cases { + if got := splitWrappedRule(c.line); strings.Join(got, "|") != strings.Join(c.want, "|") { + t.Errorf("splitWrappedRule(%q) = %q, want %q", c.line, got, c.want) + } + } +} From cc0d1c96fb65d1e9c219bc5eec2848efad202817 Mon Sep 17 00:00:00 2001 From: Ahimsa Jenar Bramsaifstyo Kusuma Date: Tue, 10 Nov 2026 07:24:36 +0700 Subject: [PATCH 5/6] Read a composer under a named rule on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once Claude Code names its conversation, the rule above its composer holds the name ("──── name ──"). On Windows that rule joins the composer row as before, and the title between its runs kept the line from reading as an empty composer: every line for GOD of Agents waited from then on, workers' reports included, until the task was settled by hand and they went as moot. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ahimsa Jenar Bramsaifstyo Kusuma --- core/agent/prompts.go | 21 +++++++++++++++++++++ core/agent/prompts_wrap_test.go | 23 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/core/agent/prompts.go b/core/agent/prompts.go index 46a54256..1d0bbfd5 100644 --- a/core/agent/prompts.go +++ b/core/agent/prompts.go @@ -303,8 +303,29 @@ func splitWrappedRule(line string) []string { if utf8.RuneCountInString(rule) < ruleRunMin { return []string{line} } + // A rule may hold a title, set off by spaces ("──── name ──"), as Claude Code's above + // its composer does once the conversation is named: the rule ends with its last run. + if title, after, ok := ruleTitle(rest); ok { + rule += title + rest = after + } if rest = strings.TrimSpace(rest); rest == "" { return []string{line} } return []string{rule, rest} } + +// ruleTitle splits what follows a rule's first run into the title in it and its closing +// run, and what comes after the rule. A draft's dashes aren't a title: what follows a +// rule's run with no space, as the composer's mark does, never is. +func ruleTitle(rest string) (title, after string, ok bool) { + if !strings.HasPrefix(rest, " ") { + return "", "", false + } + i := strings.IndexAny(rest, ruleChars) + if i < 0 || !strings.HasSuffix(rest[:i], " ") { + return "", "", false + } + after = strings.TrimLeft(rest[i:], ruleChars) + return rest[:len(rest)-len(after)], after, true +} diff --git a/core/agent/prompts_wrap_test.go b/core/agent/prompts_wrap_test.go index 2b4100af..10d1cef0 100644 --- a/core/agent/prompts_wrap_test.go +++ b/core/agent/prompts_wrap_test.go @@ -34,6 +34,25 @@ func TestPromptEmptyUnderARuleWrappedOntoIt(t *testing.T) { } } +// Once Claude Code names its conversation, the rule above its composer holds the name, and +// on Windows the composer joins that rule as before. Read as a draft, it kept every line for +// GOD of Agents waiting from the moment its conversation got a name. +func TestPromptEmptyUnderANamedRuleWrappedOntoIt(t *testing.T) { + screen := func(draft string) string { + return "✻ Worked for 32s · done 7:20 AM\n\n" + + strings.Repeat("─", 90) + " Akses sesi di aws-vps ─❯ " + draft + "\n" + + strings.Repeat("─", 77) + "  ~\\.enowx-app\\goa  ⎇ no git\n" + + "Opus 5.5  Ctx Used: 59.0%\n" + + " ⏵⏵ bypass permissions on (shift+tab to cycle)\n" + } + if !PromptEmpty(screen("")) { + t.Error("an empty composer under a named rule reads as holding a draft") + } + if PromptEmpty(screen("lanjutkan")) { + t.Error("a draft under a named rule reads as an empty composer") + } +} + // A rule's run is cut from what follows it, and nothing else is: a line that only starts // with a dash or two, as a turn's time drawn between rules, stays whole. func TestSplitWrappedRule(t *testing.T) { @@ -48,6 +67,10 @@ func TestSplitWrappedRule(t *testing.T) { {"─ Worked for 1m 03s ─", []string{"─ Worked for 1m 03s ─"}}, {"──── note", []string{"──── note"}}, {"❯ hello", []string{"❯ hello"}}, + {rule + " Akses sesi ──❯ ", []string{rule + " Akses sesi ──", "❯"}}, + {rule + " Akses sesi ──❯ draft", []string{rule + " Akses sesi ──", "❯ draft"}}, + {rule + " Akses sesi ──", []string{rule + " Akses sesi ──"}}, + {rule + "❯ a ── b", []string{rule, "❯ a ── b"}}, } for _, c := range cases { if got := splitWrappedRule(c.line); strings.Join(got, "|") != strings.Join(c.want, "|") { From 8e6670132b70c3b8d55da83ae467919fb92e1122 Mon Sep 17 00:00:00 2001 From: Ahimsa Jenar Bramsaifstyo Kusuma Date: Tue, 10 Nov 2026 07:37:51 +0700 Subject: [PATCH 6/6] Tell GOA a report even once its task is settled The docs said a report waiting for GOA isn't dropped when its task is settled, but every line of a settled task was: a report GOA hadn't seen went without a word once the task was settled by hand. A report now goes all the same, saying the task is settled already; only lines saying to look (a stall, a turn's end, a question) are dropped as moot. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ahimsa Jenar Bramsaifstyo Kusuma --- daemon/goa.go | 9 ++++++ daemon/goa_settled_report_test.go | 48 +++++++++++++++++++++++++++++++ daemon/goa_turnline.go | 3 +- docs/goa.md | 2 +- 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 daemon/goa_settled_report_test.go diff --git a/daemon/goa.go b/daemon/goa.go index 72541dd7..9d07d87d 100644 --- a/daemon/goa.go +++ b/daemon/goa.go @@ -346,8 +346,14 @@ type goaLine struct { Closed bool `json:"closed,omitempty"` // HoldUntil keeps a report back for its turn's end to join it. HoldUntil time.Time `json:"holdUntil,omitzero"` + // 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"` } +// goaSettledNote follows a report whose task was settled before GOA took it. +const goaSettledNote = " (Its task is settled already: nothing to settle, only to read.)" + // goaTellMax bounds what waits for a GOA that never comes back, the oldest going. const goaTellMax = 200 @@ -445,6 +451,9 @@ func (s *Server) goaLinesToSay() []goaLine { s.goa.tell = append(s.goa.tell, l) case l.Task == "" || !s.goa.board.IsSettled(l.Task): out = append(out, l) + case l.Report: + l.Text += goaSettledNote + out = append(out, l) } } s.goa.mu.Unlock() diff --git a/daemon/goa_settled_report_test.go b/daemon/goa_settled_report_test.go new file mode 100644 index 00000000..7b1b9140 --- /dev/null +++ b/daemon/goa_settled_report_test.go @@ -0,0 +1,48 @@ +package daemon + +import ( + "strings" + "testing" + + "github.com/enowx/enowx/proto" +) + +// A report still waiting for GOA when its task is settled goes all the same, said to be +// settled, as GOA may have settled it on a guess; a line only saying to look goes no more. +func TestGOAReportOfASettledTaskStillGoes(t *testing.T) { + srv := startServer(t) + task, err := srv.goa.board.Add(proto.GOATask{Title: "settled", Session: "a-session", Status: proto.GOATaskWorking}) + if err != nil { + t.Fatal(err) + } + if _, err := srv.goa.board.Update(task.ID, func(x *proto.GOATask) { x.Status = proto.GOATaskDone }); err != nil { + t.Fatal(err) + } + srv.goa.mu.Lock() + srv.goa.tell = append(srv.goa.tell, + goaLine{Text: "it reports done", Task: task.ID, Session: "a-session", Turn: goaLineReport, Report: true, Closed: true}, + goaLine{Text: "it isn't moving", Task: task.ID, Session: "a-session"}, + goaLine{Text: "no task"}) + srv.goa.mu.Unlock() + got := srv.goaLinesToSay() + if len(got) != 2 || got[0].Text != "it reports done"+goaSettledNote || got[1].Text != "no task" { + t.Fatalf("to say: %+v", got) + } + if !strings.Contains(got[0].Text, "settled already") { + t.Fatalf("the report doesn't say its task is settled: %q", got[0].Text) + } +} + +// A report joined to its turn's end, or a turn's end to it, stays a report. +func TestGOAReportJoinedStaysAReport(t *testing.T) { + srv := startServer(t) + srv.goa.mu.Lock() + srv.goa.tell = append(srv.goa.tell, goaLine{Text: "[enowX GOA] Task t1 finished a turn: said", Task: "t1", Session: "s1", Turn: goaLineTurn}) + srv.goa.mu.Unlock() + srv.goaTellReport(proto.SessionInfo{ID: "s1"}, goaLine{Text: "[enowX GOA] Task t1 reports done: ok", Task: "t1", Session: "s1"}) + srv.goa.mu.Lock() + defer srv.goa.mu.Unlock() + if len(srv.goa.tell) != 1 || !srv.goa.tell[0].Report { + t.Fatalf("waiting: %+v", srv.goa.tell) + } +} diff --git a/daemon/goa_turnline.go b/daemon/goa_turnline.go index d779c2f6..2f303603 100644 --- a/daemon/goa_turnline.go +++ b/daemon/goa_turnline.go @@ -34,12 +34,13 @@ func releaseHeld(lines []goaLine, session string) { // goaTellReport tells of a report of session info, as line: joined to its turn's end if // that waits for GOA, else held for it if the session is at work. func (s *Server) goaTellReport(info proto.SessionInfo, line goaLine) { - line.Turn = goaLineReport + line.Turn, line.Report = goaLineReport, true s.goa.mu.Lock() i := s.goaOpenLine(line, goaLineTurn) if i >= 0 { said := s.goa.tell[i].Text s.goa.tell[i].Text, s.goa.tell[i].Turn, s.goa.tell[i].Closed = line.Text+lastMessageOf(said), goaLineTurn, true + s.goa.tell[i].Report = true } s.goa.mu.Unlock() if i >= 0 { diff --git a/docs/goa.md b/docs/goa.md index 9f081931..03b62f00 100644 --- a/docs/goa.md +++ b/docs/goa.md @@ -114,7 +114,7 @@ What the session doesn't say, enowX says for it: its process exiting or crashing A task for a session asleep brings it back on its conversation first, hibernated or ended, once, whoever gets there first; one queued goes in when it is back. What can't come back (a plain shell) fails at once, with that said, on the board and to GOA, as does a wake that fails. -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 typing it fails, the newest 200 kept. Of a task since settled, a line only saying to look (a stall, a turn's end, what it asks) is dropped, as moot; a report isn't: it goes all the same, saying the task is settled already. 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.