From df6db40dce21f69e685db6240c992d19701da38d Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 22 Mar 2026 16:43:01 -0400 Subject: [PATCH] refactor: replace MCP tools with session CLI transport --- README.md | 7 +- bun.lock | 183 --------- package.json | 1 - skills/hunk-review/SKILL.md | 42 +- skills/hunk-review/references/commands.md | 2 +- skills/hunk-review/references/mcp-review.md | 59 +-- src/core/cli.ts | 6 +- src/mcp/server.ts | 426 ++++++-------------- src/session/commands.ts | 393 ++++++++---------- src/session/protocol.ts | 93 +++++ test/mcp-e2e.test.ts | 303 +++++++------- test/mcp-server.test.ts | 46 ++- test/session-commands.test.ts | 55 +-- 13 files changed, 658 insertions(+), 958 deletions(-) create mode 100644 src/session/protocol.ts diff --git a/README.md b/README.md index 164e7857..e65fcd31 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Then load it in Pi with: /skill:hunk-review ``` -The skill explains what Hunk is and how to use Hunk's MCP tools for live code review. +The skill explains what Hunk is and how to use `hunk session ...` for live code review. ## Config @@ -144,13 +144,14 @@ agent_notes = false ## Advanced workflows - `hunk diff --agent-context ` loads inline agent rationale from a JSON sidecar -- `hunk mcp serve` runs the local MCP daemon for agent-to-diff communication +- `hunk mcp serve` runs the local Hunk session daemon and websocket broker + - normal Hunk sessions auto-start/register with it when MCP is enabled - Hunk keeps the daemon loopback-only by default - if you intentionally need remote access, set `HUNK_MCP_UNSAFE_ALLOW_REMOTE=1` and choose a non-loopback `HUNK_MCP_HOST` ### Live session control CLI -`hunk session ...` is the human/script interface to the same local daemon that MCP uses for live review sessions. +`hunk session ...` is the user-facing and agent-facing interface to Hunk's local live review session daemon. Use explicit session targeting with either a live `` or `--repo ` when exactly one live session matches that repo root. diff --git a/bun.lock b/bun.lock index f7ac9f23..1543644c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,6 @@ "": { "name": "hunk", "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", "@opentui/core": "^0.1.88", "@opentui/react": "^0.1.88", "@pierre/diffs": "^1.1.0", @@ -25,8 +24,6 @@ "packages": { "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], - "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], - "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], @@ -83,8 +80,6 @@ "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], - "@opentui/core": ["@opentui/core@0.1.88", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.88", "@opentui/core-darwin-x64": "0.1.88", "@opentui/core-linux-arm64": "0.1.88", "@opentui/core-linux-x64": "0.1.88", "@opentui/core-win32-arm64": "0.1.88", "@opentui/core-win32-x64": "0.1.88", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-eaDVZfAzZraddOIkgWSHMVkyaY0O20foYnPWKPQx1TY4t7G1oatIoan2zkytx67epW+4BZQ9vGib+61/uNM1MA=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.88", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oGRexWwZFeQJymOK5ORrLrwJUbPHMYaFa0EcLnlhvPnymm1xyMcRKm39ez0WSIdtiCCi/PmMHX95CfyyJB5VMA=="], @@ -165,12 +160,6 @@ "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], - "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - - "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - - "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], @@ -179,8 +168,6 @@ "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "bun": ["bun@1.3.11", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.11", "@oven/bun-darwin-x64": "1.3.11", "@oven/bun-darwin-x64-baseline": "1.3.11", "@oven/bun-linux-aarch64": "1.3.11", "@oven/bun-linux-aarch64-musl": "1.3.11", "@oven/bun-linux-x64": "1.3.11", "@oven/bun-linux-x64-baseline": "1.3.11", "@oven/bun-linux-x64-musl": "1.3.11", "@oven/bun-linux-x64-musl-baseline": "1.3.11", "@oven/bun-windows-aarch64": "1.3.11", "@oven/bun-windows-x64": "1.3.11", "@oven/bun-windows-x64-baseline": "1.3.11" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-AvXWYFO6j/ZQ7bhGm4X6eilq2JHsDVC90ZM32k2B7/srhC2gs3Sdki1QTbwrdRCo8o7eT+167vcB1yzOvPdbjA=="], @@ -199,12 +186,6 @@ "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-zvnUl4EAsQbKsmZVu+lEJcH8axQ7MiCfqg2OmnHd6uw1THABmHaX0GbpKiHshdgadNN2Nf+4zDyTJB5YMcAdrA=="], - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], @@ -215,134 +196,44 @@ "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], - - "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - - "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - - "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - - "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], - "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], - - "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], - "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], - "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - - "express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="], - - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], - "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - - "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], - "hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="], - "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], - - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], - "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], - "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], - "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], - "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - - "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], @@ -355,24 +246,8 @@ "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="], @@ -385,18 +260,10 @@ "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], - "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], - "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], - "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "planck": ["planck@1.4.3", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-B+lHKhRSeg7vZOfEyEzyQVu7nx8JHcX3QgnAcHXrPW0j04XYKX5eXSiUrxH2Z5QR8OoqvjD6zKIaPMdMYAd0uA=="], "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], @@ -405,14 +272,6 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - - "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], - - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - - "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], @@ -431,48 +290,22 @@ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], - "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - - "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - - "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], - - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], "stage-js": ["stage-js@1.0.1", "", {}, "sha512-cz14aPp/wY0s3bkb/B93BPP5ZAEhgBbRmAT3CCDqert8eCAqIpQ0RB2zpK8Ksxf+Pisl5oTzvPHtL4CVzzeHcw=="], - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], @@ -483,14 +316,10 @@ "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], - "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], - "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], @@ -505,22 +334,14 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], - "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], - "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], @@ -533,8 +354,6 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], - "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -569,8 +388,6 @@ "@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@modelcontextprotocol/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@opentui/core/diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], diff --git a/package.json b/package.json index 42a45d2c..e302d57f 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,6 @@ "typescript": "^5.9.3" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", "@opentui/core": "^0.1.88", "@opentui/react": "^0.1.88", "@pierre/diffs": "^1.1.0", diff --git a/skills/hunk-review/SKILL.md b/skills/hunk-review/SKILL.md index 945fcb6f..0029b121 100644 --- a/skills/hunk-review/SKILL.md +++ b/skills/hunk-review/SKILL.md @@ -1,6 +1,6 @@ --- name: hunk-review -description: Use when the task involves Hunk or Hunk MCP review sessions. Helps Pi briefly explain what Hunk is, prefer live Hunk MCP inspection over shell parsing, inspect current review focus, navigate hunks, and leave inline review comments. +description: Use when the task involves Hunk review sessions. Helps Pi briefly explain what Hunk is, prefer live Hunk session CLI inspection over shell parsing, inspect current review focus, navigate hunks, and leave inline review comments. compatibility: Requires Hunk from this repo or the published hunkdiff package. Works best with a real TTY for interactive review. --- @@ -8,7 +8,7 @@ compatibility: Requires Hunk from this repo or the published hunkdiff package. W Use this skill when working with Hunk itself or when the user wants a code-review workflow centered on Hunk. -When this skill activates, start by briefly explaining what Hunk is in plain language before jumping into MCP details. +When this skill activates, start by briefly explaining what Hunk is in plain language before jumping into session-control details. ## What Hunk is @@ -21,42 +21,42 @@ Keep these product rules in mind: - `[` and `]` navigate hunks across the full review stream - agent notes belong beside the code they explain -## Default rule: prefer live MCP review +## Default rule: prefer live session CLI review -If a live Hunk session already exists, prefer Hunk's MCP tools over launching new shell commands or scraping terminal output. +If a live Hunk session already exists, prefer `hunk session ...` over launching new shell commands that scrape terminal output. -The MCP daemon is local-only and brokers commands to one or more live Hunk sessions. +The local Hunk daemon is loopback-only by default and brokers commands to one or more live Hunk sessions. Important behavior: - normal Hunk sessions auto-start and register with the daemon when MCP is enabled -- `hunk mcp serve` exists for manual startup or debugging -- `HUNK_MCP_DISABLE=1` disables MCP registration for a session +- `hunk mcp serve` exists for manual startup or debugging, but it is not the default review path +- `HUNK_MCP_DISABLE=1` disables daemon registration for a session - one daemon can serve many Hunk sessions -## Recommended MCP review loop +## Recommended review loop Use this flow by default: -1. `list_sessions` -2. `get_selected_context` -3. `navigate_to_hunk` only if the current focus is wrong -4. `comment` +1. `hunk session list` +2. `hunk session context` +3. `hunk session navigate` only if the current focus is wrong +4. `hunk session comment add` -Use `get_session` only when you need broader session metadata. +Use `hunk session get` only when you need broader session metadata. Guidelines: - if multiple sessions are live, pass `sessionId` explicitly -- prefer `get_selected_context` before navigating blindly -- use `navigate_to_hunk` for hunk-level movement; do not invent extra remote-control behavior -- use `comment` for concise inline review notes tied to real diff lines -- prefer `reveal: true` unless the user wants a quieter action +- prefer `hunk session context` before navigating blindly +- use `hunk session navigate` for hunk-level movement; do not invent extra remote-control behavior +- use `hunk session comment add` for concise inline review notes tied to real diff lines +- prefer visible, review-oriented actions over shell parsing of rendered terminal output -For concrete MCP tool behavior and examples, read [references/mcp-review.md](references/mcp-review.md). +For concrete review flow examples, read [references/mcp-review.md](references/mcp-review.md). ## Start Hunk only when needed -If no live Hunk session exists and the user wants an interactive review UI, launch Hunk itself with a minimal command and let it auto-start/register with the MCP daemon. +If no live Hunk session exists and the user wants an interactive review UI, launch Hunk itself with a minimal command and let it auto-start/register with the daemon. -After launching Hunk, go back to `list_sessions` rather than suggesting manual daemon management. +After launching Hunk, go back to `hunk session list` rather than suggesting manual daemon management. Inside the Hunk repo, prefer the source entrypoint: @@ -87,7 +87,7 @@ When using Hunk for agent changes: Prefer a skill over a prompt dump: - keep always-loaded context small - load the full Hunk workflow only when the task is actually about review -- use Hunk's existing MCP tools rather than ad hoc shell parsing +- use Hunk's session CLI rather than a separate agent-facing MCP tool surface Prefer review-oriented actions: - inspect the current live diff session diff --git a/skills/hunk-review/references/commands.md b/skills/hunk-review/references/commands.md index a86e8030..316fd3cd 100644 --- a/skills/hunk-review/references/commands.md +++ b/skills/hunk-review/references/commands.md @@ -2,7 +2,7 @@ ## Source repo vs installed CLI -Launching Hunk normally should auto-start/register the MCP daemon when MCP is enabled, so Pi usually does not need to run `hunk mcp serve` first. +Launching Hunk normally should auto-start/register the local session daemon when MCP is enabled, so Pi usually does not need to run `hunk mcp serve` first. If Pi is operating inside the Hunk source repo, prefer the source entrypoint so review and validation target the current checkout: diff --git a/skills/hunk-review/references/mcp-review.md b/skills/hunk-review/references/mcp-review.md index b0d7eaf7..11389994 100644 --- a/skills/hunk-review/references/mcp-review.md +++ b/skills/hunk-review/references/mcp-review.md @@ -1,6 +1,6 @@ -# Hunk MCP review flow +# Hunk live review flow -Hunk MCP is a local-only loopback daemon that brokers commands to one or more live Hunk review sessions. +Hunk uses one local-only loopback daemon to broker commands to one or more live Hunk review sessions. ## Daemon model @@ -11,34 +11,37 @@ Hunk MCP is a local-only loopback daemon that brokers commands to one or more li hunk mcp serve ``` -- Disable MCP registration for one Hunk session with: +- Disable daemon registration for one Hunk session with: ```bash HUNK_MCP_DISABLE=1 hunk diff ``` -## Current tool surface +## User and agent interface -The review-oriented MCP tools are: -- `list_sessions` -- `get_session` -- `get_selected_context` -- `navigate_to_hunk` -- `comment` +The review-oriented interface is `hunk session ...`: +- `hunk session list` +- `hunk session get` +- `hunk session context` +- `hunk session navigate` +- `hunk session comment add` +- `hunk session comment list` +- `hunk session comment rm` +- `hunk session comment clear --yes` -## Recommended agent flow +## Recommended review flow ### 1. Discover the target session -Call `list_sessions` first. +Run `hunk session list` first. -If no session exists but the user wants interactive review, launch Hunk (`hunk diff`, `hunk show`, or the source entrypoint in this repo), then come back and call `list_sessions` again. +If no session exists but the user wants interactive review, launch Hunk (`hunk diff`, `hunk show`, or the source entrypoint in this repo), then come back and run `hunk session list` again. -Use `sessionId` explicitly whenever more than one live session exists. +Use explicit `sessionId` or `--repo ` whenever more than one live session exists. ### 2. Inspect current focus -Call `get_selected_context` to see: +Run `hunk session context` to see: - current file - current hunk index - selected hunk old/new ranges @@ -49,30 +52,28 @@ This is the best way to respect what the human reviewer is already looking at. ### 3. Move only when needed -If the current focus is wrong, call `navigate_to_hunk` with either: -- `hunkIndex`, or -- `side` + `line` +If the current focus is wrong, run `hunk session navigate` with either: +- `--hunk `, or +- `--old-line ` / `--new-line ` Prefer hunk-level movement over adding broader remote-control actions. ### 4. Leave inline review notes -Call `comment` with: -- `sessionId` -- `filePath` -- `side` -- `line` -- `summary` -- optional `rationale` -- optional `author` -- usually `reveal: true` +Run `hunk session comment add` with: +- `` or `--repo ` +- `--file` +- `--old-line` or `--new-line` +- `--summary` +- optional `--rationale` +- optional `--author` Use concise review comments tied to actual diff lines. ## Practical guidance for Pi -- Prefer MCP tools over scraping terminal text when a live Hunk session already exists. -- Use `get_session` when you need broad session metadata; use `get_selected_context` for fast focus-aware checks. +- Prefer `hunk session ...` over scraping terminal text when a live Hunk session already exists. +- Use `hunk session get` when you need broad session metadata; use `hunk session context` for fast focus-aware checks. - In multi-session setups, never assume the sole-session fallback is still safe after new windows open. - Keep comments review-oriented rather than conversational. - If the user wants silent inspection rather than visible interaction, avoid unnecessary navigation and only comment when asked. diff --git a/src/core/cli.ts b/src/core/cli.ts index b1a307ca..1865bf1b 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -127,7 +127,7 @@ function renderCliHelp() { " hunk pager general Git pager wrapper with diff detection", " hunk difftool [path] review Git difftool file pairs", " hunk session inspect or control a live Hunk session", - " hunk mcp serve run the local Hunk MCP daemon", + " hunk mcp serve run the local Hunk session daemon", "", "Options:", " -h, --help show help", @@ -691,7 +691,7 @@ async function parseMcpCommand(tokens: string[]): Promise { text: [ "Usage: hunk mcp serve", "", - "Run the local Hunk MCP daemon and websocket session broker.", + "Run the local Hunk session daemon and websocket session broker.", "", "Environment:", " HUNK_MCP_HOST bind host (default 127.0.0.1; loopback only unless explicitly overridden)", @@ -711,7 +711,7 @@ async function parseMcpCommand(tokens: string[]): Promise { text: [ "Usage: hunk mcp serve", "", - "Run the local Hunk MCP daemon and websocket session broker.", + "Run the local Hunk session daemon and websocket session broker.", ].join("\n") + "\n", }; } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 573cee5d..8673860f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,62 +1,29 @@ -import { randomUUID } from "node:crypto"; -import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import * as z from "zod/v4"; -import { HUNK_MCP_PATH, HUNK_SESSION_SOCKET_PATH, resolveHunkMcpConfig } from "./config"; +import { HUNK_SESSION_SOCKET_PATH, resolveHunkMcpConfig } from "./config"; import { HunkDaemonState } from "./daemonState"; import type { SessionClientMessage } from "./types"; +import { + HUNK_SESSION_API_PATH, + HUNK_SESSION_API_VERSION, + HUNK_SESSION_CAPABILITIES_PATH, + type SessionDaemonAction, + type SessionDaemonCapabilities, + type SessionDaemonRequest, + type SessionDaemonResponse, +} from "../session/protocol"; const STALE_SESSION_TTL_MS = 45_000; const STALE_SESSION_SWEEP_INTERVAL_MS = 15_000; -interface McpTransportEntry { - server: McpServer; - transport: WebStandardStreamableHTTPServerTransport; -} - -const sessionSelectorSchema = z.object({ - sessionId: z.string().optional().describe("Explicit Hunk session id."), - repoRoot: z.string().optional().describe("Repo root fallback when exactly one session matches."), -}); - -const navigateToHunkSchema = sessionSelectorSchema.extend({ - filePath: z.string().describe("Diff file path as shown by Hunk."), - hunkIndex: z.number().int().nonnegative().optional().describe("0-based hunk index within the file."), - side: z.enum(["old", "new"]).optional().describe("Optional diff side when resolving by line number."), - line: z.number().int().positive().optional().describe("Optional 1-based diff line number when resolving by line number."), -}).refine( - (input) => input.hunkIndex !== undefined || (input.side !== undefined && input.line !== undefined), - { - error: "Provide either hunkIndex or both side and line.", - path: ["hunkIndex"], - }, -); - -const listCommentsSchema = sessionSelectorSchema.extend({ - filePath: z.string().optional().describe("Optional diff file path to filter live comments."), -}); - -const removeCommentSchema = sessionSelectorSchema.extend({ - commentId: z.string().describe("Stable live comment id to remove."), -}); - -const clearCommentsSchema = sessionSelectorSchema.extend({ - filePath: z.string().optional().describe("Optional diff file path to clear only one file's live comments."), -}); - -function formatToolJson(value: unknown) { - return JSON.stringify(value, null, 2); -} - -function textContent(text: string) { - return [ - { - type: "text" as const, - text, - }, - ]; -} +const SUPPORTED_SESSION_ACTIONS: SessionDaemonAction[] = [ + "list", + "get", + "context", + "navigate", + "comment-add", + "comment-list", + "comment-rm", + "comment-clear", +]; function formatDaemonServeError(error: unknown, host: string, port: number) { const message = error instanceof Error ? error.message : String(error); @@ -75,199 +42,109 @@ function formatDaemonServeError(error: unknown, host: string, port: number) { return new Error(`Failed to start the Hunk MCP daemon on ${host}:${port}: ${message}`); } -function createHunkMcpServer(state: HunkDaemonState) { - const server = new McpServer({ - name: "hunk", - version: "0.1.0", - }); - - server.registerTool( - "list_sessions", - { - title: "List live Hunk sessions", - description: "List the live Hunk diff-review sessions currently registered with the local daemon.", - } as any, - (async () => { - const sessions = state.listSessions(); - - return { - content: textContent(formatToolJson({ sessions })), - structuredContent: { - sessions, - }, - }; - }) as any, - ); - - server.registerTool( - "get_session", - { - title: "Get one live Hunk session", - description: "Fetch details for one live Hunk session by session id or repo root.", - inputSchema: sessionSelectorSchema as any, - } as any, - (async (input: { sessionId?: string; repoRoot?: string }) => { - const session = state.getSession({ sessionId: input.sessionId, repoRoot: input.repoRoot }); - - return { - content: textContent(formatToolJson(session)), - structuredContent: { - session, - }, - }; - }) as any, - ); - - server.registerTool( - "get_selected_context", - { - title: "Get the selected file and hunk", - description: "Inspect which file and hunk the live Hunk session is currently focused on.", - inputSchema: sessionSelectorSchema as any, - } as any, - (async (input: { sessionId?: string; repoRoot?: string }) => { - const context = state.getSelectedContext({ sessionId: input.sessionId, repoRoot: input.repoRoot }); - - return { - content: textContent(formatToolJson(context)), - structuredContent: { - context, - }, - }; - }) as any, - ); - - server.registerTool( - "navigate_to_hunk", - { - title: "Focus one diff hunk", - description: "Move the live Hunk session to one diff hunk by index or by a specific diff line.", - inputSchema: navigateToHunkSchema as any, - } as any, - (async (input: { - sessionId?: string; - repoRoot?: string; - filePath: string; - hunkIndex?: number; - side?: "old" | "new"; - line?: number; - }) => { - const result = await state.sendNavigateToHunk(input); - - return { - content: textContent(formatToolJson(result)), - structuredContent: { - result, - }, - }; - }) as any, - ); - - server.registerTool( - "comment", - { - title: "Comment on a live Hunk diff", - description: "Attach an inline review note to a specific diff line in a live Hunk session.", - inputSchema: sessionSelectorSchema.extend({ - filePath: z.string().describe("Diff file path as shown by Hunk."), - side: z.enum(["old", "new"]).describe("Which side of the diff the line belongs to."), - line: z.number().int().positive().describe("1-based diff line number on the chosen side."), - summary: z.string().min(1).describe("Short inline review note."), - rationale: z.string().optional().describe("Optional longer explanation shown in the note card."), - reveal: z.boolean().optional().describe("Whether Hunk should jump to and reveal the note. Defaults to true."), - author: z.string().optional().describe("Optional author label for the live comment."), - }) as any, - } as any, - (async (input: { - sessionId?: string; - repoRoot?: string; - filePath: string; - side: "old" | "new"; - line: number; - summary: string; - rationale?: string; - reveal?: boolean; - author?: string; - }) => { - const result = await state.sendComment({ - ...input, - reveal: input.reveal ?? true, - }); - - return { - content: textContent(formatToolJson(result)), - structuredContent: { - result, - }, - }; - }) as any, - ); - - server.registerTool( - "list_comments", - { - title: "List live Hunk comments", - description: "List live inline review comments currently attached to a Hunk session.", - inputSchema: listCommentsSchema as any, - } as any, - (async (input: { sessionId?: string; repoRoot?: string; filePath?: string }) => { - const comments = state.listComments({ sessionId: input.sessionId, repoRoot: input.repoRoot }, { filePath: input.filePath }); +function sessionCapabilities(): SessionDaemonCapabilities { + return { + version: HUNK_SESSION_API_VERSION, + actions: SUPPORTED_SESSION_ACTIONS, + }; +} - return { - content: textContent(formatToolJson({ comments })), - structuredContent: { - comments, - }, - }; - }) as any, - ); +function jsonError(message: string, status = 400) { + return Response.json({ error: message }, { status }); +} - server.registerTool( - "remove_comment", - { - title: "Remove one live Hunk comment", - description: "Remove one live inline review comment from a Hunk session by comment id.", - inputSchema: removeCommentSchema as any, - } as any, - (async (input: { sessionId?: string; repoRoot?: string; commentId: string }) => { - const result = await state.sendRemoveComment(input); +async function parseJsonRequest(request: Request) { + try { + return (await request.json()) as SessionDaemonRequest; + } catch { + throw new Error("Expected one JSON request body."); + } +} - return { - content: textContent(formatToolJson(result)), - structuredContent: { - result, - }, - }; - }) as any, - ); +async function handleSessionApiRequest(state: HunkDaemonState, request: Request) { + if (request.method !== "POST") { + return jsonError("Session API requests must use POST.", 405); + } - server.registerTool( - "clear_comments", - { - title: "Clear live Hunk comments", - description: "Clear live inline review comments from a Hunk session, optionally limited to one file.", - inputSchema: clearCommentsSchema as any, - } as any, - (async (input: { sessionId?: string; repoRoot?: string; filePath?: string }) => { - const result = await state.sendClearComments(input); + try { + const input = await parseJsonRequest(request); + let response: SessionDaemonResponse; + + switch (input.action) { + case "list": + response = { sessions: state.listSessions() }; + break; + case "get": + response = { session: state.getSession(input.selector) }; + break; + case "context": + response = { context: state.getSelectedContext(input.selector) }; + break; + case "navigate": { + if (input.hunkNumber === undefined && (input.side === undefined || input.line === undefined)) { + throw new Error("navigate requires either hunkNumber or both side and line."); + } - return { - content: textContent(formatToolJson(result)), - structuredContent: { - result, - }, - }; - }) as any, - ); + response = { + result: await state.sendNavigateToHunk({ + ...input.selector, + filePath: input.filePath, + hunkIndex: input.hunkNumber !== undefined ? input.hunkNumber - 1 : undefined, + side: input.side, + line: input.line, + }), + }; + break; + } + case "comment-add": + response = { + result: await state.sendComment({ + ...input.selector, + filePath: input.filePath, + side: input.side, + line: input.line, + summary: input.summary, + rationale: input.rationale, + author: input.author, + reveal: input.reveal, + }), + }; + break; + case "comment-list": + response = { + comments: state.listComments(input.selector, { filePath: input.filePath }), + }; + break; + case "comment-rm": + response = { + result: await state.sendRemoveComment({ + ...input.selector, + commentId: input.commentId, + }), + }; + break; + case "comment-clear": + response = { + result: await state.sendClearComments({ + ...input.selector, + filePath: input.filePath, + }), + }; + break; + default: + throw new Error("Unknown session API action."); + } - return server; + return Response.json(response); + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Unknown session API error."); + } } -/** Serve the local Hunk MCP daemon and websocket session broker. */ +/** Serve the local Hunk session daemon and websocket session broker. */ export function serveHunkMcpServer() { const config = resolveHunkMcpConfig(); const state = new HunkDaemonState(); - const transports = new Map(); const startedAt = Date.now(); let shuttingDown = false; @@ -276,9 +153,9 @@ export function serveHunkMcpServer() { }, STALE_SESSION_SWEEP_INTERVAL_MS); sweepTimer.unref?.(); - let server: ReturnType>; + let server: ReturnType>; try { - server = Bun.serve<{ sessionId?: string }>({ + server = Bun.serve<{}>({ hostname: config.host, port: config.port, fetch: async (request, bunServer) => { @@ -291,7 +168,8 @@ export function serveHunkMcpServer() { pid: process.pid, startedAt: new Date(startedAt).toISOString(), uptimeMs: Date.now() - startedAt, - transport: `${config.httpOrigin}${HUNK_MCP_PATH}`, + sessionApi: `${config.httpOrigin}${HUNK_SESSION_API_PATH}`, + sessionCapabilities: `${config.httpOrigin}${HUNK_SESSION_CAPABILITIES_PATH}`, sessionSocket: `${config.wsOrigin}${HUNK_SESSION_SOCKET_PATH}`, sessions: state.listSessions().length, pendingCommands: state.getPendingCommandCount(), @@ -299,68 +177,27 @@ export function serveHunkMcpServer() { }); } - if (url.pathname === HUNK_SESSION_SOCKET_PATH) { - if (bunServer.upgrade(request, { data: {} })) { - return undefined; - } - - return new Response("Expected websocket upgrade.", { status: 426 }); + if (url.pathname === HUNK_SESSION_CAPABILITIES_PATH) { + return Response.json(sessionCapabilities()); } - if (url.pathname !== HUNK_MCP_PATH) { - return new Response("Not found.", { status: 404 }); + if (url.pathname === HUNK_SESSION_API_PATH) { + return handleSessionApiRequest(state, request); } - const headerSessionId = request.headers.get("mcp-session-id") ?? undefined; - const parsedBody = request.method === "POST" ? await request.json() : undefined; - - if (headerSessionId && transports.has(headerSessionId)) { - const entry = transports.get(headerSessionId)!; - return entry.transport.handleRequest(request, { parsedBody }); + if (url.pathname === "/mcp") { + return jsonError("Hunk no longer exposes agent-facing MCP tools. Use `hunk session ...` instead.", 410); } - if (!headerSessionId && request.method === "POST" && isInitializeRequest(parsedBody)) { - let transport: WebStandardStreamableHTTPServerTransport; - let transportEntry: McpTransportEntry; - - transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - enableJsonResponse: true, - onsessioninitialized: (sessionId) => { - transports.set(sessionId, transportEntry); - }, - onsessionclosed: (sessionId) => { - const entry = transports.get(sessionId); - if (entry) { - void entry.server.close(); - transports.delete(sessionId); - } - }, - }); - - const mcpServer = createHunkMcpServer(state); - transportEntry = { - server: mcpServer, - transport, - }; + if (url.pathname === HUNK_SESSION_SOCKET_PATH) { + if (bunServer.upgrade(request, { data: {} })) { + return undefined; + } - await mcpServer.connect(transport); - return transport.handleRequest(request, { parsedBody }); + return new Response("Expected websocket upgrade.", { status: 426 }); } - return Response.json( - { - jsonrpc: "2.0", - error: { - code: -32000, - message: "Bad Request: No valid MCP session id provided.", - }, - id: null, - }, - { - status: 400, - }, - ); + return new Response("Not found.", { status: 404 }); }, websocket: { message: (socket, message) => { @@ -411,18 +248,13 @@ export function serveHunkMcpServer() { process.off("SIGTERM", shutdown); state.shutdown(); - for (const [sessionId, entry] of transports.entries()) { - void entry.server.close(); - transports.delete(sessionId); - } - server.stop(true); }; process.once("SIGINT", shutdown); process.once("SIGTERM", shutdown); - console.log(`Hunk MCP daemon listening on ${config.httpOrigin}${HUNK_MCP_PATH}`); + console.log(`Hunk session daemon listening on ${config.httpOrigin}${HUNK_SESSION_API_PATH}`); console.log(`Hunk session websocket listening on ${config.wsOrigin}${HUNK_SESSION_SOCKET_PATH}`); return server; diff --git a/src/session/commands.ts b/src/session/commands.ts index 73191ae4..31485ec5 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -1,5 +1,3 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { resolve } from "node:path"; import type { SessionCommandInput, @@ -22,11 +20,17 @@ import type { SelectedSessionContext, SessionLiveCommentSummary, } from "../mcp/types"; +import { + HUNK_SESSION_API_PATH, + HUNK_SESSION_API_VERSION, + HUNK_SESSION_CAPABILITIES_PATH, + type SessionDaemonAction, + type SessionDaemonCapabilities, + type SessionDaemonRequest, +} from "./protocol"; export interface HunkDaemonCliClient { - connect(): Promise; - close(): Promise; - listToolNames(): Promise>; + getCapabilities(): Promise; listSessions(): Promise; getSession(selector: SessionSelectorInput): Promise; getSelectedContext(selector: SessionSelectorInput): Promise; @@ -37,18 +41,21 @@ export interface HunkDaemonCliClient { clearComments(input: SessionCommentClearCommandInput): Promise; } -const REQUIRED_TOOLS_BY_ACTION: Partial> = { - context: ["get_selected_context"], - navigate: ["navigate_to_hunk"], - "comment-list": ["list_comments"], - "comment-rm": ["remove_comment"], - "comment-clear": ["clear_comments"], +const REQUIRED_ACTION_BY_COMMAND: Record = { + list: "list", + get: "get", + context: "context", + navigate: "navigate", + "comment-add": "comment-add", + "comment-list": "comment-list", + "comment-rm": "comment-rm", + "comment-clear": "comment-clear", }; interface SessionCommandTestHooks { createClient?: () => HunkDaemonCliClient; resolveDaemonAvailability?: (action: SessionCommandInput["action"]) => Promise; - restartDaemonForMissingTools?: (missingTools: string[], selector?: SessionSelectorInput) => Promise; + restartDaemonForMissingAction?: (action: SessionDaemonAction, selector?: SessionSelectorInput) => Promise; } let sessionCommandTestHooks: SessionCommandTestHooks | null = null; @@ -58,120 +65,89 @@ export function setSessionCommandTestHooks(hooks: SessionCommandTestHooks | null } function createDaemonCliClient() { - return sessionCommandTestHooks?.createClient?.() ?? new McpHunkDaemonCliClient(); + return sessionCommandTestHooks?.createClient?.() ?? new HttpHunkDaemonCliClient(); } -function extractToolValue( - result: Awaited>, - key: string, -): ResultType | undefined { - const content = (result.content ?? []) as Array<{ type?: string; text?: string }>; - const text = content.find((entry) => entry.type === "text")?.text; - - if (result.isError) { - throw new Error(text || "The Hunk daemon returned an MCP tool error."); +async function extractResponseError(response: Response) { + try { + const parsed = (await response.json()) as { error?: string }; + if (typeof parsed.error === "string" && parsed.error.length > 0) { + return parsed.error; + } + } catch { + // Fall through to status text. } - const structured = result.structuredContent as Record | undefined; - if (structured && key in structured) { - return structured[key]; - } + return response.statusText || "Unknown Hunk session daemon error."; +} - if (!text) { - return undefined; - } +class HttpHunkDaemonCliClient implements HunkDaemonCliClient { + private readonly config = resolveHunkMcpConfig(); - try { - const parsed = JSON.parse(text) as ResultType | Record; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && key in parsed) { - return (parsed as Record)[key]; + private async request(input: SessionDaemonRequest) { + const response = await fetch(`${this.config.httpOrigin}${HUNK_SESSION_API_PATH}`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(input), + }); + + if (!response.ok) { + throw new Error(await extractResponseError(response)); } - return parsed as ResultType; - } catch { - return undefined; + return (await response.json()) as ResultType; } -} -class McpHunkDaemonCliClient implements HunkDaemonCliClient { - private readonly transport = new StreamableHTTPClientTransport(new URL(`${resolveHunkMcpConfig().httpOrigin}/mcp`)); - private readonly client = new Client({ name: "hunk-session-cli", version: "1.0.0" }); + async getCapabilities() { + const response = await fetch(`${this.config.httpOrigin}${HUNK_SESSION_CAPABILITIES_PATH}`); + if (response.status === 404 || response.status === 410) { + return null; + } - async connect() { - await this.client.connect(this.transport); - } + if (!response.ok) { + throw new Error(await extractResponseError(response)); + } - async close() { - await this.transport.close().catch(() => undefined); - } + const capabilities = (await response.json()) as SessionDaemonCapabilities; + if (capabilities.version !== HUNK_SESSION_API_VERSION || !Array.isArray(capabilities.actions)) { + throw new Error("The Hunk session daemon returned an invalid capabilities payload."); + } - async listToolNames() { - const result = await this.client.listTools(); - return new Set(result.tools.map((tool) => tool.name)); + return capabilities; } async listSessions() { - const result = await this.client.callTool({ - name: "list_sessions", - arguments: {}, - }); - - return extractToolValue(result, "sessions") ?? []; + return (await this.request<{ sessions: ListedSession[] }>({ action: "list" })).sessions; } async getSession(selector: SessionSelectorInput) { - const result = await this.client.callTool({ - name: "get_session", - arguments: selector as Record, - }); - - const session = extractToolValue(result, "session"); - if (!session) { - throw new Error("The Hunk daemon returned no session payload."); - } - - return session; + return (await this.request<{ session: ListedSession }>({ action: "get", selector })).session; } async getSelectedContext(selector: SessionSelectorInput) { - const result = await this.client.callTool({ - name: "get_selected_context", - arguments: selector as Record, - }); - - const context = extractToolValue(result, "context"); - if (!context) { - throw new Error("The Hunk daemon returned no selected-context payload."); - } - - return context; + return (await this.request<{ context: SelectedSessionContext }>({ action: "context", selector })).context; } async navigateToHunk(input: SessionNavigateCommandInput) { - const result = await this.client.callTool({ - name: "navigate_to_hunk", - arguments: { - ...input.selector, + return ( + await this.request<{ result: NavigatedSelectionResult }>({ + action: "navigate", + selector: input.selector, filePath: input.filePath, - hunkIndex: input.hunkNumber !== undefined ? input.hunkNumber - 1 : undefined, + hunkNumber: input.hunkNumber, side: input.side, line: input.line, - }, - }); - - const navigated = extractToolValue(result, "result"); - if (!navigated) { - throw new Error("The Hunk daemon returned no navigation result."); - } - - return navigated; + }) + ).result; } async addComment(input: SessionCommentAddCommandInput) { - const result = await this.client.callTool({ - name: "comment", - arguments: { - ...input.selector, + return ( + await this.request<{ result: AppliedCommentResult }>({ + action: "comment-add", + selector: input.selector, filePath: input.filePath, side: input.side, line: input.line, @@ -179,61 +155,38 @@ class McpHunkDaemonCliClient implements HunkDaemonCliClient { rationale: input.rationale, author: input.author, reveal: input.reveal, - }, - }); - - const comment = extractToolValue(result, "result"); - if (!comment) { - throw new Error("The Hunk daemon returned no comment result."); - } - - return comment; + }) + ).result; } async listComments(input: SessionCommentListCommandInput) { - const result = await this.client.callTool({ - name: "list_comments", - arguments: { - ...input.selector, + return ( + await this.request<{ comments: SessionLiveCommentSummary[] }>({ + action: "comment-list", + selector: input.selector, filePath: input.filePath, - }, - }); - - return extractToolValue(result, "comments") ?? []; + }) + ).comments; } async removeComment(input: SessionCommentRemoveCommandInput) { - const result = await this.client.callTool({ - name: "remove_comment", - arguments: { - ...input.selector, + return ( + await this.request<{ result: RemovedCommentResult }>({ + action: "comment-rm", + selector: input.selector, commentId: input.commentId, - }, - }); - - const removed = extractToolValue(result, "result"); - if (!removed) { - throw new Error("The Hunk daemon returned no remove-comment result."); - } - - return removed; + }) + ).result; } async clearComments(input: SessionCommentClearCommandInput) { - const result = await this.client.callTool({ - name: "clear_comments", - arguments: { - ...input.selector, + return ( + await this.request<{ result: ClearedCommentsResult }>({ + action: "comment-clear", + selector: input.selector, filePath: input.filePath, - }, - }); - - const cleared = extractToolValue(result, "result"); - if (!cleared) { - throw new Error("The Hunk daemon returned no clear-comments result."); - } - - return cleared; + }) + ).result; } } @@ -271,7 +224,11 @@ async function waitForDaemonShutdown(timeoutMs = 3_000) { return false; } -function sessionMatchesSelector(session: ListedSession, selector: SessionSelectorInput) { +function sessionMatchesSelector(session: ListedSession, selector?: SessionSelectorInput) { + if (!selector) { + return true; + } + if (selector.sessionId) { return session.sessionId === selector.sessionId; } @@ -283,20 +240,19 @@ function sessionMatchesSelector(session: ListedSession, selector: SessionSelecto return true; } -async function waitForSessionRegistration(selector: SessionSelectorInput, timeoutMs = 8_000) { +async function waitForSessionRegistration(selector?: SessionSelectorInput, timeoutMs = 8_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const client = createDaemonCliClient(); - await client.connect(); try { const sessions = await client.listSessions(); if (sessions.some((session) => sessionMatchesSelector(session, selector))) { return true; } - } finally { - await client.close(); + } catch { + // Keep polling while the fresh daemon/session reconnects. } await Bun.sleep(200); @@ -305,12 +261,13 @@ async function waitForSessionRegistration(selector: SessionSelectorInput, timeou return false; } -async function restartDaemonForMissingTools(missingTools: string[], selector?: SessionSelectorInput) { +async function restartDaemonForMissingAction(action: SessionDaemonAction, selector?: SessionSelectorInput) { const health = await readDaemonHealth(); const pid = health?.pid; + const hadSessions = (health?.sessions ?? 0) > 0; if (!pid || pid === process.pid) { throw new Error( - `The running Hunk MCP daemon is missing required tools (${missingTools.join(", ")}). ` + + `The running Hunk session daemon is missing required support for ${action}. ` + `Restart Hunk so it can launch a fresh daemon from the current source tree.`, ); } @@ -319,9 +276,7 @@ async function restartDaemonForMissingTools(missingTools: string[], selector?: S const shutDown = await waitForDaemonShutdown(); if (!shutDown) { - throw new Error( - `Stopped waiting for the old Hunk MCP daemon to exit after it was found missing required tools (${missingTools.join(", ")}).`, - ); + throw new Error(`Stopped waiting for the old Hunk session daemon to exit after it was found missing ${action}.`); } launchHunkDaemon(); @@ -329,47 +284,28 @@ async function restartDaemonForMissingTools(missingTools: string[], selector?: S const config = resolveHunkMcpConfig(); const ready = await waitForHunkDaemonHealth({ config, timeoutMs: 3_000 }); if (!ready) { - throw new Error("Timed out waiting for the refreshed Hunk MCP daemon to start."); + throw new Error("Timed out waiting for the refreshed Hunk session daemon to start."); } - if (selector) { + if (selector || hadSessions) { const registered = await waitForSessionRegistration(selector); if (!registered) { - throw new Error( - "Timed out waiting for the live Hunk session to reconnect after refreshing the MCP daemon.", - ); + throw new Error("Timed out waiting for the live Hunk session to reconnect after refreshing the session daemon."); } } } -async function ensureRequiredTools(action: SessionCommandInput["action"], selector?: SessionSelectorInput) { - const requiredTools = REQUIRED_TOOLS_BY_ACTION[action] ?? []; - if (requiredTools.length === 0) { - return; - } - +async function ensureRequiredAction(action: SessionDaemonAction, selector?: SessionSelectorInput) { const client = createDaemonCliClient(); - await client.connect(); - - try { - const toolNames = await client.listToolNames(); - const missingTools = requiredTools.filter((tool) => !toolNames.has(tool)); - if (missingTools.length === 0) { - return; - } - - const looksLikeOlderHunkDaemon = toolNames.has("list_sessions") && toolNames.has("get_session"); - if (!looksLikeOlderHunkDaemon) { - throw new Error( - `The Hunk MCP daemon is missing required tools (${missingTools.join(", ")}). Available tools: ${[...toolNames].join(", ") || "(none)"}.`, - ); - } - } finally { - await client.close(); + const capabilities = await client.getCapabilities(); + if (capabilities?.actions.includes(action)) { + return; } - await (sessionCommandTestHooks?.restartDaemonForMissingTools?.(requiredTools, selector) - ?? restartDaemonForMissingTools(requiredTools, selector)); + await ( + sessionCommandTestHooks?.restartDaemonForMissingAction?.(action, selector) + ?? restartDaemonForMissingAction(action, selector) + ); } function stringifyJson(value: unknown) { @@ -523,62 +459,57 @@ export async function runSessionCommand(input: SessionCommandInput) { } const normalizedSelector = "selector" in input ? normalizeRepoRoot(input.selector) : null; - await ensureRequiredTools(input.action, normalizedSelector ?? undefined); + await ensureRequiredAction(REQUIRED_ACTION_BY_COMMAND[input.action], normalizedSelector ?? undefined); const client = createDaemonCliClient(); - await client.connect(); - try { - switch (input.action) { - case "list": { - const sessions = await client.listSessions(); - return renderOutput(input.output, { sessions }, () => formatListOutput(sessions)); - } - case "get": { - const session = await client.getSession(normalizedSelector!); - return renderOutput(input.output, { session }, () => formatSessionOutput(session)); - } - case "context": { - const context = await client.getSelectedContext(normalizedSelector!); - return renderOutput(input.output, { context }, () => formatContextOutput(context)); - } - case "navigate": { - const result = await client.navigateToHunk({ - ...input, - selector: normalizedSelector!, - }); - return renderOutput(input.output, { result }, () => formatNavigationOutput(input.selector, result)); - } - case "comment-add": { - const result = await client.addComment({ - ...input, - selector: normalizedSelector!, - }); - return renderOutput(input.output, { result }, () => formatCommentOutput(input.selector, result)); - } - case "comment-list": { - const comments = await client.listComments({ - ...input, - selector: normalizedSelector!, - }); - return renderOutput(input.output, { comments }, () => formatCommentListOutput(input.selector, comments)); - } - case "comment-rm": { - const result = await client.removeComment({ - ...input, - selector: normalizedSelector!, - }); - return renderOutput(input.output, { result }, () => formatRemoveCommentOutput(input.selector, result)); - } - case "comment-clear": { - const result = await client.clearComments({ - ...input, - selector: normalizedSelector!, - }); - return renderOutput(input.output, { result }, () => formatClearCommentsOutput(input.selector, result)); - } + switch (input.action) { + case "list": { + const sessions = await client.listSessions(); + return renderOutput(input.output, { sessions }, () => formatListOutput(sessions)); + } + case "get": { + const session = await client.getSession(normalizedSelector!); + return renderOutput(input.output, { session }, () => formatSessionOutput(session)); + } + case "context": { + const context = await client.getSelectedContext(normalizedSelector!); + return renderOutput(input.output, { context }, () => formatContextOutput(context)); + } + case "navigate": { + const result = await client.navigateToHunk({ + ...input, + selector: normalizedSelector!, + }); + return renderOutput(input.output, { result }, () => formatNavigationOutput(input.selector, result)); + } + case "comment-add": { + const result = await client.addComment({ + ...input, + selector: normalizedSelector!, + }); + return renderOutput(input.output, { result }, () => formatCommentOutput(input.selector, result)); + } + case "comment-list": { + const comments = await client.listComments({ + ...input, + selector: normalizedSelector!, + }); + return renderOutput(input.output, { comments }, () => formatCommentListOutput(input.selector, comments)); + } + case "comment-rm": { + const result = await client.removeComment({ + ...input, + selector: normalizedSelector!, + }); + return renderOutput(input.output, { result }, () => formatRemoveCommentOutput(input.selector, result)); + } + case "comment-clear": { + const result = await client.clearComments({ + ...input, + selector: normalizedSelector!, + }); + return renderOutput(input.output, { result }, () => formatClearCommentsOutput(input.selector, result)); } - } finally { - await client.close(); } } diff --git a/src/session/protocol.ts b/src/session/protocol.ts new file mode 100644 index 00000000..12149449 --- /dev/null +++ b/src/session/protocol.ts @@ -0,0 +1,93 @@ +import type { + SessionCommentAddCommandInput, + SessionCommentClearCommandInput, + SessionCommentListCommandInput, + SessionCommentRemoveCommandInput, + SessionNavigateCommandInput, + SessionSelectorInput, +} from "../core/types"; +import type { + AppliedCommentResult, + ClearedCommentsResult, + ListedSession, + NavigatedSelectionResult, + RemovedCommentResult, + SelectedSessionContext, + SessionLiveCommentSummary, +} from "../mcp/types"; + +export const HUNK_SESSION_API_PATH = "/session-api"; +export const HUNK_SESSION_CAPABILITIES_PATH = `${HUNK_SESSION_API_PATH}/capabilities`; +export const HUNK_SESSION_API_VERSION = 1; + +export type SessionDaemonAction = + | "list" + | "get" + | "context" + | "navigate" + | "comment-add" + | "comment-list" + | "comment-rm" + | "comment-clear"; + +export interface SessionDaemonCapabilities { + version: number; + actions: SessionDaemonAction[]; +} + +export type SessionDaemonRequest = + | { + action: "list"; + } + | { + action: "get"; + selector: SessionSelectorInput; + } + | { + action: "context"; + selector: SessionSelectorInput; + } + | { + action: "navigate"; + selector: SessionNavigateCommandInput["selector"]; + filePath: string; + hunkNumber?: number; + side?: "old" | "new"; + line?: number; + } + | { + action: "comment-add"; + selector: SessionCommentAddCommandInput["selector"]; + filePath: string; + side: "old" | "new"; + line: number; + summary: string; + rationale?: string; + author?: string; + reveal: boolean; + } + | { + action: "comment-list"; + selector: SessionCommentListCommandInput["selector"]; + filePath?: string; + } + | { + action: "comment-rm"; + selector: SessionCommentRemoveCommandInput["selector"]; + commentId: string; + } + | { + action: "comment-clear"; + selector: SessionCommentClearCommandInput["selector"]; + filePath?: string; + }; + +export type SessionDaemonResponse = + | { sessions: ListedSession[] } + | { session: ListedSession } + | { context: SelectedSessionContext } + | { result: NavigatedSelectionResult } + | { result: AppliedCommentResult } + | { comments: SessionLiveCommentSummary[] } + | { result: RemovedCommentResult } + | { result: ClearedCommentsResult }; diff --git a/test/mcp-e2e.test.ts b/test/mcp-e2e.test.ts index ed2db72a..1461013b 100644 --- a/test/mcp-e2e.test.ts +++ b/test/mcp-e2e.test.ts @@ -3,8 +3,6 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const repoRoot = process.cwd(); const sourceEntrypoint = join(repoRoot, "src/main.tsx"); @@ -21,32 +19,15 @@ interface HealthResponse { sessions: number; } -interface ListedSessionSummary { - sessionId: string; - title: string; - files: Array<{ - path: string; +interface SessionListJson { + sessions: Array<{ + sessionId: string; + files: Array<{ + path: string; + }>; }>; } -interface ListedSessionFileSummary { - path: string; - hunkCount: number; - selected: boolean; -} - -interface SelectedContextSummary { - selectedFile: { - path: string; - hunkCount: number; - } | null; - selectedHunk: { - index: number; - oldRange?: [number, number]; - newRange?: [number, number]; - } | null; -} - interface FixtureFiles { dir: string; before: string; @@ -79,7 +60,7 @@ function stripTerminalControl(text: string) { } function createFixtureFiles(name: string, beforeLines: string[], afterLines: string[]): FixtureFiles { - const dir = mkdtempSync(join(tmpdir(), `hunk-mcp-e2e-${name}-`)); + const dir = mkdtempSync(join(tmpdir(), `hunk-session-e2e-${name}-`)); tempDirs.push(dir); const beforeName = `${name}-before.ts`; @@ -128,7 +109,24 @@ function spawnHunkSession( }); } -async function waitUntil(label: string, fn: () => Promise, timeoutMs = 10_000, intervalMs = 150) { +function runSessionCli(args: string[], port: number) { + const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "session", ...args], { + cwd: repoRoot, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + HUNK_MCP_PORT: `${port}`, + }, + }); + + const stdout = Buffer.from(proc.stdout).toString("utf8"); + const stderr = Buffer.from(proc.stderr).toString("utf8"); + return { proc, stdout, stderr }; +} + +async function waitUntil(label: string, fn: () => Promise | T | null, timeoutMs = 10_000, intervalMs = 150) { const deadline = Date.now() + timeoutMs; for (;;) { @@ -146,7 +144,7 @@ async function waitUntil(label: string, fn: () => Promise, timeoutM } async function waitForHealth(port: number) { - return waitUntil("MCP daemon health endpoint", async () => { + return waitUntil("session daemon health endpoint", async () => { try { const response = await fetch(`http://127.0.0.1:${port}/health`); if (!response.ok) { @@ -160,39 +158,12 @@ async function waitForHealth(port: number) { }); } -async function listSessions(client: Client) { - const result = await client.callTool({ - name: "list_sessions", - arguments: {}, - }); - - return ((result.structuredContent as { sessions?: ListedSessionSummary[] } | undefined)?.sessions ?? []); -} - -async function listFiles(client: Client, sessionId: string) { - const result = await client.callTool({ - name: "list_files", - arguments: { sessionId }, - }); - - return ((result.structuredContent as { files?: ListedSessionFileSummary[] } | undefined)?.files ?? []); -} - -async function getSelectedContext(client: Client, sessionId: string) { - const result = await client.callTool({ - name: "get_selected_context", - arguments: { sessionId }, - }); - - return (result.structuredContent as { context?: SelectedContextSummary } | undefined)?.context ?? null; -} - afterEach(() => { cleanupTempDirs(); }); -describe("MCP end-to-end", () => { - test("a live Hunk session auto-starts the daemon and renders MCP comments inline", async () => { +describe("live session end-to-end", () => { + test("a live Hunk session auto-starts the daemon and renders CLI comments inline", async () => { if (!ttyToolsAvailable) { return; } @@ -206,52 +177,58 @@ describe("MCP end-to-end", () => { const hunkProc = spawnHunkSession(fixture, { port }); let daemonPid: number | null = null; - let transport: StreamableHTTPClientTransport | null = null; try { const health = await waitForHealth(port); daemonPid = health.pid; expect(health.ok).toBe(true); - const client = new Client({ name: "mcp-e2e-test", version: "1.0.0" }); - transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`)); - await client.connect(transport); - const listed = await waitUntil("registered Hunk session", async () => { - const sessions = await listSessions(client); - return sessions.length > 0 ? sessions : null; + const { proc, stdout } = runSessionCli(["list", "--json"], port); + if (proc.exitCode !== 0) { + return null; + } + + const parsed = JSON.parse(stdout) as SessionListJson; + return parsed.sessions.length > 0 ? parsed.sessions : null; }); const targetSession = listed.find((session) => session.files.some((file) => file.path === fixture.afterName)) ?? listed[0]!; - const commentResult = await client.callTool({ - name: "comment", - arguments: { - sessionId: targetSession.sessionId, + const comment = runSessionCli( + [ + "comment", + "add", + targetSession.sessionId, + "--file", + fixture.afterName, + "--new-line", + "2", + "--summary", + "CLI autostart note", + "--rationale", + "Injected after the Hunk session auto-started the local daemon.", + "--author", + "Pi", + "--json", + ], + port, + ); + expect(comment.proc.exitCode).toBe(0); + expect(comment.stderr).toBe(""); + expect(JSON.parse(comment.stdout)).toMatchObject({ + result: { filePath: fixture.afterName, - side: "new", line: 2, - summary: "MCP autostart note", - rationale: "Injected after the Hunk session auto-started the local daemon.", - author: "Pi", - reveal: true, }, }); - const structured = commentResult.structuredContent as { result?: { filePath?: string; line?: number } } | undefined; - expect(structured?.result?.filePath).toBe(fixture.afterName); - expect(structured?.result?.line).toBe(2); - const hunkExitCode = await hunkProc.exited; expect([0, 124]).toContain(hunkExitCode); const transcript = stripTerminalControl(await Bun.file(fixture.transcript).text()); - expect(transcript).toContain("MCP autostart note"); + expect(transcript).toContain("CLI autostart note"); expect(transcript).toContain("Injected after the Hunk"); } finally { - if (transport) { - await transport.close().catch(() => undefined); - } - hunkProc.kill(); await hunkProc.exited.catch(() => undefined); @@ -265,7 +242,7 @@ describe("MCP end-to-end", () => { } }, 20_000); - test("expanded MCP tools can inspect the selected context and navigate hunks in a live session", async () => { + test("session CLI can inspect current focus and navigate hunks in a live session", async () => { if (!ttyToolsAvailable) { return; } @@ -298,70 +275,64 @@ describe("MCP end-to-end", () => { ], ); const port = 48500 + Math.floor(Math.random() * 1000); - const hunkProc = spawnHunkSession(fixture, { port }); + const hunkProc = spawnHunkSession(fixture, { port, quitAfterSeconds: 14, timeoutSeconds: 16 }); let daemonPid: number | null = null; - let transport: StreamableHTTPClientTransport | null = null; try { const health = await waitForHealth(port); daemonPid = health.pid; expect(health.ok).toBe(true); - const client = new Client({ name: "mcp-navigation-test", version: "1.0.0" }); - transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`)); - await client.connect(transport); - const listed = await waitUntil("registered Hunk session", async () => { - const sessions = await listSessions(client); - return sessions.length > 0 ? sessions : null; + const { proc, stdout } = runSessionCli(["list", "--json"], port); + if (proc.exitCode !== 0) { + return null; + } + + const parsed = JSON.parse(stdout) as SessionListJson; + return parsed.sessions.length > 0 ? parsed.sessions : null; }); const targetSession = listed.find((session) => session.files.some((file) => file.path === fixture.afterName)) ?? listed[0]!; - const initialContext = await getSelectedContext(client, targetSession.sessionId); - expect(initialContext?.selectedFile?.path).toBe(fixture.afterName); - expect(initialContext?.selectedHunk?.index).toBe(0); - - const navigateResult = await client.callTool({ - name: "navigate_to_hunk", - arguments: { - sessionId: targetSession.sessionId, - filePath: fixture.afterName, - hunkIndex: 1, + const initialContext = runSessionCli(["context", targetSession.sessionId, "--json"], port); + expect(initialContext.proc.exitCode).toBe(0); + expect(JSON.parse(initialContext.stdout)).toMatchObject({ + context: { + selectedFile: { + path: fixture.afterName, + }, + selectedHunk: { + index: 0, + }, }, }); - const navigated = (navigateResult.structuredContent as { result?: { hunkIndex?: number } } | undefined)?.result; - expect(navigated?.hunkIndex).toBe(1); - - const updatedContext = await waitUntil("selected hunk update", async () => { - const context = await getSelectedContext(client, targetSession.sessionId); - return context?.selectedHunk?.index === 1 ? context : null; - }); - expect(updatedContext.selectedHunk?.newRange).toBeDefined(); - expect(updatedContext.selectedHunk?.oldRange).toBeDefined(); - await client.callTool({ - name: "navigate_to_hunk", - arguments: { - sessionId: targetSession.sessionId, + const navigate = runSessionCli( + ["navigate", targetSession.sessionId, "--file", fixture.afterName, "--hunk", "2", "--json"], + port, + ); + expect(navigate.proc.exitCode).toBe(0); + expect(JSON.parse(navigate.stdout)).toMatchObject({ + result: { filePath: fixture.afterName, - hunkIndex: 0, + hunkIndex: 1, }, }); - const resetContext = await waitUntil("selected hunk reset", async () => { - const context = await getSelectedContext(client, targetSession.sessionId); - return context?.selectedHunk?.index === 0 ? context : null; + await waitUntil("selected hunk update", () => { + const context = runSessionCli(["context", targetSession.sessionId, "--json"], port); + if (context.proc.exitCode !== 0) { + return null; + } + + const parsed = JSON.parse(context.stdout) as { context?: { selectedHunk?: { index: number } } }; + return parsed.context?.selectedHunk?.index === 1 ? parsed : null; }); - expect(resetContext.selectedFile?.path).toBe(fixture.afterName); const hunkExitCode = await hunkProc.exited; expect([0, 124]).toContain(hunkExitCode); } finally { - if (transport) { - await transport.close().catch(() => undefined); - } - hunkProc.kill(); await hunkProc.exited.catch(() => undefined); @@ -375,7 +346,7 @@ describe("MCP end-to-end", () => { } }, 20_000); - test("one daemon routes comments to the correct Hunk session when multiple local sessions are open", async () => { + test("one daemon routes CLI comments to the correct Hunk session when multiple local sessions are open", async () => { if (!ttyToolsAvailable) { return; } @@ -391,24 +362,24 @@ describe("MCP end-to-end", () => { ["export const beta = 2;", "export const shared = true;", "export const onlyBeta = true;"], ); const port = 49000 + Math.floor(Math.random() * 1000); - const hunkProcA = spawnHunkSession(fixtureA, { port }); - const hunkProcB = spawnHunkSession(fixtureB, { port }); + const hunkProcA = spawnHunkSession(fixtureA, { port, quitAfterSeconds: 10, timeoutSeconds: 12 }); + const hunkProcB = spawnHunkSession(fixtureB, { port, quitAfterSeconds: 10, timeoutSeconds: 12 }); let daemonPid: number | null = null; - let transport: StreamableHTTPClientTransport | null = null; try { const health = await waitForHealth(port); daemonPid = health.pid; expect(health.ok).toBe(true); - const client = new Client({ name: "mcp-multisession-test", version: "1.0.0" }); - transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`)); - await client.connect(transport); - const sessions = await waitUntil("two registered Hunk sessions", async () => { - const listed = await listSessions(client); - return listed.length === 2 ? listed : null; + const listed = runSessionCli(["list", "--json"], port); + if (listed.proc.exitCode !== 0) { + return null; + } + + const parsed = JSON.parse(listed.stdout) as SessionListJson; + return parsed.sessions.length === 2 ? parsed.sessions : null; }); const sessionA = sessions.find((session) => session.files.some((file) => file.path === fixtureA.afterName)); @@ -416,33 +387,41 @@ describe("MCP end-to-end", () => { expect(sessionA).toBeDefined(); expect(sessionB).toBeDefined(); - await client.callTool({ - name: "comment", - arguments: { - sessionId: sessionA!.sessionId, - filePath: fixtureA.afterName, - side: "new", - line: 2, - summary: "Alpha note", - rationale: "Delivered only to the alpha Hunk session.", - author: "Pi", - reveal: true, - }, - }); - - await client.callTool({ - name: "comment", - arguments: { - sessionId: sessionB!.sessionId, - filePath: fixtureB.afterName, - side: "new", - line: 2, - summary: "Beta note", - rationale: "Delivered only to the beta Hunk session.", - author: "Pi", - reveal: true, - }, - }); + const commentA = runSessionCli( + [ + "comment", + "add", + sessionA!.sessionId, + "--file", + fixtureA.afterName, + "--new-line", + "2", + "--summary", + "Alpha note", + "--rationale", + "Delivered only to the alpha Hunk session.", + ], + port, + ); + expect(commentA.proc.exitCode).toBe(0); + + const commentB = runSessionCli( + [ + "comment", + "add", + sessionB!.sessionId, + "--file", + fixtureB.afterName, + "--new-line", + "2", + "--summary", + "Beta note", + "--rationale", + "Delivered only to the beta Hunk session.", + ], + port, + ); + expect(commentB.proc.exitCode).toBe(0); const [exitCodeA, exitCodeB] = await Promise.all([hunkProcA.exited, hunkProcB.exited]); expect([0, 124]).toContain(exitCodeA); @@ -459,10 +438,6 @@ describe("MCP end-to-end", () => { expect(transcriptB).toContain("Delivered only to the beta"); expect(transcriptB).not.toContain("Alpha note"); } finally { - if (transport) { - await transport.close().catch(() => undefined); - } - hunkProcA.kill(); hunkProcB.kill(); await Promise.allSettled([hunkProcA.exited, hunkProcB.exited]); diff --git a/test/mcp-server.test.ts b/test/mcp-server.test.ts index 34963eff..79b27beb 100644 --- a/test/mcp-server.test.ts +++ b/test/mcp-server.test.ts @@ -6,6 +6,19 @@ const originalHost = process.env.HUNK_MCP_HOST; const originalPort = process.env.HUNK_MCP_PORT; const originalUnsafeRemote = process.env.HUNK_MCP_UNSAFE_ALLOW_REMOTE; +async function reserveLoopbackPort() { + const listener = createServer(() => undefined); + await new Promise((resolve, reject) => { + listener.once("error", reject); + listener.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = listener.address(); + const port = typeof address === "object" && address ? address.port : 0; + await new Promise((resolve) => listener.close(() => resolve())); + return port; +} + afterEach(() => { if (originalHost === undefined) { delete process.env.HUNK_MCP_HOST; @@ -26,7 +39,7 @@ afterEach(() => { } }); -describe("Hunk MCP server", () => { +describe("Hunk session daemon server", () => { test("refuses non-loopback binding unless explicitly allowed", () => { process.env.HUNK_MCP_HOST = "0.0.0.0"; process.env.HUNK_MCP_PORT = "47657"; @@ -53,4 +66,35 @@ describe("Hunk MCP server", () => { await new Promise((resolve) => listener.close(() => resolve())); } }); + + test("exposes session capabilities and rejects the old MCP tool endpoint", async () => { + const port = await reserveLoopbackPort(); + process.env.HUNK_MCP_HOST = "127.0.0.1"; + process.env.HUNK_MCP_PORT = String(port); + + const server = serveHunkMcpServer(); + + try { + const capabilities = await fetch(`http://127.0.0.1:${port}/session-api/capabilities`); + expect(capabilities.status).toBe(200); + await expect(capabilities.json()).resolves.toMatchObject({ + version: 1, + actions: ["list", "get", "context", "navigate", "comment-add", "comment-list", "comment-rm", "comment-clear"], + }); + + const legacyMcp = await fetch(`http://127.0.0.1:${port}/mcp`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({}), + }); + expect(legacyMcp.status).toBe(410); + await expect(legacyMcp.json()).resolves.toMatchObject({ + error: expect.stringContaining("Use `hunk session ...` instead"), + }); + } finally { + server.stop(true); + } + }); }); diff --git a/test/session-commands.test.ts b/test/session-commands.test.ts index 572404ba..a1419b61 100644 --- a/test/session-commands.test.ts +++ b/test/session-commands.test.ts @@ -38,9 +38,10 @@ function createListedSession(sessionId: string) { function createClient(overrides: Partial): HunkDaemonCliClient { return { - connect: async () => undefined, - close: async () => undefined, - listToolNames: async () => new Set(), + getCapabilities: async () => ({ + version: 1, + actions: ["list", "get", "context", "navigate", "comment-add", "comment-list", "comment-rm", "comment-clear"], + }), listSessions: async () => [], getSession: async () => createListedSession("session-1"), getSelectedContext: async () => ({ @@ -96,16 +97,16 @@ afterEach(() => { }); describe("session command compatibility checks", () => { - test("refreshes an older Hunk daemon before running a newer context command", async () => { + test("refreshes an older daemon without the session API before running context", async () => { const selector: SessionSelectorInput = { sessionId: "session-1" }; - const restartCalls: Array<{ missingTools: string[]; selector?: SessionSelectorInput }> = []; + const restartCalls: Array<{ action: string; selector?: SessionSelectorInput }> = []; const createdClients: string[] = []; const clients = [ createClient({ - listToolNames: async () => { - createdClients.push("stale-tools"); - return new Set(["list_sessions", "get_session", "comment"]); + getCapabilities: async () => { + createdClients.push("stale-capabilities"); + return null; }, }), createClient({ @@ -147,8 +148,8 @@ describe("session command compatibility checks", () => { return client; }, resolveDaemonAvailability: async () => true, - restartDaemonForMissingTools: async (missingTools, receivedSelector) => { - restartCalls.push({ missingTools, selector: receivedSelector }); + restartDaemonForMissingAction: async (action, receivedSelector) => { + restartCalls.push({ action, selector: receivedSelector }); }, }); @@ -172,32 +173,38 @@ describe("session command compatibility checks", () => { }); expect(restartCalls).toEqual([ { - missingTools: ["get_selected_context"], + action: "context", selector, }, ]); - expect(createdClients).toEqual(["stale-tools", "fresh-context"]); + expect(createdClients).toEqual(["stale-capabilities", "fresh-context"]); }); - test("throws a clear error when the daemon is missing tools but does not look like Hunk", async () => { + test("does not restart when the daemon already exposes the needed session action", async () => { + const restartCalls: string[] = []; + setSessionCommandTestHooks({ createClient: () => createClient({ - listToolNames: async () => new Set(["list_sessions", "strange_tool"]), + getCapabilities: async () => ({ + version: 1, + actions: ["list", "get", "context", "navigate", "comment-add", "comment-list", "comment-rm", "comment-clear"], + }), }), resolveDaemonAvailability: async () => true, - restartDaemonForMissingTools: async () => { - throw new Error("should not restart"); + restartDaemonForMissingAction: async (action) => { + restartCalls.push(action); }, }); - await expect( - runSessionCommand({ - kind: "session", - action: "comment-list", - selector: { sessionId: "session-1" }, - output: "json", - } satisfies SessionCommandInput), - ).rejects.toThrow("missing required tools (list_comments)"); + const output = await runSessionCommand({ + kind: "session", + action: "comment-list", + selector: { sessionId: "session-1" }, + output: "json", + } satisfies SessionCommandInput); + + expect(JSON.parse(output)).toEqual({ comments: [] }); + expect(restartCalls).toEqual([]); }); });