on main help sign in
feat: be able to continue non-interactive sessions (#2401)

Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>

commitfad58e2c269654c052a768191cc7a3ec0af391e5 authorDaniil Sivak <seroperson@gmail.com> dateMon 16 Mar 2026, 23:40 (7 months ago) parentc9efb20
10 files changed+317−11
Taskfile.yaml+5 internal/app/app.go+39−3 internal/app/resolve_session_test.go+174 internal/cmd/run.go+34−5 internal/db/db.go+10 internal/db/querier.go+1 internal/db/sessions.sql.go+26 internal/db/sql/sessions.sql+6 internal/event/event.go+13−3 internal/session/session.go+9
Taskfile.yaml+5
@@ -184,3 +184,8 @@ tasks:
184184 - go get charm.land/fantasy
185185 - go get charm.land/catwalk
186186 - go mod tidy
187+
188+ sqlc:
189+ desc: Generate code using SQLC
190+ cmds:
191+ - sqlc generate
internal/app/app.go+39−3
@@ -157,9 +157,40 @@ func (app *App) AgentNotifications() *pubsub.Broker[notify.Notification] {
157157 return app.agentNotifications
158158}
159159
160+// resolveSession resolves which session to use for a non-interactive run
161+// If continueSessionID is set, it looks up that session by ID
162+// If useLast is set, it returns the most recently updated top-level session
163+// Otherwise, it creates a new session
164+func (app *App) resolveSession(ctx context.Context, continueSessionID string, useLast bool) (session.Session, error) {
165+ switch {
166+ case continueSessionID != "":
167+ if app.Sessions.IsAgentToolSession(continueSessionID) {
168+ return session.Session{}, fmt.Errorf("cannot continue an agent tool session: %s", continueSessionID)
169+ }
170+ sess, err := app.Sessions.Get(ctx, continueSessionID)
171+ if err != nil {
172+ return session.Session{}, fmt.Errorf("session not found: %s", continueSessionID)
173+ }
174+ if sess.ParentSessionID != "" {
175+ return session.Session{}, fmt.Errorf("cannot continue a child session: %s", continueSessionID)
176+ }
177+ return sess, nil
178+
179+ case useLast:
180+ sess, err := app.Sessions.GetLast(ctx)
181+ if err != nil {
182+ return session.Session{}, fmt.Errorf("no sessions found to continue")
183+ }
184+ return sess, nil
185+
186+ default:
187+ return app.Sessions.Create(ctx, agent.DefaultSessionName)
188+ }
189+}
190+
160191// RunNonInteractive runs the application in non-interactive mode with the
161192// given prompt, printing to stdout.
162−func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error {
193+func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool, continueSessionID string, useLast bool) error {
163194 slog.Info("Running in non-interactive mode")
164195
165196 ctx, cancel := context.WithCancel(ctx)
@@ -227,11 +258,16 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
227258
228259 defer stopSpinner()
229260
230− sess, err := app.Sessions.Create(ctx, agent.DefaultSessionName)
261+ sess, err := app.resolveSession(ctx, continueSessionID, useLast)
231262 if err != nil {
232263 return fmt.Errorf("failed to create session for non-interactive mode: %w", err)
233264 }
234− slog.Info("Created session for non-interactive run", "session_id", sess.ID)
265+
266+ if continueSessionID != "" || useLast {
267+ slog.Info("Continuing session for non-interactive run", "session_id", sess.ID)
268+ } else {
269+ slog.Info("Created session for non-interactive run", "session_id", sess.ID)
270+ }
235271
236272 // Automatically approve all permission requests for this non-interactive
237273 // session.
internal/app/resolve_session_test.gonew file+174
@@ -0,0 +1,174 @@
1+package app
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "strings"
8+ "testing"
9+
10+ "github.com/charmbracelet/crush/internal/pubsub"
11+ "github.com/charmbracelet/crush/internal/session"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+// mockSessionService is a minimal mock of session.Service for testing resolveSession.
16+type mockSessionService struct {
17+ sessions []session.Session
18+ created []session.Session
19+}
20+
21+func (m *mockSessionService) Subscribe(context.Context) <-chan pubsub.Event[session.Session] {
22+ return make(chan pubsub.Event[session.Session])
23+}
24+
25+func (m *mockSessionService) Create(_ context.Context, title string) (session.Session, error) {
26+ s := session.Session{ID: "new-session-id", Title: title}
27+ m.created = append(m.created, s)
28+ return s, nil
29+}
30+
31+func (m *mockSessionService) CreateTitleSession(context.Context, string) (session.Session, error) {
32+ return session.Session{}, nil
33+}
34+
35+func (m *mockSessionService) CreateTaskSession(context.Context, string, string, string) (session.Session, error) {
36+ return session.Session{}, nil
37+}
38+
39+func (m *mockSessionService) Get(_ context.Context, id string) (session.Session, error) {
40+ for _, s := range m.sessions {
41+ if s.ID == id {
42+ return s, nil
43+ }
44+ }
45+ return session.Session{}, sql.ErrNoRows
46+}
47+
48+func (m *mockSessionService) GetLast(_ context.Context) (session.Session, error) {
49+ if len(m.sessions) > 0 {
50+ return m.sessions[0], nil
51+ }
52+ return session.Session{}, sql.ErrNoRows
53+}
54+
55+func (m *mockSessionService) List(context.Context) ([]session.Session, error) {
56+ return m.sessions, nil
57+}
58+
59+func (m *mockSessionService) Save(_ context.Context, s session.Session) (session.Session, error) {
60+ return s, nil
61+}
62+
63+func (m *mockSessionService) UpdateTitleAndUsage(context.Context, string, string, int64, int64, float64) error {
64+ return nil
65+}
66+
67+func (m *mockSessionService) Rename(context.Context, string, string) error {
68+ return nil
69+}
70+
71+func (m *mockSessionService) Delete(context.Context, string) error {
72+ return nil
73+}
74+
75+func (m *mockSessionService) CreateAgentToolSessionID(messageID, toolCallID string) string {
76+ return fmt.Sprintf("%s$$%s", messageID, toolCallID)
77+}
78+
79+func (m *mockSessionService) ParseAgentToolSessionID(sessionID string) (string, string, bool) {
80+ parts := strings.Split(sessionID, "$$")
81+ if len(parts) != 2 {
82+ return "", "", false
83+ }
84+ return parts[0], parts[1], true
85+}
86+
87+func (m *mockSessionService) IsAgentToolSession(sessionID string) bool {
88+ _, _, ok := m.ParseAgentToolSessionID(sessionID)
89+ return ok
90+}
91+
92+func newTestApp(sessions session.Service) *App {
93+ return &App{Sessions: sessions}
94+}
95+
96+func TestResolveSession_NewSession(t *testing.T) {
97+ mock := &mockSessionService{}
98+ app := newTestApp(mock)
99+
100+ sess, err := app.resolveSession(t.Context(), "", false)
101+ require.NoError(t, err)
102+ require.Equal(t, "new-session-id", sess.ID)
103+ require.Len(t, mock.created, 1)
104+}
105+
106+func TestResolveSession_ContinueByID(t *testing.T) {
107+ mock := &mockSessionService{
108+ sessions: []session.Session{
109+ {ID: "existing-id", Title: "Old session"},
110+ },
111+ }
112+ app := newTestApp(mock)
113+
114+ sess, err := app.resolveSession(t.Context(), "existing-id", false)
115+ require.NoError(t, err)
116+ require.Equal(t, "existing-id", sess.ID)
117+ require.Equal(t, "Old session", sess.Title)
118+ require.Empty(t, mock.created)
119+}
120+
121+func TestResolveSession_ContinueByID_NotFound(t *testing.T) {
122+ mock := &mockSessionService{}
123+ app := newTestApp(mock)
124+
125+ _, err := app.resolveSession(t.Context(), "nonexistent", false)
126+ require.Error(t, err)
127+ require.Contains(t, err.Error(), "session not found")
128+}
129+
130+func TestResolveSession_ContinueByID_ChildSession(t *testing.T) {
131+ mock := &mockSessionService{
132+ sessions: []session.Session{
133+ {ID: "child-id", ParentSessionID: "parent-id", Title: "Child session"},
134+ },
135+ }
136+ app := newTestApp(mock)
137+
138+ _, err := app.resolveSession(t.Context(), "child-id", false)
139+ require.Error(t, err)
140+ require.Contains(t, err.Error(), "cannot continue a child session")
141+}
142+
143+func TestResolveSession_ContinueByID_AgentToolSession(t *testing.T) {
144+ mock := &mockSessionService{}
145+ app := newTestApp(mock)
146+
147+ _, err := app.resolveSession(t.Context(), "msg123$$tool456", false)
148+ require.Error(t, err)
149+ require.Contains(t, err.Error(), "cannot continue an agent tool session")
150+}
151+
152+func TestResolveSession_Last(t *testing.T) {
153+ mock := &mockSessionService{
154+ sessions: []session.Session{
155+ {ID: "most-recent", Title: "Latest session"},
156+ {ID: "older", Title: "Older session"},
157+ },
158+ }
159+ app := newTestApp(mock)
160+
161+ sess, err := app.resolveSession(t.Context(), "", true)
162+ require.NoError(t, err)
163+ require.Equal(t, "most-recent", sess.ID)
164+ require.Empty(t, mock.created)
165+}
166+
167+func TestResolveSession_Last_NoSessions(t *testing.T) {
168+ mock := &mockSessionService{}
169+ app := newTestApp(mock)
170+
171+ _, err := app.resolveSession(t.Context(), "", true)
172+ require.Error(t, err)
173+ require.Contains(t, err.Error(), "no sessions found")
174+}
internal/cmd/run.go+34−5
@@ -36,12 +36,23 @@ crush run --quiet "Generate a README for this project"
3636
3737# Run in verbose mode (show logs)
3838crush run --verbose "Generate a README for this project"
39+
40+# Continue a previous session
41+crush run --session {session-id} "Follow up on your last response"
42+
43+# Continue the most recent session
44+crush run --continue "Follow up on your last response"
45+
3946 `,
4047 RunE: func(cmd *cobra.Command, args []string) error {
41− quiet, _ := cmd.Flags().GetBool("quiet")
42− verbose, _ := cmd.Flags().GetBool("verbose")
43− largeModel, _ := cmd.Flags().GetString("model")
44− smallModel, _ := cmd.Flags().GetString("small-model")
48+ var (
49+ quiet, _ = cmd.Flags().GetBool("quiet")
50+ verbose, _ = cmd.Flags().GetBool("verbose")
51+ largeModel, _ = cmd.Flags().GetString("model")
52+ smallModel, _ = cmd.Flags().GetString("small-model")
53+ sessionID, _ = cmd.Flags().GetString("session")
54+ useLast, _ = cmd.Flags().GetBool("continue")
55+ )
4556
4657 // Cancel on SIGINT or SIGTERM.
4758 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
@@ -53,6 +64,14 @@ crush run --verbose "Generate a README for this project"
5364 }
5465 defer app.Shutdown()
5566
67+ if sessionID != "" {
68+ sess, err := resolveSessionID(ctx, app.Sessions, sessionID)
69+ if err != nil {
70+ return err
71+ }
72+ sessionID = sess.ID
73+ }
74+
5675 if !app.Config().IsConfigured() {
5776 return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
5877 }
@@ -76,7 +95,14 @@ crush run --verbose "Generate a README for this project"
7695 event.SetNonInteractive(true)
7796 event.AppInitialized()
7897
79− return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose)
98+ switch {
99+ case sessionID != "":
100+ event.SetContinueBySessionID(true)
101+ case useLast:
102+ event.SetContinueLastSession(true)
103+ }
104+
105+ return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose, sessionID, useLast)
80106 },
81107}
82108
@@ -85,4 +111,7 @@ func init() {
85111 runCmd.Flags().BoolP("verbose", "v", false, "Show logs")
86112 runCmd.Flags().StringP("model", "m", "", "Model to use. Accepts 'model' or 'provider/model' to disambiguate models with the same name across providers")
87113 runCmd.Flags().String("small-model", "", "Small model to use. If not provided, uses the default small model for the provider")
114+ runCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
115+ runCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
116+ runCmd.MarkFlagsMutuallyExclusive("session", "continue")
88117}
internal/db/db.go+10
@@ -63,6 +63,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
6363 if q.getHourDayHeatmapStmt, err = db.PrepareContext(ctx, getHourDayHeatmap); err != nil {
6464 return nil, fmt.Errorf("error preparing query GetHourDayHeatmap: %w", err)
6565 }
66+ if q.getLastSessionStmt, err = db.PrepareContext(ctx, getLastSession); err != nil {
67+ return nil, fmt.Errorf("error preparing query GetLastSession: %w", err)
68+ }
6669 if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil {
6770 return nil, fmt.Errorf("error preparing query GetMessage: %w", err)
6871 }
@@ -202,6 +205,11 @@ func (q *Queries) Close() error {
202205 err = fmt.Errorf("error closing getHourDayHeatmapStmt: %w", cerr)
203206 }
204207 }
208+ if q.getLastSessionStmt != nil {
209+ if cerr := q.getLastSessionStmt.Close(); cerr != nil {
210+ err = fmt.Errorf("error closing getLastSessionStmt: %w", cerr)
211+ }
212+ }
205213 if q.getMessageStmt != nil {
206214 if cerr := q.getMessageStmt.Close(); cerr != nil {
207215 err = fmt.Errorf("error closing getMessageStmt: %w", cerr)
@@ -369,6 +377,7 @@ type Queries struct {
369377 getFileByPathAndSessionStmt *sql.Stmt
370378 getFileReadStmt *sql.Stmt
371379 getHourDayHeatmapStmt *sql.Stmt
380+ getLastSessionStmt *sql.Stmt
372381 getMessageStmt *sql.Stmt
373382 getRecentActivityStmt *sql.Stmt
374383 getSessionByIDStmt *sql.Stmt
@@ -411,6 +420,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
411420 getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt,
412421 getFileReadStmt: q.getFileReadStmt,
413422 getHourDayHeatmapStmt: q.getHourDayHeatmapStmt,
423+ getLastSessionStmt: q.getLastSessionStmt,
414424 getMessageStmt: q.getMessageStmt,
415425 getRecentActivityStmt: q.getRecentActivityStmt,
416426 getSessionByIDStmt: q.getSessionByIDStmt,
internal/db/querier.go+1
@@ -22,6 +22,7 @@ type Querier interface {
2222 GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error)
2323 GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error)
2424 GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error)
25+ GetLastSession(ctx context.Context) (Session, error)
2526 GetMessage(ctx context.Context, id string) (Message, error)
2627 GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error)
2728 GetSessionByID(ctx context.Context, id string) (Session, error)
internal/db/sessions.sql.go+26
@@ -83,6 +83,32 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error {
8383 return err
8484}
8585
86+const getLastSession = `-- name: GetLastSession :one
87+SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id, todos
88+FROM sessions
89+ORDER BY updated_at DESC
90+LIMIT 1
91+`
92+
93+func (q *Queries) GetLastSession(ctx context.Context) (Session, error) {
94+ row := q.queryRow(ctx, q.getLastSessionStmt, getLastSession)
95+ var i Session
96+ err := row.Scan(
97+ &i.ID,
98+ &i.ParentSessionID,
99+ &i.Title,
100+ &i.MessageCount,
101+ &i.PromptTokens,
102+ &i.CompletionTokens,
103+ &i.Cost,
104+ &i.UpdatedAt,
105+ &i.CreatedAt,
106+ &i.SummaryMessageID,
107+ &i.Todos,
108+ )
109+ return i, err
110+}
111+
86112const getSessionByID = `-- name: GetSessionByID :one
87113SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id, todos
88114FROM sessions
internal/db/sql/sessions.sql+6
@@ -28,6 +28,12 @@ SELECT *
2828FROM sessions
2929WHERE id = ? LIMIT 1;
3030
31+-- name: GetLastSession :one
32+SELECT *
33+FROM sessions
34+ORDER BY updated_at DESC
35+LIMIT 1;
36+
3137-- name: ListSessions :many
3238SELECT *
3339FROM sessions
internal/event/event.go+13−3
@@ -17,7 +17,9 @@ const (
1717 endpoint = "https://data.charm.land"
1818 key = "phc_4zt4VgDWLqbYnJYEwLRxFoaTL2noNrQij0C6E8k3I0V"
1919
20− nonInteractiveEventName = "NonInteractive"
20+ nonInteractiveAttrName = "NonInteractive"
21+ continueSessionByIDAttrName = "ContinueSessionByID"
22+ continueLastSessionAttrName = "ContinueLastSession"
2123)
2224
2325var (
@@ -30,11 +32,19 @@ var (
3032 Set("SHELL", filepath.Base(os.Getenv("SHELL"))).
3133 Set("Version", version.Version).
3234 Set("GoVersion", runtime.Version()).
33− Set(nonInteractiveEventName, false)
35+ Set(nonInteractiveAttrName, false)
3436)
3537
3638func SetNonInteractive(nonInteractive bool) {
37− baseProps = baseProps.Set(nonInteractiveEventName, nonInteractive)
39+ baseProps = baseProps.Set(nonInteractiveAttrName, nonInteractive)
40+}
41+
42+func SetContinueBySessionID(continueBySessionID bool) {
43+ baseProps = baseProps.Set(continueSessionByIDAttrName, continueBySessionID)
44+}
45+
46+func SetContinueLastSession(continueLastSession bool) {
47+ baseProps = baseProps.Set(continueLastSessionAttrName, continueLastSession)
3848}
3949
4050func Init() {
internal/session/session.go+9
@@ -66,6 +66,7 @@ type Service interface {
6666 CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error)
6767 CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error)
6868 Get(ctx context.Context, id string) (Session, error)
69+ GetLast(ctx context.Context) (Session, error)
6970 List(ctx context.Context) ([]Session, error)
7071 Save(ctx context.Context, session Session) (Session, error)
7172 UpdateTitleAndUsage(ctx context.Context, sessionID, title string, promptTokens, completionTokens int64, cost float64) error
@@ -166,6 +167,14 @@ func (s *service) Get(ctx context.Context, id string) (Session, error) {
166167 return s.fromDBItem(dbSession), nil
167168}
168169
170+func (s *service) GetLast(ctx context.Context) (Session, error) {
171+ dbSession, err := s.q.GetLastSession(ctx)
172+ if err != nil {
173+ return Session{}, err
174+ }
175+ return s.fromDBItem(dbSession), nil
176+}
177+
169178func (s *service) Save(ctx context.Context, session Session) (Session, error) {
170179 todosJSON, err := marshalTodos(session.Todos)
171180 if err != nil {