From 8981456dfdf8294af6790550a8c8d34817f9bc53 Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:24:57 +0400 Subject: [PATCH 01/21] chore: add MCP SDK and CLI stream fixtures Pin @modelcontextprotocol/sdk 1.32.1 for the tool bridge. Fixtures are sanitized `-p` stream-json recordings (text, tool, signed-out). --- package-lock.json | 945 +++++++++++++++++++- package.json | 1 + tests/fixtures/claude-code/signed-out.jsonl | 4 + tests/fixtures/claude-code/text.jsonl | 11 + tests/fixtures/claude-code/tool.jsonl | 26 + 5 files changed, 957 insertions(+), 30 deletions(-) create mode 100644 tests/fixtures/claude-code/signed-out.jsonl create mode 100644 tests/fixtures/claude-code/text.jsonl create mode 100644 tests/fixtures/claude-code/tool.jsonl diff --git a/package-lock.json b/package-lock.json index a7d6a2b..eb4d334 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@earendil-works/pi-agent-core": "1.4.0", "@earendil-works/pi-ai": "1.4.0", + "@modelcontextprotocol/sdk": "1.32.1", "@mozilla/readability": "^0.6.0", "@radix-ui/react-use-controllable-state": "^1.2.6", "@resvg/resvg-js": "^2.6.2", @@ -1610,6 +1611,18 @@ } } }, + "node_modules/@hono/node-server": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz", + "integrity": "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4 || ^5.0.0-0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2327,6 +2340,68 @@ "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", "license": "BSD-2-Clause" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "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" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@mozilla/readability": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz", @@ -6372,6 +6447,44 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -6419,6 +6532,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/anser": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/anser/-/anser-2.3.5.tgz", @@ -6980,6 +7132,59 @@ "dev": true, "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.1.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.3", + "on-finished": "^2.4.1", + "qs": "^6.16.1", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", @@ -7086,6 +7291,15 @@ "node": ">=12.0.0" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/bytestreamjs": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", @@ -7148,7 +7362,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7162,7 +7375,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -7604,6 +7816,46 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -7611,6 +7863,23 @@ "dev": true, "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -7633,7 +7902,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -8398,6 +8666,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -8639,7 +8916,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -8669,6 +8945,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -9006,6 +9288,15 @@ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -9154,7 +9445,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9164,7 +9454,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9174,7 +9463,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -9310,6 +9598,12 @@ "integrity": "sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw==", "license": "MIT" }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -9752,6 +10046,36 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -9759,6 +10083,106 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "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.8", + "qs": "^6.16.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.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz", + "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -9769,7 +10193,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -9826,7 +10249,6 @@ "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", - "dev": true, "funding": [ { "type": "github", @@ -9947,6 +10369,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -10030,6 +10473,15 @@ "node": ">=12.20.0" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/framer-motion": { "version": "13.1.1", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.1.1.tgz", @@ -10053,6 +10505,15 @@ } } }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -10093,7 +10554,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10196,7 +10656,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -10230,7 +10689,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -10432,7 +10890,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10535,7 +10992,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10564,7 +11020,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -10823,6 +11278,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hono": { + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -10967,6 +11431,26 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -11083,7 +11567,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/inline-style-parser": { @@ -11116,6 +11599,24 @@ "node": ">=12" } }, + "node_modules/ip-address": { + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -11455,6 +11956,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -11624,7 +12131,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jake": { @@ -11655,6 +12161,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.13", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.13.tgz", + "integrity": "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", @@ -11713,6 +12228,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -12286,7 +12807,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -12649,6 +13169,31 @@ "ce-la-react": "^0.3.2" } }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -13564,6 +14109,35 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/next": { "version": "16.3.2", "resolved": "https://registry.npmjs.org/next/-/next-16.3.2.tgz", @@ -13886,11 +14460,19 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13999,11 +14581,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-finished": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -14211,6 +14808,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/partial-json": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", @@ -14247,7 +14853,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -14260,6 +14865,16 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pdfjs-dist": { "version": "6.3.289", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz", @@ -14306,6 +14921,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkijs": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.1.tgz", @@ -14562,6 +15186,23 @@ "node": ">=12.0.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/proxy-agent-negotiate": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", @@ -14637,6 +15278,22 @@ "node": ">=16.0.0" } }, + "node_modules/qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -14748,6 +15405,50 @@ } } }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -15209,7 +15910,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -15369,6 +16069,22 @@ "points-on-path": "^0.2.1" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -15536,6 +16252,57 @@ "license": "MIT", "optional": true }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", @@ -15553,6 +16320,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -15602,6 +16388,12 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/sharp": { "version": "0.35.3", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", @@ -15656,7 +16448,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -15669,7 +16460,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -15692,7 +16482,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -15712,7 +16501,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -15729,7 +16517,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -15748,7 +16535,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -15862,6 +16648,15 @@ "node": ">= 6" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -16353,6 +17148,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tokenlens": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tokenlens/-/tokenlens-1.3.1.tgz", @@ -16511,6 +17315,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typebox": { "version": "1.3.27", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", @@ -16786,6 +17646,15 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unrs-resolver": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", @@ -16957,6 +17826,15 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -17042,7 +17920,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -17191,7 +18068,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -17289,6 +18165,15 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", diff --git a/package.json b/package.json index fdd6c84..de33dcc 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dependencies": { "@earendil-works/pi-agent-core": "1.4.0", "@earendil-works/pi-ai": "1.4.0", + "@modelcontextprotocol/sdk": "1.32.1", "@mozilla/readability": "^0.6.0", "@radix-ui/react-use-controllable-state": "^1.2.6", "@resvg/resvg-js": "^2.6.2", diff --git a/tests/fixtures/claude-code/signed-out.jsonl b/tests/fixtures/claude-code/signed-out.jsonl new file mode 100644 index 0000000..19c5beb --- /dev/null +++ b/tests/fixtures/claude-code/signed-out.jsonl @@ -0,0 +1,4 @@ +{"type":"system","subtype":"init","cwd":"/tmp/claude-code-empty","session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","model":"claude-haiku-5-5","tools":[],"mcp_servers":[],"apiKeySource":"none","permissionMode":"default","claude_code_version":"2.1.308","uuid":"941c8a75-e5cc-4014-93ec-4b065e619d15"} +{"type":"system","subtype":"status","status":"requesting","session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","uuid":"1cbc259e-ad27-49ae-8a03-1dd0bc7d04ae"} +{"type":"assistant","message":{"diagnostics":null,"id":"a4eeead1-04bb-47fd-9567-bebc78052a3e","container":null,"model":"","role":"assistant","stop_details":null,"stop_reason":"stop_sequence","stop_sequence":"","type":"message","usage":{"output_tokens_details":null,"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":null,"cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":null,"iterations":null,"speed":null,"fallback_credit":null},"content":[{"type":"text","text":"Not logged in · Please run /login"}],"context_management":null},"parent_tool_use_id":null,"session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","uuid":"58c2fe59-d155-4d2d-a0e1-f64e6aa09199","timestamp":"2026-10-24T14:03:24.319Z","error":"authentication_failed","is_api_error_message":true} +{"duration_api_ms":0,"stop_reason":"stop_sequence","session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","total_cost_usd":0,"usage":{"output_tokens_details":{"thinking_tokens":0},"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard","fallback_credit":null},"modelUsage":{},"permission_denials":[],"terminal_reason":"api_error","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":true,"num_turns":1,"subtype":"success","api_error_status":null,"result":"Not logged in · Please run /login","type":"result","duration_ms":21,"uuid":"7e41c29d-1912-4c14-8955-9f176fef725f","queued_turn_count":0,"result_index":0} diff --git a/tests/fixtures/claude-code/text.jsonl b/tests/fixtures/claude-code/text.jsonl new file mode 100644 index 0000000..fd12b6a --- /dev/null +++ b/tests/fixtures/claude-code/text.jsonl @@ -0,0 +1,11 @@ +{"type":"system","subtype":"init","cwd":"/tmp/claude-code-empty","session_id":"a489e311-9d94-4990-b539-63a765dd0edd","model":"claude-haiku-5-5","tools":[],"mcp_servers":[],"apiKeySource":"none","permissionMode":"default","claude_code_version":"2.1.308","uuid":"c7e564b7-af08-4024-a89e-66736710e2c1"} +{"type":"system","subtype":"status","status":"requesting","session_id":"a489e311-9d94-4990-b539-63a765dd0edd","uuid":"0738b666-b249-4cca-8c36-f92df8326546"} +{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBka5NLhDVNVihRY4Lm","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"output_tokens":5,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"512e3a21-2563-457b-addb-adade96b3d2b","ttft_ms":1224,"thinking_display":"updates","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"0b2a75fb-819f-43ef-9fc7-839f1a5c5a01","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello there"}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"85823b9b-d5e0-4973-93f9-8525a8894762","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"} +{"type":"assistant","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBka5NLhDVNVihRY4Lm","type":"message","role":"assistant","content":[{"type":"text","text":"hello there"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"output_tokens":5,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","uuid":"92acba82-60ca-4502-a5a9-bafd75bd7277","timestamp":"2026-10-24T14:02:46.324Z","request_id":"req_011CgMBkZrxiAPxuioCwrRRu"} +{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"1d19a483-51e6-45b6-96a2-bf445a9ab04b","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"} +{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"output_tokens":5,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":607,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"85ded13f-99fc-4ac2-b211-2c71a40bb817","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"} +{"type":"stream_event","event":{"type":"message_stop"},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"d9085ac3-b00c-4e22-a17e-0fafcf7782a2","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"uuid":"679f75df-0571-4b65-bc3e-c4cee96c7061","session_id":"a489e311-9d94-4990-b539-63a765dd0edd"} +{"duration_api_ms":1236,"stop_reason":"end_turn","session_id":"a489e311-9d94-4990-b539-63a765dd0edd","total_cost_usd":0.0001241,"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"output_tokens":5,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":607,"ephemeral_5m_input_tokens":0},"inference_geo":"global","iterations":[{"input_tokens":2,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":607,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"type":"message"}],"speed":"standard","fallback_credit":null},"modelUsage":{"claude-haiku-5-5":{"inputTokens":2,"outputTokens":5,"cacheReadInputTokens":0,"cacheCreationInputTokens":607,"webSearchRequests":0,"costUSD":0.0001241,"contextWindow":1000000,"maxOutputTokens":128000,"thinkingTokens":0,"canonicalModel":"claude-haiku-5-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":false,"num_turns":1,"subtype":"success","api_error_status":null,"result":"hello there","ttft_ms":1245,"type":"result","duration_ms":1258,"uuid":"faf44ee1-84e1-40eb-914e-654abb6c2286","ttft_stream_ms":1244,"time_to_request_ms":21,"first_content_frame_ms":1245,"queued_turn_count":0,"result_index":0} diff --git a/tests/fixtures/claude-code/tool.jsonl b/tests/fixtures/claude-code/tool.jsonl new file mode 100644 index 0000000..c1b7863 --- /dev/null +++ b/tests/fixtures/claude-code/tool.jsonl @@ -0,0 +1,26 @@ +{"type":"system","subtype":"init","cwd":"/tmp/claude-code-empty","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","model":"claude-haiku-5-5","tools":["mcp__learnharness__get_weather"],"mcp_servers":[{"name":"learnharness","status":"connected","source":"dynamic"}],"apiKeySource":"none","permissionMode":"default","claude_code_version":"2.1.308","uuid":"9524e7cd-f580-4019-8e63-861e36aa190d"} +{"type":"system","subtype":"status","status":"requesting","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"6a55f520-5449-4845-bfe7-d3d74e4f5dda"} +{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnEgMnaPp7zJQPnYig","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1102,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1102},"output_tokens":32,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"16a84391-efcc-4221-9029-1fbea99edbab","ttft_ms":1170,"thinking_display":"updates","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01JBB4cJsPa585FWb1XrfbNX","name":"mcp__learnharness__get_weather","input":{},"caller":{"type":"direct"}}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"460ff54c-2ab3-46a0-b2bf-c0d780d04e58","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"e73df731-5ecc-439c-811a-7b6cc5219a7f","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"city\": \"Paris"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"1ae8e01b-8cfe-4282-a008-a9ef3f2e05fa","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"}"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"b7cf01be-dc67-463e-ad06-19267ea81cc3","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"} +{"type":"assistant","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnEgMnaPp7zJQPnYig","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JBB4cJsPa585FWb1XrfbNX","name":"mcp__learnharness__get_weather","input":{"city":"Paris"},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1102,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1102},"output_tokens":32,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"da987ff6-9b30-4c1e-8f5b-20bda7386663","timestamp":"2026-10-24T14:03:09.115Z","request_id":"req_011CgMBnETxUPY4jwbRZ3rFy","wire_tool_inputs":{"toolu_01JBB4cJsPa585FWb1XrfbNX":{"city":"Paris"}},"tool_use_meta":[{"id":"toolu_01JBB4cJsPa585FWb1XrfbNX","display_name":"Get Weather","server_display_name":"learnharness","tool_name":"get_weather"}]} +{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"f438dbab-5bac-4b68-a237-ef9564abd25c","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JBB4cJsPa585FWb1XrfbNX","type":"tool_result","content":[{"type":"text","text":"Sunny, 21C in Paris"}]}]},"parent_tool_use_id":null,"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"f44480b5-8e02-4958-888e-fbbe3b54dc80","timestamp":"2026-10-24T14:03:09.124Z","tool_use_result":[{"type":"text","text":"Sunny, 21C in Paris"}],"tool_result_meta":[{"id":"toolu_01JBB4cJsPa585FWb1XrfbNX","permission_decision":{"decision":"accept","source":"config","reason_type":"rule"}}]} +{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":1102,"cache_read_input_tokens":0,"output_tokens":59,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":59,"cache_read_input_tokens":0,"cache_creation_input_tokens":1102,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1102},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"7fec4e77-f338-4f24-810c-d6fa959d154f","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"} +{"type":"stream_event","event":{"type":"message_stop"},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"5d3b3311-08d0-4af6-be82-b0d3f472e99d","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"uuid":"f21e5357-4ffc-4346-b9fc-6543e910eab3","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038"} +{"type":"system","subtype":"status","status":"requesting","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"22297606-28e0-4b2f-9208-bc4be5487a80"} +{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnLsfzVJKssny6FQZm","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":1102,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"output_tokens":1,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"90d16779-8e65-40d2-a55e-a68a9786a927","ttft_ms":1274,"thinking_display":"updates","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"1f8d1832-7837-444e-a07e-2598927888bb","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"It"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"29baece7-f120-4088-8901-9ee4c16f9ffa","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"'s sunny in Paris"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"7189b574-320a-4730-9cf7-c92c089bdf95","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" right"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"1db451a5-7027-4b25-bb69-2b8b6b94d600","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" now, with a temperature of"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"7ca48472-b631-4117-a204-9f6fe7854f4a","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" 21°C."}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"86930cda-4e2b-405c-a45f-29ee42d2efca","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"assistant","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnLsfzVJKssny6FQZm","type":"message","role":"assistant","content":[{"type":"text","text":"It's sunny in Paris right now, with a temperature of 21°C."}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":1102,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"output_tokens":1,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"69cad926-aa35-4a82-a24c-599cc5422d99","timestamp":"2026-10-24T14:03:10.497Z","request_id":"req_011CgMBnLc4EeExeJ32dkAoH"} +{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"c05c7c31-f90a-4f4b-8a5b-31f2dae60294","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":1102,"output_tokens":25,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":25,"cache_read_input_tokens":1102,"cache_creation_input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"6193aeb9-61b1-41e8-959a-9ae646429d11","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"stream_event","event":{"type":"message_stop"},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"22fbda30-76c1-4527-9819-cc3d00fb4e68","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"duration_api_ms":2827,"stop_reason":"end_turn","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","total_cost_usd":0.00029382000000000003,"usage":{"input_tokens":4,"cache_creation_input_tokens":1202,"cache_read_input_tokens":1102,"output_tokens":84,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":1202,"ephemeral_5m_input_tokens":0},"inference_geo":"global","iterations":[{"input_tokens":2,"output_tokens":25,"cache_read_input_tokens":1102,"cache_creation_input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"type":"message"}],"speed":"standard","fallback_credit":null},"modelUsage":{"claude-haiku-5-5":{"inputTokens":4,"outputTokens":84,"cacheReadInputTokens":1102,"cacheCreationInputTokens":1202,"webSearchRequests":0,"costUSD":0.00029382000000000003,"contextWindow":1000000,"maxOutputTokens":128000,"thinkingTokens":0,"canonicalModel":"claude-haiku-5-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":false,"num_turns":2,"subtype":"success","api_error_status":null,"result":"It's sunny in Paris right now, with a temperature of 21°C.","ttft_ms":1457,"type":"result","duration_ms":2854,"uuid":"0be743a1-83e1-45ab-96f5-926ffee7a85b","ttft_stream_ms":1191,"time_to_request_ms":21,"first_content_frame_ms":1191,"queued_turn_count":0,"result_index":0} From 1df95fdfdd57c7a74997c48483ee34b67bf8b0e5 Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:24:57 +0400 Subject: [PATCH 02/21] feat: locate CLI binary Search PATH, then standard install dirs, since the packaged app often starts with a minimal PATH. --- src/agent/claude-code/binary.ts | 61 ++++++++++++++++++++++++++++++++ tests/claude-code-binary.test.ts | 54 ++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/agent/claude-code/binary.ts create mode 100644 tests/claude-code-binary.test.ts diff --git a/src/agent/claude-code/binary.ts b/src/agent/claude-code/binary.ts new file mode 100644 index 0000000..44becc3 --- /dev/null +++ b/src/agent/claude-code/binary.ts @@ -0,0 +1,61 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +type BinaryLookup = { + env: Record; + platform: NodeJS.Platform; + homeDir: string; + isExecutable: (path: string) => boolean; +}; + +type SearchSpec = { + paths: path.PlatformPath; + names: string[]; + knownDirs: string[]; +}; + +function getWindowsSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec { + const paths = path.win32; + const appData = env.APPDATA ?? paths.join(homeDir, "AppData", "Roaming"); + return { + paths, + names: ["claude.cmd", "claude.exe"], + knownDirs: [paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), env.npm_config_prefix ?? paths.join(appData, "npm")], + }; +} + +function getPosixSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec { + const paths = path.posix; + const npmBin = env.npm_config_prefix ? paths.join(env.npm_config_prefix, "bin") : paths.join(homeDir, ".npm-global", "bin"); + return { + paths, + names: ["claude"], + knownDirs: [paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), npmBin, "/opt/homebrew/bin", "/usr/local/bin"], + }; +} + +export function resolveClaudeBinary({ env, platform, homeDir, isExecutable }: BinaryLookup): string | null { + const { paths, names, knownDirs } = platform === "win32" ? getWindowsSearchSpec(env, homeDir) : getPosixSearchSpec(env, homeDir); + const pathDirs = (env.PATH ?? "").split(paths.delimiter).filter(Boolean); + for (const dir of [...pathDirs, ...knownDirs]) { + for (const name of names) { + const candidate = paths.join(dir, name); + if (isExecutable(candidate)) return candidate; + } + } + return null; +} + +function isExecutableFile(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return fs.statSync(filePath).isFile(); + } catch { + return false; + } +} + +export function getClaudeBinary(): string | null { + return resolveClaudeBinary({ env: process.env, platform: process.platform, homeDir: os.homedir(), isExecutable: isExecutableFile }); +} diff --git a/tests/claude-code-binary.test.ts b/tests/claude-code-binary.test.ts new file mode 100644 index 0000000..05adf28 --- /dev/null +++ b/tests/claude-code-binary.test.ts @@ -0,0 +1,54 @@ +/** + * Claude Code binary lookup (src/agent/claude-code/binary.ts). + * + * The packaged desktop app often starts with a minimal PATH, so lookup falls + * back to the standard install locations. A fake isExecutable set stands in + * for the filesystem. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveClaudeBinary } from "../src/agent/claude-code/binary.ts"; + +const HOME = "/home/u"; + +function resolveWith(executables: string[], env: Record = {}, platform: NodeJS.Platform = "linux", homeDir = HOME) { + const found = new Set(executables); + return resolveClaudeBinary({ env, platform, homeDir, isExecutable: (p) => found.has(p) }); +} + +test("prefers PATH hit", () => { + const binary = resolveWith(["/usr/bin/claude", `${HOME}/.local/bin/claude`], { PATH: "/opt/x:/usr/bin" }); + assert.equal(binary, "/usr/bin/claude"); +}); + +test("falls back to ~/.local/bin when PATH misses", () => { + const binary = resolveWith([`${HOME}/.local/bin/claude`, "/usr/local/bin/claude"], { PATH: "/usr/bin" }); + assert.equal(binary, `${HOME}/.local/bin/claude`); +}); + +test("checks ~/.claude/local, npm global bin, /opt/homebrew/bin, /usr/local/bin in that order", () => { + const all = [`${HOME}/.claude/local/claude`, `${HOME}/.npm-global/bin/claude`, "/opt/homebrew/bin/claude", "/usr/local/bin/claude"]; + for (let i = 0; i < all.length; i++) { + assert.equal(resolveWith(all.slice(i), { PATH: "" }), all[i]); + } +}); + +test("npm global bin follows npm_config_prefix when set", () => { + const binary = resolveWith(["/opt/npm/bin/claude", `${HOME}/.npm-global/bin/claude`], { npm_config_prefix: "/opt/npm" }); + assert.equal(binary, "/opt/npm/bin/claude"); +}); + +test("windows looks for claude.cmd and claude.exe", () => { + const winHome = "C:\\Users\\u"; + const env = { PATH: "C:\\tools;C:\\bin", APPDATA: `${winHome}\\AppData\\Roaming` }; + assert.equal(resolveWith(["C:\\bin\\claude.cmd"], env, "win32", winHome), "C:\\bin\\claude.cmd"); + assert.equal(resolveWith(["C:\\bin\\claude.exe"], env, "win32", winHome), "C:\\bin\\claude.exe"); + assert.equal(resolveWith([`${winHome}\\.local\\bin\\claude.exe`], env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`); + assert.equal(resolveWith([`${winHome}\\AppData\\Roaming\\npm\\claude.cmd`], env, "win32", winHome), `${winHome}\\AppData\\Roaming\\npm\\claude.cmd`); + assert.equal(resolveWith(["C:\\bin\\claude"], env, "win32", winHome), null); +}); + +test("returns null when every location misses", () => { + assert.equal(resolveWith([], { PATH: "/usr/bin:/bin" }), null); +}); From 913b29efe596e0edaee7aec60de3779aa9ed03c9 Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:24:57 +0400 Subject: [PATCH 03/21] feat: add subscription CLI provider catalog opus, sonnet and haiku aliases, low..max thinking, zero cost, keyless auth. Streaming is injected. --- src/agent/claude-code/constants.ts | 8 +++++ src/agent/claude-code/provider.ts | 33 +++++++++++++++++ tests/claude-code-provider.test.ts | 57 ++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 src/agent/claude-code/constants.ts create mode 100644 src/agent/claude-code/provider.ts create mode 100644 tests/claude-code-provider.test.ts diff --git a/src/agent/claude-code/constants.ts b/src/agent/claude-code/constants.ts new file mode 100644 index 0000000..18ac8d7 --- /dev/null +++ b/src/agent/claude-code/constants.ts @@ -0,0 +1,8 @@ +export const CLAUDE_CODE_PROVIDER = "claude-code"; + +export const CLAUDE_CODE_API = "claude-code-cli"; + +export enum ClaudeCodeError { + CliNotFound = "claude CLI not found", + NotSignedIn = "not signed in", +} diff --git a/src/agent/claude-code/provider.ts b/src/agent/claude-code/provider.ts new file mode 100644 index 0000000..c190230 --- /dev/null +++ b/src/agent/claude-code/provider.ts @@ -0,0 +1,33 @@ +import { createProvider, type Api, type Model, type Provider, type SimpleStreamOptions, type StreamFunction } from "@earendil-works/pi-ai"; + +import { CLAUDE_CODE_API, CLAUDE_CODE_PROVIDER } from "./constants.ts"; + +const SHARED_MODEL_FIELDS = { + api: CLAUDE_CODE_API, + provider: CLAUDE_CODE_PROVIDER, + baseUrl: "", + input: ["text", "image"], + // Billed to the user's Claude subscription, not per token. + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: true, + // The CLI has no thinking-off switch; effort low is the floor. + thinkingLevelMap: { off: null, minimal: null, low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "max" }, + contextWindow: 1_000_000, + maxTokens: 128_000, +} satisfies Omit, "id" | "name">; + +export const CLAUDE_CODE_MODELS: readonly Model[] = [ + { ...SHARED_MODEL_FIELDS, id: "opus", name: "Claude Opus" }, + { ...SHARED_MODEL_FIELDS, id: "sonnet", name: "Claude Sonnet" }, + { ...SHARED_MODEL_FIELDS, id: "haiku", name: "Claude Haiku" }, +]; + +export function createClaudeCodeProvider(streamSimple: StreamFunction): Provider { + return createProvider({ + id: CLAUDE_CODE_PROVIDER, + name: "Claude Code", + auth: { apiKey: { name: "Claude Code CLI", resolve: async () => ({ auth: {}, source: "claude CLI" }) } }, + models: CLAUDE_CODE_MODELS, + api: { stream: streamSimple, streamSimple }, + }); +} diff --git a/tests/claude-code-provider.test.ts b/tests/claude-code-provider.test.ts new file mode 100644 index 0000000..a8ef24d --- /dev/null +++ b/tests/claude-code-provider.test.ts @@ -0,0 +1,57 @@ +/** + * Claude Code provider catalog (src/agent/claude-code/provider.ts). + * + * Registered on a fresh pi model collection with a stub stream function, so + * only the catalog and auth surface are under test here. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { clampThinkingLevel, createAssistantMessageEventStream, createModels, getSupportedThinkingLevels } from "@earendil-works/pi-ai"; + +import { CLAUDE_CODE_PROVIDER } from "../src/agent/claude-code/constants.ts"; +import { createClaudeCodeProvider } from "../src/agent/claude-code/provider.ts"; + +function createCollection() { + const collection = createModels(); + collection.setProvider(createClaudeCodeProvider(() => createAssistantMessageEventStream())); + return collection; +} + +test("lists exactly the opus, sonnet and haiku aliases", () => { + const models = createCollection().getModels(CLAUDE_CODE_PROVIDER); + assert.deepEqual( + models.map((m) => m.id), + ["opus", "sonnet", "haiku"], + ); + assert.deepEqual( + models.map((m) => m.name), + ["Claude Opus", "Claude Sonnet", "Claude Haiku"], + ); +}); + +test("supports low through max thinking and clamps off to low", () => { + for (const model of createCollection().getModels(CLAUDE_CODE_PROVIDER)) { + assert.deepEqual(getSupportedThinkingLevels(model), ["low", "medium", "high", "xhigh", "max"]); + assert.equal(clampThinkingLevel(model, "off"), "low"); + assert.equal(clampThinkingLevel(model, "minimal"), "low"); + } +}); + +test("accepts text and image input", () => { + for (const model of createCollection().getModels(CLAUDE_CODE_PROVIDER)) { + assert.deepEqual(model.input, ["text", "image"]); + } +}); + +test("every cost field is zero", () => { + for (const model of createCollection().getModels(CLAUDE_CODE_PROVIDER)) { + assert.deepEqual(model.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); + } +}); + +test("auth resolves as configured without any key", async () => { + const collection = createCollection(); + assert.ok(await collection.checkAuth(CLAUDE_CODE_PROVIDER)); + assert.equal((await collection.getAvailable(CLAUDE_CODE_PROVIDER)).length, 3); +}); From 5c0425901c06213b38e82dc3789a3c4f26716ac8 Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:35:20 +0400 Subject: [PATCH 04/21] feat: replay pi transcript as CLI input --- src/agent/claude-code/transcript.ts | 75 ++++++++++++++ tests/claude-code-transcript.test.ts | 146 +++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 src/agent/claude-code/transcript.ts create mode 100644 tests/claude-code-transcript.test.ts diff --git a/src/agent/claude-code/transcript.ts b/src/agent/claude-code/transcript.ts new file mode 100644 index 0000000..67cba3c --- /dev/null +++ b/src/agent/claude-code/transcript.ts @@ -0,0 +1,75 @@ +import type { ImageContent, Message, TextContent, Tool } from "@earendil-works/pi-ai"; +import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript"; + +export type ClaudeContentBlock = { type: "text"; text: string } | { type: "image"; source: { type: "base64"; media_type: string; data: string } }; + +export type ClaudeInput = { + systemPrompt: string; + tools: Tool[]; + userContent: ClaudeContentBlock[]; +}; + +type ReplayPart = string | ImageContent; + +function toImageBlock(image: ImageContent): ClaudeContentBlock { + return { type: "image", source: { type: "base64", media_type: image.mimeType, data: image.data } }; +} + +function getLabelledParts(label: string, content: string | (TextContent | ImageContent)[]): ReplayPart[] { + if (typeof content === "string") return [`${label}\n${content}`]; + const text = content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("\n"); + const images = content.filter((block) => block.type === "image"); + return [text ? `${label}\n${text}` : label, ...images]; +} + +function getReplayParts(message: Message): ReplayPart[] { + switch (message.role) { + case "system": + return []; + case "user": + return getLabelledParts("[user]", message.content); + case "assistant": + return message.content.flatMap((block) => { + if (block.type === "text") return [`[assistant]\n${block.text}`]; + if (block.type === "toolCall") return [`[tool call ${block.name}] ${JSON.stringify(block.arguments)}`]; + return []; + }); + case "toolResult": + return getLabelledParts(message.isError ? `[tool result ${message.toolName} error]` : `[tool result ${message.toolName}]`, message.content); + } +} + +function toContentBlocks(parts: ReplayPart[]): ClaudeContentBlock[] { + const blocks: ClaudeContentBlock[] = []; + let pendingText: string[] = []; + const flushText = () => { + if (pendingText.length > 0) blocks.push({ type: "text", text: pendingText.join("\n\n") }); + pendingText = []; + }; + for (const part of parts) { + if (typeof part === "string") { + pendingText.push(part); + } else { + flushText(); + blocks.push(toImageBlock(part)); + } + } + flushText(); + return blocks; +} + +function toUnlabelledBlocks(content: string | (TextContent | ImageContent)[]): ClaudeContentBlock[] { + if (typeof content === "string") return [{ type: "text", text: content }]; + return content.map((block) => (block.type === "text" ? { type: "text", text: block.text } : toImageBlock(block))); +} + +export function toClaudeInput(messages: Message[]): ClaudeInput { + const conversation = messages.filter((message) => message.role !== "system"); + const onlyMessage = conversation.length === 1 ? conversation[0] : null; + const userContent = onlyMessage?.role === "user" ? toUnlabelledBlocks(onlyMessage.content) : toContentBlocks(conversation.flatMap(getReplayParts)); + return { systemPrompt: getCurrentSystemPrompt(messages), tools: getCurrentTools(messages), userContent }; +} + +export function toStreamJsonLine(content: ClaudeContentBlock[]): string { + return JSON.stringify({ type: "user", message: { role: "user", content } }); +} diff --git a/tests/claude-code-transcript.test.ts b/tests/claude-code-transcript.test.ts new file mode 100644 index 0000000..0fbec27 --- /dev/null +++ b/tests/claude-code-transcript.test.ts @@ -0,0 +1,146 @@ +/** + * Claude Code transcript replay (src/agent/claude-code/transcript.ts). + * + * The CLI makes one model call per input user message, so earlier turns are + * rendered as labelled text inside the single user message it receives. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { AssistantMessage, Message, SystemMessage, Tool, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai"; + +import { toClaudeInput, toStreamJsonLine } from "../src/agent/claude-code/transcript.ts"; + +const READ_TOOL: Tool = { name: "read", description: "Read a file", parameters: { type: "object", properties: {} } }; +const WRITE_TOOL: Tool = { name: "write", description: "Write a file", parameters: { type: "object", properties: {} } }; + +function system(fields: Partial): SystemMessage { + return { role: "system", content: "", timestamp: 0, ...fields }; +} + +function user(content: UserMessage["content"]): UserMessage { + return { role: "user", content, timestamp: 0 }; +} + +function assistant(content: AssistantMessage["content"]): AssistantMessage { + return { + role: "assistant", + content, + api: "claude-code-cli", + provider: "claude-code", + model: "opus", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "stop", + timestamp: 0, + }; +} + +function toolResult(toolName: string, content: ToolResultMessage["content"], isError = false): ToolResultMessage { + return { role: "toolResult", toolCallId: `${toolName}-1`, toolName, content, isError, timestamp: 0 }; +} + +test("system prompt joins all system content, then the live sections", () => { + const messages: Message[] = [ + system({ content: "You are a tutor.", sections: { goals: "Teach TCP.", style: "Be brief." } }), + user("hi"), + system({ content: "The learner switched topics.", sections: { goals: "Teach TLS.", style: null } }), + user("next"), + ]; + assert.equal(toClaudeInput(messages).systemPrompt, "You are a tutor.\n\nThe learner switched topics.\n\nTeach TLS."); +}); + +test("tools reflect toolsAdded minus toolsRemoved", () => { + const messages: Message[] = [ + system({ content: "p", toolsAdded: [READ_TOOL, WRITE_TOOL] }), + user("hi"), + system({ toolsRemoved: [{ name: "read" }] }), + user("next"), + ]; + assert.deepEqual( + toClaudeInput(messages).tools.map((t) => t.name), + ["write"], + ); +}); + +test("single user message passes through as text", () => { + const input = toClaudeInput([system({ content: "p" }), user("what is a socket?")]); + assert.deepEqual(input.userContent, [{ type: "text", text: "what is a socket?" }]); +}); + +test("history renders user, assistant text, tool call name+JSON args and tool result text in order before the latest user message", () => { + const messages: Message[] = [ + user([{ type: "text", text: "open notes.md" }]), + assistant([ + { type: "text", text: "Reading it." }, + { type: "toolCall", id: "read-1", name: "read", arguments: { path: "notes.md" } }, + ]), + toolResult("read", [{ type: "text", text: "# Notes" }]), + assistant([{ type: "text", text: "It has a heading." }]), + user("summarise it"), + ]; + const expected = [ + "[user]\nopen notes.md", + "[assistant]\nReading it.", + '[tool call read] {"path":"notes.md"}', + "[tool result read]\n# Notes", + "[assistant]\nIt has a heading.", + "[user]\nsummarise it", + ].join("\n\n"); + assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]); +}); + +test("error tool results are marked as errors in the replay", () => { + const messages: Message[] = [ + user("open missing.md"), + assistant([{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "missing.md" } }]), + toolResult("read", [{ type: "text", text: "ENOENT" }], true), + user("why?"), + ]; + const expected = ["[user]\nopen missing.md", '[tool call read] {"path":"missing.md"}', "[tool result read error]\nENOENT", "[user]\nwhy?"].join("\n\n"); + assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]); +}); + +test("images in user and tool-result content become base64 image blocks", () => { + const messages: Message[] = [ + user([ + { type: "text", text: "look" }, + { type: "image", data: "AAAA", mimeType: "image/png" }, + ]), + assistant([{ type: "toolCall", id: "shot-1", name: "screenshot", arguments: {} }]), + toolResult("screenshot", [{ type: "image", data: "BBBB", mimeType: "image/jpeg" }]), + user("compare"), + ]; + assert.deepEqual(toClaudeInput(messages).userContent, [ + { type: "text", text: "[user]\nlook" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "AAAA" } }, + { type: "text", text: "[tool call screenshot] {}\n\n[tool result screenshot]" }, + { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "BBBB" } }, + { type: "text", text: "[user]\ncompare" }, + ]); +}); + +test("transcript ending on a tool result renders as history with no trailing user turn", () => { + const messages: Message[] = [ + user("open notes.md"), + assistant([{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "notes.md" } }]), + toolResult("read", [{ type: "text", text: "# Notes" }]), + ]; + const expected = ["[user]\nopen notes.md", '[tool call read] {"path":"notes.md"}', "[tool result read]\n# Notes"].join("\n\n"); + assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]); +}); + +test("thinking blocks are omitted from replay", () => { + const messages: Message[] = [ + user("hi"), + assistant([ + { type: "thinking", thinking: "secret plan" }, + { type: "text", text: "Hello." }, + ]), + user("bye"), + ]; + assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: "[user]\nhi\n\n[assistant]\nHello.\n\n[user]\nbye" }]); +}); + +test('toStreamJsonLine emits {"type":"user","message":{"role":"user","content":[...]}}', () => { + assert.deepEqual(JSON.parse(toStreamJsonLine([{ type: "text", text: "hi" }])), { type: "user", message: { role: "user", content: [{ type: "text", text: "hi" }] } }); +}); From 76b91dc1e5cff6ba8de9e480b84d8c83c298a2a9 Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:06:50 +0400 Subject: [PATCH 05/21] fix: prefer claude.exe over claude.cmd on Windows Node cannot spawn a .cmd file without a shell, so a claude.cmd found earlier on PATH would hide a working claude.exe. --- src/agent/claude-code/binary.ts | 6 +++--- tests/claude-code-binary.test.ts | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/agent/claude-code/binary.ts b/src/agent/claude-code/binary.ts index 44becc3..5e57cda 100644 --- a/src/agent/claude-code/binary.ts +++ b/src/agent/claude-code/binary.ts @@ -20,7 +20,7 @@ function getWindowsSearchSpec(env: BinaryLookup["env"], homeDir: string): Search const appData = env.APPDATA ?? paths.join(homeDir, "AppData", "Roaming"); return { paths, - names: ["claude.cmd", "claude.exe"], + names: ["claude.exe", "claude.cmd"], knownDirs: [paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), env.npm_config_prefix ?? paths.join(appData, "npm")], }; } @@ -38,8 +38,8 @@ function getPosixSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSp export function resolveClaudeBinary({ env, platform, homeDir, isExecutable }: BinaryLookup): string | null { const { paths, names, knownDirs } = platform === "win32" ? getWindowsSearchSpec(env, homeDir) : getPosixSearchSpec(env, homeDir); const pathDirs = (env.PATH ?? "").split(paths.delimiter).filter(Boolean); - for (const dir of [...pathDirs, ...knownDirs]) { - for (const name of names) { + for (const name of names) { + for (const dir of [...pathDirs, ...knownDirs]) { const candidate = paths.join(dir, name); if (isExecutable(candidate)) return candidate; } diff --git a/tests/claude-code-binary.test.ts b/tests/claude-code-binary.test.ts index 05adf28..00c7d34 100644 --- a/tests/claude-code-binary.test.ts +++ b/tests/claude-code-binary.test.ts @@ -49,6 +49,13 @@ test("windows looks for claude.cmd and claude.exe", () => { assert.equal(resolveWith(["C:\\bin\\claude"], env, "win32", winHome), null); }); +test("windows prefers claude.exe anywhere over claude.cmd, which Node cannot spawn without a shell", () => { + const winHome = "C:\\Users\\u"; + const env = { PATH: "C:\\bin", APPDATA: `${winHome}\\AppData\\Roaming` }; + const executables = ["C:\\bin\\claude.cmd", `${winHome}\\.local\\bin\\claude.exe`]; + assert.equal(resolveWith(executables, env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`); +}); + test("returns null when every location misses", () => { assert.equal(resolveWith([], { PATH: "/usr/bin:/bin" }), null); }); From 6650f91eca53dc1b31a48009b77345d3adcde0db Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:04:22 +0400 Subject: [PATCH 06/21] feat: add MCP bridge exposing pi tools to the CLI A localhost-only MCP HTTP server, guarded by a per-turn bearer token, lists pi's tools and holds each call until pi has run it and returned the result. --- src/agent/claude-code/tool-bridge.ts | 193 ++++++++++++++++++++++ tests/claude-code-tool-bridge.test.ts | 225 ++++++++++++++++++++++++++ 2 files changed, 418 insertions(+) create mode 100644 src/agent/claude-code/tool-bridge.ts create mode 100644 tests/claude-code-tool-bridge.test.ts diff --git a/src/agent/claude-code/tool-bridge.ts b/src/agent/claude-code/tool-bridge.ts new file mode 100644 index 0000000..92e2aad --- /dev/null +++ b/src/agent/claude-code/tool-bridge.ts @@ -0,0 +1,193 @@ +import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import * as http from "node:http"; +import type { AddressInfo } from "node:net"; + +import type { ImageContent, JsonObject, TextContent, Tool, ToolResultMessage } from "@earendil-works/pi-ai"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + type CallToolResult, + type Tool as McpTool, +} from "@modelcontextprotocol/sdk/types.js"; + +export const BRIDGE_SERVER_NAME = "learnharness"; + +const MCP_TOOL_PREFIX = `mcp__${BRIDGE_SERVER_NAME}__`; +const LOOPBACK_HOST = "127.0.0.1"; +const TOKEN_BYTES = 32; + +export type PendingToolCall = { name: string; arguments: JsonObject }; + +export type ToolBridge = { + mcpConfig: string; + allowedTools: string[]; + waitForToolCalls(count: number): Promise; + resolveToolCall(call: PendingToolCall, result: ToolResultMessage): void; + getPendingToolCalls(): PendingToolCall[]; + close(): Promise; +}; + +type ToolCallWaiter = { + count: number; + resolve: (calls: PendingToolCall[]) => void; + reject: (error: Error) => void; +}; + +export function toolNameFromMcp(name: string): string { + return name.startsWith(MCP_TOOL_PREFIX) ? name.slice(MCP_TOOL_PREFIX.length) : name; +} + +function toMcpContent(block: TextContent | ImageContent): CallToolResult["content"][number] { + switch (block.type) { + case "text": + return { type: "text", text: block.text }; + + case "image": + return { type: "image", data: block.data, mimeType: block.mimeType }; + } +} + +function toCallToolResult(result: ToolResultMessage): CallToolResult { + return { content: result.content.map(toMcpContent), isError: result.isError }; +} + +function toMcpTool(tool: Tool): McpTool { + return { name: tool.name, description: tool.description, inputSchema: tool.parameters as McpTool["inputSchema"] }; +} + +class ToolCallQueue { + private readonly results = new Map void>(); + private waiters: ToolCallWaiter[] = []; + + add(call: PendingToolCall): Promise { + const result = new Promise((resolve) => this.results.set(call, resolve)); + + this.wakeWaiters(); + + return result; + } + + waitFor(count: number): Promise { + return new Promise((resolve, reject) => { + this.waiters.push({ count, resolve, reject }); + this.wakeWaiters(); + }); + } + + resolve(call: PendingToolCall, result: ToolResultMessage): void { + this.results.get(call)?.(toCallToolResult(result)); + this.results.delete(call); + } + + list(): PendingToolCall[] { + return [...this.results.keys()]; + } + + clear(): void { + this.results.clear(); + + for (const waiter of this.waiters) waiter.reject(new Error("claude-code tool bridge closed")); + this.waiters = []; + } + + private wakeWaiters(): void { + const calls = this.list(); + + const ready = this.waiters.filter((waiter) => calls.length >= waiter.count); + this.waiters = this.waiters.filter((waiter) => calls.length < waiter.count); + + for (const waiter of ready) waiter.resolve(calls); + } +} + +// Low-level Server on purpose: McpServer.registerTool only takes zod schemas, and pi tools carry +// JSON Schema that must reach the CLI unchanged. +function createMcpServer(tools: Tool[], queue: ToolCallQueue): Server { + const server = new Server({ name: BRIDGE_SERVER_NAME, version: "1.0.0" }, { capabilities: { tools: {} } }); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: tools.map(toMcpTool) })); + + server.setRequestHandler(CallToolRequestSchema, (request) => { + const call = { name: request.params.name, arguments: (request.params.arguments ?? {}) as JsonObject }; + return queue.add(call); + }); + + return server; +} + +function isAuthorized(header: string | undefined, expected: Buffer): boolean { + if (header === undefined) return false; + + const actual = Buffer.from(header); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +function createHttpServer(transport: StreamableHTTPServerTransport, authorization: string): http.Server { + const expected = Buffer.from(authorization); + + return http.createServer((req, res) => { + if (!isAuthorized(req.headers.authorization, expected)) { + res.writeHead(401).end(); + return; + } + + transport.handleRequest(req, res).catch((error: unknown) => { + console.error("claude-code tool bridge request failed", error); + + if (!res.headersSent) res.writeHead(500); + res.end(); + }); + }); +} + +function listen(server: http.Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + + server.listen(0, LOOPBACK_HOST, () => resolve(server.address() as AddressInfo)); + }); +} + +function closeHttpServer(server: http.Server): Promise { + server.closeAllConnections(); + + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +function toMcpConfig(port: number, authorization: string): string { + const server = { type: "http", url: `http://${LOOPBACK_HOST}:${port}/mcp`, headers: { Authorization: authorization } }; + + return JSON.stringify({ mcpServers: { [BRIDGE_SERVER_NAME]: server } }); +} + +export async function createToolBridge(tools: Tool[]): Promise { + const authorization = `Bearer ${randomBytes(TOKEN_BYTES).toString("hex")}`; + + const queue = new ToolCallQueue(); + const mcpServer = createMcpServer(tools, queue); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); + await mcpServer.connect(transport); + + const httpServer = createHttpServer(transport, authorization); + const { port } = await listen(httpServer); + + return { + mcpConfig: toMcpConfig(port, authorization), + allowedTools: tools.map((tool) => `${MCP_TOOL_PREFIX}${tool.name}`), + waitForToolCalls: (count) => queue.waitFor(count), + resolveToolCall: (call, result) => queue.resolve(call, result), + getPendingToolCalls: () => queue.list(), + close: async () => { + queue.clear(); + await mcpServer.close(); + await closeHttpServer(httpServer); + }, + }; +} diff --git a/tests/claude-code-tool-bridge.test.ts b/tests/claude-code-tool-bridge.test.ts new file mode 100644 index 0000000..3d18881 --- /dev/null +++ b/tests/claude-code-tool-bridge.test.ts @@ -0,0 +1,225 @@ +/** + * Claude Code MCP tool bridge (src/agent/claude-code/tool-bridge.ts). + * + * A real MCP SDK client talks to the bridge over loopback HTTP, the same way + * the claude CLI does, so listing, auth, pending calls and their results + * are all exercised end to end. + */ +import assert from "node:assert/strict"; +import * as net from "node:net"; +import * as os from "node:os"; +import test from "node:test"; + +import type { Tool, ToolResultMessage } from "@earendil-works/pi-ai"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { Type } from "typebox"; + +import { BRIDGE_SERVER_NAME, createToolBridge, toolNameFromMcp, type ToolBridge } from "../src/agent/claude-code/tool-bridge.ts"; + +const READ_TOOL: Tool = { name: "read", description: "Read a file", parameters: Type.Object({ path: Type.String() }) }; +const SCREENSHOT_TOOL: Tool = { name: "screenshot", description: "Capture the screen", parameters: Type.Object({}) }; + +type BridgeServerConfig = { type: string; url: string; headers: { Authorization: string } }; + +function getServerConfig(bridge: ToolBridge): BridgeServerConfig { + const config = JSON.parse(bridge.mcpConfig) as { mcpServers: Record }; + const server = config.mcpServers[BRIDGE_SERVER_NAME]; + assert.ok(server); + return server; +} + +async function connectClient(bridge: ToolBridge, authorization = getServerConfig(bridge).headers.Authorization): Promise { + const transport = new StreamableHTTPClientTransport(new URL(getServerConfig(bridge).url), { + requestInit: { headers: { Authorization: authorization } }, + }); + const client = new Client({ name: "test", version: "0" }); + await client.connect(transport); + + return client; +} + +function toolResult(content: ToolResultMessage["content"], isError = false): ToolResultMessage { + return { role: "toolResult", toolCallId: "call-1", toolName: "read", content, isError, timestamp: 0 }; +} + +test("lists exactly the given tools with their JSON schemas", async (t) => { + const bridge = await createToolBridge([READ_TOOL, SCREENSHOT_TOOL]); + t.after(() => bridge.close()); + const client = await connectClient(bridge); + t.after(() => client.close()); + + const { tools } = await client.listTools(); + + assert.deepEqual( + tools.map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })), + [ + { name: "read", description: "Read a file", inputSchema: JSON.parse(JSON.stringify(READ_TOOL.parameters)) }, + { name: "screenshot", description: "Capture the screen", inputSchema: JSON.parse(JSON.stringify(SCREENSHOT_TOOL.parameters)) }, + ], + ); + + assert.deepEqual(bridge.allowedTools, ["mcp__learnharness__read", "mcp__learnharness__screenshot"]); +}); + +test("rejects requests without the bearer token", async (t) => { + const bridge = await createToolBridge([READ_TOOL]); + t.after(() => bridge.close()); + + await assert.rejects(connectClient(bridge, "Bearer wrong"), (error: { code?: number }) => error.code === 401); + const response = await fetch(getServerConfig(bridge).url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + assert.equal(response.status, 401); +}); + +test("binds 127.0.0.1", async (t) => { + const bridge = await createToolBridge([READ_TOOL]); + t.after(() => bridge.close()); + + const url = new URL(getServerConfig(bridge).url); + assert.equal(url.hostname, "127.0.0.1"); + + const externalAddress = Object.values(os.networkInterfaces()) + .flat() + .find((address) => address && !address.internal && address.family === "IPv4"); + + if (!externalAddress) { + t.diagnostic("no non-loopback IPv4 interface; skipped external connect check"); + return; + } + + await assert.rejects( + new Promise((resolve, reject) => { + const socket = net.connect(Number(url.port), externalAddress.address, () => resolve(socket.end())); + socket.once("error", reject); + }), + { code: "ECONNREFUSED" }, + ); +}); + +test("tools/call stays pending until resolved and returns its text and image content", async (t) => { + const bridge = await createToolBridge([READ_TOOL]); + t.after(() => bridge.close()); + const client = await connectClient(bridge); + t.after(() => client.close()); + + let settled = false; + const pending = client.callTool({ name: "read", arguments: { path: "notes.md" } }).finally(() => { + settled = true; + }); + + const [call] = await bridge.waitForToolCalls(1); + assert.ok(call); + assert.deepEqual(call, { name: "read", arguments: { path: "notes.md" } }); + assert.deepEqual(bridge.getPendingToolCalls(), [call]); + + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(settled, false); + + bridge.resolveToolCall( + call, + toolResult([ + { type: "text", text: "# Notes" }, + { type: "image", data: "AAAA", mimeType: "image/png" }, + ]), + ); + + const result = await pending; + assert.deepEqual(result.content, [ + { type: "text", text: "# Notes" }, + { type: "image", data: "AAAA", mimeType: "image/png" }, + ]); + assert.notEqual(result.isError, true); + assert.deepEqual(bridge.getPendingToolCalls(), []); +}); + +test("waitForToolCalls resolves once that many calls are pending, in arrival order", async (t) => { + const bridge = await createToolBridge([READ_TOOL, SCREENSHOT_TOOL]); + t.after(() => bridge.close()); + const client = await connectClient(bridge); + t.after(() => client.close()); + + let waited = false; + const waiting = bridge.waitForToolCalls(2).finally(() => { + waited = true; + }); + + void client.callTool({ name: "read", arguments: { path: "a.md" } }).catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(waited, false); + + void client.callTool({ name: "screenshot", arguments: {} }).catch(() => {}); + + assert.deepEqual(await waiting, [ + { name: "read", arguments: { path: "a.md" } }, + { name: "screenshot", arguments: {} }, + ]); +}); + +test("isError result becomes MCP isError true", async (t) => { + const bridge = await createToolBridge([READ_TOOL]); + t.after(() => bridge.close()); + const client = await connectClient(bridge); + t.after(() => client.close()); + + const pending = client.callTool({ name: "read", arguments: { path: "missing.md" } }); + const [call] = await bridge.waitForToolCalls(1); + assert.ok(call); + + bridge.resolveToolCall(call, toolResult([{ type: "text", text: "ENOENT" }], true)); + + const result = await pending; + assert.equal(result.isError, true); + assert.deepEqual(result.content, [{ type: "text", text: "ENOENT" }]); +}); + +test("close fails pending calls and frees the port", async () => { + const bridge = await createToolBridge([READ_TOOL]); + const port = Number(new URL(getServerConfig(bridge).url).port); + const client = await connectClient(bridge); + const pending = client.callTool({ name: "read", arguments: { path: "notes.md" } }); + await bridge.waitForToolCalls(1); + + await bridge.close(); + + await assert.rejects(pending); + assert.deepEqual(bridge.getPendingToolCalls(), []); + await client.close(); + + const probe = net.createServer(); + await new Promise((resolve, reject) => { + probe.once("error", reject); + probe.listen(port, "127.0.0.1", resolve); + }); + await new Promise((resolve) => probe.close(resolve)); +}); + +test("close rejects consumers still waiting for tool calls", { timeout: 1000 }, async () => { + const bridge = await createToolBridge([READ_TOOL]); + const waiting = bridge.waitForToolCalls(1); + + await bridge.close(); + await assert.rejects(waiting, /tool bridge closed/); +}); + +test('mcpConfig is {"mcpServers":{"learnharness":{"type":"http","url":...,"headers":{"Authorization":"Bearer ..."}}}}', async (t) => { + const bridge = await createToolBridge([READ_TOOL]); + t.after(() => bridge.close()); + + const config = JSON.parse(bridge.mcpConfig) as { mcpServers: Record }; + assert.deepEqual(Object.keys(config.mcpServers), ["learnharness"]); + + const server = getServerConfig(bridge); + assert.deepEqual(Object.keys(server).sort(), ["headers", "type", "url"]); + assert.equal(server.type, "http"); + assert.match(server.url, /^http:\/\/127\.0\.0\.1:\d+\/mcp$/); + assert.match(server.headers.Authorization, /^Bearer [0-9a-f]{64}$/); +}); + +test("toolNameFromMcp strips the bridge prefix", () => { + assert.equal(toolNameFromMcp("mcp__learnharness__read"), "read"); + assert.equal(toolNameFromMcp("read"), "read"); +}); From c41c5f3132284b7e1fee889245aa239449206d9a Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:04:22 +0400 Subject: [PATCH 07/21] feat: stream turns through the claude CLI Each turn runs claude -p with stream-json in and out and maps its events onto pi's assistant message stream. A process that stops on tool calls is kept alive and continued when pi sends the results back, instead of replaying the whole transcript. API credentials are removed from the CLI environment so turns always bill to the signed-in subscription. --- src/agent/claude-code/cli-events.ts | 181 +++++ src/agent/claude-code/live-processes.ts | 41 ++ src/agent/claude-code/stream.ts | 602 ++++++++++++++++ tests/claude-code-stream.test.ts | 781 +++++++++++++++++++++ tests/fixtures/claude-code/fake-claude.mjs | 79 +++ 5 files changed, 1684 insertions(+) create mode 100644 src/agent/claude-code/cli-events.ts create mode 100644 src/agent/claude-code/live-processes.ts create mode 100644 src/agent/claude-code/stream.ts create mode 100644 tests/claude-code-stream.test.ts create mode 100644 tests/fixtures/claude-code/fake-claude.mjs diff --git a/src/agent/claude-code/cli-events.ts b/src/agent/claude-code/cli-events.ts new file mode 100644 index 0000000..f532946 --- /dev/null +++ b/src/agent/claude-code/cli-events.ts @@ -0,0 +1,181 @@ +import type { Usage } from "@earendil-works/pi-ai"; + +export type CliUsage = Partial>; + +export type CliBlock = { type: "text" } | { type: "thinking" } | { type: "toolUse"; id: string; name: string }; + +export type CliEvent = + | { type: "messageStart"; usage: CliUsage } + | { type: "blockStart"; index: number; block: CliBlock } + | { type: "textDelta"; index: number; text: string } + | { type: "thinkingDelta"; index: number; thinking: string } + | { type: "toolInputDelta"; index: number; partialJson: string } + | { type: "blockStop"; index: number } + | { type: "messageDelta"; stopReason: string | null; usage: CliUsage } + | { type: "messageStop" } + | { type: "assistantError"; error: string; text: string } + | { type: "resultError"; text: string }; + +type RawUsage = { + input_tokens?: number | null; + output_tokens?: number | null; + cache_read_input_tokens?: number | null; + cache_creation_input_tokens?: number | null; +}; + +type RawContentBlock = { type: string; id?: string; name?: string }; + +type RawDelta = { + type?: string; + text?: string; + thinking?: string; + partial_json?: string; + stop_reason?: string | null; +}; + +type RawStreamEvent = { + type: string; + index?: number; + message?: { usage?: RawUsage }; + content_block?: RawContentBlock; + delta?: RawDelta; + usage?: RawUsage; +}; + +type RawCliLine = { + type?: string; + event?: RawStreamEvent; + error?: string | null; + message?: { content?: { type: string; text?: string }[] }; + is_error?: boolean; + result?: string; + subtype?: string; + errors?: string[]; +}; + +function toUsage(raw: RawUsage): CliUsage { + const usage: CliUsage = {}; + + if (typeof raw.input_tokens === "number") usage.input = raw.input_tokens; + if (typeof raw.output_tokens === "number") usage.output = raw.output_tokens; + if (typeof raw.cache_read_input_tokens === "number") usage.cacheRead = raw.cache_read_input_tokens; + if (typeof raw.cache_creation_input_tokens === "number") usage.cacheWrite = raw.cache_creation_input_tokens; + + return usage; +} + +function toBlock(raw: RawContentBlock): CliBlock | null { + switch (raw.type) { + case "text": + return { type: "text" }; + + case "thinking": + return { type: "thinking" }; + + case "tool_use": + if (typeof raw.id !== "string" || typeof raw.name !== "string") return null; + return { type: "toolUse", id: raw.id, name: raw.name }; + + default: + return null; + } +} + +function parseDelta(index: number, delta: RawDelta): CliEvent | null { + switch (delta.type) { + case "text_delta": + if (typeof delta.text !== "string") return null; + return { type: "textDelta", index, text: delta.text }; + + case "thinking_delta": + if (typeof delta.thinking !== "string") return null; + return { type: "thinkingDelta", index, thinking: delta.thinking }; + + case "input_json_delta": + if (typeof delta.partial_json !== "string") return null; + return { type: "toolInputDelta", index, partialJson: delta.partial_json }; + + default: + return null; + } +} + +function parseBlockEvent(index: number, event: RawStreamEvent): CliEvent | null { + switch (event.type) { + case "content_block_start": { + if (!event.content_block) return null; + + const block = toBlock(event.content_block); + return block ? { type: "blockStart", index, block } : null; + } + + case "content_block_delta": + return event.delta ? parseDelta(index, event.delta) : null; + + case "content_block_stop": + return { type: "blockStop", index }; + + default: + return null; + } +} + +function parseStreamEvent(event: RawStreamEvent): CliEvent | null { + switch (event.type) { + case "message_start": + return { type: "messageStart", usage: toUsage(event.message?.usage ?? {}) }; + + case "message_delta": { + const stopReason = event.delta?.stop_reason ?? null; + return { type: "messageDelta", stopReason, usage: toUsage(event.usage ?? {}) }; + } + + case "message_stop": + return { type: "messageStop" }; + + default: + return event.index === undefined ? null : parseBlockEvent(event.index, event); + } +} + +function getText(line: RawCliLine): string { + const blocks = line.message?.content ?? []; + const texts = blocks.flatMap((block) => (block.type === "text" && block.text ? [block.text] : [])); + + return texts.join("\n"); +} + +function getResultErrorText(line: RawCliLine): string { + if (line.result) return line.result; + if (line.errors?.length) return line.errors.join("\n"); + return `claude CLI failed: ${line.subtype ?? "unknown error"}`; +} + +export function parseCliLine(text: string): CliEvent | null { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + + if (typeof parsed !== "object" || parsed === null) return null; + + const line = parsed as RawCliLine; + + switch (line.type) { + case "stream_event": + return line.event ? parseStreamEvent(line.event) : null; + + case "assistant": + if (typeof line.error !== "string") return null; + return { type: "assistantError", error: line.error, text: getText(line) }; + + case "result": + if (line.is_error !== true) return null; + return { type: "resultError", text: getResultErrorText(line) }; + + default: + return null; + } +} diff --git a/src/agent/claude-code/live-processes.ts b/src/agent/claude-code/live-processes.ts new file mode 100644 index 0000000..f5e24b3 --- /dev/null +++ b/src/agent/claude-code/live-processes.ts @@ -0,0 +1,41 @@ +export type LiveProcess = { toolCallIds: string[]; kill(): void }; + +// pi runs a message's tools (each capped at 30s by tool-execute) and calls back well within this; +// it stays under MCP_TOOL_TIMEOUT so an orphaned process is killed before the CLI gives up on its own. +export const LIVE_PROCESS_IDLE_TIMEOUT_MS = 10 * 60_000; + +/** Test seam (runner.ts's pdfModelOverride idiom). */ +export const liveProcessIdleTimeoutMs = { current: LIVE_PROCESS_IDLE_TIMEOUT_MS }; + +// Each live process mapped to the timer that kills it if pi never calls back. +const liveProcesses = new Map(); + +export function addLiveProcess(process: LiveProcess): void { + const timer = setTimeout(() => { + removeLiveProcess(process); + process.kill(); + }, liveProcessIdleTimeoutMs.current); + + timer.unref(); + + liveProcesses.set(process, timer); +} + +function hasSameIds(ids: string[], otherIds: string[]): boolean { + return ids.length === otherIds.length && ids.every((id) => otherIds.includes(id)); +} + +export function getLiveProcess(toolCallIds: string[]): LiveProcess | null { + if (toolCallIds.length === 0) return null; + + for (const process of liveProcesses.keys()) { + if (hasSameIds(process.toolCallIds, toolCallIds)) return process; + } + + return null; +} + +export function removeLiveProcess(process: LiveProcess): void { + clearTimeout(liveProcesses.get(process)); + liveProcesses.delete(process); +} diff --git a/src/agent/claude-code/stream.ts b/src/agent/claude-code/stream.ts new file mode 100644 index 0000000..919561a --- /dev/null +++ b/src/agent/claude-code/stream.ts @@ -0,0 +1,602 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { isDeepStrictEqual } from "node:util"; + +import { + createAssistantMessageEventStream, + type Api, + type AssistantMessage, + type AssistantMessageEventStream, + type Message, + type Model, + type SimpleStreamOptions, + type StopReason, + type StreamFunction, + type ThinkingLevel, + type Tool, + type ToolCall, + type ToolResultMessage, + type TranscriptContext, + validateToolCall, +} from "@earendil-works/pi-ai"; +import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messages"; +import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse"; + +import { getClaudeBinary } from "./binary.ts"; +import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts"; +import { ClaudeCodeError } from "./constants.ts"; +import { addLiveProcess, getLiveProcess, removeLiveProcess, type LiveProcess } from "./live-processes.ts"; +import { createToolBridge, toolNameFromMcp, type PendingToolCall, type ToolBridge } from "./tool-bridge.ts"; +import { toClaudeInput, toStreamJsonLine } from "./transcript.ts"; + +type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv }; + +export type RunClaude = (args: string[], options: RunClaudeOptions) => ChildProcessWithoutNullStreams | null; + +// A held MCP call lasts until pi has run every tool of the message (each capped at 30s by +// tool-execute) and called back; this must outlast that so the CLI never times out first. +const MCP_TOOL_TIMEOUT_MS = 30 * 60_000; + +const PRIVATE_FILE_MODE = 0o600; + +const EFFORTS: Record = { + minimal: "low", + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + max: "max", +}; + +const STOP_REASONS: Record = { + end_turn: "stop", + pause_turn: "stop", + stop_sequence: "stop", + max_tokens: "length", + tool_use: "toolUse", + refusal: "error", +}; + +const REFUSAL_MESSAGE = "The model refused to complete the request"; +const ABORT_MESSAGE = "Request was aborted"; +const START_FAILED_MESSAGE = "claude CLI failed to start"; +const NO_STOP_REASON_MESSAGE = "claude CLI ended the message without a stop reason"; +const NOT_SIGNED_IN_ERROR = "authentication_failed"; + +export function toEffort(level: ThinkingLevel | undefined): AnthropicEffort { + return level ? EFFORTS[level] : "medium"; +} + +type ClaudeArgsInput = { + model: string; + effort: AnthropicEffort; + systemPromptFile: string; + mcpConfigFile: string; + allowedTools: string[]; +}; + +export function toClaudeArgs(input: ClaudeArgsInput): string[] { + return [ + "-p", + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--verbose", + "--include-partial-messages", + "--model", + input.model, + "--effort", + input.effort, + "--system-prompt-file", + input.systemPromptFile, + "--tools", + "", + "--strict-mcp-config", + "--mcp-config", + input.mcpConfigFile, + "--setting-sources", + "", + "--disable-slash-commands", + "--no-session-persistence", + "--permission-prompts", + "none", + ...(input.allowedTools.length > 0 ? ["--allowedTools", input.allowedTools.join(",")] : []), + ]; +} + +function createOutput(model: Model): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "pending", + timestamp: Date.now(), + }; +} + +function getAssistantErrorMessage(error: string, text: string): string { + return error === NOT_SIGNED_IN_ERROR ? ClaudeCodeError.NotSignedIn : text || error; +} + +class ClaudeTurn { + private readonly output: AssistantMessage; + private readonly contentIndexes = new Map(); + private readonly partialJson = new Map(); + started = false; + finished = false; + + constructor( + private readonly stream: AssistantMessageEventStream, + model: Model, + ) { + this.output = createOutput(model); + } + + handle(event: CliEvent): void { + if (this.finished) return; + + switch (event.type) { + case "messageStart": + this.updateUsage(event.usage); + this.start(); + return; + + case "blockStart": + this.startBlock(event.index, event.block); + return; + + case "textDelta": { + const found = this.getBlock(event.index); + if (!found) return; + + const { contentIndex, block } = found; + if (block.type !== "text") return; + + block.text += event.text; + this.stream.push({ type: "text_delta", contentIndex, delta: event.text, partial: this.output }); + return; + } + + case "thinkingDelta": { + const found = this.getBlock(event.index); + if (!found) return; + + const { contentIndex, block } = found; + if (block.type !== "thinking") return; + + block.thinking += event.thinking; + this.stream.push({ type: "thinking_delta", contentIndex, delta: event.thinking, partial: this.output }); + return; + } + + case "toolInputDelta": { + const found = this.getBlock(event.index); + if (!found) return; + + const { contentIndex, block } = found; + if (block.type !== "toolCall") return; + + const partialJson = (this.partialJson.get(event.index) ?? "") + event.partialJson; + this.partialJson.set(event.index, partialJson); + block.arguments = parseStreamingJson(partialJson); + this.stream.push({ type: "toolcall_delta", contentIndex, delta: event.partialJson, partial: this.output }); + return; + } + + case "blockStop": + this.stopBlock(event.index); + return; + + case "messageDelta": + this.updateUsage(event.usage); + if (event.stopReason) this.setStopReason(event.stopReason); + return; + + case "messageStop": + this.finish(); + return; + + case "assistantError": + this.fail(getAssistantErrorMessage(event.error, event.text), "error"); + return; + + case "resultError": + this.fail(event.text, "error"); + return; + } + } + + getToolCalls(): ToolCall[] { + if (!this.finished || this.output.stopReason !== "toolUse") return []; + + return this.output.content.filter((block) => block.type === "toolCall"); + } + + fail(message: string, reason: "aborted" | "error"): void { + if (this.finished) return; + + this.finished = true; + this.output.stopReason = reason; + this.output.errorMessage = message; + this.stream.push({ type: "error", reason, error: this.output }); + this.stream.end(); + } + + private start(): void { + if (this.started) return; + + this.started = true; + this.stream.push({ type: "start", partial: this.output }); + } + + private startBlock(index: number, block: CliBlock): void { + const contentIndex = this.output.content.length; + this.contentIndexes.set(index, contentIndex); + + switch (block.type) { + case "text": + this.output.content.push({ type: "text", text: "" }); + this.stream.push({ type: "text_start", contentIndex, partial: this.output }); + return; + + case "thinking": + this.output.content.push({ type: "thinking", thinking: "" }); + this.stream.push({ type: "thinking_start", contentIndex, partial: this.output }); + return; + + case "toolUse": + this.output.content.push({ type: "toolCall", id: block.id, name: toolNameFromMcp(block.name), arguments: {} }); + this.stream.push({ type: "toolcall_start", contentIndex, partial: this.output }); + return; + } + } + + private getBlock(index: number): { contentIndex: number; block: AssistantMessage["content"][number] } | null { + const contentIndex = this.contentIndexes.get(index); + if (contentIndex === undefined) return null; + + const block = this.output.content[contentIndex]; + return block ? { contentIndex, block } : null; + } + + private stopBlock(index: number): void { + const found = this.getBlock(index); + if (!found) return; + + const { contentIndex, block } = found; + this.contentIndexes.delete(index); + + switch (block.type) { + case "text": + this.stream.push({ type: "text_end", contentIndex, content: block.text, partial: this.output }); + return; + + case "thinking": + this.stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: this.output }); + return; + + case "toolCall": + Object.assign(block, parseToolCallArguments(this.partialJson.get(index) ?? "")); + this.partialJson.delete(index); + this.stream.push({ type: "toolcall_end", contentIndex, toolCall: block satisfies ToolCall, partial: this.output }); + return; + } + } + + private updateUsage(usage: CliUsage): void { + Object.assign(this.output.usage, usage); + + const { input, output, cacheRead, cacheWrite } = this.output.usage; + this.output.usage.totalTokens = input + output + cacheRead + cacheWrite; + } + + private setStopReason(stopReason: string): void { + const mapped = STOP_REASONS[stopReason]; + + if (!mapped) { + this.output.stopReason = "error"; + this.output.errorMessage = `Unhandled stop reason: ${stopReason}`; + return; + } + + this.output.stopReason = mapped; + } + + private finish(): void { + const { stopReason } = this.output; + + if (stopReason === "error") { + this.fail(this.output.errorMessage ?? REFUSAL_MESSAGE, "error"); + return; + } + + if (stopReason === "pending" || stopReason === "aborted") { + this.fail(this.output.errorMessage ?? NO_STOP_REASON_MESSAGE, "error"); + return; + } + + this.finished = true; + this.stream.push({ type: "done", reason: stopReason, message: this.output }); + this.stream.end(); + } +} + +type TurnFiles = { cwd: string; privateDir: string; systemPromptFile: string; mcpConfigFile: string }; + +async function createTurnFiles(systemPrompt: string, mcpConfig: string): Promise { + const cwd = await mkdtemp(join(tmpdir(), "claude-code-cwd-")); + const privateDir = await mkdtemp(join(tmpdir(), "claude-code-private-")); + + const systemPromptFile = join(privateDir, "system-prompt.md"); + const mcpConfigFile = join(privateDir, "mcp.json"); + + await writeFile(systemPromptFile, systemPrompt, { mode: PRIVATE_FILE_MODE }); + await writeFile(mcpConfigFile, mcpConfig, { mode: PRIVATE_FILE_MODE }); + + return { cwd, privateDir, systemPromptFile, mcpConfigFile }; +} + +async function removeTurnResources(bridge: ToolBridge, files: TurnFiles): Promise { + await bridge.close(); + await rm(files.cwd, { recursive: true, force: true }); + await rm(files.privateDir, { recursive: true, force: true }); +} + +type UnansweredToolCall = { toolCall: ToolCall; result: ToolResultMessage }; + +// The CLI answers calls to undeclared tools or with invalid arguments itself, without reaching the bridge. +function isValidToolCall(tools: Tool[], toolCall: ToolCall): boolean { + try { + validateToolCall(tools, toolCall); + return true; + } catch { + return false; + } +} + +function isSameToolCall(toolCall: ToolCall, call: PendingToolCall): boolean { + return toolCall.name === call.name && isDeepStrictEqual(toolCall.arguments, call.arguments); +} + +class ClaudeProcess implements LiveProcess { + toolCallIds: string[] = []; + private stderr = ""; + private closed = false; + private signal: AbortSignal | undefined; + + constructor( + private readonly child: ChildProcessWithoutNullStreams, + private readonly bridge: ToolBridge, + private readonly files: TurnFiles, + private readonly tools: Tool[], + private turn: ClaudeTurn, + ) {} + + start(stdinLine: string, signal: AbortSignal | undefined): void { + this.child.once("close", (code: number | null) => this.handleClose(`claude CLI exited with code ${code}`)); + this.child.once("error", (error) => { + console.error("claude CLI process failed:", error); + this.turn.fail(START_FAILED_MESSAGE, "error"); + this.handleClose(START_FAILED_MESSAGE); + }); + + this.child.stderr.setEncoding("utf8"); + this.child.stderr.on("data", (chunk: string) => (this.stderr += chunk)); + + this.child.stdin.on("error", (error) => console.error("claude CLI stdin closed early:", error)); + this.child.stdin.end(`${stdinLine}\n`); + + createInterface({ input: this.child.stdout }).on("line", (line) => this.handleLine(line)); + + this.setSignal(signal); + } + + continueTurn(turn: ClaudeTurn, toolResults: ToolResultMessage[], signal: AbortSignal | undefined): void { + removeLiveProcess(this); + + const unanswered = this.turn.getToolCalls().flatMap((toolCall) => { + const result = toolResults.find((toolResult) => toolResult.toolCallId === toolCall.id); + return result ? [{ toolCall, result }] : []; + }); + + this.turn = turn; + this.setSignal(signal); + + this.answerToolCalls(turn, unanswered).catch((error: unknown) => { + console.error("claude CLI tool results failed:", error); + turn.fail(START_FAILED_MESSAGE, "error"); + this.kill(); + }); + } + + kill(): void { + removeLiveProcess(this); + this.child.kill(); + } + + private readonly abort = () => { + this.turn.fail(ABORT_MESSAGE, "aborted"); + this.kill(); + }; + + private setSignal(signal: AbortSignal | undefined): void { + this.signal?.removeEventListener("abort", this.abort); + this.signal = signal; + signal?.addEventListener("abort", this.abort, { once: true }); + + if (signal?.aborted) this.abort(); + } + + private handleLine(line: string): void { + const event = parseCliLine(line); + if (!event) return; + + // Output after a finished message means the CLI moved on without waiting for our tool results. + if (this.turn.finished) { + this.kill(); + return; + } + + this.turn.handle(event); + if (!this.turn.finished) return; + + const toolCalls = this.turn.getToolCalls(); + this.toolCallIds = toolCalls.map((toolCall) => toolCall.id); + + const reachesBridge = toolCalls.length > 0 && toolCalls.every((toolCall) => isValidToolCall(this.tools, toolCall)); + if (!reachesBridge) { + this.kill(); + return; + } + + addLiveProcess(this); + } + + private async answerToolCalls(turn: ClaudeTurn, unanswered: UnansweredToolCall[]): Promise { + while (unanswered.length > 0 && !turn.started) { + const pending = this.bridge.getPendingToolCalls(); + let answered = false; + + for (const call of pending) { + const index = unanswered.findIndex(({ toolCall }) => isSameToolCall(toolCall, call)); + const match = unanswered[index]; + if (!match) continue; + + this.bridge.resolveToolCall(call, match.result); + unanswered.splice(index, 1); + answered = true; + } + + if (answered) continue; + + // Rejects only once the bridge has closed, which means the process is already gone. + const next = await this.bridge.waitForToolCalls(pending.length + 1).catch(() => null); + if (!next) return; + } + } + + private handleClose(message: string): void { + if (this.closed) return; + + this.closed = true; + removeLiveProcess(this); + this.signal?.removeEventListener("abort", this.abort); + + if (this.stderr.trim()) console.error("claude CLI stderr:", this.stderr.trim()); + + this.turn.fail(message, "error"); + + removeTurnResources(this.bridge, this.files).catch((error: unknown) => { + console.error("claude CLI cleanup failed:", error); + }); + } +} + +function continueLiveProcess(turn: ClaudeTurn, messages: Message[], signal: AbortSignal | undefined): boolean { + const assistantIndex = messages.findLastIndex((message) => message.role === "assistant"); + const assistant = messages[assistantIndex]; + if (assistant?.role !== "assistant") return false; + + const toolCallIds = assistant.content.flatMap((block) => (block.type === "toolCall" ? [block.id] : [])); + const process = getLiveProcess(toolCallIds); + if (!(process instanceof ClaudeProcess)) return false; + + const trailing = messages.slice(assistantIndex + 1); + const toolResults = trailing.filter((message) => message.role === "toolResult"); + + const onlyToolResults = toolResults.length === trailing.length; + const answersEveryCall = toolCallIds.every((id) => toolResults.some((toolResult) => toolResult.toolCallId === id)); + + if (!onlyToolResults || !answersEveryCall) { + process.kill(); + return false; + } + + process.continueTurn(turn, toolResults, signal); + return true; +} + +// Either one takes precedence over the logged-in subscription, so a key saved on the Keys page +// would bill turns to the API account. +const API_CREDENTIAL_ENV_NAMES = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]; + +function toClaudeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const claudeEnv: NodeJS.ProcessEnv = { ...env, MCP_TOOL_TIMEOUT: String(MCP_TOOL_TIMEOUT_MS) }; + + for (const name of API_CREDENTIAL_ENV_NAMES) delete claudeEnv[name]; + + return claudeEnv; +} + +async function startProcess( + runClaude: RunClaude, + turn: ClaudeTurn, + model: Model, + context: TranscriptContext, + options: SimpleStreamOptions | undefined, +): Promise { + const input = toClaudeInput(context.messages); + const bridge = await createToolBridge(input.tools); + const files = await createTurnFiles(input.systemPrompt, bridge.mcpConfig); + + let child: ChildProcessWithoutNullStreams | null; + try { + const args = toClaudeArgs({ + model: model.id, + effort: toEffort(options?.reasoning), + systemPromptFile: files.systemPromptFile, + mcpConfigFile: files.mcpConfigFile, + allowedTools: bridge.allowedTools, + }); + child = runClaude(args, { cwd: files.cwd, env: toClaudeEnv(process.env) }); + } catch (error) { + await removeTurnResources(bridge, files); + throw error; + } + + if (!child) { + turn.fail(ClaudeCodeError.CliNotFound, "error"); + await removeTurnResources(bridge, files); + return; + } + + new ClaudeProcess(child, bridge, files, input.tools, turn).start(toStreamJsonLine(input.userContent), options?.signal); +} + +export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunction { + return (model, context, options) => { + const stream = createAssistantMessageEventStream(); + const turn = new ClaudeTurn(stream, model); + + if (continueLiveProcess(turn, context.messages, options?.signal)) return stream; + + startProcess(runClaude, turn, model, context, options).catch((error: unknown) => { + console.error("claude CLI turn failed:", error); + turn.fail(START_FAILED_MESSAGE, "error"); + }); + + return stream; + }; +} + +function spawnClaude(args: string[], options: RunClaudeOptions): ChildProcessWithoutNullStreams | null { + const binary = getClaudeBinary(); + + return binary ? spawn(binary, args, { ...options, stdio: "pipe" }) : null; +} + +export const streamClaudeCode = createClaudeCodeStreamFunction(spawnClaude); diff --git a/tests/claude-code-stream.test.ts b/tests/claude-code-stream.test.ts new file mode 100644 index 0000000..96acc52 --- /dev/null +++ b/tests/claude-code-stream.test.ts @@ -0,0 +1,781 @@ +/** + * Claude Code stream function (src/agent/claude-code/stream.ts) and CLI line + * parser (src/agent/claude-code/cli-events.ts). + * + * The runClaude seam spawns tests/fixtures/claude-code/fake-claude.mjs, which + * replays recorded or synthetic stream-json lines, so the real process + * plumbing (argv, stdin, stdout lines, stderr, exit, kill) runs end to end. + */ +import assert from "node:assert/strict"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { once } from "node:events"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, sep } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import test, { type TestContext } from "node:test"; + +import type { + AssistantMessage, + AssistantMessageEvent, + AssistantMessageEventStream, + JsonObject, + Message, + SimpleStreamOptions, + StreamFunction, + Api, + Tool, + ToolResultMessage, +} from "@earendil-works/pi-ai"; +import { normalizeContext } from "@earendil-works/pi-ai/utils/transcript"; + +import { parseCliLine } from "../src/agent/claude-code/cli-events.ts"; +import { ClaudeCodeError } from "../src/agent/claude-code/constants.ts"; +import { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } from "../src/agent/claude-code/live-processes.ts"; +import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; +import { createClaudeCodeStreamFunction, toClaudeArgs, toEffort, type RunClaude } from "../src/agent/claude-code/stream.ts"; +import { toStreamJsonLine } from "../src/agent/claude-code/transcript.ts"; + +const FAKE_CLAUDE = fileURLToPath(new URL("./fixtures/claude-code/fake-claude.mjs", import.meta.url)); +const FIXTURES = fileURLToPath(new URL("./fixtures/claude-code/", import.meta.url)); +const HOLD = JSON.stringify({ fake: "hold" }); +const REPLY = JSON.stringify({ fake: "reply" }); +const CLEANUP_TIMEOUT_MS = 2000; + +const READ_TOOL: Tool = { name: "read", description: "Read a file", parameters: { type: "object", properties: {} } }; +const HI: Message = { role: "user", content: "hi", timestamp: 0 }; + +type ToolUse = { id: string; name: string; arguments: JsonObject }; + +type RunCall = { + args: string[]; + cwd: string; + cwdEntries: string[]; + env: NodeJS.ProcessEnv; + systemPrompt: string; + mcpConfig: string; + mcpConfigMode: number; + child: ChildProcessWithoutNullStreams; +}; + +type ScenarioRun = { events: AssistantMessageEvent[]; calls: RunCall[] }; + +function readFixtureLines(name: string): string[] { + return readFileSync(join(FIXTURES, name), "utf8").split("\n").filter(Boolean); +} + +function getFlagValue(args: string[], flag: string): string { + const value = args[args.indexOf(flag) + 1]; + assert.ok(args.includes(flag) && value !== undefined, `missing ${flag}`); + + return value; +} + +function streamEvent(event: object): string { + return JSON.stringify({ type: "stream_event", event }); +} + +function exitWith(code: number, stderr: string): string { + return JSON.stringify({ fake: "exit", code, stderr }); +} + +function getToolScenario(): string[] { + const lines = readFixtureLines("tool.jsonl"); + const firstMessageStop = lines.findIndex((line) => line.includes('"type":"message_stop"')); + return [...lines.slice(0, firstMessageStop + 1).filter((line) => !line.startsWith('{"type":"user"')), HOLD]; +} + +function createFakeRun( + t: TestContext, + scenario: string[], + extraEnv: Record = {}, +): { runClaude: RunClaude; calls: RunCall[] } { + const dir = mkdtempSync(join(tmpdir(), "claude-code-test-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const scenarioFile = join(dir, "scenario.jsonl"); + writeFileSync(scenarioFile, `${scenario.join("\n")}\n`); + + const calls: RunCall[] = []; + + const runClaude: RunClaude = (args, options) => { + const mcpConfigFile = getFlagValue(args, "--mcp-config"); + const child = spawn(process.execPath, [FAKE_CLAUDE, ...args], { + cwd: options.cwd, + env: { ...options.env, FAKE_CLAUDE_SCENARIO: scenarioFile, ...extraEnv }, + stdio: "pipe", + }); + t.after(() => child.kill()); + calls.push({ + args, + cwd: options.cwd, + cwdEntries: readdirSync(options.cwd), + env: options.env, + systemPrompt: readFileSync(getFlagValue(args, "--system-prompt-file"), "utf8"), + mcpConfig: readFileSync(mcpConfigFile, "utf8"), + mcpConfigMode: statSync(mcpConfigFile).mode & 0o777, + child, + }); + return child; + }; + + return { runClaude, calls }; +} + +async function collectEvents( + stream: AssistantMessageEventStream, + onEvent: (event: AssistantMessageEvent) => void = () => {}, +): Promise { + const events: AssistantMessageEvent[] = []; + + for await (const event of stream) { + events.push(event); + onEvent(event); + } + + return events; +} + +async function runScenario( + t: TestContext, + scenario: string[], + options?: SimpleStreamOptions, + extraEnv?: Record, +): Promise { + const { runClaude, calls } = createFakeRun(t, scenario, extraEnv); + + const model = CLAUDE_CODE_MODELS[0]; + assert.ok(model); + + const context = normalizeContext({ + systemPrompt: "You are a tutor.", + messages: [{ role: "user", content: "hi", timestamp: 0 }], + tools: [READ_TOOL], + }); + const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(model, context, options)); + + return { events, calls }; +} + +function toToolUseMessage(toolUses: ToolUse[]): string[] { + return [ + streamEvent({ type: "message_start", message: { usage: {} } }), + ...toolUses.flatMap((toolUse, index) => [ + streamEvent({ + type: "content_block_start", + index, + content_block: { type: "tool_use", id: toolUse.id, name: `mcp__learnharness__${toolUse.name}`, input: {} }, + }), + streamEvent({ + type: "content_block_delta", + index, + delta: { type: "input_json_delta", partial_json: JSON.stringify(toolUse.arguments) }, + }), + streamEvent({ type: "content_block_stop", index }), + ]), + streamEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }), + streamEvent({ type: "message_stop" }), + ]; +} + +function callTool(toolUse: ToolUse): string { + return JSON.stringify({ fake: "call", name: toolUse.name, arguments: toolUse.arguments }); +} + +function toToolResult(toolUse: ToolUse, text: string): ToolResultMessage { + return { + role: "toolResult", + toolCallId: toolUse.id, + toolName: toolUse.name, + content: [{ type: "text", text }], + isError: false, + timestamp: 0, + }; +} + +function getModel() { + const model = CLAUDE_CODE_MODELS[0]; + assert.ok(model); + return model; +} + +async function runTurn( + streamFunction: StreamFunction, + messages: Message[], + options?: SimpleStreamOptions, +): Promise { + const context = normalizeContext({ systemPrompt: "You are a tutor.", messages, tools: [READ_TOOL] }); + + const done = getLastEvent(await collectEvents(streamFunction(getModel(), context, options))); + + assert.ok(done.type === "done", `turn ended with ${done.type}`); + + return done.message; +} + +function getText(message: AssistantMessage): string { + return message.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join(""); +} + +function getMcpUrl(call: RunCall): string { + const mcpConfig = JSON.parse(call.mcpConfig) as { mcpServers: Record }; + const url = mcpConfig.mcpServers.learnharness?.url; + assert.ok(url); + return url; +} + +function getOnlyCall(calls: RunCall[]): RunCall { + assert.equal(calls.length, 1); + const [call] = calls; + assert.ok(call); + return call; +} + +function getLastEvent(events: AssistantMessageEvent[]): AssistantMessageEvent { + const event = events.at(-1); + assert.ok(event); + return event; +} + +function getErrorMessage(events: AssistantMessageEvent[]): string | undefined { + const event = getLastEvent(events); + assert.equal(event.type, "error"); + return event.type === "error" ? event.error.errorMessage : undefined; +} + +async function waitForExit(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + + await assert.doesNotReject(once(child, "exit", { signal: AbortSignal.timeout(CLEANUP_TIMEOUT_MS) }), "claude CLI process was not killed"); +} + +async function waitForRemoval(path: string): Promise { + const deadline = Date.now() + CLEANUP_TIMEOUT_MS; + + while (existsSync(path)) { + assert.ok(Date.now() < deadline, `${path} was not removed`); + await sleep(10); + } +} + +async function assertCleanedUp(call: RunCall): Promise { + await waitForExit(call.child); + await waitForRemoval(call.cwd); + await waitForRemoval(dirname(getFlagValue(call.args, "--system-prompt-file"))); +} + +test("toClaudeArgs includes every isolation flag from Global Constraints", () => { + const input = { + model: "opus", + effort: "high", + systemPromptFile: "/private/system-prompt.md", + mcpConfigFile: "/private/mcp.json", + allowedTools: ["mcp__learnharness__read", "mcp__learnharness__write"], + } as const; + + assert.deepEqual(toClaudeArgs({ ...input, allowedTools: [...input.allowedTools] }), [ + "-p", + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--verbose", + "--include-partial-messages", + "--model", + "opus", + "--effort", + "high", + "--system-prompt-file", + "/private/system-prompt.md", + "--tools", + "", + "--strict-mcp-config", + "--mcp-config", + "/private/mcp.json", + "--setting-sources", + "", + "--disable-slash-commands", + "--no-session-persistence", + "--permission-prompts", + "none", + "--allowedTools", + "mcp__learnharness__read,mcp__learnharness__write", + ]); + + assert.equal(toClaudeArgs({ ...input, allowedTools: [] }).includes("--allowedTools"), false); +}); + +test('toEffort maps low..max verbatim, minimal to "low", undefined to "medium"', () => { + assert.deepEqual( + (["low", "medium", "high", "xhigh", "max"] as const).map((level) => toEffort(level)), + ["low", "medium", "high", "xhigh", "max"], + ); + + assert.equal(toEffort("minimal"), "low"); + assert.equal(toEffort(undefined), "medium"); +}); + +test("text scenario streams start, text_start, text_delta…, text_end, done(stop)", async (t) => { + const { events, calls } = await runScenario(t, readFixtureLines("text.jsonl")); + + assert.deepEqual( + events.map((event) => event.type), + ["start", "text_start", "text_delta", "text_end", "done"], + ); + + const done = getLastEvent(events); + assert.ok(done.type === "done"); + assert.equal(done.reason, "stop"); + assert.equal(done.message.stopReason, "stop"); + assert.deepEqual(done.message.content, [{ type: "text", text: "hello there" }]); + assert.equal(done.message.provider, "claude-code"); + assert.equal(done.message.api, "claude-code-cli"); + assert.equal(done.message.model, "opus"); + + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("thinking deltas map to thinking_*", async (t) => { + const { events } = await runScenario(t, [ + streamEvent({ type: "message_start", message: { usage: { input_tokens: 3, output_tokens: 1 } } }), + streamEvent({ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }), + streamEvent({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "Sockets are " } }), + streamEvent({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "endpoints." } }), + streamEvent({ type: "content_block_stop", index: 0 }), + streamEvent({ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }), + streamEvent({ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "An endpoint." } }), + streamEvent({ type: "content_block_stop", index: 1 }), + streamEvent({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 9 } }), + streamEvent({ type: "message_stop" }), + ]); + + assert.deepEqual( + events.map((event) => event.type), + ["start", "thinking_start", "thinking_delta", "thinking_delta", "thinking_end", "text_start", "text_delta", "text_end", "done"], + ); + + const done = getLastEvent(events); + assert.ok(done.type === "done"); + assert.deepEqual(done.message.content, [ + { type: "thinking", thinking: "Sockets are endpoints." }, + { type: "text", text: "An endpoint." }, + ]); +}); + +test("tool scenario ends with toolcall_end (Claude's tool_use id, unprefixed name) and done(toolUse)", async (t) => { + const { events } = await runScenario(t, getToolScenario()); + + assert.deepEqual( + events.map((event) => event.type), + ["start", "toolcall_start", "toolcall_delta", "toolcall_delta", "toolcall_delta", "toolcall_end", "done"], + ); + + const toolCallEnd = events.find((event) => event.type === "toolcall_end"); + assert.ok(toolCallEnd?.type === "toolcall_end"); + assert.deepEqual(toolCallEnd.toolCall, { + type: "toolCall", + id: "toolu_01JBB4cJsPa585FWb1XrfbNX", + name: "get_weather", + arguments: { city: "Paris" }, + }); + + const done = getLastEvent(events); + assert.ok(done.type === "done"); + assert.equal(done.reason, "toolUse"); + assert.deepEqual(done.message.content, [toolCallEnd.toolCall]); +}); + +test("unknown tool name and bad JSON args still surface as tool calls", async (t) => { + const { events } = await runScenario(t, [ + streamEvent({ type: "message_start", message: { usage: {} } }), + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "toolu_a", name: "mcp__learnharness__nope", input: {} }, + }), + streamEvent({ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"path": ' } }), + streamEvent({ type: "content_block_stop", index: 0 }), + streamEvent({ type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "toolu_b", name: "Bash", input: {} } }), + streamEvent({ type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: '{"command":"ls"}' } }), + streamEvent({ type: "content_block_stop", index: 1 }), + streamEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }), + streamEvent({ type: "message_stop" }), + HOLD, + ]); + + const toolCalls = events.flatMap((event) => (event.type === "toolcall_end" ? [event.toolCall] : [])); + assert.deepEqual( + toolCalls.map((call) => ({ id: call.id, name: call.name, arguments: call.arguments })), + [ + { id: "toolu_a", name: "nope", arguments: {} }, + { id: "toolu_b", name: "Bash", arguments: { command: "ls" } }, + ], + ); + + assert.match(toolCalls[0]?.argumentsError ?? "", /\{"path": /); + assert.equal(toolCalls[1]?.argumentsError, undefined); + + const done = getLastEvent(events); + assert.ok(done.type === "done"); + assert.equal(done.reason, "toolUse"); +}); + +test("usage maps input/output/cacheRead/cacheWrite with zero cost, message_delta overriding message_start", async (t) => { + const { events } = await runScenario(t, getToolScenario()); + const done = getLastEvent(events); + assert.ok(done.type === "done"); + assert.deepEqual(done.message.usage, { + input: 2, + output: 59, + cacheRead: 0, + cacheWrite: 1102, + totalTokens: 1163, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }); +}); + +test('missing binary → error "claude CLI not found"', async () => { + const model = CLAUDE_CODE_MODELS[0]; + assert.ok(model); + + let cwd = ""; + const runClaude: RunClaude = (_args, options) => { + cwd = options.cwd; + return null; + }; + + const context = normalizeContext({ systemPrompt: "p", messages: [{ role: "user", content: "hi", timestamp: 0 }] }); + const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(model, context)); + + assert.deepEqual( + events.map((event) => event.type), + ["error"], + ); + + assert.equal(getErrorMessage(events), ClaudeCodeError.CliNotFound); + await waitForRemoval(cwd); +}); + +test('signed-out fixture → error "not signed in"', async (t) => { + const { events, calls } = await runScenario(t, [...readFixtureLines("signed-out.jsonl"), exitWith(1, "")]); + assert.equal(getErrorMessage(events), ClaudeCodeError.NotSignedIn); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("other assistant errors surface their text", async (t) => { + const assistantError = JSON.stringify({ + type: "assistant", + error: "rate_limit", + message: { content: [{ type: "text", text: "API Error: Rate limit reached" }] }, + }); + const { events } = await runScenario(t, [assistantError, exitWith(1, "")]); + assert.equal(getErrorMessage(events), "API Error: Rate limit reached"); +}); + +test("result error before message_stop surfaces the result text", async (t) => { + const resultError = JSON.stringify({ + type: "result", + subtype: "error_during_execution", + is_error: true, + result: "Credit balance is too low", + }); + const { events } = await runScenario(t, [resultError, HOLD]); + assert.equal(getErrorMessage(events), "Credit balance is too low"); +}); + +test("result error without result text surfaces its errors", async (t) => { + const resultError = JSON.stringify({ + type: "result", + subtype: "error_max_turns", + is_error: true, + errors: ["Reached maximum number of turns"], + }); + const { events } = await runScenario(t, [resultError, HOLD]); + assert.equal(getErrorMessage(events), "Reached maximum number of turns"); +}); + +test("result error without result text or errors surfaces its subtype", async (t) => { + const resultError = JSON.stringify({ type: "result", subtype: "error_during_execution", is_error: true }); + const { events } = await runScenario(t, [resultError, HOLD]); + assert.equal(getErrorMessage(events), "claude CLI failed: error_during_execution"); +}); + +test("refusal stop reason → error", async (t) => { + const { events } = await runScenario(t, [ + streamEvent({ type: "message_start", message: { usage: {} } }), + streamEvent({ type: "message_delta", delta: { stop_reason: "refusal" }, usage: {} }), + streamEvent({ type: "message_stop" }), + ]); + assert.equal(getErrorMessage(events), "The model refused to complete the request"); +}); + +test("non-zero exit mid-stream → error naming the exit code, stderr logged not shown, process and temp dirs gone", async (t) => { + const consoleError = t.mock.method(console, "error", () => {}); + const midStream = readFixtureLines("text.jsonl").slice(0, 5); + const { events, calls } = await runScenario(t, [...midStream, exitWith(1, "Error: ENOENT /home/someone/.claude/x\n at main\n")]); + + assert.deepEqual( + events.map((event) => event.type), + ["start", "text_start", "text_delta", "error"], + ); + + assert.equal(getErrorMessage(events), "claude CLI exited with code 1"); + assert.ok( + consoleError.mock.calls.some((call) => call.arguments.some((argument) => String(argument).includes("ENOENT /home/someone/.claude/x"))), + ); + + const error = getLastEvent(events); + assert.ok(error.type === "error"); + assert.equal(error.reason, "error"); + assert.deepEqual(error.error.content, [{ type: "text", text: "hello there" }]); + + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("exit without stderr names the exit code", async (t) => { + const { events } = await runScenario(t, [exitWith(3, "")]); + assert.equal(getErrorMessage(events), "claude CLI exited with code 3"); +}); + +test("abort kills the process and ends with error(aborted)", async (t) => { + const { runClaude, calls } = createFakeRun(t, [streamEvent({ type: "message_start", message: { usage: {} } }), HOLD]); + const model = CLAUDE_CODE_MODELS[0]; + assert.ok(model); + const controller = new AbortController(); + const context = normalizeContext({ systemPrompt: "p", messages: [{ role: "user", content: "hi", timestamp: 0 }] }); + const stream = createClaudeCodeStreamFunction(runClaude)(model, context, { signal: controller.signal }); + + const events = await collectEvents(stream, (event) => { + if (event.type === "start") controller.abort(); + }); + + assert.deepEqual( + events.map((event) => event.type), + ["start", "error"], + ); + + const error = getLastEvent(events); + assert.ok(error.type === "error"); + assert.equal(error.reason, "aborted"); + + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("cwd is an empty temp dir; prompt and mcp config live in a separate private dir", async (t) => { + const { calls } = await runScenario(t, readFixtureLines("text.jsonl")); + const call = getOnlyCall(calls); + + assert.deepEqual(call.cwdEntries, []); + assert.ok(call.cwd.startsWith(tmpdir())); + + const privateDir = dirname(getFlagValue(call.args, "--system-prompt-file")); + assert.notEqual(privateDir, call.cwd); + assert.ok(!privateDir.startsWith(call.cwd + sep)); + assert.equal(dirname(getFlagValue(call.args, "--mcp-config")), privateDir); + assert.equal(call.systemPrompt, "You are a tutor."); + assert.equal(call.mcpConfigMode, 0o600); + + const mcpConfig = JSON.parse(call.mcpConfig) as { mcpServers: Record }; + assert.match(mcpConfig.mcpServers.learnharness?.url ?? "", /^http:\/\/127\.0\.0\.1:\d+\/mcp$/); +}); + +test("spawns with the model alias, effort, bridge tools, MCP_TOOL_TIMEOUT and the caller's HOME", async (t) => { + const { calls } = await runScenario(t, readFixtureLines("text.jsonl"), { reasoning: "xhigh" }); + const call = getOnlyCall(calls); + assert.equal(getFlagValue(call.args, "--model"), "opus"); + assert.equal(getFlagValue(call.args, "--effort"), "xhigh"); + assert.equal(getFlagValue(call.args, "--allowedTools"), "mcp__learnharness__read"); + assert.equal(call.env.MCP_TOOL_TIMEOUT, "1800000"); + assert.equal(call.env.HOME, process.env.HOME); + assert.equal(call.env.CLAUDE_CONFIG_DIR, process.env.CLAUDE_CONFIG_DIR); +}); + +test("spawns without Anthropic API credentials so turns stay on the subscription", async (t) => { + const saved = { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN }; + t.after(() => { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); + + process.env.ANTHROPIC_API_KEY = "sk-ant-test"; + process.env.ANTHROPIC_AUTH_TOKEN = "token-test"; + + const { calls } = await runScenario(t, readFixtureLines("text.jsonl")); + const call = getOnlyCall(calls); + + assert.equal(call.env.ANTHROPIC_API_KEY, undefined); + assert.equal(call.env.ANTHROPIC_AUTH_TOKEN, undefined); +}); + +test("writes the transcript as one stream-json user line on stdin", async (t) => { + const stdinFile = join(mkdtempSync(join(tmpdir(), "claude-code-stdin-")), "stdin.jsonl"); + t.after(() => rmSync(dirname(stdinFile), { recursive: true, force: true })); + + await runScenario(t, readFixtureLines("text.jsonl"), undefined, { FAKE_CLAUDE_STDIN: stdinFile }); + + assert.equal(readFileSync(stdinFile, "utf8"), `${toStreamJsonLine([{ type: "text", text: "hi" }])}\n`); +}); + +test("parseCliLine ignores non-JSON, system, rate-limit and successful result lines", () => { + assert.equal(parseCliLine("not json"), null); + assert.equal(parseCliLine("null"), null); + assert.equal(parseCliLine('{"type":"system","subtype":"init"}'), null); + assert.equal(parseCliLine('{"type":"rate_limit_event"}'), null); + assert.equal(parseCliLine('{"type":"result","is_error":false,"result":"hello there"}'), null); + assert.equal(parseCliLine('{"type":"assistant","error":null,"message":{"content":[{"type":"text","text":"hello there"}]}}'), null); +}); + +test("trailing toolResults matching a live process continue it: held calls answered, new assistant message streamed, no new spawn", async (t) => { + const readA: ToolUse = { id: "toolu_continue_a", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA]), callTool(readA), REPLY]); + const streamFunction = createClaudeCodeStreamFunction(runClaude); + const toolMessage = await runTurn(streamFunction, [HI]); + + const reply = await runTurn(streamFunction, [HI, toolMessage, toToolResult(readA, "contents of a")]); + + assert.equal(toolMessage.stopReason, "toolUse"); + assert.equal(getText(reply), "contents of a"); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("multiple tool calls in one message are all answered by id", async (t) => { + const readA: ToolUse = { id: "toolu_many_a", name: "read", arguments: { path: "a" } }; + const readB: ToolUse = { id: "toolu_many_b", name: "read", arguments: { path: "b" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA, readB]), callTool(readB), callTool(readA), REPLY]); + const streamFunction = createClaudeCodeStreamFunction(runClaude); + const toolMessage = await runTurn(streamFunction, [HI]); + + const reply = await runTurn(streamFunction, [HI, toolMessage, toToolResult(readA, "A"), toToolResult(readB, "B")]); + + assert.equal(getText(reply), "B | A"); + assert.equal(calls.length, 1); +}); + +test("partial id match starts fresh and kills the old process", async (t) => { + const readA: ToolUse = { id: "toolu_partial_a", name: "read", arguments: { path: "a" } }; + const readB: ToolUse = { id: "toolu_partial_b", name: "read", arguments: { path: "b" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA, readB]), callTool(readA), callTool(readB), REPLY]); + const streamFunction = createClaudeCodeStreamFunction(runClaude); + const toolMessage = await runTurn(streamFunction, [HI]); + + await runTurn(streamFunction, [HI, toolMessage, toToolResult(readA, "A")]); + + assert.equal(calls.length, 2); + const [oldCall] = calls; + assert.ok(oldCall); + await assertCleanedUp(oldCall); +}); + +test("user message after tool results starts fresh with unfinished calls in the replay", async (t) => { + const stdinFile = join(mkdtempSync(join(tmpdir(), "claude-code-stdin-")), "stdin.jsonl"); + t.after(() => rmSync(dirname(stdinFile), { recursive: true, force: true })); + + const readA: ToolUse = { id: "toolu_steer_a", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA]), callTool(readA), REPLY], { FAKE_CLAUDE_STDIN: stdinFile }); + const streamFunction = createClaudeCodeStreamFunction(runClaude); + const toolMessage = await runTurn(streamFunction, [HI]); + const steer: Message = { role: "user", content: "wait, explain sockets first", timestamp: 0 }; + + await runTurn(streamFunction, [HI, toolMessage, toToolResult(readA, "A"), steer]); + + assert.equal(calls.length, 2); + + const [oldCall] = calls; + assert.ok(oldCall); + await assertCleanedUp(oldCall); + + const stdin = readFileSync(stdinFile, "utf8"); + assert.ok(stdin.includes('[tool call read] {\\"path\\":\\"a\\"}'), stdin); + assert.ok(stdin.includes("wait, explain sockets first"), stdin); +}); + +test("output while waiting for tool results kills the process", async (t) => { + const readA: ToolUse = { id: "toolu_moved_on_a", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA]), REPLY, HOLD]); + + await runTurn(createClaudeCodeStreamFunction(runClaude), [HI]); + + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("an undeclared tool call is not kept waiting, since the CLI answers it itself", async (t) => { + const readA: ToolUse = { id: "toolu_undeclared_a", name: "read", arguments: { path: "a" } }; + const nope: ToolUse = { id: "toolu_undeclared_nope", name: "nope", arguments: {} }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA, nope]), HOLD]); + + const toolMessage = await runTurn(createClaudeCodeStreamFunction(runClaude), [HI]); + + assert.equal(toolMessage.stopReason, "toolUse"); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("a tool call with invalid JSON arguments is not kept waiting, since the CLI answers it itself", async (t) => { + const { runClaude, calls } = createFakeRun(t, [ + streamEvent({ type: "message_start", message: { usage: {} } }), + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "toolu_bad_json", name: "mcp__learnharness__read", input: {} }, + }), + streamEvent({ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"path": ' } }), + streamEvent({ type: "content_block_stop", index: 0 }), + streamEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }), + streamEvent({ type: "message_stop" }), + HOLD, + ]); + + const toolMessage = await runTurn(createClaudeCodeStreamFunction(runClaude), [HI]); + + assert.equal(toolMessage.stopReason, "toolUse"); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("abort while waiting kills the process and closes the bridge", async (t) => { + const readA: ToolUse = { id: "toolu_abort_a", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA]), callTool(readA), REPLY]); + const controller = new AbortController(); + + await runTurn(createClaudeCodeStreamFunction(runClaude), [HI], { signal: controller.signal }); + + controller.abort(); + + const call = getOnlyCall(calls); + await assertCleanedUp(call); + await assert.rejects(fetch(getMcpUrl(call))); +}); + +test("idle timeout kills an orphaned waiting process", async (t) => { + liveProcessIdleTimeoutMs.current = 50; + t.after(() => (liveProcessIdleTimeoutMs.current = LIVE_PROCESS_IDLE_TIMEOUT_MS)); + + const readA: ToolUse = { id: "toolu_idle_a", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA]), callTool(readA), REPLY]); + + await runTurn(createClaudeCodeStreamFunction(runClaude), [HI]); + + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("two concurrent streams keep separate processes and results never cross", async (t) => { + const readOne: ToolUse = { id: "toolu_concurrent_one", name: "read", arguments: { path: "one" } }; + const readTwo: ToolUse = { id: "toolu_concurrent_two", name: "read", arguments: { path: "two" } }; + const one = createFakeRun(t, [...toToolUseMessage([readOne]), callTool(readOne), REPLY]); + const two = createFakeRun(t, [...toToolUseMessage([readTwo]), callTool(readTwo), REPLY]); + + const streamOne = createClaudeCodeStreamFunction(one.runClaude); + const streamTwo = createClaudeCodeStreamFunction(two.runClaude); + const [toolMessageOne, toolMessageTwo] = await Promise.all([runTurn(streamOne, [HI]), runTurn(streamTwo, [HI])]); + + const [replyOne, replyTwo] = await Promise.all([ + runTurn(streamOne, [HI, toolMessageOne, toToolResult(readOne, "from one")]), + runTurn(streamTwo, [HI, toolMessageTwo, toToolResult(readTwo, "from two")]), + ]); + + assert.equal(getText(replyOne), "from one"); + assert.equal(getText(replyTwo), "from two"); + assert.equal(one.calls.length, 1); + assert.equal(two.calls.length, 1); +}); diff --git a/tests/fixtures/claude-code/fake-claude.mjs b/tests/fixtures/claude-code/fake-claude.mjs new file mode 100644 index 0000000..cb64d65 --- /dev/null +++ b/tests/fixtures/claude-code/fake-claude.mjs @@ -0,0 +1,79 @@ +/** + * Stand-in for the claude CLI in tests. Reads the stream-json input from + * stdin, then replays the scenario file named by FAKE_CLAUDE_SCENARIO line by + * line. Lines with a "fake" key are directives instead of output: + * {"fake":"hold"} stay alive until killed, like the CLI waiting on a held MCP call + * {"fake":"exit","code":1,"stderr":"..."} write stderr and exit with that code + * {"fake":"call","name":"read","arguments":{...}} call that tool on the MCP bridge and wait for its result + * {"fake":"reply"} stream one assistant message whose text is every result so far, joined by " | " + * FAKE_CLAUDE_STDIN, when set, receives the stdin it read. + */ +import { readFileSync, writeFileSync } from "node:fs"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +const HOLD_INTERVAL_MS = 60_000; +const BRIDGE_SERVER_NAME = "learnharness"; + +function writeLine(line) { + return new Promise((resolve) => process.stdout.write(`${line}\n`, resolve)); +} + +function streamEvent(event) { + return JSON.stringify({ type: "stream_event", event }); +} + +async function connectBridge(mcpConfig) { + const { url, headers } = JSON.parse(mcpConfig).mcpServers[BRIDGE_SERVER_NAME]; + const client = new Client({ name: "fake-claude", version: "1.0.0" }); + + await client.connect(new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers } })); + return client; +} + +async function writeReply(text) { + await writeLine(streamEvent({ type: "message_start", message: { usage: {} } })); + await writeLine(streamEvent({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })); + await writeLine(streamEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text } })); + await writeLine(streamEvent({ type: "content_block_stop", index: 0 })); + await writeLine(streamEvent({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: {} })); + await writeLine(streamEvent({ type: "message_stop" })); +} + +const mcpConfig = readFileSync(process.argv[process.argv.indexOf("--mcp-config") + 1], "utf8"); + +let input = ""; +for await (const chunk of process.stdin) input += chunk; +if (process.env.FAKE_CLAUDE_STDIN) writeFileSync(process.env.FAKE_CLAUDE_STDIN, input); + +let bridge = null; +const results = []; +const lines = readFileSync(process.env.FAKE_CLAUDE_SCENARIO, "utf8").split("\n").filter(Boolean); +for (const line of lines) { + const directive = JSON.parse(line); + + if (directive.fake === "hold") { + setInterval(() => {}, HOLD_INTERVAL_MS); + break; + } + + if (directive.fake === "exit") { + process.stderr.write(directive.stderr ?? ""); + process.exit(directive.code); + } + + if (directive.fake === "call") { + bridge ??= await connectBridge(mcpConfig); + const result = await bridge.callTool({ name: directive.name, arguments: directive.arguments }); + results.push(result.content.map((block) => block.text).join("")); + continue; + + } + if (directive.fake === "reply") { + await writeReply(results.join(" | ")); + continue; + } + + await writeLine(line); +} From 2b2cf79b9ac65567429b7108c513a8ca041699b7 Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:04:22 +0400 Subject: [PATCH 08/21] feat: register claude-code provider --- src/agent/models.ts | 3 +++ src/server/model-catalog.ts | 6 ++++-- tests/model-catalog.test.ts | 26 +++++++++++++++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/agent/models.ts b/src/agent/models.ts index 80bc11e..034a7ea 100644 --- a/src/agent/models.ts +++ b/src/agent/models.ts @@ -12,6 +12,8 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { type ModelRole } from "../../config.ts"; import { DEFAULT_THINKING_ORDER } from "../core/settings.ts"; import packageMetadata from "../../package.json" with { type: "json" }; +import { createClaudeCodeProvider } from "./claude-code/provider.ts"; +import { streamClaudeCode } from "./claude-code/stream.ts"; /** App identity is immutable and shared by Models and every native Agent. * Agent's default Pi attribution otherwise overrides the Models identity. */ @@ -57,6 +59,7 @@ export function piModels() { if (!collection) { collection = createModels({ identity: APP_IDENTITY }); for (const provider of builtinProviders()) collection.setProvider(provider); + collection.setProvider(createClaudeCodeProvider(streamClaudeCode)); } return collection; } diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index 58b5cfc..129a754 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -34,6 +34,7 @@ import type { } from "../core/contracts/models.ts"; import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts"; import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts"; +import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts"; import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts"; /** The picker's client-safe model entry (contract: core/contracts/models.ts). */ @@ -130,9 +131,10 @@ export function catalogProviders(): CatalogProvider[] { /** The providers whose key is present — the picker federates exactly * these (a keyless provider's models are unreachable by design; the - * Keys page is the discovery surface, same discipline as search). */ + * Keys page is the discovery surface, same discipline as search), plus + * claude-code, which runs on the local CLI's subscription and takes no key. */ function keyedProviderIds(): Set { - return new Set(catalogProviders().filter((p) => p.keyPresent).map((p) => p.id)); + return new Set([...catalogProviders().filter((p) => p.keyPresent).map((p) => p.id), CLAUDE_CODE_PROVIDER]); } /** Project one catalog model to the client DTO. */ diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index d28f4d4..5d93a8e 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -1,6 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { + catalogContextLimits, catalogDefaultsFor, catalogProviders, catalogRolePins, @@ -22,6 +23,7 @@ import { BACKGROUND_MODEL_ROLES } from "../src/core/settings.ts"; import { resolveSettings } from "../src/core/settings-resolution.ts"; import { readSettings } from "../src/store/settings.ts"; import { effectiveSettings } from "../src/server/stores.ts"; +import { providerKeyPresent } from "../src/server/keys.ts"; // The model-control wave (2026-09-12): pi-ai owns the registry — these pins // hold our projection + validation honest against the shipped catalog, and @@ -326,4 +328,26 @@ describe("pi thinking truth (pinned against pi-ai upgrades)", () => { const { piEngineFor } = await import("../src/agent/models.ts"); assert.throws(() => piEngineFor("prober"), /no configured model for role "prober"/); }); -}); \ No newline at end of file +}); +describe("the claude-code provider (subscription CLI, no key)", () => { + it("modelCatalog() lists claude-code opus/sonnet/haiku with no keys saved", () => { + const ids = modelCatalog() + .models.filter((m) => m.provider === "claude-code") + .map((m) => m.id); + assert.deepEqual(ids, ["haiku", "opus", "sonnet"]); + }); + + it("catalogContextLimits() has claude-code:opus", () => { + assert.ok((catalogContextLimits().get("claude-code:opus") ?? 0) > 0); + }); + + it('piEngineForSpec({ provider: "claude-code", id: "sonnet" }) resolves', () => { + const engine = piEngineForSpec({ provider: "claude-code", id: "sonnet" }); + assert.equal(engine.model.provider, "claude-code"); + assert.equal(engine.model.id, "sonnet"); + }); + + it('providerKeyPresent("claude-code") is true', () => { + assert.equal(providerKeyPresent("claude-code"), true); + }); +}); From a86a4622fb24e627249ccdf224bf6caa97c3b761 Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:04:22 +0400 Subject: [PATCH 09/21] refactor: split dense lines in binary lookup and transcript replay --- src/agent/claude-code/binary.ts | 34 ++++++++++++++++++++++---- src/agent/claude-code/transcript.ts | 36 +++++++++++++++++++++++----- tests/claude-code-binary.test.ts | 5 +++- tests/claude-code-transcript.test.ts | 21 +++++++++++++--- 4 files changed, 81 insertions(+), 15 deletions(-) diff --git a/src/agent/claude-code/binary.ts b/src/agent/claude-code/binary.ts index 5e57cda..81b4a55 100644 --- a/src/agent/claude-code/binary.ts +++ b/src/agent/claude-code/binary.ts @@ -18,32 +18,51 @@ type SearchSpec = { function getWindowsSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec { const paths = path.win32; const appData = env.APPDATA ?? paths.join(homeDir, "AppData", "Roaming"); + return { paths, names: ["claude.exe", "claude.cmd"], - knownDirs: [paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), env.npm_config_prefix ?? paths.join(appData, "npm")], + knownDirs: [ + paths.join(homeDir, ".local", "bin"), + paths.join(homeDir, ".claude", "local"), + env.npm_config_prefix ?? paths.join(appData, "npm"), + ], }; } function getPosixSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec { const paths = path.posix; - const npmBin = env.npm_config_prefix ? paths.join(env.npm_config_prefix, "bin") : paths.join(homeDir, ".npm-global", "bin"); + const npmBin = env.npm_config_prefix + ? paths.join(env.npm_config_prefix, "bin") + : paths.join(homeDir, ".npm-global", "bin"); + return { paths, names: ["claude"], - knownDirs: [paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), npmBin, "/opt/homebrew/bin", "/usr/local/bin"], + knownDirs: [ + paths.join(homeDir, ".local", "bin"), + paths.join(homeDir, ".claude", "local"), + npmBin, + "/opt/homebrew/bin", + "/usr/local/bin", + ], }; } export function resolveClaudeBinary({ env, platform, homeDir, isExecutable }: BinaryLookup): string | null { - const { paths, names, knownDirs } = platform === "win32" ? getWindowsSearchSpec(env, homeDir) : getPosixSearchSpec(env, homeDir); + const { paths, names, knownDirs } = platform === "win32" + ? getWindowsSearchSpec(env, homeDir) + : getPosixSearchSpec(env, homeDir); + const pathDirs = (env.PATH ?? "").split(paths.delimiter).filter(Boolean); + for (const name of names) { for (const dir of [...pathDirs, ...knownDirs]) { const candidate = paths.join(dir, name); if (isExecutable(candidate)) return candidate; } } + return null; } @@ -57,5 +76,10 @@ function isExecutableFile(filePath: string): boolean { } export function getClaudeBinary(): string | null { - return resolveClaudeBinary({ env: process.env, platform: process.platform, homeDir: os.homedir(), isExecutable: isExecutableFile }); + return resolveClaudeBinary({ + env: process.env, + platform: process.platform, + homeDir: os.homedir(), + isExecutable: isExecutableFile, + }); } diff --git a/src/agent/claude-code/transcript.ts b/src/agent/claude-code/transcript.ts index 67cba3c..ecc5e5d 100644 --- a/src/agent/claude-code/transcript.ts +++ b/src/agent/claude-code/transcript.ts @@ -1,7 +1,9 @@ import type { ImageContent, Message, TextContent, Tool } from "@earendil-works/pi-ai"; import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript"; -export type ClaudeContentBlock = { type: "text"; text: string } | { type: "image"; source: { type: "base64"; media_type: string; data: string } }; +export type ClaudeContentBlock = + | { type: "text"; text: string } + | { type: "image"; source: { type: "base64"; media_type: string; data: string } }; export type ClaudeInput = { systemPrompt: string; @@ -17,8 +19,10 @@ function toImageBlock(image: ImageContent): ClaudeContentBlock { function getLabelledParts(label: string, content: string | (TextContent | ImageContent)[]): ReplayPart[] { if (typeof content === "string") return [`${label}\n${content}`]; + const text = content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("\n"); const images = content.filter((block) => block.type === "image"); + return [text ? `${label}\n${text}` : label, ...images]; } @@ -26,26 +30,33 @@ function getReplayParts(message: Message): ReplayPart[] { switch (message.role) { case "system": return []; + case "user": return getLabelledParts("[user]", message.content); + case "assistant": return message.content.flatMap((block) => { if (block.type === "text") return [`[assistant]\n${block.text}`]; if (block.type === "toolCall") return [`[tool call ${block.name}] ${JSON.stringify(block.arguments)}`]; return []; }); - case "toolResult": - return getLabelledParts(message.isError ? `[tool result ${message.toolName} error]` : `[tool result ${message.toolName}]`, message.content); + + case "toolResult": { + const label = message.isError ? `[tool result ${message.toolName} error]` : `[tool result ${message.toolName}]`; + return getLabelledParts(label, message.content); + } } } function toContentBlocks(parts: ReplayPart[]): ClaudeContentBlock[] { const blocks: ClaudeContentBlock[] = []; let pendingText: string[] = []; + const flushText = () => { if (pendingText.length > 0) blocks.push({ type: "text", text: pendingText.join("\n\n") }); pendingText = []; }; + for (const part of parts) { if (typeof part === "string") { pendingText.push(part); @@ -54,20 +65,33 @@ function toContentBlocks(parts: ReplayPart[]): ClaudeContentBlock[] { blocks.push(toImageBlock(part)); } } + flushText(); + return blocks; } function toUnlabelledBlocks(content: string | (TextContent | ImageContent)[]): ClaudeContentBlock[] { if (typeof content === "string") return [{ type: "text", text: content }]; + return content.map((block) => (block.type === "text" ? { type: "text", text: block.text } : toImageBlock(block))); } +function toUserContent(conversation: Message[]): ClaudeContentBlock[] { + const [first] = conversation; + if (conversation.length === 1 && first?.role === "user") return toUnlabelledBlocks(first.content); + + return toContentBlocks(conversation.flatMap(getReplayParts)); +} + export function toClaudeInput(messages: Message[]): ClaudeInput { const conversation = messages.filter((message) => message.role !== "system"); - const onlyMessage = conversation.length === 1 ? conversation[0] : null; - const userContent = onlyMessage?.role === "user" ? toUnlabelledBlocks(onlyMessage.content) : toContentBlocks(conversation.flatMap(getReplayParts)); - return { systemPrompt: getCurrentSystemPrompt(messages), tools: getCurrentTools(messages), userContent }; + + return { + systemPrompt: getCurrentSystemPrompt(messages), + tools: getCurrentTools(messages), + userContent: toUserContent(conversation), + }; } export function toStreamJsonLine(content: ClaudeContentBlock[]): string { diff --git a/tests/claude-code-binary.test.ts b/tests/claude-code-binary.test.ts index 00c7d34..66a716a 100644 --- a/tests/claude-code-binary.test.ts +++ b/tests/claude-code-binary.test.ts @@ -45,7 +45,10 @@ test("windows looks for claude.cmd and claude.exe", () => { assert.equal(resolveWith(["C:\\bin\\claude.cmd"], env, "win32", winHome), "C:\\bin\\claude.cmd"); assert.equal(resolveWith(["C:\\bin\\claude.exe"], env, "win32", winHome), "C:\\bin\\claude.exe"); assert.equal(resolveWith([`${winHome}\\.local\\bin\\claude.exe`], env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`); - assert.equal(resolveWith([`${winHome}\\AppData\\Roaming\\npm\\claude.cmd`], env, "win32", winHome), `${winHome}\\AppData\\Roaming\\npm\\claude.cmd`); + assert.equal( + resolveWith([`${winHome}\\AppData\\Roaming\\npm\\claude.cmd`], env, "win32", winHome), + `${winHome}\\AppData\\Roaming\\npm\\claude.cmd`, + ); assert.equal(resolveWith(["C:\\bin\\claude"], env, "win32", winHome), null); }); diff --git a/tests/claude-code-transcript.test.ts b/tests/claude-code-transcript.test.ts index 0fbec27..5e8db33 100644 --- a/tests/claude-code-transcript.test.ts +++ b/tests/claude-code-transcript.test.ts @@ -29,7 +29,14 @@ function assistant(content: AssistantMessage["content"]): AssistantMessage { api: "claude-code-cli", provider: "claude-code", model: "opus", - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, stopReason: "stop", timestamp: 0, }; @@ -96,7 +103,12 @@ test("error tool results are marked as errors in the replay", () => { toolResult("read", [{ type: "text", text: "ENOENT" }], true), user("why?"), ]; - const expected = ["[user]\nopen missing.md", '[tool call read] {"path":"missing.md"}', "[tool result read error]\nENOENT", "[user]\nwhy?"].join("\n\n"); + const expected = [ + "[user]\nopen missing.md", + '[tool call read] {"path":"missing.md"}', + "[tool result read error]\nENOENT", + "[user]\nwhy?", + ].join("\n\n"); assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]); }); @@ -142,5 +154,8 @@ test("thinking blocks are omitted from replay", () => { }); test('toStreamJsonLine emits {"type":"user","message":{"role":"user","content":[...]}}', () => { - assert.deepEqual(JSON.parse(toStreamJsonLine([{ type: "text", text: "hi" }])), { type: "user", message: { role: "user", content: [{ type: "text", text: "hi" }] } }); + assert.deepEqual(JSON.parse(toStreamJsonLine([{ type: "text", text: "hi" }])), { + type: "user", + message: { role: "user", content: [{ type: "text", text: "hi" }] }, + }); }); From 43d7ec5577154ec48afeaf908bfb636b6e742ba5 Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:16:51 +0400 Subject: [PATCH 10/21] fix: count claude-code as configured when the CLI is installed --- src/server/model-catalog.ts | 13 +++++--- tests/model-catalog.test.ts | 61 ++++++++++++++++++++++++++++++++----- 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index e833368..633a2f7 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -43,6 +43,7 @@ import type { } from "../core/contracts/models.ts"; import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts"; import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts"; +import { getClaudeBinary } from "../agent/claude-code/binary.ts"; import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts"; import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts"; @@ -135,15 +136,19 @@ export function catalogProviders(): CatalogProvider[] { id: p.id, envVar: p.envVar, keyPresent: keyValue(p.envVar) !== undefined, - })).concat([{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() }]).sort((a, b) => a.id.localeCompare(b.id)); + })) + .concat([ + { id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() }, + { id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: getClaudeBinary() !== null }, + ]) + .sort((a, b) => a.id.localeCompare(b.id)); } /** The providers whose key is present — the picker federates exactly * these (a keyless provider's models are unreachable by design; the - * Keys page is the discovery surface, same discipline as search), plus - * claude-code, which runs on the local CLI's subscription and takes no key. */ + * Keys page is the discovery surface, same discipline as search). */ function keyedProviderIds(): Set { - return new Set([...catalogProviders().filter((p) => p.keyPresent).map((p) => p.id), CLAUDE_CODE_PROVIDER]); + return new Set(catalogProviders().filter((p) => p.keyPresent).map((p) => p.id)); } /** Project one catalog model to the client DTO. */ diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index 5d93a8e..b3de078 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -1,3 +1,6 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { @@ -329,16 +332,60 @@ describe("pi thinking truth (pinned against pi-ai upgrades)", () => { assert.throws(() => piEngineFor("prober"), /no configured model for role "prober"/); }); }); +/** Runs fn with PATH and HOME pointed at a temp dir, so the CLI lookup only + * finds a claude binary when installed is true. */ +function withClaudeInstalled(installed: boolean, fn: () => void): void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-catalog-")); + const saved = { PATH: process.env.PATH, HOME: process.env.HOME, npm_config_prefix: process.env.npm_config_prefix }; + + if (installed) fs.writeFileSync(path.join(dir, "claude"), "#!/bin/sh\n", { mode: 0o755 }); + process.env.PATH = dir; + process.env.HOME = dir; + delete process.env.npm_config_prefix; + + try { + fn(); + } finally { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + fs.rmSync(dir, { recursive: true, force: true }); + } +} + describe("the claude-code provider (subscription CLI, no key)", () => { - it("modelCatalog() lists claude-code opus/sonnet/haiku with no keys saved", () => { - const ids = modelCatalog() - .models.filter((m) => m.provider === "claude-code") - .map((m) => m.id); - assert.deepEqual(ids, ["haiku", "opus", "sonnet"]); + it("modelCatalog() lists claude-code opus/sonnet/haiku when the CLI is installed", () => { + withClaudeInstalled(true, () => { + const ids = modelCatalog() + .models.filter((m) => m.provider === "claude-code") + .map((m) => m.id); + assert.deepEqual(ids, ["haiku", "opus", "sonnet"]); + }); }); - it("catalogContextLimits() has claude-code:opus", () => { - assert.ok((catalogContextLimits().get("claude-code:opus") ?? 0) > 0); + it("catalogProviders() marks claude-code present when the CLI is installed", () => { + withClaudeInstalled(true, () => { + const row = catalogProviders().find((p) => p.id === "claude-code"); + assert.deepEqual(row, { id: "claude-code", envVar: "", keyPresent: true }); + }); + }); + + it("catalogProviders() marks claude-code absent and modelCatalog() hides it when the CLI is missing", () => { + withClaudeInstalled(false, () => { + const row = catalogProviders().find((p) => p.id === "claude-code"); + assert.equal(row?.keyPresent, false); + assert.equal( + modelCatalog().models.some((m) => m.provider === "claude-code"), + false, + ); + }); + }); + + it("catalogContextLimits() has claude-code:opus when the CLI is installed", () => { + withClaudeInstalled(true, () => { + assert.ok((catalogContextLimits().get("claude-code:opus") ?? 0) > 0); + }); }); it('piEngineForSpec({ provider: "claude-code", id: "sonnet" }) resolves', () => { From 0b77ede6ffce019712320eeff160760b1ae9e95a Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:11:31 +0400 Subject: [PATCH 11/21] test: cover tool results arriving before and after the idle timeout --- tests/claude-code-harness.test.ts | 196 ++++++++++++++++++++++++++++++ tests/claude-code-stream.test.ts | 46 +++++++ 2 files changed, 242 insertions(+) create mode 100644 tests/claude-code-harness.test.ts diff --git a/tests/claude-code-harness.test.ts b/tests/claude-code-harness.test.ts new file mode 100644 index 0000000..1dcd8c2 --- /dev/null +++ b/tests/claude-code-harness.test.ts @@ -0,0 +1,196 @@ +/** + * Claude Code provider driven through the real chat handler: a learner-facing + * question ends the turn while the CLI process waits on the held MCP call, and + * the learner's answer arrives later as that call's tool result. + * + * The runClaude seam spawns tests/fixtures/claude-code/fake-claude.mjs, one + * scenario per spawn, so the spawn count shows whether the answer continued + * the waiting process or started a fresh one with a replay. + */ +import assert from "node:assert/strict"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { once } from "node:events"; +import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import test, { type TestContext } from "node:test"; + +import { PATHS } from "../config.ts"; +import { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } from "../src/agent/claude-code/live-processes.ts"; +import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; +import { createClaudeCodeStreamFunction, type RunClaude } from "../src/agent/claude-code/stream.ts"; +import type { PiEngine } from "../src/agent/models.ts"; +import { createTeacherAgent } from "../src/agent/teacher-agent.ts"; +import { danglingQuestionOf } from "../src/core/native-messages.ts"; +import { handleChatRequest } from "../src/server/chat-handler.ts"; +import { isTurnRunning } from "../src/server/turn-liveness.ts"; +import { runningTurn } from "../src/server/turn-runs.ts"; +import { chatStore } from "../src/store/chats.ts"; + +const FAKE_CLAUDE = fileURLToPath(new URL("./fixtures/claude-code/fake-claude.mjs", import.meta.url)); +const TEXT_FIXTURE = fileURLToPath(new URL("./fixtures/claude-code/text.jsonl", import.meta.url)); +const TURN_TIMEOUT_MS = 8000; + +const ASK_ID = "toolu_harness_ask"; +const ASK_INPUT = { question: "What do you already know about sockets?" }; +const ANSWER_TEXT = "Only that they carry bytes between programs"; + +type FakeRun = { runClaude: RunClaude; children: ChildProcessWithoutNullStreams[]; stdinFiles: string[] }; + +function streamEvent(event: object): string { + return JSON.stringify({ type: "stream_event", event }); +} + +function toAskScenario(): string[] { + return [ + streamEvent({ type: "message_start", message: { usage: {} } }), + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: ASK_ID, name: "mcp__learnharness__ask_user_question", input: {} }, + }), + streamEvent({ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify(ASK_INPUT) } }), + streamEvent({ type: "content_block_stop", index: 0 }), + streamEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }), + streamEvent({ type: "message_stop" }), + JSON.stringify({ fake: "call", name: "ask_user_question", arguments: ASK_INPUT }), + JSON.stringify({ fake: "reply" }), + ]; +} + +function createFakeRun(t: TestContext, scenarios: string[][]): FakeRun { + const dir = mkdtempSync(join(tmpdir(), "claude-code-harness-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const children: ChildProcessWithoutNullStreams[] = []; + const stdinFiles: string[] = []; + + const runClaude: RunClaude = (args, options) => { + const scenario = scenarios[children.length]; + assert.ok(scenario, `unexpected spawn #${children.length + 1}`); + + const scenarioFile = join(dir, `scenario-${children.length}.jsonl`); + const stdinFile = join(dir, `stdin-${children.length}.jsonl`); + writeFileSync(scenarioFile, `${scenario.join("\n")}\n`); + + const child = spawn(process.execPath, [FAKE_CLAUDE, ...args], { + cwd: options.cwd, + env: { ...options.env, FAKE_CLAUDE_SCENARIO: scenarioFile, FAKE_CLAUDE_STDIN: stdinFile }, + stdio: "pipe", + }); + t.after(() => child.kill()); + + children.push(child); + stdinFiles.push(stdinFile); + return child; + }; + + return { runClaude, children, stdinFiles }; +} + +function createClaudeCodeChat(t: TestContext): string { + const chat = chatStore.createChat("Claude Code: question answered later"); + chatStore.saveSettings(chat.id, { model: { provider: "claude-code", id: "sonnet" } }); + t.after(() => unlinkSync(join(PATHS.chats, `${chat.id}.json`))); + + return chat.id; +} + +function createAgentFactory(fakeRun: FakeRun): Parameters[1] { + const model = CLAUDE_CODE_MODELS.find((candidate) => candidate.id === "sonnet"); + assert.ok(model); + + const engine: PiEngine = { model, streamFn: createClaudeCodeStreamFunction(fakeRun.runClaude) }; + + return (options) => createTeacherAgent(undefined, { engine, messages: options.messages }); +} + +async function runChatTurn(chatId: string, input: object, agentFactory: Parameters[1]): Promise { + const request = new Request("http://test/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chatId, input }), + }); + + const ack = await handleChatRequest(request, agentFactory); + assert.equal(ack.status, 200, await ack.clone().text()); + + const handle = runningTurn(chatId); + if (handle) await new Response(handle.subscribe()).text(); + + const deadline = Date.now() + TURN_TIMEOUT_MS; + while (isTurnRunning(chatId)) { + assert.ok(Date.now() < deadline, "turn did not settle"); + await sleep(10); + } +} + +function toAnswerInput(): object { + return { + type: "answer", + toolCallId: ASK_ID, + answer: { kind: "ask", questionText: ASK_INPUT.question, dontKnow: false, answers: [], text: ANSWER_TEXT, answeredAt: new Date().toISOString() }, + }; +} + +function getLastAssistantText(chatId: string): string { + const messages = chatStore.getChat(chatId)?.messages ?? []; + const assistant = messages.findLast((message) => message.role === "assistant"); + assert.ok(assistant?.role === "assistant"); + + return assistant.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join(""); +} + +async function waitForExit(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + + await once(child, "exit", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) }); +} + +test("a question answered before the idle timeout continues the waiting CLI process with the answer", async (t) => { + const fakeRun = createFakeRun(t, [toAskScenario()]); + const agentFactory = createAgentFactory(fakeRun); + const chatId = createClaudeCodeChat(t); + + await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, agentFactory); + + assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, ASK_ID); + const [waiting] = fakeRun.children; + assert.ok(waiting); + assert.equal(waiting.exitCode, null, "the CLI process should wait on the held question call"); + + await runChatTurn(chatId, toAnswerInput(), agentFactory); + + assert.equal(fakeRun.children.length, 1); + assert.ok(getLastAssistantText(chatId).includes(ANSWER_TEXT), getLastAssistantText(chatId)); + await waitForExit(waiting); +}); + +test("a question answered after the idle timeout starts a fresh CLI process that replays the question and answer", async (t) => { + liveProcessIdleTimeoutMs.current = 300; + t.after(() => (liveProcessIdleTimeoutMs.current = LIVE_PROCESS_IDLE_TIMEOUT_MS)); + + const fakeRun = createFakeRun(t, [toAskScenario(), readFileSync(TEXT_FIXTURE, "utf8").split("\n").filter(Boolean)]); + const agentFactory = createAgentFactory(fakeRun); + const chatId = createClaudeCodeChat(t); + + await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, agentFactory); + + const [waiting] = fakeRun.children; + assert.ok(waiting); + assert.equal(waiting.exitCode, null, "the CLI process should wait on the held question call"); + await waitForExit(waiting); + + await runChatTurn(chatId, toAnswerInput(), agentFactory); + + assert.equal(fakeRun.children.length, 2); + assert.equal(getLastAssistantText(chatId), "hello there"); + + const replay = readFileSync(fakeRun.stdinFiles[1] ?? "", "utf8"); + assert.ok(replay.includes("[tool call ask_user_question]"), replay); + assert.ok(replay.includes(ASK_INPUT.question), replay); + assert.ok(replay.includes("[tool result ask_user_question]"), replay); + assert.ok(replay.includes(ANSWER_TEXT), replay); +}); diff --git a/tests/claude-code-stream.test.ts b/tests/claude-code-stream.test.ts index 96acc52..d5e48b3 100644 --- a/tests/claude-code-stream.test.ts +++ b/tests/claude-code-stream.test.ts @@ -759,6 +759,52 @@ test("idle timeout kills an orphaned waiting process", async (t) => { await assertCleanedUp(getOnlyCall(calls)); }); +test("a tool result arriving after the idle timeout starts fresh with the call and its result in the replay", async (t) => { + liveProcessIdleTimeoutMs.current = 300; + t.after(() => (liveProcessIdleTimeoutMs.current = LIVE_PROCESS_IDLE_TIMEOUT_MS)); + + const stdinFile = join(mkdtempSync(join(tmpdir(), "claude-code-stdin-")), "stdin.jsonl"); + t.after(() => rmSync(dirname(stdinFile), { recursive: true, force: true })); + + const readA: ToolUse = { id: "toolu_late_a", name: "read", arguments: { path: "a" } }; + const waiting = createFakeRun(t, [...toToolUseMessage([readA]), callTool(readA), REPLY]); + const fresh = createFakeRun(t, readFixtureLines("text.jsonl"), { FAKE_CLAUDE_STDIN: stdinFile }); + const runClaude: RunClaude = (args, options) => (waiting.calls.length === 0 ? waiting : fresh).runClaude(args, options); + const streamFunction = createClaudeCodeStreamFunction(runClaude); + const toolMessage = await runTurn(streamFunction, [HI]); + + const waitingCall = getOnlyCall(waiting.calls); + assert.equal(waitingCall.child.exitCode, null, "the process should wait for the tool result until the idle timeout"); + await assertCleanedUp(waitingCall); + + const reply = await runTurn(streamFunction, [HI, toolMessage, toToolResult(readA, "contents of a")]); + + assert.equal(getText(reply), "hello there"); + await assertCleanedUp(getOnlyCall(fresh.calls)); + + const stdin = readFileSync(stdinFile, "utf8"); + assert.ok(stdin.includes('[tool call read] {\\"path\\":\\"a\\"}'), stdin); + assert.ok(stdin.includes("[tool result read]"), stdin); + assert.ok(stdin.includes("contents of a"), stdin); +}); + +test("a tool result arriving late but before the idle timeout still continues the waiting process", async (t) => { + liveProcessIdleTimeoutMs.current = 1000; + t.after(() => (liveProcessIdleTimeoutMs.current = LIVE_PROCESS_IDLE_TIMEOUT_MS)); + + const readA: ToolUse = { id: "toolu_slow_a", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA]), callTool(readA), REPLY]); + const streamFunction = createClaudeCodeStreamFunction(runClaude); + const toolMessage = await runTurn(streamFunction, [HI]); + + await sleep(500); + + const reply = await runTurn(streamFunction, [HI, toolMessage, toToolResult(readA, "contents of a")]); + + assert.equal(getText(reply), "contents of a"); + await assertCleanedUp(getOnlyCall(calls)); +}); + test("two concurrent streams keep separate processes and results never cross", async (t) => { const readOne: ToolUse = { id: "toolu_concurrent_one", name: "read", arguments: { path: "one" } }; const readTwo: ToolUse = { id: "toolu_concurrent_two", name: "read", arguments: { path: "two" } }; From 6112bd9ae6b6f28d0f40a426b07eabca723e602f Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:11:31 +0400 Subject: [PATCH 12/21] docs: correct idle timeout comments for question tools --- src/agent/claude-code/live-processes.ts | 5 +++-- src/agent/claude-code/stream.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/agent/claude-code/live-processes.ts b/src/agent/claude-code/live-processes.ts index f5e24b3..61f036a 100644 --- a/src/agent/claude-code/live-processes.ts +++ b/src/agent/claude-code/live-processes.ts @@ -1,7 +1,8 @@ export type LiveProcess = { toolCallIds: string[]; kill(): void }; -// pi runs a message's tools (each capped at 30s by tool-execute) and calls back well within this; -// it stays under MCP_TOOL_TIMEOUT so an orphaned process is killed before the CLI gives up on its own. +// Tools usually call back within seconds, but a question tool holds its call until the learner +// answers. Past this, the process is killed and the answer starts a fresh one with a full replay. +// It stays under MCP_TOOL_TIMEOUT so the process is killed before the CLI gives up on its own. export const LIVE_PROCESS_IDLE_TIMEOUT_MS = 10 * 60_000; /** Test seam (runner.ts's pdfModelOverride idiom). */ diff --git a/src/agent/claude-code/stream.ts b/src/agent/claude-code/stream.ts index 919561a..0f9dd4d 100644 --- a/src/agent/claude-code/stream.ts +++ b/src/agent/claude-code/stream.ts @@ -36,8 +36,9 @@ type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv }; export type RunClaude = (args: string[], options: RunClaudeOptions) => ChildProcessWithoutNullStreams | null; -// A held MCP call lasts until pi has run every tool of the message (each capped at 30s by -// tool-execute) and called back; this must outlast that so the CLI never times out first. +// A held MCP call lasts until pi calls back with the tool result, which for a question tool is +// whenever the learner answers; this must outlast the live-process idle timeout so the CLI never +// times out first. const MCP_TOOL_TIMEOUT_MS = 30 * 60_000; const PRIVATE_FILE_MODE = 0o600; From 6ca431644d1f3f7027cde8bedb08c11364faf139 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 08:01:50 +0200 Subject: [PATCH 13/21] fix: enforce Claude CLI MCP admission and safe continuation --- src/agent/claude-code/binary.ts | 4 +- src/agent/claude-code/cli-events.ts | 13 +- src/agent/claude-code/live-processes.ts | 9 +- src/agent/claude-code/reuse-context.ts | 27 ++ src/agent/claude-code/stream.ts | 313 +++++++++++++-------- src/agent/claude-code/tool-bridge.ts | 40 ++- src/agent/teacher-agent.ts | 3 +- tests/claude-code-binary.test.ts | 6 +- tests/claude-code-harness.test.ts | 30 +- tests/claude-code-stream.test.ts | 219 +++++++++++--- tests/claude-code-tool-bridge.test.ts | 35 ++- tests/fixtures/claude-code/fake-claude.mjs | 33 ++- tests/send-to-user.test.ts | 3 + 13 files changed, 534 insertions(+), 201 deletions(-) create mode 100644 src/agent/claude-code/reuse-context.ts diff --git a/src/agent/claude-code/binary.ts b/src/agent/claude-code/binary.ts index 81b4a55..13e86e6 100644 --- a/src/agent/claude-code/binary.ts +++ b/src/agent/claude-code/binary.ts @@ -21,7 +21,9 @@ function getWindowsSearchSpec(env: BinaryLookup["env"], homeDir: string): Search return { paths, - names: ["claude.exe", "claude.cmd"], + // Direct spawn deliberately uses no shell. A .cmd-only npm install + // is not runnable and must not count as an available provider. + names: ["claude.exe"], knownDirs: [ paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), diff --git a/src/agent/claude-code/cli-events.ts b/src/agent/claude-code/cli-events.ts index f532946..f3dddca 100644 --- a/src/agent/claude-code/cli-events.ts +++ b/src/agent/claude-code/cli-events.ts @@ -14,7 +14,8 @@ export type CliEvent = | { type: "messageDelta"; stopReason: string | null; usage: CliUsage } | { type: "messageStop" } | { type: "assistantError"; error: string; text: string } - | { type: "resultError"; text: string }; + | { type: "resultError"; text: string } + | { type: "toolResults"; ids: string[] }; type RawUsage = { input_tokens?: number | null; @@ -46,7 +47,7 @@ type RawCliLine = { type?: string; event?: RawStreamEvent; error?: string | null; - message?: { content?: { type: string; text?: string }[] }; + message?: { content?: { type: string; text?: string; tool_use_id?: string }[] }; is_error?: boolean; result?: string; subtype?: string; @@ -139,7 +140,7 @@ function parseStreamEvent(event: RawStreamEvent): CliEvent | null { } function getText(line: RawCliLine): string { - const blocks = line.message?.content ?? []; + const blocks = Array.isArray(line.message?.content) ? line.message.content : []; const texts = blocks.flatMap((block) => (block.type === "text" && block.text ? [block.text] : [])); return texts.join("\n"); @@ -171,6 +172,12 @@ export function parseCliLine(text: string): CliEvent | null { if (typeof line.error !== "string") return null; return { type: "assistantError", error: line.error, text: getText(line) }; + case "user": { + const blocks = Array.isArray(line.message?.content) ? line.message.content : []; + const ids = blocks.flatMap((block) => block.type === "tool_result" && typeof block.tool_use_id === "string" ? [block.tool_use_id] : []); + return ids.length ? { type: "toolResults", ids } : null; + } + case "result": if (line.is_error !== true) return null; return { type: "resultError", text: getResultErrorText(line) }; diff --git a/src/agent/claude-code/live-processes.ts b/src/agent/claude-code/live-processes.ts index 61f036a..1a68672 100644 --- a/src/agent/claude-code/live-processes.ts +++ b/src/agent/claude-code/live-processes.ts @@ -1,4 +1,4 @@ -export type LiveProcess = { toolCallIds: string[]; kill(): void }; +export type LiveProcess = { readonly owner: symbol; toolCallIds: string[]; kill(): void }; // Tools usually call back within seconds, but a question tool holds its call until the learner // answers. Past this, the process is killed and the answer starts a fresh one with a full replay. @@ -12,6 +12,7 @@ export const liveProcessIdleTimeoutMs = { current: LIVE_PROCESS_IDLE_TIMEOUT_MS const liveProcesses = new Map(); export function addLiveProcess(process: LiveProcess): void { + removeLiveProcess(process); const timer = setTimeout(() => { removeLiveProcess(process); process.kill(); @@ -23,14 +24,14 @@ export function addLiveProcess(process: LiveProcess): void { } function hasSameIds(ids: string[], otherIds: string[]): boolean { - return ids.length === otherIds.length && ids.every((id) => otherIds.includes(id)); + return ids.length === otherIds.length && new Set(otherIds).size === otherIds.length && ids.every((id) => otherIds.includes(id)); } -export function getLiveProcess(toolCallIds: string[]): LiveProcess | null { +export function getLiveProcess(toolCallIds: string[], owner: symbol): LiveProcess | null { if (toolCallIds.length === 0) return null; for (const process of liveProcesses.keys()) { - if (hasSameIds(process.toolCallIds, toolCallIds)) return process; + if (process.owner === owner && hasSameIds(process.toolCallIds, toolCallIds)) return process; } return null; diff --git a/src/agent/claude-code/reuse-context.ts b/src/agent/claude-code/reuse-context.ts new file mode 100644 index 0000000..63cbd7d --- /dev/null +++ b/src/agent/claude-code/reuse-context.ts @@ -0,0 +1,27 @@ +import type { Api, Message, Model, TranscriptContext } from "@earendil-works/pi-ai"; +import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript"; + +/** Compare model-visible history, not app IDs, timestamps, usage, or Pi's added thinkingLevel. */ +export function conversationForReuse(messages: Message[]): unknown[] { + return messages.flatMap((message): unknown[] => { + switch (message.role) { + case "system": return []; + case "user": return [{ role: message.role, content: message.content }]; + case "assistant": return [{ role: message.role, content: message.content }]; + case "toolResult": return [{ + role: message.role, + toolCallId: message.toolCallId, + toolName: message.toolName, + content: message.content, + isError: message.isError, + }]; + } + }); +} + +export function configurationForReuse(model: Model, context: TranscriptContext, effort: string): unknown { + const systemPrompt = getCurrentSystemPrompt(context.messages); + const tools = getCurrentTools(context.messages); + // The wire is JSON: TypeBox's schema symbols are not CLI configuration. + return JSON.parse(JSON.stringify({ model, effort, systemPrompt, tools })) as unknown; +} diff --git a/src/agent/claude-code/stream.ts b/src/agent/claude-code/stream.ts index 0f9dd4d..cd27167 100644 --- a/src/agent/claude-code/stream.ts +++ b/src/agent/claude-code/stream.ts @@ -16,11 +16,9 @@ import { type StopReason, type StreamFunction, type ThinkingLevel, - type Tool, type ToolCall, type ToolResultMessage, type TranscriptContext, - validateToolCall, } from "@earendil-works/pi-ai"; import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messages"; import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse"; @@ -29,7 +27,8 @@ import { getClaudeBinary } from "./binary.ts"; import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts"; import { ClaudeCodeError } from "./constants.ts"; import { addLiveProcess, getLiveProcess, removeLiveProcess, type LiveProcess } from "./live-processes.ts"; -import { createToolBridge, toolNameFromMcp, type PendingToolCall, type ToolBridge } from "./tool-bridge.ts"; +import { configurationForReuse, conversationForReuse } from "./reuse-context.ts"; +import { BRIDGE_SERVER_NAME, createToolBridge, toolNameFromMcp, type PendingToolCall, type ToolBridge } from "./tool-bridge.ts"; import { toClaudeInput, toStreamJsonLine } from "./transcript.ts"; type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv }; @@ -137,6 +136,8 @@ class ClaudeTurn { private readonly output: AssistantMessage; private readonly contentIndexes = new Map(); private readonly partialJson = new Map(); + private readonly toolBlocks = new Map(); + private readonly intents: ToolCall[] = []; started = false; finished = false; @@ -188,13 +189,12 @@ class ClaudeTurn { const found = this.getBlock(event.index); if (!found) return; - const { contentIndex, block } = found; + const { block } = found; if (block.type !== "toolCall") return; const partialJson = (this.partialJson.get(event.index) ?? "") + event.partialJson; this.partialJson.set(event.index, partialJson); block.arguments = parseStreamingJson(partialJson); - this.stream.push({ type: "toolcall_delta", contentIndex, delta: event.partialJson, partial: this.output }); return; } @@ -208,7 +208,9 @@ class ClaudeTurn { return; case "messageStop": - this.finish(); + // Tool intent is not execution admission. The process publishes only + // calls that actually reach the authenticated MCP bridge. + if (this.output.stopReason !== "toolUse") this.finish(); return; case "assistantError": @@ -221,10 +223,22 @@ class ClaudeTurn { } } - getToolCalls(): ToolCall[] { - if (!this.finished || this.output.stopReason !== "toolUse") return []; + get waitingForAdmission(): boolean { return !this.finished && this.output.stopReason === "toolUse"; } - return this.output.content.filter((block) => block.type === "toolCall"); + getIntents(): ToolCall[] { return this.intents; } + + getMessage(): AssistantMessage { return this.output; } + + admit(calls: ToolCall[]): void { + this.start(); + for (const call of calls) { + const contentIndex = this.output.content.length; + this.output.content.push(call); + this.stream.push({ type: "toolcall_start", contentIndex, partial: this.output }); + this.stream.push({ type: "toolcall_end", contentIndex, toolCall: call, partial: this.output }); + } + this.output.stopReason = "toolUse"; + this.finish(); } fail(message: string, reason: "aborted" | "error"): void { @@ -260,13 +274,18 @@ class ClaudeTurn { return; case "toolUse": - this.output.content.push({ type: "toolCall", id: block.id, name: toolNameFromMcp(block.name), arguments: {} }); - this.stream.push({ type: "toolcall_start", contentIndex, partial: this.output }); + if (!block.name.startsWith(`mcp__${BRIDGE_SERVER_NAME}__`)) { + this.fail("claude CLI emitted an unsupported native tool call", "error"); + return; + } + this.toolBlocks.set(index, { type: "toolCall", id: block.id, name: toolNameFromMcp(block.name), arguments: {} }); return; } } private getBlock(index: number): { contentIndex: number; block: AssistantMessage["content"][number] } | null { + const tool = this.toolBlocks.get(index); + if (tool) return { contentIndex: -1, block: tool }; const contentIndex = this.contentIndexes.get(index); if (contentIndex === undefined) return null; @@ -293,7 +312,8 @@ class ClaudeTurn { case "toolCall": Object.assign(block, parseToolCallArguments(this.partialJson.get(index) ?? "")); this.partialJson.delete(index); - this.stream.push({ type: "toolcall_end", contentIndex, toolCall: block satisfies ToolCall, partial: this.output }); + this.toolBlocks.delete(index); + this.intents.push(block); return; } } @@ -340,51 +360,48 @@ type TurnFiles = { cwd: string; privateDir: string; systemPromptFile: string; mc async function createTurnFiles(systemPrompt: string, mcpConfig: string): Promise { const cwd = await mkdtemp(join(tmpdir(), "claude-code-cwd-")); - const privateDir = await mkdtemp(join(tmpdir(), "claude-code-private-")); - - const systemPromptFile = join(privateDir, "system-prompt.md"); - const mcpConfigFile = join(privateDir, "mcp.json"); - - await writeFile(systemPromptFile, systemPrompt, { mode: PRIVATE_FILE_MODE }); - await writeFile(mcpConfigFile, mcpConfig, { mode: PRIVATE_FILE_MODE }); - - return { cwd, privateDir, systemPromptFile, mcpConfigFile }; -} - -async function removeTurnResources(bridge: ToolBridge, files: TurnFiles): Promise { - await bridge.close(); - await rm(files.cwd, { recursive: true, force: true }); - await rm(files.privateDir, { recursive: true, force: true }); -} - -type UnansweredToolCall = { toolCall: ToolCall; result: ToolResultMessage }; - -// The CLI answers calls to undeclared tools or with invalid arguments itself, without reaching the bridge. -function isValidToolCall(tools: Tool[], toolCall: ToolCall): boolean { + let privateDir: string | undefined; try { - validateToolCall(tools, toolCall); - return true; - } catch { - return false; + privateDir = await mkdtemp(join(tmpdir(), "claude-code-private-")); + const systemPromptFile = join(privateDir, "system-prompt.md"); + const mcpConfigFile = join(privateDir, "mcp.json"); + await writeFile(systemPromptFile, systemPrompt, { mode: PRIVATE_FILE_MODE }); + await writeFile(mcpConfigFile, mcpConfig, { mode: PRIVATE_FILE_MODE }); + return { cwd, privateDir, systemPromptFile, mcpConfigFile }; + } catch (error) { + await Promise.all([rm(cwd, { recursive: true, force: true }), ...(privateDir ? [rm(privateDir, { recursive: true, force: true })] : [])]); + throw error; } } -function isSameToolCall(toolCall: ToolCall, call: PendingToolCall): boolean { - return toolCall.name === call.name && isDeepStrictEqual(toolCall.arguments, call.arguments); +async function removeTurnResources(bridge: ToolBridge, files: TurnFiles): Promise { + await Promise.all([ + bridge.close(), + rm(files.cwd, { recursive: true, force: true }), + rm(files.privateDir, { recursive: true, force: true }), + ]); } class ClaudeProcess implements LiveProcess { toolCallIds: string[] = []; private stderr = ""; private closed = false; + private stopping = false; + private killTimer: NodeJS.Timeout | undefined; private signal: AbortSignal | undefined; + private sourceCalls: ToolCall[] = []; + private sourceComplete = false; + private admitted: PendingToolCall[] = []; + private pendingError: string | undefined; constructor( private readonly child: ChildProcessWithoutNullStreams, private readonly bridge: ToolBridge, private readonly files: TurnFiles, - private readonly tools: Tool[], private turn: ClaudeTurn, + readonly owner: symbol, + private readonly configuration: unknown, + private conversation: unknown[], ) {} start(stdinLine: string, signal: AbortSignal | undefined): void { @@ -394,39 +411,92 @@ class ClaudeProcess implements LiveProcess { this.turn.fail(START_FAILED_MESSAGE, "error"); this.handleClose(START_FAILED_MESSAGE); }); - this.child.stderr.setEncoding("utf8"); - this.child.stderr.on("data", (chunk: string) => (this.stderr += chunk)); - + this.child.stderr.on("data", (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-65_536); }); this.child.stdin.on("error", (error) => console.error("claude CLI stdin closed early:", error)); this.child.stdin.end(`${stdinLine}\n`); - - createInterface({ input: this.child.stdout }).on("line", (line) => this.handleLine(line)); - + createInterface({ input: this.child.stdout }).on("line", (line) => { + try { this.handleLine(line); } + catch { this.fail("claude CLI emitted malformed stream output"); } + }); this.setSignal(signal); } - continueTurn(turn: ClaudeTurn, toolResults: ToolResultMessage[], signal: AbortSignal | undefined): void { + canContinue(owner: symbol, configuration: unknown, messages: Message[], results: ToolResultMessage[]): boolean { + const conversation = conversationForReuse(messages); + return !this.stopping && owner === this.owner + && isDeepStrictEqual(configuration, this.configuration) + && isDeepStrictEqual(conversation.slice(0, this.conversation.length), this.conversation) + && conversation.length === this.conversation.length + this.admitted.length + && results.length === this.admitted.length + && new Set(results.map((result) => result.toolCallId)).size === results.length + && this.admitted.every((call) => results.some((result) => result.toolCallId === call.id && result.toolName === call.name)); + } + + continueTurn(turn: ClaudeTurn, messages: Message[], results: ToolResultMessage[], signal: AbortSignal | undefined): void { removeLiveProcess(this); - - const unanswered = this.turn.getToolCalls().flatMap((toolCall) => { - const result = toolResults.find((toolResult) => toolResult.toolCallId === toolCall.id); - return result ? [{ toolCall, result }] : []; - }); - + const admitted = this.admitted; + this.admitted = []; + this.conversation = structuredClone(conversationForReuse(messages)); this.turn = turn; + this.sourceComplete = this.sourceCalls.length > 0; this.setSignal(signal); - - this.answerToolCalls(turn, unanswered).catch((error: unknown) => { - console.error("claude CLI tool results failed:", error); - turn.fail(START_FAILED_MESSAGE, "error"); - this.kill(); - }); + if (this.stopping) return; + for (const call of admitted) { + const result = results.find((candidate) => candidate.toolCallId === call.id)!; + this.bridge.resolveToolCall(call, result); + } + this.flushAdmissions(); } kill(): void { removeLiveProcess(this); + if (this.stopping || this.closed) return; + this.stopping = true; this.child.kill(); + this.killTimer = setTimeout(() => this.child.kill("SIGKILL"), 1000); + this.killTimer.unref(); + } + + fail(message: string): void { + // A parallel request can fail after the admitted slice was handed to + // Pi. Retain that failure for its continuation instead of silently + // replaying and hiding the compatibility error. + if (this.turn.finished && this.toolCallIds.length > 0) this.pendingError = message; + this.turn.fail(message, "error"); + this.kill(); + if (this.pendingError) addLiveProcess(this); + } + + consumeFailure(owner: symbol, turn: ClaudeTurn): boolean { + if (owner !== this.owner || !this.pendingError) return false; + const message = this.pendingError; + this.pendingError = undefined; + this.kill(); + turn.fail(message, "error"); + return true; + } + + /** Only authenticated MCP requests, never stdout intent alone, admit side effects. */ + flushAdmissions(): void { + if (this.stopping || this.turn.finished || !this.sourceComplete) return; + const pending = this.bridge.getPendingToolCalls(); + if (pending.length === 0) return; + const calls: ToolCall[] = []; + for (const call of pending) { + const intent = this.sourceCalls.find((candidate) => candidate.id === call.id); + if (!intent || intent.name !== call.name || !isDeepStrictEqual(intent.arguments, call.arguments)) { + this.fail("claude CLI compatibility error: MCP call does not match its streamed tool intent"); + return; + } + calls.push(intent); + } + this.sourceCalls = this.sourceCalls.filter((call) => !pending.some((admitted) => admitted.id === call.id)); + this.admitted = pending; + this.toolCallIds = pending.map((call) => call.id); + this.turn.admit(calls); + this.conversation.push(...structuredClone(conversationForReuse([this.turn.getMessage()]))); + addLiveProcess(this); } private readonly abort = () => { @@ -438,96 +508,72 @@ class ClaudeProcess implements LiveProcess { this.signal?.removeEventListener("abort", this.abort); this.signal = signal; signal?.addEventListener("abort", this.abort, { once: true }); - if (signal?.aborted) this.abort(); } private handleLine(line: string): void { + if (this.stopping) return; const event = parseCliLine(line); if (!event) return; - - // Output after a finished message means the CLI moved on without waiting for our tool results. - if (this.turn.finished) { - this.kill(); - return; - } - - this.turn.handle(event); - if (!this.turn.finished) return; - - const toolCalls = this.turn.getToolCalls(); - this.toolCallIds = toolCalls.map((toolCall) => toolCall.id); - - const reachesBridge = toolCalls.length > 0 && toolCalls.every((toolCall) => isValidToolCall(this.tools, toolCall)); - if (!reachesBridge) { - this.kill(); - return; - } - - addLiveProcess(this); - } - - private async answerToolCalls(turn: ClaudeTurn, unanswered: UnansweredToolCall[]): Promise { - while (unanswered.length > 0 && !turn.started) { - const pending = this.bridge.getPendingToolCalls(); - let answered = false; - - for (const call of pending) { - const index = unanswered.findIndex(({ toolCall }) => isSameToolCall(toolCall, call)); - const match = unanswered[index]; - if (!match) continue; - - this.bridge.resolveToolCall(call, match.result); - unanswered.splice(index, 1); - answered = true; + if (event.type === "toolResults") { + if (event.ids.some((id) => this.sourceCalls.some((call) => call.id === id))) { + this.fail("claude CLI refused a harness tool call before MCP admission"); } - - if (answered) continue; - - // Rejects only once the bridge has closed, which means the process is already gone. - const next = await this.bridge.waitForToolCalls(pending.length + 1).catch(() => null); - if (!next) return; + return; + } + // Moving on before held calls were answered means the CLI did not + // honor this tool wait. Never execute further stdout-only intentions. + if (this.turn.finished) { + this.fail("claude CLI moved on while harness tool calls were waiting for results"); + return; + } + if (event.type === "messageStart" && this.sourceComplete && this.sourceCalls.length > 0) { + this.fail("claude CLI moved on without admitting its harness tool calls"); + return; + } + this.turn.handle(event); + if (this.turn.finished) { this.kill(); return; } + if (event.type === "messageStop" && this.turn.waitingForAdmission) { + this.sourceCalls = this.turn.getIntents(); + if (this.sourceCalls.length === 0 || new Set(this.sourceCalls.map((call) => call.id)).size !== this.sourceCalls.length) { + this.fail("claude CLI emitted an invalid tool batch"); + return; + } + this.sourceComplete = true; + this.flushAdmissions(); } } private handleClose(message: string): void { if (this.closed) return; - this.closed = true; + clearTimeout(this.killTimer); removeLiveProcess(this); + if (this.pendingError) addLiveProcess(this); this.signal?.removeEventListener("abort", this.abort); - if (this.stderr.trim()) console.error("claude CLI stderr:", this.stderr.trim()); - this.turn.fail(message, "error"); - removeTurnResources(this.bridge, this.files).catch((error: unknown) => { console.error("claude CLI cleanup failed:", error); }); } } -function continueLiveProcess(turn: ClaudeTurn, messages: Message[], signal: AbortSignal | undefined): boolean { +function continueLiveProcess(turn: ClaudeTurn, owner: symbol, configuration: unknown, messages: Message[], signal: AbortSignal | undefined): boolean { const assistantIndex = messages.findLastIndex((message) => message.role === "assistant"); const assistant = messages[assistantIndex]; if (assistant?.role !== "assistant") return false; - - const toolCallIds = assistant.content.flatMap((block) => (block.type === "toolCall" ? [block.id] : [])); - const process = getLiveProcess(toolCallIds); + const ids = assistant.content.flatMap((block) => block.type === "toolCall" ? [block.id] : []); + const process = getLiveProcess(ids, owner); if (!(process instanceof ClaudeProcess)) return false; - + if (process.consumeFailure(owner, turn)) return true; const trailing = messages.slice(assistantIndex + 1); - const toolResults = trailing.filter((message) => message.role === "toolResult"); - - const onlyToolResults = toolResults.length === trailing.length; - const answersEveryCall = toolCallIds.every((id) => toolResults.some((toolResult) => toolResult.toolCallId === id)); - - if (!onlyToolResults || !answersEveryCall) { + const results = trailing.filter((message) => message.role === "toolResult"); + if (trailing.length !== results.length || !process.canContinue(owner, configuration, messages, results)) { process.kill(); return false; } - - process.continueTurn(turn, toolResults, signal); + process.continueTurn(turn, messages, results, signal); return true; } @@ -549,10 +595,28 @@ async function startProcess( model: Model, context: TranscriptContext, options: SimpleStreamOptions | undefined, + owner: symbol, + configuration: unknown, ): Promise { + if (options?.signal?.aborted) { turn.fail(ABORT_MESSAGE, "aborted"); return; } const input = toClaudeInput(context.messages); - const bridge = await createToolBridge(input.tools); - const files = await createTurnFiles(input.systemPrompt, bridge.mcpConfig); + let cliProcess: ClaudeProcess | undefined; + const bridge = await createToolBridge(input.tools, { + onCall: () => cliProcess?.flushAdmissions(), + onError: (error) => cliProcess?.fail(error.message), + }); + let files: TurnFiles; + try { + files = await createTurnFiles(input.systemPrompt, bridge.mcpConfig); + } catch (error) { + await bridge.close(); + throw error; + } + if (options?.signal?.aborted) { + turn.fail(ABORT_MESSAGE, "aborted"); + await removeTurnResources(bridge, files); + return; + } let child: ChildProcessWithoutNullStreams | null; try { @@ -575,17 +639,20 @@ async function startProcess( return; } - new ClaudeProcess(child, bridge, files, input.tools, turn).start(toStreamJsonLine(input.userContent), options?.signal); + cliProcess = new ClaudeProcess(child, bridge, files, turn, owner, configuration, structuredClone(conversationForReuse(context.messages))); + cliProcess.start(toStreamJsonLine(input.userContent), options?.signal); } export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunction { + const owner = Symbol("claude-code stream owner"); return (model, context, options) => { const stream = createAssistantMessageEventStream(); const turn = new ClaudeTurn(stream, model); - if (continueLiveProcess(turn, context.messages, options?.signal)) return stream; + const configuration = configurationForReuse(model, context, toEffort(options?.reasoning)); + if (continueLiveProcess(turn, owner, configuration, context.messages, options?.signal)) return stream; - startProcess(runClaude, turn, model, context, options).catch((error: unknown) => { + startProcess(runClaude, turn, model, context, options, owner, configuration).catch((error: unknown) => { console.error("claude CLI turn failed:", error); turn.fail(START_FAILED_MESSAGE, "error"); }); diff --git a/src/agent/claude-code/tool-bridge.ts b/src/agent/claude-code/tool-bridge.ts index 92e2aad..db9f4da 100644 --- a/src/agent/claude-code/tool-bridge.ts +++ b/src/agent/claude-code/tool-bridge.ts @@ -7,7 +7,9 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { CallToolRequestSchema, + ErrorCode, ListToolsRequestSchema, + McpError, type CallToolResult, type Tool as McpTool, } from "@modelcontextprotocol/sdk/types.js"; @@ -18,7 +20,11 @@ const MCP_TOOL_PREFIX = `mcp__${BRIDGE_SERVER_NAME}__`; const LOOPBACK_HOST = "127.0.0.1"; const TOKEN_BYTES = 32; -export type PendingToolCall = { name: string; arguments: JsonObject }; +export type PendingToolCall = { id: string; name: string; arguments: JsonObject }; + +type BridgeCallbacks = { onCall(): void; onError(error: Error): void }; + +export const MCP_IDENTITY_ERROR = "claude CLI compatibility error: MCP calls must include a unique claudecode/toolUseId"; export type ToolBridge = { mcpConfig: string; @@ -104,14 +110,28 @@ class ToolCallQueue { // Low-level Server on purpose: McpServer.registerTool only takes zod schemas, and pi tools carry // JSON Schema that must reach the CLI unchanged. -function createMcpServer(tools: Tool[], queue: ToolCallQueue): Server { +function createMcpServer(tools: Tool[], queue: ToolCallQueue, callbacks?: BridgeCallbacks): Server { const server = new Server({ name: BRIDGE_SERVER_NAME, version: "1.0.0" }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: tools.map(toMcpTool) })); + const seenIds = new Set(); server.setRequestHandler(CallToolRequestSchema, (request) => { - const call = { name: request.params.name, arguments: (request.params.arguments ?? {}) as JsonObject }; - return queue.add(call); + const id = request.params._meta?.["claudecode/toolUseId"]; + if (typeof id !== "string" || !id.trim() || seenIds.has(id)) { + const error = new McpError(ErrorCode.InvalidParams, MCP_IDENTITY_ERROR); + callbacks?.onError(error); + throw error; + } + if (!tools.some((tool) => tool.name === request.params.name)) { + const error = new McpError(ErrorCode.InvalidParams, "claude CLI requested an undeclared harness tool"); + callbacks?.onError(error); + throw error; + } + seenIds.add(id); + const result = queue.add({ id, name: request.params.name, arguments: (request.params.arguments ?? {}) as JsonObject }); + callbacks?.onCall(); + return result; }); return server; @@ -167,16 +187,22 @@ function toMcpConfig(port: number, authorization: string): string { return JSON.stringify({ mcpServers: { [BRIDGE_SERVER_NAME]: server } }); } -export async function createToolBridge(tools: Tool[]): Promise { +export async function createToolBridge(tools: Tool[], callbacks?: BridgeCallbacks): Promise { const authorization = `Bearer ${randomBytes(TOKEN_BYTES).toString("hex")}`; const queue = new ToolCallQueue(); - const mcpServer = createMcpServer(tools, queue); + const mcpServer = createMcpServer(tools, queue, callbacks); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); await mcpServer.connect(transport); const httpServer = createHttpServer(transport, authorization); - const { port } = await listen(httpServer); + let port: number; + try { + ({ port } = await listen(httpServer)); + } catch (error) { + await mcpServer.close(); + throw error; + } return { mcpConfig: toMcpConfig(port, authorization), diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts index 6fe6ac1..cfdab68 100644 --- a/src/agent/teacher-agent.ts +++ b/src/agent/teacher-agent.ts @@ -17,6 +17,7 @@ import { import { practiceTools, type PracticeToolContext } from "./tools/practice.ts"; import { healToolRecord, withArgumentHeal } from "./tools/heal.ts"; import { sendToUserTool } from "./tools/send-to-user.ts"; +import { CLAUDE_CODE_PROVIDER } from "./claude-code/constants.ts"; import { quizInputObject, quizInputProblems } from "../core/question-core.ts"; import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts"; import { toLlmMessages, nativeInputCause, PRACTICE_PROBLEM_TOOL } from "../core/native-messages.ts"; @@ -202,7 +203,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent // transport. Explicit test registries remain caller-owned. tools: [ ...tools, - ...(opts.tools === undefined && engine.model.id.toLowerCase().includes("claude") + ...(opts.tools === undefined && (engine.model.provider === CLAUDE_CODE_PROVIDER || engine.model.id.toLowerCase().includes("claude")) ? [withArgumentHeal(sendToUserTool)] : []), ], diff --git a/tests/claude-code-binary.test.ts b/tests/claude-code-binary.test.ts index 66a716a..599f5d9 100644 --- a/tests/claude-code-binary.test.ts +++ b/tests/claude-code-binary.test.ts @@ -39,15 +39,15 @@ test("npm global bin follows npm_config_prefix when set", () => { assert.equal(binary, "/opt/npm/bin/claude"); }); -test("windows looks for claude.cmd and claude.exe", () => { +test("windows finds native claude.exe and does not advertise unsupported .cmd-only installs", () => { const winHome = "C:\\Users\\u"; const env = { PATH: "C:\\tools;C:\\bin", APPDATA: `${winHome}\\AppData\\Roaming` }; - assert.equal(resolveWith(["C:\\bin\\claude.cmd"], env, "win32", winHome), "C:\\bin\\claude.cmd"); + assert.equal(resolveWith(["C:\\bin\\claude.cmd"], env, "win32", winHome), null); assert.equal(resolveWith(["C:\\bin\\claude.exe"], env, "win32", winHome), "C:\\bin\\claude.exe"); assert.equal(resolveWith([`${winHome}\\.local\\bin\\claude.exe`], env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`); assert.equal( resolveWith([`${winHome}\\AppData\\Roaming\\npm\\claude.cmd`], env, "win32", winHome), - `${winHome}\\AppData\\Roaming\\npm\\claude.cmd`, + null, ); assert.equal(resolveWith(["C:\\bin\\claude"], env, "win32", winHome), null); }); diff --git a/tests/claude-code-harness.test.ts b/tests/claude-code-harness.test.ts index 1dcd8c2..d300b35 100644 --- a/tests/claude-code-harness.test.ts +++ b/tests/claude-code-harness.test.ts @@ -55,7 +55,7 @@ function toAskScenario(): string[] { streamEvent({ type: "content_block_stop", index: 0 }), streamEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }), streamEvent({ type: "message_stop" }), - JSON.stringify({ fake: "call", name: "ask_user_question", arguments: ASK_INPUT }), + JSON.stringify({ fake: "call", id: ASK_ID, name: "ask_user_question", arguments: ASK_INPUT }), JSON.stringify({ fake: "reply" }), ]; } @@ -168,6 +168,34 @@ test("a question answered before the idle timeout continues the waiting CLI proc await waitForExit(waiting); }); +test("a question pauses later calls from the same CLI response until the learner answers", async (t) => { + const secondId = "toolu_second_question"; + const secondQuestion = "What do you want to learn next?"; + const scenario = toAskScenario(); + scenario.splice(4, 0, + streamEvent({ type: "content_block_start", index: 1, content_block: { type: "tool_use", id: secondId, name: "mcp__learnharness__ask_user_question", input: {} } }), + streamEvent({ type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: JSON.stringify({ question: secondQuestion }) } }), + streamEvent({ type: "content_block_stop", index: 1 }), + ); + scenario.splice(scenario.length - 1, 0, JSON.stringify({ fake: "call", id: secondId, name: "ask_user_question", arguments: { question: secondQuestion } })); + const fakeRun = createFakeRun(t, [scenario]); + const factory = createAgentFactory(fakeRun); + const chatId = createClaudeCodeChat(t); + await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, factory); + assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, ASK_ID); + assert.equal(JSON.stringify(chatStore.getChat(chatId)?.messages).includes(secondId), false, "unadmitted second question must not be posed or persisted"); + await runChatTurn(chatId, toAnswerInput(), factory); + assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, secondId); + await runChatTurn(chatId, { + type: "answer", toolCallId: secondId, + answer: { kind: "ask", questionText: secondQuestion, dontKnow: false, answers: [], text: "HTTP next", answeredAt: new Date().toISOString() }, + }, factory); + assert.equal(fakeRun.children.length, 1); + assert.ok(getLastAssistantText(chatId).includes(ANSWER_TEXT)); + assert.ok(getLastAssistantText(chatId).includes("HTTP next")); + await waitForExit(fakeRun.children[0]!); +}); + test("a question answered after the idle timeout starts a fresh CLI process that replays the question and answer", async (t) => { liveProcessIdleTimeoutMs.current = 300; t.after(() => (liveProcessIdleTimeoutMs.current = LIVE_PROCESS_IDLE_TIMEOUT_MS)); diff --git a/tests/claude-code-stream.test.ts b/tests/claude-code-stream.test.ts index d5e48b3..efdd322 100644 --- a/tests/claude-code-stream.test.ts +++ b/tests/claude-code-stream.test.ts @@ -29,6 +29,8 @@ import type { ToolResultMessage, } from "@earendil-works/pi-ai"; import { normalizeContext } from "@earendil-works/pi-ai/utils/transcript"; +import { Agent } from "@earendil-works/pi-agent-core"; +import { Type } from "typebox"; import { parseCliLine } from "../src/agent/claude-code/cli-events.ts"; import { ClaudeCodeError } from "../src/agent/claude-code/constants.ts"; @@ -83,7 +85,8 @@ function exitWith(code: number, stderr: string): string { function getToolScenario(): string[] { const lines = readFixtureLines("tool.jsonl"); const firstMessageStop = lines.findIndex((line) => line.includes('"type":"message_stop"')); - return [...lines.slice(0, firstMessageStop + 1).filter((line) => !line.startsWith('{"type":"user"')), HOLD]; + return [...lines.slice(0, firstMessageStop + 1).filter((line) => !line.startsWith('{"type":"user"')), + callTool({ id: "toolu_01JBB4cJsPa585FWb1XrfbNX", name: "get_weather", arguments: { city: "Paris" } }), HOLD]; } function createFakeRun( @@ -104,9 +107,9 @@ function createFakeRun( const child = spawn(process.execPath, [FAKE_CLAUDE, ...args], { cwd: options.cwd, env: { ...options.env, FAKE_CLAUDE_SCENARIO: scenarioFile, ...extraEnv }, - stdio: "pipe", - }); - t.after(() => child.kill()); + stdio: ["pipe", "pipe", "pipe", "ipc"], + }) as ChildProcessWithoutNullStreams; + t.after(() => child.kill("SIGKILL")); calls.push({ args, cwd: options.cwd, @@ -151,7 +154,7 @@ async function runScenario( const context = normalizeContext({ systemPrompt: "You are a tutor.", messages: [{ role: "user", content: "hi", timestamp: 0 }], - tools: [READ_TOOL], + tools: [READ_TOOL, { name: "get_weather", description: "Weather", parameters: { type: "object", properties: {} } }], }); const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(model, context, options)); @@ -180,7 +183,7 @@ function toToolUseMessage(toolUses: ToolUse[]): string[] { } function callTool(toolUse: ToolUse): string { - return JSON.stringify({ fake: "call", name: toolUse.name, arguments: toolUse.arguments }); + return JSON.stringify({ fake: "call", id: toolUse.id, name: toolUse.name, arguments: toolUse.arguments }); } function toToolResult(toolUse: ToolUse, text: string): ToolResultMessage { @@ -368,7 +371,7 @@ test("tool scenario ends with toolcall_end (Claude's tool_use id, unprefixed nam assert.deepEqual( events.map((event) => event.type), - ["start", "toolcall_start", "toolcall_delta", "toolcall_delta", "toolcall_delta", "toolcall_end", "done"], + ["start", "toolcall_start", "toolcall_end", "done"], ); const toolCallEnd = events.find((event) => event.type === "toolcall_end"); @@ -386,7 +389,7 @@ test("tool scenario ends with toolcall_end (Claude's tool_use id, unprefixed nam assert.deepEqual(done.message.content, [toolCallEnd.toolCall]); }); -test("unknown tool name and bad JSON args still surface as tool calls", async (t) => { +test("unsupported native tool intent fails without exposing executable calls", async (t) => { const { events } = await runScenario(t, [ streamEvent({ type: "message_start", message: { usage: {} } }), streamEvent({ @@ -404,21 +407,8 @@ test("unknown tool name and bad JSON args still surface as tool calls", async (t HOLD, ]); - const toolCalls = events.flatMap((event) => (event.type === "toolcall_end" ? [event.toolCall] : [])); - assert.deepEqual( - toolCalls.map((call) => ({ id: call.id, name: call.name, arguments: call.arguments })), - [ - { id: "toolu_a", name: "nope", arguments: {} }, - { id: "toolu_b", name: "Bash", arguments: { command: "ls" } }, - ], - ); - - assert.match(toolCalls[0]?.argumentsError ?? "", /\{"path": /); - assert.equal(toolCalls[1]?.argumentsError, undefined); - - const done = getLastEvent(events); - assert.ok(done.type === "done"); - assert.equal(done.reason, "toolUse"); + assert.equal(events.some((event) => event.type === "toolcall_end"), false); + assert.match(getErrorMessage(events) ?? "", /unsupported native tool/); }); test("usage maps input/output/cacheRead/cacheWrite with zero cost, message_delta overriding message_start", async (t) => { @@ -585,7 +575,7 @@ test("spawns with the model alias, effort, bridge tools, MCP_TOOL_TIMEOUT and th const call = getOnlyCall(calls); assert.equal(getFlagValue(call.args, "--model"), "opus"); assert.equal(getFlagValue(call.args, "--effort"), "xhigh"); - assert.equal(getFlagValue(call.args, "--allowedTools"), "mcp__learnharness__read"); + assert.equal(getFlagValue(call.args, "--allowedTools"), "mcp__learnharness__read,mcp__learnharness__get_weather"); assert.equal(call.env.MCP_TOOL_TIMEOUT, "1800000"); assert.equal(call.env.HOME, process.env.HOME); assert.equal(call.env.CLAUDE_CONFIG_DIR, process.env.CLAUDE_CONFIG_DIR); @@ -641,27 +631,35 @@ test("trailing toolResults matching a live process continue it: held calls answe await assertCleanedUp(getOnlyCall(calls)); }); -test("multiple tool calls in one message are all answered by id", async (t) => { +test("sequential identical calls split one response and correlate results by id, not arguments", async (t) => { const readA: ToolUse = { id: "toolu_many_a", name: "read", arguments: { path: "a" } }; - const readB: ToolUse = { id: "toolu_many_b", name: "read", arguments: { path: "b" } }; - const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA, readB]), callTool(readB), callTool(readA), REPLY]); + const readB: ToolUse = { id: "toolu_many_b", name: "read", arguments: { path: "a" } }; + const source = toToolUseMessage([readA, readB]); + source[0] = streamEvent({ type: "message_start", message: { usage: { input_tokens: 7, output_tokens: 2 } } }); + const { runClaude, calls } = createFakeRun(t, [...source, callTool(readB), callTool(readA), REPLY]); const streamFunction = createClaudeCodeStreamFunction(runClaude); const toolMessage = await runTurn(streamFunction, [HI]); - const reply = await runTurn(streamFunction, [HI, toolMessage, toToolResult(readA, "A"), toToolResult(readB, "B")]); + assert.deepEqual(toolMessage.content.filter((block) => block.type === "toolCall").map((call) => call.id), [readB.id]); + const history = [HI, toolMessage, toToolResult(readB, "B")]; + const second = await runTurn(streamFunction, history); + assert.deepEqual(second.content.filter((block) => block.type === "toolCall").map((call) => call.id), [readA.id]); + const reply = await runTurn(streamFunction, [...history, second, toToolResult(readA, "A")]); assert.equal(getText(reply), "B | A"); + assert.equal(toolMessage.usage.totalTokens, 9); + assert.equal(second.usage.totalTokens, 0, "splitting one response must not charge its tokens twice"); assert.equal(calls.length, 1); }); -test("partial id match starts fresh and kills the old process", async (t) => { +test("mismatched result id starts fresh and kills the old process", async (t) => { const readA: ToolUse = { id: "toolu_partial_a", name: "read", arguments: { path: "a" } }; const readB: ToolUse = { id: "toolu_partial_b", name: "read", arguments: { path: "b" } }; const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA, readB]), callTool(readA), callTool(readB), REPLY]); const streamFunction = createClaudeCodeStreamFunction(runClaude); const toolMessage = await runTurn(streamFunction, [HI]); - await runTurn(streamFunction, [HI, toolMessage, toToolResult(readA, "A")]); + await runTurn(streamFunction, [HI, toolMessage, toToolResult(readB, "B")]); assert.equal(calls.length, 2); const [oldCall] = calls; @@ -695,24 +693,23 @@ test("user message after tool results starts fresh with unfinished calls in the test("output while waiting for tool results kills the process", async (t) => { const readA: ToolUse = { id: "toolu_moved_on_a", name: "read", arguments: { path: "a" } }; const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA]), REPLY, HOLD]); - - await runTurn(createClaudeCodeStreamFunction(runClaude), [HI]); - + const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(getModel(), normalizeContext({ messages: [HI], tools: [READ_TOOL] }))); + assert.match(getErrorMessage(events) ?? "", /moved on without admitting/); + assert.equal(events.some((event) => event.type === "toolcall_end"), false); await assertCleanedUp(getOnlyCall(calls)); }); -test("an undeclared tool call is not kept waiting, since the CLI answers it itself", async (t) => { +test("unadmitted tool intent never becomes an executable call", async (t) => { const readA: ToolUse = { id: "toolu_undeclared_a", name: "read", arguments: { path: "a" } }; const nope: ToolUse = { id: "toolu_undeclared_nope", name: "nope", arguments: {} }; - const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA, nope]), HOLD]); - - const toolMessage = await runTurn(createClaudeCodeStreamFunction(runClaude), [HI]); - - assert.equal(toolMessage.stopReason, "toolUse"); + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA, nope]), exitWith(1, "tool denied")]); + const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(getModel(), normalizeContext({ messages: [HI], tools: [READ_TOOL] }))); + assert.equal(events.some((event) => event.type === "toolcall_end"), false); + assert.match(getErrorMessage(events) ?? "", /exited with code 1/); await assertCleanedUp(getOnlyCall(calls)); }); -test("a tool call with invalid JSON arguments is not kept waiting, since the CLI answers it itself", async (t) => { +test("a CLI-rejected malformed call is not exposed for execution", async (t) => { const { runClaude, calls } = createFakeRun(t, [ streamEvent({ type: "message_start", message: { usage: {} } }), streamEvent({ @@ -724,12 +721,13 @@ test("a tool call with invalid JSON arguments is not kept waiting, since the CLI streamEvent({ type: "content_block_stop", index: 0 }), streamEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }), streamEvent({ type: "message_stop" }), + JSON.stringify({ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "toolu_bad_json", is_error: true, content: "invalid arguments" }] } }), HOLD, ]); - const toolMessage = await runTurn(createClaudeCodeStreamFunction(runClaude), [HI]); - - assert.equal(toolMessage.stopReason, "toolUse"); + const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(getModel(), normalizeContext({ messages: [HI], tools: [READ_TOOL] }))); + assert.equal(events.some((event) => event.type === "toolcall_end"), false); + assert.match(getErrorMessage(events) ?? "", /refused.*before MCP admission/); await assertCleanedUp(getOnlyCall(calls)); }); @@ -805,9 +803,140 @@ test("a tool result arriving late but before the idle timeout still continues th await assertCleanedUp(getOnlyCall(calls)); }); -test("two concurrent streams keep separate processes and results never cross", async (t) => { +test("CLI denial cannot execute a tool in the native Pi loop", async (t) => { + const read: ToolUse = { id: "toolu_denied", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([read]), JSON.stringify({ type: "user", message: { content: [ + { type: "tool_result", tool_use_id: read.id, is_error: true, content: "Denied by managed policy" }, + ] } }), HOLD]); + let executed = 0; + const agent = new Agent({ initialState: { model: getModel(), tools: [{ + name: "read", label: "Read", description: "Read", parameters: Type.Object({ path: Type.String() }), + execute: async () => { executed++; return { content: [{ type: "text", text: "must not run" }], details: null }; }, + }] }, streamFn: createClaudeCodeStreamFunction(runClaude) }); + await agent.prompt("hi"); + assert.equal(executed, 0); + const last = agent.state.messages.at(-1); + assert.ok(last?.role === "assistant"); + assert.equal(last.stopReason, "error"); + assert.match(last.errorMessage ?? "", /refused.*before MCP admission/); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("pre-aborted requests do not spawn a process", async () => { + const controller = new AbortController(); + controller.abort(); + let spawned = false; + const stream = createClaudeCodeStreamFunction(() => { spawned = true; return null; }); + const events = await collectEvents(stream(getModel(), normalizeContext({ messages: [HI] }), { signal: controller.signal })); + assert.equal(spawned, false); + assert.match(getErrorMessage(events) ?? "", /aborted/); +}); + +test("missing MCP identity fails closed without emitting executable calls", async (t) => { + const read: ToolUse = { id: "toolu_no_meta", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([read]), JSON.stringify({ fake: "call", name: read.name, arguments: read.arguments })]); + const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(getModel(), normalizeContext({ messages: [HI], tools: [READ_TOOL] }))); + assert.equal(events.some((event) => event.type === "toolcall_end"), false); + assert.match(getErrorMessage(events) ?? "", /compatibility error.*claudecode\/toolUseId/); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("a parallel identity failure after an admitted slice is reported on continuation, not hidden by replay", async (t) => { + const first: ToolUse = { id: "toolu_good_parallel", name: "read", arguments: { path: "a" } }; + const second: ToolUse = { id: "toolu_bad_parallel", name: "read", arguments: { path: "b" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([first, second]), + JSON.stringify({ fake: "start-call", id: first.id, name: first.name, arguments: first.arguments }), + JSON.stringify({ fake: "wait-for-message" }), + JSON.stringify({ fake: "call", name: second.name, arguments: second.arguments }), HOLD]); + const stream = createClaudeCodeStreamFunction(runClaude); + const message = await runTurn(stream, [HI]); + getOnlyCall(calls).child.send("continue"); + await waitForExit(getOnlyCall(calls).child); + const events = await collectEvents(stream(getModel(), normalizeContext({ systemPrompt: "You are a tutor.", messages: [HI, message, toToolResult(first, "A")], tools: [READ_TOOL] }))); + assert.match(getErrorMessage(events) ?? "", /compatibility error.*claudecode\/toolUseId/); + assert.equal(calls.length, 1, "must not hide the compatibility failure by spawning again"); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("output after MCP admission fails continuation instead of silently replaying", async (t) => { + const call: ToolUse = { id: "toolu_moved_on", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([call]), + JSON.stringify({ fake: "start-call", id: call.id, name: call.name, arguments: call.arguments }), + JSON.stringify({ fake: "wait-for-message" }), ...toToolUseMessage([call]), HOLD]); + const stream = createClaudeCodeStreamFunction(runClaude); + const message = await runTurn(stream, [HI]); + getOnlyCall(calls).child.send("continue"); + await waitForExit(getOnlyCall(calls).child); + const events = await collectEvents(stream(getModel(), normalizeContext({ systemPrompt: "You are a tutor.", messages: [HI, message, toToolResult(call, "A")], tools: [READ_TOOL] }))); + assert.match(getErrorMessage(events) ?? "", /moved on while.*waiting for results/); + assert.equal(calls.length, 1); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("normal completion escalates termination when the child ignores SIGTERM", async (t) => { + const { events, calls } = await runScenario(t, [JSON.stringify({ fake: "ignore-term" }), ...readFixtureLines("text.jsonl"), HOLD]); + assert.equal(getLastEvent(events).type, "done"); + await assertCleanedUp(getOnlyCall(calls)); + assert.equal(getOnlyCall(calls).child.signalCode, "SIGKILL"); +}); + +test("a synchronous spawn failure removes both temp directories and the bridge", async () => { + let resources: { cwd: string; privateDir: string; url: string } | undefined; + const stream = createClaudeCodeStreamFunction((args, options) => { + const config = JSON.parse(readFileSync(getFlagValue(args, "--mcp-config"), "utf8")); + resources = { cwd: options.cwd, privateDir: dirname(getFlagValue(args, "--mcp-config")), url: config.mcpServers.learnharness.url }; + throw new Error("synthetic spawn failure"); + }); + const events = await collectEvents(stream(getModel(), normalizeContext({ messages: [HI] }))); + assert.match(getErrorMessage(events) ?? "", /failed to start/); + assert.ok(resources); + assert.equal(existsSync(resources.cwd), false); + assert.equal(existsSync(resources.privateDir), false); + await assert.rejects(fetch(resources.url)); +}); + +test("a malformed JSON event shape fails the turn rather than escaping the readline handler", async (t) => { + const { events, calls } = await runScenario(t, [JSON.stringify({ type: "assistant", error: "bad", message: { content: [null] } }), HOLD]); + assert.match(getErrorMessage(events) ?? "", /malformed stream output/); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("a mismatched MCP identity cannot admit a streamed call", async (t) => { + const read: ToolUse = { id: "toolu_intent", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([read]), callTool({ ...read, id: "toolu_someone_else" })]); + const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(getModel(), normalizeContext({ messages: [HI], tools: [READ_TOOL] }))); + assert.equal(events.some((event) => event.type === "toolcall_end"), false); + assert.match(getErrorMessage(events) ?? "", /does not match its streamed tool intent/); + await assertCleanedUp(getOnlyCall(calls)); +}); + +for (const change of ["model", "effort", "system prompt", "tool schema", "tool description", "history"] as const) { + test(`a changed ${change} prevents reuse of a held process`, async (t) => { + const read: ToolUse = { id: `toolu_reuse_${change}`, name: "read", arguments: { path: "a" } }; + const waiting = createFakeRun(t, [...toToolUseMessage([read]), callTool(read), REPLY]); + const fresh = createFakeRun(t, readFixtureLines("text.jsonl")); + const stream = createClaudeCodeStreamFunction((args, options) => (waiting.calls.length === 0 ? waiting : fresh).runClaude(args, options)); + const message = await runTurn(stream, [HI]); + const messages = [change === "history" ? { ...HI, content: "replacement history after compaction" } : HI, message, toToolResult(read, "A")]; + const context = normalizeContext({ + systemPrompt: change === "system prompt" ? "Changed tutor instructions" : "You are a tutor.", + messages, + tools: [{ ...READ_TOOL, + ...(change === "tool schema" ? { parameters: { type: "object", properties: { path: { type: "string" } } } } : {}), + ...(change === "tool description" ? { description: "Changed description" } : {}), + }], + }); + const model = change === "model" ? CLAUDE_CODE_MODELS[1]! : getModel(); + const events = await collectEvents(stream(model, context, change === "effort" ? { reasoning: "high" } : undefined)); + assert.equal(getLastEvent(events).type, "done"); + assert.equal(fresh.calls.length, 1, "must start a fresh CLI rather than silently use stale configuration"); + await assertCleanedUp(getOnlyCall(waiting.calls)); + }); +} + +for (const sharedIdentity of [false, true]) test(`two concurrent stream owners keep separate processes${sharedIdentity ? " even with identical call IDs and arguments" : ""}`, async (t) => { const readOne: ToolUse = { id: "toolu_concurrent_one", name: "read", arguments: { path: "one" } }; - const readTwo: ToolUse = { id: "toolu_concurrent_two", name: "read", arguments: { path: "two" } }; + const readTwo: ToolUse = sharedIdentity ? { ...readOne } : { id: "toolu_concurrent_two", name: "read", arguments: { path: "two" } }; const one = createFakeRun(t, [...toToolUseMessage([readOne]), callTool(readOne), REPLY]); const two = createFakeRun(t, [...toToolUseMessage([readTwo]), callTool(readTwo), REPLY]); diff --git a/tests/claude-code-tool-bridge.test.ts b/tests/claude-code-tool-bridge.test.ts index 3d18881..9b44dfb 100644 --- a/tests/claude-code-tool-bridge.test.ts +++ b/tests/claude-code-tool-bridge.test.ts @@ -107,13 +107,13 @@ test("tools/call stays pending until resolved and returns its text and image con t.after(() => client.close()); let settled = false; - const pending = client.callTool({ name: "read", arguments: { path: "notes.md" } }).finally(() => { + const pending = client.callTool({ name: "read", arguments: { path: "notes.md" }, _meta: { "claudecode/toolUseId": "call-1" } }).finally(() => { settled = true; }); const [call] = await bridge.waitForToolCalls(1); assert.ok(call); - assert.deepEqual(call, { name: "read", arguments: { path: "notes.md" } }); + assert.deepEqual(call, { id: "call-1", name: "read", arguments: { path: "notes.md" } }); assert.deepEqual(bridge.getPendingToolCalls(), [call]); await new Promise((resolve) => setTimeout(resolve, 20)); @@ -147,15 +147,15 @@ test("waitForToolCalls resolves once that many calls are pending, in arrival ord waited = true; }); - void client.callTool({ name: "read", arguments: { path: "a.md" } }).catch(() => {}); + void client.callTool({ name: "read", arguments: { path: "a.md" }, _meta: { "claudecode/toolUseId": "call-1" } }).catch(() => {}); await new Promise((resolve) => setTimeout(resolve, 20)); assert.equal(waited, false); - void client.callTool({ name: "screenshot", arguments: {} }).catch(() => {}); + void client.callTool({ name: "screenshot", arguments: {}, _meta: { "claudecode/toolUseId": "call-2" } }).catch(() => {}); assert.deepEqual(await waiting, [ - { name: "read", arguments: { path: "a.md" } }, - { name: "screenshot", arguments: {} }, + { id: "call-1", name: "read", arguments: { path: "a.md" } }, + { id: "call-2", name: "screenshot", arguments: {} }, ]); }); @@ -165,7 +165,7 @@ test("isError result becomes MCP isError true", async (t) => { const client = await connectClient(bridge); t.after(() => client.close()); - const pending = client.callTool({ name: "read", arguments: { path: "missing.md" } }); + const pending = client.callTool({ name: "read", arguments: { path: "missing.md" }, _meta: { "claudecode/toolUseId": "call-1" } }); const [call] = await bridge.waitForToolCalls(1); assert.ok(call); @@ -180,7 +180,7 @@ test("close fails pending calls and frees the port", async () => { const bridge = await createToolBridge([READ_TOOL]); const port = Number(new URL(getServerConfig(bridge).url).port); const client = await connectClient(bridge); - const pending = client.callTool({ name: "read", arguments: { path: "notes.md" } }); + const pending = client.callTool({ name: "read", arguments: { path: "notes.md" }, _meta: { "claudecode/toolUseId": "call-1" } }); await bridge.waitForToolCalls(1); await bridge.close(); @@ -219,6 +219,25 @@ test('mcpConfig is {"mcpServers":{"learnharness":{"type":"http","url":...,"heade assert.match(server.headers.Authorization, /^Bearer [0-9a-f]{64}$/); }); +test("missing and reused Claude tool IDs are rejected rather than matched by arguments", async (t) => { + const failures: string[] = []; + const bridge = await createToolBridge([READ_TOOL], { onCall: () => {}, onError: (error) => failures.push(error.message) }); + t.after(() => bridge.close()); + const client = await connectClient(bridge); + t.after(() => client.close()); + await assert.rejects(client.callTool({ name: "read", arguments: { path: "a" } }), /claudecode\/toolUseId/); + assert.deepEqual(bridge.getPendingToolCalls(), []); + const params = { name: "read", arguments: { path: "a" }, _meta: { "claudecode/toolUseId": "same-id" } }; + const first = client.callTool(params); + const [call] = await bridge.waitForToolCalls(1); + assert.ok(call); + bridge.resolveToolCall(call, toolResult([{ type: "text", text: "first" }])); + await first; + await assert.rejects(client.callTool(params), /claudecode\/toolUseId/); + assert.deepEqual(bridge.getPendingToolCalls(), []); + assert.equal(failures.length, 2); +}); + test("toolNameFromMcp strips the bridge prefix", () => { assert.equal(toolNameFromMcp("mcp__learnharness__read"), "read"); assert.equal(toolNameFromMcp("read"), "read"); diff --git a/tests/fixtures/claude-code/fake-claude.mjs b/tests/fixtures/claude-code/fake-claude.mjs index cb64d65..d1fef86 100644 --- a/tests/fixtures/claude-code/fake-claude.mjs +++ b/tests/fixtures/claude-code/fake-claude.mjs @@ -4,11 +4,15 @@ * line. Lines with a "fake" key are directives instead of output: * {"fake":"hold"} stay alive until killed, like the CLI waiting on a held MCP call * {"fake":"exit","code":1,"stderr":"..."} write stderr and exit with that code - * {"fake":"call","name":"read","arguments":{...}} call that tool on the MCP bridge and wait for its result + * {"fake":"call","id":"toolu_...","name":"read","arguments":{...}} call the MCP bridge and wait for its result + * {"fake":"start-call",...} start the same request without waiting for its result + * {"fake":"wait-for-message"} pause until the test releases the child over IPC + * {"fake":"ignore-term"} ignore SIGTERM to exercise kill escalation * {"fake":"reply"} stream one assistant message whose text is every result so far, joined by " | " * FAKE_CLAUDE_STDIN, when set, receives the stdin it read. */ import { readFileSync, writeFileSync } from "node:fs"; +import { once } from "node:events"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; @@ -49,10 +53,15 @@ if (process.env.FAKE_CLAUDE_STDIN) writeFileSync(process.env.FAKE_CLAUDE_STDIN, let bridge = null; const results = []; +const pendingResults = []; const lines = readFileSync(process.env.FAKE_CLAUDE_SCENARIO, "utf8").split("\n").filter(Boolean); for (const line of lines) { const directive = JSON.parse(line); + if (directive.fake === "wait-for-message") { + await once(process, "message"); + continue; + } if (directive.fake === "hold") { setInterval(() => {}, HOLD_INTERVAL_MS); break; @@ -63,14 +72,28 @@ for (const line of lines) { process.exit(directive.code); } - if (directive.fake === "call") { + if (directive.fake === "ignore-term") { + process.on("SIGTERM", () => {}); + continue; + } + if (directive.fake === "call" || directive.fake === "start-call") { bridge ??= await connectBridge(mcpConfig); - const result = await bridge.callTool({ name: directive.name, arguments: directive.arguments }); - results.push(result.content.map((block) => block.text).join("")); + const request = bridge.callTool({ + name: directive.name, + arguments: directive.arguments, + ...(directive.id !== undefined ? { _meta: { "claudecode/toolUseId": directive.id } } : {}), + }); + if (directive.fake === "start-call") { + pendingResults.push(request); + void request.catch(() => {}); + } else { + const result = await request; + results.push(result.content.map((block) => block.text).join("")); + } continue; - } if (directive.fake === "reply") { + for (const result of await Promise.all(pendingResults.splice(0))) results.push(result.content.map((block) => block.text).join("")); await writeReply(results.join(" | ")); continue; } diff --git a/tests/send-to-user.test.ts b/tests/send-to-user.test.ts index beaeae3..80109d4 100644 --- a/tests/send-to-user.test.ts +++ b/tests/send-to-user.test.ts @@ -88,6 +88,9 @@ describe("send_to_user model gate", () => { ["openrouter", "anthropic/claude-sonnet-5.5", true], ["amazon-bedrock", "us.anthropic.claude-opus-5-5-v1:0", true], ["fixture", "CLAUDE-fixture", true], + ["claude-code", "opus", true], + ["claude-code", "sonnet", true], + ["claude-code", "haiku", true], ["openrouter", "z-ai/glm-5.3-flash", false], ["openai", "gpt-4o-mini", false], ["anthropic", "opaque-alias", false], From 64a36bebd2ebd4b5806c7aeeea53b30aeab8415d Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 17:19:51 +0200 Subject: [PATCH 14/21] fix: scope held Claude processes and honor MCP cancellation --- src/agent/claude-code/live-processes.ts | 8 ++-- src/agent/claude-code/stream.ts | 34 +++++++++----- src/agent/claude-code/tool-bridge.ts | 29 +++++++++--- tests/claude-code-harness.test.ts | 42 +++++++++++++++-- tests/claude-code-stream.test.ts | 53 ++++++++++++++++++++-- tests/claude-code-tool-bridge.test.ts | 27 +++++++++++ tests/fixtures/claude-code/fake-claude.mjs | 36 ++++++++++++--- 7 files changed, 192 insertions(+), 37 deletions(-) diff --git a/src/agent/claude-code/live-processes.ts b/src/agent/claude-code/live-processes.ts index 1a68672..ebb729b 100644 --- a/src/agent/claude-code/live-processes.ts +++ b/src/agent/claude-code/live-processes.ts @@ -1,4 +1,4 @@ -export type LiveProcess = { readonly owner: symbol; toolCallIds: string[]; kill(): void }; +export type LiveProcess = { readonly owner: symbol; readonly sessionId: string | undefined; toolCallIds: string[]; kill(): void }; // Tools usually call back within seconds, but a question tool holds its call until the learner // answers. Past this, the process is killed and the answer starts a fresh one with a full replay. @@ -27,11 +27,11 @@ function hasSameIds(ids: string[], otherIds: string[]): boolean { return ids.length === otherIds.length && new Set(otherIds).size === otherIds.length && ids.every((id) => otherIds.includes(id)); } -export function getLiveProcess(toolCallIds: string[], owner: symbol): LiveProcess | null { - if (toolCallIds.length === 0) return null; +export function getLiveProcess(toolCallIds: string[], owner: symbol, sessionId: string | undefined): LiveProcess | null { + if (!sessionId || toolCallIds.length === 0) return null; for (const process of liveProcesses.keys()) { - if (process.owner === owner && hasSameIds(process.toolCallIds, toolCallIds)) return process; + if (process.owner === owner && process.sessionId === sessionId && hasSameIds(process.toolCallIds, toolCallIds)) return process; } return null; diff --git a/src/agent/claude-code/stream.ts b/src/agent/claude-code/stream.ts index cd27167..8c9c89a 100644 --- a/src/agent/claude-code/stream.ts +++ b/src/agent/claude-code/stream.ts @@ -392,6 +392,7 @@ class ClaudeProcess implements LiveProcess { private sourceCalls: ToolCall[] = []; private sourceComplete = false; private admitted: PendingToolCall[] = []; + private readonly cancelledIds = new Set(); private pendingError: string | undefined; constructor( @@ -400,6 +401,7 @@ class ClaudeProcess implements LiveProcess { private readonly files: TurnFiles, private turn: ClaudeTurn, readonly owner: symbol, + readonly sessionId: string | undefined, private readonly configuration: unknown, private conversation: unknown[], ) {} @@ -422,9 +424,9 @@ class ClaudeProcess implements LiveProcess { this.setSignal(signal); } - canContinue(owner: symbol, configuration: unknown, messages: Message[], results: ToolResultMessage[]): boolean { + canContinue(owner: symbol, sessionId: string | undefined, configuration: unknown, messages: Message[], results: ToolResultMessage[]): boolean { const conversation = conversationForReuse(messages); - return !this.stopping && owner === this.owner + return !this.stopping && owner === this.owner && !!sessionId && sessionId === this.sessionId && isDeepStrictEqual(configuration, this.configuration) && isDeepStrictEqual(conversation.slice(0, this.conversation.length), this.conversation) && conversation.length === this.conversation.length + this.admitted.length @@ -462,7 +464,7 @@ class ClaudeProcess implements LiveProcess { // A parallel request can fail after the admitted slice was handed to // Pi. Retain that failure for its continuation instead of silently // replaying and hiding the compatibility error. - if (this.turn.finished && this.toolCallIds.length > 0) this.pendingError = message; + if (this.sessionId && this.turn.finished && this.toolCallIds.length > 0) this.pendingError = message; this.turn.fail(message, "error"); this.kill(); if (this.pendingError) addLiveProcess(this); @@ -477,6 +479,13 @@ class ClaudeProcess implements LiveProcess { return true; } + cancelCall(call: PendingToolCall): void { + this.cancelledIds.add(call.id); + // Already admitted work belongs to Pi and cannot be undone. A queued + // request, however, must never become executable after cancellation. + this.sourceCalls = this.sourceCalls.filter((intent) => intent.id !== call.id); + } + /** Only authenticated MCP requests, never stdout intent alone, admit side effects. */ flushAdmissions(): void { if (this.stopping || this.turn.finished || !this.sourceComplete) return; @@ -496,7 +505,8 @@ class ClaudeProcess implements LiveProcess { this.toolCallIds = pending.map((call) => call.id); this.turn.admit(calls); this.conversation.push(...structuredClone(conversationForReuse([this.turn.getMessage()]))); - addLiveProcess(this); + if (this.sessionId) addLiveProcess(this); + else this.kill(); // No trusted scope: return the batch, then use fresh replay. } private readonly abort = () => { @@ -534,11 +544,12 @@ class ClaudeProcess implements LiveProcess { this.turn.handle(event); if (this.turn.finished) { this.kill(); return; } if (event.type === "messageStop" && this.turn.waitingForAdmission) { - this.sourceCalls = this.turn.getIntents(); - if (this.sourceCalls.length === 0 || new Set(this.sourceCalls.map((call) => call.id)).size !== this.sourceCalls.length) { + const intents = this.turn.getIntents(); + if (intents.length === 0 || new Set(intents.map((call) => call.id)).size !== intents.length) { this.fail("claude CLI emitted an invalid tool batch"); return; } + this.sourceCalls = intents.filter((call) => !this.cancelledIds.has(call.id)); this.sourceComplete = true; this.flushAdmissions(); } @@ -559,17 +570,17 @@ class ClaudeProcess implements LiveProcess { } } -function continueLiveProcess(turn: ClaudeTurn, owner: symbol, configuration: unknown, messages: Message[], signal: AbortSignal | undefined): boolean { +function continueLiveProcess(turn: ClaudeTurn, owner: symbol, sessionId: string | undefined, configuration: unknown, messages: Message[], signal: AbortSignal | undefined): boolean { const assistantIndex = messages.findLastIndex((message) => message.role === "assistant"); const assistant = messages[assistantIndex]; if (assistant?.role !== "assistant") return false; const ids = assistant.content.flatMap((block) => block.type === "toolCall" ? [block.id] : []); - const process = getLiveProcess(ids, owner); + const process = getLiveProcess(ids, owner, sessionId); if (!(process instanceof ClaudeProcess)) return false; if (process.consumeFailure(owner, turn)) return true; const trailing = messages.slice(assistantIndex + 1); const results = trailing.filter((message) => message.role === "toolResult"); - if (trailing.length !== results.length || !process.canContinue(owner, configuration, messages, results)) { + if (trailing.length !== results.length || !process.canContinue(owner, sessionId, configuration, messages, results)) { process.kill(); return false; } @@ -604,6 +615,7 @@ async function startProcess( const bridge = await createToolBridge(input.tools, { onCall: () => cliProcess?.flushAdmissions(), onError: (error) => cliProcess?.fail(error.message), + onCancel: (call) => cliProcess?.cancelCall(call), }); let files: TurnFiles; try { @@ -639,7 +651,7 @@ async function startProcess( return; } - cliProcess = new ClaudeProcess(child, bridge, files, turn, owner, configuration, structuredClone(conversationForReuse(context.messages))); + cliProcess = new ClaudeProcess(child, bridge, files, turn, owner, options?.sessionId, configuration, structuredClone(conversationForReuse(context.messages))); cliProcess.start(toStreamJsonLine(input.userContent), options?.signal); } @@ -650,7 +662,7 @@ export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunc const turn = new ClaudeTurn(stream, model); const configuration = configurationForReuse(model, context, toEffort(options?.reasoning)); - if (continueLiveProcess(turn, owner, configuration, context.messages, options?.signal)) return stream; + if (continueLiveProcess(turn, owner, options?.sessionId, configuration, context.messages, options?.signal)) return stream; startProcess(runClaude, turn, model, context, options, owner, configuration).catch((error: unknown) => { console.error("claude CLI turn failed:", error); diff --git a/src/agent/claude-code/tool-bridge.ts b/src/agent/claude-code/tool-bridge.ts index db9f4da..71dc6de 100644 --- a/src/agent/claude-code/tool-bridge.ts +++ b/src/agent/claude-code/tool-bridge.ts @@ -22,7 +22,7 @@ const TOKEN_BYTES = 32; export type PendingToolCall = { id: string; name: string; arguments: JsonObject }; -type BridgeCallbacks = { onCall(): void; onError(error: Error): void }; +type BridgeCallbacks = { onCall(): void; onError(error: Error): void; onCancel?(call: PendingToolCall): void }; export const MCP_IDENTITY_ERROR = "claude CLI compatibility error: MCP calls must include a unique claudecode/toolUseId"; @@ -67,8 +67,22 @@ class ToolCallQueue { private readonly results = new Map void>(); private waiters: ToolCallWaiter[] = []; - add(call: PendingToolCall): Promise { - const result = new Promise((resolve) => this.results.set(call, resolve)); + add(call: PendingToolCall, signal: AbortSignal, onCancel?: (call: PendingToolCall) => void): Promise { + const result = new Promise((resolve) => { + const cancel = () => { + const settle = this.results.get(call); + if (!settle) return; + settle({ content: [{ type: "text", text: "MCP request cancelled" }], isError: true }); + onCancel?.(call); + }; + this.results.set(call, (result) => { + this.results.delete(call); + signal.removeEventListener("abort", cancel); + resolve(result); + }); + signal.addEventListener("abort", cancel, { once: true }); + if (signal.aborted) cancel(); + }); this.wakeWaiters(); @@ -84,7 +98,6 @@ class ToolCallQueue { resolve(call: PendingToolCall, result: ToolResultMessage): void { this.results.get(call)?.(toCallToolResult(result)); - this.results.delete(call); } list(): PendingToolCall[] { @@ -92,7 +105,9 @@ class ToolCallQueue { } clear(): void { - this.results.clear(); + for (const settle of this.results.values()) { + settle({ content: [{ type: "text", text: "MCP bridge closed" }], isError: true }); + } for (const waiter of this.waiters) waiter.reject(new Error("claude-code tool bridge closed")); this.waiters = []; @@ -116,7 +131,7 @@ function createMcpServer(tools: Tool[], queue: ToolCallQueue, callbacks?: Bridge server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: tools.map(toMcpTool) })); const seenIds = new Set(); - server.setRequestHandler(CallToolRequestSchema, (request) => { + server.setRequestHandler(CallToolRequestSchema, (request, extra) => { const id = request.params._meta?.["claudecode/toolUseId"]; if (typeof id !== "string" || !id.trim() || seenIds.has(id)) { const error = new McpError(ErrorCode.InvalidParams, MCP_IDENTITY_ERROR); @@ -129,7 +144,7 @@ function createMcpServer(tools: Tool[], queue: ToolCallQueue, callbacks?: Bridge throw error; } seenIds.add(id); - const result = queue.add({ id, name: request.params.name, arguments: (request.params.arguments ?? {}) as JsonObject }); + const result = queue.add({ id, name: request.params.name, arguments: (request.params.arguments ?? {}) as JsonObject }, extra.signal, callbacks?.onCancel); callbacks?.onCall(); return result; }); diff --git a/tests/claude-code-harness.test.ts b/tests/claude-code-harness.test.ts index d300b35..aab05bb 100644 --- a/tests/claude-code-harness.test.ts +++ b/tests/claude-code-harness.test.ts @@ -78,9 +78,9 @@ function createFakeRun(t: TestContext, scenarios: string[][]): FakeRun { const child = spawn(process.execPath, [FAKE_CLAUDE, ...args], { cwd: options.cwd, env: { ...options.env, FAKE_CLAUDE_SCENARIO: scenarioFile, FAKE_CLAUDE_STDIN: stdinFile }, - stdio: "pipe", - }); - t.after(() => child.kill()); + stdio: ["pipe", "pipe", "pipe", "ipc"], + }) as ChildProcessWithoutNullStreams; + t.after(() => child.kill("SIGKILL")); children.push(child); stdinFiles.push(stdinFile); @@ -104,7 +104,7 @@ function createAgentFactory(fakeRun: FakeRun): Parameters createTeacherAgent(undefined, { engine, messages: options.messages }); + return (options) => createTeacherAgent(undefined, { engine, chatId: options.chatId, messages: options.messages }); } async function runChatTurn(chatId: string, input: object, agentFactory: Parameters[1]): Promise { @@ -196,6 +196,40 @@ test("a question pauses later calls from the same CLI response until the learner await waitForExit(fakeRun.children[0]!); }); +test("an individually cancelled MCP question queued behind a learner wait never executes", async (t) => { + const cancelledId = "toolu_cancelled_question"; + const question = "This cancelled question must never be posed"; + const scenario = toAskScenario(); + scenario.splice(4, 0, + streamEvent({ type: "content_block_start", index: 1, content_block: { type: "tool_use", id: cancelledId, name: "mcp__learnharness__ask_user_question", input: {} } }), + streamEvent({ type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: JSON.stringify({ question }) } }), + streamEvent({ type: "content_block_stop", index: 1 }), + ); + scenario.splice(scenario.length - 2, 2, + JSON.stringify({ fake: "start-call", id: ASK_ID, name: "ask_user_question", arguments: ASK_INPUT }), + JSON.stringify({ fake: "wait-for-message" }), + JSON.stringify({ fake: "start-call", id: cancelledId, name: "ask_user_question", arguments: { question } }), + JSON.stringify({ fake: "cancel-call", id: cancelledId }), + JSON.stringify({ fake: "checkpoint" }), + JSON.stringify({ fake: "reply" }), + ); + const fakeRun = createFakeRun(t, [scenario]); + const factory = createAgentFactory(fakeRun); + const chatId = createClaudeCodeChat(t); + await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, factory); + assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, ASK_ID); + const child = fakeRun.children[0]!; + const cancelledOnServer = once(child, "message", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) }); + child.send("queue and cancel B"); + assert.deepEqual((await cancelledOnServer)[0], { fake: "checkpoint" }); + await runChatTurn(chatId, toAnswerInput(), factory); + assert.equal(fakeRun.children.length, 1); + assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? []), null); + assert.equal(JSON.stringify(chatStore.getChat(chatId)?.messages).includes(cancelledId), false, "cancelled B must never reach native tool execution or persistence"); + assert.ok(getLastAssistantText(chatId).includes(ANSWER_TEXT)); + await waitForExit(child); +}); + test("a question answered after the idle timeout starts a fresh CLI process that replays the question and answer", async (t) => { liveProcessIdleTimeoutMs.current = 300; t.after(() => (liveProcessIdleTimeoutMs.current = LIVE_PROCESS_IDLE_TIMEOUT_MS)); diff --git a/tests/claude-code-stream.test.ts b/tests/claude-code-stream.test.ts index efdd322..0a61add 100644 --- a/tests/claude-code-stream.test.ts +++ b/tests/claude-code-stream.test.ts @@ -45,6 +45,7 @@ const HOLD = JSON.stringify({ fake: "hold" }); const REPLY = JSON.stringify({ fake: "reply" }); const CLEANUP_TIMEOUT_MS = 2000; +const SESSION_ID = "stream-test-chat"; const READ_TOOL: Tool = { name: "read", description: "Read a file", parameters: { type: "object", properties: {} } }; const HI: Message = { role: "user", content: "hi", timestamp: 0 }; @@ -156,7 +157,7 @@ async function runScenario( messages: [{ role: "user", content: "hi", timestamp: 0 }], tools: [READ_TOOL, { name: "get_weather", description: "Weather", parameters: { type: "object", properties: {} } }], }); - const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(model, context, options)); + const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(model, context, { sessionId: SESSION_ID, ...options })); return { events, calls }; } @@ -210,7 +211,7 @@ async function runTurn( ): Promise { const context = normalizeContext({ systemPrompt: "You are a tutor.", messages, tools: [READ_TOOL] }); - const done = getLastEvent(await collectEvents(streamFunction(getModel(), context, options))); + const done = getLastEvent(await collectEvents(streamFunction(getModel(), context, { sessionId: SESSION_ID, ...options }))); assert.ok(done.type === "done", `turn ended with ${done.type}`); @@ -852,7 +853,7 @@ test("a parallel identity failure after an admitted slice is reported on continu const message = await runTurn(stream, [HI]); getOnlyCall(calls).child.send("continue"); await waitForExit(getOnlyCall(calls).child); - const events = await collectEvents(stream(getModel(), normalizeContext({ systemPrompt: "You are a tutor.", messages: [HI, message, toToolResult(first, "A")], tools: [READ_TOOL] }))); + const events = await collectEvents(stream(getModel(), normalizeContext({ systemPrompt: "You are a tutor.", messages: [HI, message, toToolResult(first, "A")], tools: [READ_TOOL] }), { sessionId: SESSION_ID })); assert.match(getErrorMessage(events) ?? "", /compatibility error.*claudecode\/toolUseId/); assert.equal(calls.length, 1, "must not hide the compatibility failure by spawning again"); await assertCleanedUp(getOnlyCall(calls)); @@ -867,7 +868,7 @@ test("output after MCP admission fails continuation instead of silently replayin const message = await runTurn(stream, [HI]); getOnlyCall(calls).child.send("continue"); await waitForExit(getOnlyCall(calls).child); - const events = await collectEvents(stream(getModel(), normalizeContext({ systemPrompt: "You are a tutor.", messages: [HI, message, toToolResult(call, "A")], tools: [READ_TOOL] }))); + const events = await collectEvents(stream(getModel(), normalizeContext({ systemPrompt: "You are a tutor.", messages: [HI, message, toToolResult(call, "A")], tools: [READ_TOOL] }), { sessionId: SESSION_ID })); assert.match(getErrorMessage(events) ?? "", /moved on while.*waiting for results/); assert.equal(calls.length, 1); await assertCleanedUp(getOnlyCall(calls)); @@ -927,13 +928,55 @@ for (const change of ["model", "effort", "system prompt", "tool schema", "tool d }], }); const model = change === "model" ? CLAUDE_CODE_MODELS[1]! : getModel(); - const events = await collectEvents(stream(model, context, change === "effort" ? { reasoning: "high" } : undefined)); + const events = await collectEvents(stream(model, context, { sessionId: SESSION_ID, ...(change === "effort" ? { reasoning: "high" as const } : {}) })); assert.equal(getLastEvent(events).type, "done"); assert.equal(fresh.calls.length, 1, "must start a fresh CLI rather than silently use stale configuration"); await assertCleanedUp(getOnlyCall(waiting.calls)); }); } +for (const identicalHistory of [false, true]) test(`one registered stream isolates two sessions with colliding tool IDs and ${identicalHistory ? "identical" : "different"} histories`, async (t) => { + const call: ToolUse = { id: "toolu_shared_singleton", name: "read", arguments: { path: "same" } }; + const one = createFakeRun(t, [...toToolUseMessage([call]), callTool(call), REPLY]); + const two = createFakeRun(t, [...toToolUseMessage([call]), callTool(call), REPLY]); + let spawns = 0; + const stream = createClaudeCodeStreamFunction((args, options) => { + const run = [one, two][spawns++]; + assert.ok(run, "a valid continuation must not spawn another CLI"); + return run.runClaude(args, options); + }); + const historyOne = [HI]; + const historyTwo: Message[] = identicalHistory ? [HI] : [{ role: "user", content: "another conversation", timestamp: 0 }]; + const messageOne = await runTurn(stream, historyOne, { sessionId: "chat-one" }); + const messageTwo = await runTurn(stream, historyTwo, { sessionId: "chat-two" }); + // B returns first while A is earlier in the registry: IDs and even full + // history must not let B resolve A's authenticated MCP request. + const replyTwo = await runTurn(stream, [...historyTwo, messageTwo, toToolResult(call, "from two")], { sessionId: "chat-two" }); + const replyOne = await runTurn(stream, [...historyOne, messageOne, toToolResult(call, "from one")], { sessionId: "chat-one" }); + assert.equal(getText(replyTwo), "from two"); + assert.equal(getText(replyOne), "from one"); + assert.equal(spawns, 2); + await assertCleanedUp(getOnlyCall(one.calls)); + await assertCleanedUp(getOnlyCall(two.calls)); +}); + +test("a caller without a session ID uses fresh replay and retains no held process", async (t) => { + const call: ToolUse = { id: "toolu_no_scope", name: "read", arguments: { path: "a" } }; + const waiting = createFakeRun(t, [...toToolUseMessage([call]), callTool(call), REPLY]); + const stdinDir = mkdtempSync(join(tmpdir(), "claude-unscoped-replay-")); + t.after(() => rmSync(stdinDir, { recursive: true, force: true })); + const stdinFile = join(stdinDir, "stdin.jsonl"); + const fresh = createFakeRun(t, readFixtureLines("text.jsonl"), { FAKE_CLAUDE_STDIN: stdinFile }); + const stream = createClaudeCodeStreamFunction((args, options) => (waiting.calls.length === 0 ? waiting : fresh).runClaude(args, options)); + const message = await runTurn(stream, [HI], { sessionId: undefined }); + await assertCleanedUp(getOnlyCall(waiting.calls)); + const reply = await runTurn(stream, [HI, message, toToolResult(call, "the preserved answer")], { sessionId: undefined }); + assert.equal(reply.stopReason, "stop"); + assert.equal(fresh.calls.length, 1); + assert.match(readFileSync(stdinFile, "utf8"), /the preserved answer/); + await assertCleanedUp(getOnlyCall(fresh.calls)); +}); + for (const sharedIdentity of [false, true]) test(`two concurrent stream owners keep separate processes${sharedIdentity ? " even with identical call IDs and arguments" : ""}`, async (t) => { const readOne: ToolUse = { id: "toolu_concurrent_one", name: "read", arguments: { path: "one" } }; const readTwo: ToolUse = sharedIdentity ? { ...readOne } : { id: "toolu_concurrent_two", name: "read", arguments: { path: "two" } }; diff --git a/tests/claude-code-tool-bridge.test.ts b/tests/claude-code-tool-bridge.test.ts index 9b44dfb..ff66fd9 100644 --- a/tests/claude-code-tool-bridge.test.ts +++ b/tests/claude-code-tool-bridge.test.ts @@ -136,6 +136,33 @@ test("tools/call stays pending until resolved and returns its text and image con assert.deepEqual(bridge.getPendingToolCalls(), []); }); +test("individual MCP cancellation removes only that request and leaves the other request resolvable", async (t) => { + let acknowledgeCancel!: () => void; + const cancelled = new Promise((resolve) => { acknowledgeCancel = resolve; }); + const cancellations: string[] = []; + const bridge = await createToolBridge([READ_TOOL], { + onCall() {}, onError(error) { throw error; }, + onCancel(call) { cancellations.push(call.id); acknowledgeCancel(); }, + }); + t.after(() => bridge.close()); + const client = await connectClient(bridge); + t.after(() => client.close()); + const first = client.callTool({ name: "read", arguments: { path: "a" }, _meta: { "claudecode/toolUseId": "call-a" } }); + const controller = new AbortController(); + const second = client.callTool({ name: "read", arguments: { path: "b" }, _meta: { "claudecode/toolUseId": "call-b" } }, undefined, { signal: controller.signal }); + const rejected = assert.rejects(second, /cancelled B/); + await bridge.waitForToolCalls(2); + controller.abort("cancelled B"); + await cancelled; + await rejected; + assert.deepEqual(cancellations, ["call-b"]); + const pending = bridge.getPendingToolCalls(); + assert.deepEqual(pending.map((call) => call.id), ["call-a"]); + bridge.resolveToolCall(pending[0]!, toolResult([{ type: "text", text: "A" }])); + assert.deepEqual((await first).content, [{ type: "text", text: "A" }]); + assert.deepEqual(bridge.getPendingToolCalls(), []); +}); + test("waitForToolCalls resolves once that many calls are pending, in arrival order", async (t) => { const bridge = await createToolBridge([READ_TOOL, SCREENSHOT_TOOL]); t.after(() => bridge.close()); diff --git a/tests/fixtures/claude-code/fake-claude.mjs b/tests/fixtures/claude-code/fake-claude.mjs index d1fef86..7438444 100644 --- a/tests/fixtures/claude-code/fake-claude.mjs +++ b/tests/fixtures/claude-code/fake-claude.mjs @@ -6,17 +6,20 @@ * {"fake":"exit","code":1,"stderr":"..."} write stderr and exit with that code * {"fake":"call","id":"toolu_...","name":"read","arguments":{...}} call the MCP bridge and wait for its result * {"fake":"start-call",...} start the same request without waiting for its result + * {"fake":"cancel-call","id":"toolu_..."} cancel an individual pending MCP request + * {"fake":"checkpoint"} notify the parent after previous sends finish * {"fake":"wait-for-message"} pause until the test releases the child over IPC * {"fake":"ignore-term"} ignore SIGTERM to exercise kill escalation * {"fake":"reply"} stream one assistant message whose text is every result so far, joined by " | " * FAKE_CLAUDE_STDIN, when set, receives the stdin it read. */ import { readFileSync, writeFileSync } from "node:fs"; -import { once } from "node:events"; +import { on } from "node:events"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +const parentMessages = on(process, "message"); const HOLD_INTERVAL_MS = 60_000; const BRIDGE_SERVER_NAME = "learnharness"; @@ -28,11 +31,16 @@ function streamEvent(event) { return JSON.stringify({ type: "stream_event", event }); } +let lastSend = Promise.resolve(); + async function connectBridge(mcpConfig) { const { url, headers } = JSON.parse(mcpConfig).mcpServers[BRIDGE_SERVER_NAME]; const client = new Client({ name: "fake-claude", version: "1.0.0" }); - await client.connect(new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers } })); + const transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers } }); + const send = transport.send.bind(transport); + transport.send = (...args) => (lastSend = send(...args)); + await client.connect(transport); return client; } @@ -54,12 +62,25 @@ if (process.env.FAKE_CLAUDE_STDIN) writeFileSync(process.env.FAKE_CLAUDE_STDIN, let bridge = null; const results = []; const pendingResults = []; +const controllers = new Map(); +const cancelled = new Set(); const lines = readFileSync(process.env.FAKE_CLAUDE_SCENARIO, "utf8").split("\n").filter(Boolean); for (const line of lines) { const directive = JSON.parse(line); + if (directive.fake === "cancel-call") { + cancelled.add(directive.id); + controllers.get(directive.id)?.abort("cancelled by fake CLI"); + await lastSend; + continue; + } + if (directive.fake === "checkpoint") { + await lastSend; + process.send?.({ fake: "checkpoint" }); + continue; + } if (directive.fake === "wait-for-message") { - await once(process, "message"); + await parentMessages.next(); continue; } if (directive.fake === "hold") { @@ -78,14 +99,17 @@ for (const line of lines) { } if (directive.fake === "call" || directive.fake === "start-call") { bridge ??= await connectBridge(mcpConfig); + const controller = new AbortController(); + controllers.set(directive.id, controller); const request = bridge.callTool({ name: directive.name, arguments: directive.arguments, ...(directive.id !== undefined ? { _meta: { "claudecode/toolUseId": directive.id } } : {}), - }); + }, undefined, { signal: controller.signal }); if (directive.fake === "start-call") { - pendingResults.push(request); + pendingResults.push({ id: directive.id, request }); void request.catch(() => {}); + await lastSend; } else { const result = await request; results.push(result.content.map((block) => block.text).join("")); @@ -93,7 +117,7 @@ for (const line of lines) { continue; } if (directive.fake === "reply") { - for (const result of await Promise.all(pendingResults.splice(0))) results.push(result.content.map((block) => block.text).join("")); + for (const result of await Promise.all(pendingResults.splice(0).filter(({ id }) => !cancelled.has(id)).map(({ request }) => request))) results.push(result.content.map((block) => block.text).join("")); await writeReply(results.join(" | ")); continue; } From c0de3f968d91c9d7c08fd2481cc3b0dc32a2e37c Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 18:17:50 +0200 Subject: [PATCH 15/21] fix: preserve native Claude CLI authentication provenance --- src/agent/claude-code/provider.ts | 3 ++- src/agent/claude-code/stream.ts | 28 +++++++++++++------------- src/core/auth-env.ts | 24 ++++++++++++++++++++++ src/server/keys.ts | 20 +++---------------- tests/claude-code-stream.test.ts | 33 ++++++++++++++++++++++++++----- tests/server-keys.test.ts | 5 +++++ 6 files changed, 76 insertions(+), 37 deletions(-) create mode 100644 src/core/auth-env.ts diff --git a/src/agent/claude-code/provider.ts b/src/agent/claude-code/provider.ts index c190230..aca199b 100644 --- a/src/agent/claude-code/provider.ts +++ b/src/agent/claude-code/provider.ts @@ -7,7 +7,8 @@ const SHARED_MODEL_FIELDS = { provider: CLAUDE_CODE_PROVIDER, baseUrl: "", input: ["text", "image"], - // Billed to the user's Claude subscription, not per token. + // Placeholder rates, not a billing guarantee: the CLI's own auth/routing + // determines charges. Cost presentation remains a separate product decision. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, reasoning: true, // The CLI has no thinking-off switch; effort low is the floor. diff --git a/src/agent/claude-code/stream.ts b/src/agent/claude-code/stream.ts index 8c9c89a..4eda283 100644 --- a/src/agent/claude-code/stream.ts +++ b/src/agent/claude-code/stream.ts @@ -23,6 +23,7 @@ import { import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messages"; import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse"; +import { externalCliEnv } from "../../core/auth-env.ts"; import { getClaudeBinary } from "./binary.ts"; import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts"; import { ClaudeCodeError } from "./constants.ts"; @@ -99,7 +100,9 @@ export function toClaudeArgs(input: ClaudeArgsInput): string[] { "--mcp-config", input.mcpConfigFile, "--setting-sources", - "", + "user", + "--settings", + JSON.stringify({ disableAllHooks: true }), "--disable-slash-commands", "--no-session-persistence", "--permission-prompts", @@ -588,16 +591,10 @@ function continueLiveProcess(turn: ClaudeTurn, owner: symbol, sessionId: string return true; } -// Either one takes precedence over the logged-in subscription, so a key saved on the Keys page -// would bill turns to the API account. -const API_CREDENTIAL_ENV_NAMES = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]; - -function toClaudeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const claudeEnv: NodeJS.ProcessEnv = { ...env, MCP_TOOL_TIMEOUT: String(MCP_TOOL_TIMEOUT_MS) }; - - for (const name of API_CREDENTIAL_ENV_NAMES) delete claudeEnv[name]; - - return claudeEnv; +function toClaudeEnv(): NodeJS.ProcessEnv { + // Preserve native CLI auth/routing, including launch-environment credentials. + // Claude Code chooses the account and billing method, not LearnHarness. + return { ...externalCliEnv(), MCP_TOOL_TIMEOUT: String(MCP_TOOL_TIMEOUT_MS) }; } async function startProcess( @@ -608,6 +605,7 @@ async function startProcess( options: SimpleStreamOptions | undefined, owner: symbol, configuration: unknown, + env: NodeJS.ProcessEnv, ): Promise { if (options?.signal?.aborted) { turn.fail(ABORT_MESSAGE, "aborted"); return; } const input = toClaudeInput(context.messages); @@ -639,7 +637,7 @@ async function startProcess( mcpConfigFile: files.mcpConfigFile, allowedTools: bridge.allowedTools, }); - child = runClaude(args, { cwd: files.cwd, env: toClaudeEnv(process.env) }); + child = runClaude(args, { cwd: files.cwd, env }); } catch (error) { await removeTurnResources(bridge, files); throw error; @@ -661,10 +659,12 @@ export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunc const stream = createAssistantMessageEventStream(); const turn = new ClaudeTurn(stream, model); - const configuration = configurationForReuse(model, context, toEffort(options?.reasoning)); + const env = toClaudeEnv(); + // An existing child must not retain stale authentication/routing after env changes. + const configuration = { request: configurationForReuse(model, context, toEffort(options?.reasoning)), env }; if (continueLiveProcess(turn, owner, options?.sessionId, configuration, context.messages, options?.signal)) return stream; - startProcess(runClaude, turn, model, context, options, owner, configuration).catch((error: unknown) => { + startProcess(runClaude, turn, model, context, options, owner, configuration, env).catch((error: unknown) => { console.error("claude CLI turn failed:", error); turn.fail(START_FAILED_MESSAGE, "error"); }); diff --git a/src/core/auth-env.ts b/src/core/auth-env.ts new file mode 100644 index 0000000..f2d8a65 --- /dev/null +++ b/src/core/auth-env.ts @@ -0,0 +1,24 @@ +/** Shared provenance only: no credential-store reads in the agent layer. */ +interface AuthBoot { + /** Names owned by the original launch environment. */ + realEnv: Set; + /** Names reflected into process.env by LearnHarness's key store. */ + fillGap: Set; +} +const BOOT_KEY = Symbol.for("lh.authBoot"); + +export function authEnvRecord(): AuthBoot { + const g = globalThis as Record; + if (!g[BOOT_KEY]) g[BOOT_KEY] = { realEnv: new Set(), fillGap: new Set() }; + return g[BOOT_KEY]; +} + +/** External CLIs own their authentication. Never pass LearnHarness-only injections. */ +export function externalCliEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const result = { ...env }; + const { realEnv, fillGap } = authEnvRecord(); + for (const name of fillGap) { + if (!realEnv.has(name)) delete result[name]; + } + return result; +} diff --git a/src/server/keys.ts b/src/server/keys.ts index 706de52..7f65362 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -54,23 +54,9 @@ import { import { authStore } from "../store/auth.ts"; import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts"; -// The once-per-process boot record — globalThis anchor (HMR re-imports the -// module; the snapshot must not re-run and misread fill-gapped vars as real -// env). Same pattern as globalThis.__lhExtractionRuns. -interface AuthBoot { - /** Vars that were in the REAL env at first init — env precedence's - * untouchable set. Fill-gapped vars are everything else we set. */ - realEnv: Set; - /** Vars WE put into process.env (the file's reflection) — deletable. */ - fillGap: Set; -} -const BOOT_KEY = Symbol.for("lh.authBoot"); - -function boot(): AuthBoot { - const g = globalThis as Record; - if (!g[BOOT_KEY]) g[BOOT_KEY] = { realEnv: new Set(), fillGap: new Set() }; - return g[BOOT_KEY]; -} +// Shared HMR-stable provenance lets external CLIs exclude file-only injections +// without importing this store-backed module into the agent layer. +import { authEnvRecord as boot } from "../core/auth-env.ts"; /** Is `id` a WRITABLE key name — curated catalog or model-provider env * var (provider-keys interlude: PUT/DELETE accept both). */ diff --git a/tests/claude-code-stream.test.ts b/tests/claude-code-stream.test.ts index 0a61add..67885c7 100644 --- a/tests/claude-code-stream.test.ts +++ b/tests/claude-code-stream.test.ts @@ -36,6 +36,7 @@ import { parseCliLine } from "../src/agent/claude-code/cli-events.ts"; import { ClaudeCodeError } from "../src/agent/claude-code/constants.ts"; import { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } from "../src/agent/claude-code/live-processes.ts"; import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; +import { authEnvRecord } from "../src/core/auth-env.ts"; import { createClaudeCodeStreamFunction, toClaudeArgs, toEffort, type RunClaude } from "../src/agent/claude-code/stream.ts"; import { toStreamJsonLine } from "../src/agent/claude-code/transcript.ts"; @@ -298,7 +299,9 @@ test("toClaudeArgs includes every isolation flag from Global Constraints", () => "--mcp-config", "/private/mcp.json", "--setting-sources", - "", + "user", + "--settings", + '{"disableAllHooks":true}', "--disable-slash-commands", "--no-session-persistence", "--permission-prompts", @@ -582,7 +585,7 @@ test("spawns with the model alias, effort, bridge tools, MCP_TOOL_TIMEOUT and th assert.equal(call.env.CLAUDE_CONFIG_DIR, process.env.CLAUDE_CONFIG_DIR); }); -test("spawns without Anthropic API credentials so turns stay on the subscription", async (t) => { +test("preserves existing CLI environment authentication without a subscription guarantee", async (t) => { const saved = { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN }; t.after(() => { for (const [name, value] of Object.entries(saved)) { @@ -597,8 +600,23 @@ test("spawns without Anthropic API credentials so turns stay on the subscription const { calls } = await runScenario(t, readFixtureLines("text.jsonl")); const call = getOnlyCall(calls); - assert.equal(call.env.ANTHROPIC_API_KEY, undefined); - assert.equal(call.env.ANTHROPIC_AUTH_TOKEN, undefined); + assert.equal(call.env.ANTHROPIC_API_KEY, "sk-ant-test"); + assert.equal(call.env.ANTHROPIC_AUTH_TOKEN, "token-test"); +}); + +test("does not inject credentials reflected only from LearnHarness's key store", async (t) => { + const name = "ANTHROPIC_API_KEY"; + const record = authEnvRecord(); + const saved = process.env[name]; + t.after(() => { + record.fillGap.delete(name); + if (saved === undefined) delete process.env[name]; else process.env[name] = saved; + }); + process.env[name] = "lh-only-key"; + record.fillGap.add(name); + const { calls } = await runScenario(t, readFixtureLines("text.jsonl")); + assert.equal(getOnlyCall(calls).env[name], undefined); + assert.equal(process.env[name], "lh-only-key", "other LearnHarness providers keep their env reflection"); }); test("writes the transcript as one stream-json user line on stdin", async (t) => { @@ -911,13 +929,18 @@ test("a mismatched MCP identity cannot admit a streamed call", async (t) => { await assertCleanedUp(getOnlyCall(calls)); }); -for (const change of ["model", "effort", "system prompt", "tool schema", "tool description", "history"] as const) { +for (const change of ["model", "effort", "system prompt", "tool schema", "tool description", "history", "authentication environment"] as const) { test(`a changed ${change} prevents reuse of a held process`, async (t) => { const read: ToolUse = { id: `toolu_reuse_${change}`, name: "read", arguments: { path: "a" } }; const waiting = createFakeRun(t, [...toToolUseMessage([read]), callTool(read), REPLY]); const fresh = createFakeRun(t, readFixtureLines("text.jsonl")); const stream = createClaudeCodeStreamFunction((args, options) => (waiting.calls.length === 0 ? waiting : fresh).runClaude(args, options)); const message = await runTurn(stream, [HI]); + if (change === "authentication environment") { + const saved = process.env.ANTHROPIC_AUTH_TOKEN; + t.after(() => { if (saved === undefined) delete process.env.ANTHROPIC_AUTH_TOKEN; else process.env.ANTHROPIC_AUTH_TOKEN = saved; }); + process.env.ANTHROPIC_AUTH_TOKEN = "changed-cli-auth"; + } const messages = [change === "history" ? { ...HI, content: "replacement history after compaction" } : HI, message, toToolResult(read, "A")]; const context = normalizeContext({ systemPrompt: change === "system prompt" ? "Changed tutor instructions" : "You are a tutor.", diff --git a/tests/server-keys.test.ts b/tests/server-keys.test.ts index 1ffd108..79e43e0 100644 --- a/tests/server-keys.test.ts +++ b/tests/server-keys.test.ts @@ -17,6 +17,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { after, describe, it } from "node:test"; +import { externalCliEnv } from "../src/core/auth-env.ts"; // THIS PROCESS'S ENV IS THE TEST SUBJECT — strip any inherited catalog vars // (a developer shell may export REAL keys; tests use throwaway values only, @@ -72,10 +73,12 @@ describe("boot fill-gap + precedence", () => { assert.ok(row.shadowedByEnv, "file value differs from the env value"); assert.equal(row.masked, `…${ENV_OWNED.slice(-4)} (${ENV_OWNED.length} chars)`); assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-filefilefile", "file copy untouched"); + assert.equal(externalCliEnv().OPENROUTER_API_KEY, ENV_OWNED, "external CLIs retain launch-env ownership"); }); it("unknown file var: fill-gapped into env, surfaced as a generic row", () => { assert.equal(process.env.FUTURE_PLANS_TOKEN, "opaque-plans-token", "reflected at boot"); + assert.equal(externalCliEnv().FUTURE_PLANS_TOKEN, undefined, "external CLIs exclude unknown file-only injections too"); const row = keyStatuses().find((s) => s.id === "FUTURE_PLANS_TOKEN")!; assert.equal(row.info, null, "no curated form"); assert.equal(row.source, "file"); @@ -103,6 +106,7 @@ describe("saveKey / deleteKey (immediate effect, no restart)", () => { const r = saveKey("DEEPGRAM_API_KEY", value); assert.ok(r.ok); assert.equal(process.env.DEEPGRAM_API_KEY, value, "env updated immediately"); + assert.equal(externalCliEnv().DEEPGRAM_API_KEY, undefined); assert.equal(authStore.authVars().DEEPGRAM_API_KEY, value, "file updated"); assert.equal(r.status.source, "file", "the reflected copy is the store's — the file is its origin of truth"); assert.ok(!r.status.shadowedByEnv); @@ -112,6 +116,7 @@ describe("saveKey / deleteKey (immediate effect, no restart)", () => { const r = saveKey("OPENROUTER_API_KEY", "sk-or-v1-newvalue12345"); assert.ok(r.ok); assert.equal(process.env.OPENROUTER_API_KEY, ENV_OWNED, "real env untouched"); + assert.equal(externalCliEnv().OPENROUTER_API_KEY, ENV_OWNED, "saving a shadowed key cannot remove real env ownership"); assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-newvalue12345", "file updated for later"); assert.ok(r.status.shadowedByEnv); }); From c5a756d7076144b076cbd20ac79bb4a26f61a053 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 19:10:47 +0200 Subject: [PATCH 16/21] feat: report Claude CLI installation and gate unsupported versions --- app/api/claude-code/route.ts | 6 ++ components/settings/claude-code-status.tsx | 49 +++++++++++++ components/settings/keys-section.tsx | 5 ++ src/agent/claude-code/installation.ts | 38 ++++++++++ src/agent/claude-code/launch.ts | 32 +++++++++ src/agent/claude-code/stream.ts | 21 +++--- src/core/contracts/claude-code.ts | 10 +++ src/core/index.ts | 1 + src/server/claude-code.ts | 8 +++ src/server/index.ts | 1 + src/server/keys.ts | 4 ++ tests/chatgpt-connection-ui.test.tsx | 6 +- tests/claude-code-harness.test.ts | 8 ++- tests/claude-code-installation.test.ts | 72 +++++++++++++++++++ tests/claude-code-status-ui.test.tsx | 81 ++++++++++++++++++++++ tests/claude-code-stream.test.ts | 6 +- tests/model-catalog.test.ts | 7 +- 17 files changed, 334 insertions(+), 21 deletions(-) create mode 100644 app/api/claude-code/route.ts create mode 100644 components/settings/claude-code-status.tsx create mode 100644 src/agent/claude-code/installation.ts create mode 100644 src/agent/claude-code/launch.ts create mode 100644 src/core/contracts/claude-code.ts create mode 100644 src/server/claude-code.ts create mode 100644 tests/claude-code-installation.test.ts create mode 100644 tests/claude-code-status-ui.test.tsx diff --git a/app/api/claude-code/route.ts b/app/api/claude-code/route.ts new file mode 100644 index 0000000..e587386 --- /dev/null +++ b/app/api/claude-code/route.ts @@ -0,0 +1,6 @@ +import { claudeCodeInstallationStatus } from "@/src/server"; + +/** Installation/version only; never reads auth status or makes a model request. */ +export async function GET() { + return Response.json(await claudeCodeInstallationStatus(), { headers: { "Cache-Control": "no-store" } }); +} diff --git a/components/settings/claude-code-status.tsx b/components/settings/claude-code-status.tsx new file mode 100644 index 0000000..274d5b4 --- /dev/null +++ b/components/settings/claude-code-status.tsx @@ -0,0 +1,49 @@ +"use client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "@/src/core"; +import { ConnectionRow } from "./connection-controls.tsx"; + +export function claudeCodeStatusLabel(status: ClaudeCodeStatus | null): string { + switch (status?.state) { + case "not-found": return "Not found"; + case "meets-minimum": return `Version ${status.version} · meets minimum`; + case "update-required": return `Version ${status.version} · update required`; + default: return "Version could not be checked"; + } +} + +export function ClaudeCodeInstallationStatus() { + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(true); + const request = useRef(null); + const refresh = useCallback(async () => { + request.current?.abort(); + const controller = new AbortController(); + request.current = controller; + setBusy(true); + try { + const response = await fetch("/api/claude-code", { cache: "no-store", signal: controller.signal }); + if (!response.ok) throw new Error("status unavailable"); + const value = await response.json() as ClaudeCodeStatus; + if (!["not-found", "meets-minimum", "update-required", "version-unchecked"].includes(value.state) + || value.minimumVersion !== CLAUDE_CODE_MINIMUM_VERSION + || (value.version !== null && (typeof value.version !== "string" || !/^\d+\.\d+\.\d+$/.test(value.version)))) throw new Error("invalid status"); + if (!controller.signal.aborted) setStatus(value); + } catch { + if (!controller.signal.aborted) setStatus(null); + } finally { + if (!controller.signal.aborted) setBusy(false); + } + }, []); + useEffect(() => { + void refresh(); + return () => request.current?.abort(); + }, [refresh]); + return
+ void refresh()} actionTestid="claude-code-check-again"> + {busy ? "Checking…" : claudeCodeStatusLabel(status)} + +

Install and authenticate in Claude Code outside LearnHarness. Authentication is not checked. Your Claude Code configuration determines billing.

+

Minimum version: {CLAUDE_CODE_MINIMUM_VERSION}. Meeting this minimum does not guarantee MCP compatibility, model access, or available quota. Claude Code setup ↗

+
; +} diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 198e93d..5d5f18e 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -27,6 +27,7 @@ import { useEffect, useRef, useState } from "react"; import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core"; import { SettingsGroup } from "./controls.tsx"; import { SubscriptionConnections } from "./subscription-connections.tsx"; +import { ClaudeCodeInstallationStatus } from "./claude-code-status.tsx"; import { ProviderMenuList } from "./model-menu.tsx"; import { MenuShell } from "@/components/menu-shell"; import { notifySettingsSaved } from "@/components/settings-signal"; @@ -471,6 +472,10 @@ export function KeysSection({ + + + + {(["Dictation", "Search"] as const).map((label) => {toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => ( Promise; + +/** No shell, auth-status command, model call, or credential-file inspection. */ +export const readClaudeVersion: ReadClaudeVersion = (binary) => new Promise((resolve, reject) => { + execFile(binary, ["--version"], { + cwd: tmpdir(), env: externalCliEnv(), windowsHide: true, + timeout: 5_000, killSignal: "SIGKILL", maxBuffer: 4_096, + }, (error, stdout) => error ? reject(error) : resolve(stdout)); +}); + +export async function inspectClaudeInstallation( + findBinary: () => string | null = getClaudeBinary, + readVersion: ReadClaudeVersion = readClaudeVersion, +): Promise { + const base = { version: null, minimumVersion: CLAUDE_CODE_MINIMUM_VERSION }; + const binary = findBinary(); + if (!binary) return { ...base, state: "not-found" }; + try { + const output = await readVersion(binary); + // Expose only a version, never arbitrary CLI stdout/stderr or an absolute path. + const match = /^(\d+)\.(\d+)\.(\d+)(?: \(Claude Code\))?$/.exec(output.trim()); + if (!match) return { ...base, state: "version-unchecked" }; + const parts = match.slice(1).map(Number); + if (!parts.every(Number.isSafeInteger)) return { ...base, state: "version-unchecked" }; + const minimum = CLAUDE_CODE_MINIMUM_VERSION.split(".").map(Number); + const differing = parts.findIndex((part, index) => part !== minimum[index]); + const meetsMinimum = differing === -1 || parts[differing]! > minimum[differing]!; + return { ...base, version: parts.join("."), state: meetsMinimum ? "meets-minimum" : "update-required" }; + } catch { + return { ...base, state: "version-unchecked" }; + } +} diff --git a/src/agent/claude-code/launch.ts b/src/agent/claude-code/launch.ts new file mode 100644 index 0000000..5b296bd --- /dev/null +++ b/src/agent/claude-code/launch.ts @@ -0,0 +1,32 @@ +import { spawn } from "node:child_process"; +import { getClaudeBinary } from "./binary.ts"; +import { inspectClaudeInstallation, readClaudeVersion, type ReadClaudeVersion } from "./installation.ts"; +import type { RunClaude } from "./stream.ts"; + +export class ClaudeLaunchError extends Error {} + +/** Injectable seams keep all verification offline; production uses the installed CLI. */ +export function createClaudeLauncher({ + findBinary = getClaudeBinary, + readVersion = readClaudeVersion, + spawnProcess = spawn, +}: { + findBinary?: () => string | null; + readVersion?: ReadClaudeVersion; + spawnProcess?: typeof spawn; +} = {}): RunClaude { + return async (args, options) => { + if (options.signal?.aborted) throw new ClaudeLaunchError("Request was aborted"); + const binary = findBinary(); + if (!binary) return null; + const status = await inspectClaudeInstallation(() => binary, readVersion); + if (status.state === "update-required") { + throw new ClaudeLaunchError(`Claude Code ${status.version} requires an update. LearnHarness requires at least ${status.minimumVersion}; update Claude Code outside LearnHarness.`); + } + if (status.state !== "meets-minimum") { + throw new ClaudeLaunchError("Claude Code's version could not be checked. Check your installation outside LearnHarness and try again."); + } + if (options.signal?.aborted) throw new ClaudeLaunchError("Request was aborted"); + return spawnProcess(binary, args, { cwd: options.cwd, env: options.env, stdio: "pipe" }); + }; +} diff --git a/src/agent/claude-code/stream.ts b/src/agent/claude-code/stream.ts index 4eda283..39a49ef 100644 --- a/src/agent/claude-code/stream.ts +++ b/src/agent/claude-code/stream.ts @@ -1,4 +1,4 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { type ChildProcessWithoutNullStreams } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -24,7 +24,7 @@ import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messag import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse"; import { externalCliEnv } from "../../core/auth-env.ts"; -import { getClaudeBinary } from "./binary.ts"; +import { ClaudeLaunchError, createClaudeLauncher } from "./launch.ts"; import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts"; import { ClaudeCodeError } from "./constants.ts"; import { addLiveProcess, getLiveProcess, removeLiveProcess, type LiveProcess } from "./live-processes.ts"; @@ -32,9 +32,10 @@ import { configurationForReuse, conversationForReuse } from "./reuse-context.ts" import { BRIDGE_SERVER_NAME, createToolBridge, toolNameFromMcp, type PendingToolCall, type ToolBridge } from "./tool-bridge.ts"; import { toClaudeInput, toStreamJsonLine } from "./transcript.ts"; -type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv }; +type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv; signal?: AbortSignal }; -export type RunClaude = (args: string[], options: RunClaudeOptions) => ChildProcessWithoutNullStreams | null; +type ClaudeChild = ChildProcessWithoutNullStreams | null; +export type RunClaude = (args: string[], options: RunClaudeOptions) => ClaudeChild | Promise; // A held MCP call lasts until pi calls back with the tool result, which for a question tool is // whenever the learner answers; this must outlast the live-process idle timeout so the CLI never @@ -637,7 +638,7 @@ async function startProcess( mcpConfigFile: files.mcpConfigFile, allowedTools: bridge.allowedTools, }); - child = runClaude(args, { cwd: files.cwd, env }); + child = await runClaude(args, { cwd: files.cwd, env, signal: options?.signal }); } catch (error) { await removeTurnResources(bridge, files); throw error; @@ -666,17 +667,11 @@ export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunc startProcess(runClaude, turn, model, context, options, owner, configuration, env).catch((error: unknown) => { console.error("claude CLI turn failed:", error); - turn.fail(START_FAILED_MESSAGE, "error"); + turn.fail(options?.signal?.aborted ? ABORT_MESSAGE : error instanceof ClaudeLaunchError ? error.message : START_FAILED_MESSAGE, options?.signal?.aborted ? "aborted" : "error"); }); return stream; }; } -function spawnClaude(args: string[], options: RunClaudeOptions): ChildProcessWithoutNullStreams | null { - const binary = getClaudeBinary(); - - return binary ? spawn(binary, args, { ...options, stdio: "pipe" }) : null; -} - -export const streamClaudeCode = createClaudeCodeStreamFunction(spawnClaude); +export const streamClaudeCode = createClaudeCodeStreamFunction(createClaudeLauncher()); diff --git a/src/core/contracts/claude-code.ts b/src/core/contracts/claude-code.ts new file mode 100644 index 0000000..50abd27 --- /dev/null +++ b/src/core/contracts/claude-code.ts @@ -0,0 +1,10 @@ +/** Installation/version only. Never an authentication, quota, or billing verdict. */ +export interface ClaudeCodeStatus { + state: "not-found" | "meets-minimum" | "update-required" | "version-unchecked"; + version: string | null; + minimumVersion: string; +} + +// --permission-prompts none was added in 2.1.259. This is not an official +// minimum-version/stability contract for claudecode/toolUseId; admission fails closed. +export const CLAUDE_CODE_MINIMUM_VERSION = "2.1.259"; diff --git a/src/core/index.ts b/src/core/index.ts index bae8b33..4ca5f03 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -54,6 +54,7 @@ export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts export { NATIVE_SUBSCRIPTIONS } from "./contracts/subscriptions.ts"; export type { NativeSubscriptionProviderId, NativeSubscriptionLogicalId, NativeSubscriptionStatus, NativeSubscriptionSnapshot, NativeSubscriptionScope, NativeSubscriptionControlBridge, SubscriptionInteraction, SubscriptionPrompt } from "./contracts/subscriptions.ts"; export * from "./cost-summary.ts"; +export { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "./contracts/claude-code.ts"; export * from "./fuzzy.ts"; // settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19): // the user-settings record's shape/sanitizer (pure) and its fully-resolved diff --git a/src/server/claude-code.ts b/src/server/claude-code.ts new file mode 100644 index 0000000..be52c06 --- /dev/null +++ b/src/server/claude-code.ts @@ -0,0 +1,8 @@ +import { inspectClaudeInstallation } from "../agent/claude-code/installation.ts"; +import { initAuthEnv } from "./keys.ts"; + +/** The Settings status seam: initialize provenance before any child env is copied. */ +export async function claudeCodeInstallationStatus() { + initAuthEnv(); + return inspectClaudeInstallation(); +} diff --git a/src/server/index.ts b/src/server/index.ts index a04dfb5..cd65868 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -45,3 +45,4 @@ export * from "./observability.ts"; export * from "./model-catalog.ts"; export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts"; export { sessionCost } from "./pricing.ts"; +export { claudeCodeInstallationStatus } from "./claude-code.ts"; diff --git a/src/server/keys.ts b/src/server/keys.ts index 7f65362..03b8d4c 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -53,6 +53,8 @@ import { } from "../core/auth-keys.ts"; import { authStore } from "../store/auth.ts"; import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts"; +import { getClaudeBinary } from "../agent/claude-code/binary.ts"; +import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts"; // Shared HMR-stable provenance lets external CLIs exclude file-only injections // without importing this store-backed module into the agent layer. @@ -187,6 +189,8 @@ export function keyValue(id: string): string | undefined { * (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take * no key at all: nothing to gate, present by definition. */ export function providerKeyPresent(provider: string): boolean { + // Presence only, not authentication readiness. Launch performs the version gate. + if (provider === CLAUDE_CODE_PROVIDER) return getClaudeBinary() !== null; if (isSubscriptionProvider(provider)) return subscriptionConnected(provider); const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider); return info ? keyValue(info.envVar) !== undefined : true; diff --git a/tests/chatgpt-connection-ui.test.tsx b/tests/chatgpt-connection-ui.test.tsx index 2edcf2f..b329ba7 100644 --- a/tests/chatgpt-connection-ui.test.tsx +++ b/tests/chatgpt-connection-ui.test.tsx @@ -77,14 +77,16 @@ test("Providers is the first/default page; General is last and other pages keep assert.doesNotMatch(html, />Model providers<\/button>/); }); -test("Providers has exactly four ordered sections; unknown variables stay masked/read-only at the end of Model API keys", async () => { +test("Providers has five ordered sections including External CLI; unknown variables stay masked/read-only at the end of Model API keys", async () => { globalThis.fetch = async (url) => { + if (url === "/api/claude-code") return Response.json({ state: "not-found", version: null, minimumVersion: "2.1.259" }); assert.equal(url, "/api/keys"); return Response.json({ providers: [{ id: "openai", envVar: "OPENAI_API_KEY", label: "OpenAI" }], keys: [{ id: "FUTURE_VAR", info: null, present: true, masked: "…abcd", source: "file", shadowedByEnv: false }] }); }; fixture(); await mount(); const headings = [...document.querySelectorAll('[data-testid^="group-"] > p')].map((el) => el.textContent); - assert.deepEqual(headings, ["Model API keys", "Subscriptions", "Dictation", "Search"]); + assert.deepEqual(headings, ["Model API keys", "Subscriptions", "External CLI", "Dictation", "Search"]); + assert(byTest("claude-code-installation").closest('[data-testid="group-external-cli"]')); assert(byTest("key-deepgram_api_key").closest('[data-testid="group-dictation"]')); assert(byTest("key-tavily_api_key").closest('[data-testid="group-search"]')); assert(byTest("key-brave_api_key").closest('[data-testid="group-search"]')); diff --git a/tests/claude-code-harness.test.ts b/tests/claude-code-harness.test.ts index aab05bb..875c96c 100644 --- a/tests/claude-code-harness.test.ts +++ b/tests/claude-code-harness.test.ts @@ -12,7 +12,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { once } from "node:events"; import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import test, { type TestContext } from "node:test"; @@ -63,6 +63,12 @@ function toAskScenario(): string[] { function createFakeRun(t: TestContext, scenarios: string[][]): FakeRun { const dir = mkdtempSync(join(tmpdir(), "claude-code-harness-")); t.after(() => rmSync(dir, { recursive: true, force: true })); + // Satisfy the real presence gate; this marker is NEVER executed. All work + // below goes through the injected Node fake-CLI seam, not an installed CLI. + writeFileSync(join(dir, process.platform === "win32" ? "claude.exe" : "claude"), "", { mode: 0o755 }); + const savedPath = process.env.PATH; + process.env.PATH = `${dir}${delimiter}${savedPath ?? ""}`; + t.after(() => { if (savedPath === undefined) delete process.env.PATH; else process.env.PATH = savedPath; }); const children: ChildProcessWithoutNullStreams[] = []; const stdinFiles: string[] = []; diff --git a/tests/claude-code-installation.test.ts b/tests/claude-code-installation.test.ts new file mode 100644 index 0000000..cc33126 --- /dev/null +++ b/tests/claude-code-installation.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { type ChildProcessWithoutNullStreams, type spawn } from "node:child_process"; +import { test } from "node:test"; +import { inspectClaudeInstallation } from "../src/agent/claude-code/installation.ts"; +import { ClaudeLaunchError, createClaudeLauncher } from "../src/agent/claude-code/launch.ts"; + +const binary = "/fake/claude"; +const options = { cwd: "/fake/work", env: { NODE_ENV: "test" as const, ANTHROPIC_API_KEY: "native-test-key" } }; + +test("missing installation does not execute a command", async () => { + assert.deepEqual(await inspectClaudeInstallation(() => null, async () => { throw new Error("must not run"); }), { + state: "not-found", version: null, minimumVersion: "2.1.259", + }); +}); + +for (const [output, state, version] of [ + ["2.1.258 (Claude Code)\n", "update-required", "2.1.258"], + ["2.1.259 (Claude Code)", "meets-minimum", "2.1.259"], + ["2.1.310 (Claude Code)", "meets-minimum", "2.1.310"], + ["2.2.0", "meets-minimum", "2.2.0"], + ["2.0.999", "update-required", "2.0.999"], + ["3.0.0", "meets-minimum", "3.0.0"], + ["unexpected output containing private details", "version-unchecked", null], + ["2.1.310-preview", "version-unchecked", null], + ["99999999999999999999999999.1.1", "version-unchecked", null], +] as const) test(`installation version output ${JSON.stringify(output)} gives ${state}`, async () => { + const status = await inspectClaudeInstallation(() => binary, async (path) => { assert.equal(path, binary); return output; }); + assert.deepEqual(status, { state, version, minimumVersion: "2.1.259" }); +}); + +test("probe failures expose neither raw errors nor binary paths", async () => { + const status = await inspectClaudeInstallation(() => binary, async () => { throw new Error("timeout /private/path secret-token"); }); + assert.deepEqual(status, { state: "version-unchecked", version: null, minimumVersion: "2.1.259" }); +}); + +for (const output of ["2.1.258", "garbage", null]) test(`launcher refuses ${output ?? "failed"} version check before model work`, async () => { + const launch = createClaudeLauncher({ findBinary: () => binary, + readVersion: async () => { if (output === null) throw new Error("failed probe"); return output; }, + spawnProcess: (() => { assert.fail("must not start model work"); }) as typeof spawn, + }); + await assert.rejects(launch(["-p"], options) as Promise, ClaudeLaunchError); +}); + +test("launcher uses the verified executable directly and preserves model args and native env", async () => { + const child = {} as ChildProcessWithoutNullStreams; + let calls = 0; + const launch = createClaudeLauncher({ findBinary: () => binary, readVersion: async () => "2.1.310 (Claude Code)", + spawnProcess: ((path: string, args: string[], opts: unknown) => { + calls++; + assert.equal(path, binary); + assert.deepEqual(args, ["-p", "--tools", ""]); + assert.deepEqual(opts, { ...options, stdio: "pipe" }); + return child; + }) as typeof spawn, + }); + assert.equal(await launch(["-p", "--tools", ""], options), child); + assert.equal(calls, 1); +}); + +test("aborting during a version check prevents model process creation", async () => { + const controller = new AbortController(); + const launch = createClaudeLauncher({ findBinary: () => binary, + readVersion: async () => { controller.abort(); return "2.1.310"; }, + spawnProcess: (() => { assert.fail("must not start after abort"); }) as typeof spawn, + }); + await assert.rejects(launch([], { ...options, signal: controller.signal }) as Promise, /aborted/); +}); + +test("missing executable is not mistaken for an unchecked version", async () => { + const launch = createClaudeLauncher({ findBinary: () => null, readVersion: async () => assert.fail("must not probe") }); + assert.equal(await launch([], options), null); +}); diff --git a/tests/claude-code-status-ui.test.tsx b/tests/claude-code-status-ui.test.tsx new file mode 100644 index 0000000..5c09ddb --- /dev/null +++ b/tests/claude-code-status-ui.test.tsx @@ -0,0 +1,81 @@ +import React, { act } from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { createRoot, type Root } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; +import { parseHTML } from "linkedom"; +import { ClaudeCodeInstallationStatus, claudeCodeStatusLabel } from "../components/settings/claude-code-status.tsx"; +import type { ClaudeCodeStatus } from "../src/core/contracts/claude-code.ts"; + +const dom = parseHTML(""); +Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); +const originalFetch = globalThis.fetch; +let root: Root | undefined, host: HTMLDivElement | undefined; +const status = (state: ClaudeCodeStatus["state"], version: string | null = null): ClaudeCodeStatus => ({ state, version, minimumVersion: "2.1.259" }); +const button = () => document.querySelector('[data-testid="claude-code-check-again"]')!; +const label = () => document.querySelector('[role="status"]')?.textContent; +async function mount() { + host = document.createElement("div"); document.body.append(host); root = createRoot(host); + await act(async () => root!.render()); +} +afterEach(async () => { + await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; + globalThis.fetch = originalFetch; +}); + +test("row is informational, disclaims auth/billing readiness, and links only to official setup", () => { + const html = renderToStaticMarkup(); + assert.match(html, /Authentication is not checked/); + assert.match(html, /configuration determines billing/); + assert.match(html, /does not guarantee MCP compatibility/); + assert.match(html, /https:\/\/code.claude.com\/docs\/en\/setup/); + assert.equal((html.match(/")); +}); + +for (const [value, text] of [ + [status("not-found"), "Not found"], + [status("meets-minimum", "2.1.310"), "Version 2.1.310 · meets minimum"], + [status("update-required", "2.1.258"), "Version 2.1.258 · update required"], + [status("version-unchecked"), "Version could not be checked"], +] as const) test(`installation display: ${text}`, () => assert.equal(claudeCodeStatusLabel(value), text)); + +test("mount checks once, Check again refreshes, and neither request asks for authentication", async () => { + const calls: string[] = []; + globalThis.fetch = (async (url, init) => { + calls.push(String(url)); + assert.equal(init?.cache, "no-store"); + return Response.json(calls.length === 1 ? status("not-found") : status("meets-minimum", "2.1.310")); + }) as typeof fetch; + await mount(); + assert.equal(label(), "Not found"); + await act(async () => button().click()); + assert.equal(label(), "Version 2.1.310 · meets minimum"); + assert.deepEqual(calls, ["/api/claude-code", "/api/claude-code"]); +}); + +test("slow checks disable recheck and unmount aborts outstanding work", async () => { + let signal: AbortSignal | null | undefined; + let resolve!: (response: Response) => void; + globalThis.fetch = ((_url, init) => { signal = init?.signal; return new Promise((done) => { resolve = done; }); }) as typeof fetch; + await mount(); + assert.equal(label(), "Checking…"); assert(button().disabled); + await act(async () => { root!.unmount(); root = undefined; }); + assert(signal?.aborted); + await act(async () => resolve(Response.json(status("not-found")))); + assert.equal(document.querySelector('[role="status"]'), null); +}); + +for (const failure of ["network", "http", "payload"] as const) test(`${failure} failure never retains a stale meets-minimum status`, async () => { + let calls = 0; + globalThis.fetch = (async () => { + if (++calls === 1) return Response.json(status("meets-minimum", "2.1.310")); + if (failure === "network") throw new Error("offline"); + return failure === "http" ? new Response(null, { status: 503 }) : Response.json({ state: "authenticated" }); + }) as typeof fetch; + await mount(); assert.equal(label(), "Version 2.1.310 · meets minimum"); + await act(async () => button().click()); + assert.equal(label(), "Version could not be checked"); assert(!button().disabled); +}); diff --git a/tests/claude-code-stream.test.ts b/tests/claude-code-stream.test.ts index 67885c7..19e3a76 100644 --- a/tests/claude-code-stream.test.ts +++ b/tests/claude-code-stream.test.ts @@ -37,6 +37,7 @@ import { ClaudeCodeError } from "../src/agent/claude-code/constants.ts"; import { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } from "../src/agent/claude-code/live-processes.ts"; import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; import { authEnvRecord } from "../src/core/auth-env.ts"; +import { ClaudeLaunchError } from "../src/agent/claude-code/launch.ts"; import { createClaudeCodeStreamFunction, toClaudeArgs, toEffort, type RunClaude } from "../src/agent/claude-code/stream.ts"; import { toStreamJsonLine } from "../src/agent/claude-code/transcript.ts"; @@ -899,15 +900,16 @@ test("normal completion escalates termination when the child ignores SIGTERM", a assert.equal(getOnlyCall(calls).child.signalCode, "SIGKILL"); }); -test("a synchronous spawn failure removes both temp directories and the bridge", async () => { +for (const asyncVersionFailure of [false, true]) test(`${asyncVersionFailure ? "an asynchronous version-check" : "a synchronous spawn"} failure removes both temp directories and the bridge`, async () => { let resources: { cwd: string; privateDir: string; url: string } | undefined; const stream = createClaudeCodeStreamFunction((args, options) => { const config = JSON.parse(readFileSync(getFlagValue(args, "--mcp-config"), "utf8")); resources = { cwd: options.cwd, privateDir: dirname(getFlagValue(args, "--mcp-config")), url: config.mcpServers.learnharness.url }; + if (asyncVersionFailure) return Promise.reject(new ClaudeLaunchError("Claude Code requires an update")); throw new Error("synthetic spawn failure"); }); const events = await collectEvents(stream(getModel(), normalizeContext({ messages: [HI] }))); - assert.match(getErrorMessage(events) ?? "", /failed to start/); + assert.match(getErrorMessage(events) ?? "", asyncVersionFailure ? /requires an update/ : /failed to start/); assert.ok(resources); assert.equal(existsSync(resources.cwd), false); assert.equal(existsSync(resources.privateDir), false); diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index b3de078..62b14fc 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -354,7 +354,7 @@ function withClaudeInstalled(installed: boolean, fn: () => void): void { } } -describe("the claude-code provider (subscription CLI, no key)", () => { +describe("the claude-code provider (native CLI authentication, no LearnHarness key)", () => { it("modelCatalog() lists claude-code opus/sonnet/haiku when the CLI is installed", () => { withClaudeInstalled(true, () => { const ids = modelCatalog() @@ -394,7 +394,8 @@ describe("the claude-code provider (subscription CLI, no key)", () => { assert.equal(engine.model.id, "sonnet"); }); - it('providerKeyPresent("claude-code") is true', () => { - assert.equal(providerKeyPresent("claude-code"), true); + it('providerKeyPresent("claude-code") follows executable presence, not a generic keyless default', () => { + withClaudeInstalled(true, () => assert.equal(providerKeyPresent("claude-code"), true)); + withClaudeInstalled(false, () => assert.equal(providerKeyPresent("claude-code"), false)); }); }); From 5093809ea08ebcec3675d3ae208df3173238b607 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 19:26:36 +0200 Subject: [PATCH 17/21] fix: authorize CLI probes and preserve executable identity --- app/api/claude-code/route.ts | 6 +-- components/settings/claude-code-status.tsx | 2 +- src/agent/claude-code/binary.ts | 7 ++- src/server/claude-code.ts | 19 +++++++ src/server/index.ts | 2 +- tests/claude-code-binary.test.ts | 6 +++ tests/claude-code-status-http.test.ts | 39 ++++++++++++++ tests/claude-code-status-ui.test.tsx | 1 + tests/claude-code-version-runner.test.ts | 63 ++++++++++++++++++++++ 9 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 tests/claude-code-status-http.test.ts create mode 100644 tests/claude-code-version-runner.test.ts diff --git a/app/api/claude-code/route.ts b/app/api/claude-code/route.ts index e587386..1708fe9 100644 --- a/app/api/claude-code/route.ts +++ b/app/api/claude-code/route.ts @@ -1,6 +1,6 @@ -import { claudeCodeInstallationStatus } from "@/src/server"; +import { claudeCodeInstallationResponse } from "@/src/server"; /** Installation/version only; never reads auth status or makes a model request. */ -export async function GET() { - return Response.json(await claudeCodeInstallationStatus(), { headers: { "Cache-Control": "no-store" } }); +export async function GET(request: Request) { + return claudeCodeInstallationResponse(request); } diff --git a/components/settings/claude-code-status.tsx b/components/settings/claude-code-status.tsx index 274d5b4..cc5fd33 100644 --- a/components/settings/claude-code-status.tsx +++ b/components/settings/claude-code-status.tsx @@ -22,7 +22,7 @@ export function ClaudeCodeInstallationStatus() { request.current = controller; setBusy(true); try { - const response = await fetch("/api/claude-code", { cache: "no-store", signal: controller.signal }); + const response = await fetch("/api/claude-code", { cache: "no-store", signal: controller.signal, headers: { "x-learnharness-claude-code": "1" } }); if (!response.ok) throw new Error("status unavailable"); const value = await response.json() as ClaudeCodeStatus; if (!["not-found", "meets-minimum", "update-required", "version-unchecked"].includes(value.state) diff --git a/src/agent/claude-code/binary.ts b/src/agent/claude-code/binary.ts index 13e86e6..1ee1871 100644 --- a/src/agent/claude-code/binary.ts +++ b/src/agent/claude-code/binary.ts @@ -6,6 +6,7 @@ type BinaryLookup = { env: Record; platform: NodeJS.Platform; homeDir: string; + cwd?: string; isExecutable: (path: string) => boolean; }; @@ -51,7 +52,7 @@ function getPosixSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSp }; } -export function resolveClaudeBinary({ env, platform, homeDir, isExecutable }: BinaryLookup): string | null { +export function resolveClaudeBinary({ env, platform, homeDir, cwd = process.cwd(), isExecutable }: BinaryLookup): string | null { const { paths, names, knownDirs } = platform === "win32" ? getWindowsSearchSpec(env, homeDir) : getPosixSearchSpec(env, homeDir); @@ -60,7 +61,9 @@ export function resolveClaudeBinary({ env, platform, homeDir, isExecutable }: Bi for (const name of names) { for (const dir of [...pathDirs, ...knownDirs]) { - const candidate = paths.join(dir, name); + // Discovery is relative to the server cwd; probes/model work use + // different directories and must receive the same absolute identity. + const candidate = paths.resolve(cwd, dir, name); if (isExecutable(candidate)) return candidate; } } diff --git a/src/server/claude-code.ts b/src/server/claude-code.ts index be52c06..bd40ebe 100644 --- a/src/server/claude-code.ts +++ b/src/server/claude-code.ts @@ -1,8 +1,27 @@ import { inspectClaudeInstallation } from "../agent/claude-code/installation.ts"; import { initAuthEnv } from "./keys.ts"; +import { subscriptionRequestOrigin } from "./web-subscription-http.ts"; /** The Settings status seam: initialize provenance before any child env is copied. */ export async function claudeCodeInstallationStatus() { initAuthEnv(); return inspectClaudeInstallation(); } + +/** Same-origin browser intent, not user authentication. No CORS grants. + * A public/shared server still requires its own access-control boundary. */ +export async function claudeCodeInstallationResponse( + request: Request, + inspect = claudeCodeInstallationStatus, +): Promise { + const origin = subscriptionRequestOrigin(request); + const supplied = request.headers.get("origin"); + const headers = { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }; + if (request.method !== "GET" || !origin + || request.headers.get("x-learnharness-claude-code") !== "1" + || request.headers.get("sec-fetch-site") === "cross-site" + || (supplied !== null && supplied !== origin)) { + return Response.json({ error: "Claude Code checks require a same-origin LearnHarness request." }, { status: 403, headers }); + } + return Response.json(await inspect(), { headers }); +} diff --git a/src/server/index.ts b/src/server/index.ts index cd65868..0c3b002 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -45,4 +45,4 @@ export * from "./observability.ts"; export * from "./model-catalog.ts"; export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts"; export { sessionCost } from "./pricing.ts"; -export { claudeCodeInstallationStatus } from "./claude-code.ts"; +export { claudeCodeInstallationResponse } from "./claude-code.ts"; diff --git a/tests/claude-code-binary.test.ts b/tests/claude-code-binary.test.ts index 599f5d9..ae9c855 100644 --- a/tests/claude-code-binary.test.ts +++ b/tests/claude-code-binary.test.ts @@ -59,6 +59,12 @@ test("windows prefers claude.exe anywhere over claude.cmd, which Node cannot spa assert.equal(resolveWith(executables, env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`); }); +test("relative Windows PATH entries are fixed to an absolute discovery identity", () => { + assert.equal(resolveClaudeBinary({ env: { PATH: ".\\bin" }, platform: "win32", homeDir: "C:\\Users\\u", cwd: "C:\\server", + isExecutable: (candidate) => candidate === "C:\\server\\bin\\claude.exe", + }), "C:\\server\\bin\\claude.exe"); +}); + test("returns null when every location misses", () => { assert.equal(resolveWith([], { PATH: "/usr/bin:/bin" }), null); }); diff --git a/tests/claude-code-status-http.test.ts b/tests/claude-code-status-http.test.ts new file mode 100644 index 0000000..1c8f746 --- /dev/null +++ b/tests/claude-code-status-http.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { claudeCodeInstallationResponse } from "../src/server/claude-code.ts"; + +function request(headers: Record = {}) { + return new Request("http://localhost:3000/api/claude-code", { headers: { "x-learnharness-claude-code": "1", ...headers } }); +} +const status = { state: "not-found" as const, version: null, minimumVersion: "2.1.259" }; + +for (const headers of [ + { origin: "https://attacker.invalid" }, + { "sec-fetch-site": "cross-site" }, + { origin: "http://localhost:3000", "sec-fetch-site": "cross-site" }, + { origin: "null" }, + { "x-learnharness-claude-code": "" }, +] as Record[]) test(`unsafe status request ${JSON.stringify(headers)} never invokes the probe`, async () => { + let probes = 0; + const response = await claudeCodeInstallationResponse(request(headers), async () => { probes++; return status; }); + assert.equal(response.status, 403); assert.equal(probes, 0); + assert.equal(response.headers.get("access-control-allow-origin"), null); +}); + +test("ordinary image/subresource GET without intent header cannot start a probe", async () => { + const response = await claudeCodeInstallationResponse(new Request("http://localhost:3000/api/claude-code"), async () => assert.fail("must not probe")); + assert.equal(response.status, 403); +}); + +for (const headers of [{}, { origin: "http://localhost:3000", "sec-fetch-site": "same-origin" }] as Record[]) test(`legitimate Providers GET ${JSON.stringify(headers)} is supported`, async () => { + let probes = 0; + const response = await claudeCodeInstallationResponse(request(headers), async () => { probes++; return status; }); + assert.equal(response.status, 200); assert.equal(probes, 1); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(await response.json(), status); +}); + +test("a forged forwarded origin cannot authorize a foreign Origin header", async () => { + const response = await claudeCodeInstallationResponse(request({ origin: "https://attacker.invalid", "x-forwarded-host": "attacker.invalid", "x-forwarded-proto": "https" }), async () => assert.fail("must not probe")); + assert.equal(response.status, 403); +}); diff --git a/tests/claude-code-status-ui.test.tsx b/tests/claude-code-status-ui.test.tsx index 5c09ddb..015fe66 100644 --- a/tests/claude-code-status-ui.test.tsx +++ b/tests/claude-code-status-ui.test.tsx @@ -47,6 +47,7 @@ test("mount checks once, Check again refreshes, and neither request asks for aut globalThis.fetch = (async (url, init) => { calls.push(String(url)); assert.equal(init?.cache, "no-store"); + assert.equal(new Headers(init?.headers).get("x-learnharness-claude-code"), "1"); return Response.json(calls.length === 1 ? status("not-found") : status("meets-minimum", "2.1.310")); }) as typeof fetch; await mount(); diff --git a/tests/claude-code-version-runner.test.ts b/tests/claude-code-version-runner.test.ts new file mode 100644 index 0000000..6faafee --- /dev/null +++ b/tests/claude-code-version-runner.test.ts @@ -0,0 +1,63 @@ +/** Real execFile boundary, authored fixture executables only. Never the installed CLI. */ +import assert from "node:assert/strict"; +import { type spawn } from "node:child_process"; +import { accessSync, constants, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test, type TestContext } from "node:test"; +import { resolveClaudeBinary } from "../src/agent/claude-code/binary.ts"; +import { readClaudeVersion } from "../src/agent/claude-code/installation.ts"; +import { createClaudeLauncher } from "../src/agent/claude-code/launch.ts"; + +const unsupportedShebang = process.platform === "win32" || /\s/.test(process.execPath); +function fixture(t: TestContext, mode: "normal" | "oversize" | "hang", name = "claude") { + const dir = mkdtempSync(join(tmpdir(), "lh-version-runner-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const bin = join(dir, "bin"); mkdirSync(bin); + const binary = join(bin, name); + const body = mode === "hang" ? ` +import { writeFileSync } from "node:fs"; +writeFileSync(process.argv[1] + ".pid", String(process.pid)); +process.on("SIGTERM", () => {}); +setInterval(() => {}, 1000); +` : mode === "oversize" ? 'process.stdout.write("x".repeat(100000));' : ` +if (JSON.stringify(process.argv.slice(2)) !== '["--version"]') process.exit(2); +process.stdout.write("2.1.310 (Claude Code)\\n"); +`; + writeFileSync(binary, `#!${process.execPath}\n${body}`, { mode: 0o755 }); + return { dir, binary }; +} + +test("production version runner directly executes a spaced/metacharacter filename with only --version", { skip: unsupportedShebang }, async (t) => { + const { binary } = fixture(t, "normal", "claude ; fixture"); + assert.equal(await readClaudeVersion(binary), "2.1.310 (Claude Code)\n"); +}); + +test("production version runner bounds stdout", { skip: unsupportedShebang }, async (t) => { + const { binary } = fixture(t, "oversize"); + await assert.rejects(readClaudeVersion(binary), (error: NodeJS.ErrnoException) => error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"); +}); + +test("production version runner times out and SIGKILLs an uncooperative fixture", { skip: unsupportedShebang, timeout: 10000 }, async (t) => { + const { binary } = fixture(t, "hang"); + const start = Date.now(); + await assert.rejects(readClaudeVersion(binary), (error: Error & { signal?: string }) => error.signal === "SIGKILL"); + assert(Date.now() - start < 8500, "bounded five-second timeout"); + const pid = Number(readFileSync(`${binary}.pid`, "utf8")); + assert.throws(() => process.kill(pid, 0), /ESRCH/, "fixture no longer exists"); +}); + +test("relative PATH is resolved at server cwd and stays the same for probe and model launch", { skip: unsupportedShebang }, async (t) => { + const { dir, binary } = fixture(t, "normal"); + const found = resolveClaudeBinary({ env: { PATH: "./bin" }, homeDir: dir, platform: process.platform, cwd: dir, + isExecutable: (path) => { try { accessSync(path, constants.X_OK); return statSync(path).isFile(); } catch { return false; } }, + }); + assert.equal(found, binary); + assert.equal(await readClaudeVersion(found!), "2.1.310 (Claude Code)\n", "probe changes cwd to tmpdir but executes the discovered file"); + let spawned = false; + const launch = createClaudeLauncher({ findBinary: () => found, + spawnProcess: ((path: string) => { assert.equal(path, binary); spawned = true; return null; }) as unknown as typeof spawn, + }); + await launch(["-p"], { cwd: tmpdir(), env: { NODE_ENV: "test" } }); + assert(spawned, "same verified absolute identity passed to the model launcher"); +}); From 17c94be32791019bf05cdb7b92f345581a7ed673 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 19:58:07 +0200 Subject: [PATCH 18/21] fix: show native Claude CLI usage as unpriced --- components/settings/model-menu.tsx | 7 ++--- src/agent/claude-code/constants.ts | 2 +- src/agent/claude-code/provider.ts | 4 +-- src/core/contracts/claude-code.ts | 2 ++ src/core/contracts/models.ts | 3 ++- src/core/index.ts | 2 +- src/server/model-catalog.ts | 2 +- src/server/pricing.ts | 5 +++- tests/claude-code-usage.test.tsx | 42 +++++++++++++++++++++++++++++ tests/model-catalog.test.ts | 4 ++- tests/pricing-native-parity.test.ts | 4 +-- 11 files changed, 64 insertions(+), 13 deletions(-) create mode 100644 tests/claude-code-usage.test.tsx diff --git a/components/settings/model-menu.tsx b/components/settings/model-menu.tsx index 792ae23..4939672 100644 --- a/components/settings/model-menu.tsx +++ b/components/settings/model-menu.tsx @@ -21,11 +21,12 @@ import { useEffect, useRef, useState } from "react"; import type { ModelCatalogEntry } from "@/src/core"; -import { fuzzyScore, subscriptionLabel } from "@/src/core"; +import { CLAUDE_CODE_PROVIDER, fuzzyScore, subscriptionLabel } from "@/src/core"; import { formatTokens } from "@/components/usage-format"; import { SearchMenuList } from "./search-menu-list"; function priceLabel(cost: ModelCatalogEntry["cost"]): string { + if (!cost) return "Unpriced"; const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`; return `${fmt(cost.input)}/${fmt(cost.output)}`; } @@ -72,8 +73,8 @@ export function ModelMenuList({ renderItem={(m) => <> {m.name} - - {subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)} + + {m.provider === CLAUDE_CODE_PROVIDER ? "CLI billing" : subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)} diff --git a/src/agent/claude-code/constants.ts b/src/agent/claude-code/constants.ts index 18ac8d7..488f3f0 100644 --- a/src/agent/claude-code/constants.ts +++ b/src/agent/claude-code/constants.ts @@ -1,4 +1,4 @@ -export const CLAUDE_CODE_PROVIDER = "claude-code"; +export { CLAUDE_CODE_PROVIDER } from "../../core/contracts/claude-code.ts"; export const CLAUDE_CODE_API = "claude-code-cli"; diff --git a/src/agent/claude-code/provider.ts b/src/agent/claude-code/provider.ts index aca199b..0165e04 100644 --- a/src/agent/claude-code/provider.ts +++ b/src/agent/claude-code/provider.ts @@ -7,8 +7,8 @@ const SHARED_MODEL_FIELDS = { provider: CLAUDE_CODE_PROVIDER, baseUrl: "", input: ["text", "image"], - // Placeholder rates, not a billing guarantee: the CLI's own auth/routing - // determines charges. Cost presentation remains a separate product decision. + // Pi-required placeholders, not prices: the CLI's own auth/routing + // determines charges. Server pricing and the client catalog report unpriced. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, reasoning: true, // The CLI has no thinking-off switch; effort low is the floor. diff --git a/src/core/contracts/claude-code.ts b/src/core/contracts/claude-code.ts index 50abd27..83d8873 100644 --- a/src/core/contracts/claude-code.ts +++ b/src/core/contracts/claude-code.ts @@ -1,3 +1,5 @@ +export const CLAUDE_CODE_PROVIDER = "claude-code"; + /** Installation/version only. Never an authentication, quota, or billing verdict. */ export interface ClaudeCodeStatus { state: "not-found" | "meets-minimum" | "update-required" | "version-unchecked"; diff --git a/src/core/contracts/models.ts b/src/core/contracts/models.ts index 2819bb5..cab7de7 100644 --- a/src/core/contracts/models.ts +++ b/src/core/contracts/models.ts @@ -21,7 +21,8 @@ export interface ModelCatalogEntry { * thinkingLevelMap — e.g. GLM-5.3-flash: low|high|max). The thinking * picker lists exactly these; anything else is a lie. */ supportedLevels: ThinkingLevel[]; - cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; + /** Null when LearnHarness cannot estimate prices for this connection. */ + cost: { input: number; output: number; cacheRead: number; cacheWrite: number } | null; contextWindow: number; maxTokens: number; } diff --git a/src/core/index.ts b/src/core/index.ts index 4ca5f03..fbdbd9e 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -54,7 +54,7 @@ export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts export { NATIVE_SUBSCRIPTIONS } from "./contracts/subscriptions.ts"; export type { NativeSubscriptionProviderId, NativeSubscriptionLogicalId, NativeSubscriptionStatus, NativeSubscriptionSnapshot, NativeSubscriptionScope, NativeSubscriptionControlBridge, SubscriptionInteraction, SubscriptionPrompt } from "./contracts/subscriptions.ts"; export * from "./cost-summary.ts"; -export { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "./contracts/claude-code.ts"; +export { CLAUDE_CODE_PROVIDER, CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "./contracts/claude-code.ts"; export * from "./fuzzy.ts"; // settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19): // the user-settings record's shape/sanitizer (pure) and its fully-resolved diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index d2d7e91..11063cd 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -164,7 +164,7 @@ export function toModelDto(model: Model): ModelDto { reasoning: model.reasoning, input: [...model.input], supportedLevels: getSupportedThinkingLevels(model), - cost: { + cost: model.provider === CLAUDE_CODE_PROVIDER ? null : { input: model.cost.input, output: model.cost.output, cacheRead: model.cost.cacheRead, diff --git a/src/server/pricing.ts b/src/server/pricing.ts index eaec42d..1454c11 100644 --- a/src/server/pricing.ts +++ b/src/server/pricing.ts @@ -4,12 +4,15 @@ */ import { calculateCost, type Usage } from "@earendil-works/pi-ai"; import { piModels } from "../agent/models.ts"; +import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts"; import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.ts"; import { isSubscriptionModel, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts"; function catalogModel(id: string) { const colon = id.indexOf(":"); - if (colon < 0 || isSubscriptionModel(id)) return undefined; + // Native CLI routing determines billing. Adapter zero-rate placeholders + // must never become an apparent $0 estimate or subscription classification. + if (colon < 0 || isSubscriptionModel(id) || id.slice(0, colon) === CLAUDE_CODE_PROVIDER) return undefined; return piModels().getModel(id.slice(0, colon), id.slice(colon + 1)); } diff --git a/tests/claude-code-usage.test.tsx b/tests/claude-code-usage.test.tsx new file mode 100644 index 0000000..7c781fc --- /dev/null +++ b/tests/claude-code-usage.test.tsx @@ -0,0 +1,42 @@ +import React from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; +import { isSubscriptionModel, sumSteps, type TurnUsage } from "../src/core/index.ts"; +import { graphCostSummary } from "../src/core/cost-summary.ts"; +import { accumulateJobUsage } from "../src/core/contracts/jobs.ts"; +import { callCost, jobCost, sessionCost } from "../src/server/pricing.ts"; +import { toModelDto } from "../src/server/model-catalog.ts"; +import { ModelMenuList } from "../components/settings/model-menu.tsx"; +import { StatsBar } from "../components/stats-bar.tsx"; + +const step = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: 50, cacheWriteTokens: 0, reasoningTokens: undefined }; +for (const model of CLAUDE_CODE_MODELS) test(`${model.id}: token usage is retained but dollars are unpriced, not free or subscription use`, () => { + const id = `${model.provider}:${model.id}`; + const turn: TurnUsage = { id: "cli-turn", messageId: "cli-message", at: "2026-10-28T00:00:00Z", model: id, steps: [step] }; + assert(!isSubscriptionModel(id)); + assert.equal(callCost(step, id), null); + const pricing = sessionCost({ turns: [turn] }); + assert.equal(pricing.unpriced, 1); assert.equal(pricing.subscription, undefined); + assert.equal(pricing.turnCosts[turn.id], null); + assert.deepEqual(sumSteps(turn.steps), { ...step, cacheWrite1hTokens: undefined, reasoningTokens: undefined }); + assert.equal(jobCost({ usage: accumulateJobUsage(undefined, id, step) }), null); + const html = renderToStaticMarkup(); + assert(!html.includes("$0")); + const summary = graphCostSummary({ chats: [{ title: "CLI fixture", extractions: [{ startedAt: 1, at: turn.at, durationMs: 1, + stages: { extractor: { model: id, steps: [step] }, auditor: { model: id, steps: [step] } }, + }] }], sources: [] }, callCost); + assert.equal(summary.unpricedRuns, 1); assert.equal(summary.subscriptionRuns, undefined); + assert.equal(summary.runs[0]!.dollars, null); +}); + +test("model-catalog API and menus never advertise the CLI adapter's placeholder zero rates", () => { + const models = CLAUDE_CODE_MODELS.map(toModelDto); + assert(models.every((model) => model.cost === null)); + const html = renderToStaticMarkup( {}} />); + assert.equal((html.match(/>CLI billing<\/span>/g) ?? []).length, 3); + assert.match(html, /does not estimate it/); + assert(!html.includes("$0")); assert(!html.includes("USD per 1M")); assert(!html.includes("(subscription)")); +}); diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index 62b14fc..3ab0ea6 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -64,7 +64,8 @@ describe("the model catalog projection", () => { assert.equal(typeof m.id, "string"); assert.equal(typeof m.name, "string"); assert.ok(Array.isArray(m.supportedLevels)); - assert.ok(typeof m.cost.input === "number" && typeof m.cost.output === "number"); + if (m.provider === "claude-code") assert.equal(m.cost, null); + else assert.ok(m.cost && typeof m.cost.input === "number" && typeof m.cost.output === "number"); assert.ok(m.contextWindow > 0); } const glm = models.find((m) => m.id === "z-ai/glm-5.3-flash"); @@ -126,6 +127,7 @@ describe("the model catalog projection", () => { assert.equal(dto.id, raw.id); assert.equal(dto.name, raw.name); assert.deepEqual(dto.supportedLevels, getSupportedThinkingLevels(raw)); + assert.ok(dto.cost); assert.equal(dto.cost.input, raw.cost.input); assert.equal(dto.contextWindow, raw.contextWindow); }); diff --git a/tests/pricing-native-parity.test.ts b/tests/pricing-native-parity.test.ts index 80056bc..5f47411 100644 --- a/tests/pricing-native-parity.test.ts +++ b/tests/pricing-native-parity.test.ts @@ -10,7 +10,7 @@ const models = piModels().getProviders().flatMap((p) => piModels().getModels(p.i const nativeUsage = (input: number): Usage => ({ input, output: 1_000, cacheRead: 10_000, cacheWrite: 2_000, cacheWrite1h: 500, totalTokens: input + 13_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }); const saved = (u: Usage) => ({ inputTokens: u.input, outputTokens: u.output, cacheReadTokens: u.cacheRead, cacheWriteTokens: u.cacheWrite, cacheWrite1hTokens: u.cacheWrite1h, reasoningTokens: 500 }); -test("all catalog models price through Pi, including every tier boundary; saved usage is never mutated", () => { +test("API catalog models price through Pi at every tier boundary; native CLI stays unpriced and saved usage is never mutated", () => { assert.ok(models.length > 100); for (const model of models) { const sizes = [1_000, ...(model.cost.tiers ?? []).flatMap((t) => [Math.max(0, t.inputTokensAbove - 12_000), Math.max(0, t.inputTokensAbove - 12_000 + 1)])]; @@ -18,7 +18,7 @@ test("all catalog models price through Pi, including every tier boundary; saved const usage = nativeUsage(input); const call = Object.freeze(saved(usage)); const before = structuredClone(call); - const expected = calculateCost(model, usage).total; + const expected = model.provider === "claude-code" ? null : calculateCost(model, usage).total; assert.equal(callCost(call, `${model.provider}:${model.id}`), expected, `${model.provider}:${model.id} input=${input}`); assert.deepEqual(call, before); } From 074ba7afe4bc7c2e4f20cc86d2031455fc0a4369 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 20:36:42 +0200 Subject: [PATCH 19/21] feat: use session-reported Claude CLI context windows --- components/chat.tsx | 5 ++ components/model-picker.tsx | 13 ++- components/settings/model-menu.tsx | 2 +- components/stats-bar.tsx | 27 ++++-- src/agent/claude-code/context-window.ts | 71 ++++++++++++++++ src/agent/claude-code/stream.ts | 24 +++++- src/core/contracts/models.ts | 5 +- src/core/native-messages.ts | 26 +++++- src/server/model-catalog.ts | 11 +-- tests/claude-code-context-ui.test.tsx | 43 ++++++++++ tests/claude-code-context-window.test.ts | 98 ++++++++++++++++++++++ tests/claude-code-harness.test.ts | 6 +- tests/claude-code-stream.test.ts | 31 ++++++- tests/fixtures/claude-code/fake-claude.mjs | 26 +++++- tests/model-catalog.test.ts | 7 +- 15 files changed, 359 insertions(+), 36 deletions(-) create mode 100644 src/agent/claude-code/context-window.ts create mode 100644 tests/claude-code-context-ui.test.tsx create mode 100644 tests/claude-code-context-window.test.ts diff --git a/components/chat.tsx b/components/chat.tsx index 2d851d1..c1b97c8 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -37,6 +37,7 @@ import { useServerEvent, useServerResync } from "@/components/server-events"; import { isProviderKeyRefusal, firstSourceTextParts, + cliContextWindowOf, messageAnchorId, textPartMessageId, kbdEventForAction as kbdEventForChat, @@ -1690,6 +1691,7 @@ export function Chat({ phase={phases.at(-1) ?? "probe"} turns={turns} contextTokens={contextTokens} + cliContextWindow={cliContextWindowOf(nativeMessages)} pricing={pricing} onReserve={setDockReserve} dictationSlot={} @@ -1916,6 +1918,7 @@ function DockOverlay({ phase, turns, contextTokens, + cliContextWindow, pricing, composer, onSubmitAnswer, @@ -1952,6 +1955,7 @@ function DockOverlay({ /** The session's usage log — the stats bar beneath the composer folds it. */ turns: TurnUsage[]; contextTokens: number | null; + cliContextWindow: ReturnType; pricing?: SessionPricing; composer: React.ReactNode; onSubmitAnswer: (output: QuestionAnswerData) => void; @@ -2407,6 +2411,7 @@ function DockOverlay({ ): Map { - return new Map(catalog.models.map((m) => [`${m.provider}:${m.id}`, m.contextWindow])); + return new Map(catalog.models.flatMap((m) => m.contextWindow === null ? [] : [[`${m.provider}:${m.id}`, m.contextWindow] as const])); } /** diff --git a/components/settings/model-menu.tsx b/components/settings/model-menu.tsx index 4939672..678ba09 100644 --- a/components/settings/model-menu.tsx +++ b/components/settings/model-menu.tsx @@ -79,7 +79,7 @@ export function ModelMenuList({ {subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id} - {formatTokens(m.contextWindow)} + {m.contextWindow === null ? "—" : formatTokens(m.contextWindow)} } /> diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index 6c1c05d..1abbc2b 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core"; +import { CLAUDE_CODE_PROVIDER, subscriptionLabels, subscriptionLabel, type cliContextWindowOf, type SessionPricing, type TurnUsage } from "@/src/core"; import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format"; import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker"; @@ -10,6 +10,15 @@ export function formatContextMeasurement(tokens: number | null, limit: number | return limit === null ? numerator : `${numerator} / ${formatTokens(limit)}`; } +/** CLI aliases have no global catalog limit: only this session's matching + * response can supply a denominator. Other providers keep their catalog join. */ +export function contextLimitForModel(model: string | undefined, limits: ReadonlyMap | undefined, + cliWindow: ReturnType = null): number | null { + if (!model) return null; + if (model.startsWith(`${CLAUDE_CODE_PROVIDER}:`)) return cliWindow?.model === model ? cliWindow.tokens : null; + return limits?.get(model) ?? null; +} + /** * The below-composer stats bar (wave 4 observability): the session's * money/context footprint as one quiet row beneath the composer — @@ -24,12 +33,14 @@ export function formatContextMeasurement(tokens: number | null, limit: number | * Spending arrives already priced by the server through Pi's catalog and * calculator. Missing cost stays "—", never fake zero. Context is a separate * completed-conversation measurement, never a money-log/summarizer fold or - * transcript estimate. Its catalog denominator follows the selected model; - * model changes preserve the numerator. Successful compaction clears it to ?. + * transcript estimate. Its denominator follows the selected model, from the + * catalog or the session's CLI report; model changes preserve the numerator. + * Successful compaction clears the numerator and any old CLI report. */ export function StatsBar({ turns, contextTokens = null, + cliContextWindow = null, pricing, dictationSlot, chatId, @@ -38,6 +49,7 @@ export function StatsBar({ }: { turns: TurnUsage[]; contextTokens?: number | null; + cliContextWindow?: ReturnType; pricing?: SessionPricing; /** The bound session (per-session models, owner 2026-09-28): present * on the in-session bar — the model control binds THE SESSION'S own @@ -86,11 +98,11 @@ export function StatsBar({ document.removeEventListener("keydown", onKeyDown); }; }, [open]); - // Catalog-only denominator; the picker reports its effective selection. - // Missing/pending catalog means usage-only, never a hardcoded maximum. + // The picker reports its effective selection. CLI windows are session- + // scoped; unknown metadata always means usage-only, never a guessed maximum. const { catalog } = useModelCatalog(); const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]); - const limit = selectedModel ? limits?.get(selectedModel) ?? null : null; + const limit = contextLimitForModel(selectedModel, limits, cliContextWindow); const contextLabel = formatContextMeasurement(contextTokens, limit); const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {}; // Honest cost: show dollars only when at least one turn priced; @@ -151,6 +163,9 @@ export function StatsBar({ {contextLabel} + {selectedModel?.startsWith(`${CLAUDE_CODE_PROVIDER}:`) && ( +

The window is reported by Claude Code’s local summary and may reflect its auto-compaction budget rather than the model’s full maximum. Unavailable windows are omitted.

+ )}
{subscription ? "Usage" : "Total cost"} {costLabel} diff --git a/src/agent/claude-code/context-window.ts b/src/agent/claude-code/context-window.ts new file mode 100644 index 0000000..9dd2354 --- /dev/null +++ b/src/agent/claude-code/context-window.ts @@ -0,0 +1,71 @@ +import { randomUUID } from "node:crypto"; + +const INITIALIZE_TIMEOUT_MS = 5_000; + +/** A best-effort, local-only CLI control query. Wire contract matches the + * official SDK0.3.259 source; this does not import or execute the SDK. + * Summary deliberately avoids the default full mode's token-count requests. + * CLI maxTokens is an effective/auto-compaction budget, not a nominal model max. */ +export class ContextWindowProbe { + private readonly initializeId = randomUUID(); + private summaryId: string | undefined; + private initialized = false; + private timer: NodeJS.Timeout | undefined; + private ready = false; + private closed = false; + + constructor( + private readonly write: (line: string) => void, + private readonly sendPrompt: () => void, + private readonly onWindow: (tokens: number) => void, + ) {} + + start(): void { + this.timer = setTimeout(() => this.proceed(false), INITIALIZE_TIMEOUT_MS); + this.timer.unref(); + this.request(this.initializeId, { subtype: "initialize" }); + } + + /** Consume control replies separately from model/tool output, including + * late/foreign replies while a native question tool is already waiting. */ + handle(value: unknown): boolean { + if (!value || typeof value !== "object" || !("type" in value) || value.type !== "control_response") return false; + if (this.closed || !("response" in value) || !value.response || typeof value.response !== "object") return true; + const response = value.response as Record; + if (response.request_id === this.initializeId) this.proceed(response.subtype === "success"); + else if (this.summaryId && response.request_id === this.summaryId) { + this.summaryId = undefined; + if (response.subtype !== "success") return true; + const body = response.response as Record | undefined; + const tokens = body?.maxTokens; + if (typeof tokens === "number" && Number.isSafeInteger(tokens) && tokens > 0) this.onWindow(tokens); + } + return true; + } + + /** Retry when the CLI has begun a model response: pre-first-turn metadata + * is not guaranteed by the protocol. Refresh also follows later responses + * within the same held process, without opening a second process. */ + refresh(): void { + if (!this.initialized || this.closed) return; + this.summaryId = randomUUID(); + this.request(this.summaryId, { subtype: "get_context_usage", detail: "summary" }); + } + + close(): void { this.closed = true; clearTimeout(this.timer); } + + private proceed(query: boolean): void { + if (this.ready || this.closed) return; + this.ready = true; + clearTimeout(this.timer); + this.initialized = query; + if (query) this.refresh(); + // Never wait for metadata to run the actual user request. No support, + // no response, or malformed metadata simply leaves the window unknown. + this.sendPrompt(); + } + + private request(id: string, request: Record): void { + this.write(JSON.stringify({ type: "control_request", request_id: id, request })); + } +} diff --git a/src/agent/claude-code/stream.ts b/src/agent/claude-code/stream.ts index 39a49ef..ba96098 100644 --- a/src/agent/claude-code/stream.ts +++ b/src/agent/claude-code/stream.ts @@ -24,9 +24,11 @@ import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messag import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse"; import { externalCliEnv } from "../../core/auth-env.ts"; +import type { LhMessage } from "../../core/native-messages.ts"; import { ClaudeLaunchError, createClaudeLauncher } from "./launch.ts"; import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts"; import { ClaudeCodeError } from "./constants.ts"; +import { ContextWindowProbe } from "./context-window.ts"; import { addLiveProcess, getLiveProcess, removeLiveProcess, type LiveProcess } from "./live-processes.ts"; import { configurationForReuse, conversationForReuse } from "./reuse-context.ts"; import { BRIDGE_SERVER_NAME, createToolBridge, toolNameFromMcp, type PendingToolCall, type ToolBridge } from "./tool-bridge.ts"; @@ -137,7 +139,7 @@ function getAssistantErrorMessage(error: string, text: string): string { } class ClaudeTurn { - private readonly output: AssistantMessage; + private readonly output: AssistantMessage & Pick; private readonly contentIndexes = new Map(); private readonly partialJson = new Map(); private readonly toolBlocks = new Map(); @@ -233,6 +235,10 @@ class ClaudeTurn { getMessage(): AssistantMessage { return this.output; } + setContextWindow(tokens: number | undefined): void { + if (!this.finished && tokens !== undefined) this.output.cliContextWindow = tokens; + } + admit(calls: ToolCall[]): void { this.start(); for (const call of calls) { @@ -398,6 +404,8 @@ class ClaudeProcess implements LiveProcess { private admitted: PendingToolCall[] = []; private readonly cancelledIds = new Set(); private pendingError: string | undefined; + private contextWindow: number | undefined; + private contextProbe: ContextWindowProbe | undefined; constructor( private readonly child: ChildProcessWithoutNullStreams, @@ -420,12 +428,17 @@ class ClaudeProcess implements LiveProcess { this.child.stderr.setEncoding("utf8"); this.child.stderr.on("data", (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-65_536); }); this.child.stdin.on("error", (error) => console.error("claude CLI stdin closed early:", error)); - this.child.stdin.end(`${stdinLine}\n`); + this.contextProbe = new ContextWindowProbe( + (line) => this.child.stdin.write(`${line}\n`), + () => { if (!this.stopping) this.child.stdin.write(`${stdinLine}\n`); }, + (tokens) => { this.contextWindow = tokens; this.turn.setContextWindow(tokens); }, + ); createInterface({ input: this.child.stdout }).on("line", (line) => { try { this.handleLine(line); } catch { this.fail("claude CLI emitted malformed stream output"); } }); this.setSignal(signal); + if (!this.stopping) this.contextProbe.start(); } canContinue(owner: symbol, sessionId: string | undefined, configuration: unknown, messages: Message[], results: ToolResultMessage[]): boolean { @@ -445,6 +458,7 @@ class ClaudeProcess implements LiveProcess { this.admitted = []; this.conversation = structuredClone(conversationForReuse(messages)); this.turn = turn; + this.turn.setContextWindow(this.contextWindow); this.sourceComplete = this.sourceCalls.length > 0; this.setSignal(signal); if (this.stopping) return; @@ -459,6 +473,7 @@ class ClaudeProcess implements LiveProcess { removeLiveProcess(this); if (this.stopping || this.closed) return; this.stopping = true; + this.contextProbe?.close(); this.child.kill(); this.killTimer = setTimeout(() => this.child.kill("SIGKILL"), 1000); this.killTimer.unref(); @@ -526,7 +541,8 @@ class ClaudeProcess implements LiveProcess { } private handleLine(line: string): void { - if (this.stopping) return; + if (this.stopping || !line.trim()) return; + if (this.contextProbe?.handle(JSON.parse(line))) return; const event = parseCliLine(line); if (!event) return; if (event.type === "toolResults") { @@ -545,6 +561,7 @@ class ClaudeProcess implements LiveProcess { this.fail("claude CLI moved on without admitting its harness tool calls"); return; } + if (event.type === "messageStart") this.contextProbe?.refresh(); this.turn.handle(event); if (this.turn.finished) { this.kill(); return; } if (event.type === "messageStop" && this.turn.waitingForAdmission) { @@ -562,6 +579,7 @@ class ClaudeProcess implements LiveProcess { private handleClose(message: string): void { if (this.closed) return; this.closed = true; + this.contextProbe?.close(); clearTimeout(this.killTimer); removeLiveProcess(this); if (this.pendingError) addLiveProcess(this); diff --git a/src/core/contracts/models.ts b/src/core/contracts/models.ts index cab7de7..03d3241 100644 --- a/src/core/contracts/models.ts +++ b/src/core/contracts/models.ts @@ -23,8 +23,9 @@ export interface ModelCatalogEntry { supportedLevels: ThinkingLevel[]; /** Null when LearnHarness cannot estimate prices for this connection. */ cost: { input: number; output: number; cacheRead: number; cacheWrite: number } | null; - contextWindow: number; - maxTokens: number; + /** Null for native CLI aliases whose effective limits are session-specific. */ + contextWindow: number | null; + maxTokens: number | null; } /** The conversation agents' resolved default (BYOK, owner 2026-09-22): the diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index c9d5a46..520fa86 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -36,6 +36,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai"; import { QUESTION_TOOLS } from "./pending.ts"; +import { CLAUDE_CODE_PROVIDER } from "./contracts/claude-code.ts"; import { CONCLUDE_ACK_TEXT, HANDOFF_TEXT, @@ -90,7 +91,26 @@ declare module "@earendil-works/pi-agent-core" { } /** A record message: a native pi message (or our marker) plus the record id. */ -export type LhMessage = (UserMessage | AssistantMessage | ToolResultMessage | LhMarkerMessage) & { id: string }; +export type LhMessage = (UserMessage | AssistantMessage | ToolResultMessage | LhMarkerMessage) & { + id: string; + /** CLI-reported effective context budget, not a catalog model maximum. + * App-only metadata: never transmitted as provider/model message content. */ + cliContextWindow?: number; +}; + +/** Latest response only: never borrow an older process/model's window when + * a fresh response has no metadata. Compaction invalidates the old report. */ +export function cliContextWindowOf(messages: readonly LhMessage[]): { model: string; tokens: number } | null { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]!; + if (message.role === "custom" && message.customType === "compaction") return null; + if (message.role !== "assistant") continue; + const tokens = message.cliContextWindow; + return message.provider === CLAUDE_CODE_PROVIDER && typeof tokens === "number" && Number.isSafeInteger(tokens) && tokens > 0 + ? { model: `${message.provider}:${message.model}`, tokens } : null; + } + return null; +} /** A record message list is assignable to the agent's context — that IS * the replay (ui-stream/chat-handler assign it to initialState.messages). */ @@ -617,8 +637,8 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] { }); continue; } - // Strip the record id: the wire carries the native shape only. - const { id: _id, ...native } = m as LhMessage; + // Strip app-only id/CLI context metadata: the wire carries the native shape only. + const { id: _id, cliContextWindow: _cliContextWindow, ...native } = m as LhMessage; out.push(native as Message); // Repair unanswered calls from completed generations, not calls in // failed generations. Pi filters error/aborted assistants itself; diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index 11063cd..6605788 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -170,15 +170,16 @@ export function toModelDto(model: Model): ModelDto { cacheRead: model.cost.cacheRead, cacheWrite: model.cost.cacheWrite, }, - contextWindow: model.contextWindow, - maxTokens: model.maxTokens, + contextWindow: model.provider === CLAUDE_CODE_PROVIDER ? null : model.contextWindow, + maxTokens: model.provider === CLAUDE_CODE_PROVIDER ? null : model.maxTokens, }; } /** The live-limits index (context-meter amendment, owner 2026-09-23): * provider:id → contextWindow from pi's catalog, over the SAME keyed - * providers the picker federates. The one source for every context - * meter's denominator — the stats-bar join holds /api/models' copy, + * providers the picker federates. Native CLI aliases are excluded: + * only a session-specific CLI report can provide their denominator. + * For other models the stats-bar join holds /api/models' copy, * and the activity feed (observability.ts) resolves job limits * server-side through this. No hardcoded fallback exists anywhere: * a model missing here reads limit-null (usage-only, honest render). */ @@ -186,7 +187,7 @@ export function catalogContextLimits(): Map { const keyed = keyedProviderIds(); const index = new Map(); for (const provider of piModels().getProviders()) { - if (!keyed.has(provider.id)) continue; + if (!keyed.has(provider.id) || provider.id === CLAUDE_CODE_PROVIDER) continue; for (const model of piModels().getModels(provider.id)) { index.set(`${provider.id}:${model.id}`, model.contextWindow); } diff --git a/tests/claude-code-context-ui.test.tsx b/tests/claude-code-context-ui.test.tsx new file mode 100644 index 0000000..3b026ac --- /dev/null +++ b/tests/claude-code-context-ui.test.tsx @@ -0,0 +1,43 @@ +import React, { act } from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { createRoot, type Root } from "react-dom/client"; +import { parseHTML } from "linkedom"; +import { StatsBar } from "../components/stats-bar.tsx"; +import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; +import { toModelDto } from "../src/server/model-catalog.ts"; +import type { ModelCatalogResponse } from "../src/core/index.ts"; + +const dom = parseHTML(""); +Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, + CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true }); +const originalFetch = globalThis.fetch; +let root: Root | undefined, host: HTMLDivElement | undefined; +afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; globalThis.fetch = originalFetch; }); + +test("compact CLI context is numeric only; effective-window explanation appears only in expanded session metadata", async () => { + const catalog: ModelCatalogResponse = { models: CLAUDE_CODE_MODELS.map(toModelDto), providers: [], + slots: { strong: null, cheap: null, vision: null }, defaults: null, + rolePins: { researcher: null, demoBuilder: null, visualMaker: null, extractor: null, auditor: null } }; + globalThis.fetch = (async (url) => { + if (url === "/api/models") return Response.json(catalog); + if (url === "/api/settings") return Response.json({ settings: { models: {} } }); + if (url === "/api/chats/cli-session") return Response.json({ settings: { model: { provider: "claude-code", id: "opus" } } }); + throw new Error(`unexpected request ${url}`); + }) as typeof fetch; + host = document.createElement("div"); document.body.append(host); root = createRoot(host); + const render = (window: { model: string; tokens: number } | null) => root!.render(); + await act(async () => render({ model: "claude-code:opus", tokens: 200000 })); + assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k"); + assert(!document.body.textContent?.includes("auto-compaction")); + const summary = document.querySelector('[data-testid="stats-summary"]')!; + assert(!summary.textContent?.includes("window")); assert(!summary.textContent?.includes("budget")); + await act(async () => summary.click()); + assert.match(document.querySelector('[data-testid="stats-detail"]')?.textContent ?? "", /auto-compaction budget rather than the model’s full maximum/); + assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k"); + await act(async () => render(null)); + assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k"); + await act(async () => render({ model: "claude-code:sonnet", tokens: 1000000 })); + assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k", "another alias cannot supply this session's denominator"); +}); diff --git a/tests/claude-code-context-window.test.ts b/tests/claude-code-context-window.test.ts new file mode 100644 index 0000000..5b131eb --- /dev/null +++ b/tests/claude-code-context-window.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ContextWindowProbe } from "../src/agent/claude-code/context-window.ts"; +import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; +import { asAgentMessages, cliContextWindowOf, toLlmMessages, type LhMessage } from "../src/core/native-messages.ts"; +import { catalogLimits } from "../components/model-picker.tsx"; +import { contextLimitForModel, formatContextMeasurement } from "../components/stats-bar.tsx"; +import { toModelDto } from "../src/server/model-catalog.ts"; + +function setup() { + const writes: { type: string; request_id: string; request: Record }[] = []; + const windows: number[] = []; + let prompts = 0; + const probe = new ContextWindowProbe((line) => writes.push(JSON.parse(line)), () => prompts++, (tokens) => windows.push(tokens)); + probe.start(); + const reply = (index: number, body: unknown, subtype = "success") => probe.handle({ type: "control_response", response: { + request_id: writes[index]!.request_id, subtype, response: body, + } }); + return { probe, writes, windows, reply, prompts: () => prompts }; +} + +test("control initialization precedes a summary-only query; neither needs user input or a model request", () => { + const p = setup(); + assert.deepEqual(p.writes[0]!.request, { subtype: "initialize" }); assert.equal(p.prompts(), 0); + p.reply(0, {}); + assert.deepEqual(p.writes[1]!.request, { subtype: "get_context_usage", detail: "summary" }); + assert.equal(p.prompts(), 1, "conversation does not wait for metadata"); + assert.notEqual(p.writes[0]!.request_id, p.writes[1]!.request_id); + p.probe.handle({ type: "control_response", response: { request_id: "foreign", subtype: "success", response: { maxTokens: 999 } } }); + assert.deepEqual(p.windows, []); + p.reply(1, { maxTokens: 200000, rawMaxTokens: 1000000 }); + assert.deepEqual(p.windows, [200000], "effective maxTokens, not an assumed nominal window"); + p.reply(0, {}); p.reply(1, { maxTokens: 1 }); + assert.equal(p.prompts(), 1); assert.deepEqual(p.windows, [200000]); + p.probe.close(); +}); + +test("metadata unavailable before first response can be queried again locally; stale/closed replies are ignored", () => { + const p = setup(); p.reply(0, {}); p.reply(1, {}, "error"); + assert.equal(p.prompts(), 1); assert.deepEqual(p.windows, []); + p.probe.refresh(); + assert.deepEqual(p.writes[2]!.request, { subtype: "get_context_usage", detail: "summary" }); + p.reply(1, { maxTokens: 1 }); assert.deepEqual(p.windows, []); + p.reply(2, { maxTokens: 200000 }); assert.deepEqual(p.windows, [200000]); + p.probe.refresh(); p.probe.close(); p.reply(3, { maxTokens: 999999 }); + assert.deepEqual(p.windows, [200000]); assert.equal(p.prompts(), 1); +}); + +for (const tokens of [null, undefined, 0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1, "200000"]) test(`invalid summary budget ${tokens} remains unknown without failing the prompt`, () => { + const p = setup(); p.reply(0, {}); p.reply(1, { maxTokens: tokens }); + assert.deepEqual(p.windows, []); assert.equal(p.prompts(), 1); p.probe.close(); +}); + +test("initialization errors/timeouts fall back to ordinary prompt delivery; close cancels the timer", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const error = setup(); error.reply(0, undefined, "error"); + assert.equal(error.prompts(), 1); assert.equal(error.writes.length, 1); error.probe.close(); + const timeout = setup(); t.mock.timers.tick(5000); + assert.equal(timeout.prompts(), 1); assert.equal(timeout.writes.length, 1); + timeout.reply(0, {}); assert.equal(timeout.writes.length, 1); timeout.probe.close(); + const closed = setup(); closed.probe.close(); t.mock.timers.tick(5000); + assert.equal(closed.prompts(), 0); +}); + +function assistant(model = "opus", tokens: number | undefined = 200000): LhMessage & { role: "assistant" } { + return { id: "cli-assistant", role: "assistant", api: "claude-code-cli", provider: "claude-code", model, + content: [], timestamp: 0, stopReason: "stop", cliContextWindow: tokens, + usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } }; +} + +test("per-session metadata survives JSON persistence, never borrows older reports, and cannot override other model selections", () => { + const first = assistant(); + const window = cliContextWindowOf(JSON.parse(JSON.stringify([first]))); + assert.deepEqual(window, { model: "claude-code:opus", tokens: 200000 }); + assert(!("cliContextWindow" in toLlmMessages(asAgentMessages([first]))[0]!)); + assert.equal(first.cliContextWindow, 200000, "provider projection never mutates the stored report"); + const catalog = new Map([["anthropic:fixture", 100000], ["claude-code:opus", 1000000]]); + assert.equal(contextLimitForModel("claude-code:opus", catalog, window), 200000); + assert.equal(contextLimitForModel("claude-code:sonnet", catalog, window), null); + assert.equal(contextLimitForModel("claude-code:opus", catalog, null), null, "no placeholder fallback"); + assert.equal(contextLimitForModel("anthropic:fixture", catalog, window), 100000); + assert.equal(contextLimitForModel(undefined, catalog, window), null); + assert.equal(formatContextMeasurement(42000, 200000), "42k / 200k"); + assert.equal(formatContextMeasurement(42000, null), "42k"); + const unreported = assistant("opus"); delete unreported.cliContextWindow; + assert.equal(cliContextWindowOf([first, unreported]), null); + const other = assistant("api"); other.provider = "anthropic"; + assert.equal(cliContextWindowOf([first, other]), null); + const compacted: LhMessage = { id: "compact", role: "custom", customType: "compaction", content: "summary", display: false, details: {}, timestamp: 1 }; + assert.equal(cliContextWindowOf([first, compacted]), null); + assert.equal(cliContextWindowOf([]), null); +}); + +test("CLI catalog limits remain unknown and cannot create a global denominator", () => { + const models = CLAUDE_CODE_MODELS.map(toModelDto); + assert(models.every((model) => model.contextWindow === null && model.maxTokens === null)); + assert.equal(catalogLimits({ models }).size, 0); +}); diff --git a/tests/claude-code-harness.test.ts b/tests/claude-code-harness.test.ts index 875c96c..78bedb2 100644 --- a/tests/claude-code-harness.test.ts +++ b/tests/claude-code-harness.test.ts @@ -23,7 +23,7 @@ import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; import { createClaudeCodeStreamFunction, type RunClaude } from "../src/agent/claude-code/stream.ts"; import type { PiEngine } from "../src/agent/models.ts"; import { createTeacherAgent } from "../src/agent/teacher-agent.ts"; -import { danglingQuestionOf } from "../src/core/native-messages.ts"; +import { cliContextWindowOf, danglingQuestionOf } from "../src/core/native-messages.ts"; import { handleChatRequest } from "../src/server/chat-handler.ts"; import { isTurnRunning } from "../src/server/turn-liveness.ts"; import { runningTurn } from "../src/server/turn-runs.ts"; @@ -157,6 +157,8 @@ async function waitForExit(child: ChildProcessWithoutNullStreams): Promise test("a question answered before the idle timeout continues the waiting CLI process with the answer", async (t) => { const fakeRun = createFakeRun(t, [toAskScenario()]); + const run = fakeRun.runClaude; + fakeRun.runClaude = (args, options) => run(args, { ...options, env: { ...options.env, FAKE_CLAUDE_WINDOW: "200000" } }); const agentFactory = createAgentFactory(fakeRun); const chatId = createClaudeCodeChat(t); @@ -170,6 +172,8 @@ test("a question answered before the idle timeout continues the waiting CLI proc await runChatTurn(chatId, toAnswerInput(), agentFactory); assert.equal(fakeRun.children.length, 1); + assert.deepEqual(cliContextWindowOf(chatStore.getChat(chatId)!.messages), { model: "claude-code:sonnet", tokens: 200000 }); + assert.deepEqual(cliContextWindowOf(JSON.parse(readFileSync(join(PATHS.chats, `${chatId}.json`), "utf8")).messages), { model: "claude-code:sonnet", tokens: 200000 }); assert.ok(getLastAssistantText(chatId).includes(ANSWER_TEXT), getLastAssistantText(chatId)); await waitForExit(waiting); }); diff --git a/tests/claude-code-stream.test.ts b/tests/claude-code-stream.test.ts index 19e3a76..8da5596 100644 --- a/tests/claude-code-stream.test.ts +++ b/tests/claude-code-stream.test.ts @@ -620,13 +620,18 @@ test("does not inject credentials reflected only from LearnHarness's key store", assert.equal(process.env[name], "lh-only-key", "other LearnHarness providers keep their env reflection"); }); -test("writes the transcript as one stream-json user line on stdin", async (t) => { +test("writes one user prompt after local-only control initialization and summary request", async (t) => { const stdinFile = join(mkdtempSync(join(tmpdir(), "claude-code-stdin-")), "stdin.jsonl"); t.after(() => rmSync(dirname(stdinFile), { recursive: true, force: true })); await runScenario(t, readFixtureLines("text.jsonl"), undefined, { FAKE_CLAUDE_STDIN: stdinFile }); - assert.equal(readFileSync(stdinFile, "utf8"), `${toStreamJsonLine([{ type: "text", text: "hi" }])}\n`); + const lines = readFileSync(stdinFile, "utf8").trim().split("\n"); + assert(lines.length >= 3); + assert.deepEqual(JSON.parse(lines[0]!).request, { subtype: "initialize" }); + assert.deepEqual(JSON.parse(lines[1]!).request, { subtype: "get_context_usage", detail: "summary" }); + assert.equal(lines[2], toStreamJsonLine([{ type: "text", text: "hi" }])); + assert(lines.slice(3).every((line) => JSON.parse(line).request.detail === "summary")); }); test("parseCliLine ignores non-JSON, system, rate-limit and successful result lines", () => { @@ -1002,6 +1007,28 @@ test("a caller without a session ID uses fresh replay and retains no held proces await assertCleanedUp(getOnlyCall(fresh.calls)); }); +test("CLI window metadata stays on its own process and survives a held-tool continuation", async (t) => { + const read: ToolUse = { id: "toolu_window", name: "read", arguments: { path: "one" } }; + const one = createFakeRun(t, [...toToolUseMessage([read]), callTool(read), REPLY], { FAKE_CLAUDE_WINDOW: "200000" }); + const two = createFakeRun(t, [...toToolUseMessage([read]), callTool(read), REPLY], { FAKE_CLAUDE_WINDOW: "750000" }); + let spawns = 0; + const stream = createClaudeCodeStreamFunction((args, opts) => (++spawns === 1 ? one : two).runClaude(args, opts)); + const window = (m: AssistantMessage) => (m as AssistantMessage & { cliContextWindow?: number }).cliContextWindow; + const a = await runTurn(stream, [HI], { sessionId: "context-one" }); + const b = await runTurn(stream, [HI], { sessionId: "context-two" }); + assert.equal(window(a), 200000); assert.equal(window(b), 750000); + const ra = await runTurn(stream, [HI, a, toToolResult(read, "a")], { sessionId: "context-one" }); + const rb = await runTurn(stream, [HI, b, toToolResult(read, "b")], { sessionId: "context-two" }); + assert.equal(window(ra), 200000); assert.equal(window(rb), 750000); assert.equal(spawns, 2); +}); + +for (const extraEnv of [{ FAKE_CLAUDE_INIT: "error" }, { FAKE_CLAUDE_WINDOW: "-1" }, {}] as Record[]) test(`missing/unsupported CLI window never prevents a normal response: ${JSON.stringify(extraEnv)}`, async (t) => { + const { events } = await runScenario(t, readFixtureLines("text.jsonl"), undefined, extraEnv); + const result = getLastEvent(events); + assert.equal(result.type, "done"); + assert(!("cliContextWindow" in (result.type === "done" ? result.message : {}))); +}); + for (const sharedIdentity of [false, true]) test(`two concurrent stream owners keep separate processes${sharedIdentity ? " even with identical call IDs and arguments" : ""}`, async (t) => { const readOne: ToolUse = { id: "toolu_concurrent_one", name: "read", arguments: { path: "one" } }; const readTwo: ToolUse = sharedIdentity ? { ...readOne } : { id: "toolu_concurrent_two", name: "read", arguments: { path: "two" } }; diff --git a/tests/fixtures/claude-code/fake-claude.mjs b/tests/fixtures/claude-code/fake-claude.mjs index 7438444..2b0ba66 100644 --- a/tests/fixtures/claude-code/fake-claude.mjs +++ b/tests/fixtures/claude-code/fake-claude.mjs @@ -15,6 +15,7 @@ */ import { readFileSync, writeFileSync } from "node:fs"; import { on } from "node:events"; +import { createInterface } from "node:readline"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; @@ -56,8 +57,29 @@ async function writeReply(text) { const mcpConfig = readFileSync(process.argv[process.argv.indexOf("--mcp-config") + 1], "utf8"); let input = ""; -for await (const chunk of process.stdin) input += chunk; -if (process.env.FAKE_CLAUDE_STDIN) writeFileSync(process.env.FAKE_CLAUDE_STDIN, input); +let receiveUser; +const userReceived = new Promise((resolve) => { receiveUser = resolve; }); +createInterface({ input: process.stdin }).on("line", async (line) => { + input += `${line}\n`; + const message = JSON.parse(line); + if (message.type !== "control_request") { + if (process.env.FAKE_CLAUDE_STDIN) writeFileSync(process.env.FAKE_CLAUDE_STDIN, input); + receiveUser(); return; + } + if (message.request.subtype === "initialize") { + if (process.env.FAKE_CLAUDE_INIT === "ignore") return; + await writeLine(JSON.stringify({ type: "control_response", response: { + request_id: message.request_id, subtype: process.env.FAKE_CLAUDE_INIT === "error" ? "error" : "success", + response: {}, error: "unsupported initialize", + } })); + } else if (message.request.subtype === "get_context_usage") { + await writeLine(JSON.stringify({ type: "control_response", response: { + request_id: message.request_id, subtype: "success", + response: { maxTokens: process.env.FAKE_CLAUDE_WINDOW ? JSON.parse(process.env.FAKE_CLAUDE_WINDOW) : null }, + } })); + } +}); +await userReceived; let bridge = null; const results = []; diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index 3ab0ea6..9af9c7d 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -66,7 +66,8 @@ describe("the model catalog projection", () => { assert.ok(Array.isArray(m.supportedLevels)); if (m.provider === "claude-code") assert.equal(m.cost, null); else assert.ok(m.cost && typeof m.cost.input === "number" && typeof m.cost.output === "number"); - assert.ok(m.contextWindow > 0); + if (m.provider === "claude-code") assert.equal(m.contextWindow, null); + else assert.ok(m.contextWindow !== null && m.contextWindow > 0); } const glm = models.find((m) => m.id === "z-ai/glm-5.3-flash"); assert.ok(glm, "glm-5.3-flash in catalog"); @@ -384,9 +385,9 @@ describe("the claude-code provider (native CLI authentication, no LearnHarness k }); }); - it("catalogContextLimits() has claude-code:opus when the CLI is installed", () => { + it("catalogContextLimits() never advertises placeholder CLI limits, even when installed", () => { withClaudeInstalled(true, () => { - assert.ok((catalogContextLimits().get("claude-code:opus") ?? 0) > 0); + assert.equal(catalogContextLimits().has("claude-code:opus"), false); }); }); From ab76e4ff50681ca7959ab3cf58c56c5cc0f6ca22 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 21:13:43 +0200 Subject: [PATCH 20/21] fix: clarify Claude CLI billing and compact status copy --- components/settings/claude-code-status.tsx | 2 +- components/settings/connection-controls.tsx | 5 +++-- components/stats-bar.tsx | 7 ++++--- tests/claude-code-context-ui.test.tsx | 9 ++++++--- tests/claude-code-status-ui.test.tsx | 13 +++++++++++++ tests/claude-code-usage.test.tsx | 13 +++++++++++++ 6 files changed, 40 insertions(+), 9 deletions(-) diff --git a/components/settings/claude-code-status.tsx b/components/settings/claude-code-status.tsx index cc5fd33..65c600a 100644 --- a/components/settings/claude-code-status.tsx +++ b/components/settings/claude-code-status.tsx @@ -40,7 +40,7 @@ export function ClaudeCodeInstallationStatus() { return () => request.current?.abort(); }, [refresh]); return
- void refresh()} actionTestid="claude-code-check-again"> + void refresh()} actionTestid="claude-code-check-again"> {busy ? "Checking…" : claudeCodeStatusLabel(status)}

Install and authenticate in Claude Code outside LearnHarness. Authentication is not checked. Your Claude Code configuration determines billing.

diff --git a/components/settings/connection-controls.tsx b/components/settings/connection-controls.tsx index 5a92319..cd67f47 100644 --- a/components/settings/connection-controls.tsx +++ b/components/settings/connection-controls.tsx @@ -4,11 +4,12 @@ import { createPortal } from "react-dom"; import { useBackdropDismiss } from "@/components/backdrop-dismiss"; import { Button } from "@/components/ui/button"; -export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) { +export function ConnectionRow({ label, action, actionVariant, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; actionVariant?: "outline" | "ghost"; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) { + const variant = actionVariant ?? (action === "Connect" ? "outline" : "ghost"); return
{label} {children} - +
; } diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index 1abbc2b..d3e9811 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -109,7 +109,8 @@ export function StatsBar({ // otherwise "—" (never a fake $0.0000). const pricedCount = turns.length - unpriced - subscription; const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model)); - const costLabel = planCostLabel(pricedCount > 0 ? cost : null, providers); + const cliBilled = turns.some((turn) => turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`)); + const costLabel = planCostLabel(pricedCount > 0 ? cost : null, cliBilled ? [...providers, "CLI"] : providers); return (
{selectedModel?.startsWith(`${CLAUDE_CODE_PROVIDER}:`) && ( -

The window is reported by Claude Code’s local summary and may reflect its auto-compaction budget rather than the model’s full maximum. Unavailable windows are omitted.

+

Window reported by Claude Code; may be lower for auto-compaction.

)}
{subscription ? "Usage" : "Total cost"} @@ -193,7 +194,7 @@ export function StatsBar({ {turn.steps.length} steps - {subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))} + {turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`) ? "CLI" : subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))} ); diff --git a/tests/claude-code-context-ui.test.tsx b/tests/claude-code-context-ui.test.tsx index 3b026ac..9aa62f3 100644 --- a/tests/claude-code-context-ui.test.tsx +++ b/tests/claude-code-context-ui.test.tsx @@ -7,7 +7,7 @@ import { parseHTML } from "linkedom"; import { StatsBar } from "../components/stats-bar.tsx"; import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; import { toModelDto } from "../src/server/model-catalog.ts"; -import type { ModelCatalogResponse } from "../src/core/index.ts"; +import type { ModelCatalogResponse, TurnUsage } from "../src/core/index.ts"; const dom = parseHTML(""); Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, @@ -27,14 +27,17 @@ test("compact CLI context is numeric only; effective-window explanation appears throw new Error(`unexpected request ${url}`); }) as typeof fetch; host = document.createElement("div"); document.body.append(host); root = createRoot(host); - const render = (window: { model: string; tokens: number } | null) => root!.render(); + const turn: TurnUsage = { id: "cli-turn", messageId: "cli-message", model: "claude-code:opus", at: "2026-10-28T00:00:00Z", steps: [{ inputTokens: 42000, outputTokens: 1, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined }] }; + const render = (window: { model: string; tokens: number } | null) => root!.render(); await act(async () => render({ model: "claude-code:opus", tokens: 200000 })); assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k"); assert(!document.body.textContent?.includes("auto-compaction")); + assert.equal(document.querySelector('[data-testid="stats-cost"]')?.textContent, "CLI"); const summary = document.querySelector('[data-testid="stats-summary"]')!; assert(!summary.textContent?.includes("window")); assert(!summary.textContent?.includes("budget")); await act(async () => summary.click()); - assert.match(document.querySelector('[data-testid="stats-detail"]')?.textContent ?? "", /auto-compaction budget rather than the model’s full maximum/); + assert.match(document.querySelector('[data-testid="stats-detail"]')?.textContent ?? "", /Window reported by Claude Code; may be lower for auto-compaction\./); + assert.equal(document.querySelector('[data-testid="stats-detail"] li > span:last-child > span:last-child')?.textContent, "CLI"); assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k"); await act(async () => render(null)); assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k"); diff --git a/tests/claude-code-status-ui.test.tsx b/tests/claude-code-status-ui.test.tsx index 015fe66..1afbf0d 100644 --- a/tests/claude-code-status-ui.test.tsx +++ b/tests/claude-code-status-ui.test.tsx @@ -6,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; import { parseHTML } from "linkedom"; import { ClaudeCodeInstallationStatus, claudeCodeStatusLabel } from "../components/settings/claude-code-status.tsx"; +import { ConnectionRow } from "../components/settings/connection-controls.tsx"; import type { ClaudeCodeStatus } from "../src/core/contracts/claude-code.ts"; const dom = parseHTML(""); @@ -33,6 +34,18 @@ test("row is informational, disclaims auth/billing readiness, and links only to assert.equal((html.match(/")); + assert.match(html, /data-variant="outline"/); + assert.match(html, /data-size="xs"/); +}); + +test("recheck matches the existing Connect button styling without restyling Manage actions", () => { + const row = (action: string) => renderToStaticMarkup( {}} />); + const connect = parseHTML(row("Connect")).document.querySelector("button")!; + const manage = parseHTML(row("Manage")).document.querySelector("button")!; + const check = parseHTML(renderToStaticMarkup()).document.querySelector("button")!; + assert.equal(check.getAttribute("class"), connect.getAttribute("class")); + assert.equal(check.getAttribute("data-variant"), "outline"); + assert.equal(manage.getAttribute("data-variant"), "ghost"); }); for (const [value, text] of [ diff --git a/tests/claude-code-usage.test.tsx b/tests/claude-code-usage.test.tsx index 7c781fc..8415d2f 100644 --- a/tests/claude-code-usage.test.tsx +++ b/tests/claude-code-usage.test.tsx @@ -25,6 +25,7 @@ for (const model of CLAUDE_CODE_MODELS) test(`${model.id}: token usage is retain assert.equal(jobCost({ usage: accumulateJobUsage(undefined, id, step) }), null); const html = renderToStaticMarkup(); assert(!html.includes("$0")); + assert.match(html, /data-testid="stats-cost">CLI<\/span>/); const summary = graphCostSummary({ chats: [{ title: "CLI fixture", extractions: [{ startedAt: 1, at: turn.at, durationMs: 1, stages: { extractor: { model: id, steps: [step] }, auditor: { model: id, steps: [step] } }, }] }], sources: [] }, callCost); @@ -32,6 +33,18 @@ for (const model of CLAUDE_CODE_MODELS) test(`${model.id}: token usage is retain assert.equal(summary.runs[0]!.dollars, null); }); +test("mixed session totals retain known API dollars and subscription labels alongside CLI billing", () => { + const cli: TurnUsage = { id: "cli", messageId: "cli", at: "2026-10-28T00:00:00Z", model: "claude-code:sonnet", steps: [step] }; + const api = { ...cli, id: "api", messageId: "api", model: "openai:fixture" }; + const pricing = { cost: 0.12, unpriced: 1, turnCosts: { cli: null, api: 0.12 } }; + const html = renderToStaticMarkup(); + assert.match(html, /data-testid="stats-cost">\$0\.1200 \+ CLI<\/span>/); + const subscription = { ...cli, id: "plan", messageId: "plan", model: "chatgpt:fixture" }; + const mixed = renderToStaticMarkup(); + assert.match(mixed, /data-testid="stats-cost">\$0\.1200 \+ ChatGPT \+ CLI<\/span>/); + assert.equal(callCost(step, cli.model), null, "CLI stays unpriced in accounting"); +}); + test("model-catalog API and menus never advertise the CLI adapter's placeholder zero rates", () => { const models = CLAUDE_CODE_MODELS.map(toModelDto); assert(models.every((model) => model.cost === null)); From c9c8cead57d69f83c9d2c3a3f9667d6711010cef Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 21:37:03 +0200 Subject: [PATCH 21/21] feat: require explicit opt-in for Claude Code use --- components/model-catalog.ts | 2 +- components/settings-signal.ts | 2 +- components/settings/claude-code-status.tsx | 90 ++++++++++++-- src/core/settings.ts | 14 ++- src/server/chat-handler.ts | 9 +- src/server/claude-code-config.ts | 13 ++ src/server/claude-code.ts | 3 + src/server/keys.ts | 7 +- src/server/model-catalog.ts | 5 +- src/server/settings-body.ts | 10 ++ tests/claude-code-harness.test.ts | 73 +++++++++-- tests/claude-code-opt-in.test.ts | 98 +++++++++++++++ tests/claude-code-status-http.test.ts | 9 +- tests/claude-code-status-ui.test.tsx | 136 ++++++++++++++------- tests/model-catalog.test.ts | 32 +++-- tests/settings-body.test.ts | 2 +- 16 files changed, 417 insertions(+), 88 deletions(-) create mode 100644 src/server/claude-code-config.ts create mode 100644 tests/claude-code-opt-in.test.ts diff --git a/components/model-catalog.ts b/components/model-catalog.ts index 6a97320..20724d2 100644 --- a/components/model-catalog.ts +++ b/components/model-catalog.ts @@ -39,7 +39,7 @@ export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; catalogReaders.add(read); if (first) { stopCatalogSignals = onSettingsSaved((section) => { - if (section === "keys") { + if (section === "keys" || section === "claudeCode") { catalogGeneration++; catalogInflight = undefined; catalogCache = undefined; fetchCatalog(); } else if (section === "models") fetchCatalog(true); diff --git a/components/settings-signal.ts b/components/settings-signal.ts index 47f885d..da4dddf 100644 --- a/components/settings-signal.ts +++ b/components/settings-signal.ts @@ -20,7 +20,7 @@ export const SETTINGS_SAVED_EVENT = "lh:settings-saved"; // "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section // dispatches it after save AND delete — deletions used to be invisible to // the zero-keys pill and the setup dialog's auto-advance. -export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction" | "dictation"; +export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction" | "dictation" | "claudeCode"; /** Writers call this after the PUT resolves ok — never before (a failed * write notifies nothing; the record is unchanged). */ diff --git a/components/settings/claude-code-status.tsx b/components/settings/claude-code-status.tsx index 65c600a..3626c2d 100644 --- a/components/settings/claude-code-status.tsx +++ b/components/settings/claude-code-status.tsx @@ -1,7 +1,9 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "@/src/core"; -import { ConnectionRow } from "./connection-controls.tsx"; +import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus, type UserSettings } from "@/src/core"; +import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal"; +import { Button } from "@/components/ui/button"; +import { Toggle } from "./controls"; export function claudeCodeStatusLabel(status: ClaudeCodeStatus | null): string { switch (status?.state) { @@ -13,10 +15,65 @@ export function claudeCodeStatusLabel(status: ClaudeCodeStatus | null): string { } export function ClaudeCodeInstallationStatus() { + const [enabled, setEnabled] = useState(false); + const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); const [status, setStatus] = useState(null); - const [busy, setBusy] = useState(true); + const [busy, setBusy] = useState(false); const request = useRef(null); + const settingsRead = useRef(null); + const settingsWrite = useRef(null); + + const read = useCallback(async () => { + settingsRead.current?.abort(); + const controller = new AbortController(); + settingsRead.current = controller; + setLoaded(false); + try { + const response = await fetch("/api/settings", { cache: "no-store", signal: controller.signal }); + if (!response.ok) throw new Error("settings unavailable"); + const body = await response.json() as { settings: UserSettings | null }; + if (!controller.signal.aborted) { + setEnabled(body.settings?.claudeCode?.enabled === true); + setLoaded(true); + setError(null); + } + } catch { + if (!controller.signal.aborted) setError("Could not load Claude Code settings."); + } + }, []); + useEffect(() => { + void read(); + return () => { settingsRead.current?.abort(); settingsWrite.current?.abort(); }; + }, [read]); + useSettingsSaved((section) => { if (section === "claudeCode") void read(); }); + + const save = async (next: boolean) => { + const controller = new AbortController(); + settingsWrite.current = controller; + settingsRead.current?.abort(); + setSaving(true); + setError(null); + try { + const response = await fetch("/api/settings/claudeCode", { method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: next }), signal: controller.signal }); + if (!response.ok) throw new Error("settings write failed"); + const body = await response.json() as { settings: UserSettings }; + if (typeof body.settings?.claudeCode?.enabled !== "boolean") throw new Error("invalid settings response"); + if (!controller.signal.aborted) { + setEnabled(body.settings.claudeCode.enabled); + notifySettingsSaved("claudeCode"); + } + } catch { + if (!controller.signal.aborted) setError("Could not save Claude Code settings."); + } finally { + if (!controller.signal.aborted) setSaving(false); + } + }; + const refresh = useCallback(async () => { + if (!enabled) return; request.current?.abort(); const controller = new AbortController(); request.current = controller; @@ -34,16 +91,27 @@ export function ClaudeCodeInstallationStatus() { } finally { if (!controller.signal.aborted) setBusy(false); } - }, []); + }, [enabled]); useEffect(() => { - void refresh(); + if (enabled) void refresh(); + else { request.current?.abort(); setStatus(null); setBusy(false); } return () => request.current?.abort(); - }, [refresh]); + }, [enabled, refresh]); + return
- void refresh()} actionTestid="claude-code-check-again"> - {busy ? "Checking…" : claudeCodeStatusLabel(status)} - -

Install and authenticate in Claude Code outside LearnHarness. Authentication is not checked. Your Claude Code configuration determines billing.

-

Minimum version: {CLAUDE_CODE_MINIMUM_VERSION}. Meeting this minimum does not guarantee MCP compatibility, model access, or available quota. Claude Code setup ↗

+
+ Claude Code + void save(next)} /> +
+ {error &&

{error}

} + {enabled && <> +
+ {busy ? "Checking…" : claudeCodeStatusLabel(status)} + +
+

Install and authenticate Claude Code in your terminal, then select a Claude Code model here. LearnHarness starts it when needed—no background process required.

+

Checks installation/version only—not authentication, model access, or quota. Claude Code controls billing. Requires v{CLAUDE_CODE_MINIMUM_VERSION}+. Setup ↗

+

Verify AI claims independently—they may be inaccurate, incomplete, misleading, or outdated.

+ }
; } diff --git a/src/core/settings.ts b/src/core/settings.ts index f7966f1..2e8d3ae 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -29,6 +29,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts"; +import { CLAUDE_CODE_PROVIDER } from "./contracts/claude-code.ts"; import type { ModelRole } from "../../config.ts"; import type { ChatSettings } from "./contracts/chat.ts"; import type { SearchEngineId } from "./search.ts"; @@ -156,10 +157,11 @@ export interface ProviderKeyMissing { /** The refusal wording's invariant tail — the matcher below keys on it * (the label prefix varies by provider). */ const PROVIDER_KEY_MISSING_TAIL = "key is set — add one in Settings → Keys"; +const CLAUDE_CODE_UNAVAILABLE = "Claude Code is unavailable. Enable and set it up in Settings → Providers."; export function providerKeyMissing(provider: string, slots: ModelSlotName[]): ProviderKeyMissing { const label = AUTH_PROVIDER_KEYS.find((p) => p.id === provider)?.label ?? provider; return { - error: `No ${label} ${PROVIDER_KEY_MISSING_TAIL}`, + error: provider === CLAUDE_CODE_PROVIDER ? CLAUDE_CODE_UNAVAILABLE : `No ${label} ${PROVIDER_KEY_MISSING_TAIL}`, code: PROVIDER_KEY_MISSING_CODE, provider, slots, @@ -170,7 +172,7 @@ export function providerKeyMissing(provider: string, slots: ModelSlotName[]): Pr * scary-error) — the envelope's home module owns the string so the * writer and the matcher can never drift. */ export function isProviderKeyRefusal(error: string | undefined): boolean { - return !!error && error.endsWith(PROVIDER_KEY_MISSING_TAIL); + return !!error && (error.endsWith(PROVIDER_KEY_MISSING_TAIL) || error === CLAUDE_CODE_UNAVAILABLE); } /** The typed throw for in-process seams (extraction pipeline) — the * ProviderKeyMissing sibling of ModelsNotConfiguredError: callers catch @@ -368,6 +370,8 @@ export interface UserSettings { version: number; general?: GeneralSettings; dictation?: DictationSettings; + /** Explicit app-wide opt-in; absent/invalid is off, including older records. */ + claudeCode?: { enabled?: boolean }; agent?: AgentSettings; /** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */ models?: ModelsSettings; @@ -631,6 +635,12 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null { ? { language: (raw as DictationSettings).language } : {}; } + if (r.claudeCode !== undefined) { + const raw = r.claudeCode; + out.claudeCode = raw !== null && typeof raw === "object" && !Array.isArray(raw) + && typeof (raw as Record).enabled === "boolean" + ? { enabled: (raw as { enabled: boolean }).enabled } : {}; + } if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) { out.agent = sanitizeAgent(r.agent as Record); } diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index b0eef2a..21ccc7e 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -7,7 +7,7 @@ import { type TeachingAgent, } from "../agent/teacher-agent.ts"; import { piEngineForSpec, type PiEngine } from "../agent/models.ts"; -import { connectionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts"; +import { connectionBackgroundAllowed as subscriptionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts"; import { nativeSubscriptionError } from "../agent/subscription-stream.ts"; import { chatGPTStreamError } from "../agent/chatgpt-stream.ts"; import { SubscriptionRequestCycle, type SubscriptionDispatchLifecycle } from "../agent/subscription-request-cycle.ts"; @@ -33,6 +33,7 @@ import { providerEventNote } from "../agent/provider-events.ts"; import { effectiveSettings, stores } from "./stores.ts"; import { searchAvailable, searchProvider } from "./search.ts"; import { providerKeyPresent } from "./keys.ts"; +import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts"; import { extractionEnabled, graphToolsEnabled, @@ -80,6 +81,12 @@ import type {CallUsage} from "../core/contracts.ts"; * for write-ahead persistence, turn liveness, and the turn services. */ +/** Fresh check only at new background-run admission; never cancel a run + * already in progress when the opt-in is switched off. */ +function connectionBackgroundAllowed(provider: string): boolean { + return subscriptionBackgroundAllowed(provider) && (provider !== CLAUDE_CODE_PROVIDER || providerKeyPresent(provider)); +} + function settlementErrorText(error: unknown): string { return error instanceof TurnPersistenceError ? error.message : "An error occurred."; } diff --git a/src/server/claude-code-config.ts b/src/server/claude-code-config.ts new file mode 100644 index 0000000..9deffc2 --- /dev/null +++ b/src/server/claude-code-config.ts @@ -0,0 +1,13 @@ +import { getClaudeBinary } from "../agent/claude-code/binary.ts"; +import { settingsStore } from "../store/settings.ts"; + +/** Fresh admission state, not a process-wide cache. Existing runs retain + * their engines; only new chat/job/extraction/compaction admissions consult it. */ +export function claudeCodeEnabled(): boolean { + return settingsStore.readSettings()?.claudeCode?.enabled === true; +} + +/** Installation/opt-in availability only, never authenticated readiness. */ +export function claudeCodeAvailable(): boolean { + return claudeCodeEnabled() && getClaudeBinary() !== null; +} diff --git a/src/server/claude-code.ts b/src/server/claude-code.ts index bd40ebe..c6b0c3b 100644 --- a/src/server/claude-code.ts +++ b/src/server/claude-code.ts @@ -1,6 +1,7 @@ import { inspectClaudeInstallation } from "../agent/claude-code/installation.ts"; import { initAuthEnv } from "./keys.ts"; import { subscriptionRequestOrigin } from "./web-subscription-http.ts"; +import { claudeCodeEnabled } from "./claude-code-config.ts"; /** The Settings status seam: initialize provenance before any child env is copied. */ export async function claudeCodeInstallationStatus() { @@ -13,6 +14,7 @@ export async function claudeCodeInstallationStatus() { export async function claudeCodeInstallationResponse( request: Request, inspect = claudeCodeInstallationStatus, + enabled = claudeCodeEnabled, ): Promise { const origin = subscriptionRequestOrigin(request); const supplied = request.headers.get("origin"); @@ -23,5 +25,6 @@ export async function claudeCodeInstallationResponse( || (supplied !== null && supplied !== origin)) { return Response.json({ error: "Claude Code checks require a same-origin LearnHarness request." }, { status: 403, headers }); } + if (!enabled()) return Response.json({ error: "Claude Code is disabled." }, { status: 403, headers }); return Response.json(await inspect(), { headers }); } diff --git a/src/server/keys.ts b/src/server/keys.ts index 03b8d4c..502bce2 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -53,7 +53,7 @@ import { } from "../core/auth-keys.ts"; import { authStore } from "../store/auth.ts"; import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts"; -import { getClaudeBinary } from "../agent/claude-code/binary.ts"; +import { claudeCodeAvailable } from "./claude-code-config.ts"; import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts"; // Shared HMR-stable provenance lets external CLIs exclude file-only injections @@ -189,8 +189,9 @@ export function keyValue(id: string): string | undefined { * (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take * no key at all: nothing to gate, present by definition. */ export function providerKeyPresent(provider: string): boolean { - // Presence only, not authentication readiness. Launch performs the version gate. - if (provider === CLAUDE_CODE_PROVIDER) return getClaudeBinary() !== null; + // Explicit opt-in + installation only, never authentication readiness. + // Consulted at admission, not inside already-running agent loops. + if (provider === CLAUDE_CODE_PROVIDER) return claudeCodeAvailable(); if (isSubscriptionProvider(provider)) return subscriptionConnected(provider); const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider); return info ? keyValue(info.envVar) !== undefined : true; diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index 6605788..f2fc362 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -31,6 +31,7 @@ export async function refreshConnectionCatalog(provider?: string): Promise ]); } function connectionModel(provider: string, id: string): Model | undefined { + if (provider === CLAUDE_CODE_PROVIDER && !claudeCodeAvailable()) return undefined; return provider === "chatgpt" ? chatGPTModel(id) : nativeSubscriptionId(provider) ? nativeSubscriptionModel(provider, id) : piModels().getModel(provider, id); } function connectionProviderIds(): string[] { return [...piModels().getProviders().map((p) => p.id), "chatgpt", ...NATIVE_SUBSCRIPTIONS.map((p) => p.logicalId)]; } @@ -48,7 +49,7 @@ import type { } from "../core/contracts/models.ts"; import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts"; import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts"; -import { getClaudeBinary } from "../agent/claude-code/binary.ts"; +import { claudeCodeAvailable } from "./claude-code-config.ts"; import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts"; import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts"; @@ -143,7 +144,7 @@ export function catalogProviders(): CatalogProvider[] { keyPresent: keyValue(p.envVar) !== undefined, })).concat([ { id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() }, - { id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: getClaudeBinary() !== null }, + { id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: claudeCodeAvailable() }, ...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })), ]).sort((a, b) => a.id.localeCompare(b.id)); } diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index 9b1a173..81ae0f2 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -91,6 +91,15 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult { + const raw = asRecord(body); + if (!raw) return { ok: false, error: "Invalid JSON body" }; + if (raw.enabled === undefined) return { ok: true, settings: {} }; + if (typeof raw.enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" }; + return { ok: true, settings: { enabled: raw.enabled } }; +} + /** agent.models: only the six known background roles; each override's * fields mirror ChatSettings (model pair + independent thinkingLevel). */ function parseRoleOverride(value: unknown, role: string): { ok: true; override: RoleModelOverride } | { ok: false; error: string } { @@ -397,6 +406,7 @@ export const SETTINGS_SECTIONS = { compaction: parseCompactionSettingsBody, models: parseModelsSettingsBody, dictation: parseDictationSettingsBody, + claudeCode: parseClaudeCodeSettingsBody, } as const; export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS; diff --git a/tests/claude-code-harness.test.ts b/tests/claude-code-harness.test.ts index 78bedb2..aa6d735 100644 --- a/tests/claude-code-harness.test.ts +++ b/tests/claude-code-harness.test.ts @@ -15,19 +15,26 @@ import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; -import test, { type TestContext } from "node:test"; +import test, { after, type TestContext } from "node:test"; -import { PATHS } from "../config.ts"; -import { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } from "../src/agent/claude-code/live-processes.ts"; -import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; -import { createClaudeCodeStreamFunction, type RunClaude } from "../src/agent/claude-code/stream.ts"; +import type { RunClaude } from "../src/agent/claude-code/stream.ts"; import type { PiEngine } from "../src/agent/models.ts"; -import { createTeacherAgent } from "../src/agent/teacher-agent.ts"; -import { cliContextWindowOf, danglingQuestionOf } from "../src/core/native-messages.ts"; -import { handleChatRequest } from "../src/server/chat-handler.ts"; -import { isTurnRunning } from "../src/server/turn-liveness.ts"; -import { runningTurn } from "../src/server/turn-runs.ts"; -import { chatStore } from "../src/store/chats.ts"; + +// Opt-in writes must not race another test process's settings record. +const testData = mkdtempSync(join(tmpdir(), "lh-cli-harness-data-")); +process.env.LH_DATA_DIR = testData; +after(() => rmSync(testData, { recursive: true, force: true })); +const { PATHS, SETTINGS_FILE } = await import("../config.ts"); +const { readSettings, writeSettings } = await import("../src/store/settings.ts"); +const { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } = await import("../src/agent/claude-code/live-processes.ts"); +const { CLAUDE_CODE_MODELS } = await import("../src/agent/claude-code/provider.ts"); +const { createClaudeCodeStreamFunction } = await import("../src/agent/claude-code/stream.ts"); +const { createTeacherAgent } = await import("../src/agent/teacher-agent.ts"); +const { cliContextWindowOf, danglingQuestionOf } = await import("../src/core/native-messages.ts"); +const { handleChatRequest } = await import("../src/server/chat-handler.ts"); +const { isTurnRunning } = await import("../src/server/turn-liveness.ts"); +const { runningTurn } = await import("../src/server/turn-runs.ts"); +const { chatStore } = await import("../src/store/chats.ts"); const FAKE_CLAUDE = fileURLToPath(new URL("./fixtures/claude-code/fake-claude.mjs", import.meta.url)); const TEXT_FIXTURE = fileURLToPath(new URL("./fixtures/claude-code/text.jsonl", import.meta.url)); @@ -61,6 +68,9 @@ function toAskScenario(): string[] { } function createFakeRun(t: TestContext, scenarios: string[][]): FakeRun { + const settings = readSettings(); + writeSettings({ ...(settings ?? { version: 1 }), claudeCode: { enabled: true } }); + t.after(() => { if (settings) writeSettings(settings); else rmSync(SETTINGS_FILE, { force: true }); }); const dir = mkdtempSync(join(tmpdir(), "claude-code-harness-")); t.after(() => rmSync(dir, { recursive: true, force: true })); // Satisfy the real presence gate; this marker is NEVER executed. All work @@ -155,6 +165,47 @@ async function waitForExit(child: ChildProcessWithoutNullStreams): Promise await once(child, "exit", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) }); } +test("disabled opt-in refuses a saved CLI chat before write-ahead, liveness, or agent creation", async (t) => { + const fakeRun = createFakeRun(t, [toAskScenario()]); + const chatId = createClaudeCodeChat(t); + writeSettings({ ...(readSettings() ?? { version: 1 }), claudeCode: { enabled: false } }); + const before = chatStore.getChat(chatId)!.messages; + const response = await handleChatRequest(new Request("http://test/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chatId, input: { type: "text", text: "must not start" } }) }), () => assert.fail("disabled work must not build an agent")); + assert.equal(response.status, 503); + assert.match((await response.json()).error, /Enable and set it up in Settings/); + assert.equal(isTurnRunning(chatId), false); assert.equal(fakeRun.children.length, 0); + assert.deepEqual(chatStore.getChat(chatId)!.messages, before); + assert.deepEqual(chatStore.getChat(chatId)!.settings?.model, { provider: "claude-code", id: "sonnet" }); +}); + +test("switching off blocks future requests but does not interrupt an already admitted CLI turn", async (t) => { + const scenario = [JSON.stringify({ fake: "checkpoint" }), JSON.stringify({ fake: "wait-for-message" }), ...readFileSync(TEXT_FIXTURE, "utf8").trim().split("\n")]; + const fakeRun = createFakeRun(t, [scenario]); + const run = fakeRun.runClaude; + let ready!: (child: ChildProcessWithoutNullStreams) => void; + let failed!: (error: unknown) => void; + const checkpoint = new Promise((resolve, reject) => { ready = resolve; failed = reject; }); + fakeRun.runClaude = async (args, options) => { + const child = await run(args, options); assert(child); + void once(child, "message", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) }).then(() => ready(child), failed); + return child; + }; + const chatId = createClaudeCodeChat(t); + const response = await handleChatRequest(new Request("http://test/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chatId, input: { type: "text", text: "complete this admitted fixture" } }) }), createAgentFactory(fakeRun)); + assert.equal(response.status, 200); + const child = await checkpoint; + const handle = runningTurn(chatId); assert(handle); + const finished = new Response(handle.subscribe()).text(); + writeSettings({ ...(readSettings() ?? { version: 1 }), claudeCode: { enabled: false } }); + assert.equal(child.signalCode, null, "opt-out must not kill an already running process"); + child.send("continue"); await finished; + assert(getLastAssistantText(chatId).length > 0, "the admitted turn completes normally"); + assert.equal(readSettings()?.claudeCode?.enabled, false); + await waitForExit(child); +}); + test("a question answered before the idle timeout continues the waiting CLI process with the answer", async (t) => { const fakeRun = createFakeRun(t, [toAskScenario()]); const run = fakeRun.runClaude; diff --git a/tests/claude-code-opt-in.test.ts b/tests/claude-code-opt-in.test.ts new file mode 100644 index 0000000..b0e0026 --- /dev/null +++ b/tests/claude-code-opt-in.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import test, { after, type TestContext } from "node:test"; + +const testData = mkdtempSync(join(tmpdir(), "lh-cli-opt-in-data-")); +process.env.LH_DATA_DIR = testData; +after(() => rmSync(testData, { recursive: true, force: true })); +const { SETTINGS_FILE } = await import("../config.ts"); +const { PUT } = await import("../app/api/settings/[section]/route.ts"); +const { sanitizeUserSettings, providerKeyMissing } = await import("../src/core/settings.ts"); +const { parseClaudeCodeSettingsBody } = await import("../src/server/settings-body.ts"); +const { claudeCodeAvailable, claudeCodeEnabled } = await import("../src/server/claude-code-config.ts"); +const { claudeCodeInstallationResponse } = await import("../src/server/claude-code.ts"); +const { modelCatalog, validateModelsSettings } = await import("../src/server/model-catalog.ts"); +const { providerKeyPresent } = await import("../src/server/keys.ts"); +const { readSettings, writeSettings } = await import("../src/store/settings.ts"); +const { chatStore } = await import("../src/store/chats.ts"); +const { jobStore } = await import("../src/store/jobs.ts"); +const { createJobRunner } = await import("../src/agent/subagents/runner.ts"); +const { CLAUDE_CODE_MODELS } = await import("../src/agent/claude-code/provider.ts"); + +function setup(t: TestContext) { + const previous = readSettings(); + const path = process.env.PATH; + const dir = mkdtempSync(join(tmpdir(), "claude-opt-in-")); + // Never executed: only installation discovery observes this marker. + writeFileSync(join(dir, process.platform === "win32" ? "claude.exe" : "claude"), "", { mode: 0o755 }); + process.env.PATH = `${dir}${delimiter}${path ?? ""}`; + t.after(() => { + if (previous) writeSettings(previous); else rmSync(SETTINGS_FILE, { force: true }); + if (path === undefined) delete process.env.PATH; else process.env.PATH = path; + rmSync(dir, { recursive: true, force: true }); + }); + writeSettings({ version: 1 }); +} +async function save(body: unknown) { + return PUT(new Request("http://test/api/settings/claudeCode", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), + { params: Promise.resolve({ section: "claudeCode" }) }); +} + +test("opt-in parses strictly and malformed stored values never enable the CLI", () => { + assert.deepEqual(parseClaudeCodeSettingsBody({}), { ok: true, settings: {} }); + assert.deepEqual(parseClaudeCodeSettingsBody({ enabled: true, ignored: true }), { ok: true, settings: { enabled: true } }); + assert.deepEqual(parseClaudeCodeSettingsBody({ enabled: false }), { ok: true, settings: { enabled: false } }); + for (const raw of [true, null, [], "on", { enabled: "true" }, { enabled: 1 }]) assert.equal(parseClaudeCodeSettingsBody(raw).ok, false); + for (const raw of [null, [], true, "on", { enabled: "true" }, { enabled: 1 }]) { + assert.notEqual(sanitizeUserSettings({ version: 1, claudeCode: raw })?.claudeCode?.enabled, true); + } + assert.equal(sanitizeUserSettings({ version: 1 })?.claudeCode, undefined); +}); + +test("default off even with an installed binary; section writes toggle availability but preserve saved model pins and unrelated settings", async (t) => { + setup(t); + const models = { strong: { model: { provider: "claude-code", id: "sonnet" } }, cheap: { model: { provider: "claude-code", id: "haiku" } } }; + writeSettings({ version: 1, models, graph: { enabled: false }, futureSection: { keep: 1 } }); + assert.equal(claudeCodeEnabled(), false); assert.equal(claudeCodeAvailable(), false); + assert.equal(providerKeyPresent("claude-code"), false); + assert(!modelCatalog().models.some((m) => m.provider === "claude-code")); + assert.equal((await save({ enabled: true })).status, 200); + assert.equal(claudeCodeEnabled(), true); assert.equal(claudeCodeAvailable(), true); + assert.equal(modelCatalog().models.filter((m) => m.provider === "claude-code").length, 3); + assert.equal((await save({ enabled: false })).status, 200); + assert.equal(claudeCodeAvailable(), false); + assert(!modelCatalog().models.some((m) => m.provider === "claude-code")); + assert.deepEqual(readSettings()?.models, models); + assert.deepEqual(readSettings()?.graph, { enabled: false }); + assert.deepEqual(readSettings()?.futureSection, { keep: 1 }); + assert.equal(validateModelsSettings(models, models).ok, true, "saved unavailable pins remain repairable, not silently removed"); + assert.equal((await save({ enabled: "true" })).status, 400); assert.equal(claudeCodeEnabled(), false); + assert.equal((await save({})).status, 200); assert.equal(claudeCodeEnabled(), false); +}); + +test("off cannot launch a version probe even through an otherwise authorized HTTP request", async (t) => { + setup(t); + const request = new Request("http://test/api/claude-code", { headers: { "x-learnharness-claude-code": "1" } }); + const response = await claudeCodeInstallationResponse(request, async () => assert.fail("disabled CLI must not execute")); + assert.equal(response.status, 403); +}); + +test("a saved CLI background job is refused before model execution while opt-in is off", async (t) => { + setup(t); + const chat = chatStore.createChat("CLI opt-in job fixture"); + t.after(() => { jobStore.deleteJobsForChat(chat.id); chatStore.deleteChat(chat.id); }); + const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "cli-off-job" }); + let calls = 0; + const runner = createJobRunner({ chatStore, jobStore, deliver: async () => {}, + engineFor: () => ({ model: CLAUDE_CODE_MODELS[0]!, streamFn: () => { calls++; assert.fail("must not run a disabled CLI model"); } }), + providerKeyMissing: (provider, tier) => providerKeyPresent(provider) ? null : providerKeyMissing(provider, [tier]), + }); + await runner.runJob(job.id, { question: "Synthetic fixture only" }); + assert.equal(calls, 0); + const refused = jobStore.getJob(job.id)!; + assert.equal(refused.status, "error"); + assert.match(JSON.stringify(refused.result), /Enable and set it up in Settings/); + assert(!JSON.stringify(refused.result).includes("add one in Settings → Keys")); +}); diff --git a/tests/claude-code-status-http.test.ts b/tests/claude-code-status-http.test.ts index 1c8f746..b9bed2d 100644 --- a/tests/claude-code-status-http.test.ts +++ b/tests/claude-code-status-http.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { claudeCodeInstallationResponse } from "../src/server/claude-code.ts"; +import { claudeCodeInstallationResponse as respond } from "../src/server/claude-code.ts"; +const claudeCodeInstallationResponse: typeof respond = (request, inspect) => respond(request, inspect, () => true); function request(headers: Record = {}) { return new Request("http://localhost:3000/api/claude-code", { headers: { "x-learnharness-claude-code": "1", ...headers } }); @@ -20,6 +21,12 @@ for (const headers of [ assert.equal(response.headers.get("access-control-allow-origin"), null); }); +test("disabled opt-in never starts even a version probe", async () => { + let probes = 0; + const response = await respond(request(), async () => { probes++; return status; }, () => false); + assert.equal(response.status, 403); assert.equal(probes, 0); +}); + test("ordinary image/subresource GET without intent header cannot start a probe", async () => { const response = await claudeCodeInstallationResponse(new Request("http://localhost:3000/api/claude-code"), async () => assert.fail("must not probe")); assert.equal(response.status, 403); diff --git a/tests/claude-code-status-ui.test.tsx b/tests/claude-code-status-ui.test.tsx index 1afbf0d..ee92d29 100644 --- a/tests/claude-code-status-ui.test.tsx +++ b/tests/claude-code-status-ui.test.tsx @@ -7,45 +7,81 @@ import { renderToStaticMarkup } from "react-dom/server"; import { parseHTML } from "linkedom"; import { ClaudeCodeInstallationStatus, claudeCodeStatusLabel } from "../components/settings/claude-code-status.tsx"; import { ConnectionRow } from "../components/settings/connection-controls.tsx"; +import { useModelCatalog } from "../components/model-catalog.ts"; import type { ClaudeCodeStatus } from "../src/core/contracts/claude-code.ts"; const dom = parseHTML(""); -Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); +Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, + CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true }); const originalFetch = globalThis.fetch; let root: Root | undefined, host: HTMLDivElement | undefined; const status = (state: ClaudeCodeStatus["state"], version: string | null = null): ClaudeCodeStatus => ({ state, version, minimumVersion: "2.1.259" }); const button = () => document.querySelector('[data-testid="claude-code-check-again"]')!; +const toggle = () => document.querySelector('[role="switch"]')!; const label = () => document.querySelector('[role="status"]')?.textContent; -async function mount() { +function fixture(probe: (init?: RequestInit) => Promise, enabled = true, failSave = false) { + const calls: string[] = []; + let on = enabled; + globalThis.fetch = (async (url, init) => { + calls.push(`${init?.method ?? "GET"} ${url}`); + if (url === "/api/settings") return Response.json({ settings: { version: 1, claudeCode: { enabled: on } } }); + if (url === "/api/settings/claudeCode") { + assert.equal(init?.method, "PUT"); assert.equal(new Headers(init.headers).get("Content-Type"), "application/json"); + if (failSave) return new Response(null, { status: 500 }); + on = JSON.parse(String(init.body)).enabled; + return Response.json({ settings: { version: 1, claudeCode: { enabled: on } } }); + } + if (url === "/api/models") return Response.json({ models: on ? [{ provider: "claude-code", id: "sonnet" }] : [] }); + assert.equal(url, "/api/claude-code"); + assert.equal(init?.cache, "no-store"); + assert.equal(new Headers(init?.headers).get("x-learnharness-claude-code"), "1"); + return probe(init); + }) as typeof fetch; + return calls; +} +async function mount(extra?: React.ReactNode) { host = document.createElement("div"); document.body.append(host); root = createRoot(host); - await act(async () => root!.render()); + await act(async () => root!.render(<>{extra})); } afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; globalThis.fetch = originalFetch; }); -test("row is informational, disclaims auth/billing readiness, and links only to official setup", () => { +test("initial render is collapsed and off, with no setup prose or version probe action", () => { const html = renderToStaticMarkup(); - assert.match(html, /Authentication is not checked/); - assert.match(html, /configuration determines billing/); - assert.match(html, /does not guarantee MCP compatibility/); - assert.match(html, /https:\/\/code.claude.com\/docs\/en\/setup/); + assert.match(html, /Claude Code/); assert.match(html, /aria-checked="false"/); assert.equal((html.match(/")); - assert.match(html, /data-variant="outline"/); - assert.match(html, /data-size="xs"/); + assert(!html.includes("Check again")); assert(!html.includes("Authenticate")); assert(!html.includes("Setup ↗")); }); -test("recheck matches the existing Connect button styling without restyling Manage actions", () => { - const row = (action: string) => renderToStaticMarkup( {}} />); - const connect = parseHTML(row("Connect")).document.querySelector("button")!; - const manage = parseHTML(row("Manage")).document.querySelector("button")!; - const check = parseHTML(renderToStaticMarkup()).document.querySelector("button")!; - assert.equal(check.getAttribute("class"), connect.getAttribute("class")); - assert.equal(check.getAttribute("data-variant"), "outline"); - assert.equal(manage.getAttribute("data-variant"), "ghost"); +test("off by default; enabling persists before probing and reveals the version/action row below the toggle", async () => { + const calls = fixture(async () => Response.json(status("not-found")), false); + await mount(); + assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button()); + assert.deepEqual(calls, ["GET /api/settings"]); + await act(async () => toggle().click()); + assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.equal(label(), "Not found"); + assert(calls.indexOf("PUT /api/settings/claudeCode") < calls.indexOf("GET /api/claude-code")); + assert.notEqual(toggle().parentElement, button().parentElement); + assert.equal(document.querySelector('[role="status"]')?.parentElement, button().parentElement); + assert.match(document.body.textContent ?? "", /no background process required/); + assert.match(document.body.textContent ?? "", /not authentication, model access, or quota/); + assert.match(document.body.textContent ?? "", /Claude Code controls billing/); + assert.match(document.body.textContent ?? "", /Verify AI claims independently—they may be inaccurate, incomplete, misleading, or outdated\./); + assert(document.querySelector("strong")?.textContent?.includes("Install and authenticate")); + assert.equal(document.querySelector("a")?.getAttribute("href"), "https://code.claude.com/docs/en/setup"); + await act(async () => toggle().click()); + assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button()); assert.equal(label(), undefined); + assert.equal(calls.filter((call) => call === "GET /api/claude-code").length, 1); +}); + +test("recheck matches Connect styling; Manage remains ghost", async () => { + fixture(async () => Response.json(status("not-found"))); await mount(); + const row = (action: string) => parseHTML(renderToStaticMarkup( {}} />)).document.querySelector("button")!; + assert.equal(button().getAttribute("class"), row("Connect").getAttribute("class")); + assert.equal(button().getAttribute("data-variant"), "outline"); + assert.equal(row("Manage").getAttribute("data-variant"), "ghost"); }); for (const [value, text] of [ @@ -55,41 +91,51 @@ for (const [value, text] of [ [status("version-unchecked"), "Version could not be checked"], ] as const) test(`installation display: ${text}`, () => assert.equal(claudeCodeStatusLabel(value), text)); -test("mount checks once, Check again refreshes, and neither request asks for authentication", async () => { - const calls: string[] = []; - globalThis.fetch = (async (url, init) => { - calls.push(String(url)); - assert.equal(init?.cache, "no-store"); - assert.equal(new Headers(init?.headers).get("x-learnharness-claude-code"), "1"); - return Response.json(calls.length === 1 ? status("not-found") : status("meets-minimum", "2.1.310")); - }) as typeof fetch; - await mount(); - assert.equal(label(), "Not found"); - await act(async () => button().click()); - assert.equal(label(), "Version 2.1.310 · meets minimum"); - assert.deepEqual(calls, ["/api/claude-code", "/api/claude-code"]); +test("enabled mount checks once; outlined Check again refreshes without probing authentication", async () => { + let probes = 0; + const calls = fixture(async () => Response.json(++probes === 1 ? status("not-found") : status("meets-minimum", "2.1.310"))); + await mount(); assert.equal(label(), "Not found"); + await act(async () => button().click()); assert.equal(label(), "Version 2.1.310 · meets minimum"); + assert.equal(calls.filter((call) => call === "GET /api/claude-code").length, 2); }); -test("slow checks disable recheck and unmount aborts outstanding work", async () => { +test("failed opt-in save stays collapsed/off and never probes", async () => { + const calls = fixture(async () => assert.fail("must not probe"), false, true); + await mount(); await act(async () => toggle().click()); + assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button()); + assert.match(document.querySelector('[role="alert"]')?.textContent ?? "", /Could not save/); + assert(!calls.includes("GET /api/claude-code")); +}); + +test("slow checks disable recheck; switching off aborts pending version work without starting another probe", async () => { let signal: AbortSignal | null | undefined; let resolve!: (response: Response) => void; - globalThis.fetch = ((_url, init) => { signal = init?.signal; return new Promise((done) => { resolve = done; }); }) as typeof fetch; - await mount(); - assert.equal(label(), "Checking…"); assert(button().disabled); - await act(async () => { root!.unmount(); root = undefined; }); - assert(signal?.aborted); - await act(async () => resolve(Response.json(status("not-found")))); - assert.equal(document.querySelector('[role="status"]'), null); + fixture((init) => { signal = init?.signal; return new Promise((done) => { resolve = done; }); }); + await mount(); assert.equal(label(), "Checking…"); assert(button().disabled); assert(!toggle().disabled); + await act(async () => toggle().click()); assert(signal?.aborted); assert(!button()); + await act(async () => resolve(Response.json(status("meets-minimum", "2.1.310")))); assert.equal(label(), undefined); }); for (const failure of ["network", "http", "payload"] as const) test(`${failure} failure never retains a stale meets-minimum status`, async () => { let calls = 0; - globalThis.fetch = (async () => { + fixture(async () => { if (++calls === 1) return Response.json(status("meets-minimum", "2.1.310")); if (failure === "network") throw new Error("offline"); return failure === "http" ? new Response(null, { status: 503 }) : Response.json({ state: "authenticated" }); - }) as typeof fetch; + }); await mount(); assert.equal(label(), "Version 2.1.310 · meets minimum"); - await act(async () => button().click()); - assert.equal(label(), "Version could not be checked"); assert(!button().disabled); + await act(async () => button().click()); assert.equal(label(), "Version could not be checked"); assert(!button().disabled); +}); + +function CatalogReader() { + const { catalog } = useModelCatalog(); + return {catalog?.models.map((m) => `${m.provider}:${m.id}`).join(",") ?? "loading"}; +} +test("persisted toggle updates invalidate shared model availability in both directions", async () => { + fixture(async () => Response.json(status("not-found")), false); + await mount(); + const models = () => document.querySelector('[data-testid="cli-catalog-models"]')?.textContent; + assert.equal(models(), ""); + await act(async () => toggle().click()); assert.equal(models(), "claude-code:sonnet"); + await act(async () => toggle().click()); assert.equal(models(), ""); }); diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index 9af9c7d..da2242c 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -1,9 +1,12 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { describe, it } from "node:test"; +import { after, describe, it } from "node:test"; import assert from "node:assert/strict"; -import { +const testData = fs.mkdtempSync(path.join(os.tmpdir(), "lh-cli-catalog-data-")); +process.env.LH_DATA_DIR = testData; +after(() => fs.rmSync(testData, { recursive: true, force: true })); +const { catalogContextLimits, catalogDefaultsFor, catalogProviders, @@ -16,17 +19,18 @@ import { toModelDto, validateChatSettings, validateModelsSettings, -} from "../src/server/model-catalog.ts"; +} = await import("../src/server/model-catalog.ts"); import type { EffectiveSlot } from "../src/core/settings-resolution.ts"; import type { SettingsValidation } from "../src/server/model-catalog.ts"; import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import { DEFAULT_THINKING_ORDER } from "../src/core/settings.ts"; -import { piEngineForSpec, piModels } from "../src/agent/models.ts"; +const { piEngineForSpec, piModels } = await import("../src/agent/models.ts"); import { BACKGROUND_MODEL_ROLES } from "../src/core/settings.ts"; import { resolveSettings } from "../src/core/settings-resolution.ts"; -import { readSettings } from "../src/store/settings.ts"; -import { effectiveSettings } from "../src/server/stores.ts"; -import { providerKeyPresent } from "../src/server/keys.ts"; +const { readSettings, writeSettings } = await import("../src/store/settings.ts"); +const { SETTINGS_FILE } = await import("../config.ts"); +const { effectiveSettings } = await import("../src/server/stores.ts"); +const { providerKeyPresent } = await import("../src/server/keys.ts"); // The model-control wave (2026-09-12): pi-ai owns the registry — these pins // hold our projection + validation honest against the shipped catalog, and @@ -337,9 +341,11 @@ describe("pi thinking truth (pinned against pi-ai upgrades)", () => { }); /** Runs fn with PATH and HOME pointed at a temp dir, so the CLI lookup only * finds a claude binary when installed is true. */ -function withClaudeInstalled(installed: boolean, fn: () => void): void { +function withClaudeInstalled(installed: boolean, fn: () => void, enabled = true): void { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-catalog-")); const saved = { PATH: process.env.PATH, HOME: process.env.HOME, npm_config_prefix: process.env.npm_config_prefix }; + const settings = readSettings(); + writeSettings({ ...(settings ?? { version: 1 }), claudeCode: { enabled } }); if (installed) fs.writeFileSync(path.join(dir, "claude"), "#!/bin/sh\n", { mode: 0o755 }); process.env.PATH = dir; @@ -353,6 +359,8 @@ function withClaudeInstalled(installed: boolean, fn: () => void): void { if (value === undefined) delete process.env[name]; else process.env[name] = value; } + if (settings) writeSettings(settings); + else fs.rmSync(SETTINGS_FILE, { force: true }); fs.rmSync(dir, { recursive: true, force: true }); } } @@ -397,8 +405,14 @@ describe("the claude-code provider (native CLI authentication, no LearnHarness k assert.equal(engine.model.id, "sonnet"); }); - it('providerKeyPresent("claude-code") follows executable presence, not a generic keyless default', () => { + it('providerKeyPresent("claude-code") requires both explicit opt-in and executable presence', () => { withClaudeInstalled(true, () => assert.equal(providerKeyPresent("claude-code"), true)); withClaudeInstalled(false, () => assert.equal(providerKeyPresent("claude-code"), false)); + withClaudeInstalled(true, () => { + assert.equal(providerKeyPresent("claude-code"), false); + assert.equal(catalogProviders().find((p) => p.id === "claude-code")?.keyPresent, false); + assert(!modelCatalog().models.some((m) => m.provider === "claude-code")); + assert.equal(validateChatSettings({ model: { provider: "claude-code", id: "sonnet" } }).ok, false); + }, false); }); }); diff --git a/tests/settings-body.test.ts b/tests/settings-body.test.ts index 16c0ac9..7c7df16 100644 --- a/tests/settings-body.test.ts +++ b/tests/settings-body.test.ts @@ -239,7 +239,7 @@ describe("SETTINGS_SECTIONS (the route's section map)", () => { // threshold is engine behavior, not an agent-section key. assert.deepEqual( Object.keys(SETTINGS_SECTIONS), - ["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation"], + ["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation", "claudeCode"], ); }); });