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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- An agent can change the model or the reasoning effort of the session it runs in through `control_session`, and close that session when you ask it to (`self` on `close_session`, `lich close --self`). Both used to be refused outright.

### Fixed

- The mouse wheel in a terminal scrolls smoothly and as far as in other terminals. A notch moves three lines of the shell's history, or a full-screen program such as `less`, eased over a few frames instead of jumping some eight lines at once. A program that takes the mouse, such as nvim, Claude Code or Codex, gets one wheel step a notch and scrolls its own distance, where it used to get about eight and race through the conversation.
Expand Down
1 change: 1 addition & 0 deletions api/spawn.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ type CloseSessionOptions struct {
Project string `json:"project"`
Worktree string `json:"worktree"`
Force bool `json:"force"`
Self bool `json:"self"`
}

// ClosedSession is what a caller is told about the session it closed.
Expand Down
1 change: 1 addition & 0 deletions api/testdata/contract.golden
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,7 @@ CloseSessionOptions {
project string
worktree string
force bool
self bool
}

ClosedSession {
Expand Down
7 changes: 5 additions & 2 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,8 @@ work when nobody knows it and that the call site never shows. The mechanism and
the default for every new session (2.1.288). The `model` and `effort` actions are the per-session route.
- A slash command that opens a dialog (`/cost`) holds that session's later slash commands until someone
presses Esc in its terminal; lich cannot tell such a command apart before running it.
- A session cannot control or ask itself: an abort would end the asking turn, a prompt or a slash command would
only run after it, and a question would ride the request it is waiting on.
- A session cannot ask itself, nor control itself past `model` and `effort`: an abort would end the asking turn,
a prompt or a slash command would only run after it, and a question would ride the request it is waiting on.
- An ask knows the conversation only as of the last finished model response, cannot be cancelled (past lich's
90 seconds the fork runs on, billed, and its answer is dropped), never reaches the session's cost, and is cut
at 16,000 characters (2.1.289).
Expand Down Expand Up @@ -849,6 +849,9 @@ work when nobody knows it and that the call site never shows. The mechanism and
but the fold (`moveCollapsed`, `native/cmd/lich-native/sidebar_prefs.go`); renaming onto a name the project
already holds merges the two folders with no undo; two names differing only by case or a space are two
folders; and a folder belongs to one project, since the name is scoped by `project_id`.
- **An agent closes its own session on its word that the user asked** (`internal/spawn/close.go`): `self` on
`close_session` (`lich close --self`) lifts the refusal, and nothing checks that a person asked for it; the
tool description is the whole guard. The close kills the caller mid-call, so its answer never reaches it.
- **An agent files by the name as written, like the window** (`internal/spawn/folders.go`): `lich file`,
`lich rename-folder`, `lich color-folder` and their MCP tools match a folder exactly, so an agent that types `auth` beside `Auth`
starts a second folder; the tool descriptions send it to `list_folders` first instead of folding case behind
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,7 @@ func (c *client) close(args []string) error {
worktree := flags.String("worktree", "",
"what to do with the checkout when this is its last session: keep or remove")
force := flags.Bool("force", false, "remove a checkout that still has uncommitted work")
self := flags.Bool("self", false, "allow closing the session this command runs in")
asJSON := flags.Bool("json", false, "print the result as JSON")
if err := c.parse(flags, args); err != nil {
return err
Expand All @@ -903,7 +904,7 @@ func (c *client) close(args []string) error {
}

closed, err := callMethod(context.Background(), c, api.SpawnClose, spawn.CloseOptions{
From: c.sessionID(), Target: flags.Arg(0), Project: *project, Worktree: *worktree, Force: *force,
From: c.sessionID(), Target: flags.Arg(0), Project: *project, Worktree: *worktree, Force: *force, Self: *self,
}, openCall)
if err != nil {
return err
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/control.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ var controlTools = []mcpTool{
"of its slash commands, such as compact or clear. It waits up to 10 seconds (60 for a " +
"slash command) for the session to confirm. A result saying delivered, with an id, is " +
"not a failure: the command still goes through. One the session never took fails, and " +
"nothing ran. Claude Code sessions only, and " +
"never your own. model and effort change that session only; the slash commands /model " +
"nothing ran. Claude Code sessions only. Your own session takes model and effort, " +
"never the other actions. model and effort change that session only; the slash commands /model " +
"and /effort are refused because Claude Code would save them as the user's default for " +
"every new session. " +
"Experimental: may change outside semver.",
Expand Down
7 changes: 4 additions & 3 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,11 @@ var commands = []command{
},
{
name: "close",
args: "[--project <name-or-path>] [--worktree keep|remove] [--force] [--json]\n" +
" <session>",
args: "[--project <name-or-path>] [--worktree keep|remove] [--force] [--self]\n" +
" [--json] <session>",
about: "Close a session. Closing the last one in a worktree needs --worktree to\n" +
"say whether the checkout stays; removing a dirty one needs --force.",
"say whether the checkout stays; removing a dirty one needs --force; the\n" +
"session the command runs in needs --self.",
},
{
name: "rename",
Expand Down
9 changes: 6 additions & 3 deletions internal/cli/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -655,8 +655,9 @@ var mcpTools = append([]mcpTool{
"that branch again resumes its conversation) or remove it. A checkout with " +
"uncommitted work is only removed with force, because what that discards is in " +
"no commit and on no remote. A checkout lich did not create is removed from the " +
"window only, so \"remove\" on one is refused here. You cannot close the session " +
"you are running in.",
"window only, so \"remove\" on one is refused here. The session you are running in " +
"closes only with self, which ends you mid-call: set it only when the user asked " +
"you to close this session.",
Schema: schema(map[string]any{
"session": property("string",
"The session to close, by the label on its card, the name it answers to or its lich id."),
Expand All @@ -668,11 +669,13 @@ var mcpTools = append([]mcpTool{
"checkout on disk, \"remove\" deletes it."),
"force": property("boolean",
"Remove a checkout that still has uncommitted work. Ask the user first."),
"self": property("boolean",
"Close the session you are running in. Only when the user asked for exactly that."),
}, "session"),
Run: func(ctx context.Context, c *client, args mcpArgs) (string, error) {
closed, err := callMethod(ctx, c, api.SpawnClose, spawn.CloseOptions{
From: c.sessionID(), Target: args.text("session"), Project: args.text("project"),
Worktree: args.text("worktree"), Force: args.flag("force"),
Worktree: args.text("worktree"), Force: args.flag("force"), Self: args.flag("self"),
}, openCall)
if err != nil {
return "", err
Expand Down
5 changes: 3 additions & 2 deletions internal/spawn/close.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ func (s *Service) Close(ctx context.Context, opts CloseOptions) (Closed, error)
if err != nil {
return Closed{}, err
}
if found.session.ID == fromID {
if found.session.ID == fromID && !opts.Self {
return Closed{}, fmt.Errorf(
"%q is this session — closing it would take down the agent asking for it", target,
"%q is this session — closing it would take down the agent asking for it. "+
"Close it with self only if the user asked for exactly that", target,
)
}

Expand Down
13 changes: 13 additions & 0 deletions internal/spawn/close_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,19 @@ func TestCloseRefusesTheSessionItWasAskedFrom(t *testing.T) {
}
}

// self is the user's say-so, relayed by the agent, to close the session it
// runs in.
func TestCloseTakesDownTheCallerWhenToldSelf(t *testing.T) {
svc, sessions, _, term, _ := closer(t)

if _, err := svc.Close(t.Context(), CloseOptions{From: "s2", Target: "shared-a", Self: true}); err != nil {
t.Fatalf("Close: %v", err)
}
if len(sessions.parked) != 1 || sessions.parked[0].sessionID != "s2" || len(term.closed) == 0 {
t.Errorf("parked = %+v, closed = %v, want s2 parked and its terminal closed", sessions.parked, term.closed)
}
}

func TestCloseKeepingAWorktreeParksTheSession(t *testing.T) {
svc, sessions, worktrees, _, _ := closer(t)

Expand Down
7 changes: 3 additions & 4 deletions internal/spawn/control.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,9 @@ func (s *Service) Control(ctx context.Context, opts ControlOptions) (Controlled,
if err != nil {
return Controlled{}, err
}
// Refused for model and effort too, which would work: nobody asked for
// them, and allowing them would need a target that defaults to the caller,
// since list_sessions never shows an agent its own label.
if found.session.ID == fromID {
// model and effort are the exception: they only change what the session's
// next requests carry, which the asking turn survives.
if found.session.ID == fromID && action != terminal.ModModel && action != terminal.ModEffort {
return Controlled{}, fmt.Errorf(
"%q is this session, and a session cannot control itself: an abort would end the "+
"turn asking for it, and a prompt or a slash command would only run once that "+
Expand Down
17 changes: 17 additions & 0 deletions internal/spawn/control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,23 @@ func TestControlRefusesBeforeReachingTheSession(t *testing.T) {
}
}

// A model or an effort only changes what the next requests carry, so the turn
// asking for it survives it.
func TestControlSetsTheCallersOwnModelAndEffort(t *testing.T) {
for _, action := range []string{"model", "effort"} {
t.Run(action, func(t *testing.T) {
svc, term := newControlService(t)
_, err := svc.Control(context.Background(), ControlOptions{From: "s1", Target: "s1", Action: action, Value: "high"})
if err != nil {
t.Fatalf("Control: %v", err)
}
if len(term.ranOn) != 1 || term.ranOn[0] != "s1" {
t.Fatalf("ran on %v, want the caller s1", term.ranOn)
}
})
}
}

func TestControlReachesASessionByItsID(t *testing.T) {
svc, term := newControlService(t)
got, err := svc.Control(context.Background(), ControlOptions{From: "s1", Target: "s2", Action: "abort"})
Expand Down
Loading