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 diff --git a/core/agent/prompts.go b/core/agent/prompts.go index 8e1072eb..1d0bbfd5 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,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 +} diff --git a/core/agent/prompts_wrap_test.go b/core/agent/prompts_wrap_test.go new file mode 100644 index 00000000..10d1cef0 --- /dev/null +++ b/core/agent/prompts_wrap_test.go @@ -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) + } + } +} 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/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_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/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/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/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. diff --git a/docs/goa.md b/docs/goa.md index 280e5801..03b62f00 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. @@ -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.