diff --git a/CHANGELOG.md b/CHANGELOG.md index d253d13e..0c87d3f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `lich sessions --json`, `lich whoami --json` and the MCP tool `list_sessions` carry the line a session's card shows under its label: `tool` and `detail` while it is busy, the tool it is running and what it acts on, and `reason` while it waits on you. A script or a desktop widget can show what each agent is doing without opening the window. +### Fixed + +- Notes from the session browser, and the Pulls screen's Fix CI errors and Resolve conflicts, reach a Claude Code session you stopped with Esc. Claude Code turned the message down until someone typed at the session, and the tray said "its mod refused it"; lich now types the message at the prompt instead. + ## [0.81.0] - 2026-10-28 > [!IMPORTANT] diff --git a/internal/relay/submit.go b/internal/relay/submit.go index 09bacf78..c7abed0a 100644 --- a/internal/relay/submit.go +++ b/internal/relay/submit.go @@ -2,6 +2,7 @@ package relay import ( "context" + "errors" "fmt" "github.com/lichdotdev/lich/api" @@ -19,6 +20,10 @@ import ( // relayed prompt is: it carries text strangers wrote (an issue body, a // comment), and an ESC[201~ in it would close the paste and type the rest as // keys. +// +// A message the mod refuses is typed instead: Claude Code turns a mod's prompt +// down once the user stopped the session, until someone types at it, and the +// person at the window is that someone. func (s *Service) Submit(ctx context.Context, opts api.SubmitOptions) (api.None, error) { sess, ok := s.sessionOf(opts.SessionID) if !ok { @@ -34,5 +39,10 @@ func (s *Service) Submit(ctx context.Context, opts api.SubmitOptions) (api.None, if !live { return api.None{}, fmt.Errorf("%q is not running", sess.Label) } - return api.None{}, s.deliver(opts.SessionID, sanitize(opts.Message), nil) + message := sanitize(opts.Message) + err = s.deliver(opts.SessionID, message, nil) + if errors.Is(err, errModRefused) { + err = s.typeIn(opts.SessionID, message) + } + return api.None{}, err } diff --git a/internal/relay/submit_test.go b/internal/relay/submit_test.go index c4a38578..17cdd57c 100644 --- a/internal/relay/submit_test.go +++ b/internal/relay/submit_test.go @@ -63,3 +63,20 @@ func TestSubmitRefusesWhatCannotTakeAPrompt(t *testing.T) { t.Errorf("typed at shell = %q, want nothing", got) } } + +// Claude Code's mod refuses a prompt once the user stopped the session, until +// someone types at it: the window's sender is that someone, so it types. +func TestSubmitTypesWhatTheModRefused(t *testing.T) { + t.Parallel() + term := newFakeTerminal("modded") + term.attachMod("modded", PromptReceipt{State: PromptAcked, Reason: "the session was stopped and nobody has sent input since"}) + svc := newRelay(merging(), term, nil) + + if _, err := svc.Submit(t.Context(), api.SubmitOptions{SessionID: "modded", Message: "fix the button"}); err != nil { + t.Fatalf("Submit = %v, want nil", err) + } + writes := term.writesTo("modded") + if len(writes) != 2 || writes[0] != "\x1b[200~fix the button\x1b[201~" || writes[1] != "\r" { + t.Errorf("writes = %q, want the paste and then Enter", writes) + } +} diff --git a/internal/relay/viamod.go b/internal/relay/viamod.go index bbcc0881..578635da 100644 --- a/internal/relay/viamod.go +++ b/internal/relay/viamod.go @@ -67,6 +67,9 @@ func (s *Service) takesDelivery(id string) (bool, error) { return ready || s.term.ModAttached(id), nil } +// errModRefused is a mod's ack that turned the prompt down, with its reason. +var errModRefused = errors.New("its mod refused it") + // handToMod hands a message to the session's mod, as a notification when note // is not nil, and reports whether it is the mod's to deliver now: false with no // error means type it instead, which is what a session with no mod and a prompt @@ -89,7 +92,7 @@ func (s *Service) handToMod(sessionID, message string, note *Notification) (bool return true, errors.New("the session ended before its mod took it") case PromptAcked: if !receipt.OK { - return true, fmt.Errorf("its mod refused it: %s", receipt.Reason) + return true, fmt.Errorf("%w: %s", errModRefused, receipt.Reason) } } return true, nil