Window reported by Claude Code; may be lower for auto-compaction.
+ )}
{subscription ? "Usage" : "Total cost"}
{costLabel}
@@ -178,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/package-lock.json b/package-lock.json
index 0a2f1db..6ed8114 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",
@@ -1615,6 +1616,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",
@@ -2380,6 +2393,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",
@@ -6443,6 +6518,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",
@@ -6490,6 +6603,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",
@@ -7051,6 +7203,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",
@@ -7157,6 +7362,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",
@@ -7219,7 +7433,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",
@@ -7233,7 +7446,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",
@@ -7675,6 +7887,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",
@@ -7682,6 +7934,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",
@@ -7704,7 +7973,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",
@@ -8469,6 +8737,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",
@@ -8710,7 +8987,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",
@@ -8740,6 +9016,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",
@@ -9077,6 +9359,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",
@@ -9225,7 +9516,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"
@@ -9235,7 +9525,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"
@@ -9245,7 +9534,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"
@@ -9381,6 +9669,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",
@@ -9823,6 +10117,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",
@@ -9830,6 +10154,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",
@@ -9840,7 +10264,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": {
@@ -9897,7 +10320,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",
@@ -10018,6 +10440,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",
@@ -10101,6 +10544,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",
@@ -10124,6 +10576,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",
@@ -10164,7 +10625,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"
@@ -10267,7 +10727,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",
@@ -10301,7 +10760,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",
@@ -10503,7 +10961,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"
@@ -10606,7 +11063,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"
@@ -10635,7 +11091,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"
@@ -10894,6 +11349,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",
@@ -11038,6 +11502,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",
@@ -11154,7 +11638,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": {
@@ -11187,6 +11670,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",
@@ -11526,6 +12027,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",
@@ -11695,7 +12202,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": {
@@ -11793,6 +12299,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",
@@ -12366,7 +12878,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"
@@ -12729,6 +13240,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",
@@ -13644,6 +14180,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.4.3",
"resolved": "https://registry.npmjs.org/next/-/next-16.4.3.tgz",
@@ -13966,11 +14531,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"
@@ -14079,11 +14652,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"
@@ -14291,6 +14879,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",
@@ -14327,7 +14924,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"
@@ -14340,6 +14936,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",
@@ -14386,6 +14992,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",
@@ -14642,6 +15257,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",
@@ -14717,6 +15349,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",
@@ -14828,6 +15476,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",
@@ -15289,7 +15981,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"
@@ -15449,6 +16140,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",
@@ -15616,6 +16323,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",
@@ -15633,6 +16391,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",
@@ -15682,6 +16459,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.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
@@ -15736,7 +16519,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"
@@ -15749,7 +16531,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"
@@ -15772,7 +16553,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",
@@ -15792,7 +16572,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",
@@ -15809,7 +16588,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",
@@ -15828,7 +16606,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",
@@ -15942,6 +16719,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",
@@ -16433,6 +17219,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",
@@ -16591,6 +17386,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",
@@ -16866,6 +17717,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",
@@ -17037,6 +17897,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",
@@ -17122,7 +17991,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"
@@ -17271,7 +18139,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": {
@@ -17369,6 +18236,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 6e1464e..f22b7e3 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/src/agent/claude-code/binary.ts b/src/agent/claude-code/binary.ts
new file mode 100644
index 0000000..1ee1871
--- /dev/null
+++ b/src/agent/claude-code/binary.ts
@@ -0,0 +1,90 @@
+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;
+ cwd?: 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,
+ // 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"),
+ 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, cwd = process.cwd(), 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 name of names) {
+ for (const dir of [...pathDirs, ...knownDirs]) {
+ // 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;
+ }
+ }
+
+ 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/src/agent/claude-code/cli-events.ts b/src/agent/claude-code/cli-events.ts
new file mode 100644
index 0000000..f3dddca
--- /dev/null
+++ b/src/agent/claude-code/cli-events.ts
@@ -0,0 +1,188 @@
+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: "toolResults"; ids: 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; tool_use_id?: 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 = Array.isArray(line.message?.content) ? 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 "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) };
+
+ default:
+ return null;
+ }
+}
diff --git a/src/agent/claude-code/constants.ts b/src/agent/claude-code/constants.ts
new file mode 100644
index 0000000..488f3f0
--- /dev/null
+++ b/src/agent/claude-code/constants.ts
@@ -0,0 +1,8 @@
+export { CLAUDE_CODE_PROVIDER } from "../../core/contracts/claude-code.ts";
+
+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/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/installation.ts b/src/agent/claude-code/installation.ts
new file mode 100644
index 0000000..9189922
--- /dev/null
+++ b/src/agent/claude-code/installation.ts
@@ -0,0 +1,38 @@
+import { execFile } from "node:child_process";
+import { tmpdir } from "node:os";
+import { externalCliEnv } from "../../core/auth-env.ts";
+import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "../../core/contracts/claude-code.ts";
+import { getClaudeBinary } from "./binary.ts";
+
+export type ReadClaudeVersion = (binary: string) => 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/live-processes.ts b/src/agent/claude-code/live-processes.ts
new file mode 100644
index 0000000..ebb729b
--- /dev/null
+++ b/src/agent/claude-code/live-processes.ts
@@ -0,0 +1,43 @@
+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.
+// 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). */
+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 {
+ removeLiveProcess(process);
+ 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 && new Set(otherIds).size === otherIds.length && ids.every((id) => otherIds.includes(id));
+}
+
+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 && process.sessionId === sessionId && 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/provider.ts b/src/agent/claude-code/provider.ts
new file mode 100644
index 0000000..0165e04
--- /dev/null
+++ b/src/agent/claude-code/provider.ts
@@ -0,0 +1,34 @@
+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"],
+ // 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.
+ 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/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
new file mode 100644
index 0000000..ba96098
--- /dev/null
+++ b/src/agent/claude-code/stream.ts
@@ -0,0 +1,695 @@
+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";
+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 ToolCall,
+ type ToolResultMessage,
+ type TranscriptContext,
+} 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 { 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";
+import { toClaudeInput, toStreamJsonLine } from "./transcript.ts";
+
+type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv; signal?: AbortSignal };
+
+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
+// 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",
+ "user",
+ "--settings",
+ JSON.stringify({ disableAllHooks: true }),
+ "--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 & Pick;
+ private readonly contentIndexes = new Map();
+ private readonly partialJson = new Map();
+ private readonly toolBlocks = new Map();
+ private readonly intents: ToolCall[] = [];
+ 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 { 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);
+ return;
+ }
+
+ case "blockStop":
+ this.stopBlock(event.index);
+ return;
+
+ case "messageDelta":
+ this.updateUsage(event.usage);
+ if (event.stopReason) this.setStopReason(event.stopReason);
+ return;
+
+ case "messageStop":
+ // 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":
+ this.fail(getAssistantErrorMessage(event.error, event.text), "error");
+ return;
+
+ case "resultError":
+ this.fail(event.text, "error");
+ return;
+ }
+ }
+
+ get waitingForAdmission(): boolean { return !this.finished && this.output.stopReason === "toolUse"; }
+
+ getIntents(): ToolCall[] { return this.intents; }
+
+ 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) {
+ 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 {
+ 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":
+ 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;
+
+ 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.toolBlocks.delete(index);
+ this.intents.push(block);
+ 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-"));
+ let privateDir: string | undefined;
+ try {
+ 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;
+ }
+}
+
+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 readonly cancelledIds = new Set();
+ private pendingError: string | undefined;
+ private contextWindow: number | undefined;
+ private contextProbe: ContextWindowProbe | undefined;
+
+ constructor(
+ private readonly child: ChildProcessWithoutNullStreams,
+ private readonly bridge: ToolBridge,
+ private readonly files: TurnFiles,
+ private turn: ClaudeTurn,
+ readonly owner: symbol,
+ readonly sessionId: string | undefined,
+ private readonly configuration: unknown,
+ private conversation: unknown[],
+ ) {}
+
+ 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 = (this.stderr + chunk).slice(-65_536); });
+ this.child.stdin.on("error", (error) => console.error("claude CLI stdin closed early:", error));
+ 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 {
+ const conversation = conversationForReuse(messages);
+ 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
+ && 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 admitted = this.admitted;
+ 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;
+ 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.contextProbe?.close();
+ 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.sessionId && 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;
+ }
+
+ 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;
+ 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()])));
+ if (this.sessionId) addLiveProcess(this);
+ else this.kill(); // No trusted scope: return the batch, then use fresh replay.
+ }
+
+ 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 {
+ 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") {
+ if (event.ids.some((id) => this.sourceCalls.some((call) => call.id === id))) {
+ this.fail("claude CLI refused a harness tool call before MCP admission");
+ }
+ 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;
+ }
+ 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) {
+ 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();
+ }
+ }
+
+ 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);
+ 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, 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, 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, sessionId, configuration, messages, results)) {
+ process.kill();
+ return false;
+ }
+ process.continueTurn(turn, messages, results, signal);
+ return true;
+}
+
+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(
+ runClaude: RunClaude,
+ turn: ClaudeTurn,
+ model: Model,
+ context: TranscriptContext,
+ 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);
+ let cliProcess: ClaudeProcess | undefined;
+ const bridge = await createToolBridge(input.tools, {
+ onCall: () => cliProcess?.flushAdmissions(),
+ onError: (error) => cliProcess?.fail(error.message),
+ onCancel: (call) => cliProcess?.cancelCall(call),
+ });
+ 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 {
+ const args = toClaudeArgs({
+ model: model.id,
+ effort: toEffort(options?.reasoning),
+ systemPromptFile: files.systemPromptFile,
+ mcpConfigFile: files.mcpConfigFile,
+ allowedTools: bridge.allowedTools,
+ });
+ child = await runClaude(args, { cwd: files.cwd, env, signal: options?.signal });
+ } catch (error) {
+ await removeTurnResources(bridge, files);
+ throw error;
+ }
+
+ if (!child) {
+ turn.fail(ClaudeCodeError.CliNotFound, "error");
+ await removeTurnResources(bridge, files);
+ return;
+ }
+
+ cliProcess = new ClaudeProcess(child, bridge, files, turn, owner, options?.sessionId, 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);
+
+ 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, env).catch((error: unknown) => {
+ console.error("claude CLI turn failed:", error);
+ turn.fail(options?.signal?.aborted ? ABORT_MESSAGE : error instanceof ClaudeLaunchError ? error.message : START_FAILED_MESSAGE, options?.signal?.aborted ? "aborted" : "error");
+ });
+
+ return stream;
+ };
+}
+
+export const streamClaudeCode = createClaudeCodeStreamFunction(createClaudeLauncher());
diff --git a/src/agent/claude-code/tool-bridge.ts b/src/agent/claude-code/tool-bridge.ts
new file mode 100644
index 0000000..71dc6de
--- /dev/null
+++ b/src/agent/claude-code/tool-bridge.ts
@@ -0,0 +1,234 @@
+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,
+ ErrorCode,
+ ListToolsRequestSchema,
+ McpError,
+ 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 = { id: string; name: string; arguments: JsonObject };
+
+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";
+
+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, 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();
+
+ 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));
+ }
+
+ list(): PendingToolCall[] {
+ return [...this.results.keys()];
+ }
+
+ clear(): void {
+ 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 = [];
+ }
+
+ 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, 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, 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);
+ 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 }, extra.signal, callbacks?.onCancel);
+ callbacks?.onCall();
+ return result;
+ });
+
+ 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[], callbacks?: BridgeCallbacks): Promise {
+ const authorization = `Bearer ${randomBytes(TOKEN_BYTES).toString("hex")}`;
+
+ const queue = new ToolCallQueue();
+ const mcpServer = createMcpServer(tools, queue, callbacks);
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
+ await mcpServer.connect(transport);
+
+ const httpServer = createHttpServer(transport, authorization);
+ let port: number;
+ try {
+ ({ port } = await listen(httpServer));
+ } catch (error) {
+ await mcpServer.close();
+ throw error;
+ }
+
+ 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/src/agent/claude-code/transcript.ts b/src/agent/claude-code/transcript.ts
new file mode 100644
index 0000000..ecc5e5d
--- /dev/null
+++ b/src/agent/claude-code/transcript.ts
@@ -0,0 +1,99 @@
+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": {
+ 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);
+ } 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)));
+}
+
+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");
+
+ return {
+ systemPrompt: getCurrentSystemPrompt(messages),
+ tools: getCurrentTools(messages),
+ userContent: toUserContent(conversation),
+ };
+}
+
+export function toStreamJsonLine(content: ClaudeContentBlock[]): string {
+ return JSON.stringify({ type: "user", message: { role: "user", content } });
+}
diff --git a/src/agent/models.ts b/src/agent/models.ts
index 69815cf..8edd8d5 100644
--- a/src/agent/models.ts
+++ b/src/agent/models.ts
@@ -17,6 +17,8 @@ import packageMetadata from "../../package.json" with { type: "json" };
import { chatGPTModel } from "./chatgpt-bridge.ts";
import { chatGPTStream } from "./chatgpt-stream.ts";
import type { ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.ts";
+import { createClaudeCodeProvider } from "./claude-code/provider.ts";
+import { streamClaudeCode } from "./claude-code/stream.ts";
import { nativeSubscriptionId, nativeSubscriptionModel } from "./subscription-bridge.ts";
import { nativeSubscriptionStream } from "./subscription-stream.ts";
@@ -81,6 +83,7 @@ export function piModels() {
if (!collection) {
collection = createModels({ identity: APP_IDENTITY });
for (const provider of builtinProviders()) collection.setProvider(provider);
+ collection.setProvider(createClaudeCodeProvider(streamClaudeCode));
}
providerSource?.sync(collection);
return collection;
diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts
index f2178c8..bc36717 100644
--- a/src/agent/teacher-agent.ts
+++ b/src/agent/teacher-agent.ts
@@ -18,6 +18,7 @@ import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
import { healToolRecord, withArgumentHeal } from "./tools/heal.ts";
import { sendToUserTool } from "./tools/send-to-user.ts";
import { createReadTool, readTool } from "./tools/read.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";
@@ -205,7 +206,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
...tools.map((tool) => tool.name === "read" && tool.execute === readTool.execute
? { ...tool, execute: createReadTool(engine.model).execute as AgentTool["execute"] }
: tool),
- ...(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/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/core/contracts/claude-code.ts b/src/core/contracts/claude-code.ts
new file mode 100644
index 0000000..83d8873
--- /dev/null
+++ b/src/core/contracts/claude-code.ts
@@ -0,0 +1,12 @@
+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";
+ 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/contracts/models.ts b/src/core/contracts/models.ts
index 7b9e9e7..e0225a2 100644
--- a/src/core/contracts/models.ts
+++ b/src/core/contracts/models.ts
@@ -21,9 +21,11 @@ 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 };
- contextWindow: number;
- maxTokens: number;
+ /** Null when LearnHarness cannot estimate prices for this connection. */
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number } | null;
+ /** 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/index.ts b/src/core/index.ts
index f1fd3dc..bc32276 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -56,6 +56,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_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/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/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
new file mode 100644
index 0000000..c6b0c3b
--- /dev/null
+++ b/src/server/claude-code.ts
@@ -0,0 +1,30 @@
+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() {
+ 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,
+ enabled = claudeCodeEnabled,
+): 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 });
+ }
+ if (!enabled()) return Response.json({ error: "Claude Code is disabled." }, { status: 403, headers });
+ return Response.json(await inspect(), { headers });
+}
diff --git a/src/server/index.ts b/src/server/index.ts
index a04dfb5..0c3b002 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 { claudeCodeInstallationResponse } from "./claude-code.ts";
diff --git a/src/server/keys.ts b/src/server/keys.ts
index 9a74b5c..2151e49 100644
--- a/src/server/keys.ts
+++ b/src/server/keys.ts
@@ -55,24 +55,12 @@ import { authStore } from "../store/auth.ts";
import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts";
import { piModels } from "../agent/models.ts";
import { customProviderConfigured, installCustomProviderSource } from "./custom-providers.ts";
+import { claudeCodeAvailable } from "./claude-code-config.ts";
+import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.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). */
@@ -206,6 +194,9 @@ export function providerKeyPresent(provider: string): boolean {
piModels(); // Synchronize hand-edited custom configuration before admission.
const custom = customProviderConfigured(provider);
if (custom !== undefined) return custom;
+ // 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 f0d8e8d..e585150 100644
--- a/src/server/model-catalog.ts
+++ b/src/server/model-catalog.ts
@@ -32,6 +32,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)]; }
@@ -49,6 +50,8 @@ 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 { 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";
/** The picker's client-safe model entry (contract: core/contracts/models.ts). */
@@ -143,6 +146,7 @@ export function catalogProviders(): CatalogProvider[] {
keyPresent: keyValue(p.envVar) !== undefined,
})).concat([
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
+ { id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: claudeCodeAvailable() },
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
...customProviderRows(),
]).sort((a, b) => a.id.localeCompare(b.id));
@@ -164,21 +168,22 @@ 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,
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 +191,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/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/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/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-binary.test.ts b/tests/claude-code-binary.test.ts
new file mode 100644
index 0000000..ae9c855
--- /dev/null
+++ b/tests/claude-code-binary.test.ts
@@ -0,0 +1,70 @@
+/**
+ * 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 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), 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),
+ null,
+ );
+ 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("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-context-ui.test.tsx b/tests/claude-code-context-ui.test.tsx
new file mode 100644
index 0000000..9aa62f3
--- /dev/null
+++ b/tests/claude-code-context-ui.test.tsx
@@ -0,0 +1,46 @@
+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, 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,
+ 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 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 ?? "", /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");
+ 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
new file mode 100644
index 0000000..aa6d735
--- /dev/null
+++ b/tests/claude-code-harness.test.ts
@@ -0,0 +1,319 @@
+/**
+ * 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 { delimiter, join } from "node:path";
+import { setTimeout as sleep } from "node:timers/promises";
+import { fileURLToPath } from "node:url";
+import test, { after, type TestContext } from "node:test";
+
+import type { RunClaude } from "../src/agent/claude-code/stream.ts";
+import type { PiEngine } from "../src/agent/models.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));
+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", id: ASK_ID, name: "ask_user_question", arguments: ASK_INPUT }),
+ JSON.stringify({ fake: "reply" }),
+ ];
+}
+
+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
+ // 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[] = [];
+
+ 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", "pipe", "pipe", "ipc"],
+ }) as ChildProcessWithoutNullStreams;
+ t.after(() => child.kill("SIGKILL"));
+
+ 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, chatId: options.chatId, 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("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;
+ fakeRun.runClaude = (args, options) => run(args, { ...options, env: { ...options.env, FAKE_CLAUDE_WINDOW: "200000" } });
+ 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.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);
+});
+
+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("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));
+
+ 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-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-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-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);
+});
diff --git a/tests/claude-code-status-http.test.ts b/tests/claude-code-status-http.test.ts
new file mode 100644
index 0000000..b9bed2d
--- /dev/null
+++ b/tests/claude-code-status-http.test.ts
@@ -0,0 +1,46 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+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 } });
+}
+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("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);
+});
+
+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
new file mode 100644
index 0000000..ee92d29
--- /dev/null
+++ b/tests/claude-code-status-ui.test.tsx
@@ -0,0 +1,141 @@
+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 { 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,
+ 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;
+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(<> {extra}>));
+}
+afterEach(async () => {
+ await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined;
+ globalThis.fetch = originalFetch;
+});
+
+test("initial render is collapsed and off, with no setup prose or version probe action", () => {
+ const html = renderToStaticMarkup( );
+ assert.match(html, /Claude Code/); assert.match(html, /aria-checked="false"/);
+ assert.equal((html.match(/ {
+ 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 [
+ [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("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("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;
+ 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;
+ 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" });
+ });
+ 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);
+});
+
+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/claude-code-stream.test.ts b/tests/claude-code-stream.test.ts
new file mode 100644
index 0000000..8da5596
--- /dev/null
+++ b/tests/claude-code-stream.test.ts
@@ -0,0 +1,1051 @@
+/**
+ * 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 { 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";
+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";
+
+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 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 };
+
+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"')),
+ callTool({ id: "toolu_01JBB4cJsPa585FWb1XrfbNX", name: "get_weather", arguments: { city: "Paris" } }), 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", "pipe", "pipe", "ipc"],
+ }) as ChildProcessWithoutNullStreams;
+ t.after(() => child.kill("SIGKILL"));
+ 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, { name: "get_weather", description: "Weather", parameters: { type: "object", properties: {} } }],
+ });
+ const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(model, context, { sessionId: SESSION_ID, ...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", id: toolUse.id, 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, { sessionId: SESSION_ID, ...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",
+ "user",
+ "--settings",
+ '{"disableAllHooks":true}',
+ "--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_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("unsupported native tool intent fails without exposing executable 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,
+ ]);
+
+ 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) => {
+ 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,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);
+});
+
+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)) {
+ 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, "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 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 });
+
+ 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", () => {
+ 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("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: "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]);
+
+ 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("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(readB, "B")]);
+
+ 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]);
+ 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("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]), 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 CLI-rejected malformed call is not exposed for execution", 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" }),
+ JSON.stringify({ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "toolu_bad_json", is_error: true, content: "invalid arguments" }] } }),
+ HOLD,
+ ]);
+
+ 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));
+});
+
+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("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("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] }), { 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));
+});
+
+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] }), { sessionId: SESSION_ID }));
+ 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");
+});
+
+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) ?? "", asyncVersionFailure ? /requires an update/ : /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", "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.",
+ 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, { 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));
+});
+
+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" } };
+ 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/claude-code-tool-bridge.test.ts b/tests/claude-code-tool-bridge.test.ts
new file mode 100644
index 0000000..ff66fd9
--- /dev/null
+++ b/tests/claude-code-tool-bridge.test.ts
@@ -0,0 +1,271 @@
+/**
+ * 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" }, _meta: { "claudecode/toolUseId": "call-1" } }).finally(() => {
+ settled = true;
+ });
+
+ const [call] = await bridge.waitForToolCalls(1);
+ assert.ok(call);
+ assert.deepEqual(call, { id: "call-1", 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("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());
+ 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" }, _meta: { "claudecode/toolUseId": "call-1" } }).catch(() => {});
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ assert.equal(waited, false);
+
+ void client.callTool({ name: "screenshot", arguments: {}, _meta: { "claudecode/toolUseId": "call-2" } }).catch(() => {});
+
+ assert.deepEqual(await waiting, [
+ { id: "call-1", name: "read", arguments: { path: "a.md" } },
+ { id: "call-2", 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" }, _meta: { "claudecode/toolUseId": "call-1" } });
+ 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" }, _meta: { "claudecode/toolUseId": "call-1" } });
+ 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("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/claude-code-transcript.test.ts b/tests/claude-code-transcript.test.ts
new file mode 100644
index 0000000..5e8db33
--- /dev/null
+++ b/tests/claude-code-transcript.test.ts
@@ -0,0 +1,161 @@
+/**
+ * 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" }] },
+ });
+});
diff --git a/tests/claude-code-usage.test.tsx b/tests/claude-code-usage.test.tsx
new file mode 100644
index 0000000..8415d2f
--- /dev/null
+++ b/tests/claude-code-usage.test.tsx
@@ -0,0 +1,55 @@
+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"));
+ 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);
+ assert.equal(summary.unpricedRuns, 1); assert.equal(summary.subscriptionRuns, undefined);
+ 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));
+ 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/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");
+});
diff --git a/tests/fixtures/claude-code/fake-claude.mjs b/tests/fixtures/claude-code/fake-claude.mjs
new file mode 100644
index 0000000..2b0ba66
--- /dev/null
+++ b/tests/fixtures/claude-code/fake-claude.mjs
@@ -0,0 +1,148 @@
+/**
+ * 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","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 { 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";
+
+const parentMessages = on(process, "message");
+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 });
+}
+
+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" });
+
+ 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;
+}
+
+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 = "";
+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 = [];
+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 parentMessages.next();
+ continue;
+ }
+ 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 === "ignore-term") {
+ process.on("SIGTERM", () => {});
+ continue;
+ }
+ 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({ id: directive.id, request });
+ void request.catch(() => {});
+ await lastSend;
+ } 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).filter(({ id }) => !cancelled.has(id)).map(({ request }) => request))) results.push(result.content.map((block) => block.text).join(""));
+ await writeReply(results.join(" | "));
+ continue;
+ }
+
+ await writeLine(line);
+}
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}
diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts
index d28f4d4..da2242c 100644
--- a/tests/model-catalog.test.ts
+++ b/tests/model-catalog.test.ts
@@ -1,6 +1,13 @@
-import { describe, it } from "node:test";
+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 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,
catalogRolePins,
@@ -12,16 +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";
+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
@@ -59,8 +68,10 @@ 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");
- assert.ok(m.contextWindow > 0);
+ 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");
+ 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");
@@ -121,6 +132,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);
});
@@ -326,4 +338,81 @@ 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
+});
+/** 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, 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;
+ 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;
+ }
+ if (settings) writeSettings(settings);
+ else fs.rmSync(SETTINGS_FILE, { force: true });
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+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()
+ .models.filter((m) => m.provider === "claude-code")
+ .map((m) => m.id);
+ assert.deepEqual(ids, ["haiku", "opus", "sonnet"]);
+ });
+ });
+
+ 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() never advertises placeholder CLI limits, even when installed", () => {
+ withClaudeInstalled(true, () => {
+ assert.equal(catalogContextLimits().has("claude-code:opus"), false);
+ });
+ });
+
+ 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") 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/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);
}
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],
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);
});
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"],
);
});
});