Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
55 changes: 52 additions & 3 deletions core/agent/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -269,14 +270,62 @@ 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}
}
// 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
}
80 changes: 80 additions & 0 deletions core/agent/prompts_wrap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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")
}
}

// 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) {
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"}},
{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, "|") {
t.Errorf("splitWrappedRule(%q) = %q, want %q", c.line, got, c.want)
}
}
}
31 changes: 31 additions & 0 deletions core/transcript/claude_running.go
Original file line number Diff line number Diff line change
@@ -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/<pid>.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 ""
}
44 changes: 44 additions & 0 deletions core/transcript/claude_running_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
9 changes: 9 additions & 0 deletions daemon/goa.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
48 changes: 48 additions & 0 deletions daemon/goa_settled_report_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
60 changes: 50 additions & 10 deletions daemon/goa_switch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading