From 8313f8731be78420964f70a20c66c403aa28c0d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Tue, 21 Jul 2026 21:02:12 -0300 Subject: [PATCH] feat(pi-kit): add cross-harness model lanes --- bun.lock | 168 +++++ .../2026-07-21-claude-lanes-deployment.md | 362 +++++++++ .../2026-07-21-pi-kit-multimodel-lanes-mcp.md | 561 ++++++++++++++ ...1-cross-harness-multimodel-lanes-design.md | 243 ++++++ packages/pi-kit/README.md | 105 +++ packages/pi-kit/extensions/lanes.ts | 604 ++++++++------- packages/pi-kit/mcp/create-server.ts | 205 +++++ packages/pi-kit/mcp/server.ts | 63 ++ packages/pi-kit/package.json | 10 +- packages/pi-kit/src/adapters/claude-code.ts | 545 ++++++++++++++ packages/pi-kit/src/adapters/pi.ts | 166 ++++ packages/pi-kit/src/child-env.ts | 45 ++ packages/pi-kit/src/execution-adapter.ts | 30 + packages/pi-kit/src/lane-coordinator.ts | 288 +++++++ packages/pi-kit/src/runner.ts | 710 ++++++++++++------ packages/pi-kit/src/schema.ts | 6 + packages/pi-kit/src/table.ts | 85 ++- packages/pi-kit/src/transcript.ts | 59 +- packages/pi-kit/test/child-env.test.ts | 58 ++ .../pi-kit/test/claude-code-adapter.test.ts | 318 ++++++++ .../pi-kit/test/execution-adapters.test.ts | 138 ++++ .../test/fixtures/claude-lane-stream.jsonl | 17 + .../pi-kit/test/fixtures/mcp-lane-process.cjs | 12 + .../fixtures/normalized-lane-stream.jsonl | 8 + packages/pi-kit/test/journal.test.ts | 23 + packages/pi-kit/test/lane-coordinator.test.ts | 473 ++++++++++++ packages/pi-kit/test/lanes-extension.test.ts | 241 +++++- packages/pi-kit/test/mcp-server.test.ts | 451 +++++++++++ packages/pi-kit/test/runner.test.ts | 633 +++++++++++++++- packages/pi-kit/test/table.test.ts | 82 +- packages/pi-kit/test/transcript.test.ts | 26 + 31 files changed, 6136 insertions(+), 599 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-21-claude-lanes-deployment.md create mode 100644 docs/superpowers/plans/2026-07-21-pi-kit-multimodel-lanes-mcp.md create mode 100644 docs/superpowers/specs/2026-07-21-cross-harness-multimodel-lanes-design.md create mode 100644 packages/pi-kit/README.md create mode 100644 packages/pi-kit/mcp/create-server.ts create mode 100755 packages/pi-kit/mcp/server.ts create mode 100644 packages/pi-kit/src/adapters/claude-code.ts create mode 100644 packages/pi-kit/src/adapters/pi.ts create mode 100644 packages/pi-kit/src/child-env.ts create mode 100644 packages/pi-kit/src/execution-adapter.ts create mode 100644 packages/pi-kit/src/lane-coordinator.ts create mode 100644 packages/pi-kit/test/child-env.test.ts create mode 100644 packages/pi-kit/test/claude-code-adapter.test.ts create mode 100644 packages/pi-kit/test/execution-adapters.test.ts create mode 100644 packages/pi-kit/test/fixtures/claude-lane-stream.jsonl create mode 100644 packages/pi-kit/test/fixtures/mcp-lane-process.cjs create mode 100644 packages/pi-kit/test/fixtures/normalized-lane-stream.jsonl create mode 100644 packages/pi-kit/test/lane-coordinator.test.ts create mode 100644 packages/pi-kit/test/mcp-server.test.ts diff --git a/bun.lock b/bun.lock index 37f06e6..b54e1b3 100644 --- a/bun.lock +++ b/bun.lock @@ -43,6 +43,10 @@ "packages/pi-kit": { "name": "@pickforge/pi-kit", "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "1.29.0", + "zod": "^4.4.3", + }, "devDependencies": { "@earendil-works/pi-ai": "0.79.10", "@earendil-works/pi-coding-agent": "0.79.10", @@ -199,6 +203,8 @@ "@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], @@ -231,6 +237,8 @@ "@mistralai/mistralai": ["@mistralai/mistralai@2.2.6", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], @@ -421,10 +429,16 @@ "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "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=="], + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], @@ -437,6 +451,8 @@ "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + "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=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], @@ -447,8 +463,14 @@ "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "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=="], + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -463,8 +485,18 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "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=="], "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], @@ -473,62 +505,124 @@ "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "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=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "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-module-lexer": ["es-module-lexer@2.3.0", "", {}, "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "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.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "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.6.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + "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=="], + "fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="], "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "gaxios": ["gaxios@7.2.0", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg=="], "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "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=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "google-auth-library": ["google-auth-library@10.9.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg=="], "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "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=="], + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + "hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="], + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "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=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + + "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=="], "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], @@ -539,6 +633,8 @@ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "jose": ["jose@6.2.4", "", {}, "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA=="], + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], @@ -547,6 +643,10 @@ "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=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], @@ -595,6 +695,16 @@ "marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "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=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], @@ -607,24 +717,36 @@ "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + "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=="], + "openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="], "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], @@ -635,6 +757,8 @@ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], @@ -645,8 +769,18 @@ "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + + "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=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], @@ -655,14 +789,32 @@ "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "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=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], @@ -673,6 +825,8 @@ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], @@ -695,6 +849,8 @@ "tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], @@ -705,6 +861,8 @@ "tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="], + "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=="], + "typebox": ["typebox@1.1.38", "", {}, "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -715,6 +873,10 @@ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vite": ["vite@8.1.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.16", "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA=="], "vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="], @@ -727,6 +889,8 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], @@ -745,10 +909,14 @@ "@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "make-dir/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "vite-node/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], "vite-node/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], diff --git a/docs/superpowers/plans/2026-07-21-claude-lanes-deployment.md b/docs/superpowers/plans/2026-07-21-claude-lanes-deployment.md new file mode 100644 index 0000000..dc67421 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-claude-lanes-deployment.md @@ -0,0 +1,362 @@ +# Managed Claude Lanes Deployment Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deploy the reviewed pi-kit lane MCP server to Claude Code through canonical chezmoi configuration, add one portable procedural skill, and repair directly related global Claude safety drift. + +**Architecture:** Keep executable runtime code in `pickforge-platform`. Use `agent-config-sync` and chezmoi as the only configuration source, with a managed wrapper and idempotent user-scoped MCP registration. Keep routing policy in existing canonical instructions; the new skill documents procedures only. + +**Tech Stack:** chezmoi, age-encrypted configuration, Bash, Claude Code 2.1.216, `agent-config-sync`. + +**Prerequisite plan:** `docs/superpowers/plans/2026-07-21-pi-kit-multimodel-lanes-mcp.md` + +**Spec:** `docs/superpowers/specs/2026-07-21-cross-harness-multimodel-lanes-design.md` + +--- + +## Safety prerequisites + +The canonical repository state changed during planning and must never be treated as a fixed baseline. At the last check it matched `origin/main` but contained user-owned `dot_pi/agent/encrypted_models.json.age` modifications and untracked `AGENTS.md`; both must remain untouched. `agent-config-sync check` and `check-live` both passed at that time. Re-run status and checks at execution time, document only failures reproduced then, and stop for any new drift. Do not edit, pull, rebase, reset, clean, or apply over user work. + +Never read or print OAuth tokens, cookies, encrypted settings plaintext in logs, or MCP credentials. Use `chezmoi --source "$CONFIG_WT" edit` for encrypted files after the worktree variable is established. + +## File map + +### Canonical chezmoi repository + +- Create: `dot_local/bin/executable_pickforge-lanes-mcp` +- Create: `run_onchange_after_configure_pickforge_lanes_mcp.sh.tmpl` +- Create encrypted through chezmoi: `dot_agents/skills/multi-model-lanes/encrypted_SKILL.md.age` +- Create: `dot_claude/skills/symlink_multi-model-lanes` +- Create: `dot_pi/agent/skills/symlink_multi-model-lanes` +- Modify: `dot_agents/skill-targets.json` +- Modify through source-scoped `chezmoi edit`: `dot_claude/encrypted_settings.json.age` +- Modify: `scripts/check-agent-config-sync.sh` +- Modify if a concise harness reference is still needed: `dot_claude/CLAUDE.md.tmpl` +- Modify if a concise harness reference is still needed: `dot_pi/agent/AGENTS.md.tmpl` + +### Rendered targets + +- Create: `~/.local/bin/pickforge-lanes-mcp` +- Create: `~/.agents/skills/multi-model-lanes/SKILL.md` +- Create symlinks: `~/.claude/skills/multi-model-lanes`, `~/.pi/agent/skills/multi-model-lanes` +- Modify through scoped apply: `~/.claude/settings.json` +- Modify through `claude mcp`: user-scoped `pickforge-lanes` registration +- Remove through `claude mcp`: duplicate user-scoped standalone `context7` registration, retaining `plugin:context7:context7` + +## Issue checklist + +- [ ] Canonical checkout inventoried and isolated without losing user work +- [ ] Current baseline check results recorded without stale assumptions +- [ ] Stable runtime wrapper installed +- [ ] User-scoped MCP registration managed idempotently +- [ ] One portable procedural skill distributed to Claude and Pi +- [ ] Claude safety hook and permission drift corrected +- [ ] Duplicate Context7 registration removed +- [ ] Scoped config checks and live MCP smoke pass + +--- + +### Task 1: Inventory canonical state and create an isolated configuration worktree + +**Files:** +- Inspect only initially: `/Users/elberte/.local/share/chezmoi` + +- [ ] **Step 1: Establish delivery mode and inspect work** + +Run: + +```bash +git -C /Users/elberte/.local/share/chezmoi status --short --branch +git -C /Users/elberte/.local/share/chezmoi log --oneline --decorate --graph --max-count=12 --all +``` + +Expected: identify current branch relation and every dirty/untracked path. Read any discovered `AGENTS.md`. Preserve all user-owned paths. + +- [ ] **Step 2: Resolve only a real history blocker** + +If the branch is ahead/behind at execution time, present the exact pull/rebase/merge operation and obtain confirmation. Never reset or clean. If it is aligned, do nothing. + +- [ ] **Step 3: Create a dedicated worktree from reviewed HEAD** + +Use `/Users/elberte/Projects/.worktrees/dotfiles/feature-multimodel-lanes` and record: + +```bash +export CONFIG_WT=/Users/elberte/Projects/.worktrees/dotfiles/feature-multimodel-lanes +``` + +Every subsequent chezmoi command must use `chezmoi --source "$CONFIG_WT" ...`; plain `chezmoi add/edit/diff/apply` is forbidden because it would target the user-owned canonical checkout. + +- [ ] **Step 4: Record current baselines with timeouts** + +Run the worktree's `scripts/check-agent-config-sync.sh` plus live `agent-config-sync check-live` through a 20-second Python subprocess timeout. Accept only failures reproduced and documented in this run; do not carry historical expected failures forward. + +- [ ] **Step 5: Keep unrelated canonical work isolated** + +Confirm the feature worktree contains none of the canonical checkout's unrelated dirty/untracked changes unless the user explicitly asks to include them. + +--- + +### Task 2: Add a stable pi-kit MCP launcher + +**Files:** +- Create: `dot_local/bin/executable_pickforge-lanes-mcp` +- Modify: `scripts/check-agent-config-sync.sh` + +- [ ] **Step 1: Add a failing canonical invariant** + +Extend the check script to require a managed executable that: + +- resolves `$HOME/Projects/Pickforge/pickforge-platform/packages/pi-kit/mcp/server.ts`; +- exits clearly if Bun or the entrypoint is missing; +- uses `exec bun `; +- contains no credentials or provider tokens. + +- [ ] **Step 2: Run the scoped check and verify failure** + +Run the check through a timeout and compare against Task 1's freshly recorded baseline only. +Expected: the new wrapper invariant FAILS and no unrelated baseline result changes. + +- [ ] **Step 3: Implement the launcher** + +```bash +#!/usr/bin/env bash +set -euo pipefail +entrypoint="$HOME/Projects/Pickforge/pickforge-platform/packages/pi-kit/mcp/server.ts" +command -v bun >/dev/null || { echo "pickforge-lanes-mcp: bun not found" >&2; exit 127; } +[[ -f "$entrypoint" ]] || { echo "pickforge-lanes-mcp: pi-kit MCP server not found at $entrypoint" >&2; exit 127; } +exec bun "$entrypoint" +``` + +- [ ] **Step 4: Commit** + +```bash +git add dot_local/bin/executable_pickforge-lanes-mcp scripts/check-agent-config-sync.sh +git commit -m "feat(config): add managed lanes launcher" +``` + +--- + +### Task 3: Add one portable multi-model lanes skill + +**Files:** +- Create encrypted: `dot_agents/skills/multi-model-lanes/encrypted_SKILL.md.age` +- Create: `dot_claude/skills/symlink_multi-model-lanes` +- Create: `dot_pi/agent/skills/symlink_multi-model-lanes` +- Modify: `dot_agents/skill-targets.json` +- Test: `scripts/check-agent-config-sync.sh` + +- [ ] **Step 1: Search for overlap before creating the skill** + +Inspect `model-runners`, Claude's existing `model-orchestration`, and Pi lane instructions. The new skill must not copy the comparative model table or create provider roles. + +- [ ] **Step 2: Add failing registry checks** + +Require `multi-model-lanes` to target only `claude` and `pi`, with valid symlinks and canonical encrypted source. + +- [ ] **Step 3: Draft the lean live skill** + +Create `~/.agents/skills/multi-model-lanes/SKILL.md` with: + +- trigger: cross-provider delegation, independent parallel work, or explicit panel; +- Claude rule: native workflows for Claude-to-Claude work; MCP rejects Anthropic selectors; +- Pi rule: use existing lane tools; +- exact spawn/status/wait/abandon procedure; +- explicit model, effort, mode, cwd and rationale requirement; +- hard policy: never run provider model CLI delegation in the foreground; use asynchronous lanes, MCP, or a provider-native background workflow; +- hard failure when no asynchronous route is available; never fall back to synchronous direct-CLI delegation; +- no polling loop; +- output contract including decisions, evidence, validation and unresolved risk; +- observable completion criteria; +- reference to canonical global model policy rather than a duplicated table. + +- [ ] **Step 4: Run the skill quality gate** + +Verify description trigger, completion criteria, progressive disclosure, no overlap, no retired tools, capability-aware targets, and the invariant that provider model CLI delegation is never foregrounded or used as a synchronous fallback. + +- [ ] **Step 5: Add the skill encrypted** + +Run: + +```bash +chezmoi --source "$CONFIG_WT" add --encrypt ~/.agents/skills/multi-model-lanes/SKILL.md +``` + +Then add the two managed relative symlinks and update `dot_agents/skill-targets.json`. + +- [ ] **Step 6: Run scoped checks** + +Expected: the new skill and symlink checks PASS; every other result matches Task 1's current baseline. + +- [ ] **Step 7: Commit** + +```bash +git add dot_agents/skills/multi-model-lanes dot_agents/skill-targets.json dot_claude/skills/symlink_multi-model-lanes dot_pi/agent/skills/symlink_multi-model-lanes scripts/check-agent-config-sync.sh +git commit -m "feat(config): add portable lanes workflow" +``` + +--- + +### Task 4: Repair global Claude safety configuration + +**Files:** +- Modify through source-scoped `chezmoi edit`: `dot_claude/encrypted_settings.json.age` +- Modify: `scripts/check-agent-config-sync.sh` + +- [ ] **Step 1: Add failing safe-profile invariants** + +Require rendered Claude settings to satisfy: + +- decision hook command uses `$HOME/.claude/hooks/decision-audit-gate.sh`; +- no `rtk hook claude` entry; +- no broad `Bash(rm *)` or `Bash(git *)` auto-allow; +- explicit read-only Git allow forms remain available; +- no literal credentials or authorization headers; +- the new MCP allow rule is exact, not a broad `mcp__*` wildcard. + +- [ ] **Step 2: Run redacted checks and record reproduced findings** + +Print invariant names and pass/fail only. Do not assume planning-time findings remain. Record which of the hook path, stale rtk, broad rm/mv/chmod/git, and MCP allow invariants currently fail. + +- [ ] **Step 3: Obtain explicit approval for permission-policy changes** + +Present the redacted findings and exact allow entries that would be removed/added. Ask the user to approve the permission narrowing. If approval is withheld or modified, stop Task 4 and update the plan rather than applying a subset implicitly. + +- [ ] **Step 4: Edit the encrypted canonical settings** + +After approval, run: `chezmoi --source "$CONFIG_WT" edit ~/.claude/settings.json`. + +Apply only the following changes that were reproduced and explicitly approved: + +- replace `/home/dev/.claude/hooks/decision-audit-gate.sh` with `$HOME/.claude/hooks/decision-audit-gate.sh`; +- remove `rtk hook claude`; +- remove `Bash(rm *)`, `Bash(mv *)`, `Bash(chmod *)`, and `Bash(git *)` from auto-allow; +- add only `Bash(git status *)`, `Bash(git diff *)`, `Bash(git log *)`, `Bash(git show *)`, `Bash(git rev-parse *)`, `Bash(git ls-files *)`, and `Bash(git branch --show-current)`; +- add exact `mcp__pickforge-lanes__lanes_spawn`, `lanes_status`, `lanes_wait`, and `lanes_abandon` allows after confirming Claude's normalized server prefix. + +Do not change the user's default Fable model or medium effort: medium differs from the table prior but is not a hard policy violation. + +- [ ] **Step 5: Render without applying and inspect only the scoped diff** + +Never print decrypted settings with `chezmoi diff`. With `umask 077`, render the target from `--source "$CONFIG_WT"` into a `0600` temporary file, run structural assertions that print invariant names only, and delete the file in a trap. Compare hashes/keys without echoing values. Verify no unrelated settings moved through redacted key-level assertions. + +- [ ] **Step 6: Run invariants** + +Expected: new safe-profile invariants PASS; `check-live` remains unchanged until scoped apply. + +- [ ] **Step 7: Commit** + +```bash +git add dot_claude/encrypted_settings.json.age scripts/check-agent-config-sync.sh +git commit -m "fix(config): tighten global tool permissions" +``` + +--- + +### Task 5: Manage user-scoped MCP registration and remove duplication + +**Files:** +- Create: `run_onchange_after_configure_pickforge_lanes_mcp.sh.tmpl` +- Modify: `scripts/check-agent-config-sync.sh` + +- [ ] **Step 1: Add failing script invariants** + +Require the script to: + +- use `claude mcp add --scope user pickforge-lanes -- pickforge-lanes-mcp`; +- be idempotent; +- never include auth headers, tokens or client secrets; +- never remove `context7` automatically; +- document the separate confirmed command for removing exactly the user-scoped `context7` entry; +- retain plugin server `plugin:context7:context7`. + +- [ ] **Step 2: Implement the idempotent configuration script** + +The script should run bounded `claude mcp get pickforge-lanes` with `umask 077`, redirect all output into a `0600` temporary file, compare only the server name/transport/command against `pickforge-lanes-mcp`, print invariant names rather than captured values, and delete the file in a trap. Never echo the captured output because MCP entries may contain headers. Remove/re-add only `pickforge-lanes` when the structural comparison differs. Do not parse or rewrite `~/.claude.json` directly. + +Do not remove Context7 from this automatic script. Capture `claude mcp list` into a `0600` temporary file, emit only parsed server names and connection booleans, delete the file, then ask for explicit confirmation and run the separate manual command `claude mcp remove context7 --scope user`. Never print raw list/get output. Retain and recheck `plugin:context7:context7`. + +- [ ] **Step 3: Test with a fake `claude` executable** + +Add shell-level checks with a fake `claude` executable for absent server, matching server, changed command and failed add. Inject fake secret headers/tokens and assert they never reach stdout, stderr or logs. Assert no infinite health-check wait. Test duplicate Context7 only as a separately confirmed manual procedure, not automatic behavior. + +- [ ] **Step 4: Commit** + +```bash +git add run_onchange_after_configure_pickforge_lanes_mcp.sh.tmpl scripts/check-agent-config-sync.sh +git commit -m "feat(config): manage lanes MCP registration" +``` + +--- + +### Task 6: Apply and validate narrowly + +**Files/targets:** +- `~/.local/bin/pickforge-lanes-mcp` +- `~/.agents/skills/multi-model-lanes/SKILL.md` +- `~/.claude/skills/multi-model-lanes` +- `~/.pi/agent/skills/multi-model-lanes` +- `~/.claude/settings.json` +- Claude user MCP registration `pickforge-lanes` + +- [ ] **Step 1: Run pre-apply checks** + +Run the feature worktree's checks and live `check-live` with timeouts. Stop for any failure not reproduced in Task 1's current baseline. Do not assume historical failures still exist. + +- [ ] **Step 2: Review explicit scoped diffs** + +Use only `chezmoi --source "$CONFIG_WT"` scoped target inspection. For encrypted settings, use secure temporary rendering plus redacted structural assertions; never print plaintext diff. Never run whole-tree status/diff/verify. + +- [ ] **Step 3: Apply named targets and install the reviewed runtime** + +Use `chezmoi --source "$CONFIG_WT" apply ` for the wrapper, skill links and settings. Before MCP registration, verify the reviewed runtime commit is checked out at `$HOME/Projects/Pickforge/pickforge-platform`, record its SHA, and run `bun install --frozen-lockfile` there. Run the approved MCP registration script separately if run scripts are not included by target-scoped apply. + +- [ ] **Step 4: Smoke-test the applied launcher without provider calls** + +Run: `~/.local/bin/pickforge-lanes-mcp **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make pi-kit's lane runtime provider-aware and expose its cross-provider operations to Claude Code through a session-local stdio MCP server while preserving Pi's current UX. + +**Architecture:** Keep `LaneRunner` as the deep process-owning module, add a real execution-adapter seam for Pi and Claude Code, and extract a harness-neutral `LaneCoordinator`. Pi and MCP become thin adapters over the same coordinator. Logical model selectors remain stable; route metadata lives in the model table. + +**Tech Stack:** TypeScript, Bun, Vitest, `@modelcontextprotocol/sdk` 1.29.0, Zod, Pi extension API, Claude Code 2.1.216 CLI. + +**Spec:** `docs/superpowers/specs/2026-07-21-cross-harness-multimodel-lanes-design.md` + +--- + +## File map + +### Create + +- `packages/pi-kit/src/execution-adapter.ts` — normalized events, process plans, parser and asynchronous adapter interfaces. +- `packages/pi-kit/src/adapters/pi.ts` — Pi command construction and JSON stream normalization. +- `packages/pi-kit/src/child-env.ts` — minimal child-environment allowlist. +- `packages/pi-kit/src/adapters/claude-code.ts` — genuine Claude Code command construction, asynchronous preflight, and stream normalization. +- `packages/pi-kit/src/lane-coordinator.ts` — one-active-run lifecycle and JSON-safe snapshots. +- `packages/pi-kit/mcp/create-server.ts` — testable MCP server factory and tool registration. +- `packages/pi-kit/mcp/server.ts` — stdio entrypoint and shutdown handling. +- `packages/pi-kit/test/execution-adapters.test.ts` — route resolution and mode command contracts. +- `packages/pi-kit/test/child-env.test.ts` — child-environment scrubbing coverage. +- `packages/pi-kit/test/claude-code-adapter.test.ts` — Claude parser, effort, mode, and asynchronous preflight tests. +- `packages/pi-kit/test/lane-coordinator.test.ts` — shared lifecycle tests. +- `packages/pi-kit/test/mcp-server.test.ts` — in-memory MCP protocol tests. +- `packages/pi-kit/test/fixtures/claude-lane-stream.jsonl` — sanitized Claude 2.1.216 stream fixture. +- `packages/pi-kit/test/fixtures/normalized-lane-stream.jsonl` — canonical transcript fixture. +- `packages/pi-kit/README.md` — runtime, MCP and safety documentation. + +### Modify + +- `packages/pi-kit/src/schema.ts` — `LaneMode` and optional `LaneSpec.mode`. +- `packages/pi-kit/src/table.ts` — route, runtime model, origin and allowed-effort metadata; add Grok. +- `packages/pi-kit/src/runner.ts` — consume adapters and normalized events while retaining process ownership. +- `packages/pi-kit/src/transcript.ts` — parse canonical transcripts and legacy Pi JSONL. +- `packages/pi-kit/extensions/lanes.ts` — use `LaneCoordinator`; keep UI and Pi tool contract. +- `packages/pi-kit/test/runner.test.ts` — preserve process behavior through injected adapters. +- `packages/pi-kit/test/lanes-extension.test.ts` — compatibility and exception-containment coverage. +- `packages/pi-kit/test/table.test.ts` — route/origin/effort/mode validation. +- `packages/pi-kit/package.json` — MCP runtime dependencies, bin and scripts. +- `bun.lock` — dependency lock. + +## Issue checklist + +- [ ] Delivery mode, canonical issue and commit authority established +- [ ] Shared schema and route policy +- [ ] Pi and Claude execution adapters +- [ ] Provider-neutral runner and canonical transcript +- [ ] Shared lane coordinator +- [ ] Pi compatibility and handler containment +- [ ] Stdio MCP surface +- [ ] Documentation and package entrypoint +- [ ] Non-billable validation +- [ ] User-approved live smoke tests + +--- + +### Task 0: Establish the Pickforge delivery workflow + +**Files:** +- Read: `AGENTS.md` +- Attempt to read: `../AGENTS.md` +- Track externally: canonical GitHub Issue + +- [ ] **Step 1: Re-read applicable instructions in the implementation worktree** + +Record that `AGENTS.md` is binding. If `../AGENTS.md` is still absent, report that precisely rather than inventing workspace rules. + +- [ ] **Step 2: Establish delivery mode** + +Ask the user to choose `local-implement` or `ship`. Do not create commits until local implementation authority is explicit. + +- [ ] **Step 3: Create or link the canonical Issue** + +Use `plan-issue` when available. Otherwise create a phone-friendly issue draft/checklist from this plan and let the user perform any public action unless the active Pickforge workflow explicitly authorizes it. Record the Issue URL in the branch/PR description once created. + +- [ ] **Step 4: Verify baseline** + +Run: + +```bash +bun install --frozen-lockfile +bun test packages/pi-kit/test +bun run typecheck +``` + +Expected: 90 existing tests pass and typecheck exits 0 before source changes. + +--- + +### Task 1: Encode route, origin, effort and mode policy + +**Files:** +- Modify: `packages/pi-kit/src/schema.ts` +- Modify: `packages/pi-kit/src/table.ts` +- Test: `packages/pi-kit/test/table.test.ts` + +- [ ] **Step 1: Write failing schema and table tests** + +Add tests covering: + +```ts +expect(findModel("anthropic/claude-fable-5")).toMatchObject({ + route: "claude-code", + runtimeModel: "fable", + allowedEfforts: ["low", "medium", "high"], + origins: ["pi"], +}); +expect(findModel("xai/grok-4.5")).toMatchObject({ + route: "pi", + runtimeModel: "grok-4.5", + allowedEfforts: ["high"], + origins: ["pi", "mcp"], +}); +expect(validateLaneSpec(validAnthropic, { origin: "mcp" })).toContain("native Claude workflow"); +expect(validateLaneSpec({ ...validAnthropic, effort: "minimal" }, { origin: "pi" })).toContain("unsupported effort"); +expect(normalizeLaneSpec(validPi, { origin: "pi" }).mode).toBe("workspace-write"); +expect(validateLaneSpec({ ...validPi, mode: undefined }, { origin: "mcp" })).toContain("mode is required"); +``` + +- [ ] **Step 2: Run the focused test and verify failure** + +Run: `bun test packages/pi-kit/test/table.test.ts` +Expected: FAIL because route metadata, origin-aware validation and mode normalization do not exist. + +- [ ] **Step 3: Add the minimal policy types** + +Add: + +```ts +export type LaneMode = "read-only" | "workspace-write"; +export type LaneOrigin = "pi" | "mcp"; +export type ExecutionRoute = "pi" | "claude-code"; +``` + +Extend `LaneSpec` with optional `mode`. Extend `ModelRow` with `route`, `runtimeModel`, `allowedEfforts`, and `origins`. Keep logical selectors unchanged. Add `xai/grok-4.5`, pinned to high and routed through Pi. + +Implement `normalizeLaneSpec(spec, { origin })` and origin-aware `validateLaneSpec`. Never accept `max`, Haiku, Luna or Terra. + +- [ ] **Step 4: Run focused tests** + +Run: `bun test packages/pi-kit/test/table.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/pi-kit/src/schema.ts packages/pi-kit/src/table.ts packages/pi-kit/test/table.test.ts +git commit -m "feat(pi-kit): encode lane execution policy" +``` + +--- + +### Task 2: Introduce the execution-adapter seam + +**Files:** +- Create: `packages/pi-kit/src/execution-adapter.ts` +- Create: `packages/pi-kit/src/adapters/pi.ts` +- Create: `packages/pi-kit/src/child-env.ts` +- Create: `packages/pi-kit/test/execution-adapters.test.ts` +- Create: `packages/pi-kit/test/child-env.test.ts` +- Modify: `packages/pi-kit/test/runner.test.ts` + +- [ ] **Step 1: Write failing adapter contract tests** + +Define test expectations around this interface: + +```ts +export type NormalizedLaneEvent = + | { v: 1; type: "task"; text: string } + | { v: 1; type: "text_delta"; delta: string } + | { v: 1; type: "thinking_delta"; delta: string } + | { v: 1; type: "tool_start"; tool: string; input: unknown } + | { v: 1; type: "tool_end"; tool: string; text: string; isError: boolean } + | { v: 1; type: "usage"; input: number; output: number; cacheRead: number; context: number } + | { v: 1; type: "assistant_end"; text: string }; + +export interface LaneProcessPlan { + command: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; +} + +export interface LaneExecutionAdapter { + route: ExecutionRoute; + preflight(spec: LaneSpec): Promise; + build(spec: LaneSpec): LaneProcessPlan; + createParser(spec: LaneSpec): { feedLine(line: string): NormalizedLaneEvent[]; end(): NormalizedLaneEvent[] }; +} +``` + +Test that the Pi adapter reproduces the current `pi --mode json --no-extensions --no-session -p` invocation, maps each logical selector to the exact provider/runtime model, uses `PIKIT_CHILD=1`, and normalizes the existing fixture. Read-only mode must expose only `read,grep,find,ls`; workspace-write must use the normal Pi tools without loading an extension or passing `--tools`. + +Add child-environment tests proving that only path/home/user/shell, temporary-directory, locale, terminal, and XDG runtime variables survive. Provider keys, cloud credentials, cookies, and agent sockets must not be inherited. + +- [ ] **Step 2: Run tests and verify failure** + +Run: `bun test packages/pi-kit/test/execution-adapters.test.ts packages/pi-kit/test/runner.test.ts` +Expected: FAIL because the adapter module does not exist. + +- [ ] **Step 3: Implement the interface and Pi adapter** + +Move Pi-specific command and JSON interpretation out of `runner.ts` without changing output semantics. Preserve the `piBinary` test override through adapter construction rather than a global. Route Sol, `xai/grok-4.5` at `high`, and GLM directly through Pi; never invoke the Grok CLI. + +Read-only mode passes `--tools read,grep,find,ls`. Workspace-write retains the normal Pi tool set with inherited extensions disabled and no child-specific gate. Build both routes from the scrubbed child environment. + +- [ ] **Step 4: Preserve compatibility and run focused tests** + +Keep the existing `LaneRunner` constructor and `piBinary` option green through a default compatibility Pi adapter. Defer any runner-test changes that require the full normalized-event refactor to Task 4. + +Run: `bun test packages/pi-kit/test/execution-adapters.test.ts packages/pi-kit/test/child-env.test.ts packages/pi-kit/test/runner.test.ts` +Expected: all suites PASS before commit. + +- [ ] **Step 5: Commit** + +```bash +git add packages/pi-kit/src/execution-adapter.ts packages/pi-kit/src/adapters/pi.ts packages/pi-kit/src/child-env.ts packages/pi-kit/test/execution-adapters.test.ts packages/pi-kit/test/child-env.test.ts packages/pi-kit/test/runner.test.ts +git commit -m "refactor(pi-kit): isolate lane execution adapters" +``` + +--- + +### Task 3: Add the safe Anthropic execution adapter + +**Files:** +- Create: `packages/pi-kit/src/adapters/claude-code.ts` +- Create: `packages/pi-kit/test/claude-code-adapter.test.ts` +- Create: `packages/pi-kit/test/fixtures/claude-lane-stream.jsonl` + +- [ ] **Step 1: Create a synthetic documented Claude 2.1.216 fixture** + +Author a synthetic fixture from the documented `stream-json` event shape. Do not invoke a provider. Include partial text, one tool lifecycle, final result and cumulative usage without account identifiers, private paths, credentials or tokens. A real stream may be compared only during the approved live-smoke phase and must never be committed raw. + +- [ ] **Step 2: Write failing command-contract tests** + +Assert the complete argv for both modes. Common required flags: + +```ts +[ + "-p", "--safe-mode", "--disable-slash-commands", "--no-chrome", + "--no-session-persistence", "--permission-mode", "dontAsk", + "--output-format", "stream-json", "--verbose", "--include-partial-messages", + "--model", runtimeModel, "--effort", spec.effort, +] +``` + +Read-only tools must be exactly `Read,Glob,Grep`. Workspace-write tools must be exactly the normal `Read,Glob,Grep,Edit,Write,Bash` set, with no child-specific command rules. Assert absence of `--bare`, `bypassPermissions`, `--dangerously-skip-permissions`, arbitrary extra args and `max`. + +- [ ] **Step 3: Write failing parser tests** + +Feed the synthetic fixture one complete JSONL line at a time through `feedLine`; `LaneRunner` owns byte/chunk framing. Assert normalized task/text deltas, tool events, final answer and cumulative usage. Include malformed-line, unknown-event and `end()` cases. + +- [ ] **Step 4: Run tests and verify failure** + +Run: `bun test packages/pi-kit/test/claude-code-adapter.test.ts` +Expected: FAIL because the adapter does not exist. + +- [ ] **Step 5: Implement asynchronous executable preflight, argv construction and parser** + +Resolve PATH candidates with `realpath`, require executable permission, and inspect only the candidate/wrapper script prefix for the literal dangerous flags `--dangerously-skip-permissions` and `--permission-mode bypassPermissions`; skip unsafe wrappers and fail if no safe candidate remains. Run version discovery asynchronously with a timeout and bounded output. `LaneRunner` must await every selected adapter preflight before spawning any lane, so a failed preflight owns no children and does not partially start a run. Build argv as fixed flags followed by `--` and `spec.task`, with no shell interpolation. Use the scrubbed child environment. Do not attempt to read or copy OAuth credentials. Let the genuine CLI report authentication failure as a failed lane. + +- [ ] **Step 6: Run focused tests** + +Run: `bun test packages/pi-kit/test/claude-code-adapter.test.ts` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add packages/pi-kit/src/adapters/claude-code.ts packages/pi-kit/test/claude-code-adapter.test.ts packages/pi-kit/test/fixtures/claude-lane-stream.jsonl +git commit -m "feat(pi-kit): add subscription-backed lane adapter" +``` + +--- + +### Task 4: Refactor LaneRunner around normalized events + +**Files:** +- Modify: `packages/pi-kit/src/runner.ts` +- Modify: `packages/pi-kit/src/transcript.ts` +- Create: `packages/pi-kit/test/fixtures/normalized-lane-stream.jsonl` +- Modify: `packages/pi-kit/test/runner.test.ts` +- Modify: `packages/pi-kit/test/transcript.test.ts` + +- [ ] **Step 1: Add failing compatibility and transcript tests** + +Define the canonical transcript as one JSON object per line using `NormalizedLaneEvent` with `v: 1`. The first record is `{ v: 1, type: "task", text: spec.task }`. Usage records are cumulative snapshots; adapters deduplicate partial/final provider reports, and the runner replaces prior counters rather than adding snapshots. + +Test that: + +- `LaneRunner` resolves the adapter from model metadata; +- zero or multiple matching adapters fail before spawn; +- all asynchronous adapter preflights settle before the first child spawn, and one rejection prevents every spawn; +- adapter events produce the existing journal/projection values; +- canonical transcript JSONL renders in the TUI model; +- archived Pi JSONL fixtures still render; +- adapter/source stdout remains bounded at 4 MiB; +- success, failure and abandonment do not settle until the child emits `close`; +- abandon and exit hooks still kill the whole process group. + +- [ ] **Step 2: Run focused tests and verify failure** + +Run: `bun test packages/pi-kit/test/runner.test.ts packages/pi-kit/test/transcript.test.ts` +Expected: FAIL on adapter resolution and canonical transcript cases. + +- [ ] **Step 3: Implement the smallest runner refactor** + +Inject an adapter registry into `RunnerOptions`, with the production default containing Pi and Claude adapters. Resolve all specs and await all route preflights before spawning the first child. Keep scheduling, process ownership, status throttling, projection mutation and cost estimation in `LaneRunner`. + +Validate lane IDs before path construction and resolve canonical transcript paths beneath the configured run directory. Write normalized transcript records there. If upstream diagnostics are retained, store them under a distinct capped source path. Never write secrets or full environment values. + +- [ ] **Step 4: Run focused tests** + +Run: `bun test packages/pi-kit/test/runner.test.ts packages/pi-kit/test/transcript.test.ts packages/pi-kit/test/execution-adapters.test.ts packages/pi-kit/test/claude-code-adapter.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/pi-kit/src/runner.ts packages/pi-kit/src/transcript.ts packages/pi-kit/test/runner.test.ts packages/pi-kit/test/transcript.test.ts packages/pi-kit/test/fixtures/normalized-lane-stream.jsonl +git commit -m "refactor(pi-kit): normalize lane runtime events" +``` + +--- + +### Task 5: Extract the shared LaneCoordinator + +**Files:** +- Create: `packages/pi-kit/src/lane-coordinator.ts` +- Create: `packages/pi-kit/test/lane-coordinator.test.ts` +- Modify: `packages/pi-kit/src/runner.ts` +- Modify: `packages/pi-kit/test/runner.test.ts` + +- [ ] **Step 1: Write failing lifecycle tests** + +Cover: + +- nonblocking spawn; +- one active run per coordinator; +- Pi-origin mode default; +- MCP-origin explicit mode and Anthropic rejection; +- filtered and whole-run status; +- wait settlement; +- aborted wait detachment without lane cancellation; +- named/all abandon; +- no-run errors; +- JSON-safe snapshots with lane arrays and finite numeric normalization; +- shutdown abandonment. + +- [ ] **Step 2: Run and verify failure** + +Run: `bun test packages/pi-kit/test/lane-coordinator.test.ts` +Expected: FAIL because the coordinator does not exist. + +- [ ] **Step 3: Implement the coordinator interface** + +Use a small interface: + +```ts +spawn(specs: LaneSpec[]): Promise; +status(lane?: string): RunSnapshotDto; +wait(signal?: AbortSignal): Promise; +abandon(input: { lane?: string; reason?: string }): RunSnapshotDto; +shutdown(reason: string): Promise; +``` + +Inject immutable origin (`pi` or `mcp`) at coordinator construction, plus runner creation, journaling and clock/ID generation for deterministic tests. Refine `LaneRunner` settlement so successful, failed, and abandoned lanes resolve only after child `close`; dispatch therefore cannot finish while a prior runner owns closing children. Add `LaneRunner.shutdown(reason): Promise` that abandons active lanes, performs SIGTERM→SIGKILL escalation, and awaits their closes. Remove the runner from the global active set only afterward. `LaneCoordinator.shutdown` awaits it. Add a regression that completes one run, starts a second, shuts down, and proves no process group from either run remains. Keep text formatting and Pi UI out of this module. + +- [ ] **Step 4: Run focused tests** + +Run: `bun test packages/pi-kit/test/lane-coordinator.test.ts packages/pi-kit/test/runner.test.ts` +Expected: PASS, including assertions that shutdown does not resolve before child `close`. + +- [ ] **Step 5: Commit** + +```bash +git add packages/pi-kit/src/lane-coordinator.ts packages/pi-kit/src/runner.ts packages/pi-kit/test/lane-coordinator.test.ts packages/pi-kit/test/runner.test.ts +git commit -m "feat(pi-kit): add shared lane coordinator" +``` + +--- + +### Task 6: Rewire the Pi extension without changing its UX + +**Files:** +- Modify: `packages/pi-kit/extensions/lanes.ts` +- Modify: `packages/pi-kit/test/lanes-extension.test.ts` + +- [ ] **Step 1: Add failing characterization tests** + +Freeze existing tool names, descriptions, response wording, widget/status behavior, nudges, command behavior and shutdown semantics. Add injected failures for spawn, status, wait, abandon, UI rendering, commands and shutdown; assert no exception escapes and tool failures set `isError: true`. + +- [ ] **Step 2: Run and verify the new failures** + +Run: `bun test packages/pi-kit/test/lanes-extension.test.ts` +Expected: new failure-injection tests FAIL against current unwrapped handlers. + +- [ ] **Step 3: Replace extension-owned lifecycle with LaneCoordinator** + +Keep Pi-specific formatting, widgets, TUI and nudges in the extension. Wrap every handler body. Treat UI calls as fallible. Convert coordinator errors to concise `isError` results. + +- [ ] **Step 4: Run focused tests** + +Run: `bun test packages/pi-kit/test/lanes-extension.test.ts packages/pi-kit/test/lane-coordinator.test.ts` +Expected: PASS with unchanged user-facing snapshots/text. + +- [ ] **Step 5: Commit** + +```bash +git add packages/pi-kit/extensions/lanes.ts packages/pi-kit/test/lanes-extension.test.ts +git commit -m "refactor(pi-kit): share lane lifecycle across harnesses" +``` + +--- + +### Task 7: Add the stdio MCP server + +**Files:** +- Modify: `packages/pi-kit/package.json` +- Modify: `bun.lock` +- Create: `packages/pi-kit/mcp/create-server.ts` +- Create: `packages/pi-kit/mcp/server.ts` +- Create: `packages/pi-kit/test/mcp-server.test.ts` + +- [ ] **Step 1: Add pinned runtime dependencies** + +Run: + +```bash +bun add --cwd packages/pi-kit @modelcontextprotocol/sdk@1.29.0 zod +``` + +Expected: `package.json` gains runtime dependencies and `bun.lock` changes only for resolved dependency metadata. + +- [ ] **Step 2: Write failing in-memory protocol tests** + +Create a real MCP `Client`, server factory and linked transports using `InMemoryTransport.createLinkedPair()`. Test all four exact tool names; the exact flattened DTO fields from the spec; omission of task, cwd, rationale, PID, origin, and timestamps; no-run errors; duplicate spawn; filtered status; detached wait cancellation; abandon; Anthropic rejection; handler exceptions; and finite numeric normalization. + +- [ ] **Step 3: Run and verify failure** + +Run: `bun test packages/pi-kit/test/mcp-server.test.ts` +Expected: FAIL because the server factory does not exist. + +- [ ] **Step 4: Implement `createLaneMcpServer`** + +Register tools with Zod input/output schemas through `McpServer.registerTool`. Use `extra.signal` for wait. Return text plus `structuredContent` on success and text-only `isError` on failures. + +- [ ] **Step 5: Implement the stdio entrypoint** + +Add `#!/usr/bin/env bun`. Connect `StdioServerTransport`. Write no logs to stdout. Use one idempotent async shutdown promise for SIGINT, SIGTERM, stdin `end`, stdin `close`, and transport closure: await coordinator shutdown and every child `close`, then close MCP server/transport and exit. Keep the synchronous runner exit hook only as last-resort reaping. Catch top-level failures and report only to stderr. Add a process fixture proving no child process group remains when the server exits. + +- [ ] **Step 6: Add package commands** + +Add: + +```json +{ + "bin": { "pickforge-lanes-mcp": "./mcp/server.ts" }, + "scripts": { + "mcp": "bun mcp/server.ts", + "test": "vitest run", + "typecheck": "tsc --noEmit" + } +} +``` + +- [ ] **Step 7: Run protocol and type tests** + +Run: + +```bash +bun test packages/pi-kit/test/mcp-server.test.ts +bun run typecheck +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add packages/pi-kit/package.json bun.lock packages/pi-kit/mcp packages/pi-kit/test/mcp-server.test.ts +git commit -m "feat(pi-kit): expose lanes over stdio MCP" +``` + +--- + +### Task 8: Document and validate the runtime + +**Files:** +- Create: `packages/pi-kit/README.md` +- Modify if generalized guidance emerged: `AGENTS.md` + +- [ ] **Step 1: Document interfaces and non-goals** + +Document logical selectors, route ownership, mode semantics, MCP tools, Claude native-workflow rule, lifecycle, data paths, auth boundaries, failure behavior and exact local commands. Do not duplicate the comparative routing table in prose; render it from or point to the canonical table. + +- [ ] **Step 2: Run all non-billable validation** + +Run without wrappers: + +```bash +bun test packages/pi-kit/test +bun run typecheck +``` + +Expected baseline plus new tests: PASS, zero failures. + +- [ ] **Step 3: Verify CLI discovery without invoking a model** + +Run: + +```bash +bun packages/pi-kit/mcp/server.ts .jsonl`; canonical per-lane transcripts are at `~/.pickforge/pi-kit/raw//.jsonl`. Set `PIKIT_DATA_DIR` to move both roots. Journal and transcript writes are best-effort and never determine lane settlement or crash the parent. + +Canonical transcript events are `task`, `thinking_delta`, `text_delta`, `tool_start`, `tool_end`, cumulative `usage`, and `assistant_end`. `LaneTranscript` also reads archived Pi JSONL. Canonical transcript capture is capped at 4 MiB per lane, and child stdout over 4 MiB fails the lane. Stored final answers are capped at 4,000 characters; rendered text/thinking entries at 20,000 characters and tool inputs/results at 4,000 characters. Parser and spawn failures become failed lanes; missing routes fail before child spawn. + +## Validation + +These checks are synthetic and do not invoke a model: + +```sh +bun test packages/pi-kit/test +bun run typecheck +bun packages/pi-kit/mcp/server.ts ; + status(lane?: string): RunSnapshotDto; + wait(signal?: AbortSignal): Promise; + abandon(input: { lane?: string; reason?: string }): RunSnapshotDto; + shutdown(reason: string): Promise; +} + +export interface LanesExtensionOptions { + createCoordinator?: () => LanesCoordinatorPort; +} + interface LastRun { id: string; - runner: LaneRunner; - projection: RunProjection; + snapshot: RunSnapshotDto; + ctx: ExtensionContext; hidden: boolean; - /** Resolves when every lane settles. */ - settled: Promise; - /** True once run_end was journaled and the summary is available. */ + /** True once the coordinated run has settled and the summary is available. */ ended: boolean; /** True once the model has consumed the final results (wait or nudge). */ reported: boolean; /** Number of lanes_wait calls currently attached; suppresses the settle nudge. */ waiters: number; + timer?: NodeJS.Timeout; } interface SpawnLaneInput { @@ -37,7 +52,7 @@ function renderWidget(ctx: WidgetContext, run: LastRun): void { try { renderWidgetUnsafe(ctx, run); } catch { - // Widget rendering must never break the runner or the session. + // Widget rendering must never break the coordinator or the session. } } @@ -62,10 +77,10 @@ function fmtDuration(ms: number): string { function renderWidgetUnsafe(ctx: WidgetContext, run: LastRun): void { const theme = ctx.ui.theme; - const lanes = [...run.projection.lanes.values()]; + const lanes = run.snapshot.lanes; spinnerTick = (spinnerTick + 1) % SPINNER.length; - const stateGlyph = (state: LaneProjection["state"]): string => { + const stateGlyph = (state: LaneSnapshotDto["state"]): string => { switch (state) { case "queued": return theme.fg("dim", "◌"); @@ -102,19 +117,19 @@ function renderWidgetUnsafe(ctx: WidgetContext, run: LastRun): void { theme.fg("accent", "▸ lanes"), theme.fg("muted", run.id), `${progress} ${theme.fg("text", `${settled}/${lanes.length}`)}`, - theme.fg("warning", `$${run.projection.totalCost.toFixed(4)}`), - theme.fg("muted", `↑${fmtTokens(run.projection.totalTokensIn)} ↓${fmtTokens(run.projection.totalTokensOut)}`), + theme.fg("warning", `$${run.snapshot.totals.cost.toFixed(4)}`), + theme.fg("muted", `↑${fmtTokens(run.snapshot.totals.tokensIn)} ↓${fmtTokens(run.snapshot.totals.tokensOut)}`), ].join(dot); - const nameWidth = Math.min(24, Math.max(...lanes.map((l) => l.spec.lane.length), 4)); + const nameWidth = Math.min(24, Math.max(...lanes.map((l) => l.lane.length), 4)); const lines = [ header, ...lanes.map((lane) => { const name = lane.state === "running" - ? theme.fg("text", lane.spec.lane.padEnd(nameWidth)) - : theme.fg("muted", lane.spec.lane.padEnd(nameWidth)); - const model = theme.fg("dim", `${shortModel(lane.spec.model)}:${lane.spec.effort}`); + ? theme.fg("text", lane.lane.padEnd(nameWidth)) + : theme.fg("muted", lane.lane.padEnd(nameWidth)); + const model = theme.fg("dim", `${shortModel(lane.model)}:${lane.effort}`); const tokens = theme.fg("muted", `↑${fmtTokens(lane.tokensIn)} ↓${fmtTokens(lane.tokensOut)}`); const cost = theme.fg("warning", `$${lane.cost.toFixed(4)}`); const parts = [`${stateGlyph(lane.state)} ${name}`, model, tokens, cost]; @@ -132,23 +147,134 @@ function renderWidgetUnsafe(ctx: WidgetContext, run: LastRun): void { ctx.ui.setStatus( "pi-lanes", running > 0 - ? theme.fg("accent", `▸ ${running} lane${running === 1 ? "" : "s"} running · $${run.projection.totalCost.toFixed(4)}`) + ? theme.fg("accent", `▸ ${running} lane${running === 1 ? "" : "s"} running · $${run.snapshot.totals.cost.toFixed(4)}`) : failed > 0 ? theme.fg("error", `▸ lanes: ${done}✔ ${failed}✖`) : theme.fg("success", `▸ lanes: ${done}/${lanes.length} ✔`), ); } -export default function lanesExtension(pi: ExtensionAPI): void { +function errorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/\s+/g, " ").trim().slice(0, 240) || "unknown error"; +} + +function toolFailure(name: string, error: unknown) { + return { + content: [{ type: "text" as const, text: `${name} failed: ${errorMessage(error)}` }], + details: undefined, + isError: true, + }; +} + +function safeNotify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error"): void { + try { + ctx.ui.notify(message, level); + } catch { + // Notifications are best-effort. + } +} + +function clearWidget(ctx: ExtensionContext): void { + try { + ctx.ui.setWidget("pi-lanes", undefined); + } catch { + // UI cleanup is best-effort. + } + try { + ctx.ui.setStatus("pi-lanes", undefined); + } catch { + // UI cleanup is best-effort. + } +} + +export default function lanesExtension(pi: ExtensionAPI, options: LanesExtensionOptions = {}): void { let lastRun: LastRun | undefined; + const coordinator = + options.createCoordinator?.() ?? + new LaneCoordinator({ + origin: "pi", + createRunner: (runnerOptions) => + new LaneRunner({ + ...runnerOptions, + onUpdate() { + try { + if (lastRun?.id === runnerOptions.runId) renderLiveWidget(lastRun.ctx, lastRun); + } catch { + // Runner updates must never escape into the coordinated lifecycle. + } + }, + }), + }); + + function updateRun(run: LastRun, snapshot: RunSnapshotDto): void { + if (snapshot.run !== run.id) return; + run.snapshot = snapshot; + run.ended = snapshot.state === "ended"; + } + + function refreshRun(run: LastRun): void { + updateRun(run, coordinator.status()); + } + + function renderLiveWidget(ctx: ExtensionContext, run: LastRun): void { + try { + refreshRun(run); + } catch { + // A stale snapshot is sufficient for best-effort UI rendering. + } + renderWidget(ctx, run); + } + + function sendSettleNudge(run: LastRun, ctx: ExtensionContext): void { + if (lastRun !== run || !run.ended || run.reported || run.waiters > 0) return; + try { + if (ctx.isIdle()) { + run.reported = true; + pi.sendUserMessage( + `[lanes] run ${run.id} settled. Call lanes_status for the results, then continue where we left off.`, + ); + } else { + pi.sendUserMessage( + `[lanes] run ${run.id} settled — results available via lanes_status/lanes_wait when convenient.`, + { deliverAs: "followUp" }, + ); + } + } catch { + // Nudge is best-effort; lanes_status still works. + } + } + + async function monitorSettlement(run: LastRun, ctx: ExtensionContext): Promise { + try { + updateRun(run, await coordinator.wait()); + } catch { + return; + } finally { + clearInterval(run.timer); + if (lastRun === run) renderWidget(ctx, run); + } + sendSettleNudge(run, ctx); + } // Lanes survive ESC/turn cancellation, but not the session itself: on - // shutdown (/new, reload, exit) any still-active run is abandoned so child - // processes cannot outlive their owning session. - pi.on("session_shutdown", () => { - if (lastRun && !lastRun.ended) { - lastRun.reported = true; // never nudge a session that is going away - lastRun.runner.abandonAll("session ended"); + // shutdown (/new, reload, exit) active children are stopped and reaped. + pi.on("session_shutdown", async () => { + if (lastRun) lastRun.reported = true; + try { + await coordinator.shutdown("session ended"); + if (lastRun) { + try { + refreshRun(lastRun); + } catch { + // Shutdown status refresh is best-effort. + } + renderWidget(lastRun.ctx, lastRun); + } + } catch { + // Session shutdown must never escape into the parent session. + } finally { + if (lastRun) clearInterval(lastRun.timer); } }); @@ -169,161 +295,95 @@ export default function lanesExtension(pi: ExtensionAPI): void { { minItems: 1 }, ), }), - async execute(_toolCallId, params, signal, _onUpdate, ctx) { - const specs: LaneSpec[] = params.lanes.map((lane: SpawnLaneInput, index: number) => ({ - lane: lane.lane?.trim() || `lane-${index + 1}`, - task: lane.task, - model: lane.model, - effort: lane.effort as LaneSpec["effort"], - ...(lane.cwd ? { cwd: lane.cwd } : {}), - ...(lane.rationale ? { rationale: lane.rationale } : {}), - })); - const violations = specs.flatMap((spec) => { - const reason = validateLaneSpec(spec); - return reason ? [`${spec.lane}: ${reason}`] : []; - }); - if (violations.length > 0) { - return { - content: [{ type: "text" as const, text: `Invalid lane specs:\n${violations.join("\n")}` }], - details: { violations }, - isError: true, - }; - } + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + try { + const specs: LaneSpec[] = params.lanes.map((lane: SpawnLaneInput, index: number) => ({ + lane: lane.lane?.trim() || `lane-${index + 1}`, + task: lane.task, + model: lane.model, + effort: lane.effort as LaneSpec["effort"], + ...(lane.cwd ? { cwd: lane.cwd } : {}), + ...(lane.rationale ? { rationale: lane.rationale } : {}), + })); + const violations = specs.flatMap((spec) => { + const reason = validateLaneSpec(spec, { origin: "pi" }); + return reason ? [`${spec.lane}: ${reason}`] : []; + }); + if (violations.length > 0) { + return { + content: [{ type: "text" as const, text: `Invalid lane specs:\n${violations.join("\n")}` }], + details: { violations }, + isError: true, + }; + } - if (lastRun && !lastRun.ended) { - return { - content: [ - { - type: "text" as const, - text: `Run ${lastRun.id} is still active. Use lanes_status to inspect it, lanes_wait to collect it, or lanes_abandon to stop it before spawning a new run.`, - }, - ], - details: { activeRun: lastRun.id }, - isError: true, - }; - } + if (lastRun && !lastRun.ended) { + return { + content: [ + { + type: "text" as const, + text: `Run ${lastRun.id} is still active. Use lanes_status to inspect it, lanes_wait to collect it, or lanes_abandon to stop it before spawning a new run.`, + }, + ], + details: { activeRun: lastRun.id }, + isError: true, + }; + } - const runId = newRunId(); - const startedAt = Date.now(); - appendEvent({ - v: 1, - t: new Date().toISOString(), - run: runId, - type: "run_created", - lanes: specs.length, - origin: "lanes_spawn", - }); - - let view: LastRun; - const activeRunner = new LaneRunner({ - runId, - append: appendEvent, - rawDir: rawRunDir(runId), - onUpdate() { - if (view && lastRun === view) renderWidget(ctx, view); - }, - }); - - // Deliberately NOT wired to the tool-call abort signal: lanes survive - // ESC / turn cancellation. Stopping a run is explicit via lanes_abandon - // or /lanes abandon. - const timer = ctx.hasUI ? setInterval(() => renderWidget(ctx, view), 1_000) : undefined; - timer?.unref(); - - const settled = activeRunner - .dispatch(specs) - .then((projections) => { - const ok = projections.every((lane) => lane.state === "done"); - appendEvent({ - v: 1, - t: new Date().toISOString(), - run: runId, - type: "run_end", - ok, - durationMs: Date.now() - startedAt, - }); - return projections; - }) - .catch((error: unknown) => { - activeRunner.abandonAll("lanes run error"); - appendEvent({ - v: 1, - t: new Date().toISOString(), - run: runId, - type: "run_end", - ok: false, - durationMs: Date.now() - startedAt, - }); - return [...activeRunner.projection().lanes.values()]; - }) - .finally(() => { - clearInterval(timer); - view.ended = true; - // Widget stays visible after completion; only /lanes hide or a new run replaces it. - if (lastRun === view) renderWidget(ctx, view); - // Nudge the model when it is idle and never collected the results. - if (lastRun === view && !view.reported && view.waiters === 0) { - try { - if (ctx.isIdle()) { - view.reported = true; - pi.sendUserMessage( - `[lanes] run ${runId} settled. Call lanes_status for the results, then continue where we left off.`, - ); - } else { - pi.sendUserMessage( - `[lanes] run ${runId} settled — results available via lanes_status/lanes_wait when convenient.`, - { deliverAs: "followUp" }, - ); - } - } catch { - // Nudge is best-effort; lanes_status still works. - } - } - }); + // Deliberately NOT wired to the tool-call abort signal: lanes survive + // ESC / turn cancellation. Stopping is explicit via lanes_abandon. + const snapshot = await coordinator.spawn(specs); + const view: LastRun = { + id: snapshot.run, + snapshot, + ctx, + hidden: false, + ended: snapshot.state === "ended", + reported: false, + waiters: 0, + }; + if (ctx.hasUI && !view.ended) { + view.timer = setInterval(() => renderLiveWidget(ctx, view), 1_000); + view.timer.unref(); + } + lastRun = view; + renderWidget(ctx, view); + void monitorSettlement(view, ctx); - view = { - id: runId, - runner: activeRunner, - projection: activeRunner.projection(), - hidden: false, - settled, - ended: false, - reported: false, - waiters: 0, - }; - lastRun = view; - renderWidget(ctx, view); - - const text = [ - `Run ${runId} spawned with ${specs.length} lane${specs.length === 1 ? "" : "s"} (non-blocking):`, - ...specs.map((spec) => ` ${spec.lane}: ${spec.model}:${spec.effort}`), - "Lanes run in the background and survive turn cancellation.", - "Use lanes_status to check progress or answer questions about a lane, lanes_wait to block for final results, lanes_abandon to stop lanes.", - "You can keep working with the user meanwhile — do not poll lanes_status in a loop.", - ].join("\n"); - return { content: [{ type: "text" as const, text }], details: { run: runId, lanes: specs.length } }; + const text = [ + `Run ${view.id} spawned with ${specs.length} lane${specs.length === 1 ? "" : "s"} (non-blocking):`, + ...specs.map((spec) => ` ${spec.lane}: ${spec.model}:${spec.effort}`), + "Lanes run in the background and survive turn cancellation.", + "Use lanes_status to check progress or answer questions about a lane, lanes_wait to block for final results, lanes_abandon to stop lanes.", + "You can keep working with the user meanwhile — do not poll lanes_status in a loop.", + ].join("\n"); + return { content: [{ type: "text" as const, text }], details: { run: view.id, lanes: specs.length } }; + } catch (error) { + return toolFailure("lanes_spawn", error); + } }, }); - function summarizeLane(lane: LaneProjection, verbose: boolean): string { + function summarizeLane(lane: LaneSnapshotDto, verbose: boolean): string { const answerLimit = verbose ? 4_000 : 400; const answer = lane.answer?.replace(/\s+/g, " ").trim().slice(0, answerLimit) ?? ""; const failure = lane.state === "failed" || lane.state === "abandoned" ? ` · ${lane.abandonReason ?? "failed"}` : ""; const activity = lane.state === "running" ? ` · ${(lane.currentTool ?? lane.lastStatus ?? "working").slice(0, verbose ? 200 : 60)}` : ""; const duration = lane.durationMs !== undefined ? ` · ${fmtDuration(lane.durationMs)}` : ""; - return `${lane.spec.lane}: ${lane.state} · ${lane.spec.model}:${lane.spec.effort} · tok ${lane.tokensIn}/${lane.tokensOut} · $${lane.cost.toFixed(4)}${duration}${failure}${activity}${answer ? ` · ${answer}` : ""}`; + return `${lane.lane}: ${lane.state} · ${lane.model}:${lane.effort} · tok ${lane.tokensIn}/${lane.tokensOut} · $${lane.cost.toFixed(4)}${duration}${failure}${activity}${answer ? ` · ${answer}` : ""}`; } - function summarizeRun(run: LastRun, laneFilter?: string): { text: string; lanes: LaneProjection[]; missing?: string } { - const all = [...run.projection.lanes.values()]; + function summarizeRun(snapshot: RunSnapshotDto, laneFilter?: string): { text: string; lanes: LaneSnapshotDto[] } { if (laneFilter) { - const lane = run.projection.lanes.get(laneFilter); - if (!lane) return { text: "", lanes: [], missing: laneFilter }; - return { text: `run ${run.id} (${run.ended ? "ended" : "active"})\n${summarizeLane(lane, true)}`, lanes: [lane] }; + const lane = snapshot.lanes[0]!; + return { + text: `run ${snapshot.run} (${snapshot.state})\n${summarizeLane(lane, true)}`, + lanes: [lane], + }; } - const header = `run ${run.id} (${run.ended ? "ended" : "active"}) · $${run.projection.totalCost.toFixed(4)} · tok ${run.projection.totalTokensIn}/${run.projection.totalTokensOut}`; - return { text: [header, ...all.map((lane) => summarizeLane(lane, run.ended))].join("\n"), lanes: all }; + const header = `run ${snapshot.run} (${snapshot.state}) · $${snapshot.totals.cost.toFixed(4)} · tok ${snapshot.totals.tokensIn}/${snapshot.totals.tokensOut}`; + return { text: [header, ...snapshot.lanes.map((lane) => summarizeLane(lane, snapshot.state === "ended"))].join("\n"), lanes: snapshot.lanes }; } pi.registerTool({ @@ -335,20 +395,27 @@ export default function lanesExtension(pi: ExtensionAPI): void { lane: Type.Optional(Type.String()), }), async execute(_toolCallId, params) { - if (!lastRun) { - return { content: [{ type: "text" as const, text: "No lane run has been spawned this session." }], details: undefined }; - } - const { text, missing } = summarizeRun(lastRun, params.lane); - if (missing) { - const known = [...lastRun.projection.lanes.keys()].join(", "); - return { - content: [{ type: "text" as const, text: `Unknown lane "${missing}". Lanes in run ${lastRun.id}: ${known}` }], - details: undefined, - isError: true, - }; + try { + if (!lastRun) { + return { content: [{ type: "text" as const, text: "No lane run has been spawned this session." }], details: undefined }; + } + if (params.lane && !lastRun.snapshot.lanes.some((lane) => lane.lane === params.lane)) { + const known = lastRun.snapshot.lanes.map((lane) => lane.lane).join(", "); + return { + content: [{ type: "text" as const, text: `Unknown lane "${params.lane}". Lanes in run ${lastRun.id}: ${known}` }], + details: undefined, + isError: true, + }; + } + const snapshot = coordinator.status(params.lane); + if (!params.lane) updateRun(lastRun, snapshot); + else lastRun.ended = snapshot.state === "ended"; + if (snapshot.state === "ended") lastRun.reported = true; + const { text } = summarizeRun(snapshot, params.lane); + return { content: [{ type: "text" as const, text }], details: undefined }; + } catch (error) { + return toolFailure("lanes_status", error); } - if (lastRun.ended) lastRun.reported = true; - return { content: [{ type: "text" as const, text }], details: undefined }; }, }); @@ -359,39 +426,39 @@ export default function lanesExtension(pi: ExtensionAPI): void { "Block until the current lane run settles and return the final per-lane results. Call this when you have nothing else to do for the user, or when the user asks for the results. Cancelling this wait does NOT stop the lanes — they keep running and lanes_status/lanes_wait remain available.", parameters: Type.Object({}), async execute(_toolCallId, _params, signal) { - if (!lastRun) { - return { content: [{ type: "text" as const, text: "No lane run has been spawned this session." }], details: undefined }; - } - const run = lastRun; - if (!run.ended) { - // Wait for settlement or the tool call being aborted — abort only - // detaches the wait, it never touches the lanes themselves. - run.waiters++; - try { - await Promise.race([ - run.settled, - new Promise((resolve) => { - if (!signal) return; - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }), - ]); - } finally { - run.waiters--; + let attachedRun: LastRun | undefined; + try { + if (!lastRun) { + return { content: [{ type: "text" as const, text: "No lane run has been spawned this session." }], details: undefined }; } - if (!run.ended) { - return { - content: [ - { type: "text" as const, text: `Wait detached; run ${run.id} keeps running. Use lanes_status or lanes_wait again.` }, - ], - details: undefined, - }; + const run = lastRun; + let snapshot = run.snapshot; + if (run.ended) { + snapshot = coordinator.status(); + updateRun(run, snapshot); + } else { + run.waiters++; + attachedRun = run; + snapshot = await coordinator.wait(signal); + updateRun(run, snapshot); + if (snapshot.state !== "ended") { + return { + content: [ + { type: "text" as const, text: `Wait detached; run ${run.id} keeps running. Use lanes_status or lanes_wait again.` }, + ], + details: undefined, + }; + } } + run.reported = true; + const { text, lanes } = summarizeRun(snapshot); + const ok = lanes.every((lane) => lane.state === "done"); + return { content: [{ type: "text" as const, text }], details: lanes, isError: !ok }; + } catch (error) { + return toolFailure("lanes_wait", error); + } finally { + if (attachedRun) attachedRun.waiters--; } - run.reported = true; - const { text, lanes } = summarizeRun(run); - const ok = lanes.every((lane) => lane.state === "done"); - return { content: [{ type: "text" as const, text }], details: lanes, isError: !ok }; }, }); @@ -405,85 +472,90 @@ export default function lanesExtension(pi: ExtensionAPI): void { reason: Type.Optional(Type.String()), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - if (!lastRun) { - return { content: [{ type: "text" as const, text: "No lane run has been spawned this session." }], details: undefined }; - } - const reason = params.reason?.trim() || "abandoned by model"; - if (params.lane) { - if (!lastRun.projection.lanes.has(params.lane)) { - const known = [...lastRun.projection.lanes.keys()].join(", "); + try { + if (!lastRun) { + return { content: [{ type: "text" as const, text: "No lane run has been spawned this session." }], details: undefined }; + } + if (params.lane && !lastRun.snapshot.lanes.some((lane) => lane.lane === params.lane)) { + const known = lastRun.snapshot.lanes.map((lane) => lane.lane).join(", "); return { content: [{ type: "text" as const, text: `Unknown lane "${params.lane}". Lanes: ${known}` }], details: undefined, isError: true, }; } - lastRun.runner.abandon(params.lane, reason); - } else { - lastRun.runner.abandonAll(reason); + const reason = params.reason?.trim() || "abandoned by model"; + updateRun(lastRun, coordinator.abandon({ ...(params.lane ? { lane: params.lane } : {}), reason })); + renderWidget(ctx, lastRun); + const { text } = summarizeRun(lastRun.snapshot); + return { content: [{ type: "text" as const, text }], details: undefined }; + } catch (error) { + return toolFailure("lanes_abandon", error); } - renderWidget(ctx, lastRun); - const { text } = summarizeRun(lastRun); - return { content: [{ type: "text" as const, text }], details: undefined }; }, }); pi.registerCommand("lanes", { description: "Show, hide, summarize, abandon lanes, or open the full-screen TUI", handler: async (args, ctx) => { - const [command = "show", lane] = (args.trim() || "show").split(/\s+/, 2); - if (command === "tui") { - if (!ctx.hasUI) { - ctx.ui.notify("lanes tui requires interactive mode", "warning"); + try { + const [command = "show", lane] = (args.trim() || "show").split(/\s+/, 2); + if (command === "tui") { + if (!ctx.hasUI) { + safeNotify(ctx, "lanes tui requires interactive mode", "warning"); + return; + } + await ctx.ui.custom( + (tui, theme, _keybindings, done) => new LanesTuiComponent(tui, theme, () => done(undefined), lastRun?.id), + { + overlay: true, + overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", row: 0, col: 0 }, + }, + ); return; } - await ctx.ui.custom( - (tui, theme, _keybindings, done) => new LanesTuiComponent(tui, theme, () => done(undefined), lastRun?.id), - { - overlay: true, - overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", row: 0, col: 0 }, - }, - ); - return; - } - if (command === "hide") { - if (lastRun) lastRun.hidden = true; - ctx.ui.setWidget("pi-lanes", undefined); - ctx.ui.setStatus("pi-lanes", undefined); - return; - } - if (!lastRun) { - ctx.ui.notify("No lane run is available", "info"); - return; - } - if (command === "abandon") { - if (!lane) { - ctx.ui.notify("Usage: /lanes abandon ", "warning"); + if (command === "hide") { + if (lastRun) lastRun.hidden = true; + clearWidget(ctx); return; } - if (lane === "all") { - lastRun.runner.abandonAll("user"); - } else { - lastRun.runner.abandon(lane, "user"); + if (!lastRun) { + safeNotify(ctx, "No lane run is available", "info"); + return; } - renderWidget(ctx, lastRun); - return; - } - if (command === "last") { - const lanes = [...lastRun.projection.lanes.values()]; - const running = lanes.filter((item) => item.state === "running").length; - ctx.ui.notify( - `run ${lastRun.id} · ${running} running · ${lanes.length} lanes · $${lastRun.projection.totalCost.toFixed(4)} · ${lastRun.projection.totalTokensIn}/${lastRun.projection.totalTokensOut} tokens`, - "info", - ); - return; - } - if (command === "show") { - lastRun.hidden = false; - renderWidget(ctx, lastRun); - return; + if (command === "abandon") { + if (!lane) { + safeNotify(ctx, "Usage: /lanes abandon ", "warning"); + return; + } + if (lane !== "all" && !lastRun.snapshot.lanes.some((item) => item.lane === lane)) return; + updateRun( + lastRun, + coordinator.abandon(lane === "all" ? { reason: "user" } : { lane, reason: "user" }), + ); + renderWidget(ctx, lastRun); + return; + } + if (command === "last") { + refreshRun(lastRun); + const lanes = lastRun.snapshot.lanes; + const running = lanes.filter((item) => item.state === "running").length; + safeNotify( + ctx, + `run ${lastRun.id} · ${running} running · ${lanes.length} lanes · $${lastRun.snapshot.totals.cost.toFixed(4)} · ${lastRun.snapshot.totals.tokensIn}/${lastRun.snapshot.totals.tokensOut} tokens`, + "info", + ); + return; + } + if (command === "show") { + lastRun.hidden = false; + renderLiveWidget(ctx, lastRun); + return; + } + safeNotify(ctx, "Usage: /lanes [show|hide|last|tui|abandon ]", "warning"); + } catch (error) { + safeNotify(ctx, `lanes command failed: ${errorMessage(error)}`, "error"); } - ctx.ui.notify("Usage: /lanes [show|hide|last|tui|abandon ]", "warning"); }, }); } diff --git a/packages/pi-kit/mcp/create-server.ts b/packages/pi-kit/mcp/create-server.ts new file mode 100644 index 0000000..084b63d --- /dev/null +++ b/packages/pi-kit/mcp/create-server.ts @@ -0,0 +1,205 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod/v4"; +import { + LaneCoordinator, + type RunSnapshotDto, +} from "../src/lane-coordinator.ts"; +import type { LaneSpec } from "../src/schema.ts"; + +export interface LaneMcpCoordinator { + spawn(specs: LaneSpec[]): Promise; + status(lane?: string): RunSnapshotDto; + wait(signal?: AbortSignal): Promise; + abandon(input: { lane?: string; reason?: string }): RunSnapshotDto; + shutdown(reason: string): Promise; +} + +export interface CreateLaneMcpServerOptions { + coordinator?: LaneMcpCoordinator; +} + +const effortSchema = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]); +const modeSchema = z.enum(["read-only", "workspace-write"]); +const stateSchema = z.enum(["queued", "running", "done", "failed", "abandoned"]); +const runStateSchema = z.enum(["active", "ended"]); + +const laneSnapshotSchema = z.object({ + lane: z.string(), + model: z.string(), + effort: effortSchema, + mode: modeSchema, + state: stateSchema, + currentTool: z.string().optional(), + lastStatus: z.string().optional(), + tokensIn: z.number(), + tokensOut: z.number(), + cost: z.number(), + context: z.number(), + answer: z.string().optional(), + durationMs: z.number().optional(), + abandonReason: z.string().optional(), +}); + +const runSnapshotSchema = z.object({ + run: z.string(), + state: runStateSchema, + ok: z.boolean().optional(), + totals: z.object({ + cost: z.number(), + tokensIn: z.number(), + tokensOut: z.number(), + }), + lanes: z.array(laneSnapshotSchema), +}); + +type WireRunSnapshot = z.infer; + +const spawnLaneSchema = z.object({ + lane: z.string().trim().min(1).optional(), + task: z.string().min(1), + model: z.string().min(1), + effort: effortSchema, + mode: modeSchema, + cwd: z.string().optional(), + rationale: z.string().optional(), +}); + +function finite(value: number): number { + return Number.isFinite(value) ? value : 0; +} + +function wireSnapshot(snapshot: RunSnapshotDto): WireRunSnapshot { + return { + run: snapshot.run, + state: snapshot.state, + ...(snapshot.ok !== undefined ? { ok: snapshot.ok } : {}), + totals: { + cost: finite(snapshot.totals.cost), + tokensIn: finite(snapshot.totals.tokensIn), + tokensOut: finite(snapshot.totals.tokensOut), + }, + lanes: snapshot.lanes.map((lane) => ({ + lane: lane.lane, + model: lane.model, + effort: lane.effort, + mode: lane.mode, + state: lane.state, + ...(lane.currentTool !== undefined ? { currentTool: lane.currentTool } : {}), + ...(lane.lastStatus !== undefined ? { lastStatus: lane.lastStatus } : {}), + tokensIn: finite(lane.tokensIn), + tokensOut: finite(lane.tokensOut), + cost: finite(lane.cost), + context: finite(lane.context), + ...(lane.answer !== undefined ? { answer: lane.answer } : {}), + ...(lane.durationMs !== undefined ? { durationMs: finite(lane.durationMs) } : {}), + ...(lane.abandonReason !== undefined ? { abandonReason: lane.abandonReason } : {}), + })), + }; +} + +function errorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/\s+/g, " ").trim().slice(0, 240) || "unknown error"; +} + +function failure(tool: string, error: unknown) { + return { + content: [{ type: "text" as const, text: `${tool} failed: ${errorMessage(error)}` }], + isError: true, + }; +} + +function success(snapshot: RunSnapshotDto) { + const dto = wireSnapshot(snapshot); + const text = `run ${dto.run} (${dto.state}) · ${dto.lanes.length} lane${dto.lanes.length === 1 ? "" : "s"} · $${dto.totals.cost.toFixed(4)}`; + return { + content: [{ type: "text" as const, text }], + structuredContent: dto, + }; +} + +export function createLaneMcpServer(options: CreateLaneMcpServerOptions = {}) { + const coordinator = options.coordinator ?? new LaneCoordinator({ origin: "mcp" }); + const server = new McpServer({ name: "pickforge-lanes", version: "0.1.0" }); + + server.registerTool( + "lanes_spawn", + { + description: "Spawn one cross-provider lane run without blocking.", + inputSchema: z.object({ lanes: z.array(spawnLaneSchema).min(1) }), + outputSchema: runSnapshotSchema, + }, + async ({ lanes }) => { + try { + const specs: LaneSpec[] = lanes.map((lane, index) => ({ + lane: lane.lane || `lane-${index + 1}`, + task: lane.task, + model: lane.model, + effort: lane.effort, + mode: lane.mode, + ...(lane.cwd !== undefined ? { cwd: lane.cwd } : {}), + ...(lane.rationale !== undefined ? { rationale: lane.rationale } : {}), + })); + return success(await coordinator.spawn(specs)); + } catch (error) { + return failure("lanes_spawn", error); + } + }, + ); + + server.registerTool( + "lanes_status", + { + description: "Return the current run or one named lane without blocking.", + inputSchema: z.object({ lane: z.string().min(1).optional() }), + outputSchema: runSnapshotSchema, + }, + async ({ lane }) => { + try { + return success(coordinator.status(lane)); + } catch (error) { + return failure("lanes_status", error); + } + }, + ); + + server.registerTool( + "lanes_wait", + { + description: "Wait for the current run; cancellation detaches only this wait.", + inputSchema: z.object({}), + outputSchema: runSnapshotSchema, + }, + async (_input, extra) => { + try { + return success(await coordinator.wait(extra.signal)); + } catch (error) { + return failure("lanes_wait", error); + } + }, + ); + + server.registerTool( + "lanes_abandon", + { + description: "Abandon one named lane or every active lane in the current run.", + inputSchema: z.object({ + lane: z.string().min(1).optional(), + reason: z.string().optional(), + }), + outputSchema: runSnapshotSchema, + }, + async ({ lane, reason }) => { + try { + return success(coordinator.abandon({ + ...(lane !== undefined ? { lane } : {}), + ...(reason !== undefined ? { reason } : {}), + })); + } catch (error) { + return failure("lanes_abandon", error); + } + }, + ); + + return { server, coordinator }; +} diff --git a/packages/pi-kit/mcp/server.ts b/packages/pi-kit/mcp/server.ts new file mode 100755 index 0000000..6195544 --- /dev/null +++ b/packages/pi-kit/mcp/server.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env bun +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createLaneMcpServer } from "./create-server.ts"; + +function boundedError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/\s+/g, " ").trim().slice(0, 500) || "unknown error"; +} + +async function main(): Promise { + const { server, coordinator } = createLaneMcpServer(); + const transport = new StdioServerTransport(); + let shutdownPromise: Promise | undefined; + let reported = false; + + const shutdown = (reason: string): Promise => { + if (!shutdownPromise) { + shutdownPromise = (async () => { + let firstError: unknown; + try { + await coordinator.shutdown(reason); + } catch (error) { + firstError = error; + } + try { + await server.close(); + } catch (error) { + firstError ??= error; + } + if (firstError !== undefined) throw firstError; + })(); + } + return shutdownPromise; + }; + + const requestShutdown = (reason: string): void => { + void shutdown(reason).catch((error) => { + if (!reported) { + reported = true; + console.error(`pickforge-lanes-mcp: ${boundedError(error)}`); + } + process.exitCode = 1; + }); + }; + + process.once("SIGINT", () => requestShutdown("received SIGINT")); + process.once("SIGTERM", () => requestShutdown("received SIGTERM")); + process.stdin.once("end", () => requestShutdown("stdin ended")); + process.stdin.once("close", () => requestShutdown("stdin closed")); + server.server.onclose = () => requestShutdown("transport closed"); + + try { + await server.connect(transport); + } catch (error) { + await shutdown("transport startup failed"); + throw error; + } +} + +void main().catch((error) => { + console.error(`pickforge-lanes-mcp: ${boundedError(error)}`); + process.exitCode = 1; +}); diff --git a/packages/pi-kit/package.json b/packages/pi-kit/package.json index 4eb7c16..df5c9d7 100644 --- a/packages/pi-kit/package.json +++ b/packages/pi-kit/package.json @@ -4,6 +4,9 @@ "description": "Personal Pi sovereignty kit: lane runner + TUI, model policy, event journal, delivery gates, vault promotion.", "type": "module", "private": true, + "bin": { + "pickforge-lanes-mcp": "./mcp/server.ts" + }, "pi": { "extensions": [ "extensions/table.ts", @@ -22,7 +25,8 @@ }, "scripts": { "test": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "mcp": "bun mcp/server.ts" }, "devDependencies": { "@earendil-works/pi-coding-agent": "0.79.10", @@ -31,5 +35,9 @@ "typebox": "1.1.38", "@earendil-works/pi-ai": "0.79.10", "@earendil-works/pi-tui": "0.79.10" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.29.0", + "zod": "^4.4.3" } } \ No newline at end of file diff --git a/packages/pi-kit/src/adapters/claude-code.ts b/packages/pi-kit/src/adapters/claude-code.ts new file mode 100644 index 0000000..d61e0a8 --- /dev/null +++ b/packages/pi-kit/src/adapters/claude-code.ts @@ -0,0 +1,545 @@ +import { spawn } from "node:child_process"; +import { + accessSync, + closeSync, + constants, + openSync, + readSync, + realpathSync, + statSync, +} from "node:fs"; +import { delimiter, isAbsolute, join, resolve } from "node:path"; +import { createChildEnvironment } from "../child-env.ts"; +import type { + LaneEventParser, + LaneExecutionAdapter, + LaneProcessPlan, + NormalizedLaneEvent, +} from "../execution-adapter.ts"; +import type { LaneSpec } from "../schema.ts"; +import { findModel } from "../table.ts"; + +export const CLAUDE_CODE_MIN_VERSION = "2.1.216"; + +const WRAPPER_PREFIX_BYTES = 64 * 1024; +const VERSION_OUTPUT_BYTES = 16 * 1024; +const VERSION_TIMEOUT_MS = 5_000; +const DIAGNOSTIC_LIMIT = 240; + +const READ_ONLY_TOOLS = ["Read", "Glob", "Grep"] as const; +const WORKSPACE_TOOLS = ["Read", "Glob", "Grep", "Edit", "Write", "Bash"] as const; + +interface JsonRecord { + [key: string]: unknown; +} + +interface UsageSnapshot { + input: number; + output: number; + cacheRead: number; + cacheCreation: number; + context: number; +} + +interface PendingTool { + id: unknown; + name: string; + input: unknown; + partialJson: string; +} + +export interface ClaudeCodeAdapterOptions { + binary?: string; + env?: NodeJS.ProcessEnv; +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function finiteNumber(record: JsonRecord, ...keys: string[]): number { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value; + } + return 0; +} + +function readUsage(value: unknown): UsageSnapshot | undefined { + if (!isRecord(value)) return undefined; + const input = finiteNumber(value, "input_tokens", "inputTokens", "input"); + const output = finiteNumber(value, "output_tokens", "outputTokens", "output"); + const cacheRead = finiteNumber(value, "cache_read_input_tokens", "cacheReadInputTokens", "cacheRead"); + const cacheCreation = finiteNumber( + value, + "cache_creation_input_tokens", + "cacheCreationInputTokens", + "cacheCreation", + ); + if (input === 0 && output === 0 && cacheRead === 0 && cacheCreation === 0) return undefined; + const reportedContext = finiteNumber(value, "context_tokens", "contextTokens", "context"); + return { + input, + output, + cacheRead, + cacheCreation, + context: reportedContext || input + output + cacheRead + cacheCreation, + }; +} + +function compareVersions(left: string, right: string): number { + const leftParts = left.split(".").map(Number); + const rightParts = right.split(".").map(Number); + for (let index = 0; index < 3; index++) { + const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (difference !== 0) return difference; + } + return 0; +} + +function parseVersion(output: string): string | undefined { + return output.match(/\b(\d+\.\d+\.\d+)\b/)?.[1]; +} + +function executableCandidates(binary: string, env: NodeJS.ProcessEnv): string[] { + if (isAbsolute(binary) || binary.includes("/") || binary.includes("\\")) { + return [isAbsolute(binary) ? binary : resolve(binary)]; + } + return (env.PATH ?? "") + .split(delimiter) + .filter(Boolean) + .map((directory) => join(directory, binary)); +} + +function wrapperHasPermissionBypass(path: string): boolean { + let descriptor: number | undefined; + try { + descriptor = openSync(path, "r"); + const bytes = Buffer.allocUnsafe(WRAPPER_PREFIX_BYTES); + const count = readSync(descriptor, bytes, 0, bytes.length, 0); + const prefix = bytes.subarray(0, count).toString("utf8"); + if (!prefix.startsWith("#!")) return false; + return ( + prefix.includes("--dangerously-skip-permissions") || + /--permission-mode(?:=|\s+)bypassPermissions/.test(prefix) + ); + } catch { + return true; + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +function versionOf(command: string, env: NodeJS.ProcessEnv): Promise { + return new Promise((resolveVersion) => { + const child = spawn(command, ["--version"], { + env, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + let output = ""; + let outputBytes = 0; + let invalid = false; + let settled = false; + + const settle = (version: string | undefined) => { + if (settled) return; + settled = true; + resolveVersion(version); + }; + const kill = () => { + invalid = true; + try { + if (child.pid) process.kill(-child.pid, "SIGKILL"); + else child.kill("SIGKILL"); + } catch { + try { + child.kill("SIGKILL"); + } catch {} + } + }; + const capture = (chunk: Buffer) => { + outputBytes += chunk.length; + if (outputBytes > VERSION_OUTPUT_BYTES) { + kill(); + return; + } + output += chunk.toString("utf8"); + }; + + child.stdout.on("data", capture); + child.stderr.on("data", capture); + const timeout = setTimeout(kill, VERSION_TIMEOUT_MS); + timeout.unref(); + const reapOnExit = () => kill(); + process.once("exit", reapOnExit); + child.once("error", () => { + invalid = true; + }); + child.once("close", (code) => { + clearTimeout(timeout); + process.removeListener("exit", reapOnExit); + settle(!invalid && code === 0 ? parseVersion(output) : undefined); + }); + }); +} + +function boundedDiagnostic(error: unknown): string { + const detail = error instanceof Error ? error.message : String(error); + return `Malformed Claude stream JSON: ${detail}`.replace(/[\r\n\t]+/g, " ").slice(0, DIAGNOSTIC_LIMIT); +} + +function isErrorResult(record: JsonRecord): boolean { + return record.is_error === true || (typeof record.subtype === "string" && /^error(?:_|$)/.test(record.subtype)); +} + +function boundedResultError(record: JsonRecord): string { + const subtype = typeof record.subtype === "string" ? record.subtype : "error"; + const detail = typeof record.result === "string" && record.result.trim() ? `: ${record.result}` : ""; + return `Claude Code ${subtype}${detail}`.replace(/[\r\n\t]+/g, " ").slice(0, DIAGNOSTIC_LIMIT); +} + +function contentText(value: unknown): string { + if (typeof value === "string") return value; + if (!Array.isArray(value)) return ""; + let text = ""; + for (const part of value) { + if (!isRecord(part)) continue; + if (typeof part.text === "string") text += part.text; + else if (typeof part.content === "string") text += part.content; + } + return text; +} + +class ClaudeCodeStreamParser implements LaneEventParser { + private readonly task: string; + private readonly startedTools = new Set(); + private readonly toolNames = new Map(); + private readonly pendingTools = new Map(); + private taskEmitted = false; + private ended = false; + private resultReceived = false; + private currentText = ""; + private currentThinking = ""; + private lastAssistantText = ""; + private usage: UsageSnapshot = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, context: 0 }; + private lastEmittedUsage = ""; + + constructor(spec: LaneSpec) { + this.task = spec.task; + } + + feedLine(line: string): NormalizedLaneEvent[] { + if (this.ended) return []; + const events = this.emitTask(); + if (!line.trim()) return events; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + events.push({ + v: 1, + type: "tool_end", + tool: "claude-code-parser", + text: boundedDiagnostic(error), + isError: true, + }); + return events; + } + if (isRecord(parsed)) events.push(...this.handleRecord(parsed)); + return events; + } + + end(): NormalizedLaneEvent[] { + if (this.ended) return []; + this.ended = true; + const events = this.emitTask(); + if (!this.resultReceived && this.lastAssistantText) { + events.push({ v: 1, type: "assistant_end", text: this.lastAssistantText }); + } + return events; + } + + private emitTask(): NormalizedLaneEvent[] { + if (this.taskEmitted) return []; + this.taskEmitted = true; + return [{ v: 1, type: "task", text: this.task }]; + } + + private handleRecord(record: JsonRecord): NormalizedLaneEvent[] { + if (record.type === "stream_event" && isRecord(record.event)) return this.handleStreamEvent(record.event); + if (record.type === "assistant" && isRecord(record.message)) return this.handleAssistant(record.message); + if (record.type === "user" && isRecord(record.message)) return this.handleUser(record.message); + if (record.type === "result") return this.handleResult(record); + return []; + } + + private handleStreamEvent(event: JsonRecord): NormalizedLaneEvent[] { + if (event.type === "message_start") { + this.currentText = ""; + this.currentThinking = ""; + return []; + } + if (event.type === "content_block_start" && isRecord(event.content_block)) { + const block = event.content_block; + if (block.type === "tool_use" && typeof block.name === "string" && typeof event.index === "number") { + this.pendingTools.set(event.index, { + id: block.id, + name: block.name, + input: block.input, + partialJson: "", + }); + } + return []; + } + if (event.type === "content_block_stop" && typeof event.index === "number") { + const pending = this.pendingTools.get(event.index); + if (!pending) return []; + this.pendingTools.delete(event.index); + let input = pending.input; + if (pending.partialJson) { + try { + input = JSON.parse(pending.partialJson); + } catch { + input = pending.input; + } + } + return this.startTool(pending.id, pending.name, input); + } + if (event.type !== "content_block_delta" || !isRecord(event.delta)) return []; + const delta = event.delta; + if ( + delta.type === "input_json_delta" && + typeof delta.partial_json === "string" && + typeof event.index === "number" + ) { + const pending = this.pendingTools.get(event.index); + if (pending) pending.partialJson += delta.partial_json; + return []; + } + if (delta.type === "text_delta" && typeof delta.text === "string") { + this.currentText += delta.text; + this.lastAssistantText = this.currentText; + return [{ v: 1, type: "text_delta", delta: delta.text }]; + } + if (delta.type === "thinking_delta" && typeof delta.thinking === "string") { + this.currentThinking += delta.thinking; + return [{ v: 1, type: "thinking_delta", delta: delta.thinking }]; + } + return []; + } + + private handleAssistant(message: JsonRecord): NormalizedLaneEvent[] { + const events: NormalizedLaneEvent[] = []; + if (Array.isArray(message.content)) { + for (const part of message.content) { + if (!isRecord(part)) continue; + if (part.type === "thinking" && typeof part.thinking === "string") { + const delta = this.missingSuffix(part.thinking, this.currentThinking); + if (delta) events.push({ v: 1, type: "thinking_delta", delta }); + this.currentThinking = part.thinking; + } else if (part.type === "text" && typeof part.text === "string") { + const delta = this.missingSuffix(part.text, this.currentText); + if (delta) events.push({ v: 1, type: "text_delta", delta }); + this.currentText = part.text; + this.lastAssistantText = part.text; + } else if (part.type === "tool_use" && typeof part.name === "string") { + events.push(...this.startTool(part.id, part.name, part.input)); + } + } + } + const snapshot = readUsage(message.usage); + if (snapshot) { + this.usage = { + input: this.usage.input + snapshot.input, + output: this.usage.output + snapshot.output, + cacheRead: this.usage.cacheRead + snapshot.cacheRead, + cacheCreation: this.usage.cacheCreation + snapshot.cacheCreation, + context: this.usage.context + snapshot.context, + }; + events.push(...this.emitUsage(this.usage)); + } + return events; + } + + private handleUser(message: JsonRecord): NormalizedLaneEvent[] { + if (!Array.isArray(message.content)) return []; + const events: NormalizedLaneEvent[] = []; + for (const part of message.content) { + if (!isRecord(part) || part.type !== "tool_result") continue; + const id = typeof part.tool_use_id === "string" ? part.tool_use_id : ""; + events.push({ + v: 1, + type: "tool_end", + tool: this.toolNames.get(id) ?? (id || "unknown"), + text: contentText(part.content), + isError: part.is_error === true, + }); + } + return events; + } + + private handleResult(record: JsonRecord): NormalizedLaneEvent[] { + if (this.resultReceived) return []; + this.resultReceived = true; + const events: NormalizedLaneEvent[] = []; + const snapshot = readUsage(record.usage); + if (snapshot) { + this.usage = snapshot; + events.push(...this.emitUsage(snapshot)); + } + if (isErrorResult(record)) { + const text = boundedResultError(record); + this.lastAssistantText = text; + events.push({ v: 1, type: "assistant_end", text, isError: true }); + return events; + } + const text = typeof record.result === "string" ? record.result : this.lastAssistantText; + this.lastAssistantText = text; + events.push({ v: 1, type: "assistant_end", text }); + return events; + } + + private startTool(idValue: unknown, name: string, input: unknown): NormalizedLaneEvent[] { + const id = typeof idValue === "string" ? idValue : `${name}:${this.startedTools.size}`; + this.toolNames.set(id, name); + if (this.startedTools.has(id)) return []; + this.startedTools.add(id); + return [{ v: 1, type: "tool_start", tool: name, input: input ?? {} }]; + } + + private emitUsage(snapshot: UsageSnapshot): NormalizedLaneEvent[] { + const key = `${snapshot.input}:${snapshot.output}:${snapshot.cacheRead}:${snapshot.context}`; + if (key === this.lastEmittedUsage) return []; + this.lastEmittedUsage = key; + return [ + { + v: 1, + type: "usage", + input: snapshot.input, + output: snapshot.output, + cacheRead: snapshot.cacheRead, + context: snapshot.context, + }, + ]; + } + + private missingSuffix(complete: string, partial: string): string { + if (!partial) return complete; + return complete.startsWith(partial) ? complete.slice(partial.length) : ""; + } +} + +export class ClaudeCodeExecutionAdapter implements LaneExecutionAdapter { + readonly route = "claude-code" as const; + private readonly binary: string; + private readonly env: NodeJS.ProcessEnv; + private command?: string; + private candidates?: string[]; + private preparation?: Promise; + + constructor(options: ClaudeCodeAdapterOptions = {}) { + this.binary = options.binary ?? "claude"; + this.env = createChildEnvironment(options.env ?? process.env); + } + + preflight(spec: LaneSpec): void { + const row = findModel(spec.model); + if (!row || row.route !== this.route) throw new Error(`Model ${spec.model} is not routed through Claude Code`); + if (!row.allowedEfforts.includes(spec.effort)) { + throw new Error(`Claude Code model ${spec.model} has unsupported effort ${spec.effort}`); + } + if (this.command || this.candidates) return; + + const candidates: string[] = []; + const seen = new Set(); + for (const candidate of executableCandidates(this.binary, this.env)) { + try { + accessSync(candidate, constants.X_OK); + const resolved = realpathSync(candidate); + if (seen.has(resolved)) continue; + seen.add(resolved); + if (!statSync(resolved).isFile()) continue; + accessSync(resolved, constants.X_OK); + if (!wrapperHasPermissionBypass(resolved)) candidates.push(resolved); + } catch { + // Try the next PATH candidate without exposing wrapper contents or environment values. + } + } + if (candidates.length === 0) throw new Error("No safe Claude Code executable was found on PATH"); + this.candidates = candidates; + } + + async prepare(spec: LaneSpec): Promise { + this.preflight(spec); + if (this.command) return; + this.preparation ??= this.prepareExecutable(); + await this.preparation; + } + + private async prepareExecutable(): Promise { + let unsupportedVersion: string | undefined; + for (const candidate of this.candidates ?? []) { + const version = await versionOf(candidate, this.env); + if (!version) continue; + if (compareVersions(version, CLAUDE_CODE_MIN_VERSION) < 0) { + unsupportedVersion = version; + continue; + } + this.command = candidate; + return; + } + if (unsupportedVersion) { + throw new Error( + `Claude Code ${unsupportedVersion} is unsupported; this adapter requires ${CLAUDE_CODE_MIN_VERSION} or newer`, + ); + } + throw new Error("No supported Claude Code executable was found on PATH"); + } + + build(spec: LaneSpec): LaneProcessPlan { + this.preflight(spec); + if (!this.command) throw new Error("Claude Code adapter preparation has not completed"); + const row = findModel(spec.model)!; + const mode = spec.mode ?? "workspace-write"; + const args = [ + "-p", + "--safe-mode", + "--disable-slash-commands", + "--no-chrome", + "--no-session-persistence", + "--permission-mode", + "dontAsk", + "--output-format", + "stream-json", + "--verbose", + "--include-partial-messages", + "--model", + row.runtimeModel, + "--effort", + spec.effort, + ]; + if (mode === "read-only") { + args.push("--tools", READ_ONLY_TOOLS.join(","), "--allowedTools", ...READ_ONLY_TOOLS); + } else if (mode === "workspace-write") { + args.push("--tools", WORKSPACE_TOOLS.join(","), "--allowedTools", ...WORKSPACE_TOOLS); + } else { + throw new Error(`Unsupported Claude Code lane mode: ${String(mode)}`); + } + args.push("--", spec.task); + return { + command: this.command!, + args, + cwd: spec.cwd ?? process.cwd(), + env: this.env, + }; + } + + createParser(spec: LaneSpec): LaneEventParser { + return new ClaudeCodeStreamParser(spec); + } +} + +export function createClaudeCodeAdapter(options: ClaudeCodeAdapterOptions = {}): ClaudeCodeExecutionAdapter { + return new ClaudeCodeExecutionAdapter(options); +} diff --git a/packages/pi-kit/src/adapters/pi.ts b/packages/pi-kit/src/adapters/pi.ts new file mode 100644 index 0000000..b076763 --- /dev/null +++ b/packages/pi-kit/src/adapters/pi.ts @@ -0,0 +1,166 @@ +import { createChildEnvironment } from "../child-env.ts"; +import type { + LaneEventParser, + LaneExecutionAdapter, + LaneProcessPlan, + NormalizedLaneEvent, +} from "../execution-adapter.ts"; +import type { LaneSpec } from "../schema.ts"; +import { findModel } from "../table.ts"; + +const READ_ONLY_TOOLS = "read,grep,find,ls"; + +const PI_PROVIDERS: Record = { + "openai-codex/gpt-5.6-sol": "openai-codex", + "xai/grok-4.5": "xai", + "ollama/glm-5.2:cloud": "ollama", +}; + +type JsonRecord = Record; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null; +} + +function numberFrom(record: JsonRecord, ...keys: string[]): number { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + return 0; +} + +function contentText(content: unknown): string { + if (!Array.isArray(content)) return ""; + let text = ""; + for (const part of content) { + if (isRecord(part) && part.type === "text" && typeof part.text === "string") text += part.text; + } + return text; +} + +function assistantText(message: unknown): string | undefined { + if (!isRecord(message) || message.role !== "assistant") return undefined; + return contentText(message.content) || undefined; +} + +class PiEventParser implements LaneEventParser { + private taskEmitted = false; + private input = 0; + private output = 0; + private cacheRead = 0; + + constructor(private readonly spec: LaneSpec) {} + + feedLine(line: string): NormalizedLaneEvent[] { + const events = this.start(); + if (!line.trim()) return events; + + let event: unknown; + try { + event = JSON.parse(line); + } catch { + return events; + } + if (!isRecord(event) || typeof event.type !== "string") return events; + + if (event.type === "message_update" && isRecord(event.assistantMessageEvent)) { + const update = event.assistantMessageEvent; + if (update.type === "text_delta" && typeof update.delta === "string") { + events.push({ v: 1, type: "text_delta", delta: update.delta }); + } else if (update.type === "thinking_delta" && typeof update.delta === "string") { + events.push({ v: 1, type: "thinking_delta", delta: update.delta }); + } + } else if (event.type === "tool_execution_start" && typeof event.toolName === "string") { + events.push({ v: 1, type: "tool_start", tool: event.toolName, input: event.args }); + } else if (event.type === "tool_execution_end") { + const tool = typeof event.toolName === "string" ? event.toolName : "tool"; + const text = isRecord(event.result) ? contentText(event.result.content) : ""; + events.push({ v: 1, type: "tool_end", tool, text, isError: event.isError === true }); + } else if (event.type === "message_end" && isRecord(event.message) && event.message.role === "assistant") { + if (isRecord(event.message.usage)) { + const usage = event.message.usage; + this.input += numberFrom(usage, "input", "inputTokens", "input_tokens"); + this.output += numberFrom(usage, "output", "outputTokens", "output_tokens"); + this.cacheRead += numberFrom(usage, "cacheRead", "cacheReadTokens", "cache_read"); + const reportedContext = numberFrom(usage, "totalTokens", "total_tokens", "total", "contextTokens"); + events.push({ + v: 1, + type: "usage", + input: this.input, + output: this.output, + cacheRead: this.cacheRead, + context: reportedContext || this.input + this.output, + }); + } + } else if (event.type === "agent_end" && Array.isArray(event.messages)) { + for (let index = event.messages.length - 1; index >= 0; index--) { + const text = assistantText(event.messages[index]); + if (text !== undefined) { + events.push({ v: 1, type: "assistant_end", text }); + break; + } + } + } + + return events; + } + + end(): NormalizedLaneEvent[] { + return this.start(); + } + + private start(): NormalizedLaneEvent[] { + if (this.taskEmitted) return []; + this.taskEmitted = true; + return [{ v: 1, type: "task", text: this.spec.task }]; + } +} + +export class PiExecutionAdapter implements LaneExecutionAdapter { + readonly route = "pi" as const; + + constructor( + private readonly piBinary = "pi", + private readonly env: NodeJS.ProcessEnv = process.env, + ) {} + + preflight(spec: LaneSpec): void { + const row = findModel(spec.model); + if (!row || row.route !== this.route || !PI_PROVIDERS[spec.model]) { + throw new Error(`model ${spec.model} is not available through the Pi execution route`); + } + if (spec.mode !== undefined && spec.mode !== "read-only" && spec.mode !== "workspace-write") { + throw new Error(`unsupported Pi lane mode: ${String(spec.mode)}`); + } + } + + build(spec: LaneSpec): LaneProcessPlan { + this.preflight(spec); + const row = findModel(spec.model)!; + const mode = spec.mode ?? "workspace-write"; + const args = ["--mode", "json", "--no-extensions", "--no-session", "-p"]; + if (mode === "read-only") args.push("--tools", READ_ONLY_TOOLS); + args.push( + "--provider", + PI_PROVIDERS[spec.model]!, + "--model", + row.runtimeModel, + "--thinking", + spec.effort, + `Task:\n${spec.task}`, + ); + + return { + command: this.piBinary, + args, + cwd: spec.cwd ?? process.cwd(), + env: { ...createChildEnvironment(this.env), PIKIT_CHILD: "1" }, + }; + } + + createParser(spec: LaneSpec): LaneEventParser { + this.preflight(spec); + return new PiEventParser(spec); + } +} diff --git a/packages/pi-kit/src/child-env.ts b/packages/pi-kit/src/child-env.ts new file mode 100644 index 0000000..b28bd29 --- /dev/null +++ b/packages/pi-kit/src/child-env.ts @@ -0,0 +1,45 @@ +const CHILD_ENV_KEYS = [ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "TMPDIR", + "TMP", + "TEMP", + "LANG", + "LANGUAGE", + "LC_ALL", + "LC_CTYPE", + "LC_COLLATE", + "LC_MESSAGES", + "LC_MONETARY", + "LC_NUMERIC", + "LC_TIME", + "LC_ADDRESS", + "LC_IDENTIFICATION", + "LC_MEASUREMENT", + "LC_NAME", + "LC_PAPER", + "LC_TELEPHONE", + "TERM", + "COLORTERM", + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + "XDG_RUNTIME_DIR", + "XDG_CONFIG_DIRS", + "XDG_DATA_DIRS", +] as const; + +export function createChildEnvironment(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const environment: NodeJS.ProcessEnv = {}; + for (const key of CHILD_ENV_KEYS) { + const value = source[key]; + if (value !== undefined) environment[key] = value; + } + return environment; +} diff --git a/packages/pi-kit/src/execution-adapter.ts b/packages/pi-kit/src/execution-adapter.ts new file mode 100644 index 0000000..75be1f6 --- /dev/null +++ b/packages/pi-kit/src/execution-adapter.ts @@ -0,0 +1,30 @@ +import type { ExecutionRoute, LaneSpec } from "./schema.ts"; + +export type NormalizedLaneEvent = + | { v: 1; type: "task"; text: string } + | { v: 1; type: "text_delta"; delta: string } + | { v: 1; type: "thinking_delta"; delta: string } + | { v: 1; type: "tool_start"; tool: string; input: unknown } + | { v: 1; type: "tool_end"; tool: string; text: string; isError: boolean } + | { v: 1; type: "usage"; input: number; output: number; cacheRead: number; context: number } + | { v: 1; type: "assistant_end"; text: string; isError?: boolean }; + +export interface LaneProcessPlan { + command: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; +} + +export interface LaneEventParser { + feedLine(line: string): NormalizedLaneEvent[]; + end(): NormalizedLaneEvent[]; +} + +export interface LaneExecutionAdapter { + route: ExecutionRoute; + preflight(spec: LaneSpec): void; + prepare?(spec: LaneSpec): Promise; + build(spec: LaneSpec): LaneProcessPlan; + createParser(spec: LaneSpec): LaneEventParser; +} diff --git a/packages/pi-kit/src/lane-coordinator.ts b/packages/pi-kit/src/lane-coordinator.ts new file mode 100644 index 0000000..4cadda5 --- /dev/null +++ b/packages/pi-kit/src/lane-coordinator.ts @@ -0,0 +1,288 @@ +import { appendEvent, newRunId as createDefaultRunId, rawRunDir } from "./journal-core.ts"; +import { LaneRunner, type RunnerOptions } from "./runner.ts"; +import type { + KitEvent, + Effort, + LaneMode, + LaneOrigin, + LaneProjection, + LaneSpec, + LaneState, + RunProjection, +} from "./schema.ts"; +import { normalizeLaneSpec, validateLaneSpec } from "./table.ts"; + +export interface LaneRunnerPort { + projection(): RunProjection; + dispatch(specs: LaneSpec[]): Promise; + abandon(lane: string, reason: string): void; + abandonAll(reason: string): void; + shutdown(reason: string): Promise; +} + +export interface LaneCoordinatorJournal { + append(event: KitEvent): void; + rawDir(runId: string): string; +} + +export interface LaneCoordinatorOptions { + origin: LaneOrigin; + createRunner?: (options: RunnerOptions) => LaneRunnerPort; + journal?: LaneCoordinatorJournal; + clock?: () => Date; + newRunId?: () => string; +} + +export interface LaneSnapshotDto { + lane: string; + model: string; + effort: Effort; + mode: LaneMode; + state: LaneState; + currentTool?: string; + lastStatus?: string; + tokensIn: number; + tokensOut: number; + cost: number; + context: number; + answer?: string; + durationMs?: number; + abandonReason?: string; +} + +export interface RunSnapshotDto { + run: string; + state: "active" | "ended"; + ok?: boolean; + totals: { + cost: number; + tokensIn: number; + tokensOut: number; + }; + lanes: LaneSnapshotDto[]; +} + +interface CoordinatedRun { + id: string; + createdAt: string; + startedAt: number; + runner: LaneRunnerPort; + ended: boolean; + ok?: boolean; + settled: Promise; +} + +const DEFAULT_JOURNAL: LaneCoordinatorJournal = { + append: appendEvent, + rawDir: rawRunDir, +}; + +function finite(value: number): number { + return Number.isFinite(value) ? value : 0; +} + +function snapshotLane(lane: LaneProjection): LaneSnapshotDto { + return { + lane: lane.spec.lane, + model: lane.spec.model, + effort: lane.spec.effort, + mode: lane.spec.mode ?? "workspace-write", + state: lane.state, + ...(lane.currentTool !== undefined ? { currentTool: lane.currentTool } : {}), + ...(lane.lastStatus !== undefined ? { lastStatus: lane.lastStatus } : {}), + tokensIn: finite(lane.tokensIn), + tokensOut: finite(lane.tokensOut), + cost: finite(lane.cost), + context: finite(lane.context), + ...(lane.answer !== undefined ? { answer: lane.answer } : {}), + ...(lane.durationMs !== undefined ? { durationMs: finite(lane.durationMs) } : {}), + ...(lane.abandonReason !== undefined ? { abandonReason: lane.abandonReason } : {}), + }; +} + +export class LaneCoordinator { + readonly origin: LaneOrigin; + private readonly createRunner: (options: RunnerOptions) => LaneRunnerPort; + private readonly journal: LaneCoordinatorJournal; + private readonly clock: () => Date; + private readonly createRunId: () => string; + private current?: CoordinatedRun; + private closed = false; + private shutdownPromise?: Promise; + + constructor(options: LaneCoordinatorOptions) { + this.origin = options.origin; + this.createRunner = options.createRunner ?? ((runnerOptions) => new LaneRunner(runnerOptions)); + this.journal = options.journal ?? DEFAULT_JOURNAL; + this.clock = options.clock ?? (() => new Date()); + this.createRunId = options.newRunId ?? createDefaultRunId; + } + + async spawn(specs: LaneSpec[]): Promise { + if (this.closed) throw new Error("Lane coordinator is shut down"); + if (this.current && !this.current.ended) { + throw new Error(`Run ${this.current.id} is still active`); + } + const normalized = this.validateAndNormalize(specs); + const id = this.createRunId(); + const created = this.clock(); + const createdAt = created.toISOString(); + const runner = this.createRunner({ + runId: id, + origin: this.origin, + append: (event) => this.journal.append(event), + rawDir: this.journal.rawDir(id), + }); + const run: CoordinatedRun = { + id, + createdAt, + startedAt: created.getTime(), + runner, + ended: false, + settled: Promise.resolve(), + }; + this.current = run; + this.appendLifecycleEvent({ + v: 1, + t: createdAt, + run: id, + type: "run_created", + lanes: normalized.length, + origin: this.origin, + }); + + const dispatch = runner.dispatch(normalized); + run.settled = dispatch.then( + (lanes) => { + this.finish(run, lanes.every((lane) => lane.state === "done")); + }, + async () => { + await runner.shutdown("lane run failed"); + this.finish(run, false); + }, + ); + return this.snapshot(run); + } + + status(lane?: string): RunSnapshotDto { + const run = this.requireRun(); + return this.snapshot(run, lane); + } + + async wait(signal?: AbortSignal): Promise { + const run = this.requireRun(); + if (!run.ended) { + if (!signal) { + await run.settled; + } else if (!signal.aborted) { + const detached = Promise.withResolvers(); + const onAbort = () => detached.resolve(); + signal.addEventListener("abort", onAbort, { once: true }); + try { + await Promise.race([run.settled, detached.promise]); + } finally { + signal.removeEventListener("abort", onAbort); + } + } + } + return this.snapshot(run); + } + + abandon(input: { lane?: string; reason?: string }): RunSnapshotDto { + const run = this.requireRun(); + const reason = input.reason?.trim() || "abandoned"; + if (input.lane !== undefined) { + if (!run.runner.projection().lanes.has(input.lane)) { + throw new Error(`Unknown lane "${input.lane}"`); + } + run.runner.abandon(input.lane, reason); + } else { + run.runner.abandonAll(reason); + } + return this.snapshot(run); + } + + shutdown(reason: string): Promise { + if (!this.shutdownPromise) { + this.closed = true; + this.shutdownPromise = this.finishShutdown(reason); + } + return this.shutdownPromise; + } + + private validateAndNormalize(specs: LaneSpec[]): LaneSpec[] { + if (specs.length === 0) throw new Error("At least one lane is required"); + const violations: string[] = []; + const seen = new Set(); + const normalized: LaneSpec[] = []; + specs.forEach((spec, index) => { + const label = spec.lane || `lane-${index + 1}`; + const reason = validateLaneSpec(spec, { origin: this.origin }); + if (reason) violations.push(`${label}: ${reason}`); + if (spec.lane) { + if (seen.has(spec.lane)) violations.push(`${spec.lane}: duplicate lane id`); + seen.add(spec.lane); + } + if (!reason) normalized.push(normalizeLaneSpec(spec, { origin: this.origin })); + }); + if (violations.length > 0) throw new Error(`Invalid lane specs:\n${violations.join("\n")}`); + return normalized; + } + + private requireRun(): CoordinatedRun { + if (!this.current) throw new Error("No lane run is available"); + return this.current; + } + + private snapshot(run: CoordinatedRun, laneName?: string): RunSnapshotDto { + const projection = run.runner.projection(); + let lanes: LaneProjection[]; + if (laneName === undefined) { + lanes = [...projection.lanes.values()]; + } else { + const lane = projection.lanes.get(laneName); + if (!lane) throw new Error(`Unknown lane "${laneName}"`); + lanes = [lane]; + } + return { + run: run.id, + state: run.ended ? "ended" : "active", + ...(run.ended ? { ok: run.ok ?? false } : {}), + totals: { + cost: finite(projection.totalCost), + tokensIn: finite(projection.totalTokensIn), + tokensOut: finite(projection.totalTokensOut), + }, + lanes: lanes.map(snapshotLane), + }; + } + + private appendLifecycleEvent(event: KitEvent): void { + try { + this.journal.append(event); + } catch {} + } + + private finish(run: CoordinatedRun, ok: boolean): void { + if (run.ended) return; + run.ended = true; + run.ok = ok; + const endedAt = this.clock(); + const elapsed = endedAt.getTime() - run.startedAt; + this.appendLifecycleEvent({ + v: 1, + t: endedAt.toISOString(), + run: run.id, + type: "run_end", + ok, + durationMs: Number.isFinite(elapsed) ? Math.max(0, elapsed) : 0, + }); + } + + private async finishShutdown(reason: string): Promise { + const run = this.current; + if (!run || run.ended) return; + await run.runner.shutdown(reason); + await run.settled; + } +} diff --git a/packages/pi-kit/src/runner.ts b/packages/pi-kit/src/runner.ts index c60680e..033d595 100644 --- a/packages/pi-kit/src/runner.ts +++ b/packages/pi-kit/src/runner.ts @@ -1,27 +1,47 @@ import { spawn, type ChildProcessByStdio } from "node:child_process"; import { createWriteStream, mkdirSync, type WriteStream } from "node:fs"; -import { join } from "node:path"; +import { isAbsolute, relative, resolve, sep } from "node:path"; import type { Readable } from "node:stream"; -import type { KitEvent, LaneProjection, LaneSpec, RunProjection } from "./schema.ts"; -import { estimateCost, validateLaneSpec } from "./table.ts"; +import { StringDecoder } from "node:string_decoder"; +import { createClaudeCodeAdapter } from "./adapters/claude-code.ts"; +import { PiExecutionAdapter } from "./adapters/pi.ts"; +import type { + LaneEventParser, + LaneExecutionAdapter, + LaneProcessPlan, + NormalizedLaneEvent, +} from "./execution-adapter.ts"; +import type { KitEvent, LaneOrigin, LaneProjection, LaneSpec, RunProjection } from "./schema.ts"; +import { estimateCost, findModel, normalizeLaneSpec, validateLaneSpec } from "./table.ts"; export interface RunnerOptions { runId: string; append: (ev: KitEvent) => void; maxConcurrent?: number; piBinary?: string; + adapters?: readonly LaneExecutionAdapter[]; + origin?: LaneOrigin; onUpdate?: () => void; - /** Directory for raw per-lane JSONL transcripts (`/.jsonl`). */ + /** Directory for canonical per-lane JSONL transcripts (`/.jsonl`). */ rawDir?: string; } +type TerminalOutcome = + | { type: "end"; ok: boolean; answer: string; stopReason?: string } + | { type: "abandoned" }; + interface LaneRuntime { child: ChildProcessByStdio; + parser: LaneEventParser; raw?: WriteStream; - finish: () => void; - settled: boolean; + terminal?: TerminalOutcome; + closed: boolean; + terminating: boolean; + parserEnded: boolean; startedAt: number; stdoutBuffer: string; + stdoutBytes: number; + stdoutOverflow: boolean; stderrTail: string; answer: string; statusTail: string; @@ -30,12 +50,20 @@ interface LaneRuntime { tokensIn: number; tokensOut: number; cacheRead: number; - cacheWrite: number; + context: number; + rawBytes: number; + rawCapped: boolean; + taskWritten: boolean; +} + +interface PreparedLane { + spec: LaneSpec; + adapter: LaneExecutionAdapter; } type JsonRecord = Record; -const MAX_STDOUT_BUFFER = 4 * 1024 * 1024; +const MAX_STREAM_BYTES = 4 * 1024 * 1024; /** Live runners with children, reaped synchronously if the parent dies. */ const ACTIVE_RUNNERS = new Set(); @@ -68,28 +96,13 @@ function isRecord(value: unknown): value is JsonRecord { return typeof value === "object" && value !== null; } -function usageNumber(usage: JsonRecord, ...keys: string[]): number { - for (const key of keys) { - const value = usage[key]; - if (typeof value === "number" && Number.isFinite(value)) return value; - } - return 0; -} - -function messageText(message: unknown): string | undefined { - if (!isRecord(message) || message.role !== "assistant" || !Array.isArray(message.content)) return undefined; - let text = ""; - for (const part of message.content) { - if (isRecord(part) && part.type === "text" && typeof part.text === "string") text += part.text; - } - return text || undefined; -} - function toolSummary(tool: string, args: unknown): string { let summary: string | undefined; if (isRecord(args)) { - if (tool === "bash" && typeof args.command === "string") summary = args.command; - if (["read", "write", "edit"].includes(tool) && typeof args.path === "string") summary = args.path; + if (tool.toLowerCase() === "bash" && typeof args.command === "string") summary = args.command; + for (const key of ["path", "file_path", "filePath"]) { + if (summary === undefined && typeof args[key] === "string") summary = args[key]; + } } if (summary === undefined) { try { @@ -101,27 +114,42 @@ function toolSummary(tool: string, args: unknown): string { return summary.replace(/\s+/g, " ").trim().slice(0, 80); } +function validUsage(event: Extract): boolean { + return [event.input, event.output, event.cacheRead, event.context].every( + (value) => typeof value === "number" && Number.isFinite(value) && value >= 0, + ); +} + +function isAssistantError(event: Extract): boolean { + return event.isError === true; +} + export class LaneRunner { private readonly append: (ev: KitEvent) => void; private readonly maxConcurrent: number; private readonly onUpdate?: () => void; - private readonly piBinary: string; + private readonly adapters: readonly LaneExecutionAdapter[]; + private readonly origin: LaneOrigin; private readonly rawDir?: string; private readonly runId: string; private readonly runtimes = new Map(); private readonly live: RunProjection; private dispatching = false; + private shuttingDown = false; + private dispatchPromise?: Promise; + private shutdownPromise?: Promise; constructor(opts: RunnerOptions) { this.runId = opts.runId; this.append = opts.append; this.maxConcurrent = Math.max(1, Math.floor(opts.maxConcurrent ?? 4)); - this.piBinary = opts.piBinary ?? "pi"; + this.adapters = opts.adapters ?? [new PiExecutionAdapter(opts.piBinary), createClaudeCodeAdapter()]; + this.origin = opts.origin ?? "pi"; if (opts.rawDir) this.rawDir = opts.rawDir; this.onUpdate = opts.onUpdate; this.live = { run: opts.runId, - origin: "runner", + origin: this.origin, createdAt: new Date().toISOString(), ended: false, lanes: new Map(), @@ -135,17 +163,43 @@ export class LaneRunner { return this.live; } - async dispatch(specs: LaneSpec[]): Promise { - if (this.dispatching) throw new Error("LaneRunner.dispatch may only be called once"); + dispatch(specs: LaneSpec[]): Promise { + if (this.shuttingDown) return Promise.reject(new Error("LaneRunner is shutting down")); + if (this.dispatching) return Promise.reject(new Error("LaneRunner.dispatch may only be called once")); + const promise = this.dispatchRun(specs); + this.dispatchPromise = promise; + return promise; + } + private async dispatchRun(specs: LaneSpec[]): Promise { const violations: string[] = []; const seen = new Set(); - specs.forEach((spec, index) => { - const reason = validateLaneSpec(spec); - if (reason) violations.push(`${spec.lane || `lane-${index + 1}`}: ${reason}`); - if (spec.lane) { - if (seen.has(spec.lane)) violations.push(`${spec.lane}: duplicate lane id`); - seen.add(spec.lane); + const prepared: PreparedLane[] = []; + specs.forEach((input, index) => { + const label = input.lane || `lane-${index + 1}`; + const reason = validateLaneSpec(input, { origin: this.origin }); + if (reason) violations.push(`${label}: ${reason}`); + if (input.lane) { + if (seen.has(input.lane)) violations.push(`${input.lane}: duplicate lane id`); + seen.add(input.lane); + } + if (reason) return; + + try { + const spec = normalizeLaneSpec(input, { origin: this.origin }); + const row = findModel(spec.model)!; + const matches = this.adapters.filter((adapter) => adapter.route === row.route); + if (matches.length !== 1) { + violations.push( + `${label}: expected exactly one execution adapter for route ${row.route}; found ${matches.length}`, + ); + return; + } + const adapter = matches[0]!; + adapter.preflight(spec); + prepared.push({ spec, adapter }); + } catch (error) { + violations.push(`${label}: ${error instanceof Error ? error.message : String(error)}`); } }); if (violations.length > 0) throw new Error(`Invalid lane specs:\n${violations.join("\n")}`); @@ -153,23 +207,26 @@ export class LaneRunner { this.dispatching = true; installExitHook(); ACTIVE_RUNNERS.add(this); - for (const spec of specs) { + for (const { spec } of prepared) { this.record({ v: 1, t: new Date().toISOString(), run: this.runId, lane: spec.lane, type: "lane_created", spec }); } let next = 0; - const workers = Array.from({ length: Math.min(this.maxConcurrent, specs.length) }, async () => { - while (next < specs.length) { - const spec = specs[next++]; - if (spec && this.live.lanes.get(spec.lane)?.state === "queued") await this.runLane(spec); + const workers = Array.from({ length: Math.min(this.maxConcurrent, prepared.length) }, async () => { + while (next < prepared.length) { + const lane = prepared[next++]; + if (lane && this.live.lanes.get(lane.spec.lane)?.state === "queued") await this.runLane(lane); } }); - await Promise.all(workers); - ACTIVE_RUNNERS.delete(this); + try { + await Promise.all(workers); + } finally { + ACTIVE_RUNNERS.delete(this); + } this.live.ended = true; this.live.ok = [...this.live.lanes.values()].every((lane) => lane.state === "done"); - this.onUpdate?.(); + this.notify(); return [...this.live.lanes.values()]; } @@ -178,17 +235,9 @@ export class LaneRunner { if (!projection || ["done", "failed", "abandoned"].includes(projection.state)) return; const runtime = this.runtimes.get(lane); - if (runtime) { - runtime.settled = true; - clearTimeout(runtime.statusTimer); - this.terminate(runtime); - } - + if (runtime?.terminal) return; this.record({ v: 1, t: new Date().toISOString(), run: this.runId, lane, type: "lane_abandoned", reason }); - if (runtime) { - this.runtimes.delete(lane); - runtime.finish(); - } + if (runtime) this.requestTerminal(runtime, { type: "abandoned" }); } /** Abandon every non-terminal lane (abort signal, shutdown). */ @@ -198,21 +247,49 @@ export class LaneRunner { } } + /** Abandon active lanes and wait until dispatch has observed every child close. */ + shutdown(reason: string): Promise { + if (!this.shutdownPromise) { + this.shuttingDown = true; + this.shutdownPromise = this.finishShutdown(reason); + } + return this.shutdownPromise; + } + /** Synchronous last-resort kill of every live child. Called from the process exit hook. */ reapAll(): void { for (const runtime of this.runtimes.values()) killTree(runtime.child, "SIGKILL"); } + private async finishShutdown(reason: string): Promise { + this.abandonAll(reason); + try { + await this.dispatchPromise; + } catch { + // Dispatch validation/preflight errors own no live children. + } finally { + ACTIVE_RUNNERS.delete(this); + } + } + /** SIGTERM the child's process group now, escalate to SIGKILL after 5s. */ private terminate(runtime: LaneRuntime): void { + if (runtime.terminating || runtime.closed) return; + runtime.terminating = true; killTree(runtime.child, "SIGTERM"); const killTimer = setTimeout(() => killTree(runtime.child, "SIGKILL"), 5_000); killTimer.unref(); runtime.child.once("close", () => clearTimeout(killTimer)); } + private requestTerminal(runtime: LaneRuntime, outcome: TerminalOutcome): void { + if (runtime.terminal) return; + runtime.terminal = outcome; + clearTimeout(runtime.statusTimer); + this.terminate(runtime); + } + private record(event: KitEvent): void { - this.append(event); if (event.type === "lane_created") { this.live.lanes.set(event.spec.lane, { spec: event.spec, @@ -257,217 +334,370 @@ export class LaneRunner { } } } - this.onUpdate?.(); + try { + this.append(event); + } catch {} + this.notify(); } - private runLane(spec: LaneSpec): Promise { - const { promise, resolve } = Promise.withResolvers(); - const slash = spec.model.indexOf("/"); - const provider = spec.model.slice(0, slash); - const modelId = spec.model.slice(slash + 1); - const child = spawn( - this.piBinary, - [ - "--mode", - "json", - "--no-extensions", - "--no-session", - "-p", - "--provider", - provider, - "--model", - modelId, - "--thinking", - spec.effort, - spec.task, - ], - { - cwd: spec.cwd ?? process.cwd(), - env: { ...process.env, PIKIT_CHILD: "1" }, - stdio: ["ignore", "pipe", "pipe"], - detached: true, - }, - ); - - let raw: WriteStream | undefined; - if (this.rawDir) { - try { - mkdirSync(this.rawDir, { recursive: true }); - raw = createWriteStream(join(this.rawDir, `${spec.lane}.jsonl`), { flags: "a" }); - } catch { - // Transcript capture is best-effort; the lane must still run. - } - } + private notify(): void { + try { + this.onUpdate?.(); + } catch {} + } - const runtime: LaneRuntime = { - child, - ...(raw ? { raw } : {}), - finish: resolve, - settled: false, - startedAt: Date.now(), - stdoutBuffer: "", - stderrTail: "", - answer: "", - statusTail: "", - lastStatusAt: 0, - tokensIn: 0, - tokensOut: 0, - cacheRead: 0, - cacheWrite: 0, - }; - this.runtimes.set(spec.lane, runtime); - - const finish = (ok: boolean, answer: string, stopReason?: string) => { - if (runtime.settled) return; - runtime.settled = true; - clearTimeout(runtime.statusTimer); - this.runtimes.delete(spec.lane); - this.terminate(runtime); + private transcriptPath(lane: string): { directory: string; path: string } { + const directory = resolve(this.rawDir!); + const path = resolve(directory, `${lane}.jsonl`); + const childPath = relative(directory, path); + if (childPath === ".." || childPath.startsWith(`..${sep}`) || isAbsolute(childPath)) { + throw new Error(`Transcript path for lane ${lane} escapes rawDir`); + } + return { directory, path }; + } + + private async runLane({ spec, adapter }: PreparedLane): Promise { + const preparationStartedAt = Date.now(); + try { + await adapter.prepare?.(spec); + } catch (error) { + if (this.live.lanes.get(spec.lane)?.state === "queued") { + const message = error instanceof Error ? error.message : String(error); this.record({ v: 1, t: new Date().toISOString(), run: this.runId, lane: spec.lane, type: "lane_end", - ok, - ...(stopReason ? { stopReason } : {}), - answer: answer.slice(0, 4_000), - durationMs: Date.now() - runtime.startedAt, + ok: false, + stopReason: `prepare:${message}`, + answer: message, + durationMs: Date.now() - preparationStartedAt, }); - resolve(); - }; + } + return; + } + if (this.live.lanes.get(spec.lane)?.state !== "queued") return; - const emitStatus = () => { - runtime.statusTimer = undefined; - if (runtime.settled || !runtime.statusTail) return; - runtime.lastStatusAt = Date.now(); - this.record({ - v: 1, - t: new Date().toISOString(), - run: this.runId, - lane: spec.lane, - type: "lane_status", - text: runtime.statusTail.slice(-120), + let plan: LaneProcessPlan; + let parser: LaneEventParser; + try { + plan = adapter.build(spec); + parser = adapter.createParser(spec); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.record({ + v: 1, + t: new Date().toISOString(), + run: this.runId, + lane: spec.lane, + type: "lane_end", + ok: false, + stopReason: `setup:${message}`, + answer: message, + durationMs: Date.now() - preparationStartedAt, + }); + return; + } + + const { promise, resolve } = Promise.withResolvers(); + let child: ChildProcessByStdio; + try { + child = spawn(plan.command, plan.args, { + cwd: plan.cwd, + env: plan.env, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + } catch (error) { + this.record({ + v: 1, + t: new Date().toISOString(), + run: this.runId, + lane: spec.lane, + type: "lane_end", + ok: false, + stopReason: `spawn:${error instanceof Error ? error.message : String(error)}`, + answer: error instanceof Error ? error.message : String(error), + durationMs: 0, + }); + resolve(); + return promise; + } + + let raw: WriteStream | undefined; + if (this.rawDir) { + try { + const transcript = this.transcriptPath(spec.lane); + mkdirSync(transcript.directory, { recursive: true }); + raw = createWriteStream(transcript.path, { flags: "a" }); + raw.on("error", () => { + // Transcript capture is best-effort; the lane must still run and settle. }); - }; + } catch { + // Transcript capture is best-effort; the lane must still run. + } + } - const handleEvent = (event: unknown) => { - if (runtime.settled || !isRecord(event) || typeof event.type !== "string") return; - if (event.type === "tool_execution_start" && typeof event.toolName === "string") { + const runtime: LaneRuntime = { + child, + parser, + ...(raw ? { raw } : {}), + closed: false, + terminating: false, + parserEnded: false, + startedAt: Date.now(), + stdoutBuffer: "", + stdoutBytes: 0, + stdoutOverflow: false, + stderrTail: "", + answer: "", + statusTail: "", + lastStatusAt: 0, + tokensIn: 0, + tokensOut: 0, + cacheRead: 0, + context: 0, + rawBytes: 0, + rawCapped: false, + taskWritten: false, + }; + this.runtimes.set(spec.lane, runtime); + this.writeCanonical(runtime, { v: 1, type: "task", text: spec.task }); + + const emitStatus = () => { + runtime.statusTimer = undefined; + if (runtime.terminal || !runtime.statusTail) return; + runtime.lastStatusAt = Date.now(); + this.record({ + v: 1, + t: new Date().toISOString(), + run: this.runId, + lane: spec.lane, + type: "lane_status", + text: runtime.statusTail.slice(-120), + }); + }; + + const handleEvent = (event: NormalizedLaneEvent, allowSuccess = true) => { + if (!isRecord(event) || event.v !== 1 || typeof event.type !== "string") return; + if (event.type === "task") { + if (!runtime.taskWritten) this.writeCanonical(runtime, { v: 1, type: "task", text: spec.task }); + return; + } + if (event.type === "usage") { + if (!validUsage(event)) return; + if ( + event.input === runtime.tokensIn && + event.output === runtime.tokensOut && + event.cacheRead === runtime.cacheRead && + event.context === runtime.context + ) { + return; + } + } + this.writeCanonical(runtime, event); + + switch (event.type) { + case "thinking_delta": + break; + case "text_delta": { + if (runtime.answer.length < 4_000) runtime.answer += event.delta.slice(0, 4_000 - runtime.answer.length); + runtime.statusTail = `${runtime.statusTail}${event.delta}`.replace(/\s+/g, " ").trim().slice(-120); + const elapsed = Date.now() - runtime.lastStatusAt; + if (elapsed >= 1_000) emitStatus(); + else if (!runtime.statusTimer) { + runtime.statusTimer = setTimeout(emitStatus, 1_000 - elapsed); + runtime.statusTimer.unref(); + } + break; + } + case "tool_start": this.record({ v: 1, t: new Date().toISOString(), run: this.runId, lane: spec.lane, type: "lane_tool", - tool: event.toolName, - summary: toolSummary(event.toolName, event.args), + tool: event.tool, + summary: toolSummary(event.tool, event.input), }); - return; - } - if (event.type === "message_start" && isRecord(event.message) && event.message.role === "assistant") { - runtime.answer = ""; - runtime.statusTail = ""; - return; - } - if (event.type === "message_update" && isRecord(event.assistantMessageEvent)) { - const update = event.assistantMessageEvent; - if (update.type === "text_delta" && typeof update.delta === "string") { - if (runtime.answer.length < 4_000) runtime.answer += update.delta.slice(0, 4_000 - runtime.answer.length); - runtime.statusTail = `${runtime.statusTail}${update.delta}`.replace(/\s+/g, " ").trim().slice(-120); - const elapsed = Date.now() - runtime.lastStatusAt; - if (elapsed >= 1_000) emitStatus(); - else if (!runtime.statusTimer) { - runtime.statusTimer = setTimeout(emitStatus, 1_000 - elapsed); - runtime.statusTimer.unref(); - } - } - return; - } - if (event.type === "message_end" && isRecord(event.message) && event.message.role === "assistant") { - const completeText = messageText(event.message); - if (completeText !== undefined) runtime.answer = completeText.slice(0, 4_000); - if (isRecord(event.message.usage)) { - const usage = event.message.usage; - runtime.tokensIn += usageNumber(usage, "input", "inputTokens", "input_tokens"); - runtime.tokensOut += usageNumber(usage, "output", "outputTokens", "output_tokens"); - runtime.cacheRead += usageNumber(usage, "cacheRead", "cacheReadTokens", "cache_read"); - runtime.cacheWrite += usageNumber(usage, "cacheWrite", "cacheWriteTokens", "cache_write"); - const reportedTotal = usageNumber(usage, "totalTokens", "total_tokens", "total", "contextTokens"); - this.record({ - v: 1, - t: new Date().toISOString(), - run: this.runId, - lane: spec.lane, - type: "lane_usage", - input: runtime.tokensIn, - output: runtime.tokensOut, - cacheRead: runtime.cacheRead, - cost: estimateCost(spec.model, runtime.tokensIn, runtime.tokensOut), - context: reportedTotal || runtime.tokensIn + runtime.tokensOut, - }); - } - return; - } - if (event.type === "agent_end") { - if (Array.isArray(event.messages)) { - for (let index = event.messages.length - 1; index >= 0; index--) { - const text = messageText(event.messages[index]); - if (text !== undefined) { - runtime.answer = text.slice(0, 4_000); - break; - } - } - } - finish(true, runtime.answer); - } - }; - - const consumeLine = (line: string) => { - if (!line.trim()) return; - if (runtime.raw?.writable) runtime.raw.write(`${line}\n`); - try { - handleEvent(JSON.parse(line)); - } catch { - // Ignore malformed child output; process exit still yields a terminal lane event. - } - }; - - child.once("spawn", () => { - if (!runtime.settled) { + break; + case "tool_end": + break; + case "usage": + runtime.tokensIn = event.input; + runtime.tokensOut = event.output; + runtime.cacheRead = event.cacheRead; + runtime.context = event.context; this.record({ v: 1, t: new Date().toISOString(), run: this.runId, lane: spec.lane, - type: "lane_start", - pid: child.pid!, + type: "lane_usage", + input: event.input, + output: event.output, + cacheRead: event.cacheRead, + cost: estimateCost(spec.model, event.input, event.output), + context: event.context, }); - } + break; + case "assistant_end": + runtime.answer = event.text.slice(0, 4_000); + if (isAssistantError(event)) { + this.requestTerminal(runtime, { + type: "end", + ok: false, + answer: runtime.answer, + stopReason: "assistant-error", + }); + } else if (allowSuccess) { + this.requestTerminal(runtime, { type: "end", ok: true, answer: runtime.answer }); + } + break; + } + }; + + const feedParser = (line: string) => { + try { + for (const event of parser.feedLine(line)) handleEvent(event); + } catch (error) { + this.requestTerminal(runtime, { + type: "end", + ok: false, + answer: runtime.stderrTail, + stopReason: `parser:${error instanceof Error ? error.message : String(error)}`, + }); + } + }; + + child.once("spawn", () => { + if (!runtime.closed && !runtime.terminal) { + this.record({ + v: 1, + t: new Date().toISOString(), + run: this.runId, + lane: spec.lane, + type: "lane_start", + pid: child.pid!, + }); + } + }); + const stdoutDecoder = new StringDecoder("utf8"); + child.stdout.on("data", (chunk: Buffer) => { + if (runtime.stdoutOverflow) return; + runtime.stdoutBytes += chunk.length; + if (runtime.stdoutBytes > MAX_STREAM_BYTES) { + runtime.stdoutOverflow = true; + runtime.stdoutBuffer = ""; + this.requestTerminal(runtime, { + type: "end", + ok: false, + answer: runtime.answer, + stopReason: "stdout-overflow", + }); + return; + } + runtime.stdoutBuffer += stdoutDecoder.write(chunk); + let newline = runtime.stdoutBuffer.indexOf("\n"); + while (newline >= 0) { + const line = runtime.stdoutBuffer.slice(0, newline); + runtime.stdoutBuffer = runtime.stdoutBuffer.slice(newline + 1); + if (line.trim()) feedParser(line); + newline = runtime.stdoutBuffer.indexOf("\n"); + } + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + runtime.stderrTail = `${runtime.stderrTail}${chunk}`.slice(-500); + }); + child.once("error", (error) => { + this.requestTerminal(runtime, { + type: "end", + ok: false, + answer: runtime.stderrTail || error.message, + stopReason: `spawn:${error.message}`, }); - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { - if (runtime.stdoutBuffer.length + chunk.length > MAX_STDOUT_BUFFER) { - finish(false, runtime.answer, "stdout-overflow"); - return; + }); + child.once("close", (code) => { + runtime.closed = true; + clearTimeout(runtime.statusTimer); + if (!runtime.stdoutOverflow) { + runtime.stdoutBuffer += stdoutDecoder.end(); + if (runtime.stdoutBuffer) feedParser(runtime.stdoutBuffer); + } + runtime.stdoutBuffer = ""; + + if (!runtime.parserEnded) { + runtime.parserEnded = true; + try { + for (const event of parser.end()) handleEvent(event, code === 0); + } catch (error) { + if (!runtime.terminal) { + runtime.terminal = { + type: "end", + ok: false, + answer: runtime.stderrTail, + stopReason: `parser:${error instanceof Error ? error.message : String(error)}`, + }; + } } - runtime.stdoutBuffer += chunk; - const lines = runtime.stdoutBuffer.split("\n"); - runtime.stdoutBuffer = lines.pop() ?? ""; - for (const line of lines) consumeLine(line); - }); - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk: string) => { - runtime.stderrTail = `${runtime.stderrTail}${chunk}`.slice(-500); - }); - child.once("error", (error) => finish(false, runtime.stderrTail || error.message, `spawn:${error.message}`)); - child.once("close", (code) => { - if (runtime.stdoutBuffer) consumeLine(runtime.stdoutBuffer); - // End the transcript only after the last stdout flush so abandoned or - // failed lanes keep their tail lines. - runtime.raw?.end(); - if (!runtime.settled) finish(false, runtime.stderrTail, `exit:${code ?? "signal"}`); - }); + } + if (!runtime.terminal) { + runtime.terminal = { + type: "end", + ok: false, + answer: runtime.stderrTail || runtime.answer, + stopReason: `exit:${code ?? "signal"}`, + }; + } + + const outcome = runtime.terminal; + if (outcome.type === "end") { + this.record({ + v: 1, + t: new Date().toISOString(), + run: this.runId, + lane: spec.lane, + type: "lane_end", + ok: outcome.ok, + ...(outcome.stopReason ? { stopReason: outcome.stopReason } : {}), + answer: outcome.answer.slice(0, 4_000), + durationMs: Date.now() - runtime.startedAt, + }); + } + this.runtimes.delete(spec.lane); + + let finished = false; + const finish = () => { + if (finished) return; + finished = true; + resolve(); + }; + if (runtime.raw && !runtime.raw.destroyed) { + runtime.raw.once("error", finish); + runtime.raw.end(finish); + } else { + finish(); + } + }); return promise; } + + private writeCanonical(runtime: LaneRuntime, event: NormalizedLaneEvent): void { + if (event.type === "task") { + if (runtime.taskWritten) return; + runtime.taskWritten = true; + } + if (!runtime.raw?.writable || runtime.rawCapped) return; + const line = `${JSON.stringify(event)}\n`; + const bytes = Buffer.byteLength(line); + if (runtime.rawBytes + bytes > MAX_STREAM_BYTES) { + runtime.rawCapped = true; + return; + } + runtime.rawBytes += bytes; + runtime.raw.write(line); + } } diff --git a/packages/pi-kit/src/schema.ts b/packages/pi-kit/src/schema.ts index a4d7c3b..b3e4b11 100644 --- a/packages/pi-kit/src/schema.ts +++ b/packages/pi-kit/src/schema.ts @@ -6,6 +6,10 @@ export type Effort = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; +export type LaneMode = "read-only" | "workspace-write"; +export type LaneOrigin = "pi" | "mcp"; +export type ExecutionRoute = "pi" | "claude-code"; + export type LaneState = "queued" | "running" | "done" | "failed" | "abandoned"; /** A single dispatched subagent lane. Model and effort MUST be explicit. */ @@ -17,6 +21,8 @@ export interface LaneSpec { /** full selector "provider/model-id", e.g. "openai-codex/gpt-5.6-sol" */ model: string; effort: Effort; + /** tool and workspace access policy; Pi-origin calls default to workspace-write */ + mode?: LaneMode; /** working directory; defaults to the parent session cwd */ cwd?: string; /** why this model/effort was chosen (journaled for audit) */ diff --git a/packages/pi-kit/src/table.ts b/packages/pi-kit/src/table.ts index 102fe92..a6a523d 100644 --- a/packages/pi-kit/src/table.ts +++ b/packages/pi-kit/src/table.ts @@ -3,27 +3,42 @@ * Mirrors the managed pool in ~/.pi/agent/AGENTS.md. Costs are rough * routing estimates in USD per million tokens, not billing truth. */ -import type { Effort, LaneSpec } from "./schema.ts"; +import type { + Effort, + ExecutionRoute, + LaneMode, + LaneOrigin, + LaneSpec, +} from "./schema.ts"; export interface ModelRow { selector: string; name: string; prior: Effort; + route: ExecutionRoute; + runtimeModel: string; + allowedEfforts: Effort[]; + origins: LaneOrigin[]; /** estimated $/Mtok input, output — routing heuristics only */ inPerM: number; outPerM: number; vision: boolean; } +const ALL_EFFORTS: Effort[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; +const ALL_ORIGINS: LaneOrigin[] = ["pi", "mcp"]; + export const MODEL_TABLE: ModelRow[] = [ - { selector: "openai-codex/gpt-5.6-sol", name: "GPT-5.6 Sol", prior: "medium", inPerM: 1.75, outPerM: 14, vision: true }, - { selector: "anthropic/claude-fable-5", name: "Fable 5", prior: "high", inPerM: 3, outPerM: 15, vision: true }, - { selector: "anthropic/claude-opus-4-8", name: "Opus 4.8", prior: "xhigh", inPerM: 10, outPerM: 40, vision: true }, - { selector: "anthropic/claude-sonnet-5", name: "Sonnet 5", prior: "medium", inPerM: 3, outPerM: 15, vision: true }, - { selector: "ollama/glm-5.2:cloud", name: "GLM-5.2", prior: "medium", inPerM: 0.6, outPerM: 2.2, vision: false }, + { selector: "openai-codex/gpt-5.6-sol", name: "GPT-5.6 Sol", prior: "medium", route: "pi", runtimeModel: "gpt-5.6-sol", allowedEfforts: [...ALL_EFFORTS], origins: [...ALL_ORIGINS], inPerM: 1.75, outPerM: 14, vision: true }, + { selector: "anthropic/claude-fable-5", name: "Fable 5", prior: "high", route: "claude-code", runtimeModel: "fable", allowedEfforts: ["low", "medium", "high"], origins: ["pi"], inPerM: 3, outPerM: 15, vision: true }, + { selector: "anthropic/claude-opus-4-8", name: "Opus 4.8", prior: "xhigh", route: "claude-code", runtimeModel: "opus", allowedEfforts: ["xhigh"], origins: ["pi"], inPerM: 10, outPerM: 40, vision: true }, + { selector: "anthropic/claude-sonnet-5", name: "Sonnet 5", prior: "medium", route: "claude-code", runtimeModel: "sonnet", allowedEfforts: ["low", "medium", "high", "xhigh"], origins: ["pi"], inPerM: 3, outPerM: 15, vision: true }, + { selector: "xai/grok-4.5", name: "Grok 4.5", prior: "high", route: "pi", runtimeModel: "grok-4.5", allowedEfforts: ["high"], origins: [...ALL_ORIGINS], inPerM: 0, outPerM: 0, vision: true }, + { selector: "ollama/glm-5.2:cloud", name: "GLM-5.2", prior: "medium", route: "pi", runtimeModel: "glm-5.2:cloud", allowedEfforts: [...ALL_EFFORTS], origins: [...ALL_ORIGINS], inPerM: 0.6, outPerM: 2.2, vision: false }, ]; -export const EFFORTS: Effort[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; +export const EFFORTS: Effort[] = [...ALL_EFFORTS]; +export const LANE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; /** Models that must never run, on any route. */ export const BANNED = [/haiku/i, /luna/i, /gpt-5\.6-terra/i]; @@ -38,24 +53,58 @@ export function findModel(selector: string): ModelRow | undefined { return MODEL_TABLE.find((m) => m.selector === selector); } +export interface LanePolicyContext { + origin: LaneOrigin; +} + +export type NormalizedLaneSpec = LaneSpec & { mode: LaneMode }; + +/** Apply origin-specific compatibility defaults after validation. */ +export function normalizeLaneSpec( + spec: LaneSpec, + { origin }: LanePolicyContext = { origin: "pi" }, +): NormalizedLaneSpec { + const mode = spec.mode ?? (origin === "pi" ? "workspace-write" : undefined); + if (!mode) throw new Error("mode is required for MCP-origin lanes"); + return { ...spec, mode }; +} + /** Validate a lane spec against policy. Returns null when valid, else a reason. */ -export function validateLaneSpec(spec: Partial): string | null { +export function validateLaneSpec( + spec: Partial, + { origin }: LanePolicyContext = { origin: "pi" }, +): string | null { + if (!spec.lane || !LANE_ID_PATTERN.test(spec.lane)) { + return "lane id must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"; + } if (!spec.task || !spec.task.trim()) return "task is required and must be self-contained"; if (!spec.model) return "model is required: state the full selector from the model table"; - if (BANNED.some((re) => re.test(spec.model!))) { - return `model ${spec.model} is banned (Haiku/Luna/Terra are never allowed)`; + const model = spec.model; + if (BANNED.some((re) => re.test(model))) { + return `model ${model} is banned (Haiku/Luna/Terra are never allowed)`; } if (!spec.effort) return "effort is required: a default lane is not a model selection"; - if (!EFFORTS.includes(spec.effort)) { - return `effort "${spec.effort}" is invalid; xhigh is the absolute ceiling (ultra/max do not exist)`; - } - const pin = EFFORT_PINS.find((p) => p.match.test(spec.model!)); - if (pin && spec.effort !== pin.level) { - return `model ${spec.model} is pinned to effort ${pin.level}`; + const effort = spec.effort; + if (!EFFORTS.includes(effort)) { + return `effort "${effort}" is invalid; xhigh is the absolute ceiling (ultra/max do not exist)`; } - const row = findModel(spec.model!); + const row = findModel(model); if (!row) { - return `model ${spec.model} is not in the current table; use one of: ${MODEL_TABLE.map((m) => m.selector).join(", ")}`; + return `model ${model} is not in the current table; use one of: ${MODEL_TABLE.map((m) => m.selector).join(", ")}`; + } + if (!row.origins.includes(origin)) { + return `model ${model} is unavailable from ${origin}; use the native Claude workflow for Claude-to-Claude delegation`; + } + if (spec.mode !== undefined && spec.mode !== "read-only" && spec.mode !== "workspace-write") { + return `mode "${spec.mode}" is invalid; use read-only or workspace-write`; + } + if (origin === "mcp" && !spec.mode) return "mode is required for MCP-origin lanes"; + const pin = EFFORT_PINS.find((p) => p.match.test(model)); + if (pin && effort !== pin.level) { + return `model ${model} is pinned to effort ${pin.level}`; + } + if (!row.allowedEfforts.includes(effort)) { + return `model ${model} has unsupported effort ${effort}; use one of: ${row.allowedEfforts.join(", ")}`; } return null; } diff --git a/packages/pi-kit/src/transcript.ts b/packages/pi-kit/src/transcript.ts index af250b9..7012f69 100644 --- a/packages/pi-kit/src/transcript.ts +++ b/packages/pi-kit/src/transcript.ts @@ -28,6 +28,14 @@ function firstText(content: unknown): string { return text; } +function inputText(input: unknown): string { + try { + return JSON.stringify(input) ?? ""; + } catch { + return String(input); + } +} + const MAX_ENTRY_TEXT = 20_000; const MAX_RESULT_TEXT = 4_000; @@ -47,7 +55,8 @@ export class LaneTranscript { return; } if (!isRecord(event) || typeof event.type !== "string") return; - this.handle(event); + if (event.v === 1 && this.handleNormalized(event)) return; + this.handlePi(event); } feedChunk(chunk: string): void { @@ -70,7 +79,53 @@ export class LaneTranscript { this.version++; } - private handle(event: JsonRecord): void { + private handleNormalized(event: JsonRecord): boolean { + switch (event.type) { + case "task": + if (typeof event.text !== "string") return true; + if (this.entries.length === 0) this.push("task", event.text.slice(0, MAX_ENTRY_TEXT)); + return true; + case "thinking_delta": + if (typeof event.delta === "string") this.appendDelta("thinking", "normalized-thinking", event.delta); + return true; + case "text_delta": + if (typeof event.delta === "string") this.appendDelta("text", "normalized-text", event.delta); + return true; + case "tool_start": + if (typeof event.tool !== "string") return true; + this.currentKey = ""; + this.push("tool", inputText(event.input).slice(0, MAX_RESULT_TEXT), event.tool); + return true; + case "tool_end": + if (typeof event.tool !== "string" || typeof event.text !== "string") return true; + this.currentKey = ""; + this.push( + "tool_result", + event.text.slice(0, MAX_RESULT_TEXT), + event.isError === true ? `${event.tool} (error)` : event.tool, + ); + return true; + case "usage": + return true; + case "assistant_end": { + if (typeof event.text !== "string" || !event.text) return true; + const rendered = this.entries + .filter((entry) => entry.kind === "text") + .map((entry) => entry.text) + .join(""); + if (!rendered) this.push("text", event.text.slice(0, MAX_ENTRY_TEXT)); + else if (event.text.startsWith(rendered) && event.text.length > rendered.length) { + this.appendDelta("text", "normalized-text", event.text.slice(rendered.length)); + } + this.currentKey = ""; + return true; + } + default: + return false; + } + } + + private handlePi(event: JsonRecord): void { if (event.type === "message_start" && isRecord(event.message) && event.message.role === "user") { if (this.entries.length === 0) this.push("task", firstText(event.message.content).slice(0, MAX_ENTRY_TEXT)); return; diff --git a/packages/pi-kit/test/child-env.test.ts b/packages/pi-kit/test/child-env.test.ts new file mode 100644 index 0000000..9d616f2 --- /dev/null +++ b/packages/pi-kit/test/child-env.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { createChildEnvironment } from "../src/child-env.ts"; + +describe("createChildEnvironment", () => { + it("keeps only the minimal runtime allowlist", () => { + const environment = createChildEnvironment({ + PATH: "/usr/bin", + HOME: "/home/lane", + USER: "lane", + LOGNAME: "lane", + SHELL: "/bin/zsh", + TMPDIR: "/tmp/lane", + LANG: "en_US.UTF-8", + LANGUAGE: "en_US", + LC_ALL: "en_US.UTF-8", + LC_CTYPE: "UTF-8", + TERM: "xterm-256color", + COLORTERM: "truecolor", + TERM_PROGRAM: "pi", + TERM_PROGRAM_VERSION: "1.0", + XDG_CONFIG_HOME: "/home/lane/.config", + XDG_CACHE_HOME: "/home/lane/.cache", + XDG_DATA_HOME: "/home/lane/.local/share", + XDG_STATE_HOME: "/home/lane/.local/state", + XDG_RUNTIME_DIR: "/tmp/lane-runtime", + OPENAI_API_KEY: "sentinel-key", + ANTHROPIC_AUTH_TOKEN: "sentinel-auth", + COOKIE: "sentinel-cookie", + AWS_ACCESS_KEY_ID: "sentinel-aws", + GOOGLE_APPLICATION_CREDENTIALS: "/sentinel/gcp.json", + AZURE_CLIENT_SECRET: "sentinel-azure", + SSH_AUTH_SOCK: "/sentinel/agent.sock", + }); + + expect(environment).toEqual({ + PATH: "/usr/bin", + HOME: "/home/lane", + USER: "lane", + LOGNAME: "lane", + SHELL: "/bin/zsh", + TMPDIR: "/tmp/lane", + LANG: "en_US.UTF-8", + LANGUAGE: "en_US", + LC_ALL: "en_US.UTF-8", + LC_CTYPE: "UTF-8", + TERM: "xterm-256color", + COLORTERM: "truecolor", + TERM_PROGRAM: "pi", + TERM_PROGRAM_VERSION: "1.0", + XDG_CONFIG_HOME: "/home/lane/.config", + XDG_CACHE_HOME: "/home/lane/.cache", + XDG_DATA_HOME: "/home/lane/.local/share", + XDG_STATE_HOME: "/home/lane/.local/state", + XDG_RUNTIME_DIR: "/tmp/lane-runtime", + }); + expect(Object.values(environment).some((value) => value?.startsWith("sentinel"))).toBe(false); + }); +}); diff --git a/packages/pi-kit/test/claude-code-adapter.test.ts b/packages/pi-kit/test/claude-code-adapter.test.ts new file mode 100644 index 0000000..d881941 --- /dev/null +++ b/packages/pi-kit/test/claude-code-adapter.test.ts @@ -0,0 +1,318 @@ +import { access, chmod, mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + CLAUDE_CODE_MIN_VERSION, + createClaudeCodeAdapter, +} from "../src/adapters/claude-code.ts"; +import type { LaneSpec } from "../src/schema.ts"; + +const fixture = fileURLToPath(new URL("./fixtures/claude-lane-stream.jsonl", import.meta.url)); +let sandbox: string; + +const baseSpec: LaneSpec = { + lane: "claude-worker", + task: "Inspect the workspace and report.", + model: "anthropic/claude-fable-5", + effort: "high", + mode: "read-only", +}; + +async function fakeClaude(directory: string, version = "2.1.216", body = ""): Promise { + await mkdir(directory, { recursive: true }); + const path = join(directory, "claude"); + await writeFile( + path, + `#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"${version} (Claude Code)\"; exit 0; fi\n${body}\n`, + "utf8", + ); + await chmod(path, 0o755); + return path; +} + +beforeEach(async () => { + sandbox = await mkdtemp(join(tmpdir(), "pi-kit-claude-adapter-")); +}); + +afterEach(async () => { + await rm(sandbox, { recursive: true, force: true }); +}); + +describe("Claude Code command policy", () => { + it("builds the complete read-only argv with the task after --", async () => { + const binary = await fakeClaude(join(sandbox, "bin")); + const adapter = createClaudeCodeAdapter({ binary }); + + await adapter.prepare(baseSpec); + const plan = adapter.build(baseSpec); + + expect(plan.command).toBe(await realpath(binary)); + expect(plan.args).toEqual([ + "-p", + "--safe-mode", + "--disable-slash-commands", + "--no-chrome", + "--no-session-persistence", + "--permission-mode", + "dontAsk", + "--output-format", + "stream-json", + "--verbose", + "--include-partial-messages", + "--model", + "fable", + "--effort", + "high", + "--tools", + "Read,Glob,Grep", + "--allowedTools", + "Read", + "Glob", + "Grep", + "--", + baseSpec.task, + ]); + expect(plan.cwd).toBe(process.cwd()); + }); + + it("builds unrestricted workspace-write tools without bespoke command policy", async () => { + const binary = await fakeClaude(join(sandbox, "bin")); + const adapter = createClaudeCodeAdapter({ binary }); + const task = "--dangerously-skip-permissions $(touch should-not-run)"; + + await adapter.prepare(baseSpec); + const plan = adapter.build({ ...baseSpec, task, mode: "workspace-write" }); + + expect(plan.args).toEqual([ + "-p", + "--safe-mode", + "--disable-slash-commands", + "--no-chrome", + "--no-session-persistence", + "--permission-mode", + "dontAsk", + "--output-format", + "stream-json", + "--verbose", + "--include-partial-messages", + "--model", + "fable", + "--effort", + "high", + "--tools", + "Read,Glob,Grep,Edit,Write,Bash", + "--allowedTools", + "Read", + "Glob", + "Grep", + "Edit", + "Write", + "Bash", + "--", + task, + ]); + expect(plan.args.slice(0, -1)).not.toContain("--dangerously-skip-permissions"); + expect(plan.args).not.toContain("--bare"); + expect(plan.args).not.toContain("bypassPermissions"); + expect(plan.args).not.toContain("max"); + expect(plan.args.at(-1)).toBe(task); + }); + + it("defaults existing Pi-origin specs to workspace-write", async () => { + const binary = await fakeClaude(join(sandbox, "bin")); + const adapter = createClaudeCodeAdapter({ binary }); + await adapter.prepare(baseSpec); + const plan = adapter.build({ ...baseSpec, mode: undefined }); + + expect(plan.args).toContain("Read,Glob,Grep,Edit,Write,Bash"); + }); + + it("does not inherit sensitive environment variables", async () => { + const binary = await fakeClaude(join(sandbox, "bin")); + const adapter = createClaudeCodeAdapter({ + binary, + env: { + PATH: "/usr/bin", + HOME: "/home/lane", + TERM: "xterm-256color", + ANTHROPIC_AUTH_TOKEN: "sentinel-auth", + COOKIE: "sentinel-cookie", + AWS_SESSION_TOKEN: "sentinel-aws", + GOOGLE_APPLICATION_CREDENTIALS: "/sentinel/gcp.json", + AZURE_CLIENT_SECRET: "sentinel-azure", + SSH_AUTH_SOCK: "/sentinel/agent.sock", + }, + }); + await adapter.prepare(baseSpec); + const plan = adapter.build(baseSpec); + + expect(plan.env).toEqual({ PATH: "/usr/bin", HOME: "/home/lane", TERM: "xterm-256color" }); + expect(Object.values(plan.env)).not.toContain("sentinel-auth"); + }); +}); + +describe("Claude Code executable preflight", () => { + it("resolves PATH candidates, skips unsafe wrappers, and returns a real executable", async () => { + await fakeClaude( + join(sandbox, "unsafe"), + "2.1.216", + "exec /usr/bin/false --permission-mode bypassPermissions \"$@\"", + ); + const safe = await fakeClaude(join(sandbox, "safe")); + const linkedDirectory = join(sandbox, "linked"); + await mkdir(linkedDirectory); + await symlink(safe, join(linkedDirectory, "claude")); + const adapter = createClaudeCodeAdapter({ + env: { ...process.env, PATH: [join(sandbox, "unsafe"), linkedDirectory].join(":") }, + }); + + await adapter.prepare(baseSpec); + expect(adapter.build(baseSpec).command).toBe(await realpath(safe)); + }); + + it("rejects dangerous wrappers and non-executable candidates", async () => { + await fakeClaude(join(sandbox, "unsafe"), "2.1.216", "exec claude --dangerously-skip-permissions \"$@\""); + const nonExecutable = join(sandbox, "plain", "claude"); + await mkdir(join(sandbox, "plain")); + await writeFile(nonExecutable, "#!/bin/sh\necho '2.1.216 (Claude Code)'\n", "utf8"); + const adapter = createClaudeCodeAdapter({ + env: { ...process.env, PATH: [join(sandbox, "unsafe"), join(sandbox, "plain")].join(":") }, + }); + + expect(() => adapter.preflight(baseSpec)).toThrow("No safe Claude Code executable"); + }); + + it.each([ + ["2.1.215", false], + [CLAUDE_CODE_MIN_VERSION, true], + ["2.1.217", true], + ])("handles Claude Code version %s", async (version, supported) => { + const binary = await fakeClaude(join(sandbox, version), version); + const adapter = createClaudeCodeAdapter({ binary }); + + if (supported) await expect(adapter.prepare(baseSpec)).resolves.toBeUndefined(); + else await expect(adapter.prepare(baseSpec)).rejects.toThrow(`requires ${CLAUDE_CODE_MIN_VERSION} or newer`); + }); + + it("runs version preparation asynchronously without invoking the provider", async () => { + const marker = join(sandbox, "version-started"); + const release = join(sandbox, "release-version"); + const provider = join(sandbox, "provider-invoked"); + const binary = join(sandbox, "claude"); + await writeFile( + binary, + `#!/usr/bin/env node\nconst { existsSync, writeFileSync } = require("node:fs");\nif (process.argv[2] === "--version") {\n writeFileSync(${JSON.stringify(marker)}, "started");\n const timer = setInterval(() => {\n if (existsSync(${JSON.stringify(release)})) {\n clearInterval(timer);\n console.log("2.1.216 (Claude Code)");\n process.exit(0);\n }\n }, 10);\n} else {\n writeFileSync(${JSON.stringify(provider)}, "invoked");\n}\n`, + "utf8", + ); + await chmod(binary, 0o755); + const adapter = createClaudeCodeAdapter({ binary }); + + let settled = false; + const preparation = adapter.prepare(baseSpec).finally(() => { + settled = true; + }); + for (let attempt = 0; attempt < 100; attempt++) { + try { + await access(marker); + break; + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + await expect(access(marker)).resolves.toBeUndefined(); + expect(settled).toBe(false); + await expect(access(provider)).rejects.toMatchObject({ code: "ENOENT" }); + + await writeFile(release, "release", "utf8"); + await preparation; + expect(adapter.build(baseSpec).command).toBe(await realpath(binary)); + await expect(access(provider)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects prohibited efforts before command construction", async () => { + const binary = await fakeClaude(join(sandbox, "bin")); + const adapter = createClaudeCodeAdapter({ binary }); + + expect(() => adapter.build({ ...baseSpec, effort: "max" as LaneSpec["effort"] })).toThrow("unsupported effort"); + }); +}); + +describe("Claude Code stream parser", () => { + it("normalizes partial text, thinking, tool lifecycle, cumulative usage, and final result", async () => { + const lines = (await readFile(fixture, "utf8")).trimEnd().split("\n"); + const parser = createClaudeCodeAdapter({ binary: "/unused" }).createParser(baseSpec); + const events = lines.flatMap((line) => parser.feedLine(line)); + + expect(events[0]).toEqual({ v: 1, type: "task", text: baseSpec.task }); + expect(events).toContainEqual({ v: 1, type: "thinking_delta", delta: "Inspecting fixture." }); + expect(events.filter((event) => event.type === "text_delta")).toEqual([ + { v: 1, type: "text_delta", delta: "Adapter " }, + { v: 1, type: "text_delta", delta: "complete." }, + ]); + expect(events).toContainEqual({ + v: 1, + type: "tool_start", + tool: "Read", + input: { file_path: "README.md" }, + }); + expect(events).toContainEqual({ + v: 1, + type: "tool_end", + tool: "Read", + text: "fixture contents", + isError: false, + }); + expect(events.filter((event) => event.type === "usage")).toEqual([ + { v: 1, type: "usage", input: 70, output: 10, cacheRead: 20, context: 105 }, + { v: 1, type: "usage", input: 120, output: 40, cacheRead: 50, context: 230 }, + ]); + expect(events.at(-1)).toEqual({ v: 1, type: "assistant_end", text: "Adapter complete." }); + expect(parser.end()).toEqual([]); + }); + + it.each([ + { case: "is_error", subtype: "failure", is_error: true, result: `auth failed ${"x".repeat(500)}` }, + { case: "error subtype", subtype: "error_max_turns", is_error: false, result: "turn limit reached" }, + ])("normalizes a zero-exit $case result as a bounded failed assistant end", (result) => { + const parser = createClaudeCodeAdapter({ binary: "/unused" }).createParser(baseSpec); + + const events = parser.feedLine(JSON.stringify({ type: "result", ...result })); + const end = events.at(-1); + + expect(end).toMatchObject({ v: 1, type: "assistant_end", isError: true }); + expect(end && "text" in end ? end.text.length : 0).toBeLessThanOrEqual(240); + expect(parser.end()).toEqual([]); + }); + + it("turns malformed JSON into a bounded diagnostic event without throwing", () => { + const parser = createClaudeCodeAdapter({ binary: "/unused" }).createParser(baseSpec); + + const events = parser.feedLine(`{"type":"assistant","private":"${"x".repeat(2_000)}`); + const diagnostic = events.find((event) => event.type === "tool_end"); + + expect(diagnostic).toMatchObject({ v: 1, type: "tool_end", tool: "claude-code-parser", isError: true }); + expect(diagnostic && "text" in diagnostic ? diagnostic.text.length : 0).toBeLessThanOrEqual(240); + }); + + it("ignores unknown events and ends partial output once", () => { + const parser = createClaudeCodeAdapter({ binary: "/unused" }).createParser(baseSpec); + + expect(parser.feedLine('{"type":"future_event","payload":true}')).toEqual([ + { v: 1, type: "task", text: baseSpec.task }, + ]); + expect( + parser.feedLine( + '{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"partial"}}}', + ), + ).toEqual([{ v: 1, type: "text_delta", delta: "partial" }]); + expect(parser.end()).toEqual([{ v: 1, type: "assistant_end", text: "partial" }]); + expect(parser.end()).toEqual([]); + }); + + it("emits only the task when an empty stream ends", () => { + const parser = createClaudeCodeAdapter({ binary: "/unused" }).createParser(baseSpec); + expect(parser.end()).toEqual([{ v: 1, type: "task", text: baseSpec.task }]); + }); +}); diff --git a/packages/pi-kit/test/execution-adapters.test.ts b/packages/pi-kit/test/execution-adapters.test.ts new file mode 100644 index 0000000..7163864 --- /dev/null +++ b/packages/pi-kit/test/execution-adapters.test.ts @@ -0,0 +1,138 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { PiExecutionAdapter } from "../src/adapters/pi.ts"; +import type { LaneSpec } from "../src/schema.ts"; + +const fixturePath = fileURLToPath(new URL("./fixtures/lane-stream.jsonl", import.meta.url)); + +const baseSpec: LaneSpec = { + lane: "worker", + task: "Run the fixture task", + model: "openai-codex/gpt-5.6-sol", + effort: "medium", + mode: "read-only", + cwd: "/workspace with spaces", +}; + +describe("PiExecutionAdapter", () => { + it("builds the existing JSON invocation with an injectable binary and read-only tools", () => { + const adapter = new PiExecutionAdapter("/opt/pi test/bin/pi"); + + expect(adapter.build(baseSpec)).toMatchObject({ + command: "/opt/pi test/bin/pi", + cwd: "/workspace with spaces", + args: [ + "--mode", + "json", + "--no-extensions", + "--no-session", + "-p", + "--tools", + "read,grep,find,ls", + "--provider", + "openai-codex", + "--model", + "gpt-5.6-sol", + "--thinking", + "medium", + "Task:\nRun the fixture task", + ], + env: expect.objectContaining({ PIKIT_CHILD: "1" }), + }); + }); + + it.each(["@prompt.md", "-", "--dangerously-skip-permissions"])( + "prefixes positional task %s with a non-option literal", + (task) => { + const plan = new PiExecutionAdapter().build({ ...baseSpec, task }); + + expect(plan.args.at(-1)).toBe(`Task:\n${task}`); + expect(plan.args.at(-1)?.startsWith("Task:\n")).toBe(true); + }, + ); + + it("does not inherit sensitive environment variables", () => { + const plan = new PiExecutionAdapter("pi", { + PATH: "/usr/bin", + HOME: "/home/lane", + LANG: "en_US.UTF-8", + OPENAI_API_KEY: "sentinel-secret", + AWS_SECRET_ACCESS_KEY: "sentinel-aws", + GOOGLE_APPLICATION_CREDENTIALS: "/sentinel/gcp.json", + AZURE_CLIENT_SECRET: "sentinel-azure", + SSH_AUTH_SOCK: "/sentinel/agent.sock", + }).build(baseSpec); + + expect(plan.env).toEqual({ + PATH: "/usr/bin", + HOME: "/home/lane", + LANG: "en_US.UTF-8", + PIKIT_CHILD: "1", + }); + expect(Object.values(plan.env)).not.toContain("sentinel-secret"); + }); + + it("uses table runtime mappings for every Pi-routed model", () => { + const adapter = new PiExecutionAdapter(); + const cases = [ + ["openai-codex/gpt-5.6-sol", "medium", "openai-codex", "gpt-5.6-sol"], + ["xai/grok-4.5", "high", "xai", "grok-4.5"], + ["ollama/glm-5.2:cloud", "medium", "ollama", "glm-5.2:cloud"], + ] as const; + + for (const [selector, effort, provider, model] of cases) { + const args = adapter.build({ ...baseSpec, model: selector, effort }).args; + expect(args.slice(args.indexOf("--provider"), args.indexOf("--thinking"))).toEqual([ + "--provider", + provider, + "--model", + model, + ]); + } + }); + + it("uses normal harness tools for workspace-write mode", () => { + const plan = new PiExecutionAdapter().build({ ...baseSpec, mode: "workspace-write" }); + + expect(plan.args).toContain("--no-extensions"); + expect(plan.args).not.toContain("-e"); + expect(plan.args).not.toContain("--extension"); + expect(plan.args).not.toContain("--tools"); + }); + + it("normalizes Pi JSONL one line at a time", async () => { + const fixture = await readFile(fixturePath, "utf8"); + const parser = new PiExecutionAdapter().createParser(baseSpec); + const events = fixture.trimEnd().split("\n").flatMap((line) => parser.feedLine(line)); + events.push(...parser.feedLine("not json"), ...parser.end()); + + expect(events).toEqual([ + { v: 1, type: "task", text: "Run the fixture task" }, + { v: 1, type: "tool_start", tool: "bash", input: { command: "printf 'fixture tool output'" } }, + { v: 1, type: "text_delta", delta: "Hello from " }, + { v: 1, type: "text_delta", delta: "lane." }, + { v: 1, type: "usage", input: 1200, output: 300, cacheRead: 40, context: 1550 }, + { v: 1, type: "assistant_end", text: "Hello from lane." }, + ]); + }); + + it("normalizes thinking and tool results", () => { + const parser = new PiExecutionAdapter().createParser(baseSpec); + const lines = [ + JSON.stringify({ type: "message_update", assistantMessageEvent: { type: "thinking_delta", delta: "consider" } }), + JSON.stringify({ + type: "tool_execution_end", + toolName: "read", + result: { content: [{ type: "text", text: "first" }, { type: "text", text: " second" }] }, + isError: true, + }), + ]; + + expect(lines.flatMap((line) => parser.feedLine(line))).toEqual([ + { v: 1, type: "task", text: "Run the fixture task" }, + { v: 1, type: "thinking_delta", delta: "consider" }, + { v: 1, type: "tool_end", tool: "read", text: "first second", isError: true }, + ]); + }); +}); diff --git a/packages/pi-kit/test/fixtures/claude-lane-stream.jsonl b/packages/pi-kit/test/fixtures/claude-lane-stream.jsonl new file mode 100644 index 0000000..b79c5ad --- /dev/null +++ b/packages/pi-kit/test/fixtures/claude-lane-stream.jsonl @@ -0,0 +1,17 @@ +{"type":"system","subtype":"init","version":"2.1.216","tools":["Read","Glob","Grep"]} +{"type":"stream_event","event":{"type":"message_start","message":{"role":"assistant","content":[],"usage":{"input_tokens":0,"output_tokens":0}}}} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Inspecting fixture."}}} +{"type":"stream_event","event":{"type":"content_block_stop","index":0}} +{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_fixture","name":"Read","input":{}}}} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"file_path\":\"README.md\"}"}}} +{"type":"stream_event","event":{"type":"content_block_stop","index":1}} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Inspecting fixture."},{"type":"tool_use","id":"toolu_fixture","name":"Read","input":{"file_path":"README.md"}}],"usage":{"input_tokens":70,"output_tokens":10,"cache_creation_input_tokens":5,"cache_read_input_tokens":20}}} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_fixture","content":"fixture contents","is_error":false}]}} +{"type":"stream_event","event":{"type":"message_start","message":{"role":"assistant","content":[],"usage":{"input_tokens":0,"output_tokens":0}}}} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Adapter "}}} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"complete."}}} +{"type":"stream_event","event":{"type":"content_block_stop","index":0}} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Adapter complete."}],"usage":{"input_tokens":50,"output_tokens":30,"cache_creation_input_tokens":15,"cache_read_input_tokens":30}}} +{"type":"result","subtype":"success","is_error":false,"result":"Adapter complete.","usage":{"input_tokens":120,"output_tokens":40,"cache_creation_input_tokens":20,"cache_read_input_tokens":50}} diff --git a/packages/pi-kit/test/fixtures/mcp-lane-process.cjs b/packages/pi-kit/test/fixtures/mcp-lane-process.cjs new file mode 100644 index 0000000..46eee62 --- /dev/null +++ b/packages/pi-kit/test/fixtures/mcp-lane-process.cjs @@ -0,0 +1,12 @@ +#!/usr/bin/env node +const { spawn } = require("node:child_process"); +const { writeFileSync } = require("node:fs"); + +const processGroupFile = process.env.MCP_FIXTURE_PROCESS_GROUP_FILE; +const grandchildFile = process.env.MCP_FIXTURE_GRANDCHILD_FILE; +if (!processGroupFile || !grandchildFile) process.exit(64); + +const grandchild = spawn("sleep", ["30"], { stdio: "ignore" }); +writeFileSync(processGroupFile, String(process.pid)); +writeFileSync(grandchildFile, String(grandchild.pid)); +setInterval(() => {}, 1_000); diff --git a/packages/pi-kit/test/fixtures/normalized-lane-stream.jsonl b/packages/pi-kit/test/fixtures/normalized-lane-stream.jsonl new file mode 100644 index 0000000..45558cd --- /dev/null +++ b/packages/pi-kit/test/fixtures/normalized-lane-stream.jsonl @@ -0,0 +1,8 @@ +{"v":1,"type":"task","text":"Run the normalized fixture task"} +{"v":1,"type":"thinking_delta","delta":"Inspecting fixture."} +{"v":1,"type":"tool_start","tool":"read","input":{"path":"README.md"}} +{"v":1,"type":"tool_end","tool":"read","text":"fixture contents","isError":false} +{"v":1,"type":"text_delta","delta":"Normalized "} +{"v":1,"type":"text_delta","delta":"answer."} +{"v":1,"type":"usage","input":120,"output":40,"cacheRead":50,"context":230} +{"v":1,"type":"assistant_end","text":"Normalized answer."} diff --git a/packages/pi-kit/test/journal.test.ts b/packages/pi-kit/test/journal.test.ts index 9fb2cc4..47b2c3d 100644 --- a/packages/pi-kit/test/journal.test.ts +++ b/packages/pi-kit/test/journal.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { appendEvent, journalDir, + listRuns, readRun, reduceRun, } from "../src/journal-core.ts"; @@ -70,6 +71,28 @@ describe("journal core", () => { expect(readRun(run)).toEqual(events); }); + it("lists only journal runs in newest-first order", () => { + appendEvent({ + v: 1, + t: "2026-07-16T12:00:00.000Z", + run: "run-20260716", + type: "run_created", + lanes: 0, + origin: "test", + }); + appendEvent({ + v: 1, + t: "2026-07-17T12:00:00.000Z", + run: "run-20260717", + type: "run_created", + lanes: 0, + origin: "test", + }); + appendFileSync(join(journalDir(), "notes.txt"), "not a run", "utf8"); + + expect(listRuns()).toEqual(["run-20260717", "run-20260716"]); + }); + it("uses cumulative lane usage and reduces terminal states and totals", () => { const run = "run-projection"; const events: KitEvent[] = [ diff --git a/packages/pi-kit/test/lane-coordinator.test.ts b/packages/pi-kit/test/lane-coordinator.test.ts new file mode 100644 index 0000000..4e9f520 --- /dev/null +++ b/packages/pi-kit/test/lane-coordinator.test.ts @@ -0,0 +1,473 @@ +import { describe, expect, it } from "vitest"; +import { + LaneCoordinator, + type LaneCoordinatorJournal, + type LaneRunnerPort, +} from "../src/lane-coordinator.ts"; +import type { LaneExecutionAdapter, NormalizedLaneEvent } from "../src/execution-adapter.ts"; +import { LaneRunner, type RunnerOptions } from "../src/runner.ts"; +import type { KitEvent, LaneProjection, LaneSpec, RunProjection } from "../src/schema.ts"; + +const solSpec: LaneSpec = { + lane: "worker", + task: "Run the fixture task", + model: "openai-codex/gpt-5.6-sol", + effort: "medium", +}; + +class FakeRunner implements LaneRunnerPort { + readonly dispatched = Promise.withResolvers(); + readonly finished = Promise.withResolvers(); + readonly abandoned: Array<{ lane?: string; reason: string }> = []; + readonly view: RunProjection; + shutdownCalls = 0; + private dispatchPromise?: Promise; + + constructor(readonly options: RunnerOptions) { + this.view = { + run: options.runId, + origin: options.origin ?? "pi", + createdAt: "runner-clock-must-not-leak", + ended: false, + lanes: new Map(), + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + }; + } + + projection(): RunProjection { + return this.view; + } + + dispatch(specs: LaneSpec[]): Promise { + for (const spec of specs) { + this.view.lanes.set(spec.lane, { + spec, + state: "running", + pid: 123, + tokensIn: 10, + tokensOut: 2, + cost: 0.25, + context: 12, + }); + } + this.dispatched.resolve(); + this.dispatchPromise = this.finished.promise.then((lanes) => { + this.view.ended = true; + this.view.ok = lanes.every((lane) => lane.state === "done"); + return lanes; + }); + return this.dispatchPromise; + } + + settle(state: LaneProjection["state"] = "done"): void { + for (const lane of this.view.lanes.values()) lane.state = state; + this.finished.resolve([...this.view.lanes.values()]); + } + + fail(error: Error): void { + this.finished.reject(error); + } + + abandon(lane: string, reason: string): void { + this.abandoned.push({ lane, reason }); + const projection = this.view.lanes.get(lane); + if (projection) { + projection.state = "abandoned"; + projection.abandonReason = reason; + } + } + + abandonAll(reason: string): void { + this.abandoned.push({ reason }); + for (const lane of this.view.lanes.values()) { + if (lane.state === "queued" || lane.state === "running") { + lane.state = "abandoned"; + lane.abandonReason = reason; + } + } + } + + async shutdown(reason: string): Promise { + this.shutdownCalls++; + this.abandonAll(reason); + await this.dispatchPromise?.catch(() => {}); + } +} + +function captureUnhandledRejections(): { errors: unknown[]; stop: () => void } { + const errors: unknown[] = []; + const onUnhandledRejection = (error: unknown) => errors.push(error); + process.on("unhandledRejection", onUnhandledRejection); + return { + errors, + stop: () => process.off("unhandledRejection", onUnhandledRejection), + }; +} + +function harness(origin: "pi" | "mcp" = "pi") { + const events: KitEvent[] = []; + const runners: FakeRunner[] = []; + const journal: LaneCoordinatorJournal = { + append: (event) => events.push(event), + rawDir: (runId) => `/raw/${runId}`, + }; + let nowMs = Date.parse("2026-07-21T12:00:00.000Z"); + const coordinator = new LaneCoordinator({ + origin, + journal, + clock: () => new Date(nowMs), + newRunId: () => `run-${runners.length + 1}`, + createRunner: (options) => { + const runner = new FakeRunner(options); + runners.push(runner); + return runner; + }, + }); + return { + coordinator, + events, + runners, + advance(ms: number) { + nowMs += ms; + }, + }; +} + +describe("LaneCoordinator", () => { + it("spawns nonblocking, defaults Pi mode, journals through injected seams, and permits only one active run", async () => { + const { coordinator, events, runners, advance } = harness(); + + const first = await coordinator.spawn([solSpec]); + + expect(first).toMatchObject({ + run: "run-1", + state: "active", + totals: { cost: 0, tokensIn: 0, tokensOut: 0 }, + lanes: [{ lane: "worker", model: solSpec.model, effort: "medium", mode: "workspace-write", state: "running" }], + }); + expect(runners[0]?.options).toMatchObject({ runId: "run-1", origin: "pi", rawDir: "/raw/run-1" }); + expect(events[0]).toEqual({ + v: 1, + t: "2026-07-21T12:00:00.000Z", + run: "run-1", + type: "run_created", + lanes: 1, + origin: "pi", + }); + await expect(coordinator.spawn([{ ...solSpec, lane: "second" }])).rejects.toThrow("still active"); + + advance(250); + runners[0]!.settle(); + await coordinator.wait(); + expect(events.at(-1)).toMatchObject({ type: "run_end", ok: true, durationMs: 250 }); + + await coordinator.spawn([{ ...solSpec, lane: "second" }]); + expect(runners).toHaveLength(2); + runners[1]!.settle(); + await coordinator.wait(); + }); + + it("keeps spawn active when the run_created journal append throws", async () => { + const events: KitEvent[] = []; + const runners: FakeRunner[] = []; + const unhandled = captureUnhandledRejections(); + const coordinator = new LaneCoordinator({ + origin: "pi", + journal: { + append(event) { + if (event.type === "run_created") throw new Error("run_created journal unavailable"); + events.push(event); + }, + rawDir: () => "/raw/run-created-failure", + }, + newRunId: () => "run-created-failure", + createRunner: (options) => { + const runner = new FakeRunner(options); + runners.push(runner); + return runner; + }, + }); + + try { + await expect(coordinator.spawn([solSpec])).resolves.toMatchObject({ state: "active" }); + expect(runners).toHaveLength(1); + runners[0]!.settle(); + await expect(coordinator.wait()).resolves.toMatchObject({ state: "ended", ok: true }); + await new Promise((resolve) => setImmediate(resolve)); + expect(unhandled.errors).toEqual([]); + expect(events.at(-1)).toMatchObject({ type: "run_end", ok: true }); + } finally { + unhandled.stop(); + } + }); + + it("keeps settlement successful when the run_end journal append throws", async () => { + const runners: FakeRunner[] = []; + const unhandled = captureUnhandledRejections(); + const coordinator = new LaneCoordinator({ + origin: "pi", + journal: { + append(event) { + if (event.type === "run_end") throw new Error("run_end journal unavailable"); + }, + rawDir: () => "/raw/run-end-failure", + }, + newRunId: () => "run-end-failure", + createRunner: (options) => { + const runner = new FakeRunner(options); + runners.push(runner); + return runner; + }, + }); + + try { + await coordinator.spawn([solSpec]); + runners[0]!.settle(); + await expect(coordinator.wait()).resolves.toMatchObject({ state: "ended", ok: true }); + expect(coordinator.status()).toMatchObject({ state: "ended", ok: true }); + await new Promise((resolve) => setImmediate(resolve)); + expect(unhandled.errors).toEqual([]); + } finally { + unhandled.stop(); + } + }); + + it("keeps shutdown successful when its run_end journal append throws", async () => { + const runners: FakeRunner[] = []; + const unhandled = captureUnhandledRejections(); + const coordinator = new LaneCoordinator({ + origin: "pi", + journal: { + append(event) { + if (event.type === "run_end") throw new Error("shutdown journal unavailable"); + }, + rawDir: () => "/raw/shutdown-failure", + }, + newRunId: () => "shutdown-failure", + createRunner: (options) => { + const runner = new FakeRunner(options); + runners.push(runner); + return runner; + }, + }); + + try { + await coordinator.spawn([solSpec]); + const shutdown = coordinator.shutdown("session ended"); + runners[0]!.settle("abandoned"); + await expect(shutdown).resolves.toBeUndefined(); + expect(coordinator.status()).toMatchObject({ state: "ended", ok: false }); + await new Promise((resolve) => setImmediate(resolve)); + expect(unhandled.errors).toEqual([]); + } finally { + unhandled.stop(); + } + }); + + it("returns while runner preparation is pending and keeps status and wait responsive", async () => { + const preparation = Promise.withResolvers(); + const events: KitEvent[] = []; + const adapter: LaneExecutionAdapter = { + route: "pi", + preflight() {}, + async prepare() { + await preparation.promise; + }, + build(spec) { + return { + command: process.execPath, + args: ["-e", 'console.log(JSON.stringify({v:1,type:"assistant_end",text:"prepared"}))'], + cwd: spec.cwd ?? process.cwd(), + env: process.env, + }; + }, + createParser() { + return { + feedLine(line) { + return [JSON.parse(line) as NormalizedLaneEvent]; + }, + end() { + return []; + }, + }; + }, + }; + const coordinator = new LaneCoordinator({ + origin: "pi", + journal: { append: (event) => events.push(event), rawDir: () => "/unused" }, + newRunId: () => "run-preparing", + createRunner: (options) => new LaneRunner({ + runId: options.runId, + append: options.append, + origin: options.origin, + adapters: [adapter], + }), + }); + + const spawned = await Promise.race([ + coordinator.spawn([solSpec]), + new Promise((_, reject) => setTimeout(() => reject(new Error("spawn blocked on preparation")), 250)), + ]); + + expect(spawned).toMatchObject({ state: "active", lanes: [{ state: "queued" }] }); + expect(coordinator.status()).toMatchObject({ state: "active", lanes: [{ state: "queued" }] }); + const controller = new AbortController(); + const detachedWait = coordinator.wait(controller.signal); + controller.abort(); + await expect(detachedWait).resolves.toMatchObject({ state: "active", lanes: [{ state: "queued" }] }); + expect(events.some((event) => event.type === "lane_start")).toBe(false); + + preparation.resolve(); + await expect(coordinator.wait()).resolves.toMatchObject({ + state: "ended", + ok: true, + lanes: [{ state: "done", answer: "prepared" }], + }); + expect(events.some((event) => event.type === "lane_start")).toBe(true); + }); + + it("requires explicit MCP mode and rejects MCP Anthropic lanes through existing policy", async () => { + const { coordinator, runners } = harness("mcp"); + + await expect(coordinator.spawn([solSpec])).rejects.toThrow("mode is required for MCP-origin lanes"); + await expect( + coordinator.spawn([ + { + lane: "anthropic", + task: "Do not run", + model: "anthropic/claude-fable-5", + effort: "high", + mode: "read-only", + }, + ]), + ).rejects.toThrow("unavailable from mcp"); + expect(runners).toHaveLength(0); + }); + + it("returns exact whole-run and filtered JSON-safe DTOs without sensitive process or prompt fields", async () => { + const { coordinator, runners } = harness(); + await coordinator.spawn([ + { ...solSpec, cwd: "/sensitive/worktree", rationale: "private routing reason" }, + { ...solSpec, lane: "other" }, + ]); + const projection = runners[0]!.view; + projection.totalCost = Number.POSITIVE_INFINITY; + projection.totalTokensIn = Number.NaN; + projection.totalTokensOut = Number.NEGATIVE_INFINITY; + const worker = projection.lanes.get("worker")!; + worker.tokensIn = Number.NaN; + worker.tokensOut = Number.POSITIVE_INFINITY; + worker.cost = Number.NEGATIVE_INFINITY; + worker.context = Number.NaN; + worker.durationMs = Number.POSITIVE_INFINITY; + + const all = coordinator.status(); + const filtered = coordinator.status("worker"); + + expect(all.lanes.map((lane) => lane.lane)).toEqual(["worker", "other"]); + expect(filtered).toEqual({ + run: "run-1", + state: "active", + totals: { cost: 0, tokensIn: 0, tokensOut: 0 }, + lanes: [{ + lane: "worker", + model: solSpec.model, + effort: "medium", + mode: "workspace-write", + state: "running", + tokensIn: 0, + tokensOut: 0, + cost: 0, + context: 0, + durationMs: 0, + }], + }); + const json = JSON.stringify(filtered); + expect(json).not.toContain("Run the fixture task"); + expect(json).not.toContain("/sensitive/worktree"); + expect(json).not.toContain("private routing reason"); + for (const field of ["task", "cwd", "rationale", "pid", "origin", "createdAt"]) { + expect(filtered).not.toHaveProperty(field); + expect(filtered.lanes[0]).not.toHaveProperty(field); + } + expect(JSON.parse(json)).toEqual(filtered); + await expect(Promise.resolve().then(() => coordinator.status("missing"))).rejects.toThrow('Unknown lane "missing"'); + + runners[0]!.settle(); + await coordinator.wait(); + }); + + it("waits for settlement but detaches an aborted wait without abandoning lanes", async () => { + const { coordinator, runners } = harness(); + await coordinator.spawn([solSpec]); + const controller = new AbortController(); + + const detached = coordinator.wait(controller.signal); + controller.abort(); + + await expect(detached).resolves.toMatchObject({ state: "active", lanes: [{ state: "running" }] }); + expect(runners[0]!.abandoned).toEqual([]); + + const settled = coordinator.wait(); + runners[0]!.settle(); + await expect(settled).resolves.toMatchObject({ state: "ended", ok: true, lanes: [{ state: "done" }] }); + }); + + it("abandons a named lane or the whole run and rejects operations without a run", async () => { + const empty = harness().coordinator; + expect(() => empty.status()).toThrow("No lane run"); + await expect(empty.wait()).rejects.toThrow("No lane run"); + expect(() => empty.abandon({})).toThrow("No lane run"); + + const { coordinator, runners } = harness(); + await coordinator.spawn([solSpec, { ...solSpec, lane: "other" }]); + + expect(coordinator.abandon({ lane: "worker", reason: "named stop" })).toMatchObject({ + lanes: [{ lane: "worker", state: "abandoned" }, { lane: "other", state: "running" }], + }); + expect(() => coordinator.abandon({ lane: "missing" })).toThrow('Unknown lane "missing"'); + expect(coordinator.abandon({ reason: "all stop" }).lanes).toEqual( + expect.arrayContaining([expect.objectContaining({ state: "abandoned", abandonReason: expect.any(String) })]), + ); + expect(runners[0]!.abandoned).toEqual([ + { lane: "worker", reason: "named stop" }, + { reason: "all stop" }, + ]); + + runners[0]!.settle("abandoned"); + await coordinator.wait(); + }); + + it("settles and journals a failed run when dispatch rejects", async () => { + const { coordinator, events, runners } = harness(); + await coordinator.spawn([solSpec]); + + runners[0]!.fail(new Error("child setup failed")); + + await expect(coordinator.wait()).resolves.toMatchObject({ state: "ended", ok: false }); + expect(runners[0]!.shutdownCalls).toBe(1); + expect(events.at(-1)).toMatchObject({ type: "run_end", ok: false }); + }); + + it("shutdown abandons and awaits the active runner close", async () => { + const { coordinator, runners } = harness(); + await coordinator.spawn([solSpec]); + + let stopped = false; + const shutdown = coordinator.shutdown("session ended").then(() => { + stopped = true; + }); + await Promise.resolve(); + + expect(stopped).toBe(false); + expect(runners[0]!.shutdownCalls).toBe(1); + expect(runners[0]!.abandoned).toEqual([{ reason: "session ended" }]); + + runners[0]!.settle("abandoned"); + await shutdown; + expect(stopped).toBe(true); + expect(coordinator.status()).toMatchObject({ state: "ended", ok: false }); + }); +}); diff --git a/packages/pi-kit/test/lanes-extension.test.ts b/packages/pi-kit/test/lanes-extension.test.ts index d50b0ca..f4f52c6 100644 --- a/packages/pi-kit/test/lanes-extension.test.ts +++ b/packages/pi-kit/test/lanes-extension.test.ts @@ -5,7 +5,11 @@ import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; -import lanesExtension from "../extensions/lanes.ts"; +import lanesExtension, { type LanesCoordinatorPort } from "../extensions/lanes.ts"; +import { readRun } from "../src/journal-core.ts"; +import type { RunSnapshotDto } from "../src/lane-coordinator.ts"; +import type { LaneSpec } from "../src/schema.ts"; +import { MODEL_TABLE } from "../src/table.ts"; const streamFixture = fileURLToPath(new URL("./fixtures/lane-stream.jsonl", import.meta.url)); const originalDataDir = process.env.PIKIT_DATA_DIR; @@ -25,31 +29,106 @@ type ToolExecute = ( ctx: ExtensionContext, ) => Promise; -function makeHarness(opts: { idle?: boolean } = {}) { - const tools = new Map(); +type CommandHandler = (args: string, ctx: ExtensionContext) => Promise; +type ShutdownHandler = () => Promise | void; + +function makeHarness( + opts: { + idle?: boolean; + hasUI?: boolean; + ui?: ExtensionContext["ui"]; + createCoordinator?: () => LanesCoordinatorPort; + } = {}, +) { + const tools = new Map(); + const commands = new Map(); + const handlers = new Map(); const sent: Array<{ content: string; options?: unknown }> = []; const pi = { - registerTool: (tool: { name: string; execute: ToolExecute }) => { + registerTool: (tool: { name: string; description: string; execute: ToolExecute }) => { tools.set(tool.name, tool); }, - registerCommand: () => {}, - on: () => {}, + registerCommand: (name: string, command: { handler: CommandHandler }) => commands.set(name, command.handler), + on: (name: string, handler: ShutdownHandler) => handlers.set(name, handler), sendUserMessage: (content: string, options?: unknown) => { sent.push({ content, options }); }, } as unknown as ExtensionAPI; const ctx = { - hasUI: false, + hasUI: opts.hasUI ?? false, isIdle: () => opts.idle ?? true, - ui: {}, + ui: opts.ui ?? {}, } as unknown as ExtensionContext; - lanesExtension(pi); + lanesExtension(pi, { createCoordinator: opts.createCoordinator }); const call = (name: string, params: Record = {}, signal?: AbortSignal) => { const tool = tools.get(name); if (!tool) throw new Error(`tool ${name} not registered`); return tool.execute("tc", params, signal, undefined, ctx); }; - return { call, sent, tools }; + return { call, commands, ctx, handlers, sent, tools }; +} + +class FaultCoordinator implements LanesCoordinatorPort { + snapshot?: RunSnapshotDto; + fail?: "spawn" | "status" | "wait" | "abandon" | "shutdown"; + shutdownImpl: (reason: string) => Promise = async () => {}; + + async spawn(specs: LaneSpec[]): Promise { + if (this.fail === "spawn") throw new Error("injected spawn failure"); + this.snapshot = { + run: "run-injected", + state: "active", + totals: { cost: 0, tokensIn: 0, tokensOut: 0 }, + lanes: specs.map((lane) => ({ + lane: lane.lane, + model: lane.model, + effort: lane.effort, + mode: lane.mode ?? "workspace-write", + state: "running", + tokensIn: 0, + tokensOut: 0, + cost: 0, + context: 0, + })), + }; + return this.snapshot; + } + + status(lane?: string): RunSnapshotDto { + if (this.fail === "status") throw new Error("injected status failure"); + const snapshot = this.requireSnapshot(); + if (lane === undefined) return snapshot; + const found = snapshot.lanes.find((item) => item.lane === lane); + if (!found) throw new Error(`Unknown lane "${lane}"`); + return { ...snapshot, lanes: [found] }; + } + + async wait(_signal?: AbortSignal): Promise { + if (this.fail === "wait") throw new Error("injected wait failure"); + return new Promise(() => {}); + } + + abandon(input: { lane?: string; reason?: string }): RunSnapshotDto { + if (this.fail === "abandon") throw new Error("injected abandon failure"); + const snapshot = this.requireSnapshot(); + for (const lane of snapshot.lanes) { + if (input.lane === undefined || input.lane === lane.lane) { + lane.state = "abandoned"; + lane.abandonReason = input.reason ?? "abandoned"; + } + } + return snapshot; + } + + shutdown(reason: string): Promise { + if (this.fail === "shutdown") return Promise.reject(new Error("injected shutdown failure")); + return this.shutdownImpl(reason); + } + + private requireSnapshot(): RunSnapshotDto { + if (!this.snapshot) throw new Error("No lane run is available"); + return this.snapshot; + } } let dataDir: string; @@ -92,6 +171,23 @@ afterEach(async () => { }); describe("async lanes extension", () => { + it("keeps the four public tool names and descriptions frozen", () => { + const { tools } = makeHarness(); + expect([...tools.keys()]).toEqual(["lanes_spawn", "lanes_status", "lanes_wait", "lanes_abandon"]); + expect(tools.get("lanes_spawn")!.description).toBe( + `Spawn independent Pi lanes that run concurrently in the background (non-blocking: this tool returns immediately; lanes survive turn cancellation). Collect results with lanes_wait, inspect progress or a specific lane with lanes_status, stop lanes only with lanes_abandon. While lanes run you can keep working with the user. You MUST state an explicit model and effort for every lane. Any effort off|minimal|low|medium|high|xhigh is allowed per lane (the value in parentheses is only that model's starting prior). Models: ${MODEL_TABLE.map((row) => `${row.selector} (prior ${row.prior})`).join(", ")}.`, + ); + expect(tools.get("lanes_status")!.description).toBe( + "Report live status of the current lane run without blocking: per-lane state, current activity, tokens, cost, and (for settled lanes) answers. Pass `lane` for a detailed view of one lane — use this when the user asks about a specific lane. Do not call in a polling loop; prefer lanes_wait when you only need the final results.", + ); + expect(tools.get("lanes_wait")!.description).toBe( + "Block until the current lane run settles and return the final per-lane results. Call this when you have nothing else to do for the user, or when the user asks for the results. Cancelling this wait does NOT stop the lanes — they keep running and lanes_status/lanes_wait remain available.", + ); + expect(tools.get("lanes_abandon")!.description).toBe( + "Explicitly stop lanes of the current run. Pass `lane` to stop one lane, omit it to stop all. This is the only way to stop lanes — they are not cancelled by ESC or turn cancellation.", + ); + }); + it("lanes_spawn returns immediately while lanes keep running", async () => { await installSlowPi(300); const { call } = makeHarness(); @@ -99,8 +195,22 @@ describe("async lanes extension", () => { const result = await call("lanes_spawn", { lanes: [spec] }); expect(Date.now() - started).toBeLessThan(250); expect(result.isError).toBeFalsy(); - expect(result.content[0]!.text).toContain("non-blocking"); - expect(result.content[0]!.text).toContain("worker"); + const run = (result.details as { run: string }).run; + expect(result.content[0]!.text).toBe( + [ + `Run ${run} spawned with 1 lane (non-blocking):`, + " worker: openai-codex/gpt-5.6-sol:medium", + "Lanes run in the background and survive turn cancellation.", + "Use lanes_status to check progress or answer questions about a lane, lanes_wait to block for final results, lanes_abandon to stop lanes.", + "You can keep working with the user meanwhile — do not poll lanes_status in a loop.", + ].join("\n"), + ); + expect(readRun(run)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "run_created", origin: "pi" }), + expect.objectContaining({ type: "lane_created", spec: expect.objectContaining({ mode: "workspace-write" }) }), + ]), + ); const status = await call("lanes_status", {}); expect(status.content[0]!.text).toMatch(/worker: (queued|running)/); @@ -189,27 +299,102 @@ describe("async lanes extension", () => { }); }); +describe("failure containment", () => { + it.each(["spawn", "status", "wait", "abandon"] as const)( + "returns a concise isError tool result when coordinator %s throws", + async (operation) => { + const coordinator = new FaultCoordinator(); + coordinator.fail = operation; + const { call } = makeHarness({ createCoordinator: () => coordinator }); + if (operation !== "spawn") { + coordinator.fail = undefined; + await call("lanes_spawn", { lanes: [spec] }); + coordinator.fail = operation; + } + + const params = operation === "abandon" ? { lane: "worker" } : operation === "spawn" ? { lanes: [spec] } : {}; + const result = await call(`lanes_${operation}`, params); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + { type: "text", text: `lanes_${operation} failed: injected ${operation} failure` }, + ]); + }, + ); + + it("contains throwing UI calls across spawn and every command path", async () => { + const coordinator = new FaultCoordinator(); + const throwingUi = { + theme: { fg: () => { throw new Error("theme failure"); } }, + setWidget: () => { throw new Error("widget failure"); }, + setStatus: () => { throw new Error("status failure"); }, + notify: () => { throw new Error("notify failure"); }, + custom: async () => { throw new Error("tui failure"); }, + } as unknown as ExtensionContext["ui"]; + const { call, commands, ctx } = makeHarness({ + hasUI: true, + ui: throwingUi, + createCoordinator: () => coordinator, + }); + + const spawned = await call("lanes_spawn", { lanes: [spec] }); + expect(spawned.isError).toBeFalsy(); + const command = commands.get("lanes")!; + for (const args of ["show", "hide", "last", "tui", "abandon worker", "unknown"]) { + await expect(command(args, ctx)).resolves.toBeUndefined(); + } + }); + + it("contains coordinator failures from commands", async () => { + const coordinator = new FaultCoordinator(); + const notifications: string[] = []; + const ui = { + theme: { fg: (_color: string, text: string) => text }, + setWidget: () => {}, + setStatus: () => {}, + notify: (message: string) => notifications.push(message), + } as unknown as ExtensionContext["ui"]; + const { call, commands, ctx } = makeHarness({ hasUI: true, ui, createCoordinator: () => coordinator }); + await call("lanes_spawn", { lanes: [spec] }); + coordinator.fail = "abandon"; + + await expect(commands.get("lanes")!("abandon worker", ctx)).resolves.toBeUndefined(); + expect(notifications).toContain("lanes command failed: injected abandon failure"); + }); +}); + describe("session shutdown", () => { it("abandons an active run and suppresses the nudge on session_shutdown", async () => { await installSlowPi(2_000); - const handlers = new Map void>(); - const tools = new Map(); - const sent: unknown[] = []; - const pi = { - registerTool: (tool: { name: string; execute: ToolExecute }) => tools.set(tool.name, tool), - registerCommand: () => {}, - sendUserMessage: (content: unknown) => sent.push(content), - on: (name: string, handler: () => void) => handlers.set(name, handler), - } as unknown as ExtensionAPI; - const ctx = { hasUI: false, isIdle: () => true, ui: {} } as unknown as ExtensionContext; - lanesExtension(pi); - await tools.get("lanes_spawn")!.execute("tc", { lanes: [spec] }, undefined, undefined, ctx); - - handlers.get("session_shutdown")!(); - - const status = await tools.get("lanes_status")!.execute("tc", {}, undefined, undefined, ctx); + const { call, handlers, sent } = makeHarness({ idle: true }); + await call("lanes_spawn", { lanes: [spec] }); + + await handlers.get("session_shutdown")!(); + + const status = await call("lanes_status", {}); expect(status.content[0]!.text).toContain("worker: abandoned"); await new Promise((resolve) => setTimeout(resolve, 200)); expect(sent).toEqual([]); }); + + it("awaits async coordinator shutdown and contains rejection", async () => { + const coordinator = new FaultCoordinator(); + const released = Promise.withResolvers(); + coordinator.shutdownImpl = async () => released.promise; + const { handlers } = makeHarness({ createCoordinator: () => coordinator }); + + let completed = false; + const shutdown = Promise.resolve(handlers.get("session_shutdown")!()).then(() => { + completed = true; + }); + await Promise.resolve(); + expect(completed).toBe(false); + released.resolve(); + await expect(shutdown).resolves.toBeUndefined(); + + const rejecting = new FaultCoordinator(); + rejecting.fail = "shutdown"; + const rejectedHarness = makeHarness({ createCoordinator: () => rejecting }); + await expect(rejectedHarness.handlers.get("session_shutdown")!()).resolves.toBeUndefined(); + }); }); diff --git a/packages/pi-kit/test/mcp-server.test.ts b/packages/pi-kit/test/mcp-server.test.ts new file mode 100644 index 0000000..1db6e63 --- /dev/null +++ b/packages/pi-kit/test/mcp-server.test.ts @@ -0,0 +1,451 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { access, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createLaneMcpServer, + type LaneMcpCoordinator, +} from "../mcp/create-server.ts"; +import { + LaneCoordinator, + type LaneCoordinatorJournal, + type LaneRunnerPort, + type RunSnapshotDto, +} from "../src/lane-coordinator.ts"; +import type { RunnerOptions } from "../src/runner.ts"; +import type { LaneProjection, LaneSpec, RunProjection } from "../src/schema.ts"; + +const solSpec: LaneSpec = { + lane: "worker", + task: "Run the fixture task", + model: "openai-codex/gpt-5.6-sol", + effort: "medium", + mode: "read-only", +}; + +class ProtocolRunner implements LaneRunnerPort { + readonly finished = Promise.withResolvers(); + readonly view: RunProjection; + readonly abandoned: Array<{ lane?: string; reason: string }> = []; + + constructor(options: RunnerOptions) { + this.view = { + run: options.runId, + origin: options.origin ?? "mcp", + createdAt: "runner timestamp", + ended: false, + lanes: new Map(), + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + }; + } + + projection(): RunProjection { + return this.view; + } + + dispatch(specs: LaneSpec[]): Promise { + for (const spec of specs) { + this.view.lanes.set(spec.lane, { + spec, + state: "running", + pid: 123, + tokensIn: 10, + tokensOut: 2, + cost: 0.25, + context: 12, + }); + } + return this.finished.promise; + } + + abandon(lane: string, reason: string): void { + this.abandoned.push({ lane, reason }); + const projection = this.view.lanes.get(lane); + if (projection) { + projection.state = "abandoned"; + projection.abandonReason = reason; + } + } + + abandonAll(reason: string): void { + this.abandoned.push({ reason }); + for (const projection of this.view.lanes.values()) { + projection.state = "abandoned"; + projection.abandonReason = reason; + } + } + + shutdown(reason: string): Promise { + this.abandonAll(reason); + return this.finished.promise.then(() => undefined); + } + + settle(state: LaneProjection["state"] = "done"): void { + for (const projection of this.view.lanes.values()) projection.state = state; + this.view.ended = true; + this.finished.resolve([...this.view.lanes.values()]); + } +} + +function realCoordinatorHarness() { + const runners: ProtocolRunner[] = []; + const journal: LaneCoordinatorJournal = { append() {}, rawDir: (run) => `/raw/${run}` }; + const coordinator = new LaneCoordinator({ + origin: "mcp", + journal, + newRunId: () => "run-mcp", + clock: () => new Date("2026-07-21T12:00:00.000Z"), + createRunner(options) { + const runner = new ProtocolRunner(options); + runners.push(runner); + return runner; + }, + }); + return { coordinator, runners }; +} + +const connected: Array<{ client: Client; server: ReturnType["server"] }> = []; + +async function protocolHarness(coordinator: LaneMcpCoordinator) { + const created = createLaneMcpServer({ coordinator }); + const client = new Client({ name: "pi-kit-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await created.server.connect(serverTransport); + await client.connect(clientTransport); + connected.push({ client, server: created.server }); + return { ...created, client }; +} + +function textOf(result: unknown): string { + if (typeof result !== "object" || result === null || !("content" in result) || !Array.isArray(result.content)) return ""; + const first: unknown = result.content[0]; + return typeof first === "object" && first !== null && "type" in first && first.type === "text" && "text" in first && typeof first.text === "string" + ? first.text + : ""; +} + +async function waitUntil(predicate: () => boolean | Promise, message: string, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(message); +} + +async function waitForFile(path: string): Promise { + await waitUntil(async () => { + try { + await access(path); + return true; + } catch { + return false; + } + }, `Timed out waiting for ${path}`, 5_000); +} + +async function waitForProcessExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise { + if (child.exitCode !== null) return child.exitCode; + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Process ${child.pid} did not exit within ${timeoutMs}ms`)), timeoutMs); + child.once("close", (code) => { + clearTimeout(timer); + resolve(code); + }); + }); +} + +afterEach(async () => { + await Promise.allSettled(connected.splice(0).map(async ({ client, server }) => { + await client.close(); + await server.close(); + })); +}); + +describe("lane MCP protocol", () => { + it("registers only the four lane tools and returns text-only no-run errors", async () => { + const { coordinator } = realCoordinatorHarness(); + const { client } = await protocolHarness(coordinator); + + expect((await client.listTools()).tools.map((tool) => tool.name)).toEqual([ + "lanes_spawn", + "lanes_status", + "lanes_wait", + "lanes_abandon", + ]); + + for (const [name, args] of [ + ["lanes_status", {}], + ["lanes_wait", {}], + ["lanes_abandon", {}], + ] as const) { + const result = await client.callTool({ name, arguments: args }); + expect(result).toMatchObject({ isError: true }); + expect(textOf(result)).toContain("No lane run"); + expect("structuredContent" in result).toBe(false); + } + }); + + it("spawns nonblocking, rejects Anthropic and duplicate runs, filters status, and abandons", async () => { + const { coordinator, runners } = realCoordinatorHarness(); + const { client } = await protocolHarness(coordinator); + + const anthropic = await client.callTool({ + name: "lanes_spawn", + arguments: { + lanes: [{ ...solSpec, lane: "claude", model: "anthropic/claude-fable-5", effort: "high" }], + }, + }); + expect(anthropic).toMatchObject({ isError: true }); + expect(textOf(anthropic)).toContain("native Claude workflow"); + expect(runners).toHaveLength(0); + + const spawned = await client.callTool({ + name: "lanes_spawn", + arguments: { + lanes: [ + { ...solSpec, cwd: "/sensitive/worktree", rationale: "private routing reason" }, + { ...solSpec, lane: "other" }, + ], + }, + }); + expect(spawned.isError).not.toBe(true); + expect(textOf(spawned)).toContain("run-mcp"); + expect(spawned.structuredContent).toMatchObject({ + run: "run-mcp", + state: "active", + totals: { cost: 0, tokensIn: 0, tokensOut: 0 }, + lanes: [ + { lane: "worker", model: solSpec.model, effort: "medium", mode: "read-only", state: "running" }, + { lane: "other", model: solSpec.model, effort: "medium", mode: "read-only", state: "running" }, + ], + }); + const spawnedJson = JSON.stringify(spawned.structuredContent); + expect(spawnedJson).not.toContain("Run the fixture task"); + expect(spawnedJson).not.toContain("/sensitive/worktree"); + expect(spawnedJson).not.toContain("private routing reason"); + for (const field of ["task", "cwd", "rationale", "pid", "origin", "createdAt"]) { + expect(spawned.structuredContent).not.toHaveProperty(field); + expect((spawned.structuredContent as { lanes: unknown[] }).lanes[0]).not.toHaveProperty(field); + } + + const duplicate = await client.callTool({ name: "lanes_spawn", arguments: { lanes: [solSpec] } }); + expect(duplicate).toMatchObject({ isError: true }); + expect(textOf(duplicate)).toContain("still active"); + expect("structuredContent" in duplicate).toBe(false); + + const filtered = await client.callTool({ name: "lanes_status", arguments: { lane: "worker" } }); + expect(filtered.structuredContent).toMatchObject({ lanes: [{ lane: "worker", state: "running" }] }); + expect((filtered.structuredContent as { lanes: unknown[] }).lanes).toHaveLength(1); + + const abandoned = await client.callTool({ + name: "lanes_abandon", + arguments: { lane: "worker", reason: "protocol stop" }, + }); + expect((abandoned.structuredContent as { lanes: unknown[] }).lanes[0]).toMatchObject({ + lane: "worker", + state: "abandoned", + abandonReason: "protocol stop", + }); + expect(runners[0]!.abandoned).toEqual([{ lane: "worker", reason: "protocol stop" }]); + + runners[0]!.settle("abandoned"); + await coordinator.wait(); + }); + + it("passes the request signal to wait so cancellation detaches without abandonment", async () => { + let waitSignal: AbortSignal | undefined; + let abandonCalls = 0; + const snapshot = finiteSnapshot(); + const coordinator: LaneMcpCoordinator = { + async spawn() { return snapshot; }, + status() { return snapshot; }, + wait(signal) { + waitSignal = signal; + return new Promise((resolve) => signal?.addEventListener("abort", () => resolve(snapshot), { once: true })); + }, + abandon() { abandonCalls++; return snapshot; }, + async shutdown() {}, + }; + const { client } = await protocolHarness(coordinator); + const controller = new AbortController(); + + const waiting = client.callTool({ name: "lanes_wait", arguments: {} }, undefined, { signal: controller.signal }); + await waitUntil(() => waitSignal !== undefined, "wait handler did not receive a signal"); + controller.abort(); + + await expect(waiting).rejects.toThrow(); + await waitUntil(() => waitSignal?.aborted === true, "wait handler signal was not aborted"); + expect(abandonCalls).toBe(0); + expect((await client.callTool({ name: "lanes_status", arguments: {} })).structuredContent).toMatchObject({ state: "active" }); + }); + + it("converts every coordinator handler exception to a bounded text-only tool error", async () => { + const coordinator: LaneMcpCoordinator = { + async spawn() { throw new Error("spawn boom"); }, + status() { throw new Error("status boom"); }, + async wait() { throw new Error("wait boom"); }, + abandon() { throw new Error("abandon boom"); }, + async shutdown() {}, + }; + const { client } = await protocolHarness(coordinator); + const calls = [ + ["lanes_spawn", { lanes: [solSpec] }, "spawn boom"], + ["lanes_status", {}, "status boom"], + ["lanes_wait", {}, "wait boom"], + ["lanes_abandon", {}, "abandon boom"], + ] as const; + + for (const [name, args, message] of calls) { + const result = await client.callTool({ name, arguments: args }); + expect(result).toMatchObject({ isError: true }); + expect(textOf(result)).toContain(message); + expect(textOf(result).length).toBeLessThanOrEqual(280); + expect("structuredContent" in result).toBe(false); + } + }); + + it("normalizes every non-finite numeric DTO field before schema validation", async () => { + const snapshot = finiteSnapshot(); + snapshot.totals.cost = Number.POSITIVE_INFINITY; + snapshot.totals.tokensIn = Number.NaN; + snapshot.totals.tokensOut = Number.NEGATIVE_INFINITY; + Object.assign(snapshot.lanes[0]!, { + tokensIn: Number.NaN, + tokensOut: Number.POSITIVE_INFINITY, + cost: Number.NEGATIVE_INFINITY, + context: Number.NaN, + durationMs: Number.POSITIVE_INFINITY, + }); + const coordinator: LaneMcpCoordinator = { + async spawn() { return snapshot; }, + status() { return snapshot; }, + async wait() { return snapshot; }, + abandon() { return snapshot; }, + async shutdown() {}, + }; + const { client } = await protocolHarness(coordinator); + + const result = await client.callTool({ name: "lanes_status", arguments: {} }); + + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + totals: { cost: 0, tokensIn: 0, tokensOut: 0 }, + lanes: [{ tokensIn: 0, tokensOut: 0, cost: 0, context: 0, durationMs: 0 }], + }); + expect(JSON.parse(JSON.stringify(result.structuredContent))).toEqual(result.structuredContent); + }); +}); + +function finiteSnapshot(): RunSnapshotDto { + return { + run: "run-test", + state: "active", + totals: { cost: 0.25, tokensIn: 10, tokensOut: 2 }, + lanes: [{ + lane: solSpec.lane, + model: solSpec.model, + effort: solSpec.effort, + mode: "read-only", + state: "running", + tokensIn: 10, + tokensOut: 2, + cost: 0.25, + context: 12, + durationMs: 50, + }], + }; +} + +describe("lane MCP stdio process", () => { + it("treats EOF as shutdown, exits cleanly, and leaves no lane process group", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-kit-mcp-process-")); + const binDir = join(root, "bin"); + const piBinary = join(binDir, "pi"); + const processGroupFile = join(root, "lane-process-group.pid"); + const grandchildFile = join(root, "lane-grandchild.pid"); + const fixture = fileURLToPath(new URL("./fixtures/mcp-lane-process.cjs", import.meta.url)); + const serverPath = fileURLToPath(new URL("../mcp/server.ts", import.meta.url)); + await mkdir(binDir); + await writeFile( + piBinary, + `#!/usr/bin/env node\nprocess.env.MCP_FIXTURE_PROCESS_GROUP_FILE = ${JSON.stringify(processGroupFile)};\nprocess.env.MCP_FIXTURE_GRANDCHILD_FILE = ${JSON.stringify(grandchildFile)};\nrequire(${JSON.stringify(fixture)});\n`, + ); + await chmod(piBinary, 0o755); + + const child = spawn("bun", [serverPath], { + cwd: fileURLToPath(new URL("..", import.meta.url)), + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ""}`, + PIKIT_DATA_DIR: join(root, "data"), + MCP_FIXTURE_PROCESS_GROUP_FILE: processGroupFile, + MCP_FIXTURE_GRANDCHILD_FILE: grandchildFile, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let processGroup: number | undefined; + let grandchild: number | undefined; + child.stdout.setEncoding("utf8").on("data", (chunk: string) => { stdout += chunk; }); + child.stderr.setEncoding("utf8").on("data", (chunk: string) => { stderr += chunk; }); + + try { + child.stdin.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "process-test", version: "1.0.0" }, + }, + })}\n`); + await waitUntil( + () => stdout.split("\n").some((line) => line.includes('"id":1')), + "MCP initialize did not complete", + 5_000, + ); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`); + child.stdin.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "lanes_spawn", arguments: { lanes: [solSpec] } }, + })}\n`); + await waitForFile(processGroupFile); + await waitForFile(grandchildFile); + const spawnedProcessGroup = Number((await readFile(processGroupFile, "utf8")).trim()); + const spawnedGrandchild = Number((await readFile(grandchildFile, "utf8")).trim()); + processGroup = spawnedProcessGroup; + grandchild = spawnedGrandchild; + + child.stdin.end(); + expect(await waitForProcessExit(child, 4_000)).toBe(0); + await waitUntil(() => { + try { process.kill(-spawnedProcessGroup, 0); return false; } catch { return true; } + }, `Process group ${spawnedProcessGroup} remained alive`, 4_000); + await waitUntil(() => { + try { process.kill(spawnedGrandchild, 0); return false; } catch { return true; } + }, `Grandchild ${spawnedGrandchild} remained alive`, 4_000); + expect(stderr).toBe(""); + expect(stdout.trim().split("\n").every((line) => { + try { JSON.parse(line); return true; } catch { return false; } + })).toBe(true); + } finally { + if (processGroup !== undefined) { + try { process.kill(-processGroup, "SIGKILL"); } catch {} + } + if (child.exitCode === null) child.kill("SIGKILL"); + await waitForProcessExit(child, 1_000).catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } + }, 15_000); +}); diff --git a/packages/pi-kit/test/runner.test.ts b/packages/pi-kit/test/runner.test.ts index d050135..dc5f8ac 100644 --- a/packages/pi-kit/test/runner.test.ts +++ b/packages/pi-kit/test/runner.test.ts @@ -1,13 +1,19 @@ -import { access, chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { access, chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { + LaneEventParser, + LaneExecutionAdapter, + NormalizedLaneEvent, +} from "../src/execution-adapter.ts"; import { LaneRunner } from "../src/runner.ts"; -import type { KitEvent, LaneSpec } from "../src/schema.ts"; +import type { ExecutionRoute, KitEvent, LaneSpec } from "../src/schema.ts"; import { estimateCost } from "../src/table.ts"; const streamFixture = fileURLToPath(new URL("./fixtures/lane-stream.jsonl", import.meta.url)); +const normalizedFixture = fileURLToPath(new URL("./fixtures/normalized-lane-stream.jsonl", import.meta.url)); const originalDataDir = process.env.PIKIT_DATA_DIR; let dataDir: string; @@ -25,6 +31,78 @@ async function executable(name: string, source: string): Promise { return path; } +async function waitForFile(path: string): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + try { + await access(path); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + throw new Error(`Timed out waiting for ${path}`); +} + +async function waitForDead(pid: number): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + try { + process.kill(pid, 0); + await new Promise((resolve) => setTimeout(resolve, 10)); + } catch { + return; + } + } + throw new Error(`Process ${pid} remained alive`); +} + +async function waitForGroupDead(processGroup: number): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + try { + process.kill(-processGroup, 0); + await new Promise((resolve) => setTimeout(resolve, 10)); + } catch { + return; + } + } + throw new Error(`Process group ${processGroup} remained alive`); +} + +function normalizedAdapter( + route: ExecutionRoute, + binary: string, + onEvent?: (event: NormalizedLaneEvent) => void, + onEnd?: () => NormalizedLaneEvent[], +): LaneExecutionAdapter { + return { + route, + preflight() {}, + build(spec) { + return { + command: binary, + args: [], + cwd: spec.cwd ?? process.cwd(), + env: { ...process.env, PIKIT_CHILD: "1" }, + }; + }, + createParser() { + let ended = false; + return { + feedLine(line): NormalizedLaneEvent[] { + if (!line.trim()) return []; + const event = JSON.parse(line) as NormalizedLaneEvent; + onEvent?.(event); + return [event]; + }, + end(): NormalizedLaneEvent[] { + if (ended) throw new Error("parser.end called more than once"); + ended = true; + return onEnd?.() ?? []; + }, + } satisfies LaneEventParser; + }, + }; +} + beforeEach(async () => { dataDir = await mkdtemp(join(tmpdir(), "pi-kit-runner-")); process.env.PIKIT_DATA_DIR = dataDir; @@ -37,24 +115,33 @@ afterEach(async () => { }); describe("LaneRunner", () => { - it("journals a JSON child stream and returns cumulative usage", async () => { - // This process-level integration fixture deliberately spaces JSONL writes to exercise chunked streaming. + it("preserves the piBinary override while writing a canonical normalized transcript", async () => { const piBinary = await executable( "fixture-pi", `#!/usr/bin/env node\nconst { readFileSync } = require("node:fs");\nconst lines = readFileSync(${JSON.stringify(streamFixture)}, "utf8").trimEnd().split("\\n");\n(async () => { for (const line of lines) { console.log(line); await new Promise((resolve) => setTimeout(resolve, 10)); } })();\n`, ); const events: KitEvent[] = []; - const runner = new LaneRunner({ runId: "run-success", append: (event) => events.push(event), piBinary }); + const rawDir = join(dataDir, "raw"); + const runner = new LaneRunner({ + runId: "run-success", + append: (event) => events.push(event), + piBinary, + rawDir, + }); const [lane] = await runner.dispatch([validSpec]); expect(events.map((event) => event.type)).toEqual( expect.arrayContaining(["lane_start", "lane_tool", "lane_usage", "lane_end"]), ); - const tool = events.find((event) => event.type === "lane_tool"); - expect(tool).toMatchObject({ tool: "bash", summary: "printf 'fixture tool output'" }); - const usage = events.find((event) => event.type === "lane_usage"); - expect(usage).toMatchObject({ + expect(events.find((event) => event.type === "lane_created")).toMatchObject({ + spec: { mode: "workspace-write" }, + }); + expect(events.find((event) => event.type === "lane_tool")).toMatchObject({ + tool: "bash", + summary: "printf 'fixture tool output'", + }); + expect(events.find((event) => event.type === "lane_usage")).toMatchObject({ input: 1200, output: 300, cacheRead: 40, @@ -69,62 +156,534 @@ describe("LaneRunner", () => { cost: estimateCost(validSpec.model, 1200, 300), answer: "Hello from lane.", }); - expect(events.at(-1)).toMatchObject({ type: "lane_end", ok: true, answer: "Hello from lane." }); + const transcript = (await readFile(join(rawDir, "worker.jsonl"), "utf8")) + .trimEnd() + .split("\n") + .map((line) => JSON.parse(line)); + expect(transcript[0]).toEqual({ v: 1, type: "task", text: validSpec.task }); + expect(transcript.at(-1)).toEqual({ v: 1, type: "assistant_end", text: "Hello from lane." }); + expect(transcript.some((event) => event.type === "session")).toBe(false); }); - it("captures a nonzero child exit as a failed lane", async () => { - const piBinary = await executable("failing-pi", "#!/usr/bin/env bash\necho 'fixture failed' >&2\nexit 7\n"); + it("resolves the execution adapter from model-table route metadata", async () => { + const binary = await executable( + "normalized-claude", + `#!/usr/bin/env node\nconst { readFileSync } = require("node:fs");\nprocess.stdout.write(readFileSync(${JSON.stringify(normalizedFixture)}));\n`, + ); const events: KitEvent[] = []; - const runner = new LaneRunner({ runId: "run-failure", append: (event) => events.push(event), piBinary }); + const spec: LaneSpec = { + lane: "claude-worker", + task: "Run the normalized fixture task", + model: "anthropic/claude-fable-5", + effort: "high", + mode: "read-only", + }; + const runner = new LaneRunner({ + runId: "run-route", + append: (event) => events.push(event), + adapters: [normalizedAdapter("claude-code", binary)], + }); + + const [lane] = await runner.dispatch([spec]); + + expect(lane).toMatchObject({ + state: "done", + answer: "Normalized answer.", + tokensIn: 120, + tokensOut: 40, + context: 230, + }); + expect(events.find((event) => event.type === "lane_tool")).toMatchObject({ tool: "read", summary: "README.md" }); + }); + + it("rejects traversal and separator lane ids before spawning or resolving transcripts", async () => { + const marker = join(dataDir, "spawned"); + const binary = await executable("unsafe-lane", `#!/usr/bin/env bash\ntouch ${JSON.stringify(marker)}\n`); + const rawDir = join(dataDir, "raw"); + const runner = new LaneRunner({ + runId: "unsafe-lane", + append() {}, + rawDir, + adapters: [normalizedAdapter("pi", binary)], + }); + + await expect( + runner.dispatch([ + { ...validSpec, lane: "../escape" }, + { ...validSpec, lane: "nested/lane" }, + { ...validSpec, lane: "nested\\lane" }, + ]), + ).rejects.toThrow("lane id"); + await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(access(join(dataDir, "escape.jsonl"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("records preparation failures without spawning the provider process", async () => { + const marker = join(dataDir, "provider-spawned"); + const binary = await executable("must-not-run", `#!/usr/bin/env bash\ntouch ${JSON.stringify(marker)}\n`); + const events: KitEvent[] = []; + const adapter: LaneExecutionAdapter = { + route: "pi", + preflight() {}, + async prepare() { + await Promise.resolve(); + throw new Error("version check failed"); + }, + build(spec) { + return { command: binary, args: [], cwd: spec.cwd ?? process.cwd(), env: process.env }; + }, + createParser() { + return { feedLine: () => [], end: () => [] }; + }, + }; + const runner = new LaneRunner({ + runId: "preparation-failure", + append: (event) => events.push(event), + adapters: [adapter], + }); const [lane] = await runner.dispatch([validSpec]); - expect(lane).toMatchObject({ state: "failed", answer: expect.stringContaining("fixture failed") }); - expect(events.at(-1)).toMatchObject({ - type: "lane_end", + expect(lane).toMatchObject({ state: "failed", answer: "version check failed" }); + expect(events.find((event) => event.type === "lane_end")).toMatchObject({ ok: false, - stopReason: "exit:7", - answer: expect.stringContaining("fixture failed"), + stopReason: "prepare:version check failed", }); + expect(events.some((event) => event.type === "lane_start")).toBe(false); + await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" }); }); - it("rejects every invalid spec before spawning", async () => { + it("reports spawn failures even when transcript capture also fails", async () => { + const rawDir = join(dataDir, "unwritable-raw"); + await mkdir(join(rawDir, `${validSpec.lane}.jsonl`), { recursive: true }); + const missingBinary = join(dataDir, "does-not-exist"); + const runner = new LaneRunner({ + runId: "spawn-failure", + append() {}, + rawDir, + adapters: [normalizedAdapter("pi", missingBinary)], + }); + + const [lane] = await runner.dispatch([validSpec]); + + expect(lane).toMatchObject({ + state: "failed", + answer: expect.stringContaining("ENOENT"), + }); + }); + + it("keeps exact-one routing for every managed provider family", async () => { + const binary = await executable( + "route-fixture", + `#!/usr/bin/env node\nconsole.log(JSON.stringify({v:1,type:"assistant_end",text:"routed"}));\n`, + ); + const seen = { pi: [] as string[], claude: [] as string[] }; + const routedAdapter = (route: ExecutionRoute, models: string[]): LaneExecutionAdapter => ({ + ...normalizedAdapter(route, binary), + preflight(spec) { + models.push(`${spec.model}:${spec.effort}`); + }, + }); + const runner = new LaneRunner({ + runId: "all-routes", + append() {}, + maxConcurrent: 6, + adapters: [routedAdapter("pi", seen.pi), routedAdapter("claude-code", seen.claude)], + }); + const specs: LaneSpec[] = [ + { ...validSpec, lane: "sol" }, + { ...validSpec, lane: "grok", model: "xai/grok-4.5", effort: "high" }, + { ...validSpec, lane: "glm", model: "ollama/glm-5.2:cloud" }, + { ...validSpec, lane: "fable", model: "anthropic/claude-fable-5", effort: "high" }, + { ...validSpec, lane: "opus", model: "anthropic/claude-opus-4-8", effort: "xhigh" }, + { ...validSpec, lane: "sonnet", model: "anthropic/claude-sonnet-5" }, + ]; + + const lanes = await runner.dispatch(specs); + + expect(lanes.every((lane) => lane.state === "done")).toBe(true); + expect(seen.pi).toEqual([ + "openai-codex/gpt-5.6-sol:medium", + "xai/grok-4.5:high", + "ollama/glm-5.2:cloud:medium", + ]); + expect(seen.claude).toEqual([ + "anthropic/claude-fable-5:high", + "anthropic/claude-opus-4-8:xhigh", + "anthropic/claude-sonnet-5:medium", + ]); + }); + + it("rejects zero or multiple matching adapters before spawning", async () => { + const marker = join(dataDir, "spawned"); + const binary = await executable("must-not-run", `#!/usr/bin/env bash\ntouch ${JSON.stringify(marker)}\n`); + const adapter = normalizedAdapter("pi", binary); + + await expect( + new LaneRunner({ runId: "none", append() {}, adapters: [] }).dispatch([validSpec]), + ).rejects.toThrow("exactly one execution adapter"); + await expect( + new LaneRunner({ runId: "many", append() {}, adapters: [adapter, adapter] }).dispatch([validSpec]), + ).rejects.toThrow("exactly one execution adapter"); + await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("validates specs for their origin and normalizes Pi-origin mode", async () => { const marker = join(dataDir, "spawned"); - const piBinary = await executable("must-not-run", `#!/usr/bin/env bash\ntouch ${JSON.stringify(marker)}\n`); + const binary = await executable("origin-fixture", `#!/usr/bin/env bash\ntouch ${JSON.stringify(marker)}\n`); const events: KitEvent[] = []; - const runner = new LaneRunner({ runId: "run-invalid", append: (event) => events.push(event), piBinary }); - const invalid = [ - { ...validSpec, lane: "missing-model", model: "" }, - { ...validSpec, lane: "missing-task", task: "" }, - ] as LaneSpec[]; + const runner = new LaneRunner({ + runId: "origin", + origin: "mcp", + append: (event) => events.push(event), + adapters: [normalizedAdapter("pi", binary)], + }); - await expect(runner.dispatch(invalid)).rejects.toThrow( - "Invalid lane specs:\nmissing-model: model is required: state the full selector from the model table\nmissing-task: task is required and must be self-contained", - ); + await expect(runner.dispatch([validSpec])).rejects.toThrow("mode is required for MCP-origin lanes"); expect(events).toEqual([]); await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" }); }); - it("abandons a running child and journals the reason", async () => { - const piBinary = await executable("sleeping-pi", "#!/usr/bin/env bash\nexec sleep 10\n"); + it("replaces cumulative usage snapshots and ignores malformed or duplicate reports", async () => { + const binary = await executable( + "usage-fixture", + `#!/usr/bin/env node\nfor (const event of [ + {v:1,type:"task",text:"Run the fixture task"}, + {v:1,type:"usage",input:70,output:10,cacheRead:20,context:100}, + {v:1,type:"usage",input:70,output:10,cacheRead:20,context:100}, + {v:1,type:"usage",input:"bad",output:999,cacheRead:0,context:999}, + {v:1,type:"usage",input:120,output:40,cacheRead:50,context:230}, + {v:1,type:"assistant_end",text:"done"} + ]) console.log(JSON.stringify(event));\nsetInterval(() => {}, 1000);\n`, + ); const events: KitEvent[] = []; + const runner = new LaneRunner({ + runId: "usage", + append: (event) => events.push(event), + adapters: [normalizedAdapter("pi", binary)], + }); + + const [lane] = await runner.dispatch([validSpec]); + + expect(events.filter((event) => event.type === "lane_usage")).toHaveLength(2); + expect(lane).toMatchObject({ tokensIn: 120, tokensOut: 40, context: 230 }); + }); + + it("does not settle success until close and flushes parser end once", async () => { + const binary = await executable( + "slow-success", + `#!/usr/bin/env node\nprocess.on("SIGTERM", () => setTimeout(() => process.exit(0), 120));\nconsole.log(JSON.stringify({v:1,type:"task",text:"Run the fixture task"}));\nconsole.log(JSON.stringify({v:1,type:"assistant_end",text:"done"}));\nsetInterval(() => {}, 1000);\n`, + ); + const assistantSeen = Promise.withResolvers(); + let endCalls = 0; + const runner = new LaneRunner({ + runId: "slow-success", + append() {}, + adapters: [ + normalizedAdapter( + "pi", + binary, + (event) => { + if (event.type === "assistant_end") assistantSeen.resolve(); + }, + () => { + endCalls++; + return []; + }, + ), + ], + }); + + let settled = false; + const dispatch = runner.dispatch([validSpec]).finally(() => { + settled = true; + }); + await assistantSeen.promise; + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(settled).toBe(false); + await dispatch; + expect(endCalls).toBe(1); + }); + + it("does not settle failure until close", async () => { + const binary = await executable( + "slow-failure", + "#!/usr/bin/env node\nconsole.error('fixture failed');\nsetTimeout(() => process.exit(7), 120);\n", + ); const started = Promise.withResolvers(); const runner = new LaneRunner({ - runId: "run-abandon", + runId: "slow-failure", append(event) { - events.push(event); if (event.type === "lane_start") started.resolve(); }, - piBinary, + adapters: [normalizedAdapter("pi", binary)], }); - const dispatch = runner.dispatch([validSpec]); + let settled = false; + const dispatch = runner.dispatch([validSpec]).finally(() => { + settled = true; + }); await started.promise; - runner.abandon(validSpec.lane, "test stop"); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(settled).toBe(false); const [lane] = await dispatch; + expect(lane).toMatchObject({ state: "failed", answer: expect.stringContaining("fixture failed") }); + }); + it("does not settle abandonment until close", async () => { + const binary = await executable( + "slow-abandon", + `#!/usr/bin/env node\nprocess.on("SIGTERM", () => setTimeout(() => process.exit(0), 120));\nconsole.log(JSON.stringify({v:1,type:"task",text:"Run the fixture task"}));\nsetInterval(() => {}, 1000);\n`, + ); + const ready = Promise.withResolvers(); + const runner = new LaneRunner({ + runId: "slow-abandon", + append() {}, + adapters: [ + normalizedAdapter("pi", binary, (event) => { + if (event.type === "task") ready.resolve(); + }), + ], + }); + + let settled = false; + const dispatch = runner.dispatch([validSpec]).finally(() => { + settled = true; + }); + await ready.promise; + runner.abandon(validSpec.lane, "test stop"); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(settled).toBe(false); + const [lane] = await dispatch; expect(lane).toMatchObject({ state: "abandoned", abandonReason: "test stop" }); - expect(events.at(-1)).toMatchObject({ type: "lane_abandoned", reason: "test stop" }); - expect(events.some((event) => event.type === "lane_end")).toBe(false); + }); + + it("does not let a late spawn event overwrite immediate abandonment", async () => { + const binary = await executable( + "immediate-abandon", + "#!/usr/bin/env node\nsetTimeout(() => {}, 2000);\n", + ); + const events: KitEvent[] = []; + const runner = new LaneRunner({ + runId: "immediate-abandon", + append: (event) => events.push(event), + adapters: [normalizedAdapter("pi", binary)], + }); + + const dispatch = runner.dispatch([validSpec]); + runner.abandon(validSpec.lane, "immediate stop"); + const [lane] = await dispatch; + + expect(lane).toMatchObject({ state: "abandoned", abandonReason: "immediate stop" }); + const abandonedIndex = events.findIndex((event) => event.type === "lane_abandoned"); + expect(events.slice(abandonedIndex + 1).some((event) => event.type === "lane_start")).toBe(false); + }); + + it("caps an unterminated stdout record and canonical transcript at 4 MiB", async () => { + const binary = await executable( + "overflow", + `#!/usr/bin/env node\nprocess.stdout.write("x".repeat(4 * 1024 * 1024 + 1));\nsetInterval(() => {}, 1000);\n`, + ); + const rawDir = join(dataDir, "overflow-raw"); + const runner = new LaneRunner({ + runId: "overflow", + append() {}, + rawDir, + adapters: [normalizedAdapter("pi", binary)], + }); + + const [lane] = await runner.dispatch([validSpec]); + + expect(lane).toMatchObject({ state: "failed" }); + expect((await stat(join(rawDir, "worker.jsonl"))).size).toBeLessThanOrEqual(4 * 1024 * 1024); + }); + + it("caps cumulative stdout bytes across newline-terminated valid, malformed, and ignored records", async () => { + const binary = await executable( + "newline-overflow", + `#!/usr/bin/env node\nconst records = [\n JSON.stringify({v:1,type:"thinking_delta",delta:"x".repeat(1000)}),\n "malformed-" + "x".repeat(1000),\n JSON.stringify({v:1,type:"ignored",text:"x".repeat(1000)}),\n];\nfor (let index = 0; index < 5000; index++) process.stdout.write(records[index % records.length] + "\\n");\nsetInterval(() => {}, 1000);\n`, + ); + const counts = { valid: 0, malformed: 0, ignored: 0 }; + const adapter: LaneExecutionAdapter = { + route: "pi", + preflight() {}, + build(spec) { + return { command: binary, args: [], cwd: spec.cwd ?? process.cwd(), env: process.env }; + }, + createParser() { + return { + feedLine(line) { + if (line.startsWith("malformed-")) { + counts.malformed++; + return []; + } + const parsed = JSON.parse(line) as { type?: string }; + if (parsed.type === "ignored") { + counts.ignored++; + return []; + } + counts.valid++; + return [parsed as NormalizedLaneEvent]; + }, + end() { + return []; + }, + }; + }, + }; + const events: KitEvent[] = []; + const runner = new LaneRunner({ + runId: "newline-overflow", + append: (event) => events.push(event), + adapters: [adapter], + }); + + const [lane] = await runner.dispatch([validSpec]); + + expect(lane).toMatchObject({ state: "failed" }); + expect(events.find((event) => event.type === "lane_end")).toMatchObject({ stopReason: "stdout-overflow" }); + expect(counts.valid).toBeGreaterThan(0); + expect(counts.malformed).toBeGreaterThan(0); + expect(counts.ignored).toBeGreaterThan(0); + }); + + it("updates projection and settles after journal appends fail during child callbacks", async () => { + const binary = await executable( + "append-failure", + `#!/usr/bin/env node\nfor (const event of [\n {v:1,type:"usage",input:12,output:3,cacheRead:1,context:16},\n {v:1,type:"assistant_end",text:"completed despite journal failure"},\n]) console.log(JSON.stringify(event));\nsetInterval(() => {}, 1000);\n`, + ); + let laneStarted = false; + const attempted: KitEvent["type"][] = []; + const runner = new LaneRunner({ + runId: "append-failure", + append(event) { + attempted.push(event.type); + if (laneStarted) throw new Error("journal unavailable"); + if (event.type === "lane_start") laneStarted = true; + }, + adapters: [normalizedAdapter("pi", binary)], + }); + + const [lane] = await runner.dispatch([validSpec]); + + expect(lane).toMatchObject({ + state: "done", + answer: "completed despite journal failure", + tokensIn: 12, + tokensOut: 3, + context: 16, + }); + expect(attempted).toContain("lane_usage"); + expect(attempted).toContain("lane_end"); + }); + + it("fails an error assistant end even when the child exits zero", async () => { + const binary = await executable("assistant-error", "#!/usr/bin/env node\n"); + const events: KitEvent[] = []; + const runner = new LaneRunner({ + runId: "assistant-error", + append: (event) => events.push(event), + adapters: [ + normalizedAdapter("pi", binary, undefined, () => [ + { v: 1, type: "assistant_end", text: "Claude failed", isError: true }, + ]), + ], + }); + + const [lane] = await runner.dispatch([validSpec]); + + expect(lane).toMatchObject({ state: "failed", answer: "Claude failed" }); + expect(events.find((event) => event.type === "lane_end")).toMatchObject({ + ok: false, + stopReason: "assistant-error", + }); + }); + + it("shutdown cannot orphan prior or current process groups across sequential runs", async () => { + const firstGrandchildFile = join(dataDir, "first-grandchild.pid"); + const secondGrandchildFile = join(dataDir, "second-grandchild.pid"); + const firstBinary = await executable( + "first-process-tree", + `#!/usr/bin/env node\nconst { spawn } = require("node:child_process");\nconst { writeFileSync } = require("node:fs");\nconst child = spawn("sleep", ["30"]);\nwriteFileSync(${JSON.stringify(firstGrandchildFile)}, String(child.pid));\nconsole.log(JSON.stringify({v:1,type:"assistant_end",text:"first done"}));\nsetInterval(() => {}, 1000);\n`, + ); + const firstEvents: KitEvent[] = []; + const firstRunner = new LaneRunner({ + runId: "first-process-tree", + append: (event) => firstEvents.push(event), + adapters: [normalizedAdapter("pi", firstBinary)], + }); + + const [firstLane] = await firstRunner.dispatch([validSpec]); + const firstProcessGroup = (firstEvents.find((event) => event.type === "lane_start") as Extract).pid; + const firstGrandchild = Number((await readFile(firstGrandchildFile, "utf8")).trim()); + expect(firstLane).toMatchObject({ state: "done" }); + await waitForDead(firstGrandchild); + await waitForGroupDead(firstProcessGroup); + + const secondBinary = await executable( + "second-process-tree", + `#!/usr/bin/env node\nconst { spawn } = require("node:child_process");\nconst { writeFileSync } = require("node:fs");\nconst child = spawn("sleep", ["30"]);\nwriteFileSync(${JSON.stringify(secondGrandchildFile)}, String(child.pid));\nprocess.on("SIGTERM", () => setTimeout(() => process.exit(0), 120));\nsetInterval(() => {}, 1000);\n`, + ); + const secondEvents: KitEvent[] = []; + const secondRunner = new LaneRunner({ + runId: "second-process-tree", + append: (event) => secondEvents.push(event), + adapters: [normalizedAdapter("pi", secondBinary)], + }); + const secondDispatch = secondRunner.dispatch([{ ...validSpec, lane: "second" }]); + await waitForFile(secondGrandchildFile); + const secondProcessGroup = (secondEvents.find((event) => event.type === "lane_start") as Extract).pid; + const secondGrandchild = Number((await readFile(secondGrandchildFile, "utf8")).trim()); + + let stopped = false; + const shutdown = secondRunner.shutdown("session ended").then(() => { + stopped = true; + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(stopped).toBe(false); + + const [secondLane] = await secondDispatch; + await shutdown; + expect(secondLane).toMatchObject({ state: "abandoned", abandonReason: "session ended" }); + await waitForDead(secondGrandchild); + await waitForGroupDead(secondProcessGroup); + }); + + it("reapAll kills the child process group used by the exit hook", async () => { + const grandchildPidFile = join(dataDir, "grandchild.pid"); + const binary = await executable( + "process-tree", + `#!/usr/bin/env bash\nsleep 30 &\necho $! > ${JSON.stringify(grandchildPidFile)}\nwait\n`, + ); + const runner = new LaneRunner({ + runId: "process-tree", + append() {}, + adapters: [normalizedAdapter("pi", binary)], + }); + + const dispatch = runner.dispatch([validSpec]); + await waitForFile(grandchildPidFile); + const grandchildPid = Number((await readFile(grandchildPidFile, "utf8")).trim()); + runner.reapAll(); + await dispatch; + + await waitForDead(grandchildPid); + }); + + it("rejects every invalid spec before spawning", async () => { + const marker = join(dataDir, "spawned"); + const piBinary = await executable("must-not-run-invalid", `#!/usr/bin/env bash\ntouch ${JSON.stringify(marker)}\n`); + const events: KitEvent[] = []; + const runner = new LaneRunner({ runId: "run-invalid", append: (event) => events.push(event), piBinary }); + const invalid = [ + { ...validSpec, lane: "missing-model", model: "" }, + { ...validSpec, lane: "missing-task", task: "" }, + ] as LaneSpec[]; + + await expect(runner.dispatch(invalid)).rejects.toThrow( + "Invalid lane specs:\nmissing-model: model is required: state the full selector from the model table\nmissing-task: task is required and must be self-contained", + ); + expect(events).toEqual([]); + await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" }); }); }); diff --git a/packages/pi-kit/test/table.test.ts b/packages/pi-kit/test/table.test.ts index a48ccbe..7ef4472 100644 --- a/packages/pi-kit/test/table.test.ts +++ b/packages/pi-kit/test/table.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vitest"; import type { LaneSpec } from "../src/schema.ts"; -import { estimateCost, MODEL_TABLE, validateLaneSpec } from "../src/table.ts"; +import { + estimateCost, + findModel, + MODEL_TABLE, + normalizeLaneSpec, + validateLaneSpec, +} from "../src/table.ts"; const valid: LaneSpec = { lane: "lane-1", @@ -10,9 +16,43 @@ const valid: LaneSpec = { effort: "medium", }; +const validAnthropic: LaneSpec = { + ...valid, + model: "anthropic/claude-fable-5", + effort: "high", +}; + describe("validateLaneSpec", () => { it("accepts a valid explicit lane", () => { expect(validateLaneSpec(valid)).toBeNull(); + expect(validateLaneSpec({ ...valid, lane: `a${"x".repeat(63)}` })).toBeNull(); + expect(validateLaneSpec({ ...valid, mode: "workspace-write" }, { origin: "mcp" })).toBeNull(); + }); + + it.each(["../escape", "nested/lane", "nested\\lane", ".hidden", `a${"x".repeat(64)}`])( + "rejects unsafe or oversized lane id %s", + (lane) => { + expect(validateLaneSpec({ ...valid, lane })).toContain("lane id"); + }, + ); + + it("rejects routes unavailable to the calling origin", () => { + expect(validateLaneSpec(validAnthropic, { origin: "mcp" })).toContain("native Claude workflow"); + expect(validateLaneSpec(validAnthropic, { origin: "pi" })).toBeNull(); + }); + + it("requires MCP callers to choose a mode", () => { + expect(validateLaneSpec({ ...valid, mode: undefined }, { origin: "mcp" })).toContain("mode is required"); + expect(validateLaneSpec({ ...valid, mode: "read-only" }, { origin: "mcp" })).toBeNull(); + }); + + it("rejects invalid modes", () => { + expect(validateLaneSpec({ ...valid, mode: "unsafe" as LaneSpec["mode"] })).toContain("mode"); + }); + + it("defaults existing Pi calls to workspace-write", () => { + expect(normalizeLaneSpec(valid, { origin: "pi" }).mode).toBe("workspace-write"); + expect(normalizeLaneSpec({ ...valid, mode: "read-only" }, { origin: "pi" }).mode).toBe("read-only"); }); it("requires an explicit model and effort", () => { @@ -27,14 +67,22 @@ describe("validateLaneSpec", () => { }, ); - it("rejects effort above the ceiling", () => { - expect(validateLaneSpec({ ...valid, effort: "ultra" as LaneSpec["effort"] })).toContain("ceiling"); + it.each(["ultra", "max"])("rejects prohibited effort %s", (effort) => { + expect(validateLaneSpec({ ...valid, effort: effort as LaneSpec["effort"] })).toContain("ceiling"); }); - it("enforces the Opus effort pin", () => { - const model = "anthropic/claude-opus-4-8"; - expect(validateLaneSpec({ ...valid, model, effort: "high" })).toContain("pinned to effort xhigh"); - expect(validateLaneSpec({ ...valid, model, effort: "xhigh" })).toBeNull(); + it("enforces route-specific efforts and hard pins", () => { + expect(validateLaneSpec({ ...validAnthropic, effort: "minimal" }, { origin: "pi" })).toContain( + "unsupported effort", + ); + + const opus = "anthropic/claude-opus-4-8"; + expect(validateLaneSpec({ ...valid, model: opus, effort: "high" })).toContain("pinned to effort xhigh"); + expect(validateLaneSpec({ ...valid, model: opus, effort: "xhigh" })).toBeNull(); + + const grok = "xai/grok-4.5"; + expect(validateLaneSpec({ ...valid, model: grok, effort: "medium" })).toContain("pinned to effort high"); + expect(validateLaneSpec({ ...valid, model: grok, effort: "high" })).toBeNull(); }); it("lists the table for an unknown selector", () => { @@ -44,6 +92,26 @@ describe("validateLaneSpec", () => { }); }); +describe("model execution metadata", () => { + it("routes Fable through Claude Code for Pi callers", () => { + expect(findModel("anthropic/claude-fable-5")).toMatchObject({ + route: "claude-code", + runtimeModel: "fable", + allowedEfforts: ["low", "medium", "high"], + origins: ["pi"], + }); + }); + + it("routes pinned Grok through Pi for both origins", () => { + expect(findModel("xai/grok-4.5")).toMatchObject({ + route: "pi", + runtimeModel: "grok-4.5", + allowedEfforts: ["high"], + origins: ["pi", "mcp"], + }); + }); +}); + describe("estimateCost", () => { it("applies per-million input and output prices", () => { expect(estimateCost("openai-codex/gpt-5.6-sol", 1_000_000, 500_000)).toBe(8.75); diff --git a/packages/pi-kit/test/transcript.test.ts b/packages/pi-kit/test/transcript.test.ts index 0e5a4c5..b72f525 100644 --- a/packages/pi-kit/test/transcript.test.ts +++ b/packages/pi-kit/test/transcript.test.ts @@ -1,9 +1,35 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { LaneTranscript } from "../src/transcript.ts"; +const normalizedFixture = fileURLToPath(new URL("./fixtures/normalized-lane-stream.jsonl", import.meta.url)); +const piFixture = fileURLToPath(new URL("./fixtures/lane-stream.jsonl", import.meta.url)); const line = (obj: unknown) => JSON.stringify(obj); describe("LaneTranscript", () => { + it("renders canonical normalized JSONL", async () => { + const transcript = new LaneTranscript(); + + transcript.feedChunk(await readFile(normalizedFixture, "utf8")); + + expect(transcript.entries).toEqual([ + { kind: "task", text: "Run the normalized fixture task" }, + { kind: "thinking", text: "Inspecting fixture." }, + { kind: "tool", title: "read", text: '{"path":"README.md"}' }, + { kind: "tool_result", title: "read", text: "fixture contents" }, + { kind: "text", text: "Normalized answer." }, + ]); + }); + + it("keeps archived Pi JSONL transcripts readable", async () => { + const transcript = new LaneTranscript(); + + transcript.feedChunk(await readFile(piFixture, "utf8")); + + expect(transcript.entries).toEqual([{ kind: "text", text: "Hello from lane." }]); + }); + it("reduces a raw pi json stream into ordered entries", () => { const t = new LaneTranscript(); t.feed(line({ type: "message_start", message: { role: "user", content: [{ type: "text", text: "do the task" }] } }));