on main help sign in
Merge remote-tracking branch 'origin/main' into ui
112b400Kujtim Hoxha
go.mod+5−5
@@ -13,12 +13,12 @@ require (
13 github.com/JohannesKaufmann/html-to-markdown v1.6.0
13 github.com/JohannesKaufmann/html-to-markdown v1.6.0
14 github.com/MakeNowJust/heredoc v1.0.0
14 github.com/MakeNowJust/heredoc v1.0.0
15 github.com/PuerkitoBio/goquery v1.11.0
15 github.com/PuerkitoBio/goquery v1.11.0
16− github.com/alecthomas/chroma/v2 v2.21.1
16+ github.com/alecthomas/chroma/v2 v2.22.0
17 github.com/atotto/clipboard v0.1.4
17 github.com/atotto/clipboard v0.1.4
18 github.com/aymanbagabas/go-udiff v0.3.1
18 github.com/aymanbagabas/go-udiff v0.3.1
19− github.com/bmatcuk/doublestar/v4 v4.9.1
19+ github.com/bmatcuk/doublestar/v4 v4.9.2
20 github.com/charlievieth/fastwalk v1.0.14
20 github.com/charlievieth/fastwalk v1.0.14
21− github.com/charmbracelet/catwalk v0.12.2
21+ github.com/charmbracelet/catwalk v0.13.0
22 github.com/charmbracelet/colorprofile v0.4.1
22 github.com/charmbracelet/colorprofile v0.4.1
23 github.com/charmbracelet/fang v0.4.4
23 github.com/charmbracelet/fang v0.4.4
24 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
24 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
@@ -58,10 +58,10 @@ require (
58 github.com/tidwall/gjson v1.18.0
58 github.com/tidwall/gjson v1.18.0
59 github.com/tidwall/sjson v1.2.5
59 github.com/tidwall/sjson v1.2.5
60 github.com/zeebo/xxh3 v1.0.2
60 github.com/zeebo/xxh3 v1.0.2
61− golang.org/x/mod v0.31.0
61+ golang.org/x/mod v0.32.0
62 golang.org/x/net v0.48.0
62 golang.org/x/net v0.48.0
63 golang.org/x/sync v0.19.0
63 golang.org/x/sync v0.19.0
64− golang.org/x/text v0.32.0
64+ golang.org/x/text v0.33.0
65 gopkg.in/natefinch/lumberjack.v2 v2.2.1
65 gopkg.in/natefinch/lumberjack.v2 v2.2.1
66 gopkg.in/yaml.v3 v3.0.1
66 gopkg.in/yaml.v3 v3.0.1
67 modernc.org/sqlite v1.43.0
67 modernc.org/sqlite v1.43.0
go.sum+12−12
@@ -39,8 +39,8 @@ github.com/RealAlexandreAI/json-repair v0.0.14 h1:4kTqotVonDVTio5n2yweRUELVcNe2x
39github.com/RealAlexandreAI/json-repair v0.0.14/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
39github.com/RealAlexandreAI/json-repair v0.0.14/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
40github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
40github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
41github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
41github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
42−github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
42+github.com/alecthomas/chroma/v2 v2.22.0 h1:PqEhf+ezz5F5owoDeOUKFzW+W3ZJDShNCaHg4sZuItI=
43−github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
43+github.com/alecthomas/chroma/v2 v2.22.0/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
44github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
44github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
45github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
45github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
46github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
46github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
@@ -86,16 +86,16 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
86github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
86github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
87github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
87github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
88github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
88github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
89−github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
89+github.com/bmatcuk/doublestar/v4 v4.9.2 h1:b0mc6WyRSYLjzofB2v/0cuDUZ+MqoGyH3r0dVij35GI=
90−github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
90+github.com/bmatcuk/doublestar/v4 v4.9.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
91github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
91github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
92github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
92github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
93github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg=
93github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg=
94github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
94github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
95github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
95github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
96github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
96github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
97−github.com/charmbracelet/catwalk v0.12.2 h1:zq9b+7kiumof/Dzvqi/oHnwMBgSN/M2Yt82vlIAiKMU=
97+github.com/charmbracelet/catwalk v0.13.0 h1:L+chddP+PJvX3Vl+hqlWW5HAwBErlkL/friQXih1JQI=
98−github.com/charmbracelet/catwalk v0.12.2/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
98+github.com/charmbracelet/catwalk v0.13.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
99github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
99github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
100github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
100github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
101github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
101github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
@@ -396,8 +396,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
396golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
396golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
397golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
397golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
398golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
398golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
399−golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
399+golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
400−golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
400+golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
401golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
401golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
402golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
402golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
403golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
403golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -462,8 +462,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
462golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
462golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
463golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
463golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
464golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
464golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
465−golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
465+golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
466−golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
466+golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
467golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
467golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
468golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
468golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
469golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
469golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -472,8 +472,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
472golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
472golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
473golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
473golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
474golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
474golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
475−golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
475+golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
476−golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
476+golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
477golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
477golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
478gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
478gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
479gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
479gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
internal/agent/agent.go+28−13
@@ -68,6 +68,7 @@ type SessionAgent interface {
68 Run(context.Context, SessionAgentCall) (*fantasy.AgentResult, error)
68 Run(context.Context, SessionAgentCall) (*fantasy.AgentResult, error)
69 SetModels(large Model, small Model)
69 SetModels(large Model, small Model)
70 SetTools(tools []fantasy.AgentTool)
70 SetTools(tools []fantasy.AgentTool)
71+ SetSystemPrompt(systemPrompt string)
71 Cancel(sessionID string)
72 Cancel(sessionID string)
72 CancelAll()
73 CancelAll()
73 IsSessionBusy(sessionID string) bool
74 IsSessionBusy(sessionID string) bool
@@ -444,7 +445,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
444 Content: content,
445 Content: content,
445 IsError: true,
446 IsError: true,
446 }
447 }
447− _, createErr = a.messages.Create(context.Background(), currentAssistant.SessionID, message.CreateMessageParams{
448+ _, createErr = a.messages.Create(ctx, currentAssistant.SessionID, message.CreateMessageParams{
448 Role: message.Tool,
449 Role: message.Tool,
449 Parts: []message.ContentPart{
450 Parts: []message.ContentPart{
450 toolResult,
451 toolResult,
@@ -561,15 +562,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
561 return err
562 return err
562 }
563 }
563
564
564− summaryPromptText := "Provide a detailed summary of our conversation above."
565− if len(currentSession.Todos) > 0 {
565+ summaryPromptText := buildSummaryPrompt(currentSession.Todos)
566− summaryPromptText += "\n\n## Current Todo List\n\n"
567− for _, t := range currentSession.Todos {
568− summaryPromptText += fmt.Sprintf("- [%s] %s\n", t.Status, t.Content)
569− }
570− summaryPromptText += "\nInclude these tasks and their statuses in your summary. "
571− summaryPromptText += "Instruct the resuming assistant to use the `todos` tool to continue tracking progress on these tasks."
572− }
573
566
574 resp, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
567 resp, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
575 Prompt: summaryPromptText,
568 Prompt: summaryPromptText,
@@ -883,14 +876,17 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session,
883}
876}
884
877
885func (a *sessionAgent) Cancel(sessionID string) {
878func (a *sessionAgent) Cancel(sessionID string) {
886− // Cancel regular requests.
879+ // Cancel regular requests. Don't use Take() here - we need the entry to
880+ // remain in activeRequests so IsBusy() returns true until the goroutine
881+ // fully completes (including error handling that may access the DB).
882+ // The defer in processRequest will clean up the entry.
887− if cancel, ok := a.activeRequests.Take(sessionID); ok && cancel != nil {
883+ if cancel, ok := a.activeRequests.Get(sessionID); ok && cancel != nil {
888 slog.Info("Request cancellation initiated", "session_id", sessionID)
884 slog.Info("Request cancellation initiated", "session_id", sessionID)
889 cancel()
885 cancel()
890 }
886 }
891
887
892 // Also check for summarize requests.
888 // Also check for summarize requests.
893− if cancel, ok := a.activeRequests.Take(sessionID + "-summarize"); ok && cancel != nil {
889+ if cancel, ok := a.activeRequests.Get(sessionID + "-summarize"); ok && cancel != nil {
894 slog.Info("Summarize cancellation initiated", "session_id", sessionID)
890 slog.Info("Summarize cancellation initiated", "session_id", sessionID)
895 cancel()
891 cancel()
896 }
892 }
@@ -972,6 +968,10 @@ func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
972 a.tools = tools
968 a.tools = tools
973}
969}
974
970
971+func (a *sessionAgent) SetSystemPrompt(systemPrompt string) {
972+ a.systemPrompt = systemPrompt
973+}
974+
975func (a *sessionAgent) Model() Model {
975func (a *sessionAgent) Model() Model {
976 return a.largeModel
976 return a.largeModel
977}
977}
@@ -1101,3 +1101,18 @@ func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Mes
1101
1101
1102 return convertedMessages
1102 return convertedMessages
1103}
1103}
1104+
1105+// buildSummaryPrompt constructs the prompt text for session summarization.
1106+func buildSummaryPrompt(todos []session.Todo) string {
1107+ var sb strings.Builder
1108+ sb.WriteString("Provide a detailed summary of our conversation above.")
1109+ if len(todos) > 0 {
1110+ sb.WriteString("\n\n## Current Todo List\n\n")
1111+ for _, t := range todos {
1112+ fmt.Fprintf(&sb, "- [%s] %s\n", t.Status, t.Content)
1113+ }
1114+ sb.WriteString("\nInclude these tasks and their statuses in your summary. ")
1115+ sb.WriteString("Instruct the resuming assistant to use the `todos` tool to continue tracking progress on these tasks.")
1116+ }
1117+ return sb.String()
1118+}
internal/agent/agent_test.go+36
@@ -1,6 +1,7 @@
1package agent
1package agent
2
2
3import (
3import (
4+ "fmt"
4 "os"
5 "os"
5 "path/filepath"
6 "path/filepath"
6 "runtime"
7 "runtime"
@@ -11,6 +12,7 @@ import (
11 "charm.land/x/vcr"
12 "charm.land/x/vcr"
12 "github.com/charmbracelet/crush/internal/agent/tools"
13 "github.com/charmbracelet/crush/internal/agent/tools"
13 "github.com/charmbracelet/crush/internal/message"
14 "github.com/charmbracelet/crush/internal/message"
15+ "github.com/charmbracelet/crush/internal/session"
14 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
17 "github.com/stretchr/testify/require"
16
18
@@ -619,3 +621,37 @@ func TestCoderAgent(t *testing.T) {
619 })
621 })
620 }
622 }
621}
623}
624+
625+func makeTestTodos(n int) []session.Todo {
626+ todos := make([]session.Todo, n)
627+ for i := range n {
628+ todos[i] = session.Todo{
629+ Status: session.TodoStatusPending,
630+ Content: fmt.Sprintf("Task %d: Implement feature with some description that makes it realistic", i),
631+ }
632+ }
633+ return todos
634+}
635+
636+func BenchmarkBuildSummaryPrompt(b *testing.B) {
637+ cases := []struct {
638+ name string
639+ numTodos int
640+ }{
641+ {"0todos", 0},
642+ {"5todos", 5},
643+ {"10todos", 10},
644+ {"50todos", 50},
645+ }
646+
647+ for _, tc := range cases {
648+ todos := makeTestTodos(tc.numTodos)
649+
650+ b.Run(tc.name, func(b *testing.B) {
651+ b.ReportAllocs()
652+ for range b.N {
653+ _ = buildSummaryPrompt(todos)
654+ }
655+ })
656+ }
657+}
internal/agent/agentic_fetch_tool.go+4−2
@@ -79,7 +79,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
79 description = "Search the web and analyze results"
79 description = "Search the web and analyze results"
80 }
80 }
81
81
82− p := c.permissions.Request(
82+ p, err := c.permissions.Request(ctx,
83 permission.CreatePermissionRequest{
83 permission.CreatePermissionRequest{
84 SessionID: validationResult.SessionID,
84 SessionID: validationResult.SessionID,
85 Path: c.cfg.WorkingDir(),
85 Path: c.cfg.WorkingDir(),
@@ -90,7 +90,9 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
90 Params: tools.AgenticFetchPermissionsParams(params),
90 Params: tools.AgenticFetchPermissionsParams(params),
91 },
91 },
92 )
92 )
93−
93+ if err != nil {
94+ return fantasy.ToolResponse{}, err
95+ }
94 if !p {
96 if !p {
95 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
97 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
96 }
98 }
internal/agent/coordinator.go+11−6
@@ -322,17 +322,12 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
322 return nil, err
322 return nil, err
323 }
323 }
324
324
325− systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
326− if err != nil {
327− return nil, err
328− }
329−
330 largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
325 largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
331 result := NewSessionAgent(SessionAgentOptions{
326 result := NewSessionAgent(SessionAgentOptions{
332 large,
327 large,
333 small,
328 small,
334 largeProviderCfg.SystemPromptPrefix,
329 largeProviderCfg.SystemPromptPrefix,
335− systemPrompt,
330+ "",
336 isSubAgent,
331 isSubAgent,
337 c.cfg.Options.DisableAutoSummarize,
332 c.cfg.Options.DisableAutoSummarize,
338 c.permissions.SkipRequests(),
333 c.permissions.SkipRequests(),
@@ -340,6 +335,16 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
340 c.messages,
335 c.messages,
341 nil,
336 nil,
342 })
337 })
338+
339+ c.readyWg.Go(func() error {
340+ systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
341+ if err != nil {
342+ return err
343+ }
344+ result.SetSystemPrompt(systemPrompt)
345+ return nil
346+ })
347+
343 c.readyWg.Go(func() error {
348 c.readyWg.Go(func() error {
344 tools, err := c.buildTools(ctx, agent)
349 tools, err := c.buildTools(ctx, agent)
345 if err != nil {
350 if err != nil {
internal/agent/tools/bash.go+4−1
@@ -215,7 +215,7 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution
215 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for executing shell command")
215 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for executing shell command")
216 }
216 }
217 if !isSafeReadOnly {
217 if !isSafeReadOnly {
218− p := permissions.Request(
218+ p, err := permissions.Request(ctx,
219 permission.CreatePermissionRequest{
219 permission.CreatePermissionRequest{
220 SessionID: sessionID,
220 SessionID: sessionID,
221 Path: execWorkingDir,
221 Path: execWorkingDir,
@@ -226,6 +226,9 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution
226 Params: BashPermissionsParams(params),
226 Params: BashPermissionsParams(params),
227 },
227 },
228 )
228 )
229+ if err != nil {
230+ return fantasy.ToolResponse{}, err
231+ }
229 if !p {
232 if !p {
230 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
233 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
231 }
234 }
internal/agent/tools/download.go+4−2
@@ -70,7 +70,7 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client *
70 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for downloading files")
70 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for downloading files")
71 }
71 }
72
72
73− p := permissions.Request(
73+ p, err := permissions.Request(ctx,
74 permission.CreatePermissionRequest{
74 permission.CreatePermissionRequest{
75 SessionID: sessionID,
75 SessionID: sessionID,
76 Path: filePath,
76 Path: filePath,
@@ -80,7 +80,9 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client *
80 Params: DownloadPermissionsParams(params),
80 Params: DownloadPermissionsParams(params),
81 },
81 },
82 )
82 )
83−
83+ if err != nil {
84+ return fantasy.ToolResponse{}, err
85+ }
84 if !p {
86 if !p {
85 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
87 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
86 }
88 }
internal/agent/tools/edit.go+12−3
@@ -122,7 +122,7 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool
122 content,
122 content,
123 strings.TrimPrefix(filePath, edit.workingDir),
123 strings.TrimPrefix(filePath, edit.workingDir),
124 )
124 )
125− p := edit.permissions.Request(
125+ p, err := edit.permissions.Request(edit.ctx,
126 permission.CreatePermissionRequest{
126 permission.CreatePermissionRequest{
127 SessionID: sessionID,
127 SessionID: sessionID,
128 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
128 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
@@ -137,6 +137,9 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool
137 },
137 },
138 },
138 },
139 )
139 )
140+ if err != nil {
141+ return fantasy.ToolResponse{}, err
142+ }
140 if !p {
143 if !p {
141 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
144 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
142 }
145 }
@@ -243,7 +246,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
243 strings.TrimPrefix(filePath, edit.workingDir),
246 strings.TrimPrefix(filePath, edit.workingDir),
244 )
247 )
245
248
246− p := edit.permissions.Request(
249+ p, err := edit.permissions.Request(edit.ctx,
247 permission.CreatePermissionRequest{
250 permission.CreatePermissionRequest{
248 SessionID: sessionID,
251 SessionID: sessionID,
249 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
252 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
@@ -258,6 +261,9 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
258 },
261 },
259 },
262 },
260 )
263 )
264+ if err != nil {
265+ return fantasy.ToolResponse{}, err
266+ }
261 if !p {
267 if !p {
262 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
268 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
263 }
269 }
@@ -378,7 +384,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
378 strings.TrimPrefix(filePath, edit.workingDir),
384 strings.TrimPrefix(filePath, edit.workingDir),
379 )
385 )
380
386
381− p := edit.permissions.Request(
387+ p, err := edit.permissions.Request(edit.ctx,
382 permission.CreatePermissionRequest{
388 permission.CreatePermissionRequest{
383 SessionID: sessionID,
389 SessionID: sessionID,
384 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
390 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
@@ -393,6 +399,9 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
393 },
399 },
394 },
400 },
395 )
401 )
402+ if err != nil {
403+ return fantasy.ToolResponse{}, err
404+ }
396 if !p {
405 if !p {
397 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
406 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
398 }
407 }
internal/agent/tools/fetch.go+4−2
@@ -55,7 +55,7 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt
55 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
55 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
56 }
56 }
57
57
58− p := permissions.Request(
58+ p, err := permissions.Request(ctx,
59 permission.CreatePermissionRequest{
59 permission.CreatePermissionRequest{
60 SessionID: sessionID,
60 SessionID: sessionID,
61 Path: workingDir,
61 Path: workingDir,
@@ -66,7 +66,9 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt
66 Params: FetchPermissionsParams(params),
66 Params: FetchPermissionsParams(params),
67 },
67 },
68 )
68 )
69−
69+ if err != nil {
70+ return fantasy.ToolResponse{}, err
71+ }
70 if !p {
72 if !p {
71 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
73 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
72 }
74 }
internal/agent/tools/ls.go+4−2
@@ -79,7 +79,7 @@ func NewLsTool(permissions permission.Service, workingDir string, lsConfig confi
79 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing directories outside working directory")
79 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing directories outside working directory")
80 }
80 }
81
81
82− granted := permissions.Request(
82+ granted, err := permissions.Request(ctx,
83 permission.CreatePermissionRequest{
83 permission.CreatePermissionRequest{
84 SessionID: sessionID,
84 SessionID: sessionID,
85 Path: absSearchPath,
85 Path: absSearchPath,
@@ -90,7 +90,9 @@ func NewLsTool(permissions permission.Service, workingDir string, lsConfig confi
90 Params: LSPermissionsParams(params),
90 Params: LSPermissionsParams(params),
91 },
91 },
92 )
92 )
93−
93+ if err != nil {
94+ return fantasy.ToolResponse{}, err
95+ }
94 if !granted {
96 if !granted {
95 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
97 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
96 }
98 }
internal/agent/tools/mcp-tools.go+4−1
@@ -89,7 +89,7 @@ func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolRe
89 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
89 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
90 }
90 }
91 permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name)
91 permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name)
92− p := m.permissions.Request(
92+ p, err := m.permissions.Request(ctx,
93 permission.CreatePermissionRequest{
93 permission.CreatePermissionRequest{
94 SessionID: sessionID,
94 SessionID: sessionID,
95 ToolCallID: params.ID,
95 ToolCallID: params.ID,
@@ -100,6 +100,9 @@ func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolRe
100 Params: params.Input,
100 Params: params.Input,
101 },
101 },
102 )
102 )
103+ if err != nil {
104+ return fantasy.ToolResponse{}, err
105+ }
103 if !p {
106 if !p {
104 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
107 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
105 }
108 }
internal/agent/tools/mcp/init.go+14−15
@@ -107,29 +107,28 @@ func GetState(name string) (ClientInfo, bool) {
107
107
108// Close closes all MCP clients. This should be called during application shutdown.
108// Close closes all MCP clients. This should be called during application shutdown.
109func Close() error {
109func Close() error {
110− var errs []error
111 var wg sync.WaitGroup
110 var wg sync.WaitGroup
111+ done := make(chan struct{}, 1)
112+ go func() {
112− for name, session := range sessions.Seq2() {
113+ for name, session := range sessions.Seq2() {
113− wg.Go(func() {
114+ wg.Go(func() {
114− done := make(chan bool, 1)
115− go func() {
116 if err := session.Close(); err != nil &&
115 if err := session.Close(); err != nil &&
117 !errors.Is(err, io.EOF) &&
116 !errors.Is(err, io.EOF) &&
118 !errors.Is(err, context.Canceled) &&
117 !errors.Is(err, context.Canceled) &&
119 err.Error() != "signal: killed" {
118 err.Error() != "signal: killed" {
120− errs = append(errs, fmt.Errorf("close mcp: %s: %w", name, err))
119+ slog.Warn("Failed to shutdown MCP client", "name", name, "error", err)
121 }
120 }
121+ })
122+ }
123+ wg.Wait()
122− done <- true
124+ done <- struct{}{}
123− }()
125+ }()
124− select {
126+ select {
125− case <-done:
127+ case <-done:
126− case <-time.After(time.Millisecond * 250):
128+ case <-time.After(5 * time.Second):
127− }
128− })
129 }
129 }
130− wg.Wait()
131 broker.Shutdown()
130 broker.Shutdown()
132− return errors.Join(errs...)
131+ return nil
133}
132}
134
133
135// Initialize initializes MCP clients based on the provided configuration.
134// Initialize initializes MCP clients based on the provided configuration.
internal/agent/tools/multiedit.go+9−3
@@ -173,7 +173,7 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
173 } else {
173 } else {
174 description = fmt.Sprintf("Create file %s with %d edits", params.FilePath, editsApplied)
174 description = fmt.Sprintf("Create file %s with %d edits", params.FilePath, editsApplied)
175 }
175 }
176− p := edit.permissions.Request(permission.CreatePermissionRequest{
176+ p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{
177 SessionID: sessionID,
177 SessionID: sessionID,
178 Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
178 Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
179 ToolCallID: call.ID,
179 ToolCallID: call.ID,
@@ -186,12 +186,15 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
186 NewContent: currentContent,
186 NewContent: currentContent,
187 },
187 },
188 })
188 })
189+ if err != nil {
190+ return fantasy.ToolResponse{}, err
191+ }
189 if !p {
192 if !p {
190 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
193 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
191 }
194 }
192
195
193 // Write the file
196 // Write the file
194− err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
197+ err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
195 if err != nil {
198 if err != nil {
196 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
199 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
197 }
200 }
@@ -314,7 +317,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
314 } else {
317 } else {
315 description = fmt.Sprintf("Apply %d edits to file %s", editsApplied, params.FilePath)
318 description = fmt.Sprintf("Apply %d edits to file %s", editsApplied, params.FilePath)
316 }
319 }
317− p := edit.permissions.Request(permission.CreatePermissionRequest{
320+ p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{
318 SessionID: sessionID,
321 SessionID: sessionID,
319 Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
322 Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
320 ToolCallID: call.ID,
323 ToolCallID: call.ID,
@@ -327,6 +330,9 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
327 NewContent: currentContent,
330 NewContent: currentContent,
328 },
331 },
329 })
332 })
333+ if err != nil {
334+ return fantasy.ToolResponse{}, err
335+ }
330 if !p {
336 if !p {
331 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
337 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
332 }
338 }
internal/agent/tools/multiedit_test.go+2−2
@@ -19,8 +19,8 @@ type mockPermissionService struct {
19 *pubsub.Broker[permission.PermissionRequest]
19 *pubsub.Broker[permission.PermissionRequest]
20}
20}
21
21
22−func (m *mockPermissionService) Request(req permission.CreatePermissionRequest) bool {
22+func (m *mockPermissionService) Request(ctx context.Context, req permission.CreatePermissionRequest) (bool, error) {
23− return true
23+ return true, nil
24}
24}
25
25
26func (m *mockPermissionService) Grant(req permission.PermissionRequest) {}
26func (m *mockPermissionService) Grant(req permission.PermissionRequest) {}
internal/agent/tools/view.go+4−2
@@ -88,7 +88,7 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
88 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
88 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
89 }
89 }
90
90
91− granted := permissions.Request(
91+ granted, err := permissions.Request(ctx,
92 permission.CreatePermissionRequest{
92 permission.CreatePermissionRequest{
93 SessionID: sessionID,
93 SessionID: sessionID,
94 Path: absFilePath,
94 Path: absFilePath,
@@ -99,7 +99,9 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
99 Params: ViewPermissionsParams(params),
99 Params: ViewPermissionsParams(params),
100 },
100 },
101 )
101 )
102−
102+ if err != nil {
103+ return fantasy.ToolResponse{}, err
104+ }
103 if !granted {
105 if !granted {
104 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
106 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
105 }
107 }
internal/agent/tools/write.go+4−1
@@ -111,7 +111,7 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
111 strings.TrimPrefix(filePath, workingDir),
111 strings.TrimPrefix(filePath, workingDir),
112 )
112 )
113
113
114− p := permissions.Request(
114+ p, err := permissions.Request(ctx,
115 permission.CreatePermissionRequest{
115 permission.CreatePermissionRequest{
116 SessionID: sessionID,
116 SessionID: sessionID,
117 Path: fsext.PathOrPrefix(filePath, workingDir),
117 Path: fsext.PathOrPrefix(filePath, workingDir),
@@ -126,6 +126,9 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
126 },
126 },
127 },
127 },
128 )
128 )
129+ if err != nil {
130+ return fantasy.ToolResponse{}, err
131+ }
129 if !p {
132 if !p {
130 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
133 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
131 }
134 }
internal/app/app.go+14−8
@@ -392,25 +392,31 @@ func (app *App) Subscribe(program *tea.Program) {
392func (app *App) Shutdown() {
392func (app *App) Shutdown() {
393 start := time.Now()
393 start := time.Now()
394 defer func() { slog.Info("Shutdown took " + time.Since(start).String()) }()
394 defer func() { slog.Info("Shutdown took " + time.Since(start).String()) }()
395− var wg sync.WaitGroup
395+
396+ // First, cancel all agents and wait for them to finish. This must complete
397+ // before closing the DB so agents can finish writing their state.
396 if app.AgentCoordinator != nil {
398 if app.AgentCoordinator != nil {
397− wg.Go(func() {
398− app.AgentCoordinator.CancelAll()
399+ app.AgentCoordinator.CancelAll()
399− })
400 }
400 }
401
401
402+ // Now run remaining cleanup tasks in parallel.
403+ var wg sync.WaitGroup
404+
402 // Kill all background shells.
405 // Kill all background shells.
403 wg.Go(func() {
406 wg.Go(func() {
404 shell.GetBackgroundShellManager().KillAll()
407 shell.GetBackgroundShellManager().KillAll()
405 })
408 })
406
409
407 // Shutdown all LSP clients.
410 // Shutdown all LSP clients.
411+ shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
412+ defer cancel()
408 for name, client := range app.LSPClients.Seq2() {
413 for name, client := range app.LSPClients.Seq2() {
409 wg.Go(func() {
414 wg.Go(func() {
410− shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
411− defer cancel()
412− if err := client.Close(shutdownCtx); err != nil {
415+ if err := client.Close(shutdownCtx); err != nil &&
416+ !errors.Is(err, io.EOF) &&
417+ !errors.Is(err, context.Canceled) &&
418+ err.Error() != "signal: killed" {
413− slog.Error("Failed to shutdown LSP client", "name", name, "error", err)
419+ slog.Warn("Failed to shutdown LSP client", "name", name, "error", err)
414 }
420 }
415 })
421 })
416 }
422 }
internal/config/attribution_migration_test.go+1−3
@@ -1,8 +1,6 @@
1package config
1package config
2
2
3import (
3import (
4− "io"
5− "strings"
6 "testing"
4 "testing"
7
5
8 "github.com/stretchr/testify/require"
6 "github.com/stretchr/testify/require"
@@ -83,7 +81,7 @@ func TestAttributionMigration(t *testing.T) {
83 t.Run(tt.name, func(t *testing.T) {
81 t.Run(tt.name, func(t *testing.T) {
84 t.Parallel()
82 t.Parallel()
85
83
86− cfg, err := loadFromReaders([]io.Reader{strings.NewReader(tt.configJSON)})
84+ cfg, err := loadFromBytes([][]byte{[]byte(tt.configJSON)})
87 require.NoError(t, err)
85 require.NoError(t, err)
88
86
89 cfg.setDefaults(t.TempDir(), "")
87 cfg.setDefaults(t.TempDir(), "")
internal/config/config.go+8−7
@@ -253,6 +253,7 @@ type Options struct {
253 DataDirectory string `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data (relative to working directory),default=.crush,example=.crush"` // Relative to the cwd
253 DataDirectory string `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data (relative to working directory),default=.crush,example=.crush"` // Relative to the cwd
254 DisabledTools []string `json:"disabled_tools,omitempty" jsonschema:"description=List of built-in tools to disable and hide from the agent,example=bash,example=sourcegraph"`
254 DisabledTools []string `json:"disabled_tools,omitempty" jsonschema:"description=List of built-in tools to disable and hide from the agent,example=bash,example=sourcegraph"`
255 DisableProviderAutoUpdate bool `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"`
255 DisableProviderAutoUpdate bool `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"`
256+ DisableDefaultProviders bool `json:"disable_default_providers,omitempty" jsonschema:"description=Ignore all default/embedded providers. When enabled, providers must be fully specified in the config file with base_url, models, and api_key - no merging with defaults occurs,default=false"`
256 Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
257 Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
257 DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
258 DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
258 InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"`
259 InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"`
@@ -811,21 +812,21 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
811 for k, v := range c.ExtraHeaders {
812 for k, v := range c.ExtraHeaders {
812 req.Header.Set(k, v)
813 req.Header.Set(k, v)
813 }
814 }
814− b, err := client.Do(req)
815+ resp, err := client.Do(req)
815 if err != nil {
816 if err != nil {
816 return fmt.Errorf("failed to create request for provider %s: %w", c.ID, err)
817 return fmt.Errorf("failed to create request for provider %s: %w", c.ID, err)
817 }
818 }
819+ defer resp.Body.Close()
818 if c.ID == string(catwalk.InferenceProviderZAI) {
820 if c.ID == string(catwalk.InferenceProviderZAI) {
819− if b.StatusCode == http.StatusUnauthorized {
821+ if resp.StatusCode == http.StatusUnauthorized {
820− // for z.ai just check if the http response is not 401
822+ // For z.ai just check if the http response is not 401.
821− return fmt.Errorf("failed to connect to provider %s: %s", c.ID, b.Status)
823+ return fmt.Errorf("failed to connect to provider %s: %s", c.ID, resp.Status)
822 }
824 }
823 } else {
825 } else {
824− if b.StatusCode != http.StatusOK {
826+ if resp.StatusCode != http.StatusOK {
825− return fmt.Errorf("failed to connect to provider %s: %s", c.ID, b.Status)
827+ return fmt.Errorf("failed to connect to provider %s: %s", c.ID, resp.Status)
826 }
828 }
827 }
829 }
828− _ = b.Body.Close()
829 return nil
830 return nil
830}
831}
831
832
internal/config/load.go+29−28
@@ -5,7 +5,6 @@ import (
5 "context"
5 "context"
6 "encoding/json"
6 "encoding/json"
7 "fmt"
7 "fmt"
8− "io"
9 "log/slog"
8 "log/slog"
10 "maps"
9 "maps"
11 "os"
10 "os"
@@ -25,25 +24,11 @@ import (
25 "github.com/charmbracelet/crush/internal/home"
24 "github.com/charmbracelet/crush/internal/home"
26 "github.com/charmbracelet/crush/internal/log"
25 "github.com/charmbracelet/crush/internal/log"
27 powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
26 powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
27+ "github.com/qjebbs/go-jsons"
28)
28)
29
29
30const defaultCatwalkURL = "https://catwalk.charm.sh"
30const defaultCatwalkURL = "https://catwalk.charm.sh"
31
31
32−// LoadReader config via io.Reader.
33−func LoadReader(fd io.Reader) (*Config, error) {
34− data, err := io.ReadAll(fd)
35− if err != nil {
36− return nil, err
37− }
38−
39− var config Config
40− err = json.Unmarshal(data, &config)
41− if err != nil {
42− return nil, err
43− }
44− return &config, err
45−}
46−
47// Load loads the configuration from the default paths.
32// Load loads the configuration from the default paths.
48func Load(workingDir, dataDir string, debug bool) (*Config, error) {
33func Load(workingDir, dataDir string, debug bool) (*Config, error) {
49 configPaths := lookupConfigs(workingDir)
34 configPaths := lookupConfigs(workingDir)
@@ -137,6 +122,14 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
137 restore := PushPopCrushEnv()
122 restore := PushPopCrushEnv()
138 defer restore()
123 defer restore()
139
124
125+ // When disable_default_providers is enabled, skip all default/embedded
126+ // providers entirely. Users must fully specify any providers they want.
127+ // We skip to the custom provider validation loop which handles all
128+ // user-configured providers uniformly.
129+ if c.Options.DisableDefaultProviders {
130+ knownProviders = nil
131+ }
132+
140 for _, p := range knownProviders {
133 for _, p := range knownProviders {
141 knownProviderNames[string(p.ID)] = true
134 knownProviderNames[string(p.ID)] = true
142 config, configExists := c.Providers.Get(string(p.ID))
135 config, configExists := c.Providers.Get(string(p.ID))
@@ -377,6 +370,10 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
377 c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
370 c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
378 }
371 }
379
372
373+ if str, ok := os.LookupEnv("CRUSH_DISABLE_DEFAULT_PROVIDERS"); ok {
374+ c.Options.DisableDefaultProviders, _ = strconv.ParseBool(str)
375+ }
376+
380 if c.Options.Attribution == nil {
377 if c.Options.Attribution == nil {
381 c.Options.Attribution = &Attribution{
378 c.Options.Attribution = &Attribution{
382 TrailerStyle: TrailerStyleAssistedBy,
379 TrailerStyle: TrailerStyleAssistedBy,
@@ -632,35 +629,39 @@ func lookupConfigs(cwd string) []string {
632}
629}
633
630
634func loadFromConfigPaths(configPaths []string) (*Config, error) {
631func loadFromConfigPaths(configPaths []string) (*Config, error) {
635− var configs []io.Reader
632+ var configs [][]byte
636
633
637 for _, path := range configPaths {
634 for _, path := range configPaths {
638− fd, err := os.Open(path)
635+ data, err := os.ReadFile(path)
639 if err != nil {
636 if err != nil {
640 if os.IsNotExist(err) {
637 if os.IsNotExist(err) {
641 continue
638 continue
642 }
639 }
643 return nil, fmt.Errorf("failed to open config file %s: %w", path, err)
640 return nil, fmt.Errorf("failed to open config file %s: %w", path, err)
644 }
641 }
645− defer fd.Close()
642+ if len(data) == 0 {
646−
643+ continue
644+ }
647− configs = append(configs, fd)
645+ configs = append(configs, data)
648 }
646 }
649
647
650− return loadFromReaders(configs)
648+ return loadFromBytes(configs)
651}
649}
652
650
653−func loadFromReaders(readers []io.Reader) (*Config, error) {
651+func loadFromBytes(configs [][]byte) (*Config, error) {
654− if len(readers) == 0 {
652+ if len(configs) == 0 {
655 return &Config{}, nil
653 return &Config{}, nil
656 }
654 }
657
655
658− merged, err := Merge(readers)
656+ data, err := jsons.Merge(configs)
659 if err != nil {
657 if err != nil {
660− return nil, fmt.Errorf("failed to merge configuration readers: %w", err)
658+ return nil, err
661 }
659 }
662−
660+ var config Config
663− return LoadReader(merged)
661+ if err := json.Unmarshal(data, &config); err != nil {
662+ return nil, err
663+ }
664+ return &config, nil
664}
665}
665
666
666func hasVertexCredentials(env env.Env) bool {
667func hasVertexCredentials(env env.Env) bool {
internal/config/load_bench_test.gonew file+103
@@ -0,0 +1,103 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func BenchmarkLoadFromConfigPaths(b *testing.B) {
10+ // Create temp config files with realistic content.
11+ tmpDir := b.TempDir()
12+
13+ globalConfig := filepath.Join(tmpDir, "global.json")
14+ localConfig := filepath.Join(tmpDir, "local.json")
15+
16+ globalContent := []byte(`{
17+ "providers": {
18+ "openai": {
19+ "api_key": "$OPENAI_API_KEY",
20+ "base_url": "https://api.openai.com/v1"
21+ },
22+ "anthropic": {
23+ "api_key": "$ANTHROPIC_API_KEY",
24+ "base_url": "https://api.anthropic.com"
25+ }
26+ },
27+ "options": {
28+ "tui": {
29+ "theme": "dark"
30+ }
31+ }
32+ }`)
33+
34+ localContent := []byte(`{
35+ "providers": {
36+ "openai": {
37+ "api_key": "sk-override-key"
38+ }
39+ },
40+ "options": {
41+ "context_paths": ["README.md", "AGENTS.md"]
42+ }
43+ }`)
44+
45+ if err := os.WriteFile(globalConfig, globalContent, 0o644); err != nil {
46+ b.Fatal(err)
47+ }
48+ if err := os.WriteFile(localConfig, localContent, 0o644); err != nil {
49+ b.Fatal(err)
50+ }
51+
52+ configPaths := []string{globalConfig, localConfig}
53+
54+ b.ReportAllocs()
55+ for b.Loop() {
56+ _, err := loadFromConfigPaths(configPaths)
57+ if err != nil {
58+ b.Fatal(err)
59+ }
60+ }
61+}
62+
63+func BenchmarkLoadFromConfigPaths_MissingFiles(b *testing.B) {
64+ // Test with mix of existing and non-existing paths.
65+ tmpDir := b.TempDir()
66+
67+ existingConfig := filepath.Join(tmpDir, "exists.json")
68+ content := []byte(`{"options": {"tui": {"theme": "dark"}}}`)
69+ if err := os.WriteFile(existingConfig, content, 0o644); err != nil {
70+ b.Fatal(err)
71+ }
72+
73+ configPaths := []string{
74+ filepath.Join(tmpDir, "nonexistent1.json"),
75+ existingConfig,
76+ filepath.Join(tmpDir, "nonexistent2.json"),
77+ }
78+
79+ b.ReportAllocs()
80+ for b.Loop() {
81+ _, err := loadFromConfigPaths(configPaths)
82+ if err != nil {
83+ b.Fatal(err)
84+ }
85+ }
86+}
87+
88+func BenchmarkLoadFromConfigPaths_Empty(b *testing.B) {
89+ // Test with no config files.
90+ tmpDir := b.TempDir()
91+ configPaths := []string{
92+ filepath.Join(tmpDir, "nonexistent1.json"),
93+ filepath.Join(tmpDir, "nonexistent2.json"),
94+ }
95+
96+ b.ReportAllocs()
97+ for b.Loop() {
98+ _, err := loadFromConfigPaths(configPaths)
99+ if err != nil {
100+ b.Fatal(err)
101+ }
102+ }
103+}
internal/config/load_test.go+216−6
@@ -5,7 +5,6 @@ import (
5 "log/slog"
5 "log/slog"
6 "os"
6 "os"
7 "path/filepath"
7 "path/filepath"
8− "strings"
9 "testing"
8 "testing"
10
9
11 "github.com/charmbracelet/catwalk/pkg/catwalk"
10 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -22,12 +21,12 @@ func TestMain(m *testing.M) {
22 os.Exit(exitVal)
21 os.Exit(exitVal)
23}
22}
24
23
25−func TestConfig_LoadFromReaders(t *testing.T) {
24+func TestConfig_LoadFromBytes(t *testing.T) {
26− data1 := strings.NewReader(`{"providers": {"openai": {"api_key": "key1", "base_url": "https://api.openai.com/v1"}}}`)
25+ data1 := []byte(`{"providers": {"openai": {"api_key": "key1", "base_url": "https://api.openai.com/v1"}}}`)
27− data2 := strings.NewReader(`{"providers": {"openai": {"api_key": "key2", "base_url": "https://api.openai.com/v2"}}}`)
26+ data2 := []byte(`{"providers": {"openai": {"api_key": "key2", "base_url": "https://api.openai.com/v2"}}}`)
28− data3 := strings.NewReader(`{"providers": {"openai": {}}}`)
27+ data3 := []byte(`{"providers": {"openai": {}}}`)
29
28
30− loadedConfig, err := loadFromReaders([]io.Reader{data1, data2, data3})
29+ loadedConfig, err := loadFromBytes([][]byte{data1, data2, data3})
31
30
32 require.NoError(t, err)
31 require.NoError(t, err)
33 require.NotNil(t, loadedConfig)
32 require.NotNil(t, loadedConfig)
@@ -1095,6 +1094,217 @@ func TestConfig_defaultModelSelection(t *testing.T) {
1095 })
1094 })
1096}
1095}
1097
1096
1097+func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
1098+ t.Run("when enabled, ignores all default providers and requires full specification", func(t *testing.T) {
1099+ knownProviders := []catwalk.Provider{
1100+ {
1101+ ID: "openai",
1102+ APIKey: "$OPENAI_API_KEY",
1103+ APIEndpoint: "https://api.openai.com/v1",
1104+ Models: []catwalk.Model{{
1105+ ID: "gpt-4",
1106+ }},
1107+ },
1108+ }
1109+
1110+ // User references openai but doesn't fully specify it (no base_url, no
1111+ // models). This should be rejected because disable_default_providers
1112+ // treats all providers as custom.
1113+ cfg := &Config{
1114+ Options: &Options{
1115+ DisableDefaultProviders: true,
1116+ },
1117+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1118+ "openai": {
1119+ APIKey: "$OPENAI_API_KEY",
1120+ },
1121+ }),
1122+ }
1123+ cfg.setDefaults("/tmp", "")
1124+
1125+ env := env.NewFromMap(map[string]string{
1126+ "OPENAI_API_KEY": "test-key",
1127+ })
1128+ resolver := NewEnvironmentVariableResolver(env)
1129+ err := cfg.configureProviders(env, resolver, knownProviders)
1130+ require.NoError(t, err)
1131+
1132+ // openai should NOT be present because it lacks base_url and models.
1133+ require.Equal(t, 0, cfg.Providers.Len())
1134+ _, exists := cfg.Providers.Get("openai")
1135+ require.False(t, exists, "openai should not be present without full specification")
1136+ })
1137+
1138+ t.Run("when enabled, fully specified providers work", func(t *testing.T) {
1139+ knownProviders := []catwalk.Provider{
1140+ {
1141+ ID: "openai",
1142+ APIKey: "$OPENAI_API_KEY",
1143+ APIEndpoint: "https://api.openai.com/v1",
1144+ Models: []catwalk.Model{{
1145+ ID: "gpt-4",
1146+ }},
1147+ },
1148+ }
1149+
1150+ // User fully specifies their provider.
1151+ cfg := &Config{
1152+ Options: &Options{
1153+ DisableDefaultProviders: true,
1154+ },
1155+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1156+ "my-llm": {
1157+ APIKey: "$MY_API_KEY",
1158+ BaseURL: "https://my-llm.example.com/v1",
1159+ Models: []catwalk.Model{{
1160+ ID: "my-model",
1161+ }},
1162+ },
1163+ }),
1164+ }
1165+ cfg.setDefaults("/tmp", "")
1166+
1167+ env := env.NewFromMap(map[string]string{
1168+ "MY_API_KEY": "test-key",
1169+ "OPENAI_API_KEY": "test-key",
1170+ })
1171+ resolver := NewEnvironmentVariableResolver(env)
1172+ err := cfg.configureProviders(env, resolver, knownProviders)
1173+ require.NoError(t, err)
1174+
1175+ // Only fully specified provider should be present.
1176+ require.Equal(t, 1, cfg.Providers.Len())
1177+ provider, exists := cfg.Providers.Get("my-llm")
1178+ require.True(t, exists, "my-llm should be present")
1179+ require.Equal(t, "https://my-llm.example.com/v1", provider.BaseURL)
1180+ require.Len(t, provider.Models, 1)
1181+
1182+ // Default openai should NOT be present.
1183+ _, exists = cfg.Providers.Get("openai")
1184+ require.False(t, exists, "openai should not be present")
1185+ })
1186+
1187+ t.Run("when disabled, includes all known providers with valid credentials", func(t *testing.T) {
1188+ knownProviders := []catwalk.Provider{
1189+ {
1190+ ID: "openai",
1191+ APIKey: "$OPENAI_API_KEY",
1192+ APIEndpoint: "https://api.openai.com/v1",
1193+ Models: []catwalk.Model{{
1194+ ID: "gpt-4",
1195+ }},
1196+ },
1197+ {
1198+ ID: "anthropic",
1199+ APIKey: "$ANTHROPIC_API_KEY",
1200+ APIEndpoint: "https://api.anthropic.com/v1",
1201+ Models: []catwalk.Model{{
1202+ ID: "claude-3",
1203+ }},
1204+ },
1205+ }
1206+
1207+ // User only configures openai, both API keys are available, but option
1208+ // is disabled.
1209+ cfg := &Config{
1210+ Options: &Options{
1211+ DisableDefaultProviders: false,
1212+ },
1213+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1214+ "openai": {
1215+ APIKey: "$OPENAI_API_KEY",
1216+ },
1217+ }),
1218+ }
1219+ cfg.setDefaults("/tmp", "")
1220+
1221+ env := env.NewFromMap(map[string]string{
1222+ "OPENAI_API_KEY": "test-key",
1223+ "ANTHROPIC_API_KEY": "test-key",
1224+ })
1225+ resolver := NewEnvironmentVariableResolver(env)
1226+ err := cfg.configureProviders(env, resolver, knownProviders)
1227+ require.NoError(t, err)
1228+
1229+ // Both providers should be present.
1230+ require.Equal(t, 2, cfg.Providers.Len())
1231+ _, exists := cfg.Providers.Get("openai")
1232+ require.True(t, exists, "openai should be present")
1233+ _, exists = cfg.Providers.Get("anthropic")
1234+ require.True(t, exists, "anthropic should be present")
1235+ })
1236+
1237+ t.Run("when enabled, provider missing models is rejected", func(t *testing.T) {
1238+ cfg := &Config{
1239+ Options: &Options{
1240+ DisableDefaultProviders: true,
1241+ },
1242+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1243+ "my-llm": {
1244+ APIKey: "test-key",
1245+ BaseURL: "https://my-llm.example.com/v1",
1246+ Models: []catwalk.Model{}, // No models.
1247+ },
1248+ }),
1249+ }
1250+ cfg.setDefaults("/tmp", "")
1251+
1252+ env := env.NewFromMap(map[string]string{})
1253+ resolver := NewEnvironmentVariableResolver(env)
1254+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
1255+ require.NoError(t, err)
1256+
1257+ // Provider should be rejected for missing models.
1258+ require.Equal(t, 0, cfg.Providers.Len())
1259+ })
1260+
1261+ t.Run("when enabled, provider missing base_url is rejected", func(t *testing.T) {
1262+ cfg := &Config{
1263+ Options: &Options{
1264+ DisableDefaultProviders: true,
1265+ },
1266+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1267+ "my-llm": {
1268+ APIKey: "test-key",
1269+ Models: []catwalk.Model{{ID: "model"}},
1270+ // No BaseURL.
1271+ },
1272+ }),
1273+ }
1274+ cfg.setDefaults("/tmp", "")
1275+
1276+ env := env.NewFromMap(map[string]string{})
1277+ resolver := NewEnvironmentVariableResolver(env)
1278+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
1279+ require.NoError(t, err)
1280+
1281+ // Provider should be rejected for missing base_url.
1282+ require.Equal(t, 0, cfg.Providers.Len())
1283+ })
1284+}
1285+
1286+func TestConfig_setDefaultsDisableDefaultProvidersEnvVar(t *testing.T) {
1287+ t.Run("sets option from environment variable", func(t *testing.T) {
1288+ t.Setenv("CRUSH_DISABLE_DEFAULT_PROVIDERS", "true")
1289+
1290+ cfg := &Config{}
1291+ cfg.setDefaults("/tmp", "")
1292+
1293+ require.True(t, cfg.Options.DisableDefaultProviders)
1294+ })
1295+
1296+ t.Run("does not override when env var is not set", func(t *testing.T) {
1297+ cfg := &Config{
1298+ Options: &Options{
1299+ DisableDefaultProviders: true,
1300+ },
1301+ }
1302+ cfg.setDefaults("/tmp", "")
1303+
1304+ require.True(t, cfg.Options.DisableDefaultProviders)
1305+ })
1306+}
1307+
1098func TestConfig_configureSelectedModels(t *testing.T) {
1308func TestConfig_configureSelectedModels(t *testing.T) {
1099 t.Run("should override defaults", func(t *testing.T) {
1309 t.Run("should override defaults", func(t *testing.T) {
1100 knownProviders := []catwalk.Provider{
1310 knownProviders := []catwalk.Provider{
internal/config/merge.godeleted−16
@@ -1,16 +0,0 @@
1−package config
2−
3−import (
4− "bytes"
5− "io"
6−
7− "github.com/qjebbs/go-jsons"
8−)
9−
10−func Merge(data []io.Reader) (io.Reader, error) {
11− got, err := jsons.Merge(data)
12− if err != nil {
13− return nil, err
14− }
15− return bytes.NewReader(got), nil
16−}
internal/config/merge_test.godeleted−27
@@ -1,27 +0,0 @@
1−package config
2−
3−import (
4− "io"
5− "strings"
6− "testing"
7−)
8−
9−func TestMerge(t *testing.T) {
10− data1 := strings.NewReader(`{"foo": "bar"}`)
11− data2 := strings.NewReader(`{"baz": "qux"}`)
12−
13− merged, err := Merge([]io.Reader{data1, data2})
14− if err != nil {
15− t.Fatalf("expected no error, got %v", err)
16− }
17−
18− expected := `{"foo":"bar","baz":"qux"}`
19− got, err := io.ReadAll(merged)
20− if err != nil {
21− t.Fatalf("expected no error reading merged data, got %v", err)
22− }
23−
24− if string(got) != expected {
25− t.Errorf("expected %s, got %s", expected, string(got))
26− }
27−}
internal/csync/maps.go+1−1
@@ -33,8 +33,8 @@ func NewLazyMap[K comparable, V any](load func() map[K]V) *Map[K, V] {
33 m := &Map[K, V]{}
33 m := &Map[K, V]{}
34 m.mu.Lock()
34 m.mu.Lock()
35 go func() {
35 go func() {
36+ defer m.mu.Unlock()
36 m.inner = load()
37 m.inner = load()
37− m.mu.Unlock()
38 }()
38 }()
39 return m
39 return m
40}
40}
internal/lsp/client.go−4
@@ -153,10 +153,6 @@ func (c *Client) Initialize(ctx context.Context, workspaceDir string) (*protocol
153
153
154// Close closes the LSP client.
154// Close closes the LSP client.
155func (c *Client) Close(ctx context.Context) error {
155func (c *Client) Close(ctx context.Context) error {
156− // Try to close all open files first
157− ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
158− defer cancel()
159−
160 c.CloseAllFiles(ctx)
156 c.CloseAllFiles(ctx)
161
157
162 // Shutdown and exit the client
158 // Shutdown and exit the client
internal/message/content.go+10−6
@@ -437,23 +437,27 @@ func (m *Message) AddBinary(mimeType string, data []byte) {
437}
437}
438
438
439func PromptWithTextAttachments(prompt string, attachments []Attachment) string {
439func PromptWithTextAttachments(prompt string, attachments []Attachment) string {
440+ var sb strings.Builder
441+ sb.WriteString(prompt)
440 addedAttachments := false
442 addedAttachments := false
441 for _, content := range attachments {
443 for _, content := range attachments {
442 if !content.IsText() {
444 if !content.IsText() {
443 continue
445 continue
444 }
446 }
445 if !addedAttachments {
447 if !addedAttachments {
446− prompt += "\n<system_info>The files below have been attached by the user, consider them in your response</system_info>\n"
448+ sb.WriteString("\n<system_info>The files below have been attached by the user, consider them in your response</system_info>\n")
447 addedAttachments = true
449 addedAttachments = true
448 }
450 }
449− tag := `<file>\n`
450 if content.FilePath != "" {
451 if content.FilePath != "" {
451− tag = fmt.Sprintf("<file path='%s'>\n", content.FilePath)
452+ fmt.Fprintf(&sb, "<file path='%s'>\n", content.FilePath)
453+ } else {
454+ sb.WriteString("<file>\n")
452 }
455 }
453− prompt += tag
456+ sb.WriteString("\n")
454− prompt += "\n" + string(content.Content) + "\n</file>\n"
457+ sb.Write(content.Content)
458+ sb.WriteString("\n</file>\n")
455 }
459 }
456− return prompt
460+ return sb.String()
457}
461}
458
462
459func (m *Message) ToAIMessage() []fantasy.Message {
463func (m *Message) ToAIMessage() []fantasy.Message {
internal/message/content_test.gonew file+45
@@ -0,0 +1,45 @@
1+package message
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+)
8+
9+func makeTestAttachments(n int, contentSize int) []Attachment {
10+ attachments := make([]Attachment, n)
11+ content := []byte(strings.Repeat("x", contentSize))
12+ for i := range n {
13+ attachments[i] = Attachment{
14+ FilePath: fmt.Sprintf("/path/to/file%d.txt", i),
15+ MimeType: "text/plain",
16+ Content: content,
17+ }
18+ }
19+ return attachments
20+}
21+
22+func BenchmarkPromptWithTextAttachments(b *testing.B) {
23+ cases := []struct {
24+ name string
25+ numFiles int
26+ contentSize int
27+ }{
28+ {"1file_100bytes", 1, 100},
29+ {"5files_1KB", 5, 1024},
30+ {"10files_10KB", 10, 10 * 1024},
31+ {"20files_50KB", 20, 50 * 1024},
32+ }
33+
34+ for _, tc := range cases {
35+ attachments := makeTestAttachments(tc.numFiles, tc.contentSize)
36+ prompt := "Process these files"
37+
38+ b.Run(tc.name, func(b *testing.B) {
39+ b.ReportAllocs()
40+ for range b.N {
41+ _ = PromptWithTextAttachments(prompt, attachments)
42+ }
43+ })
44+ }
45+}
internal/permission/permission.go+13−8
@@ -47,7 +47,7 @@ type Service interface {
47 GrantPersistent(permission PermissionRequest)
47 GrantPersistent(permission PermissionRequest)
48 Grant(permission PermissionRequest)
48 Grant(permission PermissionRequest)
49 Deny(permission PermissionRequest)
49 Deny(permission PermissionRequest)
50− Request(opts CreatePermissionRequest) bool
50+ Request(ctx context.Context, opts CreatePermissionRequest) (bool, error)
51 AutoApproveSession(sessionID string)
51 AutoApproveSession(sessionID string)
52 SetSkipRequests(skip bool)
52 SetSkipRequests(skip bool)
53 SkipRequests() bool
53 SkipRequests() bool
@@ -122,9 +122,9 @@ func (s *permissionService) Deny(permission PermissionRequest) {
122 }
122 }
123}
123}
124
124
125−func (s *permissionService) Request(opts CreatePermissionRequest) bool {
125+func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) {
126 if s.skip {
126 if s.skip {
127− return true
127+ return true, nil
128 }
128 }
129
129
130 // tell the UI that a permission was requested
130 // tell the UI that a permission was requested
@@ -137,7 +137,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
137 // Check if the tool/action combination is in the allowlist
137 // Check if the tool/action combination is in the allowlist
138 commandKey := opts.ToolName + ":" + opts.Action
138 commandKey := opts.ToolName + ":" + opts.Action
139 if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
139 if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
140− return true
140+ return true, nil
141 }
141 }
142
142
143 s.autoApproveSessionsMu.RLock()
143 s.autoApproveSessionsMu.RLock()
@@ -145,7 +145,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
145 s.autoApproveSessionsMu.RUnlock()
145 s.autoApproveSessionsMu.RUnlock()
146
146
147 if autoApprove {
147 if autoApprove {
148− return true
148+ return true, nil
149 }
149 }
150
150
151 fileInfo, err := os.Stat(opts.Path)
151 fileInfo, err := os.Stat(opts.Path)
@@ -176,7 +176,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
176 for _, p := range s.sessionPermissions {
176 for _, p := range s.sessionPermissions {
177 if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
177 if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
178 s.sessionPermissionsMu.RUnlock()
178 s.sessionPermissionsMu.RUnlock()
179− return true
179+ return true, nil
180 }
180 }
181 }
181 }
182 s.sessionPermissionsMu.RUnlock()
182 s.sessionPermissionsMu.RUnlock()
@@ -185,7 +185,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
185 for _, p := range s.sessionPermissions {
185 for _, p := range s.sessionPermissions {
186 if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
186 if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
187 s.sessionPermissionsMu.RUnlock()
187 s.sessionPermissionsMu.RUnlock()
188− return true
188+ return true, nil
189 }
189 }
190 }
190 }
191 s.sessionPermissionsMu.RUnlock()
191 s.sessionPermissionsMu.RUnlock()
@@ -199,7 +199,12 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
199 // Publish the request
199 // Publish the request
200 s.Publish(pubsub.CreatedEvent, permission)
200 s.Publish(pubsub.CreatedEvent, permission)
201
201
202+ select {
203+ case <-ctx.Done():
202− return <-respCh
204+ return false, ctx.Err()
205+ case granted := <-respCh:
206+ return granted, nil
207+ }
203}
208}
204
209
205func (s *permissionService) AutoApproveSession(sessionID string) {
210func (s *permissionService) AutoApproveSession(sessionID string) {
internal/permission/permission_test.go+14−8
@@ -5,6 +5,7 @@ import (
5 "testing"
5 "testing"
6
6
7 "github.com/stretchr/testify/assert"
7 "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
8)
9)
9
10
10func TestPermissionService_AllowedCommands(t *testing.T) {
11func TestPermissionService_AllowedCommands(t *testing.T) {
@@ -81,14 +82,16 @@ func TestPermissionService_AllowedCommands(t *testing.T) {
81func TestPermissionService_SkipMode(t *testing.T) {
82func TestPermissionService_SkipMode(t *testing.T) {
82 service := NewPermissionService("/tmp", true, []string{})
83 service := NewPermissionService("/tmp", true, []string{})
83
84
84− result := service.Request(CreatePermissionRequest{
85+ result, err := service.Request(t.Context(), CreatePermissionRequest{
85 SessionID: "test-session",
86 SessionID: "test-session",
86 ToolName: "bash",
87 ToolName: "bash",
87 Action: "execute",
88 Action: "execute",
88 Description: "test command",
89 Description: "test command",
89 Path: "/tmp",
90 Path: "/tmp",
90 })
91 })
91−
92+ if err != nil {
93+ t.Errorf("unexpected error: %v", err)
94+ }
92 if !result {
95 if !result {
93 t.Error("expected permission to be granted in skip mode")
96 t.Error("expected permission to be granted in skip mode")
94 }
97 }
@@ -115,7 +118,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
115
118
116 go func() {
119 go func() {
117 defer wg.Done()
120 defer wg.Done()
118− result1 = service.Request(req1)
121+ result1, _ = service.Request(t.Context(), req1)
119 }()
122 }()
120
123
121 var permissionReq PermissionRequest
124 var permissionReq PermissionRequest
@@ -136,7 +139,8 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
136 Params: map[string]string{"file": "test.txt"},
139 Params: map[string]string{"file": "test.txt"},
137 Path: "/tmp/test.txt",
140 Path: "/tmp/test.txt",
138 }
141 }
139− result2 := service.Request(req2)
142+ result2, err := service.Request(t.Context(), req2)
143+ require.NoError(t, err)
140 assert.True(t, result2, "Second request should be auto-approved")
144 assert.True(t, result2, "Second request should be auto-approved")
141 })
145 })
142 t.Run("Sequential requests with temporary grants", func(t *testing.T) {
146 t.Run("Sequential requests with temporary grants", func(t *testing.T) {
@@ -156,7 +160,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
156 var wg sync.WaitGroup
160 var wg sync.WaitGroup
157
161
158 wg.Go(func() {
162 wg.Go(func() {
159− result1 = service.Request(req)
163+ result1, _ = service.Request(t.Context(), req)
160 })
164 })
161
165
162 var permissionReq PermissionRequest
166 var permissionReq PermissionRequest
@@ -170,7 +174,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
170 var result2 bool
174 var result2 bool
171
175
172 wg.Go(func() {
176 wg.Go(func() {
173− result2 = service.Request(req)
177+ result2, _ = service.Request(t.Context(), req)
174 })
178 })
175
179
176 event = <-events
180 event = <-events
@@ -215,7 +219,8 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
215 wg.Add(1)
219 wg.Add(1)
216 go func(index int, request CreatePermissionRequest) {
220 go func(index int, request CreatePermissionRequest) {
217 defer wg.Done()
221 defer wg.Done()
218− results = append(results, service.Request(request))
222+ result, _ := service.Request(t.Context(), request)
223+ results = append(results, result)
219 }(i, req)
224 }(i, req)
220 }
225 }
221
226
@@ -241,7 +246,8 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
241 assert.Equal(t, 2, grantedCount, "Should have 2 granted and 1 denied")
246 assert.Equal(t, 2, grantedCount, "Should have 2 granted and 1 denied")
242 secondReq := requests[1]
247 secondReq := requests[1]
243 secondReq.Description = "Repeat of second request"
248 secondReq.Description = "Repeat of second request"
244− result := service.Request(secondReq)
249+ result, err := service.Request(t.Context(), secondReq)
250+ require.NoError(t, err)
245 assert.True(t, result, "Repeated request should be auto-approved due to persistent permission")
251 assert.True(t, result, "Repeated request should be auto-approved due to persistent permission")
246 })
252 })
247}
253}
internal/shell/background.go+19−7
@@ -4,6 +4,7 @@ import (
4 "bytes"
4 "bytes"
5 "context"
5 "context"
6 "fmt"
6 "fmt"
7+ "slices"
7 "sync"
8 "sync"
8 "sync/atomic"
9 "sync/atomic"
9 "time"
10 "time"
@@ -163,15 +164,26 @@ func (m *BackgroundShellManager) Cleanup() int {
163
164
164// KillAll terminates all background shells.
165// KillAll terminates all background shells.
165func (m *BackgroundShellManager) KillAll() {
166func (m *BackgroundShellManager) KillAll() {
166− shells := make([]*BackgroundShell, 0, m.shells.Len())
167+ shells := slices.Collect(m.shells.Seq())
167− for shell := range m.shells.Seq() {
168− shells = append(shells, shell)
169− }
170 m.shells.Reset(map[string]*BackgroundShell{})
168 m.shells.Reset(map[string]*BackgroundShell{})
169+ done := make(chan struct{}, 1)
170+ go func() {
171+ var wg sync.WaitGroup
172+ for _, shell := range shells {
173+ wg.Go(func() {
174+ shell.cancel()
175+ <-shell.done
176+ })
177+ }
178+ wg.Wait()
179+ done <- struct{}{}
180+ }()
171
181
172− for _, shell := range shells {
182+ select {
173− shell.cancel()
183+ case <-done:
174− <-shell.done
184+ return
185+ case <-time.After(time.Second * 5):
186+ return
175 }
187 }
176}
188}
177
189
internal/shell/shell.go+5−3
@@ -247,12 +247,14 @@ func (s *Shell) newInterp(stdout, stderr io.Writer) (*interp.Runner, error) {
247 )
247 )
248}
248}
249
249
250−// updateShellFromRunner updates the shell from the interpreter after execution
250+// updateShellFromRunner updates the shell from the interpreter after execution.
251func (s *Shell) updateShellFromRunner(runner *interp.Runner) {
251func (s *Shell) updateShellFromRunner(runner *interp.Runner) {
252 s.cwd = runner.Dir
252 s.cwd = runner.Dir
253− s.env = nil
253+ s.env = s.env[:0]
254 for name, vr := range runner.Vars {
254 for name, vr := range runner.Vars {
255+ if vr.Exported {
255− s.env = append(s.env, fmt.Sprintf("%s=%s", name, vr.Str))
256+ s.env = append(s.env, name+"="+vr.Str)
257+ }
256 }
258 }
257}
259}
258
260
internal/skills/skills.go+1−1
@@ -147,7 +147,7 @@ func Discover(paths []string) []*Skill {
147 slog.Warn("Skill validation failed", "path", path, "error", err)
147 slog.Warn("Skill validation failed", "path", path, "error", err)
148 return nil
148 return nil
149 }
149 }
150− slog.Info("Successfully loaded skill", "name", skill.Name, "path", path)
150+ slog.Debug("Successfully loaded skill", "name", skill.Name, "path", path)
151 mu.Lock()
151 mu.Lock()
152 skills = append(skills, skill)
152 skills = append(skills, skill)
153 mu.Unlock()
153 mu.Unlock()
schema.json+5
@@ -421,6 +421,11 @@
421 "description": "Disable providers auto-update",
421 "description": "Disable providers auto-update",
422 "default": false
422 "default": false
423 },
423 },
424+ "disable_default_providers": {
425+ "type": "boolean",
426+ "description": "Ignore all default/embedded providers. When enabled",
427+ "default": false
428+ },
424 "attribution": {
429 "attribution": {
425 "$ref": "#/$defs/Attribution",
430 "$ref": "#/$defs/Attribution",
426 "description": "Attribution settings for generated content"
431 "description": "Attribution settings for generated content"