From 46e7c290f1abb12beec519e24918fd1d6fc9b895 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:18:16 -0700 Subject: [PATCH 01/13] Start the graphcode-nod runtime package Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- NodRuntime/.gitignore | 2 ++ NodRuntime/package.json | 12 ++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 NodRuntime/.gitignore create mode 100644 NodRuntime/package.json diff --git a/NodRuntime/.gitignore b/NodRuntime/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/NodRuntime/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/NodRuntime/package.json b/NodRuntime/package.json new file mode 100644 index 00000000..64d910b4 --- /dev/null +++ b/NodRuntime/package.json @@ -0,0 +1,12 @@ +{ + "name": "graphcode-nod", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { "graphcode-nod": "src/main.ts" }, + "scripts": { + "test": "bun test", + "typecheck": "tsc --noEmit", + "build": "bun build src/main.ts --compile --outfile dist/graphcode-nod" + } +} From 3e4de90999d1297e8eed3e96cbe54aa5f88c6b93 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:29:17 -0700 Subject: [PATCH 02/13] Add the Nod runtime core: event log, control socket, staging, gate, goal Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- NodRuntime/bun.lock | 346 ++++++++++++++++++++++++++++ NodRuntime/package.json | 12 +- NodRuntime/src/controlSocket.ts | 75 ++++++ NodRuntime/src/diff.ts | 164 +++++++++++++ NodRuntime/src/engine.ts | 82 +++++++ NodRuntime/src/eventLog.ts | 85 +++++++ NodRuntime/src/goal.ts | 171 ++++++++++++++ NodRuntime/src/hunks.ts | 179 ++++++++++++++ NodRuntime/src/permissions.ts | 220 ++++++++++++++++++ NodRuntime/src/presence.ts | 100 ++++++++ NodRuntime/src/protocol.ts | 212 +++++++++++++++++ NodRuntime/src/runtime.ts | 397 ++++++++++++++++++++++++++++++++ NodRuntime/src/settings.ts | 69 ++++++ NodRuntime/src/tools.ts | 94 ++++++++ 14 files changed, 2205 insertions(+), 1 deletion(-) create mode 100644 NodRuntime/bun.lock create mode 100644 NodRuntime/src/controlSocket.ts create mode 100644 NodRuntime/src/diff.ts create mode 100644 NodRuntime/src/engine.ts create mode 100644 NodRuntime/src/eventLog.ts create mode 100644 NodRuntime/src/goal.ts create mode 100644 NodRuntime/src/hunks.ts create mode 100644 NodRuntime/src/permissions.ts create mode 100644 NodRuntime/src/presence.ts create mode 100644 NodRuntime/src/protocol.ts create mode 100644 NodRuntime/src/runtime.ts create mode 100644 NodRuntime/src/settings.ts create mode 100644 NodRuntime/src/tools.ts diff --git a/NodRuntime/bun.lock b/NodRuntime/bun.lock new file mode 100644 index 00000000..189d0bc8 --- /dev/null +++ b/NodRuntime/bun.lock @@ -0,0 +1,346 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "graphcode-nod", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.287", + "@github/copilot-sdk": "^1.0.16", + }, + "devDependencies": { + "@types/bun": "^1.4.2", + "typescript": "^7.0.2", + }, + }, + }, + "packages": { + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.287", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.287", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.287", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.287", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.287", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.287", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.287", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.287", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.287" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-DrYUMzmSVfL1VciNDo/9BhxiVnTsSCKVzxLHB3+NvvRiyN4pfWTKFrUzQesPnWswds9x1Ir10Gi6GRsnjT0UkA=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.287", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Ic9GCrPBmMroLi7q+IeQQdtVDQLOaaSxkH1NzPsSRZxSvFcMH6M7zz/LahzlLQTdDyebcyMLk4aBXl4H8NnbFw=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.287", "", { "os": "darwin", "cpu": "x64" }, "sha512-7BxpyKMkzLQxCdq4OEnrjtLRC3O69l0LSBZYyUPXPTCFTnvzqpELjPRUssyW9auKEtVoh8eTySwC2rwGzuMUqA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.287", "", { "os": "linux", "cpu": "arm64" }, "sha512-CWqO5p3YSBmpi/qHywul0re6fjljbDMZVj45PouNb1Qqws2Wi2h/Wp/ojOnfQAmq4m29pV2qgDnN2UtjaOobKA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.287", "", { "os": "linux", "cpu": "arm64" }, "sha512-2WuRGYigs03B0Z7EnG72Ogiz1cMWIAyTwmV+DpNelhFeKb9VUevLkVRvS9nVQvyin/sO3FCBHV8dYsgZEwpmvQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.287", "", { "os": "linux", "cpu": "x64" }, "sha512-/6Zw5nym4xfc2eFGaIrS7dt7JoBgASsGUnLWMoPV4M1hGH0tOSPzpixfb4OWO6r9iw9Nn/Zk5Ke1KRuMuja2+Q=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.287", "", { "os": "linux", "cpu": "x64" }, "sha512-be7lBO3WihsGbGQbMCAowxmt8vqGgmUo2HkbBee8jrch04LG4LCRj6Ry/gHtYSTle30WAnoyqCLsPHFj064waw=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.287", "", { "os": "win32", "cpu": "arm64" }, "sha512-6AdDoLnnVG9aRrWoGVQjS3i4+hceCGFEHVPkMBzbVFs76G1WKlY/ZMnELu3X+ZE5QJFT3j4qqmdJ0y+QbrycEg=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.287", "", { "os": "win32", "cpu": "x64" }, "sha512-fczDcWG2Hu+nYQgxeQEsGn5l+3M06RpJXysIsu/BmHTPl7UceTfTpSYr6O/9GU/sNhU0CADJaIIZNwQTfK0DWw=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.131.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-9+PepoU7qVMmM70jSahdnfcMHAvAnzhMOLv2W9WjRLO+OG/MAiBETkwKZqv7KIJ1Cj5h8eZ019bHzZaq+Mbepw=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@github/copilot-sdk": ["@github/copilot-sdk@1.0.16", "", { "dependencies": { "koffi": "3.2.1", "vscode-jsonrpc": "8.2.1", "zod": "4.3.6" }, "optionalDependencies": { "@github/copilot-sdk-darwin-arm64": "1.0.16", "@github/copilot-sdk-darwin-x64": "1.0.16", "@github/copilot-sdk-linux-arm64": "1.0.16", "@github/copilot-sdk-linux-x64": "1.0.16", "@github/copilot-sdk-linuxmusl-arm64": "1.0.16", "@github/copilot-sdk-linuxmusl-x64": "1.0.16", "@github/copilot-sdk-win32-arm64": "1.0.16", "@github/copilot-sdk-win32-x64": "1.0.16" } }, "sha512-m21UkMs8hkI6nRu2+EoP1kAGLJcEXFnNzAokEjwLcVmPojlO6fdLpfka1c1Nypxr39YZjyI3jHHAXwoe66SO2A=="], + + "@github/copilot-sdk-darwin-arm64": ["@github/copilot-sdk-darwin-arm64@1.0.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+ACscTN4OuSYDQL7/1yoi1FfDeI8qNlekip+dt2hvb9ZC4rz3YNVrhWkzrZsc7gX1ATD0YHGp71JDKqNAxHmBg=="], + + "@github/copilot-sdk-darwin-x64": ["@github/copilot-sdk-darwin-x64@1.0.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-M+R5xi4hqSrzbf+ICloMfiotuDch7URzPwL+5I4q1gy1VlY1EqpD39HW9LbqqB68iOqMRnW3anE+kzb5PYxFjA=="], + + "@github/copilot-sdk-linux-arm64": ["@github/copilot-sdk-linux-arm64@1.0.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-AjtL7pnhc0OajCO9NV963gwqFHiwIKIZEwpRd5tmGgB/Yc+Rr6QdRsc5ZMNe75ptagnVqViVqiTr9g/EnRspXw=="], + + "@github/copilot-sdk-linux-x64": ["@github/copilot-sdk-linux-x64@1.0.16", "", { "os": "linux", "cpu": "x64" }, "sha512-YLxPU1IdwH4cNvIJhawzyb6nMCbXB6ZfeebUs0kQEJbu6pzsMkyl1uSWCCv1vpAHPuhub1BgMokoY1kX9chElg=="], + + "@github/copilot-sdk-linuxmusl-arm64": ["@github/copilot-sdk-linuxmusl-arm64@1.0.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-hkHq6WSj5ug5JdDKrLxMbDyjp5wx6zbsn3swZcC6fy6lFSJMX2kCG7PivcOq1Uj7o/POiN/DhhJqvQxOGuX1IA=="], + + "@github/copilot-sdk-linuxmusl-x64": ["@github/copilot-sdk-linuxmusl-x64@1.0.16", "", { "os": "linux", "cpu": "x64" }, "sha512-JWjzUi366WXGqdY9xIJvmJRTB+sCsW8qasVwLXj12X05GxYWMdfHK+7t8CiYTs2L1NKu1NiOYd5rS7D+ChSZyw=="], + + "@github/copilot-sdk-win32-arm64": ["@github/copilot-sdk-win32-arm64@1.0.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-nQ//JXHfPfn4sfxtZ3rcLR993Mn8YQkC/RXHW9zu0W4Qtb1kyKec7XSi+VRMsKWPyzL7B7X+SHjTRSmn9AqZnQ=="], + + "@github/copilot-sdk-win32-x64": ["@github/copilot-sdk-win32-x64@1.0.16", "", { "os": "win32", "cpu": "x64" }, "sha512-x9JyZks9NqmMNWkAI1wNcbvuPiMUUOvaU6tWq+nzp/TFdowW/kgaMX3jebnxeNUq/gqi2rPabRHp/cCMCma8eA=="], + + "@hono/node-server": ["@hono/node-server@2.1.3", "", { "peerDependencies": { "hono": "^4" } }, "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw=="], + + "@koromix/koffi-android-arm64": ["@koromix/koffi-android-arm64@3.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-1pJQ4jnZlUJduK9u9DC5CGy3aOgDUPvIXpNb6syV3+Dh5Q/ugezAIGCqvY+w+1mgXsve0pd0NVvJRjdZNHQ6MA=="], + + "@koromix/koffi-android-x64": ["@koromix/koffi-android-x64@3.2.1", "", { "os": "android", "cpu": "x64" }, "sha512-HH40xGh3gVQifjOBnhwT2tECC0lL1lYe+nxHvWNSzxDIyQNcVPXg38ta7vuONRFpD+uIrw7fqGYLzbZIagkVcg=="], + + "@koromix/koffi-darwin-arm64": ["@koromix/koffi-darwin-arm64@3.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vj4h+xcjc5+Cn0DhPHjgRX4omKAv96Kehtcd+1YgYuY2W7FvQn9vS+3SmzVwhC5Qmg9bIwUZObYQ8T/4hBqQqA=="], + + "@koromix/koffi-darwin-x64": ["@koromix/koffi-darwin-x64@3.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-gFCWxNBTZIvxo1p+PURWfsy2Ctj5FGnVVs1f03lTLhBvmxEto70pdIiFztdFLDFkAJ1pmtQmruRKapeK+E8YPA=="], + + "@koromix/koffi-freebsd-arm64": ["@koromix/koffi-freebsd-arm64@3.2.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-qj+f1s2e6vULaUG1cdlTcCXmunCq2t+rjxku1+esaMIqVnHpOwj0QzPuInG0AFdXjwBNQhyVR/HpDj8daEwwsQ=="], + + "@koromix/koffi-freebsd-ia32": ["@koromix/koffi-freebsd-ia32@3.2.1", "", { "os": "freebsd", "cpu": "ia32" }, "sha512-6olHb1Qfgai0jjs6ddlDDD0ZfsCxy7SPi8rMRpuYQWH0qhgtyQu82hw5b1p7z+TJ0zZP3ZeQQ6l+U/MlM1ICHQ=="], + + "@koromix/koffi-freebsd-x64": ["@koromix/koffi-freebsd-x64@3.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Dikhw1ySYNVMkmeFvFVjnU5Wdk6mffNoOjJxm9bTG96vg7OlemylxqdEven47R1YJ3yzNVJn/MlQ207ORWfi2w=="], + + "@koromix/koffi-linux-arm": ["@koromix/koffi-linux-arm@3.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OfwUwZylidq95wQKp6ClInULrfB2giu7dqM6Rhe0zAe6lES5I2SXNw15T9+GnRHk3/9hKT2XZ37OZLaKSyWNLA=="], + + "@koromix/koffi-linux-arm64": ["@koromix/koffi-linux-arm64@3.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-K+cGUL5iBcDqxmsocrjmlASqDf24gc7artbVW3PewG2c9AqwC63lezgwvB85Nx4lZAQjB6zIFHh9A7t1yGbwhw=="], + + "@koromix/koffi-linux-ia32": ["@koromix/koffi-linux-ia32@3.2.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-rxj6UYjU1qd98gxNQOSCdLpc5cPRi5Giq9rNd3jnGuSNIyMkwa6Dxw4cUjmhIBCYESMJtmNt5NWnJp5u9wTfYQ=="], + + "@koromix/koffi-linux-loong64": ["@koromix/koffi-linux-loong64@3.2.1", "", { "os": "linux", "cpu": "none" }, "sha512-aHhnHzkPRmT/IHDlGvESJ/Bs32m8N6UE6Ab6kMeJzgk74IN8af2m/81/wZJtybR1M2UxCV4NmlVNUYQQvSAO3Q=="], + + "@koromix/koffi-linux-riscv64": ["@koromix/koffi-linux-riscv64@3.2.1", "", { "os": "linux", "cpu": "none" }, "sha512-qtQBsjbm3LiirLJvajWmKkNb7ARk7fvJVXdftJ7NtAnF3Xw8EbDvrtvmvtNI1yLPlYcBmlzCCD71hwhWYk0SIA=="], + + "@koromix/koffi-linux-x64": ["@koromix/koffi-linux-x64@3.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-c7hw7Qs/r5gnFRTQLcbifBwRU7wiocj+2pVuDQ5Ahb3r36SZmupmgYbTWcLvTW+hul1jd7SKRV0d14ZJq/tvSw=="], + + "@koromix/koffi-openbsd-ia32": ["@koromix/koffi-openbsd-ia32@3.2.1", "", { "os": "openbsd", "cpu": "ia32" }, "sha512-mmY8fY8LQ/CB52+h3yrMYmVyoxzW3x08S0yI6VNfHWdfU6yJtZkKCbhjmQCYMrWbYKC4gMvwZwCywIGPAkLyeA=="], + + "@koromix/koffi-openbsd-x64": ["@koromix/koffi-openbsd-x64@3.2.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-k4ig6aAPbFSRATOIIOfdf/KtlOGH4SVls6L9fy0QnTxRJYvY2oSltTsQtBDANgEQldlq8Kl5WnpRa1VSibP4Lw=="], + + "@koromix/koffi-win32-arm64": ["@koromix/koffi-win32-arm64@3.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-cTWBJGK//pDMeKQJE/79Aq9MiOAF4H8QyLZHSQ9IWm8czOfwjG4J1AhsQ9DjI9KFOykH77hhnpmQTGVMIubGig=="], + + "@koromix/koffi-win32-ia32": ["@koromix/koffi-win32-ia32@3.2.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-Z50EM6TAZ7CFyMmyX6thv8eNpJchqe9eenhibSIy2Eq/FQYF76gU2VK/LEoaF46L8hfC7TpTp9b10MvReHEyFA=="], + + "@koromix/koffi-win32-x64": ["@koromix/koffi-win32-x64@3.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-ZmZNiBO6bkOSh3QNzgfvb1cMY0yMobn6ZQrSMqbAce21qyYL8niIbyipz9N/PIRDciGhsV0wUxnZsxIO+yWsHQ=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.31.0", "", { "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" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw=="], + + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.7.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hono": ["hono@4.13.12", "", {}, "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.7.3", "", {}, "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "koffi": ["koffi@3.2.1", "", { "optionalDependencies": { "@koromix/koffi-android-arm64": "3.2.1", "@koromix/koffi-android-x64": "3.2.1", "@koromix/koffi-darwin-arm64": "3.2.1", "@koromix/koffi-darwin-x64": "3.2.1", "@koromix/koffi-freebsd-arm64": "3.2.1", "@koromix/koffi-freebsd-ia32": "3.2.1", "@koromix/koffi-freebsd-x64": "3.2.1", "@koromix/koffi-linux-arm": "3.2.1", "@koromix/koffi-linux-arm64": "3.2.1", "@koromix/koffi-linux-ia32": "3.2.1", "@koromix/koffi-linux-loong64": "3.2.1", "@koromix/koffi-linux-riscv64": "3.2.1", "@koromix/koffi-linux-x64": "3.2.1", "@koromix/koffi-openbsd-ia32": "3.2.1", "@koromix/koffi-openbsd-x64": "3.2.1", "@koromix/koffi-win32-arm64": "3.2.1", "@koromix/koffi-win32-ia32": "3.2.1", "@koromix/koffi-win32-x64": "3.2.1" } }, "sha512-0qE3lZ8jllRqPN4Ob6Ajl7c2bJSJDhQWuKLGP5hIEpHLllJWv1ydHFMhHmHc5p/W9GticKVDbYzZd7TBoQ4CZg=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], + + "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], + + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + } +} diff --git a/NodRuntime/package.json b/NodRuntime/package.json index 64d910b4..1d63b1bb 100644 --- a/NodRuntime/package.json +++ b/NodRuntime/package.json @@ -3,10 +3,20 @@ "version": "0.1.0", "private": true, "type": "module", - "bin": { "graphcode-nod": "src/main.ts" }, + "bin": { + "graphcode-nod": "src/main.ts" + }, "scripts": { "test": "bun test", "typecheck": "tsc --noEmit", "build": "bun build src/main.ts --compile --outfile dist/graphcode-nod" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.287", + "@github/copilot-sdk": "^1.0.16" + }, + "devDependencies": { + "@types/bun": "^1.4.2", + "typescript": "^7.0.2" } } diff --git a/NodRuntime/src/controlSocket.ts b/NodRuntime/src/controlSocket.ts new file mode 100644 index 00000000..4fabfd22 --- /dev/null +++ b/NodRuntime/src/controlSocket.ts @@ -0,0 +1,75 @@ +import { createServer, type Server, type Socket } from "node:net"; +import { existsSync, mkdirSync, unlinkSync } from "node:fs"; +import { dirname } from "node:path"; +import { parseCommand, type NodCommand } from "./protocol"; + +export type CommandHandler = (command: NodCommand) => Promise | void; + +/** + * `control.sock`: one `NodCommand` per line in, one `{"ok":…}` per line out, in order. + * Commands on one connection are handled one at a time so their replies line up with + * the lines that asked for them. + */ +export class ControlSocket { + private server: Server | undefined; + private sockets = new Set(); + + constructor( + readonly path: string, + private readonly handle: CommandHandler, + ) {} + + async listen(): Promise { + mkdirSync(dirname(this.path), { recursive: true }); + // A socket file left by a killed runtime refuses every connect; a live runtime for the + // same node is the launcher's job to prevent, not this one's. + if (existsSync(this.path)) unlinkSync(this.path); + const server = createServer((socket) => this.accept(socket)); + this.server = server; + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(this.path, () => { + server.off("error", reject); + resolve(); + }); + }); + } + + async close(): Promise { + for (const socket of this.sockets) socket.destroy(); + const server = this.server; + this.server = undefined; + if (server) await new Promise((resolve) => server.close(() => resolve())); + if (existsSync(this.path)) unlinkSync(this.path); + } + + private accept(socket: Socket): void { + this.sockets.add(socket); + socket.setEncoding("utf8"); + let buffered = ""; + let chain = Promise.resolve(); + socket.on("data", (chunk: string) => { + buffered += chunk; + let newline: number; + while ((newline = buffered.indexOf("\n")) >= 0) { + const line = buffered.slice(0, newline).trim(); + buffered = buffered.slice(newline + 1); + if (line.length === 0) continue; + chain = chain.then(() => this.reply(socket, line)); + } + }); + socket.on("error", () => socket.destroy()); + socket.on("close", () => this.sockets.delete(socket)); + } + + private async reply(socket: Socket, line: string): Promise { + let answer: { ok: true } | { ok: false; error: string }; + try { + await this.handle(parseCommand(line)); + answer = { ok: true }; + } catch (error) { + answer = { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + if (!socket.destroyed) socket.write(JSON.stringify(answer) + "\n"); + } +} diff --git a/NodRuntime/src/diff.ts b/NodRuntime/src/diff.ts new file mode 100644 index 00000000..c8aa8d45 --- /dev/null +++ b/NodRuntime/src/diff.ts @@ -0,0 +1,164 @@ +export interface Hunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + /** Each line prefixed with ' ', '-' or '+'. */ + lines: string[]; +} + +type Op = { kind: " " | "-" | "+"; text: string }; + +/** Lines as `split("\n")` gives them, so joining with "\n" restores the text byte for byte. */ +export function splitLines(text: string): string[] { + return text.split("\n"); +} + +/** Myers' O(ND) line diff, after trimming the common prefix and suffix. */ +export function diffLines(a: string[], b: string[]): Op[] { + let prefix = 0; + while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) prefix++; + let suffix = 0; + while ( + suffix < a.length - prefix && + suffix < b.length - prefix && + a[a.length - 1 - suffix] === b[b.length - 1 - suffix] + ) + suffix++; + const midA = a.slice(prefix, a.length - suffix); + const midB = b.slice(prefix, b.length - suffix); + const ops: Op[] = a.slice(0, prefix).map((text) => ({ kind: " ", text })); + ops.push(...myers(midA, midB)); + ops.push(...a.slice(a.length - suffix).map((text): Op => ({ kind: " ", text }))); + return ops; +} + +function myers(a: string[], b: string[]): Op[] { + const n = a.length; + const m = b.length; + if (n === 0) return b.map((text) => ({ kind: "+", text })); + if (m === 0) return a.map((text) => ({ kind: "-", text })); + const max = n + m; + const offset = max; + let v = new Int32Array(2 * max + 2); + const trace: Int32Array[] = []; + outer: for (let d = 0; d <= max; d++) { + trace.push(v.slice()); + for (let k = -d; k <= d; k += 2) { + let x = + k === -d || (k !== d && v[offset + k - 1]! < v[offset + k + 1]!) ? v[offset + k + 1]! : v[offset + k - 1]! + 1; + let y = x - k; + while (x < n && y < m && a[x] === b[y]) { + x++; + y++; + } + v[offset + k] = x; + if (x >= n && y >= m) { + trace.push(v.slice()); + break outer; + } + } + } + const ops: Op[] = []; + let x = n; + let y = m; + for (let d = trace.length - 2; d >= 0 && (x > 0 || y > 0); d--) { + v = trace[d]!; + const k = x - y; + const prevK = + k === -d || (k !== d && v[offset + k - 1]! < v[offset + k + 1]!) ? k + 1 : k - 1; + const prevX = v[offset + prevK]!; + const prevY = prevX - prevK; + while (x > prevX && y > prevY) { + ops.push({ kind: " ", text: a[--x]! }); + y--; + } + if (d === 0) break; + if (x === prevX) ops.push({ kind: "+", text: b[--y]! }); + else ops.push({ kind: "-", text: a[--x]! }); + } + while (x > 0 && y > 0) { + ops.push({ kind: " ", text: a[--x]! }); + y--; + } + return ops.reverse(); +} + +/** Groups a diff into unified-diff hunks with `context` lines around each change, as git does. */ +export function hunks(before: string, after: string, context = 3): Hunk[] { + const ops = diffLines(splitLines(before), splitLines(after)); + const changed = ops.map((op, i) => (op.kind === " " ? -1 : i)).filter((i) => i >= 0); + if (changed.length === 0) return []; + const ranges: [number, number][] = []; + for (const i of changed) { + const start = Math.max(0, i - context); + const end = Math.min(ops.length - 1, i + context); + const last = ranges[ranges.length - 1]; + if (last && start <= last[1] + 1) last[1] = Math.max(last[1], end); + else ranges.push([start, end]); + } + const oldLineAt: number[] = []; + const newLineAt: number[] = []; + let oldLine = 1; + let newLine = 1; + for (const op of ops) { + oldLineAt.push(oldLine); + newLineAt.push(newLine); + if (op.kind !== "+") oldLine++; + if (op.kind !== "-") newLine++; + } + return ranges.map(([start, end]) => { + const slice = ops.slice(start, end + 1); + const oldLines = slice.filter((op) => op.kind !== "+").length; + const newLines = slice.filter((op) => op.kind !== "-").length; + return { + oldStart: oldLineAt[start]!, + oldLines, + newStart: newLineAt[start]!, + newLines, + lines: slice.map((op) => op.kind + op.text), + }; + }); +} + +export function hunkHeader(hunk: Hunk): string { + return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`; +} + +export function hunkStats(hunk: Hunk): { added: number; removed: number } { + return { + added: hunk.lines.filter((l) => l.startsWith("+")).length, + removed: hunk.lines.filter((l) => l.startsWith("-")).length, + }; +} + +export class HunkConflict extends Error {} + +/** + * Applies one hunk to `text` wherever its old side now sits — other hunks of the same edit + * may have been accepted or rejected first, so the expected line is a hint, not an address. + * `reverse` undoes a hunk that was already applied. + */ +export function applyHunk(text: string, hunk: Hunk, reverse = false): string { + const from = reverse ? "+" : "-"; + const to = reverse ? "-" : "+"; + const oldBlock = hunk.lines.filter((l) => !l.startsWith(to)).map((l) => l.slice(1)); + const newBlock = hunk.lines.filter((l) => !l.startsWith(from)).map((l) => l.slice(1)); + const lines = splitLines(text); + const hint = (reverse ? hunk.newStart : hunk.oldStart) - 1; + const at = locate(lines, oldBlock, hint); + if (at < 0) throw new HunkConflict("the file changed under this hunk"); + lines.splice(at, oldBlock.length, ...newBlock); + return lines.join("\n"); +} + +function locate(lines: string[], block: string[], hint: number): number { + const matches = (at: number) => block.every((line, i) => lines[at + i] === line); + const last = lines.length - block.length; + for (let distance = 0; distance <= lines.length; distance++) { + for (const at of distance === 0 ? [hint] : [hint - distance, hint + distance]) { + if (at >= 0 && at <= last && matches(at)) return at; + } + } + return -1; +} diff --git a/NodRuntime/src/engine.ts b/NodRuntime/src/engine.ts new file mode 100644 index 00000000..fe967dc4 --- /dev/null +++ b/NodRuntime/src/engine.ts @@ -0,0 +1,82 @@ +import type { NodAttachment, NodEngineKind, NodFailureKind, NodToolStatus } from "./protocol"; +import type { ToolIntent } from "./permissions"; + +export interface EngineStart { + cwd: string; + model?: string; + /** Continue this conversation instead of starting one. */ + resume?: string; + /** Appended to the engine's own system prompt: the briefing and Nod's identity. */ + systemAppend?: string; +} + +export interface EngineSession { + conversationID: string; + model: string; +} + +export interface UsageReport { + inputTokens: number; + outputTokens: number; + /** Dollars spent so far this process, cumulative — Claude only. */ + costUSD?: number; + /** Copilot bills premium requests instead of dollars; cumulative. */ + premiumRequests?: number; + /** 0…1 of the context window in use. */ + contextUsed: number; +} + +export interface ToolRequest { + intent: ToolIntent; + /** For an edit the runtime can stage: the file and its full content afterwards. */ + edit?: { path: string; after: string }; +} + +export type Authorization = + | { allow: true } + /** `interrupt` ends the turn too — an unattended loop that hit a question it can't ask. */ + | { allow: false; message: string; interrupt?: boolean }; + +export interface EngineFailure { + kind: NodFailureKind; + message: string; +} + +/** What an engine reports while a turn runs; the runtime turns these into events. */ +export interface TurnCallbacks { + text(messageID: string, delta: string, final: boolean): void; + toolCall(callID: string, tool: string, input: unknown): void; + toolResult(callID: string, status: NodToolStatus, summary: string, output?: string, durationMs?: number): void; + usage(report: UsageReport): void; + compacted(): void; + /** Called before a gated tool runs; may wait on a human. */ + authorize(request: ToolRequest): Promise; + /** Steer text waiting for the next tool boundary, consumed by the call. */ + takeSteer(): string | undefined; +} + +export interface TurnResult { + /** The agent's final words this turn. */ + lastMessage: string; + failure?: EngineFailure; + interrupted?: boolean; +} + +/** + * One engine behind Nod: the Claude Agent SDK or the GitHub Copilot SDK. Everything Nod + * adds — the event log, staging, the gate, the goal — sits above this and never + * branches on which engine it has. + */ +export interface Engine { + readonly kind: NodEngineKind; + start(options: EngineStart): Promise; + /** Runs one turn to its end. Only one turn runs at a time. */ + runTurn(text: string, attachments: NodAttachment[], callbacks: TurnCallbacks): Promise; + interrupt(): Promise; + setModel(model: string): Promise; + /** Summarises older turns; reports `compacted` through the callbacks. */ + compact(callbacks: TurnCallbacks): Promise; + /** A one-shot question with no tools and no conversation — the goal judge and `-p`. */ + ask(prompt: string, model?: string): Promise; + close(): Promise; +} diff --git a/NodRuntime/src/eventLog.ts b/NodRuntime/src/eventLog.ts new file mode 100644 index 00000000..93fd4f0a --- /dev/null +++ b/NodRuntime/src/eventLog.ts @@ -0,0 +1,85 @@ +import { appendFileSync, existsSync, mkdirSync, openSync, readSync, closeSync, statSync } from "node:fs"; +import { dirname } from "node:path"; +import { PROTOCOL_VERSION, wireDate, type NodEvent, type NodEventRecord } from "./protocol"; + +/** + * `events.jsonl`: one record per line, append-only, `seq` strictly increasing across every + * run that ever wrote the file — a resumed runtime continues the numbering rather than + * restarting it, because readers tail by `seq`. + * + * Appends are synchronous: a record must be on disk before the event it describes can have + * consequences (a permission ask the app is about to answer, a hunk it is about to accept). + */ +export class EventLog { + private seq: number; + private needsNewline: boolean; + private listeners = new Set<(record: NodEventRecord) => void>(); + + constructor( + readonly path: string, + private readonly clock: () => Date = () => new Date(), + ) { + mkdirSync(dirname(path), { recursive: true }); + let tail = readTail(path, TAIL_BYTES); + this.seq = lastSeq(tail); + // One record (a large diff or tool output) can outgrow the tail window. + if (this.seq === 0 && Buffer.byteLength(tail) >= TAIL_BYTES) { + tail = readTail(path, Number.MAX_SAFE_INTEGER); + this.seq = lastSeq(tail); + } + // A runtime killed mid-write leaves a torn last line; starting on a fresh line keeps + // the torn one from swallowing the next record. + this.needsNewline = tail.length > 0 && !tail.endsWith("\n"); + } + + get lastSeq(): number { + return this.seq; + } + + append(event: NodEvent): NodEventRecord { + this.seq += 1; + const record = { v: PROTOCOL_VERSION, seq: this.seq, at: wireDate(this.clock()), ...event } as NodEventRecord; + const line = JSON.stringify(record) + "\n"; + appendFileSync(this.path, this.needsNewline ? "\n" + line : line); + this.needsNewline = false; + for (const listener of this.listeners) listener(record); + return record; + } + + onRecord(listener: (record: NodEventRecord) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } +} + +const TAIL_BYTES = 64 * 1024; + +function readTail(path: string, maxBytes: number): string { + if (!existsSync(path)) return ""; + const size = statSync(path).size; + if (size === 0) return ""; + const length = Math.min(size, maxBytes); + const buffer = Buffer.alloc(length); + const fd = openSync(path, "r"); + try { + readSync(fd, buffer, 0, length, size - length); + } finally { + closeSync(fd); + } + return buffer.toString("utf8"); +} + +/** The highest `seq` among the complete records in a log tail; torn or foreign lines are skipped. */ +export function lastSeq(tail: string): number { + let max = 0; + for (const line of tail.split("\n")) { + if (!line.startsWith("{")) continue; + try { + const seq = (JSON.parse(line) as { seq?: unknown }).seq; + if (typeof seq === "number" && Number.isInteger(seq) && seq > max) max = seq; + } catch { + // torn line + } + } + return max; +} diff --git a/NodRuntime/src/goal.ts b/NodRuntime/src/goal.ts new file mode 100644 index 00000000..da9362b9 --- /dev/null +++ b/NodRuntime/src/goal.ts @@ -0,0 +1,171 @@ +import type { EventLog } from "./eventLog"; +import type { NodGoalClause } from "./protocol"; + +/** Asks a model one question and returns its text answer; engines provide it. */ +export type Judge = (prompt: string, model: string) => Promise; + +/** + * Splits a goal into checkable clauses: list items, then `;`, then a top-level "and". + * "and" inside backticks, quotes or brackets never splits, and a piece too short to be a + * claim of its own ("resetsAt") is joined back onto the one before it. + */ +export function splitGoal(goal: string): string[] { + const body = goal.trim().replace(/^(done when|goal:|the goal is( that)?)\s*/i, ""); + const items = body + .split(/\n+/) + .map((line) => line.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, "").trim()) + .filter(Boolean); + const clauses: string[] = []; + for (const item of items) { + splitTopLevel(item).forEach((piece, i) => { + const text = piece.trim().replace(/[.,]$/, "").trim(); + if (!text) return; + const previous = clauses[clauses.length - 1]; + if (i > 0 && previous !== undefined && text.split(/\s+/).length < 3) { + clauses[clauses.length - 1] = `${previous} and ${text}`; + } else { + clauses.push(text); + } + }); + } + return clauses.length > 0 ? clauses : [body.trim()]; +} + +function splitTopLevel(text: string): string[] { + const pieces: string[] = []; + let depth = 0; + let quote: string | null = null; + let start = 0; + for (let i = 0; i < text.length; i++) { + const c = text[i]!; + if (quote) { + if (c === quote) quote = null; + continue; + } + if (c === "`" || c === '"') quote = c; + else if ("([{".includes(c)) depth++; + else if (")]}".includes(c)) depth = Math.max(0, depth - 1); + else if (depth === 0) { + if (c === ";") { + pieces.push(text.slice(start, i)); + start = i + 1; + } else { + const rest = text.slice(i); + const match = /^,?\s+and\s+/i.exec(rest); + if (match && (c === " " || c === ",")) { + pieces.push(text.slice(start, i)); + start = i + match[0].length; + i = start - 1; + } + } + } + } + pieces.push(text.slice(start)); + return pieces; +} + +export interface GoalEvidence { + /** The agent's last words before trying to stop. */ + lastMessage: string; + /** Recent tool calls with their outcomes, newest last. */ + toolResults: string[]; +} + +export interface GoalVerdict { + met: boolean; + clauses: NodGoalClause[]; +} + +export function evaluatorPrompt(clauses: string[], evidence: GoalEvidence): string { + return [ + "You are the goal evaluator for a coding agent. The agent is trying to stop.", + "Decide, for each numbered clause of its goal, whether the evidence below shows it is met.", + "Be strict: a clause is met only if the evidence demonstrates it, not if the agent merely claims it.", + "", + "Clauses:", + ...clauses.map((clause, i) => `${i}. ${clause}`), + "", + "Recent tool activity:", + ...(evidence.toolResults.length > 0 ? evidence.toolResults.map((r) => `- ${r}`) : ["(none)"]), + "", + "The agent's last message:", + evidence.lastMessage || "(empty)", + "", + 'Answer with JSON only: {"clauses":[{"index":0,"met":true,"evidence":"one short line"}]}', + ].join("\n"); +} + +/** Reads the judge's answer; anything unreadable counts as not met, never as met. */ +export function parseVerdict(answer: string, clauses: string[]): GoalVerdict { + const byIndex = new Map(); + const json = /\{[\s\S]*\}/.exec(answer)?.[0]; + if (json) { + try { + const parsed = JSON.parse(json) as { clauses?: unknown }; + if (Array.isArray(parsed.clauses)) { + for (const entry of parsed.clauses as Record[]) { + if (typeof entry?.index === "number") { + byIndex.set(entry.index, { + met: entry.met === true, + evidence: typeof entry.evidence === "string" ? entry.evidence : undefined, + }); + } + } + } + } catch { + // unreadable: every clause stays unmet + } + } + const result = clauses.map((text, i) => { + const judged = byIndex.get(i); + return { text, met: judged?.met ?? false, evidence: judged ? judged.evidence : "the evaluator gave no verdict" }; + }); + return { met: result.every((c) => c.met), clauses: result }; +} + +/** + * The native goal: checked every time the agent tries to stop. Not met means the agent is + * sent back to work with the unmet clauses; met (or marked done by a human) ends the loop. + */ +export class GoalEvaluator { + private markedDone = false; + + constructor( + public goal: string, + private readonly judge: Judge, + readonly model: string, + private readonly log: EventLog, + ) {} + + markDone(): void { + this.markedDone = true; + } + + get isMarkedDone(): boolean { + return this.markedDone; + } + + async check(turn: number, evidence: GoalEvidence): Promise { + const clauses = splitGoal(this.goal); + let verdict: GoalVerdict; + if (this.markedDone) { + verdict = { met: true, clauses: clauses.map((text) => ({ text, met: true, evidence: "marked done by a human" })) }; + } else { + // A judge that fails reads as "not met", the same as one that answers nonsense. + const answer = await this.judge(evaluatorPrompt(clauses, evidence), this.model).catch(() => ""); + verdict = parseVerdict(answer, clauses); + } + this.log.append({ type: "goalCheck", turn, evaluatorModel: this.model, clauses: verdict.clauses, met: verdict.met }); + return verdict; + } + + /** What the agent is told when it tried to stop too early. */ + static continuation(verdict: GoalVerdict): string { + const unmet = verdict.clauses.filter((c) => !c.met); + return [ + "Goal check: not yet. These parts of the goal are not met:", + ...unmet.map((c) => `- ${c.text}${c.evidence ? ` (${c.evidence})` : ""}`), + "Keep working until they are, then stop.", + ].join("\n"); + } +} diff --git a/NodRuntime/src/hunks.ts b/NodRuntime/src/hunks.ts new file mode 100644 index 00000000..97441f96 --- /dev/null +++ b/NodRuntime/src/hunks.ts @@ -0,0 +1,179 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, relative } from "node:path"; +import { applyHunk, hunkHeader, hunks, hunkStats, type Hunk } from "./diff"; +import type { EventLog } from "./eventLog"; +import type { NodHunkDecision } from "./protocol"; + +export interface StagedHunk { + id: string; + file: string; + hunk: Hunk; + turn: number; + autoAccepted: boolean; + state: "pending" | "accepted" | "rejected" | "commented"; + note?: string; +} + +export interface EditOutcome { + /** + * Every hunk accepted: nothing has been written, and the agent's own tool should now run + * and write the edit exactly as asked. Otherwise the accepted hunks are already on disk + * and the tool must not run. + */ + allAccepted: boolean; + hunks: StagedHunk[]; + /** What to tell the agent about the parts that did not land. */ + feedback: string; +} + +/** + * Edits are staged, not written. A hunk lands in the worktree only when it is accepted — + * or, in Auto mode, it lands at once and a later reject reverse-applies it, so an + * auto-accepted hunk stays reviewable until the turn ends. + * + * The edit is held until every hunk has a decision. All accepted, the agent's tool writes + * it; only some, the stager writes those and the tool is refused with the reviewer's notes + * — the stager never writes an edit the tool is about to write again. + */ +export class HunkStager { + private staged = new Map(); + private waiters = new Map void>(); + private nextID = 1; + + constructor( + private readonly log: EventLog, + private readonly worktree: string, + ) {} + + /** Called when an auto-accepted hunk is rejected or sent back after it landed. */ + onLateDecision?: (hunk: StagedHunk) => void; + + /** + * Stages the change from the file's current content to `after`, and resolves once every + * hunk has a decision — at once in auto mode. + */ + async stageEdit(file: string, after: string, turn: number, auto: boolean): Promise { + const before = readOr(file); + const staged = hunks(before, after).map((hunk) => this.stage(file, hunk, turn, auto)); + if (auto) return { allAccepted: true, hunks: staged, feedback: "" }; + await Promise.all(staged.map((h) => this.decided(h.id))); + const result = outcome(staged, this.worktree); + if (!result.allAccepted) { + // Applied to the file as it is now, so a human's edit made meanwhile survives. + const current = readOr(file); + let text = current; + for (const h of staged.filter((h) => h.state === "accepted")) text = applyHunk(text, h.hunk); + if (text !== current) writeFile(file, text); + } + return result; + } + + pending(): StagedHunk[] { + return [...this.staged.values()].filter((h) => h.state === "pending"); + } + + get(id: string): StagedHunk | undefined { + return this.staged.get(id); + } + + /** Accepted lines this turn, for `turnEnded`. */ + tally(turn: number): { filesChanged: number; added: number; removed: number } { + const landed = [...this.staged.values()].filter((h) => h.turn === turn && h.state === "accepted"); + const files = new Set(landed.map((h) => h.file)); + let added = 0; + let removed = 0; + for (const h of landed) { + const stats = hunkStats(h.hunk); + added += stats.added; + removed += stats.removed; + } + return { filesChanged: files.size, added, removed }; + } + + /** Auto-accepted hunks stop being reviewable once their turn has ended. */ + closeTurn(turn: number): void { + for (const [id, h] of this.staged) { + if (h.turn === turn && h.state !== "pending") this.staged.delete(id); + } + } + + /** A stopped turn can't wait for review: its pending hunks are dropped, not written. */ + rejectPending(note: string): void { + for (const h of this.pending()) this.resolve(h.id, "reject", note); + } + + resolve(id: string, decision: NodHunkDecision, note?: string): void { + const h = this.staged.get(id); + if (!h) throw new Error(`no hunk ${id}`); + const late = h.state === "accepted" && h.autoAccepted; + if (h.state === "pending") { + h.state = decision === "accept" ? "accepted" : decision === "reject" ? "rejected" : "commented"; + } else if (late && decision !== "accept") { + writeFile(h.file, applyHunk(readOr(h.file), h.hunk, true)); + h.state = decision === "reject" ? "rejected" : "commented"; + } else { + throw new Error(`hunk ${id} is already ${h.state}`); + } + h.note = note; + this.log.append({ type: "hunkResolved", hunkID: id, decision, note }); + this.waiters.get(id)?.(); + this.waiters.delete(id); + if (late) this.onLateDecision?.(h); + } + + private stage(file: string, hunk: Hunk, turn: number, auto: boolean): StagedHunk { + const staged: StagedHunk = { + id: `h${this.nextID++}`, + file, + hunk, + turn, + autoAccepted: auto, + state: auto ? "accepted" : "pending", + }; + this.staged.set(staged.id, staged); + const stats = hunkStats(hunk); + this.log.append({ + type: "hunkStaged", + turn, + hunkID: staged.id, + file: relative(this.worktree, file), + header: hunkHeader(hunk), + diff: [hunkHeader(hunk), ...hunk.lines].join("\n"), + added: stats.added, + removed: stats.removed, + autoAccepted: auto, + }); + return staged; + } + + private decided(id: string): Promise { + if (this.staged.get(id)?.state !== "pending") return Promise.resolve(); + return new Promise((resolve) => this.waiters.set(id, resolve)); + } +} + +function outcome(staged: StagedHunk[], worktree: string): EditOutcome { + const allAccepted = staged.every((h) => h.state === "accepted"); + const notes = staged + .filter((h) => h.state !== "accepted") + .map((h) => { + const where = `${relative(worktree, h.file)} ${hunkHeader(h.hunk)}`; + const verb = h.state === "rejected" ? "was rejected" : "was sent back"; + return `- ${where} ${verb}${h.note ? `: ${h.note}` : ""}`; + }); + const accepted = staged.filter((h) => h.state === "accepted").length; + const feedback = allAccepted + ? "" + : `The reviewer accepted ${accepted} of ${staged.length} hunks of this edit; accepted hunks are on disk, the rest are not.\n` + + notes.join("\n"); + return { allAccepted, hunks: staged, feedback }; +} + +function readOr(file: string): string { + return existsSync(file) ? readFileSync(file, "utf8") : ""; +} + +function writeFile(file: string, text: string): void { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, text); +} diff --git a/NodRuntime/src/permissions.ts b/NodRuntime/src/permissions.ts new file mode 100644 index 00000000..b513bfb0 --- /dev/null +++ b/NodRuntime/src/permissions.ts @@ -0,0 +1,220 @@ +import { isAbsolute, relative, resolve } from "node:path"; +import type { EventLog } from "./eventLog"; +import type { NodPermissionDecision, NodPermissionKind } from "./protocol"; +import type { Ask, NodSettings } from "./settings"; + +/** What a tool call wants to do, as far as the gate cares. Engines map their own tools onto it. */ +export type ToolIntent = + | { kind: "read" } + | { kind: "shell"; command: string } + | { kind: "fetch"; url: string } + | { kind: "edit"; path: string } + | { kind: "mcp"; server: string; tool: string } + | { kind: "messageLoop"; subject: string }; + +export type GateVerdict = + | { verdict: "allow" } + /** An edit inside the worktree: allowed, but through the hunk stager. */ + | { verdict: "stage" } + | { verdict: "deny"; message: string } + /** An unattended loop needed a human; the run fails rather than waiting. */ + | { verdict: "fail"; message: string }; + +export interface GateOptions { + settings: NodSettings; + worktree: string; + /** Timed loops and composite children: nobody is there to answer. */ + unattended: boolean; + log: EventLog; + /** Called when an ask opens and when the last one closes, for presence. */ + onAwaiting?: (awaiting: boolean) => void; +} + +interface OpenAsk { + kind: NodPermissionKind; + subject: string; + resolve: (decision: NodPermissionDecision) => void; +} + +/** + * Gates shell, network and out-of-worktree work by `NodSettings` and the shell allowlist. + * Reads, searches and in-worktree edits never ask here — edits are reviewed as hunks. + */ +export class PermissionGate { + private asks = new Map(); + private sessionAllowed = new Set(); + private nextID = 1; + + constructor(private readonly options: GateOptions) {} + + get openAsks(): number { + return this.asks.size; + } + + async check(intent: ToolIntent): Promise { + const { settings } = this.options; + switch (intent.kind) { + case "read": + return { verdict: "allow" }; + case "edit": + if (isInside(this.options.worktree, intent.path)) return { verdict: "stage" }; + return this.byPolicy(settings.editsOutsideWorktree, "editOutsideWorktree", intent.path, + "Edits a file outside this loop's worktree."); + case "shell": { + if (matchesAllowlist(intent.command, settings.shellAllowlist)) return { verdict: "allow" }; + const network = usesNetwork(intent.command); + if (network) { + if (settings.shell === "never") return deny("shell", intent.command); + return this.byPolicy(settings.network, "network", intent.command, + "Reaches the network. Not in this project's allowlist."); + } + return this.byPolicy(settings.shell, "shell", intent.command, "Not in this project's allowlist."); + } + case "fetch": + return this.byPolicy(settings.network, "network", intent.url, "Fetches a URL."); + case "mcp": + return { verdict: "allow" }; + case "messageLoop": + if (settings.messagesOtherLoops === "send") return { verdict: "allow" }; + if (settings.messagesOtherLoops === "never") return deny("messageLoop", intent.subject); + return this.ask("messageLoop", intent.subject, "Sends a message to another loop."); + } + } + + resolve(askID: string, decision: NodPermissionDecision): void { + const ask = this.asks.get(askID); + if (!ask) throw new Error(`no open ask ${askID}`); + this.asks.delete(askID); + if (decision === "alwaysAllow") this.sessionAllowed.add(key(ask.kind, ask.subject)); + this.options.log.append({ type: "permissionResolved", askID, decision }); + if (this.asks.size === 0) this.options.onAwaiting?.(false); + ask.resolve(decision); + } + + /** A stopped turn takes its open asks with it. */ + denyAll(): void { + for (const askID of [...this.asks.keys()]) this.resolve(askID, "deny"); + } + + private byPolicy(policy: Ask, kind: NodPermissionKind, subject: string, reason: string): Promise | GateVerdict { + if (policy === "always") return { verdict: "allow" }; + if (policy === "never") return deny(kind, subject); + return this.ask(kind, subject, reason); + } + + private async ask(kind: NodPermissionKind, subject: string, reason: string): Promise { + if (this.sessionAllowed.has(key(kind, subject))) return { verdict: "allow" }; + if (this.options.unattended) { + return { + verdict: "fail", + message: `This loop runs unattended and cannot stop to ask: ${describe(kind)} \`${subject}\`. ${reason}`, + }; + } + const askID = `p${this.nextID++}`; + const decision = await new Promise((resolve) => { + this.asks.set(askID, { kind, subject, resolve }); + this.options.log.append({ + type: "permissionAsked", + askID, + kind, + subject, + reason, + answerableFromCard: kind === "shell" && isReadOnlyCommand(subject), + }); + if (this.asks.size === 1) this.options.onAwaiting?.(true); + }); + return decision === "deny" + ? { verdict: "deny", message: `The human denied this: ${describe(kind)} \`${subject}\`.` } + : { verdict: "allow" }; + } +} + +function deny(kind: NodPermissionKind, subject: string): GateVerdict { + return { verdict: "deny", message: `Nod's settings never allow this: ${describe(kind)} \`${subject}\`.` }; +} + +function describe(kind: NodPermissionKind): string { + switch (kind) { + case "shell": return "run a command"; + case "network": return "reach the network"; + case "editOutsideWorktree": return "edit outside the worktree"; + case "messageLoop": return "message another loop"; + case "mcpTool": return "use an MCP tool"; + } +} + +function key(kind: NodPermissionKind, subject: string): string { + return `${kind}\u0000${subject}`; +} + +export function isInside(root: string, path: string): boolean { + const rel = relative(resolve(root), resolve(root, path)); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +/** + * Allowlist patterns are words: `*` alone matches any number of further words (`swift test + * *`), `*` inside a word is a wildcard, and `a|b|c` is one word of alternatives + * (`git status|diff|log`). A compound command is allowed only if every part is, and a + * command with substitutions never is — its real argv isn't known until it runs. + */ +export function matchesAllowlist(command: string, patterns: string[]): boolean { + if (patterns.length === 0) return false; + if (/`|\$\(|<\(|>\(/.test(command)) return false; + const parts = command.split(/&&|\|\||;|\||\n/).map((p) => p.trim()).filter(Boolean); + if (parts.length === 0) return false; + const regexes = patterns.map(patternRegex); + return parts.every((part) => regexes.some((re) => re.test(part))); +} + +function patternRegex(pattern: string): RegExp { + const words = pattern.trim().split(/\s+/); + let source = ""; + words.forEach((word, i) => { + if (word === "*") { + source += i === 0 ? "\\S+(?:\\s+\\S+)*" : "(?:\\s+\\S+)*"; + return; + } + const alternatives = word.split("|").map((alt) => alt.split("*").map(escape).join("\\S*")); + source += (i === 0 ? "" : "\\s+") + `(?:${alternatives.join("|")})`; + }); + return new RegExp(`^${source}$`); +} + +function escape(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +const networkCommands: RegExp[] = [ + /^(curl|wget|ssh|scp|sftp|rsync|nc|ncat|telnet|ping|dig|nslookup|ftp|gh|brew)\b/, + /^git\s+(clone|fetch|pull|push|ls-remote|remote\s+update|submodule\s+(update|sync))\b/, + /^(npm|pnpm|yarn|bun)\s+(install|i|add|ci|update|upgrade|publish|dlx|create)\b/, + /^(npx|bunx|pnpx|uvx)\b/, + /^(pip3?|uv\s+pip|pipx)\s+install\b/, + /^(uv|poetry)\s+(add|sync|lock|install)\b/, + /^swift\s+package\s+(resolve|update|reset)\b/, + /^(cargo)\s+(install|fetch|update|add|publish)\b/, + /^go\s+(get|install|mod\s+download)\b/, + /^(docker|podman)\s+(pull|push|login)\b/, + /^(gem|bundle)\s+(install|update)\b/, + /^(pod)\s+(install|update|repo)\b/, + /^tuist\s+install\b/, +]; + +export function usesNetwork(command: string): boolean { + return command + .split(/&&|\|\||;|\||\n/) + .map((part) => part.trim().replace(/^(sudo|env(\s+\w+=\S+)*|time)\s+/, "")) + .some((part) => networkCommands.some((re) => re.test(part))); +} + +const readOnlyHeads = /^(ls|cat|head|tail|wc|grep|rg|find|pwd|which|file|stat|du|df|tree|echo|git\s+(status|diff|log|show|branch|blame))\b/; + +export function isReadOnlyCommand(command: string): boolean { + if (/[>]|`|\$\(|-delete\b|-exec\b/.test(command)) return false; + return command + .split(/&&|\|\||;|\|/) + .map((part) => part.trim()) + .filter(Boolean) + .every((part) => readOnlyHeads.test(part)); +} diff --git a/NodRuntime/src/presence.ts b/NodRuntime/src/presence.ts new file mode 100644 index 00000000..ce0f9dc0 --- /dev/null +++ b/NodRuntime/src/presence.ts @@ -0,0 +1,100 @@ +import { spawn } from "node:child_process"; +import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { supportDirectory } from "./settings"; + +export type Presence = "busy" | "awaitingInput" | "idle" | "absent"; + +/** + * Encodes a phrase for a `zmx` label value, which takes only `[A-Za-z0-9._-]`: exactly what + * PresenceHooks' activity script does, so `ZmxSessionLauncher.decodedActivity` reads it back. + * Literal `_` becomes `_5F`, every other disallowed byte a space, and spaces `_20`. + */ +export function encodeActivity(phrase: string): string { + return phrase + .slice(0, 64) + .replace(/_/g, "_5F") + .replace(/[^A-Za-z0-9._-]/g, " ") + .replace(/ +/g, " ") + .trim() + .replace(/ /g, "_20"); +} + +export type LabelRunner = (args: string[]) => Promise; + +/** + * Writes the labels Claude Code's hooks write (`presence`, `activity`, `usage`) and the + * `sessions/.id` pointer plus `.history` line `captureSessionID` writes, so the + * daemon's existing readers serve Nod unchanged. Outside a zmx session it does nothing, + * like the hooks' `$ZMX_SESSION` guard. + */ +export class PresenceReporter { + private chain: Promise = Promise.resolve(); + private last = ""; + + constructor( + private readonly session: string | undefined, + private readonly run: LabelRunner = zmxRunner(), + private readonly support = supportDirectory(), + ) {} + + get enabled(): boolean { + return Boolean(this.session); + } + + presence(presence: Presence, activity?: string): Promise { + // Every report but a tool call's clears the activity: the last thing a session was + // doing is stale the moment it answers, stops or waits. + const labels = [`presence=${presence}`, `activity=${activity ? encodeActivity(activity) : ""}`]; + return this.set(labels); + } + + usage(inputTokens: number, outputTokens: number): Promise { + return this.set([`usage=input.${Math.round(inputTokens)}_output.${Math.round(outputTokens)}`]); + } + + sessionID(nodeID: string, conversationID: string, cwd: string): void { + if (!this.session) return; + const directory = join(this.support, "sessions"); + mkdirSync(directory, { recursive: true }); + const epoch = Math.floor(Date.now() / 1000); + appendFileSync(join(directory, `${nodeID}.history`), `${epoch} ${conversationID} ${cwd}\n`); + writeFileSync(join(directory, `${nodeID}.id`), conversationID); + } + + /** Writes are serialised, so labels land in the order they were reported. */ + private set(labels: string[]): Promise { + if (!this.session) return Promise.resolve(); + const key = labels.join(" "); + if (key === this.last) return this.chain; + this.last = key; + const session = this.session; + this.chain = this.chain.then(() => this.run(["set", session, ...labels]).catch(() => {})); + return this.chain; + } + + flush(): Promise { + return this.chain; + } +} + +export function zmxPath(support = supportDirectory()): string { + const bundled = join(support, "bin", "zmx"); + return existsSync(bundled) ? bundled : "zmx"; +} + +function zmxRunner(path = zmxPath()): LabelRunner { + return (args) => + new Promise((resolve) => { + const child = spawn(path, args, { stdio: "ignore" }); + const timer = setTimeout(() => child.kill("SIGKILL"), 5000); + child.on("error", () => { + clearTimeout(timer); + resolve(); + }); + child.on("exit", () => { + clearTimeout(timer); + resolve(); + }); + }); +} diff --git a/NodRuntime/src/protocol.ts b/NodRuntime/src/protocol.ts new file mode 100644 index 00000000..4ef1e1cf --- /dev/null +++ b/NodRuntime/src/protocol.ts @@ -0,0 +1,212 @@ +// The TypeScript side of GraphcodeKit/Sources/Domain/NodProtocol.swift. PROTOCOL.md is the +// prose version; the three change together. + +export const PROTOCOL_VERSION = 1; + +export type NodEngineKind = "claude" | "copilot"; +export type NodDelivery = "queue" | "steer"; +export type NodTurnOrigin = "user" | "queue" | "steer" | "handoff" | "mail" | "timer" | "goalCheck"; +export type NodPermissionKind = "shell" | "network" | "editOutsideWorktree" | "messageLoop" | "mcpTool"; +export type NodPermissionDecision = "allowOnce" | "alwaysAllow" | "deny"; +export type NodHunkDecision = "accept" | "reject" | "comment"; +export type NodToolStatus = "running" | "ok" | "error"; +export type NodFailureKind = + | "signInExpired" + | "contextFull" + | "spendCap" + | "permissionUnavailable" + | "engineError"; + +export interface NodAttachment { + kind: "file" | "image" | "loopTranscript"; + reference: string; + label?: string; +} + +export interface NodGoalClause { + text: string; + met: boolean; + evidence?: string; +} + +export interface NodPlanStep { + id: string; + text: string; + files: string[]; + size?: "small" | "medium" | "large"; + editedByHuman: boolean; +} + +export type NodEvent = + | { type: "sessionStarted"; engine: NodEngineKind; model: string; conversationID: string; resumed: boolean } + | { type: "turnStarted"; turn: number; origin: NodTurnOrigin } + | { + type: "userMessage"; + id: string; + text: string; + delivery: NodDelivery; + attachments: NodAttachment[]; + fromNodeID?: string; + } + | { type: "assistantText"; turn: number; messageID: string; delta: string; final: boolean } + | { type: "toolCall"; turn: number; callID: string; tool: string; title: string } + | { + type: "toolResult"; + callID: string; + status: NodToolStatus; + summary: string; + output?: string; + durationMs?: number; + } + | { + type: "hunkStaged"; + turn: number; + hunkID: string; + file: string; + header: string; + diff: string; + added: number; + removed: number; + autoAccepted: boolean; + } + | { type: "hunkResolved"; hunkID: string; decision: NodHunkDecision; note?: string } + | { + type: "permissionAsked"; + askID: string; + kind: NodPermissionKind; + subject: string; + reason: string; + answerableFromCard: boolean; + } + | { type: "permissionResolved"; askID: string; decision: NodPermissionDecision } + | { type: "goalCheck"; turn: number; evaluatorModel: string; clauses: NodGoalClause[]; met: boolean } + | { type: "turnEnded"; turn: number; filesChanged: number; added: number; removed: number; summary?: string } + | { + type: "usage"; + inputTokens: number; + outputTokens: number; + costUSD?: number; + premiumRequests?: number; + contextUsed: number; + } + | { type: "planProposed"; planID: string; title: string; steps: NodPlanStep[] } + | { type: "mailDraft"; draftID: string; toNodeID: string; inReplyTo?: string; text: string } + | { type: "compacted"; fromTurn: number; throughTurn: number } + | { type: "activity"; line: string } + | { type: "failure"; kind: NodFailureKind; message: string }; + +export type NodEventRecord = NodEvent & { v: number; seq: number; at: string }; + +export type NodCommand = + | { type: "send"; text: string; delivery: NodDelivery; attachments: NodAttachment[] } + | { type: "stop" } + | { type: "resolveHunk"; hunkID: string; decision: NodHunkDecision; note?: string } + | { type: "resolvePermission"; askID: string; decision: NodPermissionDecision } + | { type: "runPlan"; planID: string; steps: NodPlanStep[]; mode: "here" | "composite" } + | { type: "fork"; messageID: string } + | { type: "sendDraft"; draftID: string; text: string } + | { type: "compact" } + | { type: "setModel"; model: string } + | { type: "markGoalDone" }; + +/** Swift's `.iso8601` date strategy rejects fractional seconds, so `at` never carries them. */ +export function wireDate(date: Date): string { + return date.toISOString().replace(/\.\d{3}Z$/, "Z"); +} + +const deliveries = new Set(["queue", "steer"]); +const hunkDecisions = new Set(["accept", "reject", "comment"]); +const permissionDecisions = new Set(["allowOnce", "alwaysAllow", "deny"]); +const attachmentKinds = new Set(["file", "image", "loopTranscript"]); + +/** + * Validates one control-socket line into a command, mirroring `NodCommand`'s Swift decoder: + * an unknown type or a missing required field is an error, unknown extra fields are ignored. + */ +export function parseCommand(line: string): NodCommand { + let raw: unknown; + try { + raw = JSON.parse(line); + } catch { + throw new Error("not JSON"); + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("not an object"); + const o = raw as Record; + const str = (key: string): string => { + const value = o[key]; + if (typeof value !== "string") throw new Error(`${String(o.type)}: missing ${key}`); + return value; + }; + const optStr = (key: string): string | undefined => { + const value = o[key]; + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") throw new Error(`${String(o.type)}: ${key} is not a string`); + return value; + }; + const oneOf = (key: string, allowed: Set): T => { + const value = str(key); + if (!allowed.has(value)) throw new Error(`${String(o.type)}: bad ${key} ${value}`); + return value as T; + }; + switch (o.type) { + case "send": { + const attachments = Array.isArray(o.attachments) ? o.attachments.map(parseAttachment) : []; + const delivery = o.delivery === undefined ? "queue" : oneOf("delivery", deliveries); + return { type: "send", text: str("text"), delivery, attachments }; + } + case "stop": + case "compact": + case "markGoalDone": + return { type: o.type }; + case "resolveHunk": + return { + type: "resolveHunk", + hunkID: str("hunkID"), + decision: oneOf("decision", hunkDecisions), + note: optStr("note"), + }; + case "resolvePermission": + return { + type: "resolvePermission", + askID: str("askID"), + decision: oneOf("decision", permissionDecisions), + }; + case "runPlan": { + if (!Array.isArray(o.steps)) throw new Error("runPlan: missing steps"); + const mode = str("mode"); + if (mode !== "here" && mode !== "composite") throw new Error(`runPlan: bad mode ${mode}`); + return { type: "runPlan", planID: str("planID"), steps: o.steps.map(parseStep), mode }; + } + case "fork": + return { type: "fork", messageID: str("messageID") }; + case "sendDraft": + return { type: "sendDraft", draftID: str("draftID"), text: str("text") }; + case "setModel": + return { type: "setModel", model: str("model") }; + default: + throw new Error(`unknown Nod command ${String(o.type)}`); + } +} + +function parseAttachment(value: unknown): NodAttachment { + const o = (value ?? {}) as Record; + if (typeof o.kind !== "string" || !attachmentKinds.has(o.kind)) throw new Error("attachment: bad kind"); + if (typeof o.reference !== "string") throw new Error("attachment: missing reference"); + return { + kind: o.kind as NodAttachment["kind"], + reference: o.reference, + label: typeof o.label === "string" ? o.label : undefined, + }; +} + +function parseStep(value: unknown): NodPlanStep { + const o = (value ?? {}) as Record; + if (typeof o.id !== "string" || typeof o.text !== "string") throw new Error("plan step: missing id or text"); + return { + id: o.id, + text: o.text, + files: Array.isArray(o.files) ? o.files.filter((f): f is string => typeof f === "string") : [], + size: o.size === "small" || o.size === "medium" || o.size === "large" ? o.size : undefined, + editedByHuman: o.editedByHuman === true, + }; +} diff --git a/NodRuntime/src/runtime.ts b/NodRuntime/src/runtime.ts new file mode 100644 index 00000000..cd4cf5af --- /dev/null +++ b/NodRuntime/src/runtime.ts @@ -0,0 +1,397 @@ +import { randomUUID } from "node:crypto"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { Authorization, Engine, EngineFailure, ToolRequest, TurnCallbacks, TurnResult, UsageReport } from "./engine"; +import type { EventLog } from "./eventLog"; +import { GoalEvaluator } from "./goal"; +import { HunkStager, type StagedHunk } from "./hunks"; +import { PermissionGate } from "./permissions"; +import type { PresenceReporter } from "./presence"; +import type { NodAttachment, NodCommand, NodDelivery, NodTurnOrigin } from "./protocol"; +import type { NodSettings } from "./settings"; +import { describeTool } from "./tools"; + +export type LoopType = "main" | "goal" | "timed" | "turn" | "composite"; + +export interface RuntimeOptions { + nodeID: string; + cwd: string; + stateDir: string; + loopType: LoopType; + settings: NodSettings; + engine: Engine; + log: EventLog; + presence: PresenceReporter; + model?: string; + goal?: string; + briefing?: string; + resume?: string; + /** Timed loops and composite children: nobody is there to answer an ask. */ + unattended?: boolean; + /** Consecutive "not yet" goal checks before Nod stops and waits for a human. */ + maxGoalContinuations?: number; +} + +interface Pending { + text: string; + origin: NodTurnOrigin; + attachments: NodAttachment[]; +} + +const CONTEXT_COMPACT_AT = 0.95; + +/** + * One Nod session: turns run one at a time off a queue, every engine report becomes an + * event-log record, and the goal is checked each time the agent stops. + */ +export class NodRuntime { + readonly gate: PermissionGate; + readonly stager: HunkStager; + readonly goal?: GoalEvaluator; + private queue: Pending[] = []; + private steerBuffer: string[] = []; + private turn = 0; + private running?: Promise; + private busy = false; + private stopRequested = false; + private compactRequested = false; + private runFailed = false; + private goalContinuations = 0; + private toolStarts = new Map(); + private recentTools: string[] = []; + private lastUsage?: UsageReport; + private runCostBaseline = 0; + private costSoFar = 0; + private conversationID = ""; + private model = ""; + private closed = false; + private idleWaiters: (() => void)[] = []; + private readonly unattended: boolean; + + constructor(private readonly options: RuntimeOptions) { + const { log, settings, cwd } = options; + this.unattended = options.unattended ?? options.loopType === "timed"; + this.gate = new PermissionGate({ + settings, + worktree: cwd, + unattended: this.unattended, + log, + onAwaiting: (awaiting) => void this.options.presence.presence(awaiting ? "awaitingInput" : "busy"), + }); + this.stager = new HunkStager(log, cwd); + this.stager.onLateDecision = (hunk) => this.steer(lateDecisionNote(hunk)); + if (options.goal?.trim()) { + const evaluatorModel = settings.goalEvaluatorModel ?? (options.engine.kind === "claude" ? "haiku" : ""); + this.goal = new GoalEvaluator(options.goal, (prompt, model) => options.engine.ask(prompt, model || undefined), evaluatorModel, log); + } + } + + get currentTurn(): number { + return this.turn; + } + + get isBusy(): boolean { + return this.busy; + } + + async start(firstPrompt?: string): Promise { + const { engine, log, presence, nodeID, cwd } = this.options; + const systemAppend = this.options.briefing ? `GraphCode briefing for this loop:\n\n${this.options.briefing}` : undefined; + const session = await engine.start({ cwd, model: this.options.model, resume: this.options.resume, systemAppend }); + this.conversationID = session.conversationID; + this.model = session.model; + log.append({ + type: "sessionStarted", + engine: engine.kind, + model: session.model, + conversationID: session.conversationID, + resumed: Boolean(this.options.resume), + }); + this.writeConversation(); + presence.sessionID(nodeID, session.conversationID, cwd); + void presence.presence("idle"); + if (firstPrompt?.trim()) this.send(firstPrompt, "queue", [], "user"); + else if (this.goal && !this.options.resume) this.send(this.goal.goal, "queue", [], "user"); + } + + /** Resolves once no turn is running and nothing is queued. */ + whenIdle(): Promise { + if (!this.busy && this.queue.length === 0) return Promise.resolve(); + return new Promise((resolve) => this.idleWaiters.push(resolve)); + } + + async handle(command: NodCommand): Promise { + switch (command.type) { + case "send": + this.send(command.text, command.delivery, command.attachments); + return; + case "stop": + await this.stop(); + return; + case "resolveHunk": + this.stager.resolve(command.hunkID, command.decision, command.note); + return; + case "resolvePermission": + this.gate.resolve(command.askID, command.decision); + return; + case "runPlan": + if (command.mode === "composite") throw new Error("composite plans are run by the graph layer, not the runtime"); + this.send( + ["Run this plan, step by step:", ...command.steps.map((s, i) => `${i + 1}. ${s.text}${s.editedByHuman ? " (edited by the human — keep it as written)" : ""}`)].join("\n"), + "queue", + [], + ); + return; + case "fork": + throw new Error("fork is not supported by this runtime yet"); + case "sendDraft": + throw new Error("mail drafts are sent by the graph layer, not the runtime"); + case "compact": + if (this.busy) this.compactRequested = true; + else await this.compactNow(); + return; + case "setModel": + await this.options.engine.setModel(command.model); + this.model = command.model; + this.writeConversation(); + return; + case "markGoalDone": + if (!this.goal) throw new Error("this loop has no goal"); + this.goal.markDone(); + if (!this.busy) await this.goal.check(this.turn, { lastMessage: "", toolResults: [] }); + return; + } + } + + /** A user message: queued for the next turn, or steered into this one at a tool boundary. */ + send(text: string, delivery: NodDelivery, attachments: NodAttachment[] = [], origin?: NodTurnOrigin, fromNodeID?: string): void { + this.options.log.append({ type: "userMessage", id: randomUUID(), text, delivery, attachments, fromNodeID }); + this.goalContinuations = 0; + if (delivery === "steer" && this.busy) { + this.steerBuffer.push(text); + return; + } + this.queue.push({ text, attachments, origin: origin ?? (this.busy ? "queue" : "user") }); + this.pump(); + } + + /** A note for the agent at its next tool boundary, without a user-message echo. */ + private steer(text: string): void { + if (this.busy) this.steerBuffer.push(text); + else { + this.queue.push({ text, attachments: [], origin: "steer" }); + this.pump(); + } + } + + async stop(): Promise { + this.queue = []; + this.steerBuffer = []; + if (!this.busy) return; + this.stopRequested = true; + this.stager.rejectPending("the turn was stopped"); + this.gate.denyAll(); + await this.options.engine.interrupt(); + } + + async close(): Promise { + this.closed = true; + this.queue = []; + if (this.busy) await this.stop(); + await this.running; + await this.options.engine.close(); + await this.options.presence.presence("absent"); + } + + private pump(): void { + if (this.running || this.closed) return; + this.running = (async () => { + while (this.queue.length > 0 && !this.closed) { + const next = this.queue.shift()!; + await this.runTurn(next); + } + this.running = undefined; + if (!this.closed) void this.options.presence.presence("idle"); + for (const resolve of this.idleWaiters.splice(0)) resolve(); + })(); + } + + private async runTurn(pending: Pending): Promise { + const { log, presence, engine } = this.options; + if (pending.origin !== "goalCheck" && pending.origin !== "steer") { + this.runCostBaseline = this.costSoFar; + this.runFailed = false; + } + this.turn += 1; + const turn = this.turn; + this.busy = true; + this.stopRequested = false; + this.recentTools = []; + log.append({ type: "turnStarted", turn, origin: pending.origin }); + void presence.presence("busy"); + + let result: TurnResult; + try { + result = await engine.runTurn(pending.text, pending.attachments, this.callbacks(turn)); + } catch (error) { + result = { lastMessage: "", failure: { kind: "engineError", message: errorMessage(error) } }; + } + if (result.failure) this.fail(result.failure); + + const tally = this.stager.tally(turn); + log.append({ type: "turnEnded", turn, ...tally, summary: summaryLine(result.lastMessage) }); + this.stager.closeTurn(turn); + this.busy = false; + + const leftover = this.steerBuffer.splice(0); + if (leftover.length > 0) this.queue.unshift({ text: leftover.join("\n\n"), attachments: [], origin: "steer" }); + + if (this.compactRequested || (this.lastUsage?.contextUsed ?? 0) >= CONTEXT_COMPACT_AT) { + this.compactRequested = false; + await this.compactNow(); + } + + const stoppedEarly = this.stopRequested || result.interrupted || this.runFailed; + if (this.goal && !stoppedEarly && this.queue.length === 0) await this.checkGoal(turn, result.lastMessage); + } + + private async checkGoal(turn: number, lastMessage: string): Promise { + const goal = this.goal!; + void this.options.presence.presence("busy", "checking the goal"); + const verdict = await goal.check(turn, { lastMessage, toolResults: this.recentTools.slice(-20) }); + if (verdict.met) return; + const limit = this.options.maxGoalContinuations ?? 20; + if (this.goalContinuations >= limit) { + this.options.log.append({ type: "activity", line: `Goal not met after ${limit} checks · waiting for you` }); + return; + } + this.goalContinuations += 1; + this.queue.push({ text: GoalEvaluator.continuation(verdict), attachments: [], origin: "goalCheck" }); + } + + private async compactNow(): Promise { + const { engine, log } = this.options; + const throughTurn = this.turn; + let compacted = false; + const callbacks = { ...this.callbacks(this.turn), compacted: () => (compacted = true) }; + const result = await engine.compact(callbacks).catch((error) => ({ + lastMessage: "", + failure: { kind: "contextFull" as const, message: `Compacting failed: ${errorMessage(error)}` }, + })); + if (result.failure) this.fail(result.failure); + if (compacted && throughTurn > 0) log.append({ type: "compacted", fromTurn: 1, throughTurn }); + } + + private callbacks(turn: number): TurnCallbacks { + const { log, presence } = this.options; + return { + text: (messageID, delta, final) => log.append({ type: "assistantText", turn, messageID, delta, final }), + toolCall: (callID, tool, input) => { + const { title, activity } = describeTool(tool, input); + this.toolStarts.set(callID, { tool, at: Date.now() }); + log.append({ type: "toolCall", turn, callID, tool, title }); + log.append({ type: "activity", line: `${capitalize(activity)} · turn ${turn}` }); + void presence.presence("busy", activity); + }, + toolResult: (callID, status, summary, output, durationMs) => { + const start = this.toolStarts.get(callID); + if (status !== "running") this.toolStarts.delete(callID); + const duration = durationMs ?? (start ? Date.now() - start.at : undefined); + log.append({ type: "toolResult", callID, status, summary, output: truncate(output), durationMs: duration }); + if (status !== "running" && start) { + this.recentTools.push(`${start.tool}: ${status} — ${summary}${output ? `\n${truncate(output, 1500)}` : ""}`); + } + }, + usage: (report) => this.recordUsage(report), + compacted: () => log.append({ type: "compacted", fromTurn: 1, throughTurn: turn }), + authorize: (request) => this.authorize(request, turn), + takeSteer: () => { + const text = this.steerBuffer.splice(0).join("\n\n"); + return text ? `The human steered while you worked: ${text}` : undefined; + }, + }; + } + + private async authorize(request: ToolRequest, turn: number): Promise { + const verdict = await this.gate.check(request.intent); + switch (verdict.verdict) { + case "allow": + return { allow: true }; + case "deny": + return { allow: false, message: verdict.message }; + case "fail": + this.fail({ kind: "permissionUnavailable", message: verdict.message }); + return { allow: false, message: verdict.message, interrupt: true }; + case "stage": { + if (!request.edit) return { allow: true }; + const auto = this.options.settings.editsInWorktree === "auto"; + if (!auto) void this.options.presence.presence("awaitingInput", "waiting for review"); + try { + const outcome = await this.stager.stageEdit(request.edit.path, request.edit.after, turn, auto); + if (!auto) void this.options.presence.presence("busy"); + return outcome.allAccepted ? { allow: true } : { allow: false, message: outcome.feedback }; + } catch (error) { + return { allow: false, message: `This edit could not be staged: ${errorMessage(error)}` }; + } + } + } + } + + private recordUsage(report: UsageReport): void { + this.lastUsage = report; + if (report.costUSD !== undefined) this.costSoFar = report.costUSD; + this.options.log.append({ type: "usage", ...report }); + void this.options.presence.usage(report.inputTokens, report.outputTokens); + this.enforceSpendCap(); + } + + /** + * The cap is per run — from the turn a human or timer started, through the goal checks + * it led to — so a timed loop's next firing starts from zero. Dollars only: Copilot + * bills premium requests, which its plan caps. + */ + private enforceSpendCap(): void { + const cap = this.options.settings.spendCapUSD; + if (!this.unattended || cap <= 0 || !this.busy || this.runFailed) return; + const spent = this.costSoFar - this.runCostBaseline; + if (spent < cap) return; + this.fail({ kind: "spendCap", message: `Hit its $${cap.toFixed(2)} cap this run ($${spent.toFixed(2)} spent). Stopped mid-turn ${this.turn}.` }); + void this.stop(); + } + + private fail(failure: EngineFailure): void { + if (this.runFailed && failure.kind !== "signInExpired") return; + this.runFailed = true; + this.options.log.append({ type: "failure", kind: failure.kind, message: failure.message }); + } + + private writeConversation(): void { + const conversation = { engine: this.options.engine.kind, model: this.model, conversationID: this.conversationID }; + writeFileSync(join(this.options.stateDir, "conversation.json"), JSON.stringify(conversation, null, 2) + "\n"); + } +} + +function lateDecisionNote(hunk: StagedHunk): string { + const verb = hunk.state === "rejected" ? "rejected" : "sent back"; + return `The reviewer ${verb} an edit you made to ${hunk.file} and it has been reverted on disk${hunk.note ? `: ${hunk.note}` : "."}`; +} + +function summaryLine(text: string): string | undefined { + const line = text.split("\n").map((l) => l.trim()).find(Boolean); + if (!line) return undefined; + return line.length > 140 ? line.slice(0, 139) + "…" : line; +} + +function truncate(text: string | undefined, max = 8000): string | undefined { + if (text === undefined) return undefined; + return text.length > max ? text.slice(0, max) + `\n… ${text.length - max} more characters` : text; +} + +function capitalize(text: string): string { + return text.charAt(0).toUpperCase() + text.slice(1); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + diff --git a/NodRuntime/src/settings.ts b/NodRuntime/src/settings.ts new file mode 100644 index 00000000..0891f1f6 --- /dev/null +++ b/NodRuntime/src/settings.ts @@ -0,0 +1,69 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import type { NodEngineKind } from "./protocol"; + +export type Ask = "always" | "ask" | "never"; + +/** `NodSettings` in GraphcodeKit, read from the same `settings.json` the app writes. */ +export interface NodSettings { + engine: NodEngineKind; + modelsByLoopType: Record; + compositeChildModel?: string; + goalEvaluatorModel?: string; + shell: Ask; + network: Ask; + editsInWorktree: "reviewHunks" | "auto"; + editsOutsideWorktree: Ask; + messagesOtherLoops: "draftForMe" | "send" | "never"; + shellAllowlist: string[]; + spendCapUSD: number; +} + +export const defaultSettings: NodSettings = { + engine: "claude", + modelsByLoopType: {}, + shell: "ask", + network: "ask", + editsInWorktree: "reviewHunks", + editsOutsideWorktree: "never", + messagesOtherLoops: "draftForMe", + shellAllowlist: [], + spendCapUSD: 2, +}; + +/** `~/.graphcode`, or `GRAPHCODE_SUPPORT_DIR` resolved against home, as `SupportDirectory` does. */ +export function supportDirectory(env: Record = process.env): string { + const configured = env.GRAPHCODE_SUPPORT_DIR?.trim(); + if (!configured) return join(homedir(), ".graphcode"); + return isAbsolute(configured) ? configured : join(homedir(), configured); +} + +/** Falls back field by field, like the Swift decoder, so a partial or missing file still works. */ +export function loadSettings(path = join(supportDirectory(), "settings.json")): NodSettings { + if (!existsSync(path)) return { ...defaultSettings }; + let nod: Record = {}; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as { nod?: unknown }; + if (parsed.nod && typeof parsed.nod === "object") nod = parsed.nod as Record; + } catch { + return { ...defaultSettings }; + } + const pick = (key: keyof NodSettings, valid: (value: unknown) => boolean): T => + (valid(nod[key]) ? nod[key] : defaultSettings[key]) as T; + const oneOf = (...values: string[]) => (value: unknown) => typeof value === "string" && values.includes(value); + const isString = (value: unknown) => typeof value === "string"; + return { + engine: pick("engine", oneOf("claude", "copilot")), + modelsByLoopType: pick("modelsByLoopType", (v) => typeof v === "object" && v !== null && !Array.isArray(v)), + compositeChildModel: isString(nod.compositeChildModel) ? (nod.compositeChildModel as string) : undefined, + goalEvaluatorModel: isString(nod.goalEvaluatorModel) ? (nod.goalEvaluatorModel as string) : undefined, + shell: pick("shell", oneOf("always", "ask", "never")), + network: pick("network", oneOf("always", "ask", "never")), + editsInWorktree: pick("editsInWorktree", oneOf("reviewHunks", "auto")), + editsOutsideWorktree: pick("editsOutsideWorktree", oneOf("always", "ask", "never")), + messagesOtherLoops: pick("messagesOtherLoops", oneOf("draftForMe", "send", "never")), + shellAllowlist: pick("shellAllowlist", (v) => Array.isArray(v) && v.every(isString)), + spendCapUSD: pick("spendCapUSD", (v) => typeof v === "number" && Number.isFinite(v) && v >= 0), + }; +} diff --git a/NodRuntime/src/tools.ts b/NodRuntime/src/tools.ts new file mode 100644 index 00000000..8d0b62b8 --- /dev/null +++ b/NodRuntime/src/tools.ts @@ -0,0 +1,94 @@ +import { basename } from "node:path"; + +export interface ToolDescription { + /** The work card's one line, e.g. `Search "UsageGate"`. */ + title: string; + /** The canvas card's live line, in PresenceHooks' activity-script phrasing. */ + activity: string; +} + +function field(input: unknown, ...keys: string[]): string { + if (typeof input !== "object" || input === null) return ""; + const record = input as Record; + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value) return value; + } + return ""; +} + +function oneLine(text: string, max = 80): string { + const line = text.replace(/\s+/g, " ").trim(); + return line.length > max ? line.slice(0, max - 1) + "…" : line; +} + +/** Covers both engines' tool names: Claude Code's (`Read`, `Bash`) and Copilot's (`view`, `bash`). */ +export function describeTool(tool: string, input: unknown): ToolDescription { + const path = field(input, "file_path", "path", "notebook_path", "fileName"); + const name = path ? basename(path) : ""; + switch (tool.toLowerCase()) { + case "edit": + case "multiedit": + case "write": + case "create": + case "str_replace_editor": + case "notebookedit": + return { title: `Edit ${name}`, activity: `editing ${name}` }; + case "read": + case "view": + return { title: `Read ${name}`, activity: `reading ${name}` }; + case "bash": + case "shell": + case "bashoutput": { + const command = oneLine(field(input, "command", "fullCommandText")); + return { title: command || "Shell", activity: `running ${command}` }; + } + case "grep": + case "rg": { + const pattern = field(input, "pattern", "query"); + return { title: `Search "${oneLine(pattern, 60)}"`, activity: `searching for ${pattern}` }; + } + case "glob": { + const pattern = field(input, "pattern"); + return { title: `Find "${oneLine(pattern, 60)}"`, activity: `looking for ${pattern}` }; + } + case "websearch": + case "web_search": { + const query = field(input, "query"); + return { title: `Search the web for "${oneLine(query, 60)}"`, activity: `searching the web for ${query}` }; + } + case "webfetch": + case "web_fetch": { + const host = field(input, "url").replace(/^\w+:\/\//, "").split("/")[0] ?? ""; + return { title: `Fetch ${host}`, activity: `reading ${host}` }; + } + case "task": + case "agent": { + const description = field(input, "description"); + return { title: `Delegate ${oneLine(description, 60)}`, activity: `delegating ${description}` }; + } + case "todowrite": + case "update_todo": + return { title: "Plan", activity: "planning" }; + } + if (tool.startsWith("mcp__")) { + const short = tool.split("__").pop() ?? tool; + return { title: `Use ${short}`, activity: `using ${short}` }; + } + return { title: tool, activity: `using ${tool}` }; +} + +/** A tool result's one-line summary: `exit 0`, `84 lines`, or the first line of the output. */ +export function summarizeResult(tool: string, output: string, isError: boolean): string { + const lines = output.split("\n").filter((l) => l.trim().length > 0); + if (isError) return oneLine(lines[0] ?? "failed", 100); + switch (tool.toLowerCase()) { + case "read": + case "view": + return `${lines.length} lines`; + case "grep": + case "glob": + return lines.length === 0 ? "no matches" : `${lines.length} ${lines.length === 1 ? "match" : "matches"}`; + } + return oneLine(lines[lines.length - 1] ?? "done", 100); +} From 5e522a318e488e25b6eed4e94e4a70ef2e4a4fc1 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:32:54 -0700 Subject: [PATCH 03/13] Test the Nod runtime core against a fake engine Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- NodRuntime/src/goal.ts | 6 +- NodRuntime/test/controlSocket.test.ts | 120 +++++++++ NodRuntime/test/eventLog.test.ts | 123 +++++++++ NodRuntime/test/fakeEngine.ts | 81 ++++++ NodRuntime/test/goal.test.ts | 126 +++++++++ NodRuntime/test/hunks.test.ts | 171 ++++++++++++ NodRuntime/test/permissions.test.ts | 185 +++++++++++++ NodRuntime/test/runtime.test.ts | 366 ++++++++++++++++++++++++++ 8 files changed, 1175 insertions(+), 3 deletions(-) create mode 100644 NodRuntime/test/controlSocket.test.ts create mode 100644 NodRuntime/test/eventLog.test.ts create mode 100644 NodRuntime/test/fakeEngine.ts create mode 100644 NodRuntime/test/goal.test.ts create mode 100644 NodRuntime/test/hunks.test.ts create mode 100644 NodRuntime/test/permissions.test.ts create mode 100644 NodRuntime/test/runtime.test.ts diff --git a/NodRuntime/src/goal.ts b/NodRuntime/src/goal.ts index da9362b9..0ca92629 100644 --- a/NodRuntime/src/goal.ts +++ b/NodRuntime/src/goal.ts @@ -6,11 +6,11 @@ export type Judge = (prompt: string, model: string) => Promise; /** * Splits a goal into checkable clauses: list items, then `;`, then a top-level "and". - * "and" inside backticks, quotes or brackets never splits, and a piece too short to be a + * "and" inside backticks, quotes or brackets never splits, and a single word that can't be a * claim of its own ("resetsAt") is joined back onto the one before it. */ export function splitGoal(goal: string): string[] { - const body = goal.trim().replace(/^(done when|goal:|the goal is( that)?)\s*/i, ""); + const body = goal.trim().replace(/^(?:done when\s*:?|goal\s*:|the goal is(?: that)?\b)\s*/i, ""); const items = body .split(/\n+/) .map((line) => line.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, "").trim()) @@ -21,7 +21,7 @@ export function splitGoal(goal: string): string[] { const text = piece.trim().replace(/[.,]$/, "").trim(); if (!text) return; const previous = clauses[clauses.length - 1]; - if (i > 0 && previous !== undefined && text.split(/\s+/).length < 3) { + if (i > 0 && previous !== undefined && text.split(/\s+/).length < 2) { clauses[clauses.length - 1] = `${previous} and ${text}`; } else { clauses.push(text); diff --git a/NodRuntime/test/controlSocket.test.ts b/NodRuntime/test/controlSocket.test.ts new file mode 100644 index 00000000..18c90244 --- /dev/null +++ b/NodRuntime/test/controlSocket.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { connect, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ControlSocket } from "../src/controlSocket"; +import type { NodCommand } from "../src/protocol"; + +const open: ControlSocket[] = []; +afterEach(async () => { + for (const socket of open.splice(0)) await socket.close(); +}); + +async function serve(handle: (command: NodCommand) => Promise | void): Promise { + // Short path: a unix socket path is capped at 104 bytes on macOS. + const path = join(mkdtempSync(join("/tmp", "nod-")), "control.sock"); + const socket = new ControlSocket(path, handle); + await socket.listen(); + open.push(socket); + return socket; +} + +function client(path: string): Promise<{ socket: Socket; replies: () => Promise; next: (n: number) => Promise }> { + return new Promise((resolve, reject) => { + const socket = connect(path); + let buffer = ""; + const received: string[] = []; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { + buffer += chunk; + let i: number; + while ((i = buffer.indexOf("\n")) >= 0) { + received.push(buffer.slice(0, i)); + buffer = buffer.slice(i + 1); + } + }); + socket.on("error", reject); + socket.on("connect", () => + resolve({ + socket, + replies: async () => received, + next: async (n: number) => { + const deadline = Date.now() + 2000; + while (received.length < n) { + if (Date.now() > deadline) throw new Error(`only ${received.length} replies`); + await new Promise((r) => setTimeout(r, 5)); + } + return received.slice(0, n); + }, + }), + ); + }); +} + +describe("ControlSocket", () => { + test("answers each command line with ok, in order, even when handlers finish out of order", async () => { + const handled: string[] = []; + const server = await serve(async (command) => { + if (command.type === "send") await new Promise((r) => setTimeout(r, 30)); + handled.push(command.type); + }); + const { socket, next } = await client(server.path); + socket.write('{"type":"send","text":"slow"}\n{"type":"stop"}\n'); + expect(await next(2)).toEqual(['{"ok":true}', '{"ok":true}']); + expect(handled).toEqual(["send", "stop"]); + socket.end(); + }); + + test("reports a malformed line or a handler error as ok:false and keeps the connection", async () => { + const server = await serve((command) => { + if (command.type === "fork") throw new Error("fork is not supported by this runtime yet"); + }); + const { socket, next } = await client(server.path); + socket.write('nonsense\n{"type":"fork","messageID":"m1"}\n{"type":"compact"}\n'); + const replies = (await next(3)).map((line) => JSON.parse(line)); + expect(replies[0]).toEqual({ ok: false, error: "not JSON" }); + expect(replies[1]).toEqual({ ok: false, error: "fork is not supported by this runtime yet" }); + expect(replies[2]).toEqual({ ok: true }); + socket.end(); + }); + + test("reassembles a command split across writes and ignores blank lines", async () => { + const seen: NodCommand[] = []; + const server = await serve((command) => void seen.push(command)); + const { socket, next } = await client(server.path); + socket.write('{"type":"setMo'); + await new Promise((r) => setTimeout(r, 20)); + socket.write('del","model":"opus"}\n\n'); + expect(await next(1)).toEqual(['{"ok":true}']); + expect(seen).toEqual([{ type: "setModel", model: "opus" }]); + socket.end(); + }); + + test("serves several clients at once", async () => { + let count = 0; + const server = await serve(() => void (count += 1)); + const a = await client(server.path); + const b = await client(server.path); + a.socket.write('{"type":"stop"}\n'); + b.socket.write('{"type":"stop"}\n'); + await a.next(1); + await b.next(1); + expect(count).toBe(2); + a.socket.end(); + b.socket.end(); + }); + + test("replaces a stale socket file left by a killed runtime, and removes its own on close", async () => { + const path = join(mkdtempSync(join("/tmp", "nod-")), "control.sock"); + writeFileSync(path, ""); + const server = new ControlSocket(path, () => {}); + await server.listen(); + const { socket, next } = await client(path); + socket.write('{"type":"stop"}\n'); + expect(await next(1)).toEqual(['{"ok":true}']); + socket.end(); + await server.close(); + expect(await Bun.file(path).exists()).toBe(false); + }); +}); diff --git a/NodRuntime/test/eventLog.test.ts b/NodRuntime/test/eventLog.test.ts new file mode 100644 index 00000000..d4d5b3d3 --- /dev/null +++ b/NodRuntime/test/eventLog.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test"; +import { appendFileSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { EventLog, lastSeq } from "../src/eventLog"; +import { parseCommand, wireDate } from "../src/protocol"; + +function tempLog(): string { + return join(mkdtempSync(join(tmpdir(), "nod-log-")), "events.jsonl"); +} + +function lines(path: string): Record[] { + return readFileSync(path, "utf8").trim().split("\n").map((line) => JSON.parse(line)); +} + +describe("EventLog", () => { + test("appends one record per line with v, strictly increasing seq and a second-precision date", () => { + const path = tempLog(); + const log = new EventLog(path, () => new Date("2026-10-01T20:00:00.123Z")); + log.append({ type: "turnStarted", turn: 1, origin: "user" }); + log.append({ type: "activity", line: "Reading Routes.swift · turn 1" }); + const records = lines(path); + expect(records).toEqual([ + { v: 1, seq: 1, at: "2026-10-01T20:00:00Z", type: "turnStarted", turn: 1, origin: "user" }, + { v: 1, seq: 2, at: "2026-10-01T20:00:00Z", type: "activity", line: "Reading Routes.swift · turn 1" }, + ]); + }); + + test("leaves optional fields out rather than writing null", () => { + const path = tempLog(); + new EventLog(path).append({ type: "hunkResolved", hunkID: "h1", decision: "accept", note: undefined }); + expect(readFileSync(path, "utf8")).not.toContain("note"); + }); + + test("a resumed runtime continues the numbering of the file it appends to", () => { + const path = tempLog(); + const first = new EventLog(path); + first.append({ type: "activity", line: "a" }); + first.append({ type: "activity", line: "b" }); + const second = new EventLog(path); + expect(second.lastSeq).toBe(2); + expect(second.append({ type: "activity", line: "c" }).seq).toBe(3); + }); + + test("a torn final line is skipped for numbering and does not swallow the next record", () => { + const path = tempLog(); + new EventLog(path).append({ type: "activity", line: "whole" }); + appendFileSync(path, '{"v":1,"seq":2,"at":"2026-10-01T20:0'); + const log = new EventLog(path); + expect(log.lastSeq).toBe(1); + log.append({ type: "activity", line: "after" }); + const text = readFileSync(path, "utf8").split("\n"); + expect(JSON.parse(text[2]!)).toMatchObject({ seq: 2, line: "after" }); + }); + + test("finds the last seq even when one record outgrows the tail window", () => { + const path = tempLog(); + const log = new EventLog(path); + log.append({ type: "activity", line: "small" }); + log.append({ type: "toolResult", callID: "c1", status: "ok", summary: "big", output: "x".repeat(200_000) }); + expect(new EventLog(path).lastSeq).toBe(2); + }); + + test("lastSeq ignores foreign lines", () => { + expect(lastSeq('garbage\n{"seq":4}\n{"seq":"9"}\n{"seq":3}\n')).toBe(4); + }); + + test("notifies listeners with the record it wrote", () => { + const log = new EventLog(tempLog()); + const seen: number[] = []; + log.onRecord((record) => seen.push(record.seq)); + log.append({ type: "activity", line: "x" }); + expect(seen).toEqual([1]); + }); + + test("wireDate drops milliseconds, which Swift's .iso8601 strategy rejects", () => { + expect(wireDate(new Date("2026-10-01T20:00:04.999Z"))).toBe("2026-10-01T20:00:04Z"); + }); +}); + +describe("parseCommand", () => { + test("reads every command type with its fields flattened beside type", () => { + expect(parseCommand('{"type":"send","text":"hi","delivery":"steer","attachments":[{"kind":"file","reference":"a.swift"}]}')).toEqual({ + type: "send", + text: "hi", + delivery: "steer", + attachments: [{ kind: "file", reference: "a.swift", label: undefined }], + }); + expect(parseCommand('{"type":"stop"}')).toEqual({ type: "stop" }); + expect(parseCommand('{"type":"resolveHunk","hunkID":"h1","decision":"comment","note":"use 51"}')).toEqual({ + type: "resolveHunk", + hunkID: "h1", + decision: "comment", + note: "use 51", + }); + expect(parseCommand('{"type":"resolvePermission","askID":"p1","decision":"alwaysAllow"}')).toMatchObject({ decision: "alwaysAllow" }); + expect(parseCommand('{"type":"setModel","model":"opus"}')).toEqual({ type: "setModel", model: "opus" }); + expect(parseCommand('{"type":"markGoalDone","extra":1}')).toEqual({ type: "markGoalDone" }); + expect(parseCommand('{"type":"runPlan","planID":"p","mode":"here","steps":[{"id":"1","text":"do"}]}')).toMatchObject({ + steps: [{ id: "1", text: "do", files: [], editedByHuman: false }], + }); + }); + + test("defaults a send's delivery to queue and attachments to none", () => { + expect(parseCommand('{"type":"send","text":"hi"}')).toEqual({ type: "send", text: "hi", delivery: "queue", attachments: [] }); + }); + + test("rejects unknown types, bad enums and missing fields", () => { + expect(() => parseCommand('{"type":"explode"}')).toThrow("unknown Nod command explode"); + expect(() => parseCommand('{"type":"resolveHunk","hunkID":"h1","decision":"maybe"}')).toThrow("bad decision"); + expect(() => parseCommand('{"type":"send"}')).toThrow("missing text"); + expect(() => parseCommand("not json")).toThrow("not JSON"); + expect(() => parseCommand("[1]")).toThrow("not an object"); + }); +}); + +test("fixture lines from PROTOCOL.md round-trip unchanged", () => { + const path = tempLog(); + const fixture = + '{"v":1,"seq":1,"at":"2026-10-01T20:00:00Z","type":"sessionStarted","engine":"claude","model":"sonnet","conversationID":"8c1","resumed":false}\n'; + writeFileSync(path, fixture); + expect(new EventLog(path).lastSeq).toBe(1); +}); diff --git a/NodRuntime/test/fakeEngine.ts b/NodRuntime/test/fakeEngine.ts new file mode 100644 index 00000000..df0b3ad0 --- /dev/null +++ b/NodRuntime/test/fakeEngine.ts @@ -0,0 +1,81 @@ +import type { Engine, EngineSession, EngineStart, TurnCallbacks, TurnResult } from "../src/engine"; +import type { NodAttachment, NodEngineKind } from "../src/protocol"; + +export type TurnScript = (callbacks: TurnCallbacks, text: string, engine: FakeEngine) => Promise | TurnResult; + +/** An engine whose turns are scripts, so tests drive exactly the reports a real SDK would make. */ +export class FakeEngine implements Engine { + readonly turns: string[] = []; + readonly asks: { prompt: string; model?: string }[] = []; + started?: EngineStart; + interrupted = 0; + model = "fake-model"; + private abort?: () => void; + + constructor( + private scripts: TurnScript[] = [], + private answers: ((prompt: string) => string)[] = [], + readonly kind: NodEngineKind = "claude", + ) {} + + queueTurn(script: TurnScript): void { + this.scripts.push(script); + } + + queueAnswer(answer: string | ((prompt: string) => string)): void { + this.answers.push(typeof answer === "string" ? () => answer : answer); + } + + async start(options: EngineStart): Promise { + this.started = options; + if (options.model) this.model = options.model; + return { conversationID: options.resume ?? "conv-1", model: this.model }; + } + + async runTurn(text: string, _attachments: NodAttachment[], callbacks: TurnCallbacks): Promise { + this.turns.push(text); + const script = this.scripts.shift() ?? (() => ({ lastMessage: "ok" })); + const interrupted = new Promise((resolve) => { + this.abort = () => resolve({ lastMessage: "", interrupted: true }); + }); + const result = await Promise.race([Promise.resolve(script(callbacks, text, this)), interrupted]); + this.abort = undefined; + return result; + } + + async interrupt(): Promise { + this.interrupted += 1; + this.abort?.(); + } + + async setModel(model: string): Promise { + this.model = model; + } + + async compact(callbacks: TurnCallbacks): Promise { + callbacks.compacted(); + return { lastMessage: "" }; + } + + async ask(prompt: string, model?: string): Promise { + this.asks.push({ prompt, model }); + const answer = this.answers.shift(); + if (!answer) throw new Error("no scripted answer"); + return answer(prompt); + } + + async close(): Promise {} +} + +export function tick(ms = 0): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Polls until `predicate` holds, failing the test after `timeout` ms. */ +export async function until(predicate: () => boolean, timeout = 2000): Promise { + const deadline = Date.now() + timeout; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("timed out waiting"); + await tick(5); + } +} diff --git a/NodRuntime/test/goal.test.ts b/NodRuntime/test/goal.test.ts new file mode 100644 index 00000000..6bd4b376 --- /dev/null +++ b/NodRuntime/test/goal.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { EventLog } from "../src/eventLog"; +import { GoalEvaluator, parseVerdict, splitGoal } from "../src/goal"; +import type { NodEventRecord } from "../src/protocol"; + +describe("splitGoal", () => { + test("splits a done-when sentence on its top-level and", () => { + expect(splitGoal("Done when every paid route enforces the cap and `swift test` passes")).toEqual([ + "every paid route enforces the cap", + "`swift test` passes", + ]); + }); + + test("never splits inside backticks, quotes or brackets", () => { + expect(splitGoal('/export returns 402 with { limit and resetsAt } and the banner reads "limit and reset"')).toEqual([ + "/export returns 402 with { limit and resetsAt }", + 'the banner reads "limit and reset"', + ]); + expect(splitGoal("`make lint and test` exits 0")).toEqual(["`make lint and test` exits 0"]); + }); + + test("a fragment too short to be a claim joins the clause before it", () => { + expect(splitGoal("The 402 body carries limit and resetsAt")).toEqual(["The 402 body carries limit and resetsAt"]); + }); + + test("list items and semicolons each become a clause, even short ones", () => { + expect(splitGoal("Done when:\n- tests pass\n- the README documents packaging; the PR is open")).toEqual([ + "tests pass", + "the README documents packaging", + "the PR is open", + ]); + }); + + test("only a real prefix is stripped", () => { + expect(splitGoal("goals page renders the cap")).toEqual(["goals page renders the cap"]); + expect(splitGoal("Goal: the cap holds")).toEqual(["the cap holds"]); + }); + + test("a goal with nothing to split is one clause", () => { + expect(splitGoal("Ship it.")).toEqual(["Ship it"]); + }); +}); + +describe("parseVerdict", () => { + const clauses = ["routes are gated", "tests pass"]; + + test("reads the judge's JSON, even wrapped in prose or a fence", () => { + const answer = 'Here you go:\n```json\n{"clauses":[{"index":0,"met":true,"evidence":"4 / 4 routes"},{"index":1,"met":false,"evidence":"1 failure"}]}\n```'; + expect(parseVerdict(answer, clauses)).toEqual({ + met: false, + clauses: [ + { text: "routes are gated", met: true, evidence: "4 / 4 routes" }, + { text: "tests pass", met: false, evidence: "1 failure" }, + ], + }); + }); + + test("anything unreadable or missing counts as not met, never as met", () => { + expect(parseVerdict("I think it's done!", clauses).met).toBe(false); + expect(parseVerdict('{"clauses":[{"index":0,"met":true}]}', clauses).clauses[1]).toEqual({ + text: "tests pass", + met: false, + evidence: "the evaluator gave no verdict", + }); + expect(parseVerdict('{"clauses":[{"index":0,"met":"yes"},{"index":1,"met":true}]}', clauses).met).toBe(false); + }); +}); + +describe("GoalEvaluator", () => { + function evaluator(goal: string, judge: (prompt: string) => Promise) { + const log = new EventLog(join(mkdtempSync(join(tmpdir(), "nod-goal-")), "events.jsonl")); + const records: NodEventRecord[] = []; + log.onRecord((r) => records.push(r)); + return { goal: new GoalEvaluator(goal, judge, "haiku", log), records }; + } + + test("splits the goal again at every check, so an edited goal is judged as edited", async () => { + const prompts: string[] = []; + const { goal, records } = evaluator("a works and b works", async (prompt) => { + prompts.push(prompt); + return '{"clauses":[{"index":0,"met":true},{"index":1,"met":true},{"index":2,"met":true}]}'; + }); + await goal.check(1, { lastMessage: "done", toolResults: ["Bash: ok — exit 0"] }); + goal.goal = "a works and b works and c works"; + await goal.check(2, { lastMessage: "done", toolResults: [] }); + expect(prompts[0]).toContain("0. a works\n1. b works"); + expect(prompts[0]).toContain("- Bash: ok — exit 0"); + expect(prompts[1]).toContain("2. c works"); + const checks = records.filter((r) => r.type === "goalCheck"); + expect(checks.map((r) => r.type === "goalCheck" && [r.turn, r.clauses.length, r.met, r.evaluatorModel])).toEqual([ + [1, 2, true, "haiku"], + [2, 3, true, "haiku"], + ]); + }); + + test("a judge that throws reads as not met", async () => { + const { goal } = evaluator("a works", async () => { + throw new Error("rate limited"); + }); + expect((await goal.check(1, { lastMessage: "", toolResults: [] })).met).toBe(false); + }); + + test("marked done passes without asking the judge", async () => { + let asked = false; + const { goal, records } = evaluator("a works and b works", async () => ((asked = true), "")); + goal.markDone(); + expect((await goal.check(3, { lastMessage: "", toolResults: [] })).met).toBe(true); + expect(asked).toBe(false); + expect(records[0]).toMatchObject({ type: "goalCheck", met: true }); + }); + + test("the continuation names only the unmet clauses, with their evidence", () => { + const text = GoalEvaluator.continuation({ + met: false, + clauses: [ + { text: "routes are gated", met: true }, + { text: "tests pass", met: false, evidence: "1 failure · LegacyExportTests" }, + ], + }); + expect(text).toContain("- tests pass (1 failure · LegacyExportTests)"); + expect(text).not.toContain("routes are gated"); + }); +}); diff --git a/NodRuntime/test/hunks.test.ts b/NodRuntime/test/hunks.test.ts new file mode 100644 index 00000000..47cfec87 --- /dev/null +++ b/NodRuntime/test/hunks.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { applyHunk, diffLines, hunks, HunkConflict } from "../src/diff"; +import { EventLog } from "../src/eventLog"; +import { HunkStager } from "../src/hunks"; +import type { NodEventRecord } from "../src/protocol"; +import { tick } from "./fakeEngine"; + +const base = Array.from({ length: 40 }, (_, i) => `line ${i + 1}`).join("\n") + "\n"; + +function setup() { + const dir = mkdtempSync(join(tmpdir(), "nod-hunks-")); + const log = new EventLog(join(dir, ".nod", "events.jsonl")); + const records: NodEventRecord[] = []; + log.onRecord((r) => records.push(r)); + const file = join(dir, "Sources", "Routes.swift"); + return { dir, log, records, file, stager: new HunkStager(log, dir) }; +} + +function edit(text: string, replacements: [string, string][]): string { + return replacements.reduce((acc, [from, to]) => acc.replace(from, to), text); +} + +describe("diff", () => { + test("applying every hunk in turn reproduces the edited text", () => { + const after = edit(base, [["line 3\n", "line 3\ninserted\n"], ["line 30\n", ""], ["line 38", "changed 38"]]); + const found = hunks(base, after); + expect(found.length).toBe(3); + expect(found.reduce((text, hunk) => applyHunk(text, hunk), base)).toBe(after); + }); + + test("nearby changes share one hunk, as git groups them", () => { + const after = edit(base, [["line 10", "ten"], ["line 13", "thirteen"]]); + const [only, ...rest] = hunks(base, after); + expect(rest).toEqual([]); + expect(only!.lines.filter((l) => l[0] !== " ")).toEqual(["-line 10", "+ten", "-line 13", "+thirteen"]); + expect(only!.oldStart).toBe(7); + }); + + test("a hunk applies wherever its context now sits, and reverses cleanly", () => { + const after = edit(base, [["line 20", "twenty"]]); + const [hunk] = hunks(base, after); + const shifted = "new top\nnew top 2\n" + base; + const applied = applyHunk(shifted, hunk!); + expect(applied).toBe("new top\nnew top 2\n" + after); + expect(applyHunk(applied, hunk!, true)).toBe(shifted); + }); + + test("a hunk whose lines are gone is a conflict", () => { + const [hunk] = hunks(base, edit(base, [["line 20", "twenty"]])); + expect(() => applyHunk("something else entirely\n", hunk!)).toThrow(HunkConflict); + }); + + test("creating a file is one hunk of additions", () => { + const [hunk] = hunks("", "a\nb\n"); + expect(hunk!.lines).toEqual(["+a", "+b", " "]); + expect(applyHunk("", hunk!)).toBe("a\nb\n"); + }); + + test("Myers finds a minimal edit script", () => { + const ops = diffLines(["a", "b", "c", "a", "b", "b", "a"], ["c", "b", "a", "b", "a", "c"]); + expect(ops.filter((op) => op.kind !== " ").length).toBe(5); + }); +}); + +describe("HunkStager", () => { + function seed(file: string, text = base): void { + mkdirSync(join(file, ".."), { recursive: true }); + writeFileSync(file, text); + } + + test("review mode writes nothing until every hunk is decided; all accepted, the tool writes", async () => { + const { stager, file, records } = setup(); + seed(file); + const after = edit(base, [["line 3", "three"], ["line 30", "thirty"]]); + let settled = false; + const outcome = stager.stageEdit(file, after, 1, false).then((o) => ((settled = true), o)); + const staged = records.filter((r) => r.type === "hunkStaged"); + expect(staged.map((r) => r.type === "hunkStaged" && [r.hunkID, r.file, r.added, r.removed, r.autoAccepted])).toEqual([ + ["h1", "Sources/Routes.swift", 1, 1, false], + ["h2", "Sources/Routes.swift", 1, 1, false], + ]); + stager.resolve("h1", "accept"); + await tick(5); + expect(settled).toBe(false); + expect(readFileSync(file, "utf8")).toBe(base); + stager.resolve("h2", "accept"); + const result = await outcome; + expect(result.allAccepted).toBe(true); + expect(result.feedback).toBe(""); + // Nothing written by the stager: the engine's own tool writes the whole edit. + expect(readFileSync(file, "utf8")).toBe(base); + expect(stager.tally(1)).toEqual({ filesChanged: 1, added: 2, removed: 2 }); + }); + + test("a partial accept writes only the accepted hunks and tells the agent about the rest", async () => { + const { stager, file, records } = setup(); + seed(file); + const after = edit(base, [["line 3", "three"], ["line 30", "thirty"]]); + const outcome = stager.stageEdit(file, after, 1, false); + stager.resolve("h1", "accept"); + stager.resolve("h2", "comment", "use 31 so it's past the cap"); + const result = await outcome; + expect(result.allAccepted).toBe(false); + expect(readFileSync(file, "utf8")).toBe(edit(base, [["line 3", "three"]])); + expect(result.feedback).toContain("accepted 1 of 2 hunks"); + expect(result.feedback).toContain("was sent back: use 31 so it's past the cap"); + expect(records.filter((r) => r.type === "hunkResolved").map((r) => r.type === "hunkResolved" && [r.hunkID, r.decision, r.note])).toEqual([ + ["h1", "accept", undefined], + ["h2", "comment", "use 31 so it's past the cap"], + ]); + }); + + test("a partial accept keeps an edit a human made to the file meanwhile", async () => { + const { stager, file } = setup(); + seed(file); + const outcome = stager.stageEdit(file, edit(base, [["line 3", "three"], ["line 30", "thirty"]]), 1, false); + writeFileSync(file, edit(base, [["line 15", "human was here"]])); + stager.resolve("h1", "accept"); + stager.resolve("h2", "reject"); + await outcome; + expect(readFileSync(file, "utf8")).toBe(edit(base, [["line 15", "human was here"], ["line 3", "three"]])); + }); + + test("rejecting everything leaves the file, and a new file, untouched", async () => { + const { stager, dir } = setup(); + const fresh = join(dir, "New.swift"); + const outcome = stager.stageEdit(fresh, "hello\n", 1, false); + stager.resolve("h1", "reject", "not needed"); + expect((await outcome).allAccepted).toBe(false); + expect(existsSync(fresh)).toBe(false); + }); + + test("auto mode stages hunks already accepted, and a late reject reverse-applies one", async () => { + const { stager, file, records } = setup(); + seed(file); + const after = edit(base, [["line 3", "three"], ["line 30", "thirty"]]); + const late: string[] = []; + stager.onLateDecision = (h) => late.push(h.id); + const result = await stager.stageEdit(file, after, 2, true); + expect(result.allAccepted).toBe(true); + expect(records.filter((r) => r.type === "hunkStaged").every((r) => r.type === "hunkStaged" && r.autoAccepted)).toBe(true); + writeFileSync(file, after); // the engine's tool writes it + stager.resolve("h2", "reject", "keep line 30"); + expect(readFileSync(file, "utf8")).toBe(edit(base, [["line 3", "three"]])); + expect(late).toEqual(["h2"]); + expect(stager.tally(2)).toEqual({ filesChanged: 1, added: 1, removed: 1 }); + }); + + test("auto-accepted hunks stop being reviewable when their turn ends", async () => { + const { stager, file } = setup(); + seed(file); + await stager.stageEdit(file, edit(base, [["line 3", "three"]]), 1, true); + stager.closeTurn(1); + expect(() => stager.resolve("h1", "reject")).toThrow("no hunk h1"); + }); + + test("a decided hunk can't be decided again, and a stopped turn rejects what is pending", async () => { + const { stager, file } = setup(); + seed(file); + const outcome = stager.stageEdit(file, edit(base, [["line 3", "three"], ["line 30", "thirty"]]), 1, false); + stager.resolve("h1", "accept"); + expect(() => stager.resolve("h1", "reject")).toThrow("already accepted"); + stager.rejectPending("the turn was stopped"); + const result = await outcome; + expect(result.hunks.map((h) => h.state)).toEqual(["accepted", "rejected"]); + expect(stager.pending()).toEqual([]); + }); +}); diff --git a/NodRuntime/test/permissions.test.ts b/NodRuntime/test/permissions.test.ts new file mode 100644 index 00000000..32d31ef0 --- /dev/null +++ b/NodRuntime/test/permissions.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { EventLog } from "../src/eventLog"; +import { isInside, isReadOnlyCommand, matchesAllowlist, PermissionGate, usesNetwork } from "../src/permissions"; +import type { NodEventRecord } from "../src/protocol"; +import { defaultSettings, loadSettings, type NodSettings } from "../src/settings"; +import { tick } from "./fakeEngine"; + +function gate(overrides: Partial = {}, unattended = false) { + const dir = mkdtempSync(join(tmpdir(), "nod-gate-")); + const log = new EventLog(join(dir, "events.jsonl")); + const records: NodEventRecord[] = []; + log.onRecord((r) => records.push(r)); + const awaiting: boolean[] = []; + const g = new PermissionGate({ + settings: { ...defaultSettings, ...overrides }, + worktree: "/work/loop", + unattended, + log, + onAwaiting: (a) => awaiting.push(a), + }); + return { gate: g, records, awaiting }; +} + +describe("allowlist patterns", () => { + const list = ["swift test *", "make lint", "git status|diff|log"]; + + test("a trailing * takes any further words, including none", () => { + expect(matchesAllowlist("swift test --filter UsageCap", list)).toBe(true); + expect(matchesAllowlist("swift test", list)).toBe(true); + expect(matchesAllowlist("swift build", list)).toBe(false); + }); + + test("a word of alternatives matches any one of them, and nothing more", () => { + expect(matchesAllowlist("git diff", list)).toBe(true); + expect(matchesAllowlist("git log", list)).toBe(true); + expect(matchesAllowlist("git push", list)).toBe(false); + expect(matchesAllowlist("git diff --stat", list)).toBe(false); + expect(matchesAllowlist("make lint", list)).toBe(true); + expect(matchesAllowlist("make lint-fix", list)).toBe(false); + }); + + test("every part of a compound command must be allowed", () => { + expect(matchesAllowlist("make lint && swift test", list)).toBe(true); + expect(matchesAllowlist("make lint && rm -rf .", list)).toBe(false); + expect(matchesAllowlist("git status; curl evil.example", list)).toBe(false); + }); + + test("a command substitution is never allowlisted", () => { + expect(matchesAllowlist("swift test $(curl x)", list)).toBe(false); + expect(matchesAllowlist("swift test `whoami`", list)).toBe(false); + }); + + test("a wildcard inside a word stays inside the word", () => { + expect(matchesAllowlist("swift test-x", ["swift test*"])).toBe(true); + expect(matchesAllowlist("swift test x", ["swift test*"])).toBe(false); + }); +}); + +describe("classification", () => { + test("network commands are recognised through env, sudo and pipelines", () => { + expect(usesNetwork("swift package resolve")).toBe(true); + expect(usesNetwork("curl -s https://x | jq .")).toBe(true); + expect(usesNetwork("FOO=1 git status && git push origin main")).toBe(true); + expect(usesNetwork("env A=1 npm install")).toBe(true); + expect(usesNetwork("swift test")).toBe(false); + expect(usesNetwork("git commit -m 'curl later'")).toBe(false); + }); + + test("read-only commands are the ones a card may answer", () => { + expect(isReadOnlyCommand("git status")).toBe(true); + expect(isReadOnlyCommand("ls -la | wc -l")).toBe(true); + expect(isReadOnlyCommand("cat a > b")).toBe(false); + expect(isReadOnlyCommand("find . -delete")).toBe(false); + expect(isReadOnlyCommand("rm -rf build")).toBe(false); + }); + + test("worktree containment resolves .. and relative paths", () => { + expect(isInside("/work/loop", "/work/loop/Sources/A.swift")).toBe(true); + expect(isInside("/work/loop", "Sources/A.swift")).toBe(true); + expect(isInside("/work/loop", "/work/loop/../other/A.swift")).toBe(false); + expect(isInside("/work/loop", "/work/loopy/A.swift")).toBe(false); + expect(isInside("/work/loop", "/etc/hosts")).toBe(false); + }); +}); + +describe("PermissionGate", () => { + test("reads are always allowed and in-worktree edits go to the stager", async () => { + const { gate: g, records } = gate(); + expect(await g.check({ kind: "read" })).toEqual({ verdict: "allow" }); + expect(await g.check({ kind: "edit", path: "/work/loop/A.swift" })).toEqual({ verdict: "stage" }); + expect(records).toEqual([]); + }); + + test("an allowlisted command runs without asking", async () => { + const { gate: g, records } = gate({ shellAllowlist: ["swift test *"] }); + expect(await g.check({ kind: "shell", command: "swift test --filter X" })).toEqual({ verdict: "allow" }); + expect(records).toEqual([]); + }); + + test("never denies and always allows, without asking", async () => { + const never = gate({ shell: "never", editsOutsideWorktree: "never" }).gate; + expect((await never.check({ kind: "shell", command: "make" })).verdict).toBe("deny"); + expect((await never.check({ kind: "edit", path: "/etc/hosts" })).verdict).toBe("deny"); + const always = gate({ shell: "always", network: "always", editsOutsideWorktree: "always" }).gate; + expect((await always.check({ kind: "shell", command: "make" })).verdict).toBe("allow"); + expect((await always.check({ kind: "fetch", url: "https://x" })).verdict).toBe("allow"); + expect((await always.check({ kind: "edit", path: "/etc/hosts" })).verdict).toBe("allow"); + }); + + test("a network command is asked about as network, even when plain shell is allowed", async () => { + const { gate: g, records } = gate({ shell: "always", network: "ask" }); + const verdict = g.check({ kind: "shell", command: "swift package resolve" }); + await tick(); + const ask = records.find((r) => r.type === "permissionAsked"); + expect(ask).toMatchObject({ askID: "p1", kind: "network", subject: "swift package resolve", answerableFromCard: false }); + g.resolve("p1", "deny"); + expect((await verdict).verdict).toBe("deny"); + }); + + test("an ask waits for its answer, reports awaiting, and logs the resolution", async () => { + const { gate: g, records, awaiting } = gate(); + let settled = false; + const verdict = g.check({ kind: "shell", command: "git status" }).then((v) => ((settled = true), v)); + await tick(5); + expect(settled).toBe(false); + expect(g.openAsks).toBe(1); + expect(records.at(-1)).toMatchObject({ type: "permissionAsked", kind: "shell", answerableFromCard: true }); + g.resolve("p1", "allowOnce"); + expect(await verdict).toEqual({ verdict: "allow" }); + expect(records.at(-1)).toMatchObject({ type: "permissionResolved", askID: "p1", decision: "allowOnce" }); + expect(awaiting).toEqual([true, false]); + }); + + test("always allow stops asking about that subject for the rest of the session", async () => { + const { gate: g, records } = gate(); + const first = g.check({ kind: "shell", command: "make bundle" }); + await tick(); + g.resolve("p1", "alwaysAllow"); + await first; + expect(await g.check({ kind: "shell", command: "make bundle" })).toEqual({ verdict: "allow" }); + expect(records.filter((r) => r.type === "permissionAsked").length).toBe(1); + }); + + test("an unattended loop fails instead of waiting", async () => { + const { gate: g, records } = gate({}, true); + const verdict = await g.check({ kind: "shell", command: "swift package resolve" }); + expect(verdict.verdict).toBe("fail"); + expect(verdict.verdict === "fail" && verdict.message).toContain("cannot stop to ask"); + expect(records).toEqual([]); + }); + + test("an unattended loop still runs what is allowlisted or always allowed", async () => { + const { gate: g } = gate({ shellAllowlist: ["make *"], network: "always" }, true); + expect((await g.check({ kind: "shell", command: "make test" })).verdict).toBe("allow"); + expect((await g.check({ kind: "fetch", url: "https://x" })).verdict).toBe("allow"); + }); + + test("messaging other loops follows its own policy", async () => { + expect((await gate({ messagesOtherLoops: "send" }).gate.check({ kind: "messageLoop", subject: "Billing" })).verdict).toBe("allow"); + expect((await gate({ messagesOtherLoops: "never" }).gate.check({ kind: "messageLoop", subject: "Billing" })).verdict).toBe("deny"); + }); + + test("denyAll answers every open ask, so a stopped turn never hangs on one", async () => { + const { gate: g } = gate(); + const a = g.check({ kind: "shell", command: "make a" }); + const b = g.check({ kind: "fetch", url: "https://x" }); + await tick(); + g.denyAll(); + expect([(await a).verdict, (await b).verdict]).toEqual(["deny", "deny"]); + expect(() => g.resolve("p1", "allowOnce")).toThrow("no open ask p1"); + }); +}); + +describe("loadSettings", () => { + test("reads the nod key field by field, falling back to defaults for bad values", async () => { + const dir = mkdtempSync(join(tmpdir(), "nod-settings-")); + const path = join(dir, "settings.json"); + await Bun.write(path, JSON.stringify({ other: 1, nod: { shell: "always", network: "sometimes", shellAllowlist: ["make *"], spendCapUSD: 5 } })); + expect(loadSettings(path)).toMatchObject({ shell: "always", network: "ask", shellAllowlist: ["make *"], spendCapUSD: 5, engine: "claude" }); + expect(loadSettings(join(dir, "missing.json"))).toEqual(defaultSettings); + }); +}); diff --git a/NodRuntime/test/runtime.test.ts b/NodRuntime/test/runtime.test.ts new file mode 100644 index 00000000..111dc4be --- /dev/null +++ b/NodRuntime/test/runtime.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { EventLog } from "../src/eventLog"; +import { PresenceReporter } from "../src/presence"; +import type { NodEventRecord } from "../src/protocol"; +import { NodRuntime, type LoopType } from "../src/runtime"; +import { defaultSettings, type NodSettings } from "../src/settings"; +import { FakeEngine, tick, until } from "./fakeEngine"; + +interface Setup { + loopType?: LoopType; + goal?: string; + settings?: Partial; + unattended?: boolean; +} + +function setup(engine: FakeEngine, options: Setup = {}) { + const cwd = mkdtempSync(join(tmpdir(), "nod-rt-")); + const stateDir = join(cwd, ".state"); + mkdirSync(stateDir); + const support = join(cwd, ".support"); + const log = new EventLog(join(stateDir, "events.jsonl")); + const records: NodEventRecord[] = []; + log.onRecord((r) => records.push(r)); + const labels: string[][] = []; + const presence = new PresenceReporter("graphcode-node-1", async (args) => void labels.push(args), support); + const runtime = new NodRuntime({ + nodeID: "node-1", + cwd, + stateDir, + loopType: options.loopType ?? "main", + settings: { ...defaultSettings, ...options.settings }, + engine, + log, + presence, + goal: options.goal, + unattended: options.unattended, + }); + const types = () => records.map((r) => r.type); + return { runtime, records, labels, cwd, stateDir, support, types, presence }; +} + +describe("NodRuntime", () => { + test("starts with sessionStarted, writes conversation.json and the session-id pointer", async () => { + const engine = new FakeEngine(); + const { runtime, records, stateDir, support, presence } = setup(engine); + await runtime.start(); + expect(records[0]).toMatchObject({ type: "sessionStarted", engine: "claude", model: "fake-model", conversationID: "conv-1", resumed: false }); + expect(JSON.parse(readFileSync(join(stateDir, "conversation.json"), "utf8"))).toEqual({ engine: "claude", model: "fake-model", conversationID: "conv-1" }); + expect(readFileSync(join(support, "sessions", "node-1.id"), "utf8")).toBe("conv-1"); + expect(readFileSync(join(support, "sessions", "node-1.history"), "utf8")).toMatch(/^\d+ conv-1 \S+\n$/); + await presence.flush(); + }); + + test("a turn becomes turnStarted, streamed text, tool cards, usage and turnEnded", async () => { + const engine = new FakeEngine([ + async (cb) => { + cb.text("m1", "Found ", false); + cb.toolCall("c1", "Grep", { pattern: "UsageGate" }); + cb.toolResult("c1", "ok", "6 hits in 4 files", undefined, 400); + cb.text("m1", "it.", false); + cb.text("m1", "", true); + cb.usage({ inputTokens: 1200, outputTokens: 300, costUSD: 0.01, contextUsed: 0.1 }); + return { lastMessage: "Found it.\nDetails follow." }; + }, + ]); + const { runtime, records, labels, types, presence } = setup(engine); + await runtime.start("Fix /export"); + await runtime.whenIdle(); + await presence.flush(); + expect(types()).toEqual([ + "sessionStarted", "userMessage", "turnStarted", "assistantText", "toolCall", "activity", + "toolResult", "assistantText", "assistantText", "usage", "turnEnded", + ]); + expect(records.find((r) => r.type === "toolCall")).toMatchObject({ turn: 1, callID: "c1", tool: "Grep", title: 'Search "UsageGate"' }); + expect(records.find((r) => r.type === "activity")).toMatchObject({ line: "Searching for UsageGate · turn 1" }); + expect(records.at(-1)).toMatchObject({ type: "turnEnded", turn: 1, filesChanged: 0, summary: "Found it." }); + expect(labels).toContainEqual(["set", "graphcode-node-1", "presence=busy", "activity=searching_20for_20UsageGate"]); + expect(labels).toContainEqual(["set", "graphcode-node-1", "usage=input.1200_output.300"]); + expect(labels.at(-1)).toEqual(["set", "graphcode-node-1", "presence=idle", "activity="]); + }); + + test("a queued message waits for the turn to end; each runs as its own turn", async () => { + let release!: () => void; + const engine = new FakeEngine([async () => (await new Promise((r) => (release = r)), { lastMessage: "one" })]); + const { runtime, records } = setup(engine); + await runtime.start("first"); + await until(() => runtime.isBusy); + runtime.send("also log when a request is blocked", "queue"); + expect(engine.turns).toEqual(["first"]); + release(); + await runtime.whenIdle(); + expect(engine.turns).toEqual(["first", "also log when a request is blocked"]); + expect(records.filter((r) => r.type === "turnStarted").map((r) => r.type === "turnStarted" && r.origin)).toEqual(["user", "queue"]); + }); + + test("a steer lands at the next tool boundary without starting a turn", async () => { + let steered: string | undefined; + let atBoundary!: () => void; + const engine = new FakeEngine([ + async (cb) => { + await new Promise((r) => (atBoundary = r)); + steered = cb.takeSteer(); + return { lastMessage: "done" }; + }, + ]); + const { runtime, records } = setup(engine); + await runtime.start("work"); + await until(() => runtime.isBusy); + runtime.send("use the fixture clock, not Date()", "steer"); + atBoundary(); + await runtime.whenIdle(); + expect(steered).toBe("The human steered while you worked: use the fixture clock, not Date()"); + expect(engine.turns.length).toBe(1); + expect(records.find((r) => r.type === "userMessage" && r.delivery === "steer")).toBeDefined(); + }); + + test("a steer that never met a tool boundary runs next, as a steer turn", async () => { + let release!: () => void; + const engine = new FakeEngine([async () => (await new Promise((r) => (release = r)), { lastMessage: "" })]); + const { runtime, records } = setup(engine); + await runtime.start("work"); + await until(() => runtime.isBusy); + runtime.send("and cover the monthly reset too", "steer"); + release(); + await runtime.whenIdle(); + expect(engine.turns).toEqual(["work", "and cover the monthly reset too"]); + expect(records.filter((r) => r.type === "turnStarted").at(-1)).toMatchObject({ origin: "steer" }); + }); + + test("an in-worktree edit is staged and held until reviewed; the tool runs only when all accepted", async () => { + const engine = new FakeEngine(); + const { runtime, records, cwd, labels, presence } = setup(engine); + const file = join(cwd, "Routes.swift"); + writeFileSync(file, "a\nb\nc\n"); + let authorization: unknown; + engine.queueTurn(async (cb) => { + authorization = await cb.authorize({ intent: { kind: "edit", path: file }, edit: { path: file, after: "a\nB\nc\n" } }); + return { lastMessage: "edited" }; + }); + await runtime.start("edit it"); + await until(() => records.some((r) => r.type === "hunkStaged")); + await presence.flush(); + expect(labels.at(-1)).toEqual(["set", "graphcode-node-1", "presence=awaitingInput", "activity=waiting_20for_20review"]); + await runtime.handle({ type: "resolveHunk", hunkID: "h1", decision: "accept" }); + await runtime.whenIdle(); + expect(authorization).toEqual({ allow: true }); + }); + + test("auto mode allows the edit at once and the turn tally counts it", async () => { + const engine = new FakeEngine(); + const { runtime, records, cwd } = setup(engine, { settings: { editsInWorktree: "auto" } }); + const file = join(cwd, "Routes.swift"); + writeFileSync(file, "a\nb\nc\n"); + engine.queueTurn(async (cb) => { + const verdict = await cb.authorize({ intent: { kind: "edit", path: file }, edit: { path: file, after: "a\nB\nc\nd\n" } }); + expect(verdict).toEqual({ allow: true }); + return { lastMessage: "edited" }; + }); + await runtime.start("edit it"); + await runtime.whenIdle(); + expect(records.find((r) => r.type === "hunkStaged")).toMatchObject({ autoAccepted: true, added: 2, removed: 1 }); + expect(records.find((r) => r.type === "turnEnded")).toMatchObject({ filesChanged: 1, added: 2, removed: 1 }); + }); + + test("a permission ask puts the loop in awaiting input until resolvePermission", async () => { + const engine = new FakeEngine(); + let verdict: unknown; + engine.queueTurn(async (cb) => { + verdict = await cb.authorize({ intent: { kind: "shell", command: "swift package resolve" } }); + return { lastMessage: "" }; + }); + const { runtime, records, labels, presence } = setup(engine); + await runtime.start("resolve deps"); + await until(() => records.some((r) => r.type === "permissionAsked")); + await presence.flush(); + expect(labels.at(-1)?.[2]).toBe("presence=awaitingInput"); + await runtime.handle({ type: "resolvePermission", askID: "p1", decision: "allowOnce" }); + await runtime.whenIdle(); + expect(verdict).toEqual({ allow: true }); + }); + + test("an unattended loop fails the run with permissionUnavailable instead of waiting", async () => { + const engine = new FakeEngine(); + let verdict: unknown; + engine.queueTurn(async (cb) => { + verdict = await cb.authorize({ intent: { kind: "shell", command: "swift package resolve" } }); + return { lastMessage: "", interrupted: true }; + }); + const { runtime, records } = setup(engine, { loopType: "timed" }); + await runtime.start("nightly deps"); + await runtime.whenIdle(); + expect(verdict).toMatchObject({ allow: false, interrupt: true }); + expect(records.find((r) => r.type === "failure")).toMatchObject({ kind: "permissionUnavailable" }); + expect(records.some((r) => r.type === "permissionAsked")).toBe(false); + }); + + test("the spend cap stops an unattended run mid-turn, and the next run starts from zero", async () => { + const engine = new FakeEngine(); + engine.queueTurn(async (cb) => { + cb.usage({ inputTokens: 1, outputTokens: 1, costUSD: 1.5, contextUsed: 0.1 }); + await tick(5); + cb.usage({ inputTokens: 2, outputTokens: 2, costUSD: 2.1, contextUsed: 0.1 }); + await new Promise(() => {}); // only the interrupt ends this turn + return { lastMessage: "" }; + }); + engine.queueTurn(async (cb) => { + cb.usage({ inputTokens: 3, outputTokens: 3, costUSD: 3.0, contextUsed: 0.1 }); + return { lastMessage: "fine" }; + }); + const { runtime, records } = setup(engine, { loopType: "timed", settings: { spendCapUSD: 2 } }); + await runtime.start("run 1"); + await runtime.whenIdle(); + expect(engine.interrupted).toBe(1); + expect(records.filter((r) => r.type === "failure")).toEqual([ + expect.objectContaining({ kind: "spendCap", message: "Hit its $2.00 cap this run ($2.10 spent). Stopped mid-turn 1." }), + ]); + runtime.send("run 2", "queue"); + await runtime.whenIdle(); + expect(records.filter((r) => r.type === "failure").length).toBe(1); + }); + + test("an attended loop has no spend cap", async () => { + const engine = new FakeEngine([async (cb) => (cb.usage({ inputTokens: 1, outputTokens: 1, costUSD: 50, contextUsed: 0 }), { lastMessage: "" })]); + const { runtime, records } = setup(engine, { settings: { spendCapUSD: 2 } }); + await runtime.start("go"); + await runtime.whenIdle(); + expect(records.some((r) => r.type === "failure")).toBe(false); + }); + + test("an engine failure becomes a failure event and the loop goes idle", async () => { + const engine = new FakeEngine([() => ({ lastMessage: "", failure: { kind: "signInExpired", message: "Claude sign-in expired." } })]); + const { runtime, records, labels, presence } = setup(engine, { goal: "it works" }); + await runtime.start("go"); + await runtime.whenIdle(); + await presence.flush(); + expect(records.find((r) => r.type === "failure")).toMatchObject({ kind: "signInExpired" }); + expect(records.some((r) => r.type === "goalCheck")).toBe(false); + expect(labels.at(-1)?.[2]).toBe("presence=idle"); + }); + + test("a thrown engine error is an engineError failure, not a crash", async () => { + const engine = new FakeEngine([() => { throw new Error("socket hang up"); }]); + const { runtime, records } = setup(engine); + await runtime.start("go"); + await runtime.whenIdle(); + expect(records.find((r) => r.type === "failure")).toMatchObject({ kind: "engineError", message: "socket hang up" }); + expect(records.at(-1)?.type).toBe("turnEnded"); + }); + + test("stop interrupts the turn, rejects pending hunks, denies open asks and clears the queue", async () => { + const engine = new FakeEngine(); + const { runtime, records, cwd } = setup(engine, { goal: "it works" }); + const file = join(cwd, "A.swift"); + writeFileSync(file, "x\n"); + engine.queueTurn(async (cb) => { + await Promise.all([ + cb.authorize({ intent: { kind: "edit", path: file }, edit: { path: file, after: "y\n" } }), + cb.authorize({ intent: { kind: "shell", command: "make" } }), + ]); + return { lastMessage: "" }; + }); + await runtime.start("go"); + await until(() => records.some((r) => r.type === "permissionAsked") && records.some((r) => r.type === "hunkStaged")); + runtime.send("queued", "queue"); + await runtime.handle({ type: "stop" }); + await runtime.whenIdle(); + expect(engine.interrupted).toBe(1); + expect(records.find((r) => r.type === "hunkResolved")).toMatchObject({ decision: "reject", note: "the turn was stopped" }); + expect(records.find((r) => r.type === "permissionResolved")).toMatchObject({ decision: "deny" }); + expect(engine.turns).toEqual(["go"]); + expect(records.some((r) => r.type === "goalCheck")).toBe(false); + expect(readFileSync(file, "utf8")).toBe("x\n"); + }); + + test("setModel and compact are passed to the engine; runPlan here queues the plan", async () => { + const engine = new FakeEngine(); + const { runtime, records, stateDir } = setup(engine); + await runtime.start(); + await runtime.handle({ type: "setModel", model: "opus" }); + expect(engine.model).toBe("opus"); + expect(JSON.parse(readFileSync(join(stateDir, "conversation.json"), "utf8")).model).toBe("opus"); + await runtime.handle({ type: "compact" }); + expect(records.some((r) => r.type === "compacted")).toBe(false); // nothing to compact before turn 1 + await runtime.handle({ + type: "runPlan", + planID: "p1", + mode: "here", + steps: [{ id: "1", text: "Move /export behind UsageGate", files: [], editedByHuman: false }, { id: "2", text: "Return 402", files: [], editedByHuman: true }], + }); + await runtime.whenIdle(); + expect(engine.turns[0]).toContain("1. Move /export behind UsageGate\n2. Return 402 (edited by the human"); + await runtime.handle({ type: "compact" }); + expect(records.at(-1)).toMatchObject({ type: "compacted", fromTurn: 1, throughTurn: 1 }); + expect(runtime.handle({ type: "runPlan", planID: "p", mode: "composite", steps: [] })).rejects.toThrow("graph layer"); + expect(runtime.handle({ type: "fork", messageID: "m" })).rejects.toThrow("not supported"); + }); + + test("context at 95% compacts after the turn", async () => { + const engine = new FakeEngine([async (cb) => (cb.usage({ inputTokens: 1, outputTokens: 1, contextUsed: 0.96 }), { lastMessage: "" })]); + const { runtime, records } = setup(engine); + await runtime.start("go"); + await runtime.whenIdle(); + expect(records.at(-1)).toMatchObject({ type: "compacted", throughTurn: 1 }); + }); +}); + +describe("goal loops", () => { + const notMet = '{"clauses":[{"index":0,"met":true,"evidence":"4 / 4 routes"},{"index":1,"met":false,"evidence":"1 failure"}]}'; + const met = '{"clauses":[{"index":0,"met":true},{"index":1,"met":true,"evidence":"31 tests pass"}]}'; + const goal = "Done when every paid route enforces the cap and swift test passes"; + + test("each stop is checked; not met sends the agent back with the unmet clauses, met ends it", async () => { + const engine = new FakeEngine([() => ({ lastMessage: "Done, I think." }), () => ({ lastMessage: "Fixed the failure." })], [() => notMet, () => met]); + const { runtime, records } = setup(engine, { loopType: "goal", goal }); + await runtime.start(); + await runtime.whenIdle(); + expect(engine.turns[0]).toBe(goal); + expect(engine.turns[1]).toContain("- swift test passes (1 failure)"); + const checks = records.filter((r) => r.type === "goalCheck"); + expect(checks.map((r) => r.type === "goalCheck" && [r.turn, r.met])).toEqual([[1, false], [2, true]]); + expect(records.filter((r) => r.type === "turnStarted").map((r) => r.type === "turnStarted" && r.origin)).toEqual(["user", "goalCheck"]); + expect(engine.asks[0]!.model).toBe("haiku"); + }); + + test("the goal is not checked while more messages are queued", async () => { + let release!: () => void; + const engine = new FakeEngine([async () => (await new Promise((r) => (release = r)), { lastMessage: "" }), () => ({ lastMessage: "" })], [() => met]); + const { runtime, records } = setup(engine, { loopType: "goal", goal }); + await runtime.start("first"); + await until(() => runtime.isBusy); + runtime.send("second", "queue"); + release(); + await runtime.whenIdle(); + expect(records.filter((r) => r.type === "goalCheck").map((r) => r.type === "goalCheck" && r.turn)).toEqual([2]); + }); + + test("markGoalDone while idle records a met check without the judge", async () => { + const engine = new FakeEngine([() => ({ lastMessage: "" })], [() => notMet]); + const { runtime, records } = setup(engine, { loopType: "goal", goal, }); + await runtime.start("go"); + await until(() => records.some((r) => r.type === "goalCheck")); + // Not met queued a continuation; the default script ends it, and the judge has no more answers. + await runtime.whenIdle(); + await runtime.handle({ type: "markGoalDone" }); + expect(records.filter((r) => r.type === "goalCheck").at(-1)).toMatchObject({ met: true }); + }); + + test("continuations stop after the limit and wait for a human", async () => { + const engine = new FakeEngine([], Array.from({ length: 10 }, () => () => notMet)); + const cwd = mkdtempSync(join(tmpdir(), "nod-rt-")); + const log = new EventLog(join(cwd, "events.jsonl")); + const records: NodEventRecord[] = []; + log.onRecord((r) => records.push(r)); + const runtime = new NodRuntime({ + nodeID: "n", cwd, stateDir: cwd, loopType: "goal", settings: defaultSettings, engine, log, + presence: new PresenceReporter(undefined), goal, maxGoalContinuations: 2, + }); + await runtime.start("go"); + await runtime.whenIdle(); + expect(engine.turns.length).toBe(3); + expect(records.at(-1)).toMatchObject({ type: "activity", line: "Goal not met after 2 checks · waiting for you" }); + }); +}); From 346f685c3dced63c6cba408aead95f66b14b04c6 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:36:25 -0700 Subject: [PATCH 04/13] Add the Claude and Copilot engines, the PTY transcript and the CLI Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- NodRuntime/src/claudeEngine.ts | 485 ++++++++++++++++++++++++++++++++ NodRuntime/src/copilotEngine.ts | 305 ++++++++++++++++++++ NodRuntime/src/credentials.ts | 61 ++++ NodRuntime/src/diff.ts | 2 +- NodRuntime/src/main.ts | 141 ++++++++++ NodRuntime/src/runtime.ts | 8 +- NodRuntime/src/transcript.ts | 73 +++++ NodRuntime/tsconfig.json | 15 + 8 files changed, 1088 insertions(+), 2 deletions(-) create mode 100644 NodRuntime/src/claudeEngine.ts create mode 100644 NodRuntime/src/copilotEngine.ts create mode 100644 NodRuntime/src/credentials.ts create mode 100644 NodRuntime/src/main.ts create mode 100644 NodRuntime/src/transcript.ts create mode 100644 NodRuntime/tsconfig.json diff --git a/NodRuntime/src/claudeEngine.ts b/NodRuntime/src/claudeEngine.ts new file mode 100644 index 00000000..1bbf07bc --- /dev/null +++ b/NodRuntime/src/claudeEngine.ts @@ -0,0 +1,485 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { extname, isAbsolute, join, resolve } from "node:path"; +import { + query, + type HookCallback, + type Options, + type PermissionResult, + type Query, + type SDKMessage, + type SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import type { ClaudeCredentials } from "./credentials"; +import type { Engine, EngineFailure, EngineSession, EngineStart, ToolRequest, TurnCallbacks, TurnResult } from "./engine"; +import type { NodAttachment } from "./protocol"; +import { summarizeResult } from "./tools"; + +/** Tools the gate must see even when the user's own Claude settings would allow them. */ +const GATED_TOOLS = /^(Bash|Edit|MultiEdit|Write|NotebookEdit|WebFetch|WebSearch|mcp__.*)$/; + +const DEFAULT_CONTEXT_WINDOW = 200_000; +// Used only until the first result reports real spend; then scaled to match it. +const FALLBACK_PRICE = { input: 3 / 1_000_000, output: 15 / 1_000_000 }; + +/** An async iterable that is fed from outside: the streaming-input prompt of a long-lived query. */ +class Inbox implements AsyncIterable { + private items: T[] = []; + private waiting?: (result: IteratorResult) => void; + private done = false; + + push(item: T): void { + if (this.waiting) { + const resolve = this.waiting; + this.waiting = undefined; + resolve({ value: item, done: false }); + } else this.items.push(item); + } + + end(): void { + this.done = true; + this.waiting?.({ value: undefined, done: true }); + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + if (this.items.length > 0) return Promise.resolve({ value: this.items.shift()!, done: false }); + if (this.done) return Promise.resolve({ value: undefined, done: true }); + return new Promise((resolve) => (this.waiting = resolve)); + }, + }; + } +} + +interface ActiveTurn { + callbacks: TurnCallbacks; + lastMessage: string; + textByMessage: Map; + failure?: EngineFailure; + interrupted: boolean; + resolve: (result: TurnResult) => void; +} + +export interface ClaudeEngineOptions { + credentials: ClaudeCredentials; + /** Claude Code to run; the SDK's bundled build when absent. */ + executable?: string; +} + +/** + * The Claude Agent SDK engine: one long-lived streaming-input query per session, so + * turns share a Claude Code process. Every gated tool is forced through `canUseTool` by + * a PreToolUse "ask", which is what makes Nod's gate authoritative over the human's own + * Claude Code allow rules; steering rides the PostToolUse hook's `additionalContext`. + */ +export class ClaudeEngine implements Engine { + readonly kind = "claude" as const; + private q?: Query; + private inbox?: Inbox; + private start_?: EngineStart; + private conversationID = ""; + private model = ""; + private turn?: ActiveTurn; + private seenUsage = new Set(); + private tokens = { input: 0, output: 0 }; + private tokensAtLastResult = { input: 0, output: 0 }; + private costAtLastResult = 0; + private priceScale = 1; + private contextWindow = DEFAULT_CONTEXT_WINDOW; + private contextTokens = 0; + private toolNames = new Map(); + private consumer?: Promise; + /** Stream deltas carry no message id; the message_start before them does. */ + private streamMessageID = ""; + + constructor(private readonly options: ClaudeEngineOptions) {} + + async start(start: EngineStart): Promise { + this.start_ = start; + this.conversationID = start.resume ?? randomUUID(); + this.model = start.model ?? "sonnet"; + this.open(Boolean(start.resume)); + return { conversationID: this.conversationID, model: this.model }; + } + + private open(resuming: boolean): void { + const start = this.start_!; + this.inbox = new Inbox(); + const options: Options = { + cwd: start.cwd, + model: this.model, + ...(resuming ? { resume: this.conversationID } : { sessionId: this.conversationID }), + permissionMode: "default", + includePartialMessages: true, + settingSources: ["user", "project", "local"], + systemPrompt: { type: "preset", preset: "claude_code", append: start.systemAppend }, + canUseTool: (tool, input, { signal }) => this.canUseTool(tool, input, signal), + hooks: { + PreToolUse: [{ hooks: [this.preToolUse] }], + PostToolUse: [{ hooks: [this.postToolUse] }], + }, + env: { ...process.env, ...this.options.credentials.env, CLAUDE_AGENT_SDK_CLIENT_APP: "graphcode-nod" }, + ...(this.options.executable ? { pathToClaudeCodeExecutable: this.options.executable } : {}), + stderr: () => {}, + }; + this.q = query({ prompt: this.inbox, options }); + this.consumer = this.consume(this.q); + } + + async runTurn(text: string, attachments: NodAttachment[], callbacks: TurnCallbacks): Promise { + if (!this.q) this.open(true); + return new Promise((resolve) => { + this.turn = { callbacks, lastMessage: "", textByMessage: new Map(), interrupted: false, resolve }; + this.inbox!.push(userMessage(text, attachments, this.start_!.cwd)); + }); + } + + async interrupt(): Promise { + if (!this.turn) return; + this.turn.interrupted = true; + await this.q?.interrupt().catch(() => {}); + } + + async setModel(model: string): Promise { + this.model = model; + await this.q?.setModel(model); + } + + compact(callbacks: TurnCallbacks): Promise { + return this.runTurn("/compact", [], callbacks); + } + + async ask(prompt: string, model?: string): Promise { + const q = query({ + prompt, + options: { + cwd: this.start_?.cwd ?? process.cwd(), + ...(model || this.model ? { model: model || this.model } : {}), + tools: [], + maxTurns: 1, + permissionMode: "dontAsk", + settingSources: [], + persistSession: false, + env: { ...process.env, ...this.options.credentials.env, CLAUDE_AGENT_SDK_CLIENT_APP: "graphcode-nod" }, + ...(this.options.executable ? { pathToClaudeCodeExecutable: this.options.executable } : {}), + stderr: () => {}, + }, + }); + let text = ""; + for await (const message of q) { + if (message.type === "assistant" && !message.parent_tool_use_id) { + const failure = assistantFailure(message.error); + if (failure) throw new Error(failure.message); + text = textOf(message.message.content); + } + if (message.type === "result") { + if (message.subtype === "success" && message.result) text = message.result; + else if (message.subtype !== "success") throw new Error(message.errors.join("; ") || message.subtype); + } + } + return text; + } + + async close(): Promise { + this.inbox?.end(); + this.q?.close(); + await this.consumer?.catch(() => {}); + } + + private preToolUse: HookCallback = async (input) => { + if (input.hook_event_name !== "PreToolUse" || !GATED_TOOLS.test(input.tool_name)) return {}; + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "ask", + permissionDecisionReason: "GraphCode Nod reviews this tool call", + }, + }; + }; + + private postToolUse: HookCallback = async (input) => { + if (input.hook_event_name !== "PostToolUse") return {}; + const steer = this.turn?.callbacks.takeSteer(); + return steer ? { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: steer } } : {}; + }; + + private async canUseTool(tool: string, input: Record, signal: AbortSignal): Promise { + const turn = this.turn; + if (!turn) return { behavior: "deny", message: "No turn is running." }; + const request = toolRequest(tool, input, this.start_!.cwd); + const aborted = new Promise((resolve) => + signal.addEventListener("abort", () => resolve({ behavior: "deny", message: "The turn was stopped." }), { once: true }), + ); + const decided = turn.callbacks.authorize(request).then((authorization) => + authorization.allow + ? { behavior: "allow", updatedInput: input } + : { behavior: "deny", message: authorization.message, interrupt: authorization.interrupt }, + ); + const result = await Promise.race([decided, aborted]); + if (result.behavior === "deny" && result.interrupt) turn.interrupted = true; + return result; + } + + private async consume(q: Query): Promise { + try { + for await (const message of q) this.onMessage(message); + this.finishTurn({ kind: "engineError", message: "Claude Code exited." }); + } catch (error) { + this.finishTurn(classifyError(error)); + } finally { + if (this.q === q) { + this.q = undefined; + this.inbox = undefined; + } + } + } + + private finishTurn(failure?: EngineFailure): void { + const turn = this.turn; + if (!turn) return; + this.turn = undefined; + turn.resolve({ lastMessage: turn.lastMessage, failure: failure ?? turn.failure, interrupted: turn.interrupted }); + } + + private onMessage(message: SDKMessage): void { + const turn = this.turn; + switch (message.type) { + case "system": + if (message.subtype === "init") this.model = message.model; + else if (message.subtype === "compact_boundary") turn?.callbacks.compacted(); + return; + case "auth_status": + if (message.error && turn) turn.failure = { kind: "signInExpired", message: message.error }; + return; + case "stream_event": { + if (!turn || message.parent_tool_use_id) return; + const event = message.event; + if (event.type === "message_start") this.streamMessageID = event.message.id; + if (event.type === "content_block_delta" && event.delta.type === "text_delta") { + const id = this.streamMessageID; + turn.textByMessage.set(id, (turn.textByMessage.get(id) ?? "") + event.delta.text); + turn.callbacks.text(id, event.delta.text, false); + } + return; + } + case "assistant": { + if (!turn || message.parent_tool_use_id) return; + const failure = assistantFailure(message.error); + if (failure) turn.failure = failure; + const id = message.message.id; + for (const block of message.message.content) { + if (block.type === "text") { + if (!turn.textByMessage.has(id) && block.text) turn.callbacks.text(id, block.text, false); + turn.textByMessage.set(id, block.text); + turn.callbacks.text(id, "", true); + if (block.text.trim()) turn.lastMessage = block.text; + } else if (block.type === "tool_use") { + this.toolNames.set(block.id, block.name); + turn.callbacks.toolCall(block.id, block.name, block.input); + } + } + this.recordUsage(id, message.message.usage); + return; + } + case "user": { + if (!turn || message.parent_tool_use_id) return; + const content = message.message.content; + if (!Array.isArray(content)) return; + for (const block of content) { + if (typeof block !== "object" || block === null || block.type !== "tool_result") continue; + const output = toolResultText(block.content); + const tool = this.toolNames.get(block.tool_use_id) ?? ""; + const isError = block.is_error === true; + turn.callbacks.toolResult(block.tool_use_id, isError ? "error" : "ok", summarizeResult(tool, output, isError), output); + } + return; + } + case "result": { + for (const usage of Object.values(message.modelUsage ?? {})) { + if (usage.contextWindow > 0) this.contextWindow = usage.contextWindow; + } + this.costAtLastResult = message.total_cost_usd; + const estimated = this.estimateAtDefaults(this.tokens); + if (estimated > 0 && message.total_cost_usd > 0) this.priceScale = message.total_cost_usd / estimated; + this.tokensAtLastResult = { ...this.tokens }; + turn?.callbacks.usage(this.usageReport(message.total_cost_usd)); + if (turn && message.subtype !== "success" && !turn.interrupted) { + turn.failure ??= resultFailure(message.subtype, message.errors, message.terminal_reason); + } + if (turn && message.terminal_reason === "prompt_too_long") { + turn.failure = { kind: "contextFull", message: "The conversation no longer fits the model's context window." }; + } + this.finishTurn(); + return; + } + } + } + + private recordUsage(messageID: string, usage: unknown): void { + // Claude Code repeats a message's usage on every content block; count each message once. + if (!usage || this.seenUsage.has(messageID)) return; + this.seenUsage.add(messageID); + const u = usage as Record; + const prompt = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0); + this.tokens.input += prompt; + this.tokens.output += u.output_tokens ?? 0; + this.contextTokens = prompt + (u.output_tokens ?? 0); + const sinceResult = { + input: this.tokens.input - this.tokensAtLastResult.input, + output: this.tokens.output - this.tokensAtLastResult.output, + }; + const cost = this.costAtLastResult + this.estimateAtDefaults(sinceResult) * this.priceScale; + this.turn?.callbacks.usage(this.usageReport(cost)); + } + + private estimateAtDefaults(tokens: { input: number; output: number }): number { + return tokens.input * FALLBACK_PRICE.input + tokens.output * FALLBACK_PRICE.output; + } + + private usageReport(costUSD: number) { + return { + inputTokens: this.tokens.input, + outputTokens: this.tokens.output, + costUSD, + contextUsed: Math.min(1, this.contextTokens / this.contextWindow), + }; + } +} + +function userMessage(text: string, attachments: NodAttachment[], cwd: string): SDKUserMessage { + const content: Array> = []; + const notes: string[] = []; + for (const attachment of attachments) { + const path = isAbsolute(attachment.reference) ? attachment.reference : resolve(cwd, attachment.reference); + if (attachment.kind === "image" && existsSync(path)) { + const media = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }[ + extname(path).toLowerCase() + ]; + if (media) { + content.push({ type: "image", source: { type: "base64", media_type: media, data: readFileSync(path).toString("base64") } }); + continue; + } + } + if (attachment.kind === "loopTranscript") notes.push(`Attached: the transcript of loop ${attachment.label ?? attachment.reference} (${attachment.reference}).`); + else notes.push(`Attached file: ${path}`); + } + content.push({ type: "text", text: notes.length > 0 ? `${notes.join("\n")}\n\n${text}` : text }); + return { + type: "user", + message: { role: "user", content: content.length === 1 ? (content[0]!.text as string) : (content as never) }, + parent_tool_use_id: null, + }; +} + +/** Maps a Claude Code tool call onto the gate's vocabulary, computing an edit's result for staging. */ +export function toolRequest(tool: string, input: Record, cwd: string): ToolRequest { + const str = (key: string) => (typeof input[key] === "string" ? (input[key] as string) : ""); + const path = (key: string) => { + const value = str(key); + return value ? (isAbsolute(value) ? value : resolve(cwd, value)) : ""; + }; + switch (tool) { + case "Bash": + return { intent: { kind: "shell", command: str("command") } }; + case "WebFetch": + return { intent: { kind: "fetch", url: str("url") } }; + case "WebSearch": + return { intent: { kind: "fetch", url: `search: ${str("query")}` } }; + case "Write": { + const file = path("file_path"); + return { intent: { kind: "edit", path: file }, edit: { path: file, after: str("content") } }; + } + case "Edit": + case "MultiEdit": { + const file = path("file_path"); + const edits = + tool === "Edit" + ? [{ old_string: str("old_string"), new_string: str("new_string"), replace_all: input.replace_all === true }] + : ((input.edits as { old_string: string; new_string: string; replace_all?: boolean }[]) ?? []); + const before = existsSync(file) ? readFileSync(file, "utf8") : ""; + const after = applyEdits(before, edits); + return { intent: { kind: "edit", path: file }, ...(after === undefined ? {} : { edit: { path: file, after } }) }; + } + case "NotebookEdit": + return { intent: { kind: "edit", path: path("notebook_path") } }; + } + if (tool.startsWith("mcp__")) { + const [, server = "", name = ""] = tool.split("__"); + return { intent: { kind: "mcp", server, tool: name } }; + } + return { intent: { kind: "read" } }; +} + +/** Claude Code's Edit semantics; undefined when the tool itself would refuse the edit. */ +export function applyEdits(text: string, edits: { old_string: string; new_string: string; replace_all?: boolean }[]): string | undefined { + let result = text; + for (const edit of edits) { + if (edit.old_string === "") { + if (result !== "") return undefined; + result = edit.new_string; + continue; + } + const count = result.split(edit.old_string).length - 1; + if (count === 0 || (count > 1 && !edit.replace_all)) return undefined; + result = edit.replace_all + ? result.split(edit.old_string).join(edit.new_string) + : result.replace(edit.old_string, () => edit.new_string); + } + return result; +} + +function textOf(content: unknown): string { + if (!Array.isArray(content)) return ""; + return content + .filter((b): b is { type: "text"; text: string } => b?.type === "text") + .map((b) => b.text) + .join(""); +} + +function toolResultText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) return textOf(content); + return ""; +} + +function assistantFailure(error: string | undefined): EngineFailure | undefined { + switch (error) { + case undefined: + return undefined; + case "authentication_failed": + case "oauth_org_not_allowed": + case "verification_required": + case "cloud_credential_error": + return { kind: "signInExpired", message: "Claude sign-in expired. Sign in again to continue." }; + case "max_output_tokens": + return undefined; + default: + return { kind: "engineError", message: `Claude reported ${error.replace(/_/g, " ")}.` }; + } +} + +function resultFailure(subtype: string, errors: string[], terminal?: string): EngineFailure { + if (subtype === "error_max_budget_usd") return { kind: "spendCap", message: "The Claude budget for this session ran out." }; + const detail = errors.filter(Boolean).join("; "); + return { kind: "engineError", message: detail || `Claude stopped: ${terminal ?? subtype}` }; +} + +function classifyError(error: unknown): EngineFailure { + const message = error instanceof Error ? error.message : String(error); + if (/auth|login|api key|401|403/i.test(message)) return { kind: "signInExpired", message }; + return { kind: "engineError", message }; +} + +/** The Claude Code to run: the user's install when there is one, so Nod shares its login and updates. */ +export function findClaudeExecutable(env: Record = process.env): string | undefined { + const candidates = [ + env.GRAPHCODE_NOD_CLAUDE, + join(homedir(), ".local", "bin", "claude"), + join(homedir(), ".claude", "local", "claude"), + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + ]; + return candidates.find((path): path is string => Boolean(path) && existsSync(path!)); +} diff --git a/NodRuntime/src/copilotEngine.ts b/NodRuntime/src/copilotEngine.ts new file mode 100644 index 00000000..3cb0beaf --- /dev/null +++ b/NodRuntime/src/copilotEngine.ts @@ -0,0 +1,305 @@ +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; +import { + CopilotClient, + RuntimeConnection, + type CopilotSession, + type PermissionRequest, + type PermissionRequestResult, + type SessionConfig, + type SessionEvent, +} from "@github/copilot-sdk"; +import type { Engine, EngineFailure, EngineSession, EngineStart, ToolRequest, TurnCallbacks, TurnResult } from "./engine"; +import type { NodAttachment } from "./protocol"; +import { summarizeResult } from "./tools"; + +interface ActiveTurn { + callbacks: TurnCallbacks; + lastMessage: string; + streamed: Set; + failure?: EngineFailure; + interrupted: boolean; + premiumCounted: boolean; + resolve: (result: TurnResult) => void; +} + +export interface CopilotEngineOptions { + /** A token from Nod's Keychain entry; without it the Copilot CLI's own login is used. */ + githubToken?: string; + /** The Copilot runtime to spawn; the SDK's bundled one when absent. */ + cliPath?: string; +} + +/** + * The GitHub Copilot SDK engine. Copilot asks permission through one handler for every + * shell, write, URL and MCP request, so the gate sits there; a write carries the file's + * new contents, which is all hunk staging needs. Steering rides `onPostToolUse`'s + * `additionalContext`, the same tool boundary the Claude engine uses. + */ +export class CopilotEngine implements Engine { + readonly kind = "copilot" as const; + private client?: CopilotClient; + private session?: CopilotSession; + private start_?: EngineStart; + private turn?: ActiveTurn; + private model = ""; + private tokens = { input: 0, output: 0 }; + private premiumRequests = 0; + private contextUsed = 0; + private tools = new Map(); + + constructor(private readonly options: CopilotEngineOptions) {} + + async start(start: EngineStart): Promise { + this.start_ = start; + const client = this.makeClient(start.cwd); + this.client = client; + await client.start(); + const config: SessionConfig = { + model: start.model, + workingDirectory: start.cwd, + streaming: true, + systemMessage: start.systemAppend ? { mode: "append", content: start.systemAppend } : undefined, + onPermissionRequest: (request) => this.onPermission(request), + hooks: { + onPostToolUse: () => { + const steer = this.turn?.callbacks.takeSteer(); + return steer ? { additionalContext: steer } : undefined; + }, + }, + }; + const session = start.resume + ? await client.resumeSession(start.resume, config) + : await client.createSession({ ...config, sessionId: randomUUID() }); + this.session = session; + session.on((event) => this.onEvent(event)); + this.model = start.model ?? (await this.currentModel()) ?? "default"; + return { conversationID: session.sessionId, model: this.model }; + } + + private makeClient(cwd: string): CopilotClient { + const path = this.options.cliPath; + return new CopilotClient({ + workingDirectory: cwd, + logLevel: "error", + ...(this.options.githubToken ? { gitHubToken: this.options.githubToken, useLoggedInUser: false } : { useLoggedInUser: true }), + ...(path ? { connection: RuntimeConnection.forStdio({ path }) } : {}), + }); + } + + private async currentModel(): Promise { + const events = await this.session?.getEvents().catch(() => []); + for (const event of [...(events ?? [])].reverse()) { + if (event.type === "session.model_change") return (event.data as { newModel?: string }).newModel; + if (event.type === "session.start") return (event.data as { selectedModel?: string }).selectedModel; + } + return undefined; + } + + runTurn(text: string, attachments: NodAttachment[], callbacks: TurnCallbacks): Promise { + const session = this.session; + if (!session) return Promise.resolve({ lastMessage: "", failure: { kind: "engineError", message: "Copilot session is not running." } }); + return new Promise((resolve) => { + this.turn = { callbacks, lastMessage: "", streamed: new Set(), interrupted: false, premiumCounted: false, resolve }; + session + .send({ prompt: withNotes(text, attachments), attachments: fileAttachments(attachments, this.start_!.cwd) }) + .catch((error) => this.finishTurn(classify(error))); + }); + } + + async interrupt(): Promise { + if (!this.turn) return; + this.turn.interrupted = true; + await this.session?.abort().catch(() => {}); + } + + async setModel(model: string): Promise { + await this.session?.setModel(model); + this.model = model; + } + + compact(callbacks: TurnCallbacks): Promise { + return this.runTurn("/compact", [], callbacks); + } + + async ask(prompt: string, model?: string): Promise { + const client = this.client ?? this.makeClient(this.start_?.cwd ?? process.cwd()); + if (!this.client) await client.start(); + const session = await client.createSession({ + model: model || this.model || undefined, + workingDirectory: this.start_?.cwd ?? process.cwd(), + availableTools: [], + onPermissionRequest: () => ({ kind: "reject" }), + }); + try { + const reply = await session.sendAndWait({ prompt }, 120_000); + return (reply?.data as { content?: string } | undefined)?.content ?? ""; + } finally { + await session.disconnect().catch(() => {}); + await client.deleteSession(session.sessionId).catch(() => {}); + if (!this.client) await client.stop(); + } + } + + async close(): Promise { + await this.session?.disconnect().catch(() => {}); + await this.client?.stop().catch(() => {}); + } + + private finishTurn(failure?: EngineFailure): void { + const turn = this.turn; + if (!turn) return; + this.turn = undefined; + turn.resolve({ lastMessage: turn.lastMessage, failure: failure ?? turn.failure, interrupted: turn.interrupted }); + } + + private async onPermission(request: PermissionRequest): Promise { + const turn = this.turn; + if (!turn) return { kind: "reject", feedback: "No turn is running." } as PermissionRequestResult; + const authorization = await turn.callbacks.authorize(toolRequest(request, this.start_!.cwd)); + if (authorization.allow) return { kind: "approve-once" } as PermissionRequestResult; + if (authorization.interrupt) { + turn.interrupted = true; + void this.session?.abort().catch(() => {}); + } + return { kind: "reject", feedback: authorization.message } as PermissionRequestResult; + } + + private onEvent(event: SessionEvent): void { + const turn = this.turn; + const data = event.data as Record; + // Sub-agent traffic stays inside its parent's card. + if (typeof data?.parentToolCallId === "string") return; + switch (event.type) { + case "assistant.message_delta": { + const id = String(data.messageId); + turn?.streamed.add(id); + turn?.callbacks.text(id, String(data.deltaContent ?? ""), false); + return; + } + case "assistant.message": { + if (!turn) return; + const id = String(data.messageId); + const content = String(data.content ?? ""); + if (!turn.streamed.has(id) && content) turn.callbacks.text(id, content, false); + turn.callbacks.text(id, "", true); + if (content.trim()) turn.lastMessage = content; + return; + } + case "tool.execution_start": { + const id = String(data.toolCallId); + const name = String(data.toolName ?? "tool"); + this.tools.set(id, { name, at: Date.now() }); + turn?.callbacks.toolCall(id, name, data.arguments); + return; + } + case "tool.execution_complete": { + const id = String(data.toolCallId); + const tool = this.tools.get(id); + this.tools.delete(id); + const ok = data.success === true; + const result = data.result as { content?: string } | undefined; + const error = data.error as { message?: string } | undefined; + const output = ok ? (result?.content ?? "") : (error?.message ?? result?.content ?? "failed"); + turn?.callbacks.toolResult(id, ok ? "ok" : "error", summarizeResult(tool?.name ?? "", output, !ok), output, tool ? Date.now() - tool.at : undefined); + return; + } + case "assistant.usage": { + this.tokens.input += Number(data.inputTokens ?? 0) + Number(data.cacheReadTokens ?? 0) + Number(data.cacheWriteTokens ?? 0); + this.tokens.output += Number(data.outputTokens ?? 0); + // One premium request per user turn, at the model's multiplier. + if (turn && !turn.premiumCounted && typeof data.cost === "number") { + turn.premiumCounted = true; + this.premiumRequests += data.cost; + } + turn?.callbacks.usage(this.report()); + return; + } + case "session.usage_info": { + const limit = Number(data.tokenLimit ?? 0); + if (limit > 0) this.contextUsed = Math.min(1, Number(data.currentTokens ?? 0) / limit); + turn?.callbacks.usage(this.report()); + return; + } + case "session.compaction_complete": + if (data.success === true) turn?.callbacks.compacted(); + return; + case "session.model_change": + if (typeof data.newModel === "string") this.model = data.newModel; + return; + case "session.error": { + if (turn) turn.failure = sessionFailure(String(data.errorType ?? ""), String(data.message ?? "Copilot reported an error.")); + return; + } + case "session.idle": + if (turn && data.aborted === true) turn.interrupted = true; + this.finishTurn(); + return; + } + } + + private report() { + return { + inputTokens: this.tokens.input, + outputTokens: this.tokens.output, + premiumRequests: Math.round(this.premiumRequests), + contextUsed: this.contextUsed, + }; + } +} + +export function toolRequest(request: PermissionRequest, cwd: string): ToolRequest { + const r = request as unknown as Record & { kind: string }; + const str = (key: string) => (typeof r[key] === "string" ? (r[key] as string) : ""); + const abs = (path: string) => (isAbsolute(path) ? path : resolve(cwd, path)); + switch (r.kind) { + case "shell": + return { intent: { kind: "shell", command: str("fullCommandText") } }; + case "write": { + const path = abs(str("resolvedPath") || str("fileName")); + const after = typeof r.newFileContents === "string" ? r.newFileContents : undefined; + return { intent: { kind: "edit", path }, ...(after === undefined ? {} : { edit: { path, after } }) }; + } + case "url": + return { intent: { kind: "fetch", url: str("url") } }; + case "mcp": + return { intent: { kind: "mcp", server: str("serverName"), tool: str("toolName") } }; + case "read": + return { intent: { kind: "read" } }; + } + return { intent: { kind: "read" } }; +} + +function sessionFailure(errorType: string, message: string): EngineFailure { + switch (errorType) { + case "authentication": + case "authorization": + return { kind: "signInExpired", message: `Copilot sign-in expired. ${message}` }; + case "context_limit": + return { kind: "contextFull", message }; + default: + return { kind: "engineError", message }; + } +} + +function classify(error: unknown): EngineFailure { + const message = error instanceof Error ? error.message : String(error); + if (/auth|login|token|401|403/i.test(message)) return { kind: "signInExpired", message }; + return { kind: "engineError", message }; +} + +function withNotes(text: string, attachments: NodAttachment[]): string { + const notes = attachments + .filter((a) => a.kind === "loopTranscript") + .map((a) => `Attached: the transcript of loop ${a.label ?? a.reference} (${a.reference}).`); + return notes.length > 0 ? `${notes.join("\n")}\n\n${text}` : text; +} + +function fileAttachments(attachments: NodAttachment[], cwd: string) { + return attachments + .filter((a) => a.kind !== "loopTranscript") + .map((a) => (isAbsolute(a.reference) ? a.reference : resolve(cwd, a.reference))) + .filter((path) => existsSync(path)) + .map((path) => ({ type: "file" as const, path })); +} diff --git a/NodRuntime/src/credentials.ts b/NodRuntime/src/credentials.ts new file mode 100644 index 00000000..7d822758 --- /dev/null +++ b/NodRuntime/src/credentials.ts @@ -0,0 +1,61 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** `NodSettings.keychainService`: Nod's own sign-ins, never under `~/.graphcode`. */ +export const KEYCHAIN_SERVICE = "app.graphcode.nod"; + +/** Keychain accounts under `KEYCHAIN_SERVICE`, written by Settings › Agents › Nod. */ +export const KeychainAccount = { + anthropicAPIKey: "anthropic-api-key", + claudeOAuthToken: "claude-oauth-token", + githubToken: "github-token", +} as const; + +export type KeychainReader = (service: string, account?: string) => string | undefined; + +export const readKeychain: KeychainReader = (service, account) => { + if (process.platform !== "darwin") return undefined; + const args = ["find-generic-password", "-s", service, ...(account ? ["-a", account] : []), "-w"]; + const result = spawnSync("/usr/bin/security", args, { encoding: "utf8", timeout: 5000 }); + if (result.status !== 0) return undefined; + const value = result.stdout.trim(); + return value || undefined; +}; + +/** Where Claude Code keeps its own login: the Keychain on macOS, a file elsewhere. */ +export function hasClaudeCodeLogin(read: KeychainReader = readKeychain, home = homedir()): boolean { + if (read("Claude Code-credentials")) return true; + return existsSync(join(home, ".claude", ".credentials.json")); +} + +export interface ClaudeCredentials { + /** Variables for the engine's environment; empty when reusing Claude Code's own login. */ + env: Record; + source: "nod-api-key" | "nod-oauth" | "environment" | "claude-code-login" | "none"; +} + +/** + * Nod's Keychain entry first, then an API key already in the environment, then the + * Claude Code login on this Mac — which needs nothing passed, because the engine runs + * Claude Code and Claude Code reads its own login. + */ +export function claudeCredentials( + read: KeychainReader = readKeychain, + env: Record = process.env, + home = homedir(), +): ClaudeCredentials { + const apiKey = read(KEYCHAIN_SERVICE, KeychainAccount.anthropicAPIKey); + if (apiKey) return { env: { ANTHROPIC_API_KEY: apiKey }, source: "nod-api-key" }; + const oauth = read(KEYCHAIN_SERVICE, KeychainAccount.claudeOAuthToken); + if (oauth) return { env: { CLAUDE_CODE_OAUTH_TOKEN: oauth }, source: "nod-oauth" }; + if (env.ANTHROPIC_API_KEY || env.CLAUDE_CODE_OAUTH_TOKEN) return { env: {}, source: "environment" }; + if (hasClaudeCodeLogin(read, home)) return { env: {}, source: "claude-code-login" }; + return { env: {}, source: "none" }; +} + +/** A GitHub token from Nod's Keychain entry; without one, the Copilot CLI's own login is used. */ +export function githubToken(read: KeychainReader = readKeychain): string | undefined { + return read(KEYCHAIN_SERVICE, KeychainAccount.githubToken); +} diff --git a/NodRuntime/src/diff.ts b/NodRuntime/src/diff.ts index c8aa8d45..80911067 100644 --- a/NodRuntime/src/diff.ts +++ b/NodRuntime/src/diff.ts @@ -40,7 +40,7 @@ function myers(a: string[], b: string[]): Op[] { if (m === 0) return a.map((text) => ({ kind: "-", text })); const max = n + m; const offset = max; - let v = new Int32Array(2 * max + 2); + let v: Int32Array = new Int32Array(2 * max + 2); const trace: Int32Array[] = []; outer: for (let d = 0; d <= max; d++) { trace.push(v.slice()); diff --git a/NodRuntime/src/main.ts b/NodRuntime/src/main.ts new file mode 100644 index 00000000..73db3a35 --- /dev/null +++ b/NodRuntime/src/main.ts @@ -0,0 +1,141 @@ +#!/usr/bin/env bun +import { mkdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { parseArgs } from "node:util"; +import { ClaudeEngine, findClaudeExecutable } from "./claudeEngine"; +import { ControlSocket } from "./controlSocket"; +import { CopilotEngine } from "./copilotEngine"; +import { claudeCredentials, githubToken } from "./credentials"; +import type { Engine } from "./engine"; +import { EventLog } from "./eventLog"; +import { PresenceReporter } from "./presence"; +import type { NodEngineKind } from "./protocol"; +import { NodRuntime, type LoopType } from "./runtime"; +import { loadSettings, supportDirectory } from "./settings"; +import { Transcript } from "./transcript"; + +const USAGE = `graphcode-nod --node --cwd [--engine claude|copilot] [--model ] + [--loop-type main|goal|timed|turn|composite] [--goal-file ] + [--briefing ] [--resume ] [--prompt ] [--unattended] +graphcode-nod -p [--engine claude|copilot] [--model ]`; + +const loopTypes = new Set(["main", "goal", "timed", "turn", "composite"]); + +export function makeEngine(kind: NodEngineKind): Engine { + if (kind === "copilot") { + return new CopilotEngine({ githubToken: githubToken(), cliPath: process.env.GRAPHCODE_NOD_COPILOT }); + } + return new ClaudeEngine({ credentials: claudeCredentials(), executable: findClaudeExecutable() }); +} + +async function main(argv: string[]): Promise { + const { values } = parseArgs({ + args: argv, + options: { + node: { type: "string" }, + cwd: { type: "string" }, + engine: { type: "string" }, + model: { type: "string" }, + "loop-type": { type: "string" }, + "goal-file": { type: "string" }, + briefing: { type: "string" }, + resume: { type: "string" }, + prompt: { type: "string" }, + print: { type: "string", short: "p" }, + unattended: { type: "boolean" }, + "exit-when-idle": { type: "boolean" }, + help: { type: "boolean", short: "h" }, + }, + strict: true, + }); + if (values.help) { + process.stdout.write(USAGE + "\n"); + return 0; + } + const settings = loadSettings(); + const engineKind = (values.engine ?? settings.engine) as NodEngineKind; + if (engineKind !== "claude" && engineKind !== "copilot") throw new Error(`unknown engine ${engineKind}`); + + if (values.print !== undefined) { + const engine = makeEngine(engineKind); + try { + const answer = await engine.ask(values.print, values.model); + process.stdout.write(answer.endsWith("\n") ? answer : answer + "\n"); + return 0; + } finally { + await engine.close(); + } + } + + if (!values.node || !values.cwd) throw new Error(`--node and --cwd are required\n${USAGE}`); + const loopType = (values["loop-type"] ?? "main") as LoopType; + if (!loopTypes.has(loopType)) throw new Error(`unknown loop type ${loopType}`); + const cwd = resolve(values.cwd); + const stateDir = join(supportDirectory(), "nod", values.node); + mkdirSync(stateDir, { recursive: true }); + + const log = new EventLog(join(stateDir, "events.jsonl")); + const transcript = new Transcript((text) => process.stdout.write(text)); + log.onRecord((record) => transcript.render(record)); + const presence = new PresenceReporter(process.env.ZMX_SESSION); + const engine = makeEngine(engineKind); + const runtime = new NodRuntime({ + nodeID: values.node, + cwd, + stateDir, + loopType, + settings, + engine, + log, + presence, + model: values.model ?? settings.modelsByLoopType[loopType], + goal: values["goal-file"] ? readFileSync(values["goal-file"], "utf8") : undefined, + briefing: values.briefing ? readFileSync(values.briefing, "utf8") : undefined, + resume: values.resume, + unattended: values.unattended || undefined, + }); + const control = new ControlSocket(join(stateDir, "control.sock"), (command) => runtime.handle(command)); + await control.listen(); + + let closing = false; + const shutdown = async (code: number) => { + if (closing) return; + closing = true; + await runtime.close().catch(() => {}); + await control.close().catch(() => {}); + process.exit(code); + }; + process.on("SIGTERM", () => void shutdown(0)); + process.on("SIGHUP", () => void shutdown(0)); + // Ctrl-C in an attached terminal stops the turn first, as Esc does in the chat pane. + process.on("SIGINT", () => void (runtime.isBusy ? runtime.stop() : shutdown(0))); + + await runtime.start(values.prompt); + + // A plain line typed into the PTY — a `.message` edge, `graphcode node send` — is a + // queued send, so every existing way of talking to a loop reaches Nod unchanged. + if (!values["exit-when-idle"]) { + const lines = createInterface({ input: process.stdin, terminal: false }); + lines.on("line", (line) => { + const text = line.trim(); + if (text) runtime.send(text, "queue"); + }); + } + + if (values["exit-when-idle"]) { + await runtime.whenIdle(); + await shutdown(0); + } + return await new Promise(() => {}); +} + +if (import.meta.main) { + main(process.argv.slice(2)).then( + (code) => process.exit(code), + (error) => { + process.stderr.write(`graphcode-nod: ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + }, + ); +} diff --git a/NodRuntime/src/runtime.ts b/NodRuntime/src/runtime.ts index cd4cf5af..0cc5f300 100644 --- a/NodRuntime/src/runtime.ts +++ b/NodRuntime/src/runtime.ts @@ -323,8 +323,14 @@ export class NodRuntime { this.fail({ kind: "permissionUnavailable", message: verdict.message }); return { allow: false, message: verdict.message, interrupt: true }; case "stage": { - if (!request.edit) return { allow: true }; const auto = this.options.settings.editsInWorktree === "auto"; + if (!request.edit) { + // Nothing to diff (a notebook, or an edit whose old text isn't in the file): in + // review mode it must not land unreviewed. + return auto + ? { allow: true } + : { allow: false, message: "This edit can't be staged for review. Re-read the file and make it as an exact text edit." }; + } if (!auto) void this.options.presence.presence("awaitingInput", "waiting for review"); try { const outcome = await this.stager.stageEdit(request.edit.path, request.edit.after, turn, auto); diff --git a/NodRuntime/src/transcript.ts b/NodRuntime/src/transcript.ts new file mode 100644 index 00000000..e47609ea --- /dev/null +++ b/NodRuntime/src/transcript.ts @@ -0,0 +1,73 @@ +import type { NodEventRecord } from "./protocol"; + +/** + * The plain-text transcript on the PTY, for `zmx attach` and remote loops. The app never + * parses it; it renders from the event log. + */ +export class Transcript { + private midLine = false; + + constructor(private readonly write: (text: string) => void) {} + + render(record: NodEventRecord): void { + switch (record.type) { + case "sessionStarted": + this.line(`● Nod · ${record.model} via ${record.engine === "claude" ? "Claude Agent SDK" : "GitHub Copilot SDK"}${record.resumed ? " · resumed" : ""}`); + return; + case "userMessage": + this.line(`${record.delivery === "steer" ? "↳ steer" : "›"} ${record.text}`); + return; + case "assistantText": + if (record.delta) { + this.write(record.delta); + this.midLine = !record.delta.endsWith("\n"); + } + if (record.final) this.endLine(); + return; + case "toolCall": + this.line(` ▸ ${record.title}`); + return; + case "toolResult": + if (record.status !== "running") this.line(` ${record.status === "ok" ? "✓" : "✗"} ${record.summary}${record.durationMs ? ` · ${seconds(record.durationMs)}` : ""}`); + return; + case "hunkStaged": + this.line(` ± ${record.file} +${record.added} −${record.removed}${record.autoAccepted ? " · accepted" : " · awaiting review"}`); + return; + case "hunkResolved": + this.line(` hunk ${record.hunkID} ${record.decision}${record.note ? `: ${record.note}` : ""}`); + return; + case "permissionAsked": + this.line(` ? Nod asks to ${record.kind === "shell" ? "run" : "use"} ${record.subject} — ${record.reason} Answer in the chat pane.`); + return; + case "permissionResolved": + this.line(` ${record.decision === "deny" ? "denied" : "allowed"}`); + return; + case "goalCheck": + this.line(` ◎ Goal check · ${record.met ? "holds" : "not yet"} · ${record.clauses.filter((c) => c.met).length}/${record.clauses.length}`); + return; + case "turnEnded": + if (record.filesChanged > 0) this.line(` ${record.filesChanged} file${record.filesChanged === 1 ? "" : "s"} · +${record.added} −${record.removed}`); + return; + case "compacted": + this.line(` ── compacted turns ${record.fromTurn}–${record.throughTurn} ──`); + return; + case "failure": + this.line(` ! ${record.message}`); + return; + } + } + + private line(text: string): void { + this.endLine(); + this.write(text + "\n"); + } + + private endLine(): void { + if (this.midLine) this.write("\n"); + this.midLine = false; + } +} + +function seconds(ms: number): string { + return ms < 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms / 1000)}s`; +} diff --git a/NodRuntime/tsconfig.json b/NodRuntime/tsconfig.json new file mode 100644 index 00000000..67543f23 --- /dev/null +++ b/NodRuntime/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2023"], + "types": ["bun"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": false + }, + "include": ["src", "test"] +} From 40edfb3ad9e910966351d2b536105dcde01dae4c Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:37:35 -0700 Subject: [PATCH 05/13] Compare worktree containment by real path Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- NodRuntime/src/permissions.ts | 16 ++++++++++++++-- NodRuntime/test/permissions.test.ts | 11 ++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/NodRuntime/src/permissions.ts b/NodRuntime/src/permissions.ts index b513bfb0..bf93daef 100644 --- a/NodRuntime/src/permissions.ts +++ b/NodRuntime/src/permissions.ts @@ -1,4 +1,5 @@ -import { isAbsolute, relative, resolve } from "node:path"; +import { existsSync, realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import type { EventLog } from "./eventLog"; import type { NodPermissionDecision, NodPermissionKind } from "./protocol"; import type { Ask, NodSettings } from "./settings"; @@ -147,11 +148,22 @@ function key(kind: NodPermissionKind, subject: string): string { return `${kind}\u0000${subject}`; } +/** + * Compared as real paths: engines report symlink-resolved paths (`/private/tmp/…` for a + * worktree opened as `/tmp/…`), and a file that doesn't exist yet resolves through its + * nearest existing ancestor. + */ export function isInside(root: string, path: string): boolean { - const rel = relative(resolve(root), resolve(root, path)); + const rel = relative(realPath(resolve(root)), realPath(resolve(root, path))); return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); } +function realPath(path: string): string { + if (existsSync(path)) return realpathSync(path); + const parent = dirname(path); + return parent === path ? path : join(realPath(parent), basename(path)); +} + /** * Allowlist patterns are words: `*` alone matches any number of further words (`swift test * *`), `*` inside a word is a wildcard, and `a|b|c` is one word of alternatives diff --git a/NodRuntime/test/permissions.test.ts b/NodRuntime/test/permissions.test.ts index 32d31ef0..c9aac7c1 100644 --- a/NodRuntime/test/permissions.test.ts +++ b/NodRuntime/test/permissions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, realpathSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { EventLog } from "../src/eventLog"; @@ -77,6 +77,15 @@ describe("classification", () => { expect(isReadOnlyCommand("rm -rf build")).toBe(false); }); + test("worktree containment compares real paths, for files that don't exist yet too", () => { + const real = mkdtempSync(join(tmpdir(), "nod-real-")); + const link = `${real}-link`; + symlinkSync(real, link); + expect(isInside(link, join(realpathSync(real), "new", "hello.txt"))).toBe(true); + expect(isInside(real, join(link, "hello.txt"))).toBe(true); + expect(isInside(link, join(real, "..", "elsewhere.txt"))).toBe(false); + }); + test("worktree containment resolves .. and relative paths", () => { expect(isInside("/work/loop", "/work/loop/Sources/A.swift")).toBe(true); expect(isInside("/work/loop", "Sources/A.swift")).toBe(true); From b2e89aa1355c9e19644fcd4c4c5ee10c34b36fb1 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:46:12 -0700 Subject: [PATCH 06/13] Locate agent runtimes for packaged builds, package for the app bundle, honour --inherit Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- NodRuntime/packaging/entitlements.plist | 12 +++++ NodRuntime/scripts/package.sh | 28 +++++++++++ NodRuntime/src/agentRuntimes.ts | 64 +++++++++++++++++++++++++ NodRuntime/src/brief.ts | 29 +++++++++++ NodRuntime/src/claudeEngine.ts | 21 +++----- NodRuntime/src/copilotEngine.ts | 12 ++++- NodRuntime/src/diff.ts | 15 ++++-- NodRuntime/src/engine.ts | 2 + NodRuntime/src/hunks.ts | 14 ++++-- NodRuntime/src/main.ts | 13 +++-- NodRuntime/src/permissions.ts | 2 +- NodRuntime/src/protocol.ts | 5 +- NodRuntime/src/runtime.ts | 15 +++++- NodRuntime/test/agentRuntimes.test.ts | 42 ++++++++++++++++ NodRuntime/test/contract.test.ts | 55 +++++++++++++++++++++ NodRuntime/test/eventLog.test.ts | 2 +- NodRuntime/test/hunks.test.ts | 18 +++++-- NodRuntime/test/runtime.test.ts | 40 ++++++++++++++++ NodRuntime/test/swift/main.swift | 39 +++++++++++++++ 19 files changed, 391 insertions(+), 37 deletions(-) create mode 100644 NodRuntime/packaging/entitlements.plist create mode 100755 NodRuntime/scripts/package.sh create mode 100644 NodRuntime/src/agentRuntimes.ts create mode 100644 NodRuntime/src/brief.ts create mode 100644 NodRuntime/test/agentRuntimes.test.ts create mode 100644 NodRuntime/test/contract.test.ts create mode 100644 NodRuntime/test/swift/main.swift diff --git a/NodRuntime/packaging/entitlements.plist b/NodRuntime/packaging/entitlements.plist new file mode 100644 index 00000000..f2eb2ecb --- /dev/null +++ b/NodRuntime/packaging/entitlements.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/NodRuntime/scripts/package.sh b/NodRuntime/scripts/package.sh new file mode 100755 index 00000000..ec7b53d6 --- /dev/null +++ b/NodRuntime/scripts/package.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Builds graphcode-nod for the app bundle: one self-contained executable plus the Copilot +# runtime it speaks to, in a folder the app copies to Contents/Helpers/nod/. +# +# scripts/package.sh [out-dir] [bun-target] e.g. scripts/package.sh dist bun-darwin-arm64 +# +# SIGN_IDENTITY set: signs both with the hardened runtime and packaging/entitlements.plist — +# a compiled Bun binary needs JIT entitlements under the hardened runtime. +set -eu +cd "$(dirname "$0")/.." +out=${1:-dist} +target=${2:-bun-darwin-$(uname -m | sed 's/x86_64/x64/')} +platform=$(echo "$target" | sed 's/^bun-//') + +bun install --frozen-lockfile +mkdir -p "$out" +bun build src/main.ts --compile --minify --target="$target" --outfile "$out/graphcode-nod" + +runtime="node_modules/@github/copilot-sdk-$platform/prebuilds/$platform" +cp "$runtime/copilot-runtime" "$runtime/runtime.node" "$out/" + +if [ -n "${SIGN_IDENTITY:-}" ]; then + for binary in "$out/graphcode-nod" "$out/copilot-runtime" "$out/runtime.node"; do + codesign --force --options runtime --timestamp --entitlements packaging/entitlements.plist \ + --sign "$SIGN_IDENTITY" "$binary" + done +fi +ls -l "$out" diff --git a/NodRuntime/src/agentRuntimes.ts b/NodRuntime/src/agentRuntimes.ts new file mode 100644 index 00000000..ce00e842 --- /dev/null +++ b/NodRuntime/src/agentRuntimes.ts @@ -0,0 +1,64 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { supportDirectory } from "./settings"; + +/** + * Where each engine's agent runtime is. A `bun build --compile` binary cannot reach the SDKs' + * platform packages — they resolve to the build machine's `node_modules` — so a packaged + * graphcode-nod looks beside itself first, then for an installed CLI. + */ +export interface Locations { + env: Record; + /** The running executable: graphcode-nod when compiled, bun when run from source. */ + execPath: string; + /** This module's directory, for the source checkout's `node_modules`. */ + sourceDir: string; + home: string; + /** `~/.graphcode`: Setup can download Claude Code to `nod/bin/claude` for a Mac without it. */ + support: string; + exists: (path: string) => boolean; +} + +const here: Locations = { + env: process.env, + execPath: process.execPath, + sourceDir: import.meta.dir, + home: homedir(), + support: supportDirectory(), + exists: existsSync, +}; + +const platform = `${process.platform}-${process.arch}`; + +/** + * Claude Code: the human's own install first, so Nod shares its sign-in and its updates; + * then one shipped beside graphcode-nod or downloaded by Setup; then the SDK's bundled build + * (source checkouts). + */ +export function claudeExecutable(at: Locations = here): string | undefined { + return [ + at.env.GRAPHCODE_NOD_CLAUDE, + join(at.home, ".local", "bin", "claude"), + join(at.home, ".claude", "local", "claude"), + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + join(dirname(at.execPath), "claude"), + join(at.support, "nod", "bin", "claude"), + ].find((path): path is string => Boolean(path) && at.exists(path!)); +} + +/** + * The Copilot runtime the SDK speaks to: one shipped beside graphcode-nod (with its + * `runtime.node`), the SDK's own in a source checkout, then an installed `copilot` CLI. + */ +export function copilotRuntime(at: Locations = here): string | undefined { + return [ + at.env.GRAPHCODE_NOD_COPILOT, + join(dirname(at.execPath), "copilot-runtime"), + join(at.sourceDir, "..", "node_modules", "@github", `copilot-sdk-${platform}`, "prebuilds", platform, "copilot-runtime"), + "/opt/homebrew/bin/copilot", + "/usr/local/bin/copilot", + join(at.home, ".local", "bin", "copilot"), + ].find((path): path is string => Boolean(path) && at.exists(path!)); +} diff --git a/NodRuntime/src/brief.ts b/NodRuntime/src/brief.ts new file mode 100644 index 00000000..ef62a092 --- /dev/null +++ b/NodRuntime/src/brief.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import type { NodAttachment } from "./protocol"; + +/** `--inherit `: the brief a composite child or fork starts from (PROTOCOL.md, Inherited briefs). */ +export interface NodBrief { + kind: string; + fromNodeID?: string; + text: string; + attachments: NodAttachment[]; + fork?: { conversationID?: string; messageID: string }; +} + +export function readBrief(path: string): NodBrief { + const raw = JSON.parse(readFileSync(path, "utf8")) as Record; + if (typeof raw.text !== "string") throw new Error(`brief ${path} has no text`); + const fork = raw.fork as Record | undefined; + return { + kind: typeof raw.kind === "string" ? raw.kind : "handoff", + fromNodeID: typeof raw.fromNodeID === "string" ? raw.fromNodeID : undefined, + text: raw.text, + attachments: Array.isArray(raw.attachments) + ? (raw.attachments as NodAttachment[]).filter((a) => typeof a?.kind === "string" && typeof a?.reference === "string") + : [], + fork: + fork && typeof fork.messageID === "string" + ? { messageID: fork.messageID, conversationID: typeof fork.conversationID === "string" ? fork.conversationID : undefined } + : undefined, + }; +} diff --git a/NodRuntime/src/claudeEngine.ts b/NodRuntime/src/claudeEngine.ts index 1bbf07bc..baded163 100644 --- a/NodRuntime/src/claudeEngine.ts +++ b/NodRuntime/src/claudeEngine.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { extname, isAbsolute, join, resolve } from "node:path"; +import { extname, isAbsolute, resolve } from "node:path"; import { query, type HookCallback, @@ -110,7 +109,11 @@ export class ClaudeEngine implements Engine { const options: Options = { cwd: start.cwd, model: this.model, - ...(resuming ? { resume: this.conversationID } : { sessionId: this.conversationID }), + ...(resuming + ? { resume: this.conversationID } + : start.forkFrom + ? { resume: start.forkFrom, forkSession: true, sessionId: this.conversationID } + : { sessionId: this.conversationID }), permissionMode: "default", includePartialMessages: true, settingSources: ["user", "project", "local"], @@ -471,15 +474,3 @@ function classifyError(error: unknown): EngineFailure { if (/auth|login|api key|401|403/i.test(message)) return { kind: "signInExpired", message }; return { kind: "engineError", message }; } - -/** The Claude Code to run: the user's install when there is one, so Nod shares its login and updates. */ -export function findClaudeExecutable(env: Record = process.env): string | undefined { - const candidates = [ - env.GRAPHCODE_NOD_CLAUDE, - join(homedir(), ".local", "bin", "claude"), - join(homedir(), ".claude", "local", "claude"), - "/opt/homebrew/bin/claude", - "/usr/local/bin/claude", - ]; - return candidates.find((path): path is string => Boolean(path) && existsSync(path!)); -} diff --git a/NodRuntime/src/copilotEngine.ts b/NodRuntime/src/copilotEngine.ts index 3cb0beaf..bd52d241 100644 --- a/NodRuntime/src/copilotEngine.ts +++ b/NodRuntime/src/copilotEngine.ts @@ -48,6 +48,8 @@ export class CopilotEngine implements Engine { private premiumRequests = 0; private contextUsed = 0; private tools = new Map(); + /** Copilot folds `additionalContext` into the tool's result; it is cut back out for the card. */ + private injectedSteer?: string; constructor(private readonly options: CopilotEngineOptions) {} @@ -65,7 +67,9 @@ export class CopilotEngine implements Engine { hooks: { onPostToolUse: () => { const steer = this.turn?.callbacks.takeSteer(); - return steer ? { additionalContext: steer } : undefined; + if (!steer) return undefined; + this.injectedSteer = steer; + return { additionalContext: steer }; }, }, }; @@ -201,7 +205,11 @@ export class CopilotEngine implements Engine { const ok = data.success === true; const result = data.result as { content?: string } | undefined; const error = data.error as { message?: string } | undefined; - const output = ok ? (result?.content ?? "") : (error?.message ?? result?.content ?? "failed"); + let output = ok ? (result?.content ?? "") : (error?.message ?? result?.content ?? "failed"); + if (this.injectedSteer && output.includes(this.injectedSteer)) { + output = output.replace(this.injectedSteer, "").trim(); + this.injectedSteer = undefined; + } turn?.callbacks.toolResult(id, ok ? "ok" : "error", summarizeResult(tool?.name ?? "", output, !ok), output, tool ? Date.now() - tool.at : undefined); return; } diff --git a/NodRuntime/src/diff.ts b/NodRuntime/src/diff.ts index 80911067..945c0acf 100644 --- a/NodRuntime/src/diff.ts +++ b/NodRuntime/src/diff.ts @@ -9,9 +9,12 @@ export interface Hunk { type Op = { kind: " " | "-" | "+"; text: string }; -/** Lines as `split("\n")` gives them, so joining with "\n" restores the text byte for byte. */ +/** + * Lines as `split("\n")` gives them, so joining with "\n" restores the text byte for byte — + * except that empty text has no lines, so creating a file diffs as pure additions. + */ export function splitLines(text: string): string[] { - return text.split("\n"); + return text === "" ? [] : text.split("\n"); } /** Myers' O(ND) line diff, after trimming the common prefix and suffix. */ @@ -126,9 +129,13 @@ export function hunkHeader(hunk: Hunk): string { } export function hunkStats(hunk: Hunk): { added: number; removed: number } { + // A hunk that ends on a change, with no context after it, ends at end of file — and an + // empty last line there is the text's final newline, not a line of its own. + const last = hunk.lines[hunk.lines.length - 1]; + const lines = last === "+" || last === "-" ? hunk.lines.slice(0, -1) : hunk.lines; return { - added: hunk.lines.filter((l) => l.startsWith("+")).length, - removed: hunk.lines.filter((l) => l.startsWith("-")).length, + added: lines.filter((l) => l.startsWith("+")).length, + removed: lines.filter((l) => l.startsWith("-")).length, }; } diff --git a/NodRuntime/src/engine.ts b/NodRuntime/src/engine.ts index fe967dc4..2412b7b8 100644 --- a/NodRuntime/src/engine.ts +++ b/NodRuntime/src/engine.ts @@ -6,6 +6,8 @@ export interface EngineStart { model?: string; /** Continue this conversation instead of starting one. */ resume?: string; + /** Start as a copy of this conversation (a fork); engines that can't fork start fresh. */ + forkFrom?: string; /** Appended to the engine's own system prompt: the briefing and Nod's identity. */ systemAppend?: string; } diff --git a/NodRuntime/src/hunks.ts b/NodRuntime/src/hunks.ts index 97441f96..6a8fd255 100644 --- a/NodRuntime/src/hunks.ts +++ b/NodRuntime/src/hunks.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, relative } from "node:path"; import { applyHunk, hunkHeader, hunks, hunkStats, type Hunk } from "./diff"; import type { EventLog } from "./eventLog"; +import { realPath } from "./permissions"; import type { NodHunkDecision } from "./protocol"; export interface StagedHunk { @@ -40,10 +41,15 @@ export class HunkStager { private waiters = new Map void>(); private nextID = 1; + private readonly worktree: string; + constructor( private readonly log: EventLog, - private readonly worktree: string, - ) {} + worktree: string, + ) { + // Engines report real paths; relative names are only right against the real worktree. + this.worktree = realPath(worktree); + } /** Called when an auto-accepted hunk is rejected or sent back after it landed. */ onLateDecision?: (hunk: StagedHunk) => void; @@ -136,7 +142,7 @@ export class HunkStager { type: "hunkStaged", turn, hunkID: staged.id, - file: relative(this.worktree, file), + file: relative(this.worktree, realPath(file)), header: hunkHeader(hunk), diff: [hunkHeader(hunk), ...hunk.lines].join("\n"), added: stats.added, @@ -157,7 +163,7 @@ function outcome(staged: StagedHunk[], worktree: string): EditOutcome { const notes = staged .filter((h) => h.state !== "accepted") .map((h) => { - const where = `${relative(worktree, h.file)} ${hunkHeader(h.hunk)}`; + const where = `${relative(worktree, realPath(h.file))} ${hunkHeader(h.hunk)}`; const verb = h.state === "rejected" ? "was rejected" : "was sent back"; return `- ${where} ${verb}${h.note ? `: ${h.note}` : ""}`; }); diff --git a/NodRuntime/src/main.ts b/NodRuntime/src/main.ts index 73db3a35..9f7846cb 100644 --- a/NodRuntime/src/main.ts +++ b/NodRuntime/src/main.ts @@ -3,11 +3,13 @@ import { mkdirSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { createInterface } from "node:readline"; import { parseArgs } from "node:util"; -import { ClaudeEngine, findClaudeExecutable } from "./claudeEngine"; +import { claudeExecutable, copilotRuntime } from "./agentRuntimes"; +import { ClaudeEngine } from "./claudeEngine"; import { ControlSocket } from "./controlSocket"; import { CopilotEngine } from "./copilotEngine"; import { claudeCredentials, githubToken } from "./credentials"; import type { Engine } from "./engine"; +import { readBrief } from "./brief"; import { EventLog } from "./eventLog"; import { PresenceReporter } from "./presence"; import type { NodEngineKind } from "./protocol"; @@ -17,16 +19,17 @@ import { Transcript } from "./transcript"; const USAGE = `graphcode-nod --node --cwd [--engine claude|copilot] [--model ] [--loop-type main|goal|timed|turn|composite] [--goal-file ] - [--briefing ] [--resume ] [--prompt ] [--unattended] + [--briefing ] [--resume ] [--inherit ] + [--prompt ] [--unattended] graphcode-nod -p [--engine claude|copilot] [--model ]`; const loopTypes = new Set(["main", "goal", "timed", "turn", "composite"]); export function makeEngine(kind: NodEngineKind): Engine { if (kind === "copilot") { - return new CopilotEngine({ githubToken: githubToken(), cliPath: process.env.GRAPHCODE_NOD_COPILOT }); + return new CopilotEngine({ githubToken: githubToken(), cliPath: copilotRuntime() }); } - return new ClaudeEngine({ credentials: claudeCredentials(), executable: findClaudeExecutable() }); + return new ClaudeEngine({ credentials: claudeCredentials(), executable: claudeExecutable() }); } async function main(argv: string[]): Promise { @@ -41,6 +44,7 @@ async function main(argv: string[]): Promise { "goal-file": { type: "string" }, briefing: { type: "string" }, resume: { type: "string" }, + inherit: { type: "string" }, prompt: { type: "string" }, print: { type: "string", short: "p" }, unattended: { type: "boolean" }, @@ -93,6 +97,7 @@ async function main(argv: string[]): Promise { goal: values["goal-file"] ? readFileSync(values["goal-file"], "utf8") : undefined, briefing: values.briefing ? readFileSync(values.briefing, "utf8") : undefined, resume: values.resume, + inherit: values.inherit && !values.resume ? readBrief(values.inherit) : undefined, unattended: values.unattended || undefined, }); const control = new ControlSocket(join(stateDir, "control.sock"), (command) => runtime.handle(command)); diff --git a/NodRuntime/src/permissions.ts b/NodRuntime/src/permissions.ts index bf93daef..08078d1f 100644 --- a/NodRuntime/src/permissions.ts +++ b/NodRuntime/src/permissions.ts @@ -158,7 +158,7 @@ export function isInside(root: string, path: string): boolean { return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); } -function realPath(path: string): string { +export function realPath(path: string): string { if (existsSync(path)) return realpathSync(path); const parent = dirname(path); return parent === path ? path : join(realPath(parent), basename(path)); diff --git a/NodRuntime/src/protocol.ts b/NodRuntime/src/protocol.ts index 4ef1e1cf..d7ed17a8 100644 --- a/NodRuntime/src/protocol.ts +++ b/NodRuntime/src/protocol.ts @@ -109,7 +109,10 @@ export type NodCommand = | { type: "setModel"; model: string } | { type: "markGoalDone" }; -/** Swift's `.iso8601` date strategy rejects fractional seconds, so `at` never carries them. */ +/** + * Second precision, as in PROTOCOL.md: Foundation's `.iso8601` strategy accepts fractional + * seconds on current macOS but not on every release the app supports. + */ export function wireDate(date: Date): string { return date.toISOString().replace(/\.\d{3}Z$/, "Z"); } diff --git a/NodRuntime/src/runtime.ts b/NodRuntime/src/runtime.ts index 0cc5f300..926c561f 100644 --- a/NodRuntime/src/runtime.ts +++ b/NodRuntime/src/runtime.ts @@ -8,6 +8,7 @@ import { HunkStager, type StagedHunk } from "./hunks"; import { PermissionGate } from "./permissions"; import type { PresenceReporter } from "./presence"; import type { NodAttachment, NodCommand, NodDelivery, NodTurnOrigin } from "./protocol"; +import type { NodBrief } from "./brief"; import type { NodSettings } from "./settings"; import { describeTool } from "./tools"; @@ -26,6 +27,8 @@ export interface RuntimeOptions { goal?: string; briefing?: string; resume?: string; + /** `--inherit`: sent as turn 1 on a fresh start, never on a resume. */ + inherit?: NodBrief; /** Timed loops and composite children: nobody is there to answer an ask. */ unattended?: boolean; /** Consecutive "not yet" goal checks before Nod stops and waits for a human. */ @@ -97,7 +100,14 @@ export class NodRuntime { async start(firstPrompt?: string): Promise { const { engine, log, presence, nodeID, cwd } = this.options; const systemAppend = this.options.briefing ? `GraphCode briefing for this loop:\n\n${this.options.briefing}` : undefined; - const session = await engine.start({ cwd, model: this.options.model, resume: this.options.resume, systemAppend }); + const inherit = this.options.resume ? undefined : this.options.inherit; + const session = await engine.start({ + cwd, + model: this.options.model, + resume: this.options.resume, + forkFrom: inherit?.fork?.conversationID, + systemAppend, + }); this.conversationID = session.conversationID; this.model = session.model; log.append({ @@ -110,8 +120,9 @@ export class NodRuntime { this.writeConversation(); presence.sessionID(nodeID, session.conversationID, cwd); void presence.presence("idle"); + if (inherit) this.send(inherit.text, "queue", inherit.attachments, "handoff", inherit.fromNodeID); if (firstPrompt?.trim()) this.send(firstPrompt, "queue", [], "user"); - else if (this.goal && !this.options.resume) this.send(this.goal.goal, "queue", [], "user"); + else if (this.goal && !this.options.resume && !inherit) this.send(this.goal.goal, "queue", [], "user"); } /** Resolves once no turn is running and nothing is queued. */ diff --git a/NodRuntime/test/agentRuntimes.test.ts b/NodRuntime/test/agentRuntimes.test.ts new file mode 100644 index 00000000..02b7e92c --- /dev/null +++ b/NodRuntime/test/agentRuntimes.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test"; +import { claudeExecutable, copilotRuntime, type Locations } from "../src/agentRuntimes"; +import { claudeCredentials } from "../src/credentials"; + +function at(present: string[], env: Record = {}): Locations { + return { + env, + execPath: "/Applications/GraphCode.app/Contents/Helpers/graphcode-nod", + sourceDir: "/$bunfs/root", + home: "/Users/me", + support: "/Users/me/.graphcode", + exists: (path) => present.includes(path), + }; +} + +test("Claude Code: the override, then the human's install, then one shipped beside graphcode-nod", () => { + const shipped = "/Applications/GraphCode.app/Contents/Helpers/claude"; + expect(claudeExecutable(at([shipped, "/Users/me/.local/bin/claude"]))).toBe("/Users/me/.local/bin/claude"); + expect(claudeExecutable(at([shipped]))).toBe(shipped); + expect(claudeExecutable(at([shipped, "/x/claude"], { GRAPHCODE_NOD_CLAUDE: "/x/claude" }))).toBe("/x/claude"); + expect(claudeExecutable(at(["/Users/me/.graphcode/nod/bin/claude"]))).toBe("/Users/me/.graphcode/nod/bin/claude"); + expect(claudeExecutable(at([]))).toBeUndefined(); +}); + +test("Copilot: the runtime shipped beside graphcode-nod wins over an installed CLI", () => { + const shipped = "/Applications/GraphCode.app/Contents/Helpers/copilot-runtime"; + expect(copilotRuntime(at([shipped, "/opt/homebrew/bin/copilot"]))).toBe(shipped); + expect(copilotRuntime(at(["/opt/homebrew/bin/copilot"]))).toBe("/opt/homebrew/bin/copilot"); + expect(copilotRuntime(at([]))).toBeUndefined(); +}); + +test("Claude credentials: Nod's Keychain entry, then the environment, then Claude Code's own login", () => { + const keychain = (entries: Record) => (service: string, account?: string) => entries[`${service}/${account ?? ""}`]; + expect(claudeCredentials(keychain({ "app.graphcode.nod/anthropic-api-key": "sk-1" }), {}, "/nohome")).toEqual({ + env: { ANTHROPIC_API_KEY: "sk-1" }, + source: "nod-api-key", + }); + expect(claudeCredentials(keychain({ "app.graphcode.nod/claude-oauth-token": "t" }), {}, "/nohome").env).toEqual({ CLAUDE_CODE_OAUTH_TOKEN: "t" }); + expect(claudeCredentials(keychain({}), { ANTHROPIC_API_KEY: "x" }, "/nohome").source).toBe("environment"); + expect(claudeCredentials(keychain({ "Claude Code-credentials/": "{}" }), {}, "/nohome")).toEqual({ env: {}, source: "claude-code-login" }); + expect(claudeCredentials(keychain({}), {}, "/nohome").source).toBe("none"); +}); diff --git a/NodRuntime/test/contract.test.ts b/NodRuntime/test/contract.test.ts new file mode 100644 index 00000000..3c36fee5 --- /dev/null +++ b/NodRuntime/test/contract.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { EventLog } from "../src/eventLog"; +import { parseCommand, type NodEvent } from "../src/protocol"; + +const protocolSwift = join(import.meta.dir, "..", "..", "GraphcodeKit", "Sources", "Domain", "NodProtocol.swift"); +const swiftc = Bun.which("swiftc"); + +const everyEvent: NodEvent[] = [ + { type: "sessionStarted", engine: "copilot", model: "gpt-5", conversationID: "c", resumed: true }, + { type: "turnStarted", turn: 1, origin: "goalCheck" }, + { type: "userMessage", id: "u", text: "hi", delivery: "queue", attachments: [{ kind: "file", reference: "A.swift" }], fromNodeID: "9B3408F9-9B16-447F-A439-FC2AA8C02D06" }, + { type: "assistantText", turn: 1, messageID: "m", delta: "Found it.", final: true }, + { type: "toolCall", turn: 1, callID: "c1", tool: "Grep", title: 'Search "UsageGate"' }, + { type: "toolResult", callID: "c1", status: "ok", summary: "6 hits", output: "…", durationMs: 400 }, + { type: "hunkStaged", turn: 1, hunkID: "h1", file: "A.swift", header: "@@ -1,1 +1,1 @@", diff: "@@\n-a\n+b", added: 1, removed: 1, autoAccepted: false }, + { type: "hunkResolved", hunkID: "h1", decision: "comment", note: "use 51" }, + { type: "permissionAsked", askID: "p1", kind: "editOutsideWorktree", subject: "/etc/hosts", reason: "r", answerableFromCard: false }, + { type: "permissionResolved", askID: "p1", decision: "alwaysAllow" }, + { type: "goalCheck", turn: 1, evaluatorModel: "haiku", clauses: [{ text: "a", met: true, evidence: "e" }, { text: "b", met: false }], met: false }, + { type: "turnEnded", turn: 1, filesChanged: 1, added: 2, removed: 1, summary: "s" }, + { type: "usage", inputTokens: 10, outputTokens: 5, costUSD: 0.01, premiumRequests: 1, contextUsed: 0.42 }, + { type: "planProposed", planID: "p", title: "t", steps: [{ id: "1", text: "x", files: [], editedByHuman: false }] }, + { type: "mailDraft", draftID: "d", toNodeID: "9B3408F9-9B16-447F-A439-FC2AA8C02D06", text: "402" }, + { type: "compacted", fromTurn: 1, throughTurn: 9 }, + { type: "activity", line: "Running swift test · turn 4" }, + { type: "failure", kind: "spendCap", message: "m" }, +]; + +test.skipIf(!swiftc)( + "the app's NodProtocol.swift decodes every event the runtime writes, and the runtime parses every command the app sends", + () => { + const dir = mkdtempSync(join(tmpdir(), "nod-contract-")); + const log = new EventLog(join(dir, "events.jsonl")); + for (const event of everyEvent) log.append(event); + const binary = join(dir, "contract"); + const build = Bun.spawnSync([swiftc!, "-O", "-module-name", "Contract", protocolSwift, join(import.meta.dir, "swift", "main.swift"), "-o", binary]); + expect(build.stderr.toString()).not.toContain("error:"); + expect(build.exitCode).toBe(0); + const run = Bun.spawnSync([binary, log.path]); + const out = run.stdout.toString().trim().split("\n"); + expect(out.filter((l) => !l.startsWith("OK") && !l.startsWith("CMD"))).toEqual([]); + expect(out.filter((l) => l.startsWith("OK")).length).toBe(everyEvent.length); + expect(run.exitCode).toBe(0); + const commands = out.filter((l) => l.startsWith("CMD ")).map((l) => parseCommand(l.slice(4))); + expect(commands.map((c) => c.type)).toEqual([ + "send", "stop", "resolveHunk", "resolvePermission", "runPlan", "fork", "sendDraft", "compact", "setModel", "markGoalDone", + ]); + expect(commands[0]).toMatchObject({ delivery: "steer", attachments: [{ kind: "loopTranscript", reference: "A1", label: "Pricing" }] }); + expect(commands[4]).toMatchObject({ steps: [{ files: ["A.swift"], size: "small", editedByHuman: true }] }); + }, + 180_000, +); diff --git a/NodRuntime/test/eventLog.test.ts b/NodRuntime/test/eventLog.test.ts index d4d5b3d3..291e53da 100644 --- a/NodRuntime/test/eventLog.test.ts +++ b/NodRuntime/test/eventLog.test.ts @@ -73,7 +73,7 @@ describe("EventLog", () => { expect(seen).toEqual([1]); }); - test("wireDate drops milliseconds, which Swift's .iso8601 strategy rejects", () => { + test("wireDate drops milliseconds, as PROTOCOL.md writes dates", () => { expect(wireDate(new Date("2026-10-01T20:00:04.999Z"))).toBe("2026-10-01T20:00:04Z"); }); }); diff --git a/NodRuntime/test/hunks.test.ts b/NodRuntime/test/hunks.test.ts index 47cfec87..11e0979c 100644 --- a/NodRuntime/test/hunks.test.ts +++ b/NodRuntime/test/hunks.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { applyHunk, diffLines, hunks, HunkConflict } from "../src/diff"; +import { applyHunk, diffLines, hunks, HunkConflict, hunkStats } from "../src/diff"; import { EventLog } from "../src/eventLog"; import { HunkStager } from "../src/hunks"; import type { NodEventRecord } from "../src/protocol"; @@ -55,7 +55,10 @@ describe("diff", () => { test("creating a file is one hunk of additions", () => { const [hunk] = hunks("", "a\nb\n"); - expect(hunk!.lines).toEqual(["+a", "+b", " "]); + expect(hunk!.lines).toEqual(["+a", "+b", "+"]); + expect(hunks("", "hello nod").map((h) => [h.oldLines, h.newLines])).toEqual([[0, 1]]); + expect(hunkStats(hunk!)).toEqual({ added: 2, removed: 0 }); + expect(hunkStats(hunks("a\n\n", "")[0]!)).toEqual({ added: 0, removed: 2 }); expect(applyHunk("", hunk!)).toBe("a\nb\n"); }); @@ -124,6 +127,15 @@ describe("HunkStager", () => { expect(readFileSync(file, "utf8")).toBe(edit(base, [["line 15", "human was here"], ["line 3", "three"]])); }); + test("names files relative to the real worktree, however the worktree was opened", async () => { + const { log, records, dir } = setup(); + const link = `${dir}-link`; + symlinkSync(dir, link); + const stager = new HunkStager(log, link); + void stager.stageEdit(join(realpathSync(dir), "Sources", "A.swift"), "x\n", 1, true); + expect(records.find((r) => r.type === "hunkStaged")).toMatchObject({ file: "Sources/A.swift", added: 1, removed: 0 }); + }); + test("rejecting everything leaves the file, and a new file, untouched", async () => { const { stager, dir } = setup(); const fresh = join(dir, "New.swift"); diff --git a/NodRuntime/test/runtime.test.ts b/NodRuntime/test/runtime.test.ts index 111dc4be..8d66943b 100644 --- a/NodRuntime/test/runtime.test.ts +++ b/NodRuntime/test/runtime.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { readBrief, type NodBrief } from "../src/brief"; import { EventLog } from "../src/eventLog"; import { PresenceReporter } from "../src/presence"; import type { NodEventRecord } from "../src/protocol"; @@ -11,6 +12,8 @@ import { FakeEngine, tick, until } from "./fakeEngine"; interface Setup { loopType?: LoopType; + inherit?: NodBrief; + resume?: string; goal?: string; settings?: Partial; unattended?: boolean; @@ -37,6 +40,8 @@ function setup(engine: FakeEngine, options: Setup = {}) { presence, goal: options.goal, unattended: options.unattended, + inherit: options.inherit, + resume: options.resume, }); const types = () => records.map((r) => r.type); return { runtime, records, labels, cwd, stateDir, support, types, presence }; @@ -307,6 +312,41 @@ describe("NodRuntime", () => { }); }); +describe("inherited briefs", () => { + const brief: NodBrief = { + kind: "fork", + fromNodeID: "9B3408F9-9B16-447F-A439-FC2AA8C02D06", + text: "Try the other approach: check the cap inside the handler.", + attachments: [{ kind: "loopTranscript", reference: "9B3408F9-9B16-447F-A439-FC2AA8C02D06" }], + fork: { conversationID: "parent-conv", messageID: "m7" }, + }; + + test("a fresh start sends the brief as turn 1, a handoff from its loop, forking the parent conversation", async () => { + const engine = new FakeEngine(); + const { runtime, records } = setup(engine, { inherit: brief, goal: "it works" }); + await runtime.start(); + await until(() => engine.turns.length > 0); + expect(engine.started?.forkFrom).toBe("parent-conv"); + expect(records.find((r) => r.type === "userMessage")).toMatchObject({ text: brief.text, fromNodeID: brief.fromNodeID, attachments: brief.attachments }); + expect(records.find((r) => r.type === "turnStarted")).toMatchObject({ origin: "handoff" }); + expect(engine.turns[0]).toBe(brief.text); + }); + + test("a resume never replays the brief", async () => { + const engine = new FakeEngine(); + const { runtime, records } = setup(engine, { inherit: brief, resume: "own-conv" }); + await runtime.start(); + expect(engine.started?.forkFrom).toBeUndefined(); + expect(records.some((r) => r.type === "userMessage")).toBe(false); + }); + + test("readBrief takes the file's fields and drops malformed attachments", async () => { + const path = join(mkdtempSync(join(tmpdir(), "nod-brief-")), "brief.json"); + writeFileSync(path, JSON.stringify({ v: 1, kind: "compositeChild", text: "Do step 3", attachments: [{ kind: "file", reference: "A.swift" }, { bad: 1 }] })); + expect(readBrief(path)).toEqual({ kind: "compositeChild", fromNodeID: undefined, text: "Do step 3", attachments: [{ kind: "file", reference: "A.swift" }], fork: undefined }); + }); +}); + describe("goal loops", () => { const notMet = '{"clauses":[{"index":0,"met":true,"evidence":"4 / 4 routes"},{"index":1,"met":false,"evidence":"1 failure"}]}'; const met = '{"clauses":[{"index":0,"met":true},{"index":1,"met":true,"evidence":"31 tests pass"}]}'; diff --git a/NodRuntime/test/swift/main.swift b/NodRuntime/test/swift/main.swift new file mode 100644 index 00000000..849219c1 --- /dev/null +++ b/NodRuntime/test/swift/main.swift @@ -0,0 +1,39 @@ +import Foundation + +// Decodes events.jsonl written by the TypeScript runtime with the app's own NodProtocol, and +// prints NodCommand JSON encoded by Swift for the TypeScript parser to read back. +let arguments = CommandLine.arguments +let data = try Data(contentsOf: URL(fileURLWithPath: arguments[1])) +let decoder = NodProtocol.makeDecoder() +var failures = 0 +for line in data.split(separator: UInt8(ascii: "\n")) { + do { + let record = try decoder.decode(NodEventRecord.self, from: Data(line)) + if case .unknown(let type) = record.event { + print("UNKNOWN \(type)") + failures += 1 + } else { + print("OK \(record.seq) \(record.event.type)") + } + } catch { + print("FAIL \(String(decoding: line, as: UTF8.self)) \(error)") + failures += 1 + } +} +let encoder = NodProtocol.makeEncoder() +let commands: [NodCommand] = [ + .send(.init(text: "hi", delivery: .steer, attachments: [.init(kind: .loopTranscript, reference: "A1", label: "Pricing")])), + .stop, + .resolveHunk(.init(hunkID: "h1", decision: .comment, note: "use 51")), + .resolvePermission(.init(askID: "p1", decision: .alwaysAllow)), + .runPlan(.init(planID: "p", steps: [.init(id: "1", text: "do", files: ["A.swift"], size: .small, editedByHuman: true)], mode: .here)), + .fork(.init(messageID: "m")), + .sendDraft(.init(draftID: "d", text: "402")), + .compact, + .setModel(.init(model: "opus")), + .markGoalDone, +] +for command in commands { + print("CMD " + String(decoding: try encoder.encode(command), as: UTF8.self)) +} +exit(failures == 0 ? 0 : 1) From 5b1e6a91db729306046a2a888da3f437f1e2ed5e Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:47:18 -0700 Subject: [PATCH 07/13] Document the Nod runtime and its packaging, and test it in CI Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- .github/workflows/nod-runtime.yml | 32 ++++++++++ NodRuntime/README.md | 99 ++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/nod-runtime.yml diff --git a/.github/workflows/nod-runtime.yml b/.github/workflows/nod-runtime.yml new file mode 100644 index 00000000..d6f19640 --- /dev/null +++ b/.github/workflows/nod-runtime.yml @@ -0,0 +1,32 @@ +name: Nod runtime + +on: + workflow_dispatch: + pull_request: + paths: + - "NodRuntime/**" + - "GraphcodeKit/Sources/Domain/NodProtocol.swift" + - ".github/workflows/nod-runtime.yml" + +permissions: + contents: read + +jobs: + test: + name: bun test + typecheck + # macOS for the contract test, which compiles NodProtocol.swift with swiftc. + runs-on: macos-26 + defaults: + run: + working-directory: NodRuntime + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - name: Install Bun + run: | + curl -fsSL https://bun.sh/install | bash -s "bun-v1.4.2" + echo "$HOME/.bun/bin" >> "$GITHUB_PATH" + - run: bun install --frozen-lockfile + - run: bun ./node_modules/.bin/tsc --noEmit -p . + - run: bun test + - name: Compile + run: bun build src/main.ts --compile --outfile dist/graphcode-nod && ./dist/graphcode-nod --help diff --git a/NodRuntime/README.md b/NodRuntime/README.md index 0aca2377..4b07fe23 100644 --- a/NodRuntime/README.md +++ b/NodRuntime/README.md @@ -49,10 +49,105 @@ prose version. The two change together. ``` graphcode-nod --node --cwd [--engine claude|copilot] [--model ] [--loop-type main|goal|timed|turn|composite] [--goal-file ] - [--briefing ] [--resume ] [--prompt ] -graphcode-nod -p [--model ] # one-shot print mode (titles, summaries) + [--briefing ] [--resume ] [--inherit ] + [--prompt ] [--unattended] +graphcode-nod -p [--engine claude|copilot] [--model ] # one-shot print mode ``` +## The runtime (`src/`) + +| Module | Does | +|---|---| +| `main.ts` | argv, print mode, PTY lines as queued sends, signals (Ctrl-C stops the turn, then quits) | +| `runtime.ts` | the turn queue, steer, stop, goal checks, spend cap, failures, presence | +| `engine.ts` | the one interface both engines implement; nothing above it branches on the engine | +| `claudeEngine.ts` · `copilotEngine.ts` | the Claude Agent SDK and GitHub Copilot SDK adapters | +| `eventLog.ts` · `controlSocket.ts` · `protocol.ts` | `events.jsonl`, `control.sock`, and the wire types mirroring `NodProtocol.swift` | +| `hunks.ts` · `diff.ts` | staged edits: Myers diff, git-style hunks, apply/reverse anywhere the context now sits | +| `permissions.ts` · `settings.ts` | the gate over `NodSettings` (read from `settings.json`'s `nod` key) and the shell allowlist | +| `goal.ts` | splits the goal into clauses and judges them every time the agent stops | +| `presence.ts` | the `presence`/`activity`/`usage` zmx labels and `sessions/.id` that `PresenceHooks` writes | +| `credentials.ts` · `agentRuntimes.ts` | Keychain sign-ins, and where each engine's agent runtime is | +| `brief.ts` | `--inherit ` (PROTOCOL.md, Inherited briefs) | + +```sh +bun install && bun test # unit tests run against fake engines; the contract test compiles NodProtocol.swift +bun ./node_modules/.bin/tsc --noEmit -p . +bun src/main.ts -p "hello" # from source +``` + +**How the pieces behave** + +- **Turns** run one at a time. `send {queue}` waits for the turn to end; `send {steer}` is + delivered at the next tool boundary through the engine's PostToolUse `additionalContext`, + and a steer that never meets a tool boundary runs next as a `steer` turn. `stop` interrupts + the turn, rejects its pending hunks, denies its open asks and clears the queue. +- **Edits** are held until every hunk of the edit has a decision. All accepted, the agent's + own tool writes the edit; some accepted, the runtime writes those to the file as it is now + and the tool is refused with the reviewer's notes. In Auto mode hunks arrive accepted; a + later reject or comment reverse-applies the hunk and tells the agent, until the turn ends. + An edit the runtime can't diff (a notebook, an `old_string` not in the file) is refused in + review mode rather than written unreviewed. +- **The gate** is authoritative: the Claude engine forces every Bash/edit/web/MCP call + through `canUseTool` with a PreToolUse `ask`, so the human's own Claude Code allow rules + can't bypass it; Copilot routes every shell/write/url/mcp request through one handler. + Allowlisted commands run; compound commands must be allowlisted in every part, and + command substitutions never are. A network command is asked about as `network`. Timed + loops (and `--unattended` composite children) fail the run with `permissionUnavailable` + instead of waiting. *Always allow* holds for the session; persisting it to the allowlist + is the app's job when it sees `permissionResolved {alwaysAllow}`. +- **The goal** (`--goal-file`) is re-split into clauses on every stop and judged by the + evaluator model (`goalEvaluatorModel`, default Haiku on Claude) from the agent's last + message and recent tool results. Anything the judge doesn't clearly mark met counts as + not met. Not met queues a `goalCheck` turn naming the unmet clauses; after 20 in a row + without a human message Nod waits. `markGoalDone` records a met check. The runtime does + not resolve the loop itself: the daemon reads `goalCheck {met: true}`. +- **Spend cap** (`spendCapUSD`) applies to unattended loops, per run (a run starts with a + turn a human or timer started). Claude's cost is exact at each turn end and estimated + mid-turn from tokens, scaled to the last exact figure, so the cap can stop mid-turn. + Copilot reports premium requests, which its plan caps. +- **Context** is reported as `usage.contextUsed`; at 95% Nod compacts after the turn. +- **Resume**: `--resume ` continues the engine conversation and appends to + the same `events.jsonl`, continuing its `seq`. `conversation.json` holds engine, model and + conversation id. + +## Sign-in + +Keychain service `app.graphcode.nod` (`NodSettings.keychainService`), accounts written by +Settings › Agents › Nod: + +| Account | Used as | +|---|---| +| `anthropic-api-key` | `ANTHROPIC_API_KEY` for Claude Code | +| `claude-oauth-token` | `CLAUDE_CODE_OAUTH_TOKEN` for Claude Code | +| `github-token` | the Copilot SDK's `gitHubToken` | + +With no Nod entry the engines reuse the Mac's existing logins: Claude Code's own +(`Claude Code-credentials` in the Keychain), and the Copilot CLI's. + +## Packaging + +**Decision: one `bun build --compile` executable, shipped with the Copilot runtime, in +`Contents/Helpers/nod/`; Claude Code is not shipped.** + +`scripts/package.sh [out-dir] [bun-target]` builds `graphcode-nod` (~64 MB, no Node or Bun +needed on the Mac) and copies the Copilot SDK's `copilot-runtime` + `runtime.node` +(~86 MB) beside it. With `SIGN_IDENTITY` set it signs all three with the hardened runtime +and `packaging/entitlements.plist` — a compiled Bun binary needs the JIT entitlements. + +A compiled binary cannot load the SDKs' platform packages (they resolve to the build +machine's `node_modules`), so `agentRuntimes.ts` looks for each agent runtime explicitly: + +| Engine | Search order | +|---|---| +| Claude | `$GRAPHCODE_NOD_CLAUDE` → the human's Claude Code (`~/.local/bin`, Homebrew, …) → `claude` beside graphcode-nod → `~/.graphcode/nod/bin/claude` | +| Copilot | `$GRAPHCODE_NOD_COPILOT` → `copilot-runtime` beside graphcode-nod → the SDK's own (source checkouts) → an installed `copilot` CLI | + +Claude Code is left out because it is a 228 MB binary that most people choosing the +Claude engine already have, and using theirs shares their sign-in and their updates. For a +Mac without it, Setup can place the SDK's platform binary at `~/.graphcode/nod/bin/claude`, +or packaging can copy it beside graphcode-nod; both are already on the search path. + ## Capabilities | Capability | Nod | Why | From be1379c5824bf6ea4871968b6683f420b25455b1 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:48:02 -0700 Subject: [PATCH 08/13] Trim Copilot's hook wrapper from tool results Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- NodRuntime/src/copilotEngine.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/NodRuntime/src/copilotEngine.ts b/NodRuntime/src/copilotEngine.ts index bd52d241..669e7cb9 100644 --- a/NodRuntime/src/copilotEngine.ts +++ b/NodRuntime/src/copilotEngine.ts @@ -207,7 +207,10 @@ export class CopilotEngine implements Engine { const error = data.error as { message?: string } | undefined; let output = ok ? (result?.content ?? "") : (error?.message ?? result?.content ?? "failed"); if (this.injectedSteer && output.includes(this.injectedSteer)) { - output = output.replace(this.injectedSteer, "").trim(); + output = output + .replace(this.injectedSteer, "") + .replace(/\s*Additional guidance from postToolUse hooks:\s*$/, "") + .trim(); this.injectedSteer = undefined; } turn?.callbacks.toolResult(id, ok ? "ok" : "error", summarizeResult(tool?.name ?? "", output, !ok), output, tool ? Date.now() - tool.at : undefined); From 1c3c919504159fcb71de1ddbf7465e3bae09b051 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:49:26 -0700 Subject: [PATCH 09/13] Find bundled agent runtimes through a symlinked graphcode-nod Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- NodRuntime/src/agentRuntimes.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/NodRuntime/src/agentRuntimes.ts b/NodRuntime/src/agentRuntimes.ts index ce00e842..4c733009 100644 --- a/NodRuntime/src/agentRuntimes.ts +++ b/NodRuntime/src/agentRuntimes.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { supportDirectory } from "./settings"; @@ -10,7 +10,11 @@ import { supportDirectory } from "./settings"; */ export interface Locations { env: Record; - /** The running executable: graphcode-nod when compiled, bun when run from source. */ + /** + * The running executable, symlinks resolved: graphcode-nod when compiled, bun when run from + * source. The daemon launches `/bin/graphcode-nod`, a symlink into the app + * bundle, and the runtimes ship beside the real file. + */ execPath: string; /** This module's directory, for the source checkout's `node_modules`. */ sourceDir: string; @@ -22,7 +26,7 @@ export interface Locations { const here: Locations = { env: process.env, - execPath: process.execPath, + execPath: resolvedExecPath(), sourceDir: import.meta.dir, home: homedir(), support: supportDirectory(), @@ -31,6 +35,14 @@ const here: Locations = { const platform = `${process.platform}-${process.arch}`; +function resolvedExecPath(): string { + try { + return realpathSync(process.execPath); + } catch { + return process.execPath; + } +} + /** * Claude Code: the human's own install first, so Nod shares its sign-in and its updates; * then one shipped beside graphcode-nod or downloaded by Setup; then the SDK's bundled build From de2e91b0ec8e7f8dbb4d0e37726adc33aca5517c Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:56:49 -0700 Subject: [PATCH 10/13] Sign Claude in with Nod's API key only, and ship the bundled Claude Code Policy #1190: no claude.ai login. Claude Code runs with inherited credentials stripped and a config directory of Nod's own, ships beside graphcode-nod, and a rejected key fails the turn instead of retrying. Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- NodRuntime/README.md | 42 +++++++++++--------- NodRuntime/scripts/package.sh | 11 ++++-- NodRuntime/src/agentRuntimes.ts | 24 ++++------- NodRuntime/src/claudeEngine.ts | 34 +++++++++++++--- NodRuntime/src/credentials.ts | 57 ++++++++++++--------------- NodRuntime/src/main.ts | 8 +++- NodRuntime/test/agentRuntimes.test.ts | 47 +++++++++++++--------- 7 files changed, 126 insertions(+), 97 deletions(-) diff --git a/NodRuntime/README.md b/NodRuntime/README.md index 4b07fe23..a1b676f1 100644 --- a/NodRuntime/README.md +++ b/NodRuntime/README.md @@ -113,41 +113,47 @@ bun src/main.ts -p "hello" # from source ## Sign-in -Keychain service `app.graphcode.nod` (`NodSettings.keychainService`), accounts written by +Keychain service `app.graphcode.nod` (`NodSettings.keychainService`), written by Settings › Agents › Nod: | Account | Used as | |---|---| -| `anthropic-api-key` | `ANTHROPIC_API_KEY` for Claude Code | -| `claude-oauth-token` | `CLAUDE_CODE_OAUTH_TOKEN` for Claude Code | -| `github-token` | the Copilot SDK's `gitHubToken` | +| `anthropic-api-key` | the Claude engine's only credential, passed to Claude Code as `ANTHROPIC_API_KEY` | +| `github-token` | the Copilot SDK's `gitHubToken`; without it, the Copilot CLI's own GitHub login | -With no Nod entry the engines reuse the Mac's existing logins: Claude Code's own -(`Claude Code-credentials` in the Keychain), and the Copilot CLI's. +The Claude engine never uses a claude.ai login (policy, mailroom #1190). Claude Code runs +with every inherited credential variable stripped and `CLAUDE_CONFIG_DIR` set to +`~/.graphcode/nod/claude`, so it cannot find the human's login either. Without a key every +turn fails with `signInExpired`; a key the API rejects fails the turn within seconds instead +of sitting in Claude Code's retry backoff. ## Packaging -**Decision: one `bun build --compile` executable, shipped with the Copilot runtime, in -`Contents/Helpers/nod/`; Claude Code is not shipped.** +**Decision: one `bun build --compile` executable plus both engines' agent runtimes, in +`Contents/Helpers/nod/`. Nothing is installed on the Mac and nothing is found on PATH.** -`scripts/package.sh [out-dir] [bun-target]` builds `graphcode-nod` (~64 MB, no Node or Bun -needed on the Mac) and copies the Copilot SDK's `copilot-runtime` + `runtime.node` -(~86 MB) beside it. With `SIGN_IDENTITY` set it signs all three with the hardened runtime -and `packaging/entitlements.plist` — a compiled Bun binary needs the JIT entitlements. +`scripts/package.sh [out-dir] [bun-target]` writes: + +| File | Size | From | +|---|---|---| +| `graphcode-nod` | ~64 MB | `bun build --compile` (no Node or Bun needed) | +| `claude` | ~228 MB | the Claude Code the Agent SDK bundles (keeps Anthropic's signature) | +| `copilot-runtime`, `runtime.node` | ~86 MB | the Copilot SDK's runtime | + +With `SIGN_IDENTITY` set it signs `graphcode-nod` and the Copilot runtime with the hardened +runtime and `packaging/entitlements.plist` (a compiled Bun binary needs the JIT +entitlements). The daemon launches `/bin/graphcode-nod`, a symlink into the +bundle placed by the launch side; the runtime resolves its real path before looking beside +itself. A compiled binary cannot load the SDKs' platform packages (they resolve to the build machine's `node_modules`), so `agentRuntimes.ts` looks for each agent runtime explicitly: | Engine | Search order | |---|---| -| Claude | `$GRAPHCODE_NOD_CLAUDE` → the human's Claude Code (`~/.local/bin`, Homebrew, …) → `claude` beside graphcode-nod → `~/.graphcode/nod/bin/claude` | +| Claude | `$GRAPHCODE_NOD_CLAUDE` (a deliberate override) → `claude` beside graphcode-nod → the SDK's own (source checkouts) | | Copilot | `$GRAPHCODE_NOD_COPILOT` → `copilot-runtime` beside graphcode-nod → the SDK's own (source checkouts) → an installed `copilot` CLI | -Claude Code is left out because it is a 228 MB binary that most people choosing the -Claude engine already have, and using theirs shares their sign-in and their updates. For a -Mac without it, Setup can place the SDK's platform binary at `~/.graphcode/nod/bin/claude`, -or packaging can copy it beside graphcode-nod; both are already on the search path. - ## Capabilities | Capability | Nod | Why | diff --git a/NodRuntime/scripts/package.sh b/NodRuntime/scripts/package.sh index ec7b53d6..edbfd2b0 100755 --- a/NodRuntime/scripts/package.sh +++ b/NodRuntime/scripts/package.sh @@ -1,11 +1,13 @@ #!/bin/sh -# Builds graphcode-nod for the app bundle: one self-contained executable plus the Copilot -# runtime it speaks to, in a folder the app copies to Contents/Helpers/nod/. +# Builds graphcode-nod for the app bundle: one self-contained executable plus both engines' +# agent runtimes — the Claude Code the Agent SDK bundles and the Copilot runtime — in a +# folder the app copies to Contents/Helpers/nod/. Nothing is installed on the Mac. # # scripts/package.sh [out-dir] [bun-target] e.g. scripts/package.sh dist bun-darwin-arm64 # -# SIGN_IDENTITY set: signs both with the hardened runtime and packaging/entitlements.plist — -# a compiled Bun binary needs JIT entitlements under the hardened runtime. +# SIGN_IDENTITY set: signs graphcode-nod and the Copilot runtime with the hardened runtime and +# packaging/entitlements.plist — a compiled Bun binary needs JIT entitlements under the +# hardened runtime. Claude Code keeps Anthropic's own signature. set -eu cd "$(dirname "$0")/.." out=${1:-dist} @@ -18,6 +20,7 @@ bun build src/main.ts --compile --minify --target="$target" --outfile "$out/grap runtime="node_modules/@github/copilot-sdk-$platform/prebuilds/$platform" cp "$runtime/copilot-runtime" "$runtime/runtime.node" "$out/" +cp "node_modules/@anthropic-ai/claude-agent-sdk-$platform/claude" "$out/" if [ -n "${SIGN_IDENTITY:-}" ]; then for binary in "$out/graphcode-nod" "$out/copilot-runtime" "$out/runtime.node"; do diff --git a/NodRuntime/src/agentRuntimes.ts b/NodRuntime/src/agentRuntimes.ts index 4c733009..511016af 100644 --- a/NodRuntime/src/agentRuntimes.ts +++ b/NodRuntime/src/agentRuntimes.ts @@ -1,12 +1,11 @@ import { existsSync, realpathSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; -import { supportDirectory } from "./settings"; /** * Where each engine's agent runtime is. A `bun build --compile` binary cannot reach the SDKs' * platform packages — they resolve to the build machine's `node_modules` — so a packaged - * graphcode-nod looks beside itself first, then for an installed CLI. + * graphcode-nod looks beside itself. */ export interface Locations { env: Record; @@ -19,8 +18,6 @@ export interface Locations { /** This module's directory, for the source checkout's `node_modules`. */ sourceDir: string; home: string; - /** `~/.graphcode`: Setup can download Claude Code to `nod/bin/claude` for a Mac without it. */ - support: string; exists: (path: string) => boolean; } @@ -29,7 +26,6 @@ const here: Locations = { execPath: resolvedExecPath(), sourceDir: import.meta.dir, home: homedir(), - support: supportDirectory(), exists: existsSync, }; @@ -44,20 +40,14 @@ function resolvedExecPath(): string { } /** - * Claude Code: the human's own install first, so Nod shares its sign-in and its updates; - * then one shipped beside graphcode-nod or downloaded by Setup; then the SDK's bundled build - * (source checkouts). + * Claude Code: the build shipped beside graphcode-nod, or the SDK's own in a source checkout + * (undefined here, which lets the SDK find it). Nod never needs a Claude Code the human + * installed; `GRAPHCODE_NOD_CLAUDE` can point at one deliberately. */ export function claudeExecutable(at: Locations = here): string | undefined { - return [ - at.env.GRAPHCODE_NOD_CLAUDE, - join(at.home, ".local", "bin", "claude"), - join(at.home, ".claude", "local", "claude"), - "/opt/homebrew/bin/claude", - "/usr/local/bin/claude", - join(dirname(at.execPath), "claude"), - join(at.support, "nod", "bin", "claude"), - ].find((path): path is string => Boolean(path) && at.exists(path!)); + return [at.env.GRAPHCODE_NOD_CLAUDE, join(dirname(at.execPath), "claude")].find( + (path): path is string => Boolean(path) && at.exists(path!), + ); } /** diff --git a/NodRuntime/src/claudeEngine.ts b/NodRuntime/src/claudeEngine.ts index baded163..aa7efa68 100644 --- a/NodRuntime/src/claudeEngine.ts +++ b/NodRuntime/src/claudeEngine.ts @@ -10,7 +10,7 @@ import { type SDKMessage, type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; -import type { ClaudeCredentials } from "./credentials"; +import { claudeEnvironment } from "./credentials"; import type { Engine, EngineFailure, EngineSession, EngineStart, ToolRequest, TurnCallbacks, TurnResult } from "./engine"; import type { NodAttachment } from "./protocol"; import { summarizeResult } from "./tools"; @@ -62,11 +62,16 @@ interface ActiveTurn { } export interface ClaudeEngineOptions { - credentials: ClaudeCredentials; + /** From Nod's Keychain entry; without one every turn fails with `signInExpired`. */ + apiKey?: string; + /** Claude Code's config directory, Nod's own rather than `~/.claude`. */ + configDir: string; /** Claude Code to run; the SDK's bundled build when absent. */ executable?: string; } +const NO_KEY = "Nod needs an Anthropic API key. Add one in Settings › Agents › Nod."; + /** * The Claude Agent SDK engine: one long-lived streaming-input query per session, so * turns share a Claude Code process. Every gated tool is forced through `canUseTool` by @@ -99,7 +104,7 @@ export class ClaudeEngine implements Engine { this.start_ = start; this.conversationID = start.resume ?? randomUUID(); this.model = start.model ?? "sonnet"; - this.open(Boolean(start.resume)); + if (this.options.apiKey) this.open(Boolean(start.resume)); return { conversationID: this.conversationID, model: this.model }; } @@ -116,14 +121,15 @@ export class ClaudeEngine implements Engine { : { sessionId: this.conversationID }), permissionMode: "default", includePartialMessages: true, - settingSources: ["user", "project", "local"], + // "user" would read Nod's own config directory, not the human's ~/.claude. + settingSources: ["project", "local"], systemPrompt: { type: "preset", preset: "claude_code", append: start.systemAppend }, canUseTool: (tool, input, { signal }) => this.canUseTool(tool, input, signal), hooks: { PreToolUse: [{ hooks: [this.preToolUse] }], PostToolUse: [{ hooks: [this.postToolUse] }], }, - env: { ...process.env, ...this.options.credentials.env, CLAUDE_AGENT_SDK_CLIENT_APP: "graphcode-nod" }, + env: claudeEnvironment(this.options.apiKey ?? "", this.options.configDir), ...(this.options.executable ? { pathToClaudeCodeExecutable: this.options.executable } : {}), stderr: () => {}, }; @@ -132,6 +138,7 @@ export class ClaudeEngine implements Engine { } async runTurn(text: string, attachments: NodAttachment[], callbacks: TurnCallbacks): Promise { + if (!this.options.apiKey) return { lastMessage: "", failure: { kind: "signInExpired", message: NO_KEY } }; if (!this.q) this.open(true); return new Promise((resolve) => { this.turn = { callbacks, lastMessage: "", textByMessage: new Map(), interrupted: false, resolve }; @@ -155,6 +162,7 @@ export class ClaudeEngine implements Engine { } async ask(prompt: string, model?: string): Promise { + if (!this.options.apiKey) throw new Error(NO_KEY); const q = query({ prompt, options: { @@ -165,13 +173,17 @@ export class ClaudeEngine implements Engine { permissionMode: "dontAsk", settingSources: [], persistSession: false, - env: { ...process.env, ...this.options.credentials.env, CLAUDE_AGENT_SDK_CLIENT_APP: "graphcode-nod" }, + env: claudeEnvironment(this.options.apiKey, this.options.configDir), ...(this.options.executable ? { pathToClaudeCodeExecutable: this.options.executable } : {}), stderr: () => {}, }, }); let text = ""; for await (const message of q) { + if (message.type === "system" && message.subtype === "api_retry" && isSignInRetry(message.error, message.error_status)) { + q.close(); + throw new Error("The Anthropic API key was rejected. Update it in Settings › Agents › Nod."); + } if (message.type === "assistant" && !message.parent_tool_use_id) { const failure = assistantFailure(message.error); if (failure) throw new Error(failure.message); @@ -252,6 +264,12 @@ export class ClaudeEngine implements Engine { case "system": if (message.subtype === "init") this.model = message.model; else if (message.subtype === "compact_boundary") turn?.callbacks.compacted(); + else if (message.subtype === "api_retry" && turn && isSignInRetry(message.error, message.error_status)) { + // Claude Code retries a rejected key with backoff for minutes; a sign-in problem + // is the human's to fix, so the turn stops now and says so. + turn.failure = { kind: "signInExpired", message: "The Anthropic API key was rejected. Update it in Settings › Agents › Nod." }; + void this.interrupt(); + } return; case "auth_status": if (message.error && turn) turn.failure = { kind: "signInExpired", message: message.error }; @@ -463,6 +481,10 @@ function assistantFailure(error: string | undefined): EngineFailure | undefined } } +export function isSignInRetry(error: string | undefined, status: number | null): boolean { + return status === 401 || status === 403 || assistantFailure(error)?.kind === "signInExpired"; +} + function resultFailure(subtype: string, errors: string[], terminal?: string): EngineFailure { if (subtype === "error_max_budget_usd") return { kind: "spendCap", message: "The Claude budget for this session ran out." }; const detail = errors.filter(Boolean).join("; "); diff --git a/NodRuntime/src/credentials.ts b/NodRuntime/src/credentials.ts index 7d822758..1a2edda0 100644 --- a/NodRuntime/src/credentials.ts +++ b/NodRuntime/src/credentials.ts @@ -1,7 +1,4 @@ import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; /** `NodSettings.keychainService`: Nod's own sign-ins, never under `~/.graphcode`. */ export const KEYCHAIN_SERVICE = "app.graphcode.nod"; @@ -9,7 +6,6 @@ export const KEYCHAIN_SERVICE = "app.graphcode.nod"; /** Keychain accounts under `KEYCHAIN_SERVICE`, written by Settings › Agents › Nod. */ export const KeychainAccount = { anthropicAPIKey: "anthropic-api-key", - claudeOAuthToken: "claude-oauth-token", githubToken: "github-token", } as const; @@ -24,38 +20,37 @@ export const readKeychain: KeychainReader = (service, account) => { return value || undefined; }; -/** Where Claude Code keeps its own login: the Keychain on macOS, a file elsewhere. */ -export function hasClaudeCodeLogin(read: KeychainReader = readKeychain, home = homedir()): boolean { - if (read("Claude Code-credentials")) return true; - return existsSync(join(home, ".claude", ".credentials.json")); -} - -export interface ClaudeCredentials { - /** Variables for the engine's environment; empty when reusing Claude Code's own login. */ - env: Record; - source: "nod-api-key" | "nod-oauth" | "environment" | "claude-code-login" | "none"; -} - /** - * Nod's Keychain entry first, then an API key already in the environment, then the - * Claude Code login on this Mac — which needs nothing passed, because the engine runs - * Claude Code and Claude Code reads its own login. + * The Claude engine signs in with an Anthropic API key from Nod's Keychain entry and nothing + * else — never a claude.ai login, Claude Code's or anyone's (policy, mailroom #1190). */ -export function claudeCredentials( - read: KeychainReader = readKeychain, - env: Record = process.env, - home = homedir(), -): ClaudeCredentials { - const apiKey = read(KEYCHAIN_SERVICE, KeychainAccount.anthropicAPIKey); - if (apiKey) return { env: { ANTHROPIC_API_KEY: apiKey }, source: "nod-api-key" }; - const oauth = read(KEYCHAIN_SERVICE, KeychainAccount.claudeOAuthToken); - if (oauth) return { env: { CLAUDE_CODE_OAUTH_TOKEN: oauth }, source: "nod-oauth" }; - if (env.ANTHROPIC_API_KEY || env.CLAUDE_CODE_OAUTH_TOKEN) return { env: {}, source: "environment" }; - if (hasClaudeCodeLogin(read, home)) return { env: {}, source: "claude-code-login" }; - return { env: {}, source: "none" }; +export function anthropicAPIKey(read: KeychainReader = readKeychain): string | undefined { + return read(KEYCHAIN_SERVICE, KeychainAccount.anthropicAPIKey); } /** A GitHub token from Nod's Keychain entry; without one, the Copilot CLI's own login is used. */ export function githubToken(read: KeychainReader = readKeychain): string | undefined { return read(KEYCHAIN_SERVICE, KeychainAccount.githubToken); } + +/** Variables that would let Claude Code sign in some other way; stripped from its environment. */ +export const FOREIGN_CLAUDE_AUTH = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CONFIG_DIR", +]; + +/** + * Claude Code's environment: the parent's, without any inherited credential, with Nod's key + * and a config directory of Nod's own — so Claude Code can't find the human's login there. + */ +export function claudeEnvironment( + apiKey: string, + configDir: string, + parent: Record = process.env, +): Record { + const env = { ...parent }; + for (const name of FOREIGN_CLAUDE_AUTH) delete env[name]; + return { ...env, ANTHROPIC_API_KEY: apiKey, CLAUDE_CONFIG_DIR: configDir, CLAUDE_AGENT_SDK_CLIENT_APP: "graphcode-nod" }; +} diff --git a/NodRuntime/src/main.ts b/NodRuntime/src/main.ts index 9f7846cb..15fbc915 100644 --- a/NodRuntime/src/main.ts +++ b/NodRuntime/src/main.ts @@ -7,7 +7,7 @@ import { claudeExecutable, copilotRuntime } from "./agentRuntimes"; import { ClaudeEngine } from "./claudeEngine"; import { ControlSocket } from "./controlSocket"; import { CopilotEngine } from "./copilotEngine"; -import { claudeCredentials, githubToken } from "./credentials"; +import { anthropicAPIKey, githubToken } from "./credentials"; import type { Engine } from "./engine"; import { readBrief } from "./brief"; import { EventLog } from "./eventLog"; @@ -29,7 +29,11 @@ export function makeEngine(kind: NodEngineKind): Engine { if (kind === "copilot") { return new CopilotEngine({ githubToken: githubToken(), cliPath: copilotRuntime() }); } - return new ClaudeEngine({ credentials: claudeCredentials(), executable: claudeExecutable() }); + return new ClaudeEngine({ + apiKey: anthropicAPIKey(), + configDir: join(supportDirectory(), "nod", "claude"), + executable: claudeExecutable(), + }); } async function main(argv: string[]): Promise { diff --git a/NodRuntime/test/agentRuntimes.test.ts b/NodRuntime/test/agentRuntimes.test.ts index 02b7e92c..49b54cb4 100644 --- a/NodRuntime/test/agentRuntimes.test.ts +++ b/NodRuntime/test/agentRuntimes.test.ts @@ -1,42 +1,51 @@ import { expect, test } from "bun:test"; import { claudeExecutable, copilotRuntime, type Locations } from "../src/agentRuntimes"; -import { claudeCredentials } from "../src/credentials"; +import { anthropicAPIKey, claudeEnvironment, githubToken } from "../src/credentials"; function at(present: string[], env: Record = {}): Locations { return { env, - execPath: "/Applications/GraphCode.app/Contents/Helpers/graphcode-nod", + execPath: "/Applications/GraphCode.app/Contents/Helpers/nod/graphcode-nod", sourceDir: "/$bunfs/root", home: "/Users/me", - support: "/Users/me/.graphcode", exists: (path) => present.includes(path), }; } -test("Claude Code: the override, then the human's install, then one shipped beside graphcode-nod", () => { - const shipped = "/Applications/GraphCode.app/Contents/Helpers/claude"; - expect(claudeExecutable(at([shipped, "/Users/me/.local/bin/claude"]))).toBe("/Users/me/.local/bin/claude"); - expect(claudeExecutable(at([shipped]))).toBe(shipped); +test("Claude Code: the build shipped beside graphcode-nod, never the human's install unless overridden", () => { + const shipped = "/Applications/GraphCode.app/Contents/Helpers/nod/claude"; + expect(claudeExecutable(at([shipped, "/Users/me/.local/bin/claude"]))).toBe(shipped); + expect(claudeExecutable(at(["/Users/me/.local/bin/claude"]))).toBeUndefined(); expect(claudeExecutable(at([shipped, "/x/claude"], { GRAPHCODE_NOD_CLAUDE: "/x/claude" }))).toBe("/x/claude"); - expect(claudeExecutable(at(["/Users/me/.graphcode/nod/bin/claude"]))).toBe("/Users/me/.graphcode/nod/bin/claude"); - expect(claudeExecutable(at([]))).toBeUndefined(); }); test("Copilot: the runtime shipped beside graphcode-nod wins over an installed CLI", () => { - const shipped = "/Applications/GraphCode.app/Contents/Helpers/copilot-runtime"; + const shipped = "/Applications/GraphCode.app/Contents/Helpers/nod/copilot-runtime"; expect(copilotRuntime(at([shipped, "/opt/homebrew/bin/copilot"]))).toBe(shipped); expect(copilotRuntime(at(["/opt/homebrew/bin/copilot"]))).toBe("/opt/homebrew/bin/copilot"); expect(copilotRuntime(at([]))).toBeUndefined(); }); -test("Claude credentials: Nod's Keychain entry, then the environment, then Claude Code's own login", () => { - const keychain = (entries: Record) => (service: string, account?: string) => entries[`${service}/${account ?? ""}`]; - expect(claudeCredentials(keychain({ "app.graphcode.nod/anthropic-api-key": "sk-1" }), {}, "/nohome")).toEqual({ - env: { ANTHROPIC_API_KEY: "sk-1" }, - source: "nod-api-key", +test("sign-in reads only Nod's own Keychain items", () => { + const asked: string[] = []; + const read = (service: string, account?: string) => (asked.push(`${service}/${account}`), account === "anthropic-api-key" ? "sk-1" : undefined); + expect(anthropicAPIKey(read)).toBe("sk-1"); + expect(githubToken(read)).toBeUndefined(); + expect(asked).toEqual(["app.graphcode.nod/anthropic-api-key", "app.graphcode.nod/github-token"]); +}); + +test("Claude Code gets Nod's key and config dir, and no inherited way to sign in", () => { + const env = claudeEnvironment("sk-nod", "/support/nod/claude", { + PATH: "/bin", + ANTHROPIC_API_KEY: "sk-other", + ANTHROPIC_AUTH_TOKEN: "t", + CLAUDE_CODE_OAUTH_TOKEN: "oauth", + CLAUDE_CONFIG_DIR: "/Users/me/.claude", + }); + expect(env).toEqual({ + PATH: "/bin", + ANTHROPIC_API_KEY: "sk-nod", + CLAUDE_CONFIG_DIR: "/support/nod/claude", + CLAUDE_AGENT_SDK_CLIENT_APP: "graphcode-nod", }); - expect(claudeCredentials(keychain({ "app.graphcode.nod/claude-oauth-token": "t" }), {}, "/nohome").env).toEqual({ CLAUDE_CODE_OAUTH_TOKEN: "t" }); - expect(claudeCredentials(keychain({}), { ANTHROPIC_API_KEY: "x" }, "/nohome").source).toBe("environment"); - expect(claudeCredentials(keychain({ "Claude Code-credentials/": "{}" }), {}, "/nohome")).toEqual({ env: {}, source: "claude-code-login" }); - expect(claudeCredentials(keychain({}), {}, "/nohome").source).toBe("none"); }); From cbc6f0c9202578a68569854b48e708e38fed9d3a Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:57:25 -0700 Subject: [PATCH 11/13] Name the Copilot goal evaluator's model Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- NodRuntime/src/runtime.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/NodRuntime/src/runtime.ts b/NodRuntime/src/runtime.ts index 926c561f..ad152adf 100644 --- a/NodRuntime/src/runtime.ts +++ b/NodRuntime/src/runtime.ts @@ -84,8 +84,14 @@ export class NodRuntime { this.stager = new HunkStager(log, cwd); this.stager.onLateDecision = (hunk) => this.steer(lateDecisionNote(hunk)); if (options.goal?.trim()) { - const evaluatorModel = settings.goalEvaluatorModel ?? (options.engine.kind === "claude" ? "haiku" : ""); - this.goal = new GoalEvaluator(options.goal, (prompt, model) => options.engine.ask(prompt, model || undefined), evaluatorModel, log); + // Copilot has no fixed cheap model to name; its judge runs on the session's default. + const evaluatorModel = settings.goalEvaluatorModel ?? (options.engine.kind === "claude" ? "haiku" : "default"); + this.goal = new GoalEvaluator( + options.goal, + (prompt, model) => options.engine.ask(prompt, model === "default" ? undefined : model), + evaluatorModel, + log, + ); } } From a6942057f21f4ded8bc28d9d7d13e5532771709f Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:57:49 -0700 Subject: [PATCH 12/13] Take the state directory and node id from the launcher's environment Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- NodRuntime/README.md | 8 +++++--- NodRuntime/src/main.ts | 18 +++++++++++++----- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/NodRuntime/README.md b/NodRuntime/README.md index a1b676f1..4fff9285 100644 --- a/NodRuntime/README.md +++ b/NodRuntime/README.md @@ -142,9 +142,11 @@ of sitting in Claude Code's retry backoff. With `SIGN_IDENTITY` set it signs `graphcode-nod` and the Copilot runtime with the hardened runtime and `packaging/entitlements.plist` (a compiled Bun binary needs the JIT -entitlements). The daemon launches `/bin/graphcode-nod`, a symlink into the -bundle placed by the launch side; the runtime resolves its real path before looking beside -itself. +entitlements). The app's panes run `/Contents/Helpers/nod/graphcode-nod`; the app +copies the whole folder to `/bin/nod/` for graphcoded (PROTOCOL.md, Launch). +The runtime resolves its own real path before looking beside itself, so a symlinked copy +works too. It keeps its state in `$NOD_STATE` (set on every launch), falling back to +`/nod//`, and takes `$NOD_NODE_ID` when `--node` is absent. A compiled binary cannot load the SDKs' platform packages (they resolve to the build machine's `node_modules`), so `agentRuntimes.ts` looks for each agent runtime explicitly: diff --git a/NodRuntime/src/main.ts b/NodRuntime/src/main.ts index 15fbc915..56af57d2 100644 --- a/NodRuntime/src/main.ts +++ b/NodRuntime/src/main.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun import { mkdirSync, readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { createInterface } from "node:readline"; import { parseArgs } from "node:util"; import { claudeExecutable, copilotRuntime } from "./agentRuntimes"; @@ -25,13 +25,19 @@ graphcode-nod -p [--engine claude|copilot] [--model ]`; const loopTypes = new Set(["main", "goal", "timed", "turn", "composite"]); +/** Claude Code's config directory: Nod's own, beside the per-node state directories. */ +function claudeConfigDirectory(): string { + const state = process.env.NOD_STATE; + return state ? join(dirname(state), "claude") : join(supportDirectory(), "nod", "claude"); +} + export function makeEngine(kind: NodEngineKind): Engine { if (kind === "copilot") { return new CopilotEngine({ githubToken: githubToken(), cliPath: copilotRuntime() }); } return new ClaudeEngine({ apiKey: anthropicAPIKey(), - configDir: join(supportDirectory(), "nod", "claude"), + configDir: claudeConfigDirectory(), executable: claudeExecutable(), }); } @@ -76,11 +82,13 @@ async function main(argv: string[]): Promise { } } - if (!values.node || !values.cwd) throw new Error(`--node and --cwd are required\n${USAGE}`); + const nodeID = values.node ?? process.env.NOD_NODE_ID; + if (!nodeID || !values.cwd) throw new Error(`--node (or NOD_NODE_ID) and --cwd are required\n${USAGE}`); const loopType = (values["loop-type"] ?? "main") as LoopType; if (!loopTypes.has(loopType)) throw new Error(`unknown loop type ${loopType}`); const cwd = resolve(values.cwd); - const stateDir = join(supportDirectory(), "nod", values.node); + // The launcher always sets NOD_STATE; a launch may not carry GRAPHCODE_SUPPORT_DIR. + const stateDir = process.env.NOD_STATE || join(supportDirectory(), "nod", nodeID); mkdirSync(stateDir, { recursive: true }); const log = new EventLog(join(stateDir, "events.jsonl")); @@ -89,7 +97,7 @@ async function main(argv: string[]): Promise { const presence = new PresenceReporter(process.env.ZMX_SESSION); const engine = makeEngine(engineKind); const runtime = new NodRuntime({ - nodeID: values.node, + nodeID, cwd, stateDir, loopType, From 603a2743e364698872307ba7d9ee2e86d9f4ef6b Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 22:03:01 -0700 Subject: [PATCH 13/13] Add the real-engine smoke driver Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- NodRuntime/scripts/smoke.ts | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 NodRuntime/scripts/smoke.ts diff --git a/NodRuntime/scripts/smoke.ts b/NodRuntime/scripts/smoke.ts new file mode 100644 index 00000000..25bb6829 --- /dev/null +++ b/NodRuntime/scripts/smoke.ts @@ -0,0 +1,57 @@ +// Drives a real graphcode-nod goal-loop session in a throwaway repo and support directory: +// accepts every hunk, allows every ask, steers once, and leaves events.jsonl to read. +// +// bun scripts/smoke.ts claude|copilot [runtime-command] +// +// The runtime command defaults to this checkout's source; pass "/path/to/graphcode-nod" to +// smoke a packaged build. Claude needs an anthropic-api-key item in app.graphcode.nod. +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { connect } from "node:net"; +import { join } from "node:path"; + +const engine = process.argv[2] === "copilot" ? "copilot" : "claude"; +const runtime = process.argv[3] ?? `bun ${join(import.meta.dir, "..", "src", "main.ts")}`; +const root = `/tmp/nod-smoke-${engine}`; +const work = join(root, "work"); +const support = join(root, "support"); +const node = "11111111-2222-3333-4444-555555555555"; +rmSync(root, { recursive: true, force: true }); +mkdirSync(work, { recursive: true }); +mkdirSync(support, { recursive: true }); +Bun.spawnSync(["git", "init", "-q"], { cwd: work }); +writeFileSync(join(support, "settings.json"), JSON.stringify({ nod: { engine, shellAllowlist: ["cat *"], editsInWorktree: "reviewHunks" } })); +const goalFile = join(root, "goal.txt"); +writeFileSync(goalFile, "Done when hello.txt contains the line `hello nod` and `cat hello.txt` has printed it"); + +const state = join(support, "nod", node); +const child = Bun.spawn( + [...runtime.split(" "), "--node", node, "--cwd", work, "--engine", engine, "--loop-type", "goal", "--goal-file", goalFile, + "--prompt", "Use your Write tool (not the shell) to create hello.txt containing the line `hello nod`. Then run `ls -la` and then `cat hello.txt`.", + "--exit-when-idle"], + { env: { ...process.env, GRAPHCODE_SUPPORT_DIR: support, NOD_STATE: state }, stdout: "inherit", stderr: "inherit" }, +); +const socket = join(state, "control.sock"); +while (!existsSync(socket)) await Bun.sleep(100); +const control = connect(socket); +let seen = 0; +let steered = false; +const poll = setInterval(() => { + const log = join(state, "events.jsonl"); + if (!existsSync(log)) return; + const lines = readFileSync(log, "utf8").trim().split("\n"); + for (const line of lines.slice(seen)) { + const record = JSON.parse(line); + if (record.type === "hunkStaged") control.write(JSON.stringify({ type: "resolveHunk", hunkID: record.hunkID, decision: "accept" }) + "\n"); + if (record.type === "permissionAsked") control.write(JSON.stringify({ type: "resolvePermission", askID: record.askID, decision: "allowOnce" }) + "\n"); + if (record.type === "toolCall" && !steered) { + steered = true; + control.write(JSON.stringify({ type: "send", text: "Also: keep your final answer to one short sentence.", delivery: "steer" }) + "\n"); + } + } + seen = lines.length; +}, 100); +const code = await child.exited; +clearInterval(poll); +control.end(); +console.error(`\nexit ${code} · ${seen} records in ${join(state, "events.jsonl")}`); +process.exit(code);