From 0ab713c7f6cb204e5af5ac87215e7ea8773b5097 Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 26 Oct 2026 22:03:50 -0300 Subject: [PATCH] feat(spawn): let a session set its own model and effort, and close itself on self control_session took model and effort on any Claude Code session but the caller's own, though both only change what the next requests carry and the asking turn survives them. close_session refused the caller outright, leaving the user no way to have an agent close its own session. model and effort now reach the caller; close_session (lich close --self) closes it when told self, which the tool description reserves for a user who asked. --- CHANGELOG.md | 4 ++++ api/spawn.go | 1 + api/testdata/contract.golden | 1 + docs/ceilings.md | 7 +++++-- internal/cli/cli.go | 3 ++- internal/cli/control.go | 4 ++-- internal/cli/help.go | 7 ++++--- internal/cli/mcp.go | 9 ++++++--- internal/spawn/close.go | 5 +++-- internal/spawn/close_test.go | 13 +++++++++++++ internal/spawn/control.go | 7 +++---- internal/spawn/control_test.go | 17 +++++++++++++++++ 12 files changed, 61 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29ce72d05..2aacc689b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/api/spawn.go b/api/spawn.go index ab8b17161..372dbc323 100644 --- a/api/spawn.go +++ b/api/spawn.go @@ -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. diff --git a/api/testdata/contract.golden b/api/testdata/contract.golden index f4377f958..c76887c40 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -844,6 +844,7 @@ CloseSessionOptions { project string worktree string force bool + self bool } ClosedSession { diff --git a/docs/ceilings.md b/docs/ceilings.md index 980367e1b..a02e28d55 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -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). @@ -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 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index adf9fa608..8d421a548 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -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 @@ -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 diff --git a/internal/cli/control.go b/internal/cli/control.go index c106b5853..c0d97c1a5 100644 --- a/internal/cli/control.go +++ b/internal/cli/control.go @@ -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.", diff --git a/internal/cli/help.go b/internal/cli/help.go index ee8a1ff53..ea3c823c0 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -98,10 +98,11 @@ var commands = []command{ }, { name: "close", - args: "[--project ] [--worktree keep|remove] [--force] [--json]\n" + - " ", + args: "[--project ] [--worktree keep|remove] [--force] [--self]\n" + + " [--json] ", 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", diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index f4b0c3e4a..493cf0714 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -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."), @@ -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 diff --git a/internal/spawn/close.go b/internal/spawn/close.go index c1cd77388..e64977c81 100644 --- a/internal/spawn/close.go +++ b/internal/spawn/close.go @@ -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, ) } diff --git a/internal/spawn/close_test.go b/internal/spawn/close_test.go index 05b17e566..313b9258b 100644 --- a/internal/spawn/close_test.go +++ b/internal/spawn/close_test.go @@ -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) diff --git a/internal/spawn/control.go b/internal/spawn/control.go index 0bec7c455..517942345 100644 --- a/internal/spawn/control.go +++ b/internal/spawn/control.go @@ -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 "+ diff --git a/internal/spawn/control_test.go b/internal/spawn/control_test.go index 10f038e9d..1486c0347 100644 --- a/internal/spawn/control_test.go +++ b/internal/spawn/control_test.go @@ -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"})