Merge remote-tracking branch 'origin/main' into feat/docker-mcp-integration

Christian Rocha created

Change summary

.github/cla-signatures.json           |  40 +++
.github/labeler.yml                   |   2 
.github/workflows/build.yml           |   2 
.github/workflows/lint.yml            |   2 
.github/workflows/schema-update.yml   |   2 
.github/workflows/security.yml        |   2 
.github/workflows/snapshot.yml        |   2 
.goreleaser.yml                       |   2 
go.mod                                |  53 +--
go.sum                                | 114 ++++----
internal/agent/agent_tool.go          |  52 ---
internal/agent/agentic_fetch_tool.go  |  55 ---
internal/agent/common_test.go         |   9 
internal/agent/coordinator.go         | 116 +++++++
internal/agent/coordinator_test.go    | 385 +++++++++++++++++++++++++++++
internal/agent/hyper/provider.json    |   0 
internal/agent/tools/context_test.go  | 233 +++++++++++++++++
internal/agent/tools/diagnostics.go   |  52 +++
internal/agent/tools/job_output.go    |   5 
internal/agent/tools/job_output.md    |   3 
internal/agent/tools/mcp/resources.go |   2 
internal/agent/tools/references.go    |  12 
internal/agent/tools/search.go        |   8 
internal/agent/tools/sourcegraph.go   |  16 
internal/agent/tools/tools.go         |  52 +--
internal/agent/tools/view.go          |  61 +--
internal/agent/tools/view_test.go     |  87 ++++++
internal/agent/tools/web_fetch.go     |   6 
internal/cmd/stats.go                 |   3 
internal/config/config.go             |   2 
internal/event/all.go                 |   4 
internal/event/event.go               |   2 
internal/lsp/client.go                |  49 ++-
internal/lsp/handlers.go              |  27 +
internal/lsp/manager.go               |  18 
internal/lsp/util/edit.go             |  69 ++++-
internal/lsp/util/edit_test.go        | 376 ++++++++++++++++++++++++++++
internal/shell/background.go          |   9 
internal/shell/background_test.go     |  25 +
internal/ui/chat/tools.go             |   4 
internal/ui/model/status.go           |   6 
internal/ui/model/ui.go               |  31 +
42 files changed, 1,647 insertions(+), 353 deletions(-)

Detailed changes

.github/cla-signatures.json 🔗

@@ -1287,6 +1287,46 @@
       "created_at": "2026-02-23T13:12:50Z",
       "repoId": 987670088,
       "pullRequestNo": 2293
+    },
+    {
+      "name": "mavaa",
+      "id": 1224973,
+      "comment_id": 3966463081,
+      "created_at": "2026-02-26T12:56:51Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2314
+    },
+    {
+      "name": "aisk",
+      "id": 699636,
+      "comment_id": 3968065934,
+      "created_at": "2026-02-26T17:23:44Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2315
+    },
+    {
+      "name": "detro",
+      "id": 114508,
+      "comment_id": 3976601253,
+      "created_at": "2026-02-28T07:36:40Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2326
+    },
+    {
+      "name": "taoeffect",
+      "id": 138706,
+      "comment_id": 3979067985,
+      "created_at": "2026-03-01T04:22:00Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2333
+    },
+    {
+      "name": "vmfu",
+      "id": 80844805,
+      "comment_id": 3980616480,
+      "created_at": "2026-03-01T17:51:04Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2337
     }
   ]
 }

.github/labeler.yml 🔗

@@ -55,6 +55,8 @@
   - "/gemini/i"
 "provider: google vertex":
   - "/vertex/i"
+"provider: hyper":
+  - "/hyper/i"
 "provider: kimi":
   - "/kimi/i"
 "provider: minimax":

.github/workflows/build.yml 🔗

@@ -18,7 +18,7 @@ jobs:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
         with:
           persist-credentials: false
-      - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
+      - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
         with:
           go-version-file: go.mod
       - run: go mod tidy

.github/workflows/lint.yml 🔗

@@ -8,5 +8,5 @@ jobs:
     uses: charmbracelet/meta/.github/workflows/lint.yml@main
     with:
       golangci_path: .golangci.yml
-      golangci_version: v2.9
+      golangci_version: v2.10
       timeout: 10m

.github/workflows/schema-update.yml 🔗

@@ -14,7 +14,7 @@ jobs:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
         with:
           token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
-      - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
+      - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
         with:
           go-version-file: go.mod
       - run: go run . schema > ./schema.json

.github/workflows/security.yml 🔗

@@ -65,7 +65,7 @@ jobs:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
         with:
           persist-credentials: false
-      - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
+      - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
         with:
           go-version: 1.26.0
       - name: Install govulncheck

.github/workflows/snapshot.yml 🔗

@@ -22,7 +22,7 @@ jobs:
         with:
           fetch-depth: 0
           persist-credentials: false
-      - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
+      - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
         with:
           go-version-file: go.mod
       - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0

.goreleaser.yml 🔗

@@ -297,6 +297,8 @@ winget:
           owner: microsoft
           name: winget-pkgs
           branch: master
+        body: |
+          /cc @andreynering
 
 changelog:
   sort: asc

go.mod 🔗

@@ -5,8 +5,8 @@ go 1.26.0
 require (
 	charm.land/bubbles/v2 v2.0.0
 	charm.land/bubbletea/v2 v2.0.0
-	charm.land/catwalk v0.21.1
-	charm.land/fantasy v0.9.0
+	charm.land/catwalk v0.25.3
+	charm.land/fantasy v0.11.0
 	charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b
 	charm.land/lipgloss/v2 v2.0.0
 	charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da
@@ -31,21 +31,21 @@ require (
 	github.com/charmbracelet/x/exp/ordered v0.1.0
 	github.com/charmbracelet/x/exp/slice v0.0.0-20260209194814-eeb2896ac759
 	github.com/charmbracelet/x/exp/strings v0.1.0
-	github.com/charmbracelet/x/powernap v0.1.0
+	github.com/charmbracelet/x/powernap v0.1.3
 	github.com/charmbracelet/x/term v0.2.2
 	github.com/clipperhouse/displaywidth v0.11.0
 	github.com/clipperhouse/uax29/v2 v2.7.0
 	github.com/denisbrodbeck/machineid v1.0.1
 	github.com/disintegration/imaging v1.6.2
 	github.com/dustin/go-humanize v1.0.1
-	github.com/go-git/go-git/v5 v5.16.5
+	github.com/go-git/go-git/v5 v5.17.0
 	github.com/google/uuid v1.6.0
 	github.com/invopop/jsonschema v0.13.0
 	github.com/joho/godotenv v1.5.1
 	github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d
 	github.com/lucasb-eyer/go-colorful v1.3.0
 	github.com/mattn/go-isatty v0.0.20
-	github.com/modelcontextprotocol/go-sdk v1.3.1
+	github.com/modelcontextprotocol/go-sdk v1.4.0
 	github.com/ncruces/go-sqlite3 v0.30.5
 	github.com/nxadm/tail v1.4.11
 	github.com/openai/openai-go/v2 v2.7.1
@@ -62,7 +62,7 @@ require (
 	github.com/tidwall/sjson v1.2.5
 	github.com/zeebo/xxh3 v1.1.0
 	go.uber.org/goleak v1.3.0
-	golang.org/x/net v0.50.0
+	golang.org/x/net v0.51.0
 	golang.org/x/sync v0.19.0
 	golang.org/x/text v0.34.0
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
@@ -80,36 +80,36 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
 	github.com/andybalholm/cascadia v1.3.3 // indirect
-	github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
+	github.com/aws/aws-sdk-go-v2 v1.41.2 // indirect
 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
-	github.com/aws/aws-sdk-go-v2/config v1.32.9 // indirect
-	github.com/aws/aws-sdk-go-v2/credentials v1.19.9 // indirect
-	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
+	github.com/aws/aws-sdk-go-v2/config v1.32.10 // indirect
+	github.com/aws/aws-sdk-go-v2/credentials v1.19.10 // indirect
+	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
 	github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
-	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
-	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
-	github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
-	github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 // indirect
-	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
-	github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
-	github.com/aws/smithy-go v1.24.0 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect
+	github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect
+	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect
+	github.com/aws/smithy-go v1.24.1 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/bahlo/generic-list-go v0.2.0 // indirect
 	github.com/buger/jsonparser v1.1.1 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
-	github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect
+	github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect
 	github.com/charmbracelet/x/json v0.2.0 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect
-	github.com/ebitengine/purego v0.10.0-alpha.4 // indirect
+	github.com/ebitengine/purego v0.10.0-alpha.5 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/fsnotify/fsnotify v1.9.0 // indirect
 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
-	github.com/go-git/go-billy/v5 v5.6.2 // indirect
+	github.com/go-git/go-billy/v5 v5.8.0 // indirect
 	github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
 	github.com/go-logfmt/logfmt v0.6.0 // indirect
 	github.com/go-logr/logr v1.4.3 // indirect
@@ -117,7 +117,6 @@ require (
 	github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
 	github.com/goccy/go-json v0.10.5 // indirect
 	github.com/goccy/go-yaml v1.19.2 // indirect
-	github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
 	github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
 	github.com/google/go-cmp v0.7.0 // indirect
 	github.com/google/jsonschema-go v0.4.2 // indirect
@@ -129,9 +128,9 @@ require (
 	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
-	github.com/kaptinlin/go-i18n v0.2.9 // indirect
+	github.com/kaptinlin/go-i18n v0.2.11 // indirect
 	github.com/kaptinlin/jsonpointer v0.4.16 // indirect
-	github.com/kaptinlin/jsonschema v0.7.2 // indirect
+	github.com/kaptinlin/jsonschema v0.7.3 // indirect
 	github.com/kaptinlin/messageformat-go v0.4.18 // indirect
 	github.com/klauspost/compress v1.18.4 // indirect
 	github.com/klauspost/cpuid/v2 v2.2.10 // indirect
@@ -180,8 +179,8 @@ require (
 	golang.org/x/sys v0.41.0 // indirect
 	golang.org/x/term v0.40.0 // indirect
 	golang.org/x/time v0.14.0 // indirect
-	google.golang.org/api v0.266.0 // indirect
-	google.golang.org/genai v1.47.0 // indirect
+	google.golang.org/api v0.267.0 // indirect
+	google.golang.org/genai v1.48.0 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
 	google.golang.org/grpc v1.79.1 // indirect
 	google.golang.org/protobuf v1.36.11 // indirect

go.sum 🔗

@@ -2,10 +2,10 @@ charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
 charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
 charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ=
 charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
-charm.land/catwalk v0.21.1 h1:CO6GDgfl6u0Gx6v3vC64B8DEnX+PhjDxX7IrVyu3Feg=
-charm.land/catwalk v0.21.1/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s=
-charm.land/fantasy v0.9.0 h1:2KzDYZC3IDb6T8KhWn4akqDHoU5Evr+VwL2xbaWtXmM=
-charm.land/fantasy v0.9.0/go.mod h1:vpR/vcgCtKZ5SWHNbW/5c1b+DMDNNO15j+t/evoQb/4=
+charm.land/catwalk v0.25.3 h1:mkeICGwUPR9ZeOKNaeRmUrTyDJazTFYiNFWSeyjhM1A=
+charm.land/catwalk v0.25.3/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s=
+charm.land/fantasy v0.11.0 h1:KrYa7B3JMCViXsbDyho9vLdzoml9Id8OgyytowrmkNY=
+charm.land/fantasy v0.11.0/go.mod h1:NtQpqji9blpicYopEzcbgj8mIR4fOMjwK0wyr/D9D5M=
 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0=
 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8=
 charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
@@ -48,36 +48,36 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
 github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
-github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
-github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
+github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
+github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
-github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A=
-github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
+github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
+github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y=
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
-github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
-github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
+github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
+github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
 github.com/aymanbagabas/go-nativeclipboard v0.1.3 h1:FmAWHPTwneAixu7uGDn3cL42xPlUCdNp2J8egMn3P1k=
 github.com/aymanbagabas/go-nativeclipboard v0.1.3/go.mod h1:2o7MyZwwi4pmXXpOpvOS5FwaHyoCIUks0ktjUvB0EoE=
 github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
@@ -94,8 +94,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg=
 github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
-github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
-github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
+github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4=
+github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8=
 github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
 github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
 github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
@@ -120,8 +120,8 @@ github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9
 github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
 github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
 github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
-github.com/charmbracelet/x/powernap v0.1.0 h1:xJPM8szKu3UY4ZuW3puc8R7Hpftq2nLIygyRe3EGUoE=
-github.com/charmbracelet/x/powernap v0.1.0/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
+github.com/charmbracelet/x/powernap v0.1.3 h1:rmxdQelSPB1QAgLRNMLOrgCTq3q2RXoLOJ2ZTwKG17g=
+github.com/charmbracelet/x/powernap v0.1.3/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
 github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
 github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
@@ -147,15 +147,17 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1
 github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
+github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/ebitengine/purego v0.10.0-alpha.4 h1:JzPbdf+cqbyT9sZtP4xnqelwUXwf7LvD8xKS6+ofTds=
-github.com/ebitengine/purego v0.10.0-alpha.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/ebitengine/purego v0.10.0-alpha.5 h1:IUIZ1pu0wnpxrn7o6utj8AeoZBS2upI11kLcddBF414=
+github.com/ebitengine/purego v0.10.0-alpha.5/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
 github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
-github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
-github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
-github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
-github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
+github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
+github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
@@ -163,10 +165,10 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
 github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
-github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
-github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
-github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s=
-github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
+github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
+github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
+github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM=
+github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI=
 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
@@ -225,12 +227,12 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
 github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d h1:on25kP+Sx7sxUMRQiA8gdcToAGet4DK/EIA30mXre+4=
 github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d/go.mod h1:SV0W0APWP9MZ1/gfDQ/NzzTlWdIgYZ/ZbpN4d/UXRYw=
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
-github.com/kaptinlin/go-i18n v0.2.9 h1:96TWNQI0j5nPhcmeFaCyX8SfyNhA0CTjeilLTy7ol9M=
-github.com/kaptinlin/go-i18n v0.2.9/go.mod h1:Sm0GTLS6hbFDrUQahycQfHF377WR9VF5eDgWrwljCAk=
+github.com/kaptinlin/go-i18n v0.2.11 h1:OayNt8mWt8nDaqAOp09/C1VG9Y5u8LpQnnxbyGARDV4=
+github.com/kaptinlin/go-i18n v0.2.11/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
 github.com/kaptinlin/jsonpointer v0.4.16 h1:Ux4w4FY+uLv+K+TxaCJtM/TpPv+1+eS6gH4Z9/uhOuA=
 github.com/kaptinlin/jsonpointer v0.4.16/go.mod h1:SsfsjqnHG5zuKo1DTBzk1VknaHlL4osHw+X9kZKukpU=
-github.com/kaptinlin/jsonschema v0.7.2 h1:I4AiYZ/be3gtWi4Mb7vtY8W6zN6f4YvT2eHCUXXJfmQ=
-github.com/kaptinlin/jsonschema v0.7.2/go.mod h1:Y6SZ/x3m9LZzEQY/NxCjHCmBPprBGMLWZDX3mFN0lJQ=
+github.com/kaptinlin/jsonschema v0.7.3 h1:kyIydij76ORiSxmfy0xFYy0cOx8MwG6pyyaSoQshsK4=
+github.com/kaptinlin/jsonschema v0.7.3/go.mod h1:Ys6zr+W6/1330FzZEouFrAYImK+AmYt5HQVTHQQXQo8=
 github.com/kaptinlin/messageformat-go v0.4.18 h1:RBlHVWgZyoxTcUgGWBsl2AcyScq/urqbLZvzgryTmSI=
 github.com/kaptinlin/messageformat-go v0.4.18/go.mod h1:ntI3154RnqJgr7GaC+vZBnIExl2V3sv9selvRNNEM24=
 github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
@@ -262,8 +264,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI=
-github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw=
+github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8=
+github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
@@ -423,8 +425,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
 golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
-golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
+golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
+golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
 golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
 golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -491,10 +493,10 @@ golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
 gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
-google.golang.org/api v0.266.0 h1:hco+oNCf9y7DmLeAtHJi/uBAY7n/7XC9mZPxu1ROiyk=
-google.golang.org/api v0.266.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
-google.golang.org/genai v1.47.0 h1:iWCS7gEdO6rctOqfCYLOrZGKu2D+N42aTnCEcBvB1jo=
-google.golang.org/genai v1.47.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
+google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE=
+google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
+google.golang.org/genai v1.48.0 h1:1vb15G291wAjJJueisMDpUhssljhEdJU2t5qTidrVPs=
+google.golang.org/genai v1.48.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
 google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=

internal/agent/agent_tool.go 🔗

@@ -4,7 +4,6 @@ import (
 	"context"
 	_ "embed"
 	"errors"
-	"fmt"
 
 	"charm.land/fantasy"
 
@@ -56,50 +55,13 @@ func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error)
 				return fantasy.ToolResponse{}, errors.New("agent message id missing from context")
 			}
 
-			agentToolSessionID := c.sessions.CreateAgentToolSessionID(agentMessageID, call.ID)
-			session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, sessionID, "New Agent Session")
-			if err != nil {
-				return fantasy.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
-			}
-			model := agent.Model()
-			maxTokens := model.CatwalkCfg.DefaultMaxTokens
-			if model.ModelCfg.MaxTokens != 0 {
-				maxTokens = model.ModelCfg.MaxTokens
-			}
-
-			providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
-			if !ok {
-				return fantasy.ToolResponse{}, errors.New("model provider not configured")
-			}
-			result, err := agent.Run(ctx, SessionAgentCall{
-				SessionID:        session.ID,
-				Prompt:           params.Prompt,
-				MaxOutputTokens:  maxTokens,
-				ProviderOptions:  getProviderOptions(model, providerCfg),
-				Temperature:      model.ModelCfg.Temperature,
-				TopP:             model.ModelCfg.TopP,
-				TopK:             model.ModelCfg.TopK,
-				FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
-				PresencePenalty:  model.ModelCfg.PresencePenalty,
+			return c.runSubAgent(ctx, subAgentParams{
+				Agent:          agent,
+				SessionID:      sessionID,
+				AgentMessageID: agentMessageID,
+				ToolCallID:     call.ID,
+				Prompt:         params.Prompt,
+				SessionTitle:   "New Agent Session",
 			})
-			if err != nil {
-				return fantasy.NewTextErrorResponse("error generating response"), nil
-			}
-			updatedSession, err := c.sessions.Get(ctx, session.ID)
-			if err != nil {
-				return fantasy.ToolResponse{}, fmt.Errorf("error getting session: %s", err)
-			}
-			parentSession, err := c.sessions.Get(ctx, sessionID)
-			if err != nil {
-				return fantasy.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err)
-			}
-
-			parentSession.Cost += updatedSession.Cost
-
-			_, err = c.sessions.Save(ctx, parentSession)
-			if err != nil {
-				return fantasy.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err)
-			}
-			return fantasy.NewTextResponse(result.Response.Content.Text()), nil
 		}), nil
 }

internal/agent/agentic_fetch_tool.go 🔗

@@ -184,51 +184,16 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
 				Tools:                fetchTools,
 			})
 
-			agentToolSessionID := c.sessions.CreateAgentToolSessionID(validationResult.AgentMessageID, call.ID)
-			session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, validationResult.SessionID, "Fetch Analysis")
-			if err != nil {
-				return fantasy.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
-			}
-
-			c.permissions.AutoApproveSession(session.ID)
-
-			// Use small model for web content analysis (faster and cheaper)
-			maxTokens := small.CatwalkCfg.DefaultMaxTokens
-			if small.ModelCfg.MaxTokens != 0 {
-				maxTokens = small.ModelCfg.MaxTokens
-			}
-
-			result, err := agent.Run(ctx, SessionAgentCall{
-				SessionID:        session.ID,
-				Prompt:           fullPrompt,
-				MaxOutputTokens:  maxTokens,
-				ProviderOptions:  getProviderOptions(small, smallProviderCfg),
-				Temperature:      small.ModelCfg.Temperature,
-				TopP:             small.ModelCfg.TopP,
-				TopK:             small.ModelCfg.TopK,
-				FrequencyPenalty: small.ModelCfg.FrequencyPenalty,
-				PresencePenalty:  small.ModelCfg.PresencePenalty,
+			return c.runSubAgent(ctx, subAgentParams{
+				Agent:          agent,
+				SessionID:      validationResult.SessionID,
+				AgentMessageID: validationResult.AgentMessageID,
+				ToolCallID:     call.ID,
+				Prompt:         fullPrompt,
+				SessionTitle:   "Fetch Analysis",
+				SessionSetup: func(sessionID string) {
+					c.permissions.AutoApproveSession(sessionID)
+				},
 			})
-			if err != nil {
-				return fantasy.NewTextErrorResponse("error generating response"), nil
-			}
-
-			updatedSession, err := c.sessions.Get(ctx, session.ID)
-			if err != nil {
-				return fantasy.ToolResponse{}, fmt.Errorf("error getting session: %s", err)
-			}
-			parentSession, err := c.sessions.Get(ctx, validationResult.SessionID)
-			if err != nil {
-				return fantasy.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err)
-			}
-
-			parentSession.Cost += updatedSession.Cost
-
-			_, err = c.sessions.Save(ctx, parentSession)
-			if err != nil {
-				return fantasy.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err)
-			}
-
-			return fantasy.NewTextResponse(result.Response.Content.Text()), nil
 		}), nil
 }

internal/agent/common_test.go 🔗

@@ -182,12 +182,9 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
 		GeneratedWith: true,
 	}
 
-	// Clear skills paths to ensure test reproducibility - user's skills
-	// would be included in prompt and break VCR cassette matching.
-	cfg.Options.SkillsPaths = []string{}
-
-	// Clear LSP config to ensure test reproducibility - user's LSP config
-	// would be included in prompt and break VCR cassette matching.
+	// Clear some fields to avoid issues with VCR cassette matching.
+	cfg.Options.SkillsPaths = nil
+	cfg.Options.ContextPaths = nil
 	cfg.LSP = nil
 
 	systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg)

internal/agent/coordinator.go 🔗

@@ -279,12 +279,15 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
 			}
 		}
 	case anthropic.Name:
-		_, hasThink := mergedOptions["thinking"]
-		if !hasThink && model.ModelCfg.Think {
-			mergedOptions["thinking"] = map[string]any{
-				// TODO: kujtim see if we need to make this dynamic
-				"budget_tokens": 2000,
-			}
+		var (
+			_, hasEffort = mergedOptions["effort"]
+			_, hasThink  = mergedOptions["thinking"]
+		)
+		switch {
+		case !hasEffort && model.ModelCfg.ReasoningEffort != "":
+			mergedOptions["effort"] = model.ModelCfg.ReasoningEffort
+		case !hasThink && model.ModelCfg.Think:
+			mergedOptions["thinking"] = map[string]any{"budget_tokens": 2000}
 		}
 		parsed, err := anthropic.ParseOptions(mergedOptions)
 		if err == nil {
@@ -318,9 +321,16 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
 	case google.Name:
 		_, hasReasoning := mergedOptions["thinking_config"]
 		if !hasReasoning {
-			mergedOptions["thinking_config"] = map[string]any{
-				"thinking_budget":  2000,
-				"include_thoughts": true,
+			if strings.HasPrefix(model.CatwalkCfg.ID, "gemini-2") {
+				mergedOptions["thinking_config"] = map[string]any{
+					"thinking_budget":  2000,
+					"include_thoughts": true,
+				}
+			} else {
+				mergedOptions["thinking_config"] = map[string]any{
+					"thinking_level":   model.ModelCfg.ReasoningEffort,
+					"include_thoughts": true,
+				}
 			}
 		}
 		parsed, err := google.ParseOptions(mergedOptions)
@@ -576,7 +586,7 @@ func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map
 		// NOTE: Prevent the SDK from picking up the API key from env.
 		os.Setenv("ANTHROPIC_API_KEY", "")
 		headers["Authorization"] = apiKey
-	case providerID == string(catwalk.InferenceProviderMiniMax):
+	case providerID == string(catwalk.InferenceProviderMiniMax) || providerID == string(catwalk.InferenceProviderMiniMaxChina):
 		// NOTE: Prevent the SDK from picking up the API key from env.
 		os.Setenv("ANTHROPIC_API_KEY", "")
 		headers["Authorization"] = "Bearer " + apiKey
@@ -946,3 +956,89 @@ func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg con
 	}
 	return nil
 }
+
+// subAgentParams holds the parameters for running a sub-agent.
+type subAgentParams struct {
+	Agent          SessionAgent
+	SessionID      string
+	AgentMessageID string
+	ToolCallID     string
+	Prompt         string
+	SessionTitle   string
+	// SessionSetup is an optional callback invoked after session creation
+	// but before agent execution, for custom session configuration.
+	SessionSetup func(sessionID string)
+}
+
+// runSubAgent runs a sub-agent and handles session management and cost accumulation.
+// It creates a sub-session, runs the agent with the given prompt, and propagates
+// the cost to the parent session.
+func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (fantasy.ToolResponse, error) {
+	// Create sub-session
+	agentToolSessionID := c.sessions.CreateAgentToolSessionID(params.AgentMessageID, params.ToolCallID)
+	session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, params.SessionID, params.SessionTitle)
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("create session: %w", err)
+	}
+
+	// Call session setup function if provided
+	if params.SessionSetup != nil {
+		params.SessionSetup(session.ID)
+	}
+
+	// Get model configuration
+	model := params.Agent.Model()
+	maxTokens := model.CatwalkCfg.DefaultMaxTokens
+	if model.ModelCfg.MaxTokens != 0 {
+		maxTokens = model.ModelCfg.MaxTokens
+	}
+
+	providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
+	if !ok {
+		return fantasy.ToolResponse{}, errors.New("model provider not configured")
+	}
+
+	// Run the agent
+	result, err := params.Agent.Run(ctx, SessionAgentCall{
+		SessionID:        session.ID,
+		Prompt:           params.Prompt,
+		MaxOutputTokens:  maxTokens,
+		ProviderOptions:  getProviderOptions(model, providerCfg),
+		Temperature:      model.ModelCfg.Temperature,
+		TopP:             model.ModelCfg.TopP,
+		TopK:             model.ModelCfg.TopK,
+		FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
+		PresencePenalty:  model.ModelCfg.PresencePenalty,
+	})
+	if err != nil {
+		return fantasy.NewTextErrorResponse("error generating response"), nil
+	}
+
+	// Update parent session cost
+	if err := c.updateParentSessionCost(ctx, session.ID, params.SessionID); err != nil {
+		return fantasy.ToolResponse{}, err
+	}
+
+	return fantasy.NewTextResponse(result.Response.Content.Text()), nil
+}
+
+// updateParentSessionCost accumulates the cost from a child session to its parent session.
+func (c *coordinator) updateParentSessionCost(ctx context.Context, childSessionID, parentSessionID string) error {
+	childSession, err := c.sessions.Get(ctx, childSessionID)
+	if err != nil {
+		return fmt.Errorf("get child session: %w", err)
+	}
+
+	parentSession, err := c.sessions.Get(ctx, parentSessionID)
+	if err != nil {
+		return fmt.Errorf("get parent session: %w", err)
+	}
+
+	parentSession.Cost += childSession.Cost
+
+	if _, err := c.sessions.Save(ctx, parentSession); err != nil {
+		return fmt.Errorf("save parent session: %w", err)
+	}
+
+	return nil
+}

internal/agent/coordinator_test.go 🔗

@@ -0,0 +1,385 @@
+package agent
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"charm.land/catwalk/pkg/catwalk"
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// mockSessionAgent is a minimal mock for the SessionAgent interface.
+type mockSessionAgent struct {
+	model     Model
+	runFunc   func(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error)
+	cancelled []string
+}
+
+func (m *mockSessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
+	return m.runFunc(ctx, call)
+}
+
+func (m *mockSessionAgent) Model() Model                        { return m.model }
+func (m *mockSessionAgent) SetModels(large, small Model)        {}
+func (m *mockSessionAgent) SetTools(tools []fantasy.AgentTool)  {}
+func (m *mockSessionAgent) SetSystemPrompt(systemPrompt string) {}
+func (m *mockSessionAgent) Cancel(sessionID string) {
+	m.cancelled = append(m.cancelled, sessionID)
+}
+func (m *mockSessionAgent) CancelAll()                                  {}
+func (m *mockSessionAgent) IsSessionBusy(sessionID string) bool         { return false }
+func (m *mockSessionAgent) IsBusy() bool                                { return false }
+func (m *mockSessionAgent) QueuedPrompts(sessionID string) int          { return 0 }
+func (m *mockSessionAgent) QueuedPromptsList(sessionID string) []string { return nil }
+func (m *mockSessionAgent) ClearQueue(sessionID string)                 {}
+func (m *mockSessionAgent) Summarize(context.Context, string, fantasy.ProviderOptions) error {
+	return nil
+}
+
+// newTestCoordinator creates a minimal coordinator for unit testing runSubAgent.
+func newTestCoordinator(t *testing.T, env fakeEnv, providerID string, providerCfg config.ProviderConfig) *coordinator {
+	cfg, err := config.Init(env.workingDir, "", false)
+	require.NoError(t, err)
+	cfg.Providers.Set(providerID, providerCfg)
+	return &coordinator{
+		cfg:      cfg,
+		sessions: env.sessions,
+	}
+}
+
+// newMockAgent creates a mockSessionAgent with the given provider and run function.
+func newMockAgent(providerID string, maxTokens int64, runFunc func(context.Context, SessionAgentCall) (*fantasy.AgentResult, error)) *mockSessionAgent {
+	return &mockSessionAgent{
+		model: Model{
+			CatwalkCfg: catwalk.Model{
+				DefaultMaxTokens: maxTokens,
+			},
+			ModelCfg: config.SelectedModel{
+				Provider: providerID,
+			},
+		},
+		runFunc: runFunc,
+	}
+}
+
+// agentResultWithText creates a minimal AgentResult with the given text response.
+func agentResultWithText(text string) *fantasy.AgentResult {
+	return &fantasy.AgentResult{
+		Response: fantasy.Response{
+			Content: fantasy.ResponseContent{
+				fantasy.TextContent{Text: text},
+			},
+		},
+	}
+}
+
+func TestRunSubAgent(t *testing.T) {
+	const providerID = "test-provider"
+	providerCfg := config.ProviderConfig{ID: providerID}
+
+	t.Run("happy path", func(t *testing.T) {
+		env := testEnv(t)
+		coord := newTestCoordinator(t, env, providerID, providerCfg)
+
+		parentSession, err := env.sessions.Create(t.Context(), "Parent")
+		require.NoError(t, err)
+
+		agent := newMockAgent(providerID, 4096, func(_ context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
+			assert.Equal(t, "do something", call.Prompt)
+			assert.Equal(t, int64(4096), call.MaxOutputTokens)
+			return agentResultWithText("done"), nil
+		})
+
+		resp, err := coord.runSubAgent(t.Context(), subAgentParams{
+			Agent:          agent,
+			SessionID:      parentSession.ID,
+			AgentMessageID: "msg-1",
+			ToolCallID:     "call-1",
+			Prompt:         "do something",
+			SessionTitle:   "Test Session",
+		})
+		require.NoError(t, err)
+		assert.Equal(t, "done", resp.Content)
+		assert.False(t, resp.IsError)
+	})
+
+	t.Run("ModelCfg.MaxTokens overrides default", func(t *testing.T) {
+		env := testEnv(t)
+		coord := newTestCoordinator(t, env, providerID, providerCfg)
+
+		parentSession, err := env.sessions.Create(t.Context(), "Parent")
+		require.NoError(t, err)
+
+		agent := &mockSessionAgent{
+			model: Model{
+				CatwalkCfg: catwalk.Model{
+					DefaultMaxTokens: 4096,
+				},
+				ModelCfg: config.SelectedModel{
+					Provider:  providerID,
+					MaxTokens: 8192,
+				},
+			},
+			runFunc: func(_ context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
+				assert.Equal(t, int64(8192), call.MaxOutputTokens)
+				return agentResultWithText("ok"), nil
+			},
+		}
+
+		resp, err := coord.runSubAgent(t.Context(), subAgentParams{
+			Agent:          agent,
+			SessionID:      parentSession.ID,
+			AgentMessageID: "msg-1",
+			ToolCallID:     "call-1",
+			Prompt:         "test",
+			SessionTitle:   "Test",
+		})
+		require.NoError(t, err)
+		assert.Equal(t, "ok", resp.Content)
+	})
+
+	t.Run("session creation failure with canceled context", func(t *testing.T) {
+		env := testEnv(t)
+		coord := newTestCoordinator(t, env, providerID, providerCfg)
+
+		parentSession, err := env.sessions.Create(t.Context(), "Parent")
+		require.NoError(t, err)
+
+		agent := newMockAgent(providerID, 4096, nil)
+
+		// Use a canceled context to trigger CreateTaskSession failure.
+		ctx, cancel := context.WithCancel(t.Context())
+		cancel()
+
+		_, err = coord.runSubAgent(ctx, subAgentParams{
+			Agent:          agent,
+			SessionID:      parentSession.ID,
+			AgentMessageID: "msg-1",
+			ToolCallID:     "call-1",
+			Prompt:         "test",
+			SessionTitle:   "Test",
+		})
+		require.Error(t, err)
+	})
+
+	t.Run("provider not configured", func(t *testing.T) {
+		env := testEnv(t)
+		coord := newTestCoordinator(t, env, providerID, providerCfg)
+
+		parentSession, err := env.sessions.Create(t.Context(), "Parent")
+		require.NoError(t, err)
+
+		// Agent references a provider that doesn't exist in config.
+		agent := newMockAgent("unknown-provider", 4096, nil)
+
+		_, err = coord.runSubAgent(t.Context(), subAgentParams{
+			Agent:          agent,
+			SessionID:      parentSession.ID,
+			AgentMessageID: "msg-1",
+			ToolCallID:     "call-1",
+			Prompt:         "test",
+			SessionTitle:   "Test",
+		})
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), "model provider not configured")
+	})
+
+	t.Run("agent run error returns error response", func(t *testing.T) {
+		env := testEnv(t)
+		coord := newTestCoordinator(t, env, providerID, providerCfg)
+
+		parentSession, err := env.sessions.Create(t.Context(), "Parent")
+		require.NoError(t, err)
+
+		agent := newMockAgent(providerID, 4096, func(_ context.Context, _ SessionAgentCall) (*fantasy.AgentResult, error) {
+			return nil, errors.New("agent exploded")
+		})
+
+		resp, err := coord.runSubAgent(t.Context(), subAgentParams{
+			Agent:          agent,
+			SessionID:      parentSession.ID,
+			AgentMessageID: "msg-1",
+			ToolCallID:     "call-1",
+			Prompt:         "test",
+			SessionTitle:   "Test",
+		})
+		// runSubAgent returns (errorResponse, nil) when agent.Run fails — not a Go error.
+		require.NoError(t, err)
+		assert.True(t, resp.IsError)
+		assert.Equal(t, "error generating response", resp.Content)
+	})
+
+	t.Run("session setup callback is invoked", func(t *testing.T) {
+		env := testEnv(t)
+		coord := newTestCoordinator(t, env, providerID, providerCfg)
+
+		parentSession, err := env.sessions.Create(t.Context(), "Parent")
+		require.NoError(t, err)
+
+		var setupCalledWith string
+		agent := newMockAgent(providerID, 4096, func(_ context.Context, _ SessionAgentCall) (*fantasy.AgentResult, error) {
+			return agentResultWithText("ok"), nil
+		})
+
+		_, err = coord.runSubAgent(t.Context(), subAgentParams{
+			Agent:          agent,
+			SessionID:      parentSession.ID,
+			AgentMessageID: "msg-1",
+			ToolCallID:     "call-1",
+			Prompt:         "test",
+			SessionTitle:   "Test",
+			SessionSetup: func(sessionID string) {
+				setupCalledWith = sessionID
+			},
+		})
+		require.NoError(t, err)
+		assert.NotEmpty(t, setupCalledWith, "SessionSetup should have been called")
+	})
+
+	t.Run("cost propagation to parent session", func(t *testing.T) {
+		env := testEnv(t)
+		coord := newTestCoordinator(t, env, providerID, providerCfg)
+
+		parentSession, err := env.sessions.Create(t.Context(), "Parent")
+		require.NoError(t, err)
+
+		agent := newMockAgent(providerID, 4096, func(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
+			// Simulate the agent incurring cost by updating the child session.
+			childSession, err := env.sessions.Get(ctx, call.SessionID)
+			if err != nil {
+				return nil, err
+			}
+			childSession.Cost = 0.05
+			_, err = env.sessions.Save(ctx, childSession)
+			if err != nil {
+				return nil, err
+			}
+			return agentResultWithText("ok"), nil
+		})
+
+		_, err = coord.runSubAgent(t.Context(), subAgentParams{
+			Agent:          agent,
+			SessionID:      parentSession.ID,
+			AgentMessageID: "msg-1",
+			ToolCallID:     "call-1",
+			Prompt:         "test",
+			SessionTitle:   "Test",
+		})
+		require.NoError(t, err)
+
+		updated, err := env.sessions.Get(t.Context(), parentSession.ID)
+		require.NoError(t, err)
+		assert.InDelta(t, 0.05, updated.Cost, 1e-9)
+	})
+}
+
+func TestUpdateParentSessionCost(t *testing.T) {
+	t.Run("accumulates cost correctly", func(t *testing.T) {
+		env := testEnv(t)
+		cfg, err := config.Init(env.workingDir, "", false)
+		require.NoError(t, err)
+		coord := &coordinator{cfg: cfg, sessions: env.sessions}
+
+		parent, err := env.sessions.Create(t.Context(), "Parent")
+		require.NoError(t, err)
+
+		child, err := env.sessions.CreateTaskSession(t.Context(), "tool-1", parent.ID, "Child")
+		require.NoError(t, err)
+
+		// Set child cost.
+		child.Cost = 0.10
+		_, err = env.sessions.Save(t.Context(), child)
+		require.NoError(t, err)
+
+		err = coord.updateParentSessionCost(t.Context(), child.ID, parent.ID)
+		require.NoError(t, err)
+
+		updated, err := env.sessions.Get(t.Context(), parent.ID)
+		require.NoError(t, err)
+		assert.InDelta(t, 0.10, updated.Cost, 1e-9)
+	})
+
+	t.Run("accumulates multiple child costs", func(t *testing.T) {
+		env := testEnv(t)
+		cfg, err := config.Init(env.workingDir, "", false)
+		require.NoError(t, err)
+		coord := &coordinator{cfg: cfg, sessions: env.sessions}
+
+		parent, err := env.sessions.Create(t.Context(), "Parent")
+		require.NoError(t, err)
+
+		child1, err := env.sessions.CreateTaskSession(t.Context(), "tool-1", parent.ID, "Child1")
+		require.NoError(t, err)
+		child1.Cost = 0.05
+		_, err = env.sessions.Save(t.Context(), child1)
+		require.NoError(t, err)
+
+		child2, err := env.sessions.CreateTaskSession(t.Context(), "tool-2", parent.ID, "Child2")
+		require.NoError(t, err)
+		child2.Cost = 0.03
+		_, err = env.sessions.Save(t.Context(), child2)
+		require.NoError(t, err)
+
+		err = coord.updateParentSessionCost(t.Context(), child1.ID, parent.ID)
+		require.NoError(t, err)
+		err = coord.updateParentSessionCost(t.Context(), child2.ID, parent.ID)
+		require.NoError(t, err)
+
+		updated, err := env.sessions.Get(t.Context(), parent.ID)
+		require.NoError(t, err)
+		assert.InDelta(t, 0.08, updated.Cost, 1e-9)
+	})
+
+	t.Run("child session not found", func(t *testing.T) {
+		env := testEnv(t)
+		cfg, err := config.Init(env.workingDir, "", false)
+		require.NoError(t, err)
+		coord := &coordinator{cfg: cfg, sessions: env.sessions}
+
+		parent, err := env.sessions.Create(t.Context(), "Parent")
+		require.NoError(t, err)
+
+		err = coord.updateParentSessionCost(t.Context(), "non-existent", parent.ID)
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), "get child session")
+	})
+
+	t.Run("parent session not found", func(t *testing.T) {
+		env := testEnv(t)
+		cfg, err := config.Init(env.workingDir, "", false)
+		require.NoError(t, err)
+		coord := &coordinator{cfg: cfg, sessions: env.sessions}
+
+		parent, err := env.sessions.Create(t.Context(), "Parent")
+		require.NoError(t, err)
+		child, err := env.sessions.CreateTaskSession(t.Context(), "tool-1", parent.ID, "Child")
+		require.NoError(t, err)
+
+		err = coord.updateParentSessionCost(t.Context(), child.ID, "non-existent")
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), "get parent session")
+	})
+
+	t.Run("zero cost handled correctly", func(t *testing.T) {
+		env := testEnv(t)
+		cfg, err := config.Init(env.workingDir, "", false)
+		require.NoError(t, err)
+		coord := &coordinator{cfg: cfg, sessions: env.sessions}
+
+		parent, err := env.sessions.Create(t.Context(), "Parent")
+		require.NoError(t, err)
+		child, err := env.sessions.CreateTaskSession(t.Context(), "tool-1", parent.ID, "Child")
+		require.NoError(t, err)
+
+		err = coord.updateParentSessionCost(t.Context(), child.ID, parent.ID)
+		require.NoError(t, err)
+
+		updated, err := env.sessions.Get(t.Context(), parent.ID)
+		require.NoError(t, err)
+		assert.InDelta(t, 0.0, updated.Cost, 1e-9)
+	})
+}

internal/agent/tools/context_test.go 🔗

@@ -0,0 +1,233 @@
+package tools
+
+import (
+	"context"
+	"testing"
+)
+
+// Test-specific context key types to avoid collisions
+type (
+	testStringKey string
+	testBoolKey   string
+	testIntKey    string
+)
+
+const (
+	testKey     testStringKey = "testKey"
+	missingKey  testStringKey = "missingKey"
+	boolTestKey testBoolKey   = "boolKey"
+	intTestKey  testIntKey    = "intKey"
+)
+
+func TestGetContextValue(t *testing.T) {
+	tests := []struct {
+		name         string
+		setup        func(ctx context.Context) context.Context
+		key          any
+		defaultValue any
+		want         any
+	}{
+		{
+			name: "returns string value",
+			setup: func(ctx context.Context) context.Context {
+				return context.WithValue(ctx, testKey, "testValue")
+			},
+			key:          testKey,
+			defaultValue: "",
+			want:         "testValue",
+		},
+		{
+			name: "returns default when key not found",
+			setup: func(ctx context.Context) context.Context {
+				return ctx
+			},
+			key:          missingKey,
+			defaultValue: "default",
+			want:         "default",
+		},
+		{
+			name: "returns default when type mismatch",
+			setup: func(ctx context.Context) context.Context {
+				return context.WithValue(ctx, testKey, 123) // int, not string
+			},
+			key:          testKey,
+			defaultValue: "default",
+			want:         "default",
+		},
+		{
+			name: "returns bool value",
+			setup: func(ctx context.Context) context.Context {
+				return context.WithValue(ctx, boolTestKey, true)
+			},
+			key:          boolTestKey,
+			defaultValue: false,
+			want:         true,
+		},
+		{
+			name: "returns int value",
+			setup: func(ctx context.Context) context.Context {
+				return context.WithValue(ctx, intTestKey, 42)
+			},
+			key:          intTestKey,
+			defaultValue: 0,
+			want:         42,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctx := tt.setup(context.Background())
+
+			var got any
+			switch tt.defaultValue.(type) {
+			case string:
+				got = getContextValue(ctx, tt.key, tt.defaultValue.(string))
+			case bool:
+				got = getContextValue(ctx, tt.key, tt.defaultValue.(bool))
+			case int:
+				got = getContextValue(ctx, tt.key, tt.defaultValue.(int))
+			}
+
+			if got != tt.want {
+				t.Errorf("getContextValue() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestGetSessionFromContext(t *testing.T) {
+	tests := []struct {
+		name string
+		ctx  context.Context
+		want string
+	}{
+		{
+			name: "returns session ID when present",
+			ctx:  context.WithValue(context.Background(), SessionIDContextKey, "session-123"),
+			want: "session-123",
+		},
+		{
+			name: "returns empty string when not present",
+			ctx:  context.Background(),
+			want: "",
+		},
+		{
+			name: "returns empty string when wrong type",
+			ctx:  context.WithValue(context.Background(), SessionIDContextKey, 123),
+			want: "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := GetSessionFromContext(tt.ctx)
+			if got != tt.want {
+				t.Errorf("GetSessionFromContext() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestGetMessageFromContext(t *testing.T) {
+	tests := []struct {
+		name string
+		ctx  context.Context
+		want string
+	}{
+		{
+			name: "returns message ID when present",
+			ctx:  context.WithValue(context.Background(), MessageIDContextKey, "msg-456"),
+			want: "msg-456",
+		},
+		{
+			name: "returns empty string when not present",
+			ctx:  context.Background(),
+			want: "",
+		},
+		{
+			name: "returns empty string when wrong type",
+			ctx:  context.WithValue(context.Background(), MessageIDContextKey, 456),
+			want: "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := GetMessageFromContext(tt.ctx)
+			if got != tt.want {
+				t.Errorf("GetMessageFromContext() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestGetSupportsImagesFromContext(t *testing.T) {
+	tests := []struct {
+		name string
+		ctx  context.Context
+		want bool
+	}{
+		{
+			name: "returns true when present and true",
+			ctx:  context.WithValue(context.Background(), SupportsImagesContextKey, true),
+			want: true,
+		},
+		{
+			name: "returns false when present and false",
+			ctx:  context.WithValue(context.Background(), SupportsImagesContextKey, false),
+			want: false,
+		},
+		{
+			name: "returns false when not present",
+			ctx:  context.Background(),
+			want: false,
+		},
+		{
+			name: "returns false when wrong type",
+			ctx:  context.WithValue(context.Background(), SupportsImagesContextKey, "true"),
+			want: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := GetSupportsImagesFromContext(tt.ctx)
+			if got != tt.want {
+				t.Errorf("GetSupportsImagesFromContext() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestGetModelNameFromContext(t *testing.T) {
+	tests := []struct {
+		name string
+		ctx  context.Context
+		want string
+	}{
+		{
+			name: "returns model name when present",
+			ctx:  context.WithValue(context.Background(), ModelNameContextKey, "claude-opus-4"),
+			want: "claude-opus-4",
+		},
+		{
+			name: "returns empty string when not present",
+			ctx:  context.Background(),
+			want: "",
+		},
+		{
+			name: "returns empty string when wrong type",
+			ctx:  context.WithValue(context.Background(), ModelNameContextKey, 789),
+			want: "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := GetModelNameFromContext(tt.ctx)
+			if got != tt.want {
+				t.Errorf("GetModelNameFromContext() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}

internal/agent/tools/diagnostics.go 🔗

@@ -7,6 +7,7 @@ import (
 	"log/slog"
 	"sort"
 	"strings"
+	"sync"
 	"time"
 
 	"charm.land/fantasy"
@@ -37,16 +38,61 @@ func NewDiagnosticsTool(lspManager *lsp.Manager) fantasy.AgentTool {
 		})
 }
 
-func notifyLSPs(
+// openInLSPs ensures LSP servers are running and aware of the file, but does
+// not notify changes or wait for fresh diagnostics. Use this for read-only
+// operations like view where the file content hasn't changed.
+func openInLSPs(
 	ctx context.Context,
 	manager *lsp.Manager,
 	filepath string,
 ) {
-	if filepath == "" {
+	if filepath == "" || manager == nil {
 		return
 	}
 
-	if manager == nil {
+	manager.Start(ctx, filepath)
+
+	for client := range manager.Clients().Seq() {
+		if !client.HandlesFile(filepath) {
+			continue
+		}
+		_ = client.OpenFileOnDemand(ctx, filepath)
+	}
+}
+
+// waitForLSPDiagnostics waits briefly for diagnostics publication after a file
+// has been opened. Intended for read-only situations where viewing up-to-date
+// files matters but latency should remain low (i.e. when using the view tool).
+func waitForLSPDiagnostics(
+	ctx context.Context,
+	manager *lsp.Manager,
+	filepath string,
+	timeout time.Duration,
+) {
+	if filepath == "" || manager == nil || timeout <= 0 {
+		return
+	}
+
+	var wg sync.WaitGroup
+	for client := range manager.Clients().Seq() {
+		if !client.HandlesFile(filepath) {
+			continue
+		}
+		wg.Go(func() {
+			client.WaitForDiagnostics(ctx, timeout)
+		})
+	}
+	wg.Wait()
+}
+
+// notifyLSPs notifies LSP servers that a file has changed and waits for
+// updated diagnostics. Use this after edit/multiedit operations.
+func notifyLSPs(
+	ctx context.Context,
+	manager *lsp.Manager,
+	filepath string,
+) {
+	if filepath == "" || manager == nil {
 		return
 	}
 

internal/agent/tools/job_output.go 🔗

@@ -19,6 +19,7 @@ var jobOutputDescription []byte
 
 type JobOutputParams struct {
 	ShellID string `json:"shell_id" description:"The ID of the background shell to retrieve output from"`
+	Wait    bool   `json:"wait" description:"If true, block until the background shell completes before returning output"`
 }
 
 type JobOutputResponseMetadata struct {
@@ -44,6 +45,10 @@ func NewJobOutputTool() fantasy.AgentTool {
 				return fantasy.NewTextErrorResponse(fmt.Sprintf("background shell not found: %s", params.ShellID)), nil
 			}
 
+			if params.Wait {
+				bgShell.WaitContext(ctx)
+			}
+
 			stdout, stderr, done, err := bgShell.GetOutput()
 
 			var outputParts []string

internal/agent/tools/job_output.md 🔗

@@ -4,16 +4,19 @@ Retrieves the current output from a background shell.
 - Provide the shell ID returned from a background bash execution
 - Returns the current stdout and stderr output
 - Indicates whether the shell has completed execution
+- Set wait=true to block until the shell completes or the request context is done
 </usage>
 
 <features>
 - View output from running background processes
 - Check if background process has completed
 - Get cumulative output from process start
+- Optionally wait for process completion (returns early on context cancel)
 </features>
 
 <tips>
 - Use this to monitor long-running processes
 - Check the 'done' status to see if process completed
 - Can be called multiple times to view incremental output
+- Use wait=true when you need the final output and exit status (or current output if the request cancels)
 </tips>

internal/agent/tools/mcp/resources.go 🔗

@@ -83,7 +83,7 @@ func getResources(ctx context.Context, c *ClientSession) ([]*Resource, error) {
 	}
 	result, err := c.ListResources(ctx, &mcp.ListResourcesParams{})
 	if err != nil {
-		// Handle "Method not found" errors from MCP servers that don't support resources/list
+		// Handle "Method not found" errors from MCP servers that don't support resources/list.
 		if isMethodNotFoundError(err) {
 			slog.Warn("MCP server does not support resources/list", "error", err)
 			return nil, nil

internal/agent/tools/references.go 🔗

@@ -71,7 +71,11 @@ func NewReferencesTool(lspManager *lsp.Manager) fantasy.AgentTool {
 					continue
 				}
 				allLocations = append(allLocations, locations...)
-				// XXX: should we break here or look for all results?
+				// Once we have results, we're done - LSP returns all references
+				// for the symbol, not just from this file.
+				if len(locations) > 0 {
+					break
+				}
 			}
 
 			if len(allLocations) > 0 {
@@ -172,15 +176,15 @@ func formatReferences(locations []protocol.Location) string {
 	sort.Strings(files)
 
 	var output strings.Builder
-	output.WriteString(fmt.Sprintf("Found %d reference(s) in %d file(s):\n\n", len(locations), len(files)))
+	fmt.Fprintf(&output, "Found %d reference(s) in %d file(s):\n\n", len(locations), len(files))
 
 	for _, file := range files {
 		refs := fileRefs[file]
-		output.WriteString(fmt.Sprintf("%s (%d reference(s)):\n", file, len(refs)))
+		fmt.Fprintf(&output, "%s (%d reference(s)):\n", file, len(refs))
 		for _, ref := range refs {
 			line := ref.Range.Start.Line + 1
 			char := ref.Range.Start.Character + 1
-			output.WriteString(fmt.Sprintf("  Line %d, Column %d\n", line, char))
+			fmt.Fprintf(&output, "  Line %d, Column %d\n", line, char)
 		}
 		output.WriteString("\n")
 	}

internal/agent/tools/search.go 🔗

@@ -191,11 +191,11 @@ func formatSearchResults(results []SearchResult) string {
 	}
 
 	var sb strings.Builder
-	sb.WriteString(fmt.Sprintf("Found %d search results:\n\n", len(results)))
+	fmt.Fprintf(&sb, "Found %d search results:\n\n", len(results))
 	for _, result := range results {
-		sb.WriteString(fmt.Sprintf("%d. %s\n", result.Position, result.Title))
-		sb.WriteString(fmt.Sprintf("   URL: %s\n", result.Link))
-		sb.WriteString(fmt.Sprintf("   Summary: %s\n\n", result.Snippet))
+		fmt.Fprintf(&sb, "%d. %s\n", result.Position, result.Title)
+		fmt.Fprintf(&sb, "   URL: %s\n", result.Link)
+		fmt.Fprintf(&sb, "   Summary: %s\n\n", result.Snippet)
 	}
 	return sb.String()
 }

internal/agent/tools/sourcegraph.go 🔗

@@ -145,7 +145,7 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string,
 		for _, err := range errors {
 			if errMap, ok := err.(map[string]any); ok {
 				if message, ok := errMap["message"].(string); ok {
-					buffer.WriteString(fmt.Sprintf("- %s\n", message))
+					fmt.Fprintf(&buffer, "- %s\n", message)
 				}
 			}
 		}
@@ -172,7 +172,7 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string,
 	limitHit, _ := searchResults["limitHit"].(bool)
 
 	buffer.WriteString("# Sourcegraph Search Results\n\n")
-	buffer.WriteString(fmt.Sprintf("Found %d matches across %d results\n", int(matchCount), int(resultCount)))
+	fmt.Fprintf(&buffer, "Found %d matches across %d results\n", int(matchCount), int(resultCount))
 
 	if limitHit {
 		buffer.WriteString("(Result limit reached, try a more specific query)\n")
@@ -215,10 +215,10 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string,
 		fileURL, _ := file["url"].(string)
 		fileContent, _ := file["content"].(string)
 
-		buffer.WriteString(fmt.Sprintf("## Result %d: %s/%s\n\n", i+1, repoName, filePath))
+		fmt.Fprintf(&buffer, "## Result %d: %s/%s\n\n", i+1, repoName, filePath)
 
 		if fileURL != "" {
-			buffer.WriteString(fmt.Sprintf("URL: %s\n\n", fileURL))
+			fmt.Fprintf(&buffer, "URL: %s\n\n", fileURL)
 		}
 
 		if len(lineMatches) > 0 {
@@ -240,24 +240,24 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string,
 
 					for j := startLine - 1; j < int(lineNumber)-1 && j < len(lines); j++ {
 						if j >= 0 {
-							buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
+							fmt.Fprintf(&buffer, "%d| %s\n", j+1, lines[j])
 						}
 					}
 
-					buffer.WriteString(fmt.Sprintf("%d|  %s\n", int(lineNumber), preview))
+					fmt.Fprintf(&buffer, "%d|  %s\n", int(lineNumber), preview)
 
 					endLine := int(lineNumber) + contextWindow
 
 					for j := int(lineNumber); j < endLine && j < len(lines); j++ {
 						if j < len(lines) {
-							buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
+							fmt.Fprintf(&buffer, "%d| %s\n", j+1, lines[j])
 						}
 					}
 
 					buffer.WriteString("```\n\n")
 				} else {
 					buffer.WriteString("```\n")
-					buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
+					fmt.Fprintf(&buffer, "%d| %s\n", int(lineNumber), preview)
 					buffer.WriteString("```\n\n")
 				}
 			}

internal/agent/tools/tools.go 🔗

@@ -22,53 +22,35 @@ const (
 	ModelNameContextKey modelNameKey = "model_name"
 )
 
-// GetSessionFromContext retrieves the session ID from the context.
-func GetSessionFromContext(ctx context.Context) string {
-	sessionID := ctx.Value(SessionIDContextKey)
-	if sessionID == nil {
-		return ""
+// getContextValue is a generic helper that retrieves a typed value from context.
+// If the value is not found or has the wrong type, it returns the default value.
+func getContextValue[T any](ctx context.Context, key any, defaultValue T) T {
+	value := ctx.Value(key)
+	if value == nil {
+		return defaultValue
 	}
-	s, ok := sessionID.(string)
-	if !ok {
-		return ""
+	if typedValue, ok := value.(T); ok {
+		return typedValue
 	}
-	return s
+	return defaultValue
+}
+
+// GetSessionFromContext retrieves the session ID from the context.
+func GetSessionFromContext(ctx context.Context) string {
+	return getContextValue(ctx, SessionIDContextKey, "")
 }
 
 // GetMessageFromContext retrieves the message ID from the context.
 func GetMessageFromContext(ctx context.Context) string {
-	messageID := ctx.Value(MessageIDContextKey)
-	if messageID == nil {
-		return ""
-	}
-	s, ok := messageID.(string)
-	if !ok {
-		return ""
-	}
-	return s
+	return getContextValue(ctx, MessageIDContextKey, "")
 }
 
 // GetSupportsImagesFromContext retrieves whether the model supports images from the context.
 func GetSupportsImagesFromContext(ctx context.Context) bool {
-	supportsImages := ctx.Value(SupportsImagesContextKey)
-	if supportsImages == nil {
-		return false
-	}
-	if supports, ok := supportsImages.(bool); ok {
-		return supports
-	}
-	return false
+	return getContextValue(ctx, SupportsImagesContextKey, false)
 }
 
 // GetModelNameFromContext retrieves the model name from the context.
 func GetModelNameFromContext(ctx context.Context) string {
-	modelName := ctx.Value(ModelNameContextKey)
-	if modelName == nil {
-		return ""
-	}
-	s, ok := modelName.(string)
-	if !ok {
-		return ""
-	}
-	return s
+	return getContextValue(ctx, ModelNameContextKey, "")
 }

internal/agent/tools/view.go 🔗

@@ -10,6 +10,7 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
+	"time"
 	"unicode/utf8"
 
 	"charm.land/fantasy"
@@ -97,7 +98,7 @@ func NewViewTool(
 
 			// Request permission for files outside working directory, unless it's a skill file.
 			if isOutsideWorkDir && !isSkillFile {
-				granted, err := permissions.Request(ctx,
+				granted, permReqErr := permissions.Request(ctx,
 					permission.CreatePermissionRequest{
 						SessionID:   sessionID,
 						Path:        absFilePath,
@@ -108,8 +109,8 @@ func NewViewTool(
 						Params:      ViewPermissionsParams(params),
 					},
 				)
-				if err != nil {
-					return fantasy.ToolResponse{}, err
+				if permReqErr != nil {
+					return fantasy.ToolResponse{}, permReqErr
 				}
 				if !granted {
 					return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
@@ -175,9 +176,9 @@ func NewViewTool(
 					return fantasy.NewTextErrorResponse(fmt.Sprintf("This model (%s) does not support image data.", modelName)), nil
 				}
 
-				imageData, err := os.ReadFile(filePath)
-				if err != nil {
-					return fantasy.ToolResponse{}, fmt.Errorf("error reading image file: %w", err)
+				imageData, readErr := os.ReadFile(filePath)
+				if readErr != nil {
+					return fantasy.ToolResponse{}, fmt.Errorf("error reading image file: %w", readErr)
 				}
 
 				encoded := base64.StdEncoding.EncodeToString(imageData)
@@ -185,22 +186,20 @@ func NewViewTool(
 			}
 
 			// Read the file content
-			content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
+			content, hasMore, err := readTextFile(filePath, params.Offset, params.Limit)
 			if err != nil {
 				return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
 			}
-			isValidUt8 := utf8.ValidString(content)
-			if !isValidUt8 {
+			if !utf8.ValidString(content) {
 				return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
 			}
 
-			notifyLSPs(ctx, lspManager, filePath)
+			openInLSPs(ctx, lspManager, filePath)
+			waitForLSPDiagnostics(ctx, lspManager, filePath, 300*time.Millisecond)
 			output := "<file>\n"
-			// Format the output with line numbers
 			output += addLineNumbers(content, params.Offset+1)
 
-			// Add a note if the content was truncated
-			if lineCount > params.Offset+len(strings.Split(content, "\n")) {
+			if hasMore {
 				output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
 					params.Offset+len(strings.Split(content, "\n")))
 			}
@@ -252,38 +251,28 @@ func addLineNumbers(content string, startLine int) string {
 	return strings.Join(result, "\n")
 }
 
-func readTextFile(filePath string, offset, limit int) (string, int, error) {
+func readTextFile(filePath string, offset, limit int) (string, bool, error) {
 	file, err := os.Open(filePath)
 	if err != nil {
-		return "", 0, err
+		return "", false, err
 	}
 	defer file.Close()
 
-	lineCount := 0
-
 	scanner := NewLineScanner(file)
 	if offset > 0 {
-		for lineCount < offset && scanner.Scan() {
-			lineCount++
+		skipped := 0
+		for skipped < offset && scanner.Scan() {
+			skipped++
 		}
 		if err = scanner.Err(); err != nil {
-			return "", 0, err
-		}
-	}
-
-	if offset == 0 {
-		_, err = file.Seek(0, io.SeekStart)
-		if err != nil {
-			return "", 0, err
+			return "", false, err
 		}
 	}
 
-	// Pre-allocate slice with expected capacity
+	// Pre-allocate slice with expected capacity.
 	lines := make([]string, 0, limit)
-	lineCount = offset
 
-	for scanner.Scan() && len(lines) < limit {
-		lineCount++
+	for len(lines) < limit && scanner.Scan() {
 		lineText := scanner.Text()
 		if len(lineText) > MaxLineLength {
 			lineText = lineText[:MaxLineLength] + "..."
@@ -291,16 +280,14 @@ func readTextFile(filePath string, offset, limit int) (string, int, error) {
 		lines = append(lines, lineText)
 	}
 
-	// Continue scanning to get total line count
-	for scanner.Scan() {
-		lineCount++
-	}
+	// Peek one more line only when we filled the limit.
+	hasMore := len(lines) == limit && scanner.Scan()
 
 	if err := scanner.Err(); err != nil {
-		return "", 0, err
+		return "", false, err
 	}
 
-	return strings.Join(lines, "\n"), lineCount, nil
+	return strings.Join(lines, "\n"), hasMore, nil
 }
 
 func getImageMimeType(filePath string) (bool, string) {

internal/agent/tools/view_test.go 🔗

@@ -0,0 +1,87 @@
+package tools
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestReadTextFileBoundaryCases(t *testing.T) {
+	t.Parallel()
+
+	tmpDir := t.TempDir()
+	filePath := filepath.Join(tmpDir, "sample.txt")
+
+	var allLines []string
+	for i := range 5 {
+		allLines = append(allLines, fmt.Sprintf("line %d", i+1))
+	}
+	require.NoError(t, os.WriteFile(filePath, []byte(strings.Join(allLines, "\n")), 0o644))
+
+	tests := []struct {
+		name        string
+		offset      int
+		limit       int
+		wantContent string
+		wantHasMore bool
+	}{
+		{
+			name:        "exactly limit lines remaining",
+			offset:      0,
+			limit:       5,
+			wantContent: "line 1\nline 2\nline 3\nline 4\nline 5",
+			wantHasMore: false,
+		},
+		{
+			name:        "limit plus one line remaining",
+			offset:      0,
+			limit:       4,
+			wantContent: "line 1\nline 2\nline 3\nline 4",
+			wantHasMore: true,
+		},
+		{
+			name:        "offset at last line",
+			offset:      4,
+			limit:       3,
+			wantContent: "line 5",
+			wantHasMore: false,
+		},
+		{
+			name:        "offset beyond eof",
+			offset:      10,
+			limit:       3,
+			wantContent: "",
+			wantHasMore: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			gotContent, gotHasMore, err := readTextFile(filePath, tt.offset, tt.limit)
+			require.NoError(t, err)
+			require.Equal(t, tt.wantContent, gotContent)
+			require.Equal(t, tt.wantHasMore, gotHasMore)
+		})
+	}
+}
+
+func TestReadTextFileTruncatesLongLines(t *testing.T) {
+	t.Parallel()
+
+	tmpDir := t.TempDir()
+	filePath := filepath.Join(tmpDir, "longline.txt")
+
+	longLine := strings.Repeat("a", MaxLineLength+10)
+	require.NoError(t, os.WriteFile(filePath, []byte(longLine), 0o644))
+
+	content, hasMore, err := readTextFile(filePath, 0, 1)
+	require.NoError(t, err)
+	require.False(t, hasMore)
+	require.Equal(t, strings.Repeat("a", MaxLineLength)+"...", content)
+}

internal/agent/tools/web_fetch.go 🔗

@@ -60,11 +60,11 @@ func NewWebFetchTool(workingDir string, client *http.Client) fantasy.AgentTool {
 					return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to close temporary file: %s", err)), nil
 				}
 
-				result.WriteString(fmt.Sprintf("Fetched content from %s (large page)\n\n", params.URL))
-				result.WriteString(fmt.Sprintf("Content saved to: %s\n\n", tempFilePath))
+				fmt.Fprintf(&result, "Fetched content from %s (large page)\n\n", params.URL)
+				fmt.Fprintf(&result, "Content saved to: %s\n\n", tempFilePath)
 				result.WriteString("Use the view and grep tools to analyze this file.")
 			} else {
-				result.WriteString(fmt.Sprintf("Fetched content from %s:\n\n", params.URL))
+				fmt.Fprintf(&result, "Fetched content from %s:\n\n", params.URL)
 				result.WriteString(content)
 			}
 

internal/cmd/stats.go 🔗

@@ -16,6 +16,7 @@ import (
 
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/event"
 	"github.com/pkg/browser"
 	"github.com/spf13/cobra"
 )
@@ -120,6 +121,8 @@ type HourDayHeatmapPt struct {
 }
 
 func runStats(cmd *cobra.Command, _ []string) error {
+	event.StatsViewed()
+
 	dataDir, _ := cmd.Flags().GetString("data-dir")
 	ctx := cmd.Context()
 

internal/config/config.go 🔗

@@ -792,7 +792,7 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
 	)
 
 	switch providerID {
-	case catwalk.InferenceProviderMiniMax:
+	case catwalk.InferenceProviderMiniMax, catwalk.InferenceProviderMiniMaxChina:
 		// NOTE: MiniMax has no good endpoint we can use to validate the API key.
 		// Let's at least check the pattern.
 		if !strings.HasPrefix(apiKey, "sk-") {

internal/event/all.go 🔗

@@ -57,3 +57,7 @@ func TokensUsed(props ...any) {
 		props...,
 	)
 }
+
+func StatsViewed() {
+	send("stats viewed")
+}

internal/event/event.go 🔗

@@ -84,7 +84,7 @@ func send(event string, props ...any) {
 
 // Error logs an error event to PostHog with the error type and message.
 func Error(errToLog any, props ...any) {
-	if client == nil {
+	if client == nil || errToLog == nil {
 		return
 	}
 	posthogErr := client.Enqueue(posthog.NewDefaultException(

internal/lsp/client.go 🔗

@@ -125,19 +125,38 @@ func (c *Client) Initialize(ctx context.Context, workspaceDir string) (*protocol
 	return result, nil
 }
 
+// closeTimeout is the maximum time to wait for a graceful LSP shutdown.
+const closeTimeout = 5 * time.Second
+
 // Kill kills the client without doing anything else.
 func (c *Client) Kill() { c.client.Kill() }
 
-// Close closes all open files in the client, then the client.
+// Close closes all open files in the client, then shuts down gracefully.
+// If shutdown takes longer than closeTimeout, it falls back to Kill().
 func (c *Client) Close(ctx context.Context) error {
 	c.CloseAllFiles(ctx)
 
-	// Shutdown and exit the client
-	if err := c.client.Shutdown(ctx); err != nil {
-		slog.Warn("Failed to shutdown LSP client", "error", err)
-	}
+	// Use a timeout to prevent hanging on unresponsive LSP servers.
+	// jsonrpc2's send lock doesn't respect context cancellation, so we
+	// need to fall back to Kill() which closes the underlying connection.
+	closeCtx, cancel := context.WithTimeout(ctx, closeTimeout)
+	defer cancel()
+
+	done := make(chan error, 1)
+	go func() {
+		if err := c.client.Shutdown(closeCtx); err != nil {
+			slog.Warn("Failed to shutdown LSP client", "error", err)
+		}
+		done <- c.client.Exit()
+	}()
 
-	return c.client.Exit()
+	select {
+	case err := <-done:
+		return err
+	case <-closeCtx.Done():
+		c.client.Kill()
+		return closeCtx.Err()
+	}
 }
 
 // createPowernapClient creates a new powernap client with the current configuration.
@@ -175,7 +194,7 @@ func (c *Client) createPowernapClient() error {
 
 // registerHandlers registers the standard LSP notification and request handlers.
 func (c *Client) registerHandlers() {
-	c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit)
+	c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit(c.client.GetOffsetEncoding()))
 	c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration)
 	c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability)
 	c.RegisterNotificationHandler("window/showMessage", func(ctx context.Context, method string, params json.RawMessage) {
@@ -277,10 +296,6 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
 	// Set initial state
 	c.SetServerState(StateStarting)
 
-	// Create a context with timeout
-	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
-	defer cancel()
-
 	// Try to ping the server with a simple request
 	ticker := time.NewTicker(500 * time.Millisecond)
 	defer ticker.Stop()
@@ -495,14 +510,9 @@ func (c *Client) RegisterServerRequestHandler(method string, handler transport.H
 
 // openKeyConfigFiles opens important configuration files that help initialize the server.
 func (c *Client) openKeyConfigFiles(ctx context.Context) {
-	wd, err := os.Getwd()
-	if err != nil {
-		return
-	}
-
 	// Try to open each file, ignoring errors if they don't exist
 	for _, file := range c.config.RootMarkers {
-		file = filepath.Join(wd, file)
+		file = filepath.Join(c.cwd, file)
 		if _, err := os.Stat(file); err == nil {
 			// File exists, try to open it
 			if err := c.OpenFile(ctx, file); err != nil {
@@ -542,6 +552,11 @@ func (c *Client) FindReferences(ctx context.Context, filepath string, line, char
 	if err := c.OpenFileOnDemand(ctx, filepath); err != nil {
 		return nil, err
 	}
+
+	// Add timeout to prevent hanging on slow LSP servers.
+	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+	defer cancel()
+
 	// NOTE: line and character should be 0-based.
 	// See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position
 	return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)

internal/lsp/handlers.go 🔗

@@ -6,6 +6,7 @@ import (
 	"log/slog"
 
 	"github.com/charmbracelet/crush/internal/lsp/util"
+	powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 )
 
@@ -44,19 +45,21 @@ func HandleRegisterCapability(_ context.Context, _ string, params json.RawMessag
 }
 
 // HandleApplyEdit handles workspace edit requests
-func HandleApplyEdit(_ context.Context, _ string, params json.RawMessage) (any, error) {
-	var edit protocol.ApplyWorkspaceEditParams
-	if err := json.Unmarshal(params, &edit); err != nil {
-		return nil, err
-	}
+func HandleApplyEdit(encoding powernap.OffsetEncoding) func(_ context.Context, _ string, params json.RawMessage) (any, error) {
+	return func(_ context.Context, _ string, params json.RawMessage) (any, error) {
+		var edit protocol.ApplyWorkspaceEditParams
+		if err := json.Unmarshal(params, &edit); err != nil {
+			return nil, err
+		}
 
-	err := util.ApplyWorkspaceEdit(edit.Edit)
-	if err != nil {
-		slog.Error("Error applying workspace edit", "error", err)
-		return protocol.ApplyWorkspaceEditResult{Applied: false, FailureReason: err.Error()}, nil
-	}
+		err := util.ApplyWorkspaceEdit(edit.Edit, encoding)
+		if err != nil {
+			slog.Error("Error applying workspace edit", "error", err)
+			return protocol.ApplyWorkspaceEditResult{Applied: false, FailureReason: err.Error()}, nil
+		}
 
-	return protocol.ApplyWorkspaceEditResult{Applied: true}, nil
+		return protocol.ApplyWorkspaceEditResult{Applied: true}, nil
+	}
 }
 
 // FileWatchRegistrationHandler is a function that will be called when file watch registrations are received
@@ -81,7 +84,7 @@ func notifyFileWatchRegistration(id string, watchers []protocol.FileSystemWatche
 func HandleServerMessage(_ context.Context, method string, params json.RawMessage) {
 	var msg protocol.ShowMessageParams
 	if err := json.Unmarshal(params, &msg); err != nil {
-		slog.Debug("Server message", "type", msg.Type, "message", msg.Message)
+		slog.Debug("Error unmarshal server message", "error", err)
 		return
 	}
 

internal/lsp/manager.go 🔗

@@ -59,9 +59,10 @@ func NewManager(cfg *config.Config) *Manager {
 	}
 
 	return &Manager{
-		clients: csync.NewMap[string, *Client](),
-		cfg:     cfg,
-		manager: manager,
+		clients:  csync.NewMap[string, *Client](),
+		cfg:      cfg,
+		manager:  manager,
+		callback: func(string, *Client) {}, // default no-op callback
 	}
 }
 
@@ -204,7 +205,7 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server
 	if existing, ok := s.clients.Get(name); ok {
 		switch existing.GetServerState() {
 		case StateReady, StateStarting, StateDisabled:
-			client.Close(ctx)
+			_ = client.Close(ctx)
 			s.callback(name, existing)
 			return
 		}
@@ -227,7 +228,8 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server
 
 	if _, err := client.Initialize(initCtx, s.cfg.WorkingDir()); err != nil {
 		slog.Error("LSP client initialization failed", "name", name, "error", err)
-		client.Close(ctx)
+		_ = client.Close(ctx)
+		s.clients.Del(name)
 		return
 	}
 
@@ -301,8 +303,10 @@ func hasRootMarkers(dir string, markers []string) bool {
 		return true
 	}
 	for _, pattern := range markers {
-		// Use fsext.GlobWithDoubleStar to find matches
-		matches, _, err := fsext.Glob(pattern, dir, 1)
+		// Use filepath.Glob for a non-recursive check in the root
+		// directory. This avoids walking the entire tree (which is
+		// catastrophic in large monorepos with node_modules, etc.).
+		matches, err := filepath.Glob(filepath.Join(dir, pattern))
 		if err == nil && len(matches) > 0 {
 			return true
 		}

internal/lsp/util/edit.go 🔗

@@ -7,10 +7,11 @@ import (
 	"sort"
 	"strings"
 
+	powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 )
 
-func applyTextEdits(uri protocol.DocumentURI, edits []protocol.TextEdit) error {
+func applyTextEdits(uri protocol.DocumentURI, edits []protocol.TextEdit, encoding powernap.OffsetEncoding) error {
 	path, err := uri.Path()
 	if err != nil {
 		return fmt.Errorf("invalid URI: %w", err)
@@ -57,7 +58,7 @@ func applyTextEdits(uri protocol.DocumentURI, edits []protocol.TextEdit) error {
 
 	// Apply each edit
 	for _, edit := range sortedEdits {
-		newLines, err := applyTextEdit(lines, edit)
+		newLines, err := applyTextEdit(lines, edit, encoding)
 		if err != nil {
 			return fmt.Errorf("failed to apply edit: %w", err)
 		}
@@ -85,13 +86,11 @@ func applyTextEdits(uri protocol.DocumentURI, edits []protocol.TextEdit) error {
 	return nil
 }
 
-func applyTextEdit(lines []string, edit protocol.TextEdit) ([]string, error) {
+func applyTextEdit(lines []string, edit protocol.TextEdit, encoding powernap.OffsetEncoding) ([]string, error) {
 	startLine := int(edit.Range.Start.Line)
 	endLine := int(edit.Range.End.Line)
-	startChar := int(edit.Range.Start.Character)
-	endChar := int(edit.Range.End.Character)
 
-	// Validate positions
+	// Validate positions before accessing lines.
 	if startLine < 0 || startLine >= len(lines) {
 		return nil, fmt.Errorf("invalid start line: %d", startLine)
 	}
@@ -99,6 +98,26 @@ func applyTextEdit(lines []string, edit protocol.TextEdit) ([]string, error) {
 		endLine = len(lines) - 1
 	}
 
+	var startChar, endChar int
+	switch encoding {
+	case powernap.UTF8:
+		// UTF-8: Character offset is already a byte offset
+		startChar = int(edit.Range.Start.Character)
+		endChar = int(edit.Range.End.Character)
+	case powernap.UTF16:
+		// UTF-16 (default): Convert to byte offset
+		startLineContent := lines[startLine]
+		endLineContent := lines[endLine]
+		startChar = powernap.PositionToByteOffset(startLineContent, edit.Range.Start.Character)
+		endChar = powernap.PositionToByteOffset(endLineContent, edit.Range.End.Character)
+	default:
+		// UTF-32: Character offset is codepoint count, convert to byte offset
+		startLineContent := lines[startLine]
+		endLineContent := lines[endLine]
+		startChar = utf32ToByteOffset(startLineContent, edit.Range.Start.Character)
+		endChar = utf32ToByteOffset(endLineContent, edit.Range.End.Character)
+	}
+
 	// Create result slice with initial capacity
 	result := make([]string, 0, len(lines))
 
@@ -149,7 +168,7 @@ func applyTextEdit(lines []string, edit protocol.TextEdit) ([]string, error) {
 }
 
 // applyDocumentChange applies a DocumentChange (create/rename/delete operations)
-func applyDocumentChange(change protocol.DocumentChange) error {
+func applyDocumentChange(change protocol.DocumentChange, encoding powernap.OffsetEncoding) error {
 	if change.CreateFile != nil {
 		path, err := change.CreateFile.URI.Path()
 		if err != nil {
@@ -222,24 +241,42 @@ func applyDocumentChange(change protocol.DocumentChange) error {
 				return fmt.Errorf("invalid edit type: %w", err)
 			}
 		}
-		return applyTextEdits(change.TextDocumentEdit.TextDocument.URI, textEdits)
+		return applyTextEdits(change.TextDocumentEdit.TextDocument.URI, textEdits, encoding)
 	}
 
 	return nil
 }
 
-// ApplyWorkspaceEdit applies the given WorkspaceEdit to the filesystem
-func ApplyWorkspaceEdit(edit protocol.WorkspaceEdit) error {
+// utf32ToByteOffset converts a UTF-32 codepoint offset to a byte offset.
+func utf32ToByteOffset(lineText string, codepointOffset uint32) int {
+	if codepointOffset == 0 {
+		return 0
+	}
+
+	var codepointCount uint32
+	for byteOffset := range lineText {
+		if codepointCount >= codepointOffset {
+			return byteOffset
+		}
+		codepointCount++
+	}
+	return len(lineText)
+}
+
+// ApplyWorkspaceEdit applies the given WorkspaceEdit to the filesystem.
+// The encoding parameter specifies the position encoding used by the LSP server
+// (UTF8, UTF16, or UTF32). This affects how character offsets are interpreted.
+func ApplyWorkspaceEdit(edit protocol.WorkspaceEdit, encoding powernap.OffsetEncoding) error {
 	// Handle Changes field
 	for uri, textEdits := range edit.Changes {
-		if err := applyTextEdits(uri, textEdits); err != nil {
+		if err := applyTextEdits(uri, textEdits, encoding); err != nil {
 			return fmt.Errorf("failed to apply text edits: %w", err)
 		}
 	}
 
 	// Handle DocumentChanges field
 	for _, change := range edit.DocumentChanges {
-		if err := applyDocumentChange(change); err != nil {
+		if err := applyDocumentChange(change, encoding); err != nil {
 			return fmt.Errorf("failed to apply document change: %w", err)
 		}
 	}
@@ -247,14 +284,18 @@ func ApplyWorkspaceEdit(edit protocol.WorkspaceEdit) error {
 	return nil
 }
 
+// rangesOverlap checks if two LSP ranges overlap.
+// Per the LSP specification, ranges are half-open intervals [start, end),
+// so adjacent ranges where one's end equals another's start do NOT overlap.
+// See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#range
 func rangesOverlap(r1, r2 protocol.Range) bool {
 	if r1.Start.Line > r2.End.Line || r2.Start.Line > r1.End.Line {
 		return false
 	}
-	if r1.Start.Line == r2.End.Line && r1.Start.Character > r2.End.Character {
+	if r1.Start.Line == r2.End.Line && r1.Start.Character >= r2.End.Character {
 		return false
 	}
-	if r2.Start.Line == r1.End.Line && r2.Start.Character > r1.End.Character {
+	if r2.Start.Line == r1.End.Line && r2.Start.Character >= r1.End.Character {
 		return false
 	}
 	return true

internal/lsp/util/edit_test.go 🔗

@@ -0,0 +1,376 @@
+package util
+
+import (
+	"testing"
+
+	powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
+	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPositionToByteOffset(t *testing.T) {
+	tests := []struct {
+		name      string
+		lineText  string
+		utf16Char uint32
+		expected  int
+	}{
+		{
+			name:      "ASCII only",
+			lineText:  "hello world",
+			utf16Char: 6,
+			expected:  6,
+		},
+		{
+			name:      "CJK characters (3 bytes each in UTF-8, 1 UTF-16 unit)",
+			lineText:  "你好world",
+			utf16Char: 2,
+			expected:  6,
+		},
+		{
+			name:      "CJK - position after CJK",
+			lineText:  "var x = \"你好world\"",
+			utf16Char: 11,
+			expected:  15,
+		},
+		{
+			name:      "Emoji (4 bytes in UTF-8, 2 UTF-16 units)",
+			lineText:  "👋hello",
+			utf16Char: 2,
+			expected:  4,
+		},
+		{
+			name:      "Multiple emoji",
+			lineText:  "👋👋world",
+			utf16Char: 4,
+			expected:  8,
+		},
+		{
+			name:      "Mixed content",
+			lineText:  "Hello👋你好",
+			utf16Char: 8,
+			expected:  12,
+		},
+		{
+			name:      "Position 0",
+			lineText:  "hello",
+			utf16Char: 0,
+			expected:  0,
+		},
+		{
+			name:      "Position beyond end",
+			lineText:  "hi",
+			utf16Char: 100,
+			expected:  2,
+		},
+		{
+			name:      "Empty string",
+			lineText:  "",
+			utf16Char: 0,
+			expected:  0,
+		},
+		{
+			name:      "Surrogate pair at start",
+			lineText:  "𐐷hello",
+			utf16Char: 2,
+			expected:  4,
+		},
+		{
+			name:      "ZWJ family emoji (1 grapheme, 7 runes, 11 UTF-16 units)",
+			lineText:  "hello👨\u200d👩\u200d👧\u200d👦world",
+			utf16Char: 16,
+			expected:  30,
+		},
+		{
+			name:      "ZWJ family emoji - offset into middle of grapheme cluster",
+			lineText:  "hello👨\u200d👩\u200d👧\u200d👦world",
+			utf16Char: 8,
+			expected:  12,
+		},
+		{
+			name:      "Flag emoji (1 grapheme, 2 runes, 4 UTF-16 units)",
+			lineText:  "hello🇺🇸world",
+			utf16Char: 9,
+			expected:  13,
+		},
+		{
+			name:      "Combining character (1 grapheme, 2 runes, 2 UTF-16 units)",
+			lineText:  "caf\u0065\u0301!",
+			utf16Char: 5,
+			expected:  6,
+		},
+		{
+			name:      "Skin tone modifier (1 grapheme, 2 runes, 4 UTF-16 units)",
+			lineText:  "hi👋🏽bye",
+			utf16Char: 6,
+			expected:  10,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := powernap.PositionToByteOffset(tt.lineText, tt.utf16Char)
+			if result != tt.expected {
+				t.Errorf("PositionToByteOffset(%q, %d) = %d, want %d",
+					tt.lineText, tt.utf16Char, result, tt.expected)
+			}
+		})
+	}
+}
+
+func TestApplyTextEdit_UTF16(t *testing.T) {
+	// Test that UTF-16 offsets are correctly converted to byte offsets
+	tests := []struct {
+		name     string
+		lines    []string
+		edit     protocol.TextEdit
+		expected []string
+	}{
+		{
+			name:  "ASCII only - no conversion needed",
+			lines: []string{"hello world"},
+			edit: protocol.TextEdit{
+				Range: protocol.Range{
+					Start: protocol.Position{Line: 0, Character: 6},
+					End:   protocol.Position{Line: 0, Character: 11},
+				},
+				NewText: "universe",
+			},
+			expected: []string{"hello universe"},
+		},
+		{
+			name:  "CJK characters - edit after Chinese characters",
+			lines: []string{`var x = "你好world"`},
+			edit: protocol.TextEdit{
+				Range: protocol.Range{
+					// "你好" = 2 UTF-16 units, but 6 bytes in UTF-8
+					// Position 11 is where "world" starts in UTF-16
+					Start: protocol.Position{Line: 0, Character: 11},
+					End:   protocol.Position{Line: 0, Character: 16},
+				},
+				NewText: "universe",
+			},
+			expected: []string{`var x = "你好universe"`},
+		},
+		{
+			name:  "Emoji - edit after emoji (2 UTF-16 units)",
+			lines: []string{`fmt.Println("👋hello")`},
+			edit: protocol.TextEdit{
+				Range: protocol.Range{
+					// 👋 = 2 UTF-16 units, 4 bytes in UTF-8
+					// Position 15 is where "hello" starts in UTF-16
+					Start: protocol.Position{Line: 0, Character: 15},
+					End:   protocol.Position{Line: 0, Character: 20},
+				},
+				NewText: "world",
+			},
+			expected: []string{`fmt.Println("👋world")`},
+		},
+		{
+			name: "ZWJ family emoji - edit after grapheme cluster",
+			// "hello👨‍👩‍👧‍👦world" — family is 1 grapheme but 11 UTF-16 units
+			lines: []string{"hello\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466world"},
+			edit: protocol.TextEdit{
+				Range: protocol.Range{
+					// "hello" = 5 UTF-16 units, family = 11 UTF-16 units
+					// "world" starts at UTF-16 offset 16
+					Start: protocol.Position{Line: 0, Character: 16},
+					End:   protocol.Position{Line: 0, Character: 21},
+				},
+				NewText: "earth",
+			},
+			expected: []string{"hello\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466earth"},
+		},
+		{
+			name: "ZWJ family emoji - edit splits grapheme cluster in half",
+			// LSP servers can position into the middle of a grapheme cluster.
+			// After "hello" (5 UTF-16 units), the ZWJ family emoji starts.
+			// UTF-16 offset 7 lands between 👨 (2 units) and ZWJ, inside
+			// the grapheme cluster. The byte offset for position 7 is 9
+			// (5 bytes for "hello" + 4 bytes for 👨).
+			lines: []string{"hello\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466world"},
+			edit: protocol.TextEdit{
+				Range: protocol.Range{
+					Start: protocol.Position{Line: 0, Character: 7},
+					End:   protocol.Position{Line: 0, Character: 16},
+				},
+				NewText: "",
+			},
+			// Keeps "hello" + 👨 (first rune of cluster) then removes
+			// the rest of the cluster, leaving "hello👨world".
+			expected: []string{"hello\U0001F468world"},
+		},
+		{
+			name: "Flag emoji - edit after flag",
+			// 🇺🇸 = 2 regional indicator runes, 4 UTF-16 units, 8 bytes
+			lines: []string{"hello🇺🇸world"},
+			edit: protocol.TextEdit{
+				Range: protocol.Range{
+					Start: protocol.Position{Line: 0, Character: 9},
+					End:   protocol.Position{Line: 0, Character: 14},
+				},
+				NewText: "earth",
+			},
+			expected: []string{"hello🇺🇸earth"},
+		},
+		{
+			name: "Combining accent - edit after composed character",
+			// "café!" where é = e + U+0301 (2 code points, 2 UTF-16 units)
+			lines: []string{"caf\u0065\u0301!"},
+			edit: protocol.TextEdit{
+				Range: protocol.Range{
+					// "caf" = 3, "e" = 1, U+0301 = 1, total = 5 UTF-16 units
+					Start: protocol.Position{Line: 0, Character: 5},
+					End:   protocol.Position{Line: 0, Character: 6},
+				},
+				NewText: "?",
+			},
+			expected: []string{"caf\u0065\u0301?"},
+		},
+		{
+			name: "Skin tone modifier - edit after modified emoji",
+			// 👋🏽 = U+1F44B U+1F3FD = 2 runes, 4 UTF-16 units, 8 bytes
+			lines: []string{"hi👋🏽bye"},
+			edit: protocol.TextEdit{
+				Range: protocol.Range{
+					// "hi" = 2, 👋🏽 = 4, total = 6 UTF-16 units
+					Start: protocol.Position{Line: 0, Character: 6},
+					End:   protocol.Position{Line: 0, Character: 9},
+				},
+				NewText: "later",
+			},
+			expected: []string{"hi👋🏽later"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result, err := applyTextEdit(tt.lines, tt.edit, powernap.UTF16)
+			if err != nil {
+				t.Fatalf("applyTextEdit failed: %v", err)
+			}
+			if len(result) != len(tt.expected) {
+				t.Errorf("expected %d lines, got %d: %v", len(tt.expected), len(result), result)
+				return
+			}
+			for i := range result {
+				if result[i] != tt.expected[i] {
+					t.Errorf("line %d: expected %q, got %q", i, tt.expected[i], result[i])
+				}
+			}
+		})
+	}
+}
+
+func TestApplyTextEdit_UTF8(t *testing.T) {
+	// Test that UTF-8 offsets are used directly without conversion
+	tests := []struct {
+		name     string
+		lines    []string
+		edit     protocol.TextEdit
+		expected []string
+	}{
+		{
+			name:  "ASCII only - direct byte offset",
+			lines: []string{"hello world"},
+			edit: protocol.TextEdit{
+				Range: protocol.Range{
+					Start: protocol.Position{Line: 0, Character: 6},
+					End:   protocol.Position{Line: 0, Character: 11},
+				},
+				NewText: "universe",
+			},
+			expected: []string{"hello universe"},
+		},
+		{
+			name:  "CJK characters - byte offset used directly",
+			lines: []string{`var x = "你好world"`},
+			edit: protocol.TextEdit{
+				Range: protocol.Range{
+					// With UTF-8 encoding, position 15 is the byte offset
+					Start: protocol.Position{Line: 0, Character: 15},
+					End:   protocol.Position{Line: 0, Character: 20},
+				},
+				NewText: "universe",
+			},
+			expected: []string{`var x = "你好universe"`},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result, err := applyTextEdit(tt.lines, tt.edit, powernap.UTF8)
+			if err != nil {
+				t.Fatalf("applyTextEdit failed: %v", err)
+			}
+			if len(result) != len(tt.expected) {
+				t.Errorf("expected %d lines, got %d: %v", len(tt.expected), len(result), result)
+				return
+			}
+			for i := range result {
+				if result[i] != tt.expected[i] {
+					t.Errorf("line %d: expected %q, got %q", i, tt.expected[i], result[i])
+				}
+			}
+		})
+	}
+}
+
+func TestRangesOverlap(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name string
+		r1   protocol.Range
+		r2   protocol.Range
+		want bool
+	}{
+		{
+			name: "adjacent ranges do not overlap",
+			r1: protocol.Range{
+				Start: protocol.Position{Line: 0, Character: 0},
+				End:   protocol.Position{Line: 0, Character: 5},
+			},
+			r2: protocol.Range{
+				Start: protocol.Position{Line: 0, Character: 5},
+				End:   protocol.Position{Line: 0, Character: 10},
+			},
+			want: false,
+		},
+		{
+			name: "overlapping ranges",
+			r1: protocol.Range{
+				Start: protocol.Position{Line: 0, Character: 0},
+				End:   protocol.Position{Line: 0, Character: 8},
+			},
+			r2: protocol.Range{
+				Start: protocol.Position{Line: 0, Character: 5},
+				End:   protocol.Position{Line: 0, Character: 10},
+			},
+			want: true,
+		},
+		{
+			name: "non-overlapping with gap",
+			r1: protocol.Range{
+				Start: protocol.Position{Line: 0, Character: 0},
+				End:   protocol.Position{Line: 0, Character: 3},
+			},
+			r2: protocol.Range{
+				Start: protocol.Position{Line: 0, Character: 7},
+				End:   protocol.Position{Line: 0, Character: 10},
+			},
+			want: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			got := rangesOverlap(tt.r1, tt.r2)
+			require.Equal(t, tt.want, got, "rangesOverlap(r1, r2)")
+			// Overlap should be symmetric
+			got2 := rangesOverlap(tt.r2, tt.r1)
+			require.Equal(t, tt.want, got2, "rangesOverlap(r2, r1) symmetry")
+		})
+	}
+}

internal/shell/background.go 🔗

@@ -234,3 +234,12 @@ func (bs *BackgroundShell) IsDone() bool {
 func (bs *BackgroundShell) Wait() {
 	<-bs.done
 }
+
+func (bs *BackgroundShell) WaitContext(ctx context.Context) bool {
+	select {
+	case <-bs.done:
+		return true
+	case <-ctx.Done():
+		return false
+	}
+}

internal/shell/background_test.go 🔗

@@ -307,3 +307,28 @@ func TestBackgroundShellManager_KillAll_Timeout(t *testing.T) {
 	// Must return promptly after timeout, not hang for 60 seconds.
 	require.Less(t, elapsed, 2*time.Second)
 }
+
+func TestBackgroundShell_WaitContext_Completed(t *testing.T) {
+	t.Parallel()
+
+	done := make(chan struct{})
+	close(done)
+
+	bgShell := &BackgroundShell{done: done}
+
+	ctx, cancel := context.WithTimeout(t.Context(), time.Second)
+	t.Cleanup(cancel)
+
+	require.True(t, bgShell.WaitContext(ctx))
+}
+
+func TestBackgroundShell_WaitContext_Canceled(t *testing.T) {
+	t.Parallel()
+
+	bgShell := &BackgroundShell{done: make(chan struct{})}
+
+	ctx, cancel := context.WithCancel(t.Context())
+	cancel()
+
+	require.False(t, bgShell.WaitContext(ctx))
+}

internal/ui/chat/tools.go 🔗

@@ -1339,7 +1339,7 @@ func (t *baseToolMessageItem) formatWebFetchResultForCopy() string {
 	}
 
 	var result strings.Builder
-	result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL))
+	fmt.Fprintf(&result, "URL: %s\n\n", params.URL)
 	result.WriteString("```markdown\n")
 	result.WriteString(t.result.Content)
 	result.WriteString("\n```")
@@ -1356,7 +1356,7 @@ func (t *baseToolMessageItem) formatAgentResultForCopy() string {
 	var result strings.Builder
 
 	if t.result.Content != "" {
-		result.WriteString(fmt.Sprintf("```markdown\n%s\n```", t.result.Content))
+		fmt.Fprintf(&result, "```markdown\n%s\n```", t.result.Content)
 	}
 
 	return result.String()

internal/ui/model/status.go 🔗

@@ -1,6 +1,7 @@
 package model
 
 import (
+	"strings"
 	"time"
 
 	"charm.land/bubbles/v2/help"
@@ -99,9 +100,10 @@ func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) {
 	}
 
 	ind := indStyle.String()
-	messageWidth := area.Dx() - lipgloss.Width(ind)
+	messageWidth := max(0, area.Dx()-lipgloss.Width(ind)-msgStyle.GetHorizontalPadding())
 	msg := ansi.Truncate(s.msg.Msg, messageWidth, "…")
-	info := msgStyle.Width(messageWidth).Render(msg)
+	msg += strings.Repeat(" ", max(0, messageWidth-lipgloss.Width(msg)))
+	info := msgStyle.Render(msg)
 
 	// Draw the info message over the help view
 	uv.NewStyledString(ind+info).Draw(scr, area)

internal/ui/model/ui.go 🔗

@@ -554,6 +554,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		m.width, m.height = msg.Width, msg.Height
 		m.updateLayoutAndSize()
+		if m.state == uiChat && m.chat.Follow() {
+			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
 	case tea.KeyboardEnhancementsMsg:
 		m.keyenh = msg
 		if msg.SupportsKeyDisambiguation() {
@@ -678,7 +683,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					cmds = append(cmds, cmd)
 				}
 				if !m.chat.SelectedItemInView() {
-					m.chat.SelectNext()
+					if m.chat.AtBottom() {
+						m.chat.SelectLast()
+					} else {
+						m.chat.SelectNext()
+					}
 					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 						cmds = append(cmds, cmd)
 					}
@@ -690,6 +699,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			if cmd := m.chat.Animate(msg); cmd != nil {
 				cmds = append(cmds, cmd)
 			}
+			if m.chat.Follow() {
+				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			}
 		}
 	case spinner.TickMsg:
 		if m.dialog.HasDialogs() {
@@ -820,7 +834,7 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
 		cmds = append(cmds, cmd)
 	}
 	m.chat.SelectLast()
-	return tea.Batch(cmds...)
+	return tea.Sequence(cmds...)
 }
 
 // loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
@@ -948,7 +962,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 			}
 		}
 	}
-	return tea.Batch(cmds...)
+	return tea.Sequence(cmds...)
 }
 
 func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
@@ -1027,16 +1041,16 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 			cmds = append(cmds, cmd)
 		}
+		m.chat.SelectLast()
 	}
 
-	return tea.Batch(cmds...)
+	return tea.Sequence(cmds...)
 }
 
 // handleChildSessionMessage handles messages from child sessions (agent tools).
 func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
 	var cmds []tea.Cmd
 
-	atBottom := m.chat.AtBottom()
 	// Only process messages with tool calls or results.
 	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
 		return nil
@@ -1116,13 +1130,14 @@ func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.
 	// Update the chat so it updates the index map for animations to work as expected
 	m.chat.UpdateNestedToolIDs(toolCallID)
 
-	if atBottom {
+	if m.chat.Follow() {
 		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 			cmds = append(cmds, cmd)
 		}
+		m.chat.SelectLast()
 	}
 
-	return tea.Batch(cmds...)
+	return tea.Sequence(cmds...)
 }
 
 func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
@@ -1808,7 +1823,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		handleGlobalKeys(msg)
 	}
 
-	return tea.Batch(cmds...)
+	return tea.Sequence(cmds...)
 }
 
 // drawHeader draws the header section of the UI.