diff --git a/README.md b/README.md index 14dc66a..44c47c2 100644 --- a/README.md +++ b/README.md @@ -419,10 +419,24 @@ The `Memory: Show loops` command is registered in the command palette when loops ### Setup -When installed via npm, the TUI plugin loads automatically. For local development, add the built TUI file to your `~/.config/opencode/tui.json`: +When installed via npm, the TUI plugin loads automatically when added to your TUI config. The plugin is auto-detected via the `./tui` export in `package.json`. + +Add to your `~/.config/opencode/tui.json` or project-level `tui.json`: + +```json +{ + "$schema": "https://opencode.ai/tui.json", + "plugin": [ + "@opencode-manager/memory" + ] +} +``` + +For local development, reference the built TUI file directly: ```json { + "$schema": "https://opencode.ai/tui.json", "plugin": [ "/path/to/opencode-memory/dist/tui.js" ] diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..41ac986 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,54 @@ +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; +import solidPlugin from "eslint-plugin-solid"; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + plugins: { + solid: solidPlugin, + }, + languageOptions: { + parserOptions: { + projectService: { + allowDefaultProject: ["scripts/*.ts", "scripts/*.js", "test/*.ts"], + }, + tsconfigRootDir: import.meta.dirname, + }, + parser: tseslint.parser, + }, + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/no-deprecated": "warn", + "no-useless-assignment": "warn", + "no-empty": "off", + }, + }, + { + files: ["**/*.tsx"], + rules: { + ...solidPlugin.configs.recommended.rules, + }, + }, + { + files: ["scripts/*.js"], + languageOptions: { + globals: { + process: "readonly", + console: "readonly", + }, + }, + }, + { + ignores: ["dist/**", "node_modules/**", "*.d.ts", "test/**"], + } +); diff --git a/package.json b/package.json index 17db975..4dcf944 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-manager/memory", - "version": "0.0.28", + "version": "0.0.29", "type": "module", "oc-plugin": [ "server", @@ -73,14 +73,23 @@ "sqlite-vec-windows-x64": "0.1.7-alpha.2" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@opentui/core": "0.1.92", "@opentui/solid": "0.1.92", + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", "bun-types": "latest", + "eslint": "^10.2.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-solid": "^0.14.5", + "prettier": "^3.8.1", "solid-js": "^1.9.12", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "typescript-eslint": "^8.58.0" }, "scripts": { "build": "bun scripts/build.ts", + "lint": "eslint .", "postinstall": "node scripts/download-models.js", "prepublishOnly": "pnpm build", "test": "bun test", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bcca35..e09d682 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,21 +21,45 @@ importers: specifier: 0.1.7-alpha.2 version: 0.1.7-alpha.2 devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.2.0) '@opentui/core': specifier: 0.1.92 version: 0.1.92(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10) '@opentui/solid': specifier: 0.1.92 version: 0.1.92(solid-js@1.9.12)(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@typescript-eslint/eslint-plugin': + specifier: ^8.58.0 + version: 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.58.0 + version: 8.58.0(eslint@10.2.0)(typescript@5.9.3) bun-types: specifier: latest version: 1.3.11 + eslint: + specifier: ^10.2.0 + version: 10.2.0 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.2.0) + eslint-plugin-solid: + specifier: ^0.14.5 + version: 0.14.5(eslint@10.2.0)(typescript@5.9.3) + prettier: + specifier: ^3.8.1 + version: 3.8.1 solid-js: specifier: ^1.9.12 version: 1.9.12 typescript: specifier: ^5.7.3 version: 5.9.3 + typescript-eslint: + specifier: ^8.58.0 + version: 8.58.0(eslint@10.2.0)(typescript@5.9.3) optionalDependencies: sqlite-vec-darwin-arm64: specifier: 0.1.7-alpha.2 @@ -198,6 +222,45 @@ packages: '@emnapi/runtime@1.9.1': resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.4': + resolution: {integrity: sha512-lf19F24LSMfF8weXvW5QEtnLqW70u7kgit5e9PSx0MsHAFclGd1T9ynvWEMDT1w5J4Qt54tomGeAhdoAku1Xow==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.4': + resolution: {integrity: sha512-jJhqiY3wPMlWWO3370M86CPJ7pt8GmEwSLglMfQhjXal07RCvhmU0as4IuUEW5SJeunfItiEetHmSxCCe9lDBg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.0': + resolution: {integrity: sha512-8FTGbNzTvmSlc4cZBaShkC6YvFMG0riksYWRFKXztqVdXaQbcZLXlFbSpC05s70sGEsXAw0qwhx69JiW7hQS7A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.4': + resolution: {integrity: sha512-55lO/7+Yp0ISKRP0PsPtNTeNGapXaO085aELZmWCVc5SH3jfrqpuU6YgOdIxMS99ZHkQN1cXKE+cdIqwww9ptw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.0': + resolution: {integrity: sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@huggingface/jinja@0.5.6': resolution: {integrity: sha512-MyMWyLnjqo+KRJYSH7oWNbsOn5onuIvfXYPcc0WOGxU0eHUV7oAYUoQTl2BMdu7ml+ea/bu11UM+EshbeHwtIA==} engines: {node: '>=18'} @@ -205,6 +268,22 @@ packages: '@huggingface/transformers@3.8.1': resolution: {integrity: sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==} + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -558,12 +637,80 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@16.9.1': resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==} '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@typescript-eslint/eslint-plugin@8.58.0': + resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.58.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.58.0': + resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.58.0': + resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.58.0': + resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.58.0': + resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.58.0': + resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.58.0': + resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.58.0': + resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.58.0': + resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.58.0': + resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@webgpu/types@0.1.69': resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} @@ -571,6 +718,19 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + any-base@1.1.0: resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} @@ -598,6 +758,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -616,6 +780,10 @@ packages: brace-expansion@2.0.3: resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -665,6 +833,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -677,6 +849,9 @@ packages: supports-color: optional: true + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -726,6 +901,61 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-solid@0.14.5: + resolution: {integrity: sha512-nfuYK09ah5aJG/oEN6P1qziy1zLgW4PDWe75VNPi4CEFYk1x2AEqwFeQfEPR7gNn0F2jOeqKhx2E+5oNCOBYWQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + typescript: '>=4.8.4' + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.2.0: + resolution: {integrity: sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -737,6 +967,28 @@ packages: exif-parser@0.1.12: resolution: {integrity: sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + file-type@16.5.4: resolution: {integrity: sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==} engines: {node: '>=10'} @@ -748,9 +1000,20 @@ packages: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} engines: {node: '>=6'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + flatbuffers@25.9.23: resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -764,6 +1027,10 @@ packages: gifwrap@0.10.1: resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} engines: {node: '>=16 || 14 >=14.17'} @@ -794,16 +1061,50 @@ packages: html-entities@2.3.3: resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + image-q@4.0.0: resolution: {integrity: sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==} + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-html@2.0.0: + resolution: {integrity: sha512-S+OpgB5i7wzIue/YSE5hg0e5ZYfG3hhpNh9KGl6ayJ38p7ED6wxQLd1TV91xHpcTvw90KMJ9EwN3F/iNflHBVg==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jimp@1.6.0: resolution: {integrity: sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg==} engines: {node: '>=18'} @@ -819,6 +1120,15 @@ packages: engines: {node: '>=6'} hasBin: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} @@ -827,10 +1137,27 @@ packages: engines: {node: '>=6'} hasBin: true + kebab-case@1.0.2: + resolution: {integrity: sha512-7n6wXq4gNgBELfDCpzKc+mRrZFs7D+wgfF5WRFLNAr4DA/qtr9Js8uOAVAfHhuLMfAcQ0pRKqbpjx+TcJVdE1Q==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + known-css-properties@0.30.0: + resolution: {integrity: sha512-VSWXYUnsPu9+WYKkfmJyLKtIvaRJi1kXUqVmBACORXZQxT5oZDsoZ2vQP+bQFDnWtpI/4eq3MLoRMjI2fnLzTQ==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + locate-path@3.0.0: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -854,6 +1181,10 @@ packages: engines: {node: '>=10.0.0'} hasBin: true + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@8.0.7: resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==} engines: {node: '>=16 || 14 >=14.17'} @@ -873,6 +1204,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} @@ -896,14 +1230,26 @@ packages: onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: resolution: {integrity: sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + p-locate@3.0.0: resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} engines: {node: '>=6'} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -927,6 +1273,14 @@ packages: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -941,6 +1295,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pixelmatch@5.3.0: resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==} hasBin: true @@ -966,6 +1324,15 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} @@ -974,6 +1341,10 @@ packages: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + readable-stream@4.7.0: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1034,6 +1405,14 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + simple-xml-to-json@1.2.4: resolution: {integrity: sha512-3MY16e0ocMHL7N1ufpdObURGyX+lCo0T/A+y6VCwosLdH1HSda4QZl1Sdt/O+2qWp48WFi26XEp5rF0LoaL0Dg==} engines: {node: '>=20.12.2'} @@ -1083,6 +1462,9 @@ packages: resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==} engines: {node: '>=10'} + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -1097,17 +1479,38 @@ packages: tinycolor2@1.6.0: resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + token-types@4.2.1: resolution: {integrity: sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==} engines: {node: '>=10'} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} + typescript-eslint@8.58.0: + resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1122,6 +1525,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + utif2@4.1.0: resolution: {integrity: sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==} @@ -1133,6 +1539,15 @@ packages: '@types/emscripten': optional: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + xml-parse-from-string@1.0.1: resolution: {integrity: sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==} @@ -1151,6 +1566,10 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} @@ -1365,6 +1784,40 @@ snapshots: tslib: 2.8.1 optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.0)': + dependencies: + eslint: 10.2.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.4': + dependencies: + '@eslint/object-schema': 3.0.4 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.4': + dependencies: + '@eslint/core': 1.2.0 + + '@eslint/core@1.2.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.2.0)': + optionalDependencies: + eslint: 10.2.0 + + '@eslint/object-schema@3.0.4': {} + + '@eslint/plugin-kit@0.7.0': + dependencies: + '@eslint/core': 1.2.0 + levn: 0.4.1 + '@huggingface/jinja@0.5.6': {} '@huggingface/transformers@3.8.1': @@ -1374,6 +1827,17 @@ snapshots: onnxruntime-web: 1.22.0-dev.20250409-89f8206ba4 sharp: 0.34.5 + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -1769,12 +2233,109 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + '@types/node@16.9.1': {} '@types/node@25.5.0': dependencies: undici-types: 7.18.2 + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint@10.2.0)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/type-utils': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + eslint: 10.2.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + eslint: 10.2.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.58.0(eslint@10.2.0)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.2.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.58.0': {} + + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.58.0(eslint@10.2.0)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + eslint: 10.2.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + eslint-visitor-keys: 5.0.1 + '@webgpu/types@0.1.69': optional: true @@ -1782,6 +2343,19 @@ snapshots: dependencies: event-target-shim: 5.0.1 + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + any-base@1.1.0: {} await-to-js@3.0.0: {} @@ -1812,6 +2386,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.12: {} @@ -1824,6 +2400,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.12 @@ -1873,12 +2453,20 @@ snapshots: convert-source-map@2.0.0: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + csstype@3.2.3: {} debug@4.4.3: dependencies: ms: 2.1.3 + deep-is@0.1.4: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -1913,12 +2501,107 @@ snapshots: escape-string-regexp@4.0.0: {} + eslint-config-prettier@10.1.8(eslint@10.2.0): + dependencies: + eslint: 10.2.0 + + eslint-plugin-solid@0.14.5(eslint@10.2.0)(typescript@5.9.3): + dependencies: + '@typescript-eslint/utils': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + eslint: 10.2.0 + estraverse: 5.3.0 + is-html: 2.0.0 + kebab-case: 1.0.2 + known-css-properties: 0.30.0 + style-to-object: 1.0.14 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.2.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.4 + '@eslint/config-helpers': 0.5.4 + '@eslint/core': 1.2.0 + '@eslint/plugin-kit': 0.7.0 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + event-target-shim@5.0.1: {} events@3.3.0: {} exif-parser@0.1.12: {} + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + file-type@16.5.4: dependencies: readable-web-to-node-stream: 3.0.4 @@ -1933,8 +2616,20 @@ snapshots: dependencies: locate-path: 3.0.0 + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + flatbuffers@25.9.23: {} + flatted@3.4.2: {} + fs.realpath@1.0.0: {} function-bind@1.1.2: {} @@ -1946,6 +2641,10 @@ snapshots: image-q: 4.0.0 omggif: 1.0.10 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + glob@9.3.5: dependencies: fs.realpath: 1.0.0 @@ -1981,16 +2680,38 @@ snapshots: html-entities@2.3.3: {} + html-tags@3.3.1: {} + ieee754@1.2.1: {} + ignore@5.3.2: {} + + ignore@7.0.5: {} + image-q@4.0.0: dependencies: '@types/node': 16.9.1 + imurmurhash@0.1.4: {} + + inline-style-parser@0.2.7: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-html@2.0.0: + dependencies: + html-tags: 3.3.1 + + isexe@2.0.0: {} + jimp@1.6.0: dependencies: '@jimp/core': 1.6.0 @@ -2027,15 +2748,38 @@ snapshots: jsesc@3.1.0: {} + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: {} json5@2.2.3: {} + kebab-case@1.0.2: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + known-css-properties@0.30.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + locate-path@3.0.0: dependencies: p-locate: 3.0.0 path-exists: 3.0.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + long@5.3.2: {} lru-cache@10.4.3: {} @@ -2052,6 +2796,10 @@ snapshots: mime@3.0.0: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + minimatch@8.0.7: dependencies: brace-expansion: 2.0.3 @@ -2066,6 +2814,8 @@ snapshots: ms@2.1.3: {} + natural-compare@1.4.0: {} + node-releases@2.0.36: {} object-keys@1.1.1: {} @@ -2091,14 +2841,31 @@ snapshots: platform: 1.3.6 protobufjs: 7.5.4 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + p-limit@2.3.0: dependencies: p-try: 2.2.0 + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + p-locate@3.0.0: dependencies: p-limit: 2.3.0 + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + p-try@2.2.0: {} pako@1.0.11: {} @@ -2118,6 +2885,10 @@ snapshots: path-exists@3.0.0: {} + path-exists@4.0.0: {} + + path-key@3.1.1: {} + path-parse@1.0.7: {} path-scurry@1.11.1: @@ -2129,6 +2900,8 @@ snapshots: picocolors@1.1.1: {} + picomatch@4.0.4: {} + pixelmatch@5.3.0: dependencies: pngjs: 6.0.0 @@ -2148,6 +2921,10 @@ snapshots: pngjs@7.0.0: {} + prelude-ls@1.2.1: {} + + prettier@3.8.1: {} + process@0.11.10: {} protobufjs@7.5.4: @@ -2165,6 +2942,8 @@ snapshots: '@types/node': 25.5.0 long: 5.3.2 + punycode@2.3.1: {} + readable-stream@4.7.0: dependencies: abort-controller: 3.0.0 @@ -2247,6 +3026,12 @@ snapshots: '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + simple-xml-to-json@1.2.4: {} solid-js@1.9.12: @@ -2292,6 +3077,10 @@ snapshots: '@tokenizer/token': 0.3.0 peek-readable: 4.1.0 + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + supports-preserve-symlinks-flag@1.0.0: {} tar@7.5.13: @@ -2307,16 +3096,40 @@ snapshots: tinycolor2@1.6.0: {} + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + token-types@4.2.1: dependencies: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + tslib@2.8.1: optional: true + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-fest@0.13.1: {} + typescript-eslint@8.58.0(eslint@10.2.0)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + eslint: 10.2.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} undici-types@7.18.2: {} @@ -2327,12 +3140,22 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + utif2@4.1.0: dependencies: pako: 1.0.11 web-tree-sitter@0.25.10: {} + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + xml-parse-from-string@1.0.1: {} xml2js@0.5.0: @@ -2346,6 +3169,8 @@ snapshots: yallist@5.0.0: {} + yocto-queue@0.1.0: {} + yoga-layout@3.2.1: {} zod@3.25.76: {} diff --git a/src/agents/architect.ts b/src/agents/architect.ts index 3030531..590e3bc 100644 --- a/src/agents/architect.ts +++ b/src/agents/architect.ts @@ -163,6 +163,6 @@ All execution modes require a **title** — a short descriptive label for the se | Loop (worktree) | memory-loop | true | Full self-contained plan | | Loop | memory-loop | false | Full self-contained plan | -"Full self-contained" means the plan must include every file path, implementation detail, code pattern, phase dependency, verification step, and gotcha. The receiving agent starts with zero context. Do NOT summarize, abbreviate, or include tags. +"Full self-contained" means the plan must include every file path, implementation detail, code pattern, phase dependency, verification step, and gotcha. The receiving agent starts with zero context. Do NOT summarize or abbreviate. `, } diff --git a/src/cli/commands/restart.ts b/src/cli/commands/restart.ts index 75fe9e0..9b3b5f5 100644 --- a/src/cli/commands/restart.ts +++ b/src/cli/commands/restart.ts @@ -1,4 +1,5 @@ import type { LoopState } from '../../services/loop' +import { buildCompletionSignalInstructions, LOOP_PERMISSION_RULESET } from '../../services/loop' import { openDatabase, confirm } from '../utils' import { findPartialMatch } from '../../utils/partial-match' import { createOpencodeClient } from '@opencode-ai/sdk/v2' @@ -194,6 +195,7 @@ export async function run(argv: RestartArgs): Promise { const createResult = await client.session.create({ title: state.worktreeName, directory, + permission: LOOP_PERMISSION_RULESET, }) if (createResult.error || !createResult.data) { @@ -231,8 +233,8 @@ export async function run(argv: RestartArgs): Promise { ) let promptText = state.prompt ?? '' - if (state.completionPromise) { - promptText += `\n\n---\n\n**IMPORTANT - Completion Signal:** When you have completed ALL phases of this plan successfully, you MUST output the following phrase exactly: ${state.completionPromise}\n\nDo NOT output this phrase until every phase is truly complete. The loop will continue until this signal is detected.` + if (state.completionSignal) { + promptText += buildCompletionSignalInstructions(state.completionSignal) } try { diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index 49f6707..4e2c85b 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -223,8 +223,8 @@ export async function run(argv: StatusArgs): Promise { console.log(` Started: ${new Date(startedAt).toISOString()}`) const sessionStatus = await tryFetchSessionStatus(argv.server ?? 'http://localhost:5551', state.sessionId, state.worktreeDir!) console.log(` Status: ${sessionStatus}`) - if (state.completionPromise) { - console.log(` Completion: ${state.completionPromise}`) + if (state.completionSignal) { + console.log(` Completion: ${state.completionSignal}`) } if (state.lastAuditResult) { for (const line of formatAuditResult(state.lastAuditResult)) { diff --git a/src/cli/index.ts b/src/cli/index.ts index b856ae6..0a10ffc 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -58,7 +58,7 @@ const commands: Record = { }, }, upgrade: { - cli: async (args, globalOpts) => { + cli: async (_args, _globalOpts) => { const { run } = await import('./commands/upgrade') await run() }, diff --git a/src/config.ts b/src/config.ts index f222ba9..1b3037f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ import type { AgentRole, AgentDefinition, AgentConfig } from './agents' -import type { PluginConfig } from './types' + const REPLACED_BUILTIN_AGENTS = ['build', 'plan'] diff --git a/src/embedding/index.ts b/src/embedding/index.ts index 190ecff..dd5270b 100644 --- a/src/embedding/index.ts +++ b/src/embedding/index.ts @@ -2,7 +2,7 @@ import { createHash } from 'crypto' import type { EmbeddingProvider } from './types' import type { EmbeddingConfig } from '../types' import { ApiEmbeddingProvider } from './api' -import { LocalEmbeddingProvider } from './local' + import { SharedEmbeddingClient } from './client' import { resolveDataDir } from '../storage/database' import type { CacheService } from '../cache/types' diff --git a/src/hooks/loop.ts b/src/hooks/loop.ts index 359e069..73a4cdf 100644 --- a/src/hooks/loop.ts +++ b/src/hooks/loop.ts @@ -1,11 +1,12 @@ import type { PluginInput } from '@opencode-ai/plugin' import type { OpencodeClient } from '@opencode-ai/sdk/v2' import type { LoopService, LoopState } from '../services/loop' -import { MAX_RETRIES, MAX_CONSECUTIVE_STALLS } from '../services/loop' -import type { Logger, PluginConfig, LoopConfig } from '../types' -import { parseModelString, retryWithModelFallback } from '../utils/model-fallback' +import { MAX_RETRIES, MAX_CONSECUTIVE_STALLS, LOOP_PERMISSION_RULESET } from '../services/loop' +import type { Logger, PluginConfig } from '../types' +import { retryWithModelFallback } from '../utils/model-fallback' +import { resolveLoopModel } from '../utils/loop-helpers' import { execSync, spawnSync } from 'child_process' -import { resolve, join } from 'path' +import { resolve } from 'path' import type { createSandboxManager } from '../sandbox/manager' export interface LoopEventHandler { @@ -19,7 +20,7 @@ export interface LoopEventHandler { export function createLoopEventHandler( loopService: LoopService, - client: PluginInput['client'], + _client: PluginInput['client'], v2Client: OpencodeClient, logger: Logger, getConfig: () => PluginConfig, @@ -54,15 +55,6 @@ export function createLoopEventHandler( let cleaned = false try { - // Remove the opencode.jsonc file we wrote for permissions - don't commit it - try { - const { unlinkSync } = await import('fs') - unlinkSync(join(state.worktreeDir, 'opencode.jsonc')) - logger.log(`Loop: removed opencode.jsonc before commit`) - } catch { - // File may not exist, ignore - } - const addResult = spawnSync('git', ['add', '-A'], { cwd: state.worktreeDir, encoding: 'utf-8' }) if (addResult.status !== 0) { throw new Error(addResult.stderr || 'git add failed') @@ -260,9 +252,8 @@ export function createLoopEventHandler( }) } - let commitResult: { committed: boolean; cleaned: boolean } | undefined if (reason === 'completed' || reason === 'cancelled') { - commitResult = await commitAndCleanupWorktree(state) + await commitAndCleanupWorktree(state) } if (state.sandbox && state.sandboxContainerName && sandboxManager) { @@ -275,8 +266,7 @@ export function createLoopEventHandler( } } - async function handlePromptError(worktreeName: string, state: LoopState, context: string, err: unknown, retryFn?: () => Promise): Promise { - const sessionId = state.sessionId + async function handlePromptError(worktreeName: string, _state: LoopState, context: string, err: unknown, retryFn?: () => Promise): Promise { const currentState = loopService.getActiveState(worktreeName) if (!currentState?.active) { logger.log(`Loop: loop ${worktreeName} already terminated, ignoring error: ${context}`) @@ -310,7 +300,7 @@ export function createLoopEventHandler( } } - async function getLastAssistantInfo(sessionId: string, worktreeDir: string): Promise<{ text: string | null; error: string | null }> { + async function getLastAssistantInfo(sessionId: string, worktreeDir: string): Promise<{ text: string | null; error: string | null; lastMessageRole: string }> { try { const messagesResult = await v2Client.session.messages({ sessionID: sessionId, @@ -323,9 +313,14 @@ export function createLoopEventHandler( parts: Array<{ type: string; text?: string }> }> + const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null const lastAssistant = [...messages].reverse().find((m) => m.info.role === 'assistant') - if (!lastAssistant) return { text: null, error: null } + if (!lastAssistant) { + const role = lastMessage?.info.role ?? 'none' + logger.log(`Loop: no assistant message found in session ${sessionId}, last message role: ${role}`) + return { text: null, error: null, lastMessageRole: role } + } const text = lastAssistant.parts .filter((p) => p.type === 'text' && typeof p.text === 'string') @@ -334,10 +329,10 @@ export function createLoopEventHandler( const error = lastAssistant.info.error?.data?.message ?? lastAssistant.info.error?.name ?? null - return { text, error } + return { text, error, lastMessageRole: 'assistant' } } catch (err) { logger.error(`Loop: could not read session messages`, err) - return { text: null, error: null } + return { text: null, error: null, lastMessageRole: 'error' } } } @@ -347,6 +342,7 @@ export function createLoopEventHandler( const createParams = { title: state.worktreeName, directory: state.worktreeDir, + permission: LOOP_PERMISSION_RULESET, } const createResult = await v2Client.session.create(createParams) @@ -384,7 +380,7 @@ export function createLoopEventHandler( return newSessionId } - async function handleCodingPhase(worktreeName: string, state: LoopState): Promise { + async function handleCodingPhase(worktreeName: string, _state: LoopState): Promise { let currentState = loopService.getActiveState(worktreeName) if (!currentState?.active) { logger.log(`Loop: loop ${worktreeName} no longer active, skipping coding phase`) @@ -398,8 +394,12 @@ export function createLoopEventHandler( } let assistantErrorDetected = false - if (currentState.completionPromise) { - const { text: textContent, error: assistantError } = await getLastAssistantInfo(currentState.sessionId, currentState.worktreeDir) + if (currentState.completionSignal) { + const { text: textContent, error: assistantError, lastMessageRole } = await getLastAssistantInfo(currentState.sessionId, currentState.worktreeDir) + if (lastMessageRole !== 'assistant') { + logger.error(`Loop: assistant message not found in coding phase (last message: ${lastMessageRole}), session may not have responded yet`) + return + } if (assistantError) { assistantErrorDetected = true logger.error(`Loop: assistant error detected in coding phase: ${assistantError}`) @@ -415,14 +415,14 @@ export function createLoopEventHandler( currentState = loopService.getActiveState(worktreeName)! } } - if (textContent && currentState.completionPromise && loopService.checkCompletionPromise(textContent, currentState.completionPromise)) { + if (textContent && currentState.completionSignal && loopService.checkCompletionSignal(textContent, currentState.completionSignal)) { const currentAuditCount = currentState.auditCount ?? 0 if (!currentState.audit || currentAuditCount >= minAudits) { if (loopService.hasOutstandingFindings(currentState.worktreeBranch)) { logger.log(`Loop: completion promise detected but outstanding review findings remain, continuing`) } else { await terminateLoop(worktreeName, currentState, 'completed') - logger.log(`Loop completed: detected ${currentState.completionPromise} at iteration ${currentState.iteration} (${currentAuditCount}/${minAudits} audits)`) + logger.log(`Loop completed: detected ${currentState.completionSignal} at iteration ${currentState.iteration} (${currentAuditCount}/${minAudits} audits)`) return } } else { @@ -498,12 +498,8 @@ export function createLoopEventHandler( logger.log(`Loop iteration ${nextIteration} for session ${activeSessionId}`) const currentConfig = getConfig() - const freshStateForModel = loopService.getActiveState(worktreeName) - const loopModel = freshStateForModel?.modelFailed - ? undefined - : (parseModelString(currentConfig.loop?.model) ?? parseModelString(currentConfig.executionModel)) - - if (freshStateForModel?.modelFailed) { + const loopModel = resolveLoopModel(currentConfig, loopService, worktreeName) + if (!loopModel) { logger.log(`Loop: configured model previously failed, using default model`) } @@ -562,7 +558,7 @@ export function createLoopEventHandler( consecutiveStalls.set(worktreeName, 0) } - async function handleAuditingPhase(worktreeName: string, state: LoopState): Promise { + async function handleAuditingPhase(worktreeName: string, _state: LoopState): Promise { // Re-fetch and validate state to catch aborts that happened during idle event processing let currentState = loopService.getActiveState(worktreeName) if (!currentState?.active) { @@ -576,7 +572,12 @@ export function createLoopEventHandler( return } - const { text: auditText, error: assistantError } = await getLastAssistantInfo(currentState.sessionId, currentState.worktreeDir) + const { text: auditText, error: assistantError, lastMessageRole } = await getLastAssistantInfo(currentState.sessionId, currentState.worktreeDir) + + if (lastMessageRole !== 'assistant') { + logger.error(`Loop: assistant message not found in auditing phase (last message: ${lastMessageRole}), session may not have responded yet`) + return + } let assistantErrorDetected = false if (assistantError) { @@ -608,14 +609,14 @@ export function createLoopEventHandler( // Always pass the full audit response to the code agent const auditFindings = auditText ?? undefined - if (currentState.completionPromise && auditText) { - if (loopService.checkCompletionPromise(auditText, currentState.completionPromise)) { + if (currentState.completionSignal && auditText) { + if (loopService.checkCompletionSignal(auditText, currentState.completionSignal)) { if (!currentState.audit || newAuditCount >= minAudits) { if (loopService.hasOutstandingFindings(currentState.worktreeBranch)) { logger.log(`Loop: completion promise detected but outstanding review findings remain, continuing`) } else { await terminateLoop(worktreeName, currentState, 'completed') - logger.log(`Loop completed: detected ${currentState.completionPromise} in audit at iteration ${currentState.iteration} (${newAuditCount}/${minAudits} audits)`) + logger.log(`Loop completed: detected ${currentState.completionSignal} in audit at iteration ${currentState.iteration} (${newAuditCount}/${minAudits} audits)`) return } } else { @@ -654,12 +655,8 @@ export function createLoopEventHandler( logger.log(`Loop iteration ${nextIteration} for session ${activeSessionId}`) const currentConfig = getConfig() - const freshStateForModel = loopService.getActiveState(worktreeName) - const loopModel = freshStateForModel?.modelFailed - ? undefined - : (parseModelString(currentConfig.loop?.model) ?? parseModelString(currentConfig.executionModel)) - - if (freshStateForModel?.modelFailed) { + const loopModel = resolveLoopModel(currentConfig, loopService, worktreeName) + if (!loopModel) { logger.log(`Loop: configured model previously failed, using default model`) } diff --git a/src/hooks/sandbox-tools.ts b/src/hooks/sandbox-tools.ts index 436444b..4cd4a05 100644 --- a/src/hooks/sandbox-tools.ts +++ b/src/hooks/sandbox-tools.ts @@ -3,6 +3,7 @@ import type { Logger } from '../types' import type { createLoopService } from '../services/loop' import type { createSandboxManager } from '../sandbox/manager' import { toContainerPath, rewriteOutput } from '../sandbox/path' +import { getSandboxForSession } from '../sandbox/context' interface SandboxToolHookDeps { loopService: ReturnType @@ -10,89 +11,91 @@ interface SandboxToolHookDeps { logger: Logger } -const pendingResults = new Map() +const pendingResults = new Map() -function getSandboxContext(deps: SandboxToolHookDeps, sessionId: string) { - if (!deps.sandboxManager) return null - - const worktreeName = deps.loopService.resolveWorktreeName(sessionId) - if (!worktreeName) return null - - const state = deps.loopService.getActiveState(worktreeName) - if (!state?.active || !state.sandbox) return null - - const active = deps.sandboxManager.getActive(worktreeName) - if (!active) return null - - return { - docker: deps.sandboxManager.docker, - containerName: active.containerName, - hostDir: active.projectDir, - } -} +const BASH_DEFAULT_TIMEOUT_MS = 120_000 +const STALE_THRESHOLD_MS = 5 * 60 * 1000 export function createSandboxToolBeforeHook(deps: SandboxToolHookDeps): Hooks['tool.execute.before'] { return async ( input: { tool: string; sessionID: string; callID: string }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches upstream Hooks type output: { args: any }, ) => { if (input.tool !== 'bash') return - const sandbox = getSandboxContext(deps, input.sessionID) + const sandbox = getSandboxForSession(deps, input.sessionID) if (!sandbox) return const { docker, containerName, hostDir } = sandbox const args = output.args - const cwd = args.workdir ? toContainerPath(args.workdir, hostDir) : undefined + + output.args = { ...args, command: 'true' } const cmd = (args.command ?? '').trimStart() - if (cmd === 'git' || cmd.startsWith('git ')) { - pendingResults.set(input.callID, 'Git is not available in sandbox mode. The worktree is managed by the loop system on the host.') - output.args = { ...args, command: 'true' } + if (cmd === 'git push' || cmd.startsWith('git push ')) { + pendingResults.set(input.callID, { result: 'Git push is not available in sandbox mode. Pushes must be run on the host.', storedAt: Date.now() }) return } deps.logger.log(`[sandbox-hook] intercepting bash: ${args.command?.slice(0, 100)}`) + const hookTimeout = (args.timeout ?? BASH_DEFAULT_TIMEOUT_MS) + 10_000 + const cwd = args.workdir ? toContainerPath(args.workdir, hostDir) : undefined + try { - const result = await docker.exec(containerName, args.command, { + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error(`sandbox hook timeout after ${hookTimeout}ms`)), hookTimeout), + ) + + const execPromise = docker.exec(containerName, args.command, { timeout: args.timeout, cwd, }) + const result = await Promise.race([execPromise, timeoutPromise]) + let dockerOutput = rewriteOutput(result.stdout, hostDir) if (result.stderr && result.exitCode !== 0) { dockerOutput += rewriteOutput(result.stderr, hostDir) } if (result.exitCode === 124) { - const timeoutMs = args.timeout ?? 120000 + const timeoutMs = args.timeout ?? BASH_DEFAULT_TIMEOUT_MS dockerOutput += `\n\n\nbash tool terminated command after exceeding timeout ${timeoutMs} ms\n` } else if (result.exitCode !== 0) { dockerOutput += `\n\n[Exit code: ${result.exitCode}]` } - pendingResults.set(input.callID, dockerOutput.trim()) + pendingResults.set(input.callID, { result: dockerOutput.trim(), storedAt: Date.now() }) } catch (err) { const message = err instanceof Error ? err.message : String(err) - pendingResults.set(input.callID, `Command failed: ${message}`) + deps.logger.log(`[sandbox-hook] exec failed for callID ${input.callID}: ${message}`) + pendingResults.set(input.callID, { result: `Command failed: ${message}`, storedAt: Date.now() }) } - - output.args = { ...args, command: 'true' } } } export function createSandboxToolAfterHook(deps: SandboxToolHookDeps): Hooks['tool.execute.after'] { return async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches upstream Hooks type input: { tool: string; sessionID: string; callID: string; args: any }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches upstream Hooks type output: { title: string; output: string; metadata: any }, ) => { if (input.tool !== 'bash') return - const dockerResult = pendingResults.get(input.callID) - if (dockerResult === undefined) return + const now = Date.now() + for (const [key, entry] of pendingResults) { + if (now - entry.storedAt > STALE_THRESHOLD_MS) { + pendingResults.delete(key) + } + } + + const entry = pendingResults.get(input.callID) + if (entry === undefined) return pendingResults.delete(input.callID) deps.logger.log(`[sandbox-hook] replacing bash output for callID ${input.callID}`) - output.output = dockerResult + output.output = entry.result } } diff --git a/src/index.ts b/src/index.ts index a3f7e6b..7841175 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ import type { Plugin, PluginInput, Hooks } from '@opencode-ai/plugin' -import { tool } from '@opencode-ai/plugin' import { createOpencodeClient as createV2Client } from '@opencode-ai/sdk/v2' import { agents } from './agents' import { createConfigHandler } from './config' @@ -16,11 +15,10 @@ import { resolveLogPath } from './storage' import { createLogger } from './utils/logger' import { createDockerService } from './sandbox/docker' import { createSandboxManager } from './sandbox/manager' -import { join } from 'path' import type { PluginConfig, CompactionConfig } from './types' -import { createTools, createToolExecuteBeforeHook, createToolExecuteAfterHook, autoValidateOnLoad, scopeEnum } from './tools' +import { createTools, createToolExecuteBeforeHook, createToolExecuteAfterHook, autoValidateOnLoad } from './tools' import { createSandboxToolBeforeHook, createSandboxToolAfterHook } from './hooks/sandbox-tools' -import type { DimensionMismatchState, InitState, ToolContext } from './tools' +import type { DimensionMismatchState, ToolContext } from './tools' import type { VecService } from './storage/vec-types' @@ -77,7 +75,11 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { const kvService = createKvService(db, logger, config.defaultKvTtlMs) const loopService = createLoopService(kvService, projectId, logger, config.loop) - migrateRalphKeys(kvService, projectId, logger).catch(() => {}) + try { + migrateRalphKeys(kvService, projectId, logger) + } catch (err) { + logger.error('Failed to migrate ralph: KV entries', err) + } const activeSandboxLoops = loopService.listActive().filter(s => s.sandbox && s.worktreeName) @@ -91,7 +93,7 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { const dockerService = createDockerService(logger) try { sandboxManager = createSandboxManager(dockerService, { - image: config.sandbox.image || 'ocm-sandbox:latest', + image: config.sandbox?.image || 'ocm-sandbox:latest', }, logger) logger.log('Docker sandbox manager initialized') } catch (err) { @@ -201,15 +203,6 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { } } } - const sandboxAny = sandboxManager as any - if (sandboxAny.isGlobalActive?.()) { - try { - await sandboxAny.stopGlobal?.() - logger.log('Cleanup: stopped global sandbox container') - } catch (err) { - logger.error('Cleanup: failed to stop global sandbox container', err) - } - } } loopHandler.terminateAll() @@ -303,18 +296,19 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { await toolExecuteAfterHook!(input, output) }, 'permission.ask': async (input, output) => { - const req = input as unknown as { sessionID: string; permission: string; patterns: string[] } - const worktreeName = loopService.resolveWorktreeName(req.sessionID) + const worktreeName = loopService.resolveWorktreeName(input.sessionID) const state = worktreeName ? loopService.getActiveState(worktreeName) : null if (!state?.active) return - if (req.patterns.some((p) => p.startsWith('git push'))) { - logger.log(`Loop: denied git push for session ${req.sessionID}`) + const patterns = Array.isArray(input.pattern) ? input.pattern : (input.pattern ? [input.pattern] : []) + + if (patterns.some((p) => p.startsWith('git push'))) { + logger.log(`Loop: denied git push for session ${input.sessionID}`) output.status = 'deny' return } - logger.log(`Loop: auto-allowing ${req.permission} [${req.patterns.join(', ')}] for session ${req.sessionID}`) + logger.log(`Loop: auto-allowing ${input.type} [${patterns.join(', ')}] for session ${input.sessionID}`) output.status = 'allow' }, 'experimental.session.compacting': async (input, output) => { diff --git a/src/sandbox/context.ts b/src/sandbox/context.ts new file mode 100644 index 0000000..31c1eb8 --- /dev/null +++ b/src/sandbox/context.ts @@ -0,0 +1,28 @@ +import type { DockerService } from './docker' +import type { PluginConfig } from '../types' + +export interface SandboxContext { + docker: DockerService + containerName: string + hostDir: string +} + +interface SandboxDeps { + sandboxManager: { docker: DockerService; getActive(name: string): { containerName: string; projectDir: string } | null } | null + loopService: { resolveWorktreeName(sessionId: string): string | null; getActiveState(name: string): { active: boolean; sandbox?: boolean } | null } +} + +export function getSandboxForSession(deps: SandboxDeps, sessionId: string): SandboxContext | null { + if (!deps.sandboxManager) return null + const worktreeName = deps.loopService.resolveWorktreeName(sessionId) + if (!worktreeName) return null + const state = deps.loopService.getActiveState(worktreeName) + if (!state?.active || !state.sandbox) return null + const active = deps.sandboxManager.getActive(worktreeName) + if (!active) return null + return { docker: deps.sandboxManager.docker, containerName: active.containerName, hostDir: active.projectDir } +} + +export function isSandboxEnabled(config: PluginConfig, sandboxManager: unknown): boolean { + return config.sandbox?.mode === 'docker' && !!sandboxManager +} diff --git a/src/sandbox/docker.ts b/src/sandbox/docker.ts index f6ccc78..c1be9d9 100644 --- a/src/sandbox/docker.ts +++ b/src/sandbox/docker.ts @@ -1,10 +1,11 @@ -import { spawn, spawnSync } from 'child_process' +import { spawn, type ChildProcess } from 'child_process' import type { Logger } from '../types' export interface DockerExecOpts { timeout?: number cwd?: string abort?: AbortSignal + stdin?: string } export interface DockerExecResult { @@ -17,7 +18,7 @@ export interface DockerService { checkDocker(): Promise imageExists(image: string): Promise buildImage(dockerfilePath: string, tag: string): Promise - createContainer(name: string, projectDir: string, image: string): Promise + createContainer(name: string, projectDir: string, image: string, extraMounts?: string[]): Promise removeContainer(name: string): Promise exec(name: string, command: string, opts?: DockerExecOpts): Promise execPipe(name: string, command: string, stdin: string, opts?: { timeout?: number; abort?: AbortSignal }): Promise @@ -74,7 +75,7 @@ export function createDockerService(logger: Logger): DockerService { }) } - async function createContainer(name: string, projectDir: string, image: string): Promise { + async function createContainer(name: string, projectDir: string, image: string, extraMounts?: string[]): Promise { const args = [ 'run', '-d', @@ -82,13 +83,16 @@ export function createDockerService(logger: Logger): DockerService { name, '-v', `${projectDir}:/workspace`, - '-w', - '/workspace', - image, - 'sleep', - 'infinity', ] + if (extraMounts) { + for (const mount of extraMounts) { + args.push('-v', mount) + } + } + + args.push('-w', '/workspace', image, 'sleep', 'infinity') + const result = await execPromise('docker', args, { timeout: 30000 }) if (result.exitCode !== 0) { throw new Error(`Failed to create container: ${result.stderr}`) @@ -129,62 +133,10 @@ export function createDockerService(logger: Logger): DockerService { stdin: string, opts?: { timeout?: number; abort?: AbortSignal }, ): Promise { - return new Promise((resolve, reject) => { - const timeout = opts?.timeout ?? DEFAULT_TIMEOUT - const child = spawn('docker', ['exec', '-i', name, 'sh', '-c', command], { - stdio: ['pipe', 'pipe', 'pipe'], - }) - - let stdout = '' - let stderr = '' - let timedOut = false - - const timeoutId = setTimeout(() => { - timedOut = true - child.kill('SIGTERM') - setTimeout(() => { - if (child.exitCode === null) { - child.kill('SIGKILL') - } - }, 5000) - }, timeout) - - if (opts?.abort) { - opts.abort.addEventListener('abort', () => { - clearTimeout(timeoutId) - child.kill('SIGTERM') - setTimeout(() => { - if (child.exitCode === null) { - child.kill('SIGKILL') - } - }, 5000) - }) - } - - child.stdout.on('data', (data) => { - stdout += data.toString() - }) - - child.stderr.on('data', (data) => { - stderr += data.toString() - }) - - child.stdin.write(stdin) - child.stdin.end() - - child.on('close', (code) => { - clearTimeout(timeoutId) - resolve({ - stdout, - stderr, - exitCode: timedOut ? 124 : (code ?? 1), - }) - }) - - child.on('error', (err) => { - clearTimeout(timeoutId) - reject(err) - }) + return execPromise('docker', ['exec', '-i', name, 'sh', '-c', command], { + timeout: opts?.timeout ?? DEFAULT_TIMEOUT, + stdin, + abort: opts?.abort, }) } @@ -212,25 +164,38 @@ export function createDockerService(logger: Logger): DockerService { function execPromise( command: string, args: string[], - options?: { timeout?: number; streaming?: boolean; abort?: AbortSignal }, + options?: { timeout?: number; streaming?: boolean; abort?: AbortSignal; stdin?: string }, ): Promise { - return new Promise((resolve, reject) => { - const timeout = options?.timeout ?? DEFAULT_TIMEOUT - const child = spawn(command, args, { - stdio: ['ignore', 'pipe', 'pipe'], + const timeout = options?.timeout ?? DEFAULT_TIMEOUT + const cmdPreview = args.slice(-1)[0]?.slice(0, 80) ?? '' + + let hardDeadlineId: ReturnType | undefined + + const inner = new Promise((resolve) => { + const stdioConfig: 'pipe' | 'ignore' = options?.stdin ? 'pipe' : 'ignore' + const child: ChildProcess = spawn(command, args, { + stdio: [stdioConfig, 'pipe', 'pipe'], }) let stdout = '' let stderr = '' let timedOut = false - const cmdPreview = args.slice(-1)[0]?.slice(0, 80) ?? '' + let settled = false + + function settle(result: DockerExecResult): void { + if (settled) return + settled = true + clearTimeout(timeoutId) + clearTimeout(hardDeadlineId) + resolve(result) + } const timeoutId = setTimeout(() => { timedOut = true logger.log(`[docker] timeout (${timeout}ms) for: ${cmdPreview}`) child.kill('SIGTERM') setTimeout(() => { - if (child.exitCode === null) { + if (!settled) { logger.log(`[docker] SIGKILL after SIGTERM for: ${cmdPreview}`) child.kill('SIGKILL') } @@ -239,14 +204,11 @@ export function createDockerService(logger: Logger): DockerService { if (options?.abort) { const onAbort = () => { - clearTimeout(timeoutId) logger.log(`[docker] abort signal for: ${cmdPreview}`) child.kill('SIGTERM') setTimeout(() => { - if (child.exitCode === null) { - child.kill('SIGKILL') - } - }, 3000) + if (!settled) child.kill('SIGKILL') + }, 5000) } if (options.abort.aborted) { onAbort() @@ -255,32 +217,49 @@ export function createDockerService(logger: Logger): DockerService { } } - child.stdout.on('data', (data) => { + child.stdout!.on('data', (data: Buffer) => { stdout += data.toString() }) - child.stderr.on('data', (data) => { + child.stderr!.on('data', (data: Buffer) => { stderr += data.toString() }) - child.on('close', (code) => { - clearTimeout(timeoutId) + if (options?.stdin) { + child.stdin!.write(options.stdin) + child.stdin!.end() + } + + child.on('close', (code: number | null) => { if (timedOut) { logger.log(`[docker] close after timeout, code=${code} for: ${cmdPreview}`) } - resolve({ + settle({ stdout, stderr, exitCode: timedOut ? 124 : (code ?? 1), }) }) - child.on('error', (err) => { - clearTimeout(timeoutId) + child.on('error', (err: Error) => { logger.log(`[docker] spawn error: ${err.message} for: ${cmdPreview}`) - reject(err) + settle({ + stdout, + stderr: stderr + err.message, + exitCode: 1, + }) }) }) + + const hardDeadline = timeout + 10_000 + const deadlinePromise = new Promise((resolve) => { + hardDeadlineId = setTimeout(() => { + logger.log(`[docker] hard deadline (${hardDeadline}ms) hit for: ${cmdPreview}`) + resolve({ stdout: '', stderr: `Command exceeded hard deadline of ${hardDeadline}ms`, exitCode: 124 }) + }, hardDeadline) + }) + + return Promise.race([inner, deadlinePromise]) } return { diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index 61fb4d8..21abfa7 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -1,6 +1,7 @@ import type { DockerService } from './docker' import type { Logger } from '../types' import { resolve } from 'path' +import { spawnSync } from 'child_process' export interface SandboxManagerConfig { image: string @@ -12,9 +13,9 @@ export interface ActiveSandbox { startedAt: string } -interface SandboxManager { +export interface SandboxManager { docker: DockerService - start(worktreeName: string, projectDir: string): Promise<{ containerName: string }> + start(worktreeName: string, projectDir: string, startedAt?: string): Promise<{ containerName: string }> stop(worktreeName: string): Promise getActive(worktreeName: string): ActiveSandbox | null isActive(worktreeName: string): boolean @@ -22,14 +23,34 @@ interface SandboxManager { restore(worktreeName: string, projectDir: string, startedAt: string): Promise } -const activeSandboxes = new Map() - export function createSandboxManager( docker: DockerService, config: SandboxManagerConfig, logger: Logger, ): SandboxManager { - async function start(worktreeName: string, projectDir: string): Promise<{ containerName: string }> { + const activeSandboxes = new Map() + + function detectGitMount(projectDir: string): string[] { + try { + const result = spawnSync('git', ['rev-parse', '--git-common-dir'], { + cwd: projectDir, + encoding: 'utf-8', + }) + if (result.status !== 0 || !result.stdout) return [] + + const gitCommonDir = resolve(projectDir, result.stdout.trim()) + + // If the git dir is already inside the project dir being mounted, no extra mount needed + if (gitCommonDir.startsWith(projectDir + '/')) return [] + + return [`${gitCommonDir}:${gitCommonDir}:ro`] + } catch { + logger.log(`[sandbox] could not detect git common dir for ${projectDir}, skipping extra mount`) + return [] + } + } + + async function start(worktreeName: string, projectDir: string, startedAt?: string): Promise<{ containerName: string }> { const dockerAvailable = await docker.checkDocker() if (!dockerAvailable) { throw new Error('Docker is not available. Please ensure Docker is running.') @@ -52,13 +73,17 @@ export function createSandboxManager( } const absoluteProjectDir = resolve(projectDir) + const extraMounts = detectGitMount(absoluteProjectDir) + if (extraMounts.length > 0) { + logger.log(`Sandbox: mounting git common dir: ${extraMounts[0]}`) + } logger.log(`Creating sandbox container ${containerName} for ${absoluteProjectDir}`) - await docker.createContainer(containerName, absoluteProjectDir, config.image) + await docker.createContainer(containerName, absoluteProjectDir, config.image, extraMounts) const active: ActiveSandbox = { containerName, projectDir: absoluteProjectDir, - startedAt: new Date().toISOString(), + startedAt: startedAt ?? new Date().toISOString(), } activeSandboxes.set(worktreeName, active) @@ -128,42 +153,12 @@ export function createSandboxManager( async function restore(worktreeName: string, projectDir: string, startedAt: string): Promise { const containerName = docker.containerName(worktreeName) const running = await docker.isRunning(containerName) - if (running) { logger.log(`Sandbox container ${containerName} already running, repopulating map`) - const active: ActiveSandbox = { - containerName, - projectDir: resolve(projectDir), - startedAt, - } - activeSandboxes.set(worktreeName, active) + activeSandboxes.set(worktreeName, { containerName, projectDir: resolve(projectDir), startedAt }) } else { logger.log(`Sandbox container ${containerName} not running, starting new container`) - const dockerAvailable = await docker.checkDocker() - if (!dockerAvailable) { - throw new Error('Docker is not available. Please ensure Docker is running.') - } - - const imageExists = await docker.imageExists(config.image) - if (!imageExists) { - throw new Error( - `Docker image "${config.image}" not found. Build it first:\n` + - ` docker build -t ${config.image} container/` - ) - } - - const absoluteProjectDir = resolve(projectDir) - logger.log(`Creating sandbox container ${containerName} for ${absoluteProjectDir}`) - await docker.createContainer(containerName, absoluteProjectDir, config.image) - - const active: ActiveSandbox = { - containerName, - projectDir: absoluteProjectDir, - startedAt, - } - - activeSandboxes.set(worktreeName, active) - logger.log(`Sandbox container ${containerName} started`) + await start(worktreeName, projectDir, startedAt) } } diff --git a/src/services/kv.ts b/src/services/kv.ts index d8467b9..36b4920 100644 --- a/src/services/kv.ts +++ b/src/services/kv.ts @@ -19,7 +19,7 @@ export interface KvService { listByPrefix(projectId: string, prefix: string): KvEntry[] } -export function createKvService(db: Database, logger?: Logger, defaultTtlMs?: number): KvService { +export function createKvService(db: Database, _logger?: Logger, defaultTtlMs?: number): KvService { const queries = createKvQuery(db) return { diff --git a/src/services/loop.ts b/src/services/loop.ts index dae3089..a7887e8 100644 --- a/src/services/loop.ts +++ b/src/services/loop.ts @@ -3,31 +3,31 @@ import type { Logger, LoopConfig } from '../types' import type { OpencodeClient } from '@opencode-ai/sdk/v2' import { findPartialMatch } from '../utils/partial-match' -export async function migrateRalphKeys(kvService: KvService, projectId: string, logger: Logger): Promise { - const oldEntries = await kvService.listByPrefix(projectId, 'ralph:') +export function migrateRalphKeys(kvService: KvService, projectId: string, logger: Logger): void { + const oldEntries = kvService.listByPrefix(projectId, 'ralph:') if (oldEntries.length === 0) return - logger.log(`Migrating ${oldEntries.length} ralph: KV entries to loop: prefix`) + logger.log(`Migrating ${String(oldEntries.length)} ralph: KV entries to loop: prefix`) for (const entry of oldEntries) { const newKey = entry.key.replace(/^ralph:/, 'loop:') - const data = typeof entry.data === 'string' ? JSON.parse(entry.data) : entry.data + const data = (typeof entry.data === 'string' ? JSON.parse(entry.data) : entry.data) as Record if ('inPlace' in data) { - data.worktree = !data.inPlace + data.worktree = !(data.inPlace as boolean) delete data.inPlace } - await kvService.set(projectId, newKey, data) - await kvService.delete(projectId, entry.key) + kvService.set(projectId, newKey, data) + kvService.delete(projectId, entry.key) } - const oldSessions = await kvService.listByPrefix(projectId, 'ralph-session:') + const oldSessions = kvService.listByPrefix(projectId, 'ralph-session:') for (const entry of oldSessions) { const newKey = entry.key.replace(/^ralph-session:/, 'loop-session:') - await kvService.set(projectId, newKey, entry.data) - await kvService.delete(projectId, entry.key) + kvService.set(projectId, newKey, entry.data) + kvService.delete(projectId, entry.key) } if (oldSessions.length > 0) { - logger.log(`Migrated ${oldSessions.length} ralph-session: KV entries to loop-session: prefix`) + logger.log(`Migrated ${String(oldSessions.length)} ralph-session: KV entries to loop-session: prefix`) } } @@ -36,6 +36,17 @@ export const STALL_TIMEOUT_MS = 60_000 export const MAX_CONSECUTIVE_STALLS = 5 export const DEFAULT_MIN_AUDITS = 1 export const RECENT_MESSAGES_COUNT = 5 +export const DEFAULT_COMPLETION_SIGNAL = 'ALL_PHASES_COMPLETE' + +export function buildCompletionSignalInstructions(signal: string): string { + return `\n\n---\n\n**IMPORTANT - Completion Signal:** When you have completed ALL phases of this plan successfully, you MUST output the following phrase exactly: ${signal}\n\nBefore outputting the completion signal, you MUST:\n1. Verify each phase's acceptance criteria are met\n2. Run all verification commands listed in the plan and confirm they pass\n3. If tests were required, confirm they exist AND pass\n\nDo NOT output this phrase until every phase is truly complete and all verification steps pass. The loop will continue until this signal is detected.` +} + +export const LOOP_PERMISSION_RULESET = [ + { permission: '*', pattern: '*', action: 'allow' as const }, + { permission: 'external_directory', pattern: '*', action: 'deny' as const }, + { permission: 'bash', pattern: 'git push *', action: 'deny' as const }, +] export interface LoopState { active: boolean @@ -45,7 +56,7 @@ export interface LoopState { worktreeBranch?: string iteration: number maxIterations: number - completionPromise: string | null + completionSignal: string | null startedAt: string prompt?: string phase: 'coding' | 'auditing' @@ -69,7 +80,7 @@ export interface LoopService { registerSession(sessionId: string, worktreeName: string): void resolveWorktreeName(sessionId: string): string | null unregisterSession(sessionId: string): void - checkCompletionPromise(text: string, promise: string): boolean + checkCompletionSignal(text: string, promise: string): boolean buildContinuationPrompt(state: LoopState, auditFindings?: string): string buildAuditPrompt(state: LoopState): string listActive(): LoopState[] @@ -98,7 +109,7 @@ export function createLoopService( function getActiveState(name: string): LoopState | null { const state = kvService.get(projectId, stateKey(name)) - if (!state || !state.active) { + if (!state?.active) { return null } return state @@ -124,27 +135,23 @@ export function createLoopService( kvService.delete(projectId, `loop-session:${sessionId}`) } - function checkCompletionPromise(text: string, promise: string): boolean { - return text.includes(promise) + function checkCompletionSignal(text: string, completionSignal: string): boolean { + return text.toLowerCase().includes(completionSignal.toLowerCase()) } + function redactCompletionSignal(text: string, promise: string): string { - let result = text - const inner = promise.replace(/<\/?promise>/g, '').trim() - if (inner) { - result = result.replaceAll(inner, '[SIGNAL_REDACTED]') - } - result = result.replaceAll(promise, '[SIGNAL_REDACTED]') - return result + const regex = new RegExp(promise.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi') + return text.replace(regex, '[SIGNAL_REDACTED]') } function buildContinuationPrompt(state: LoopState, auditFindings?: string): string { - let systemLine = `Loop iteration ${state.iteration ?? 0}` + let systemLine = `Loop iteration ${String(state.iteration)}` - if (state.completionPromise) { - systemLine += ` | To stop: output ${state.completionPromise} (ONLY after all verification commands pass AND all phase acceptance criteria are met)` - } else if ((state.maxIterations ?? 0) > 0) { - systemLine += ` / ${state.maxIterations}` + if (state.completionSignal) { + systemLine += ` | To stop: output ${state.completionSignal} (ONLY after all verification commands pass AND all phase acceptance criteria are met)` + } else if (state.maxIterations > 0) { + systemLine += ` / ${String(state.maxIterations)}` } else { systemLine += ` | No completion promise set - loop runs until cancelled` } @@ -152,10 +159,10 @@ export function createLoopService( let prompt = `[${systemLine}]\n\n${state.prompt ?? ''}` if (auditFindings) { - const cleanedFindings = state.completionPromise - ? redactCompletionSignal(auditFindings, state.completionPromise) + const cleanedFindings = state.completionSignal + ? redactCompletionSignal(auditFindings, state.completionSignal) : auditFindings - const completionInstruction = state.completionPromise + const completionInstruction = state.completionSignal ? '\n\nAfter fixing all issues, output the completion signal.' : '' prompt += `\n\n---\nThe code auditor reviewed your changes. You MUST address all bugs and convention violations below — do not dismiss findings as unrelated to the task. Fix them directly without creating a plan or asking for approval.\n\n${cleanedFindings}${completionInstruction}` @@ -164,20 +171,20 @@ export function createLoopService( const outstandingFindings = getOutstandingFindings(state.worktreeBranch) if (outstandingFindings.length > 0) { const findingKeys = outstandingFindings.map((f) => `- \`${f.key}\``).join('\n') - prompt += `\n\n---\n⚠️ Outstanding Review Findings (${outstandingFindings.length})\n\nThese review findings are blocking loop completion. Fix these issues so they pass the next audit review.\n\n${findingKeys}` + prompt += `\n\n---\n⚠️ Outstanding Review Findings (${String(outstandingFindings.length)})\n\nThese review findings are blocking loop completion. Fix these issues so they pass the next audit review.\n\n${findingKeys}` } return prompt } function buildAuditPrompt(state: LoopState): string { - const taskSummary = (state.prompt?.length ?? 0) > 200 - ? `${state.prompt?.substring(0, 197)}...` + const taskSummary = state.prompt && state.prompt.length > 200 + ? `${state.prompt.substring(0, 197)}...` : (state.prompt ?? '') const branchInfo = state.worktreeBranch ? ` (branch: ${state.worktreeBranch})` : '' return [ - `Post-iteration ${state.iteration ?? 0} code review${branchInfo}.`, + `Post-iteration ${String(state.iteration)} code review${branchInfo}.`, '', `Task context: ${taskSummary}`, '', @@ -194,15 +201,19 @@ export function createLoopService( function listActive(): LoopState[] { const entries = kvService.listByPrefix(projectId, 'loop:') return entries - .map((entry) => entry.data as LoopState) - .filter((state): state is LoopState => state !== null && state.active) + .map((entry) => entry.data) + .filter((data): data is LoopState => + data !== null && typeof data === 'object' && 'active' in data && (data as LoopState).active + ) } function listRecent(): LoopState[] { const entries = kvService.listByPrefix(projectId, 'loop:') return entries - .map((entry) => entry.data as LoopState) - .filter((state): state is LoopState => state !== null && !state.active) + .map((entry) => entry.data) + .filter((data): data is LoopState => + data !== null && typeof data === 'object' && 'active' in data && !(data as LoopState).active + ) } function findByWorktreeName(name: string): LoopState | null { @@ -242,7 +253,7 @@ export function createLoopService( } setState(state.worktreeName, updated) } - logger.log(`Loop: terminated ${active.length} active loop(s)`) + logger.log(`Loop: terminated ${String(active.length)} active loop(s)`) } function reconcileStale(): number { @@ -254,7 +265,7 @@ export function createLoopService( completedAt: new Date().toISOString(), terminationReason: 'shutdown', }) - logger.log(`Reconciled stale active loop: ${state.worktreeName} (was at iteration ${state.iteration})`) + logger.log(`Reconciled stale active loop: ${state.worktreeName} (was at iteration ${String(state.iteration)})`) } return active.length } @@ -264,7 +275,7 @@ export function createLoopService( if (!branch) return findings return findings.filter((f) => { const data = f.data as Record | null - return data && data.branch === branch + return data?.branch === branch }) } @@ -280,7 +291,7 @@ export function createLoopService( registerSession, resolveWorktreeName, unregisterSession, - checkCompletionPromise, + checkCompletionSignal, buildContinuationPrompt, buildAuditPrompt, listActive, @@ -297,7 +308,7 @@ export function createLoopService( } export interface LoopSessionOutput { - messages: Array<{ text: string; cost: number; tokens: { input: number; output: number; reasoning: number; cacheRead: number; cacheWrite: number } }> + messages: { text: string; cost: number; tokens: { input: number; output: number; reasoning: number; cacheRead: number; cacheWrite: number } }[] totalCost: number totalTokens: { input: number; output: number; reasoning: number; cacheRead: number; cacheWrite: number } fileChanges: { additions: number; deletions: number; files: number } | null @@ -320,18 +331,19 @@ export async function fetchSessionOutput( directory, }) - const messages = (messagesResult.data ?? []) as Array<{ + const messages = (messagesResult.data ?? []) as { info: { role: string; cost?: number; tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } } - parts: Array<{ type: string; text?: string }> - }> + parts: { type: string; text?: string }[] + }[] const assistantMessages = messages.filter((m) => m.info.role === 'assistant') const lastThree = assistantMessages.slice(-RECENT_MESSAGES_COUNT) const extractedMessages = lastThree.map((msg) => { const text = msg.parts - .filter((p) => p.type === 'text' && typeof p.text === 'string') - .map((p) => p.text as string) + .filter((p) => p.type === 'text' && p.text !== undefined) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .map((p) => p.text!) .join('\n') const cost = msg.info.cost ?? 0 const tokens = msg.info.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } @@ -342,8 +354,8 @@ export async function fetchSessionOutput( input: tokens.input, output: tokens.output, reasoning: tokens.reasoning, - cacheRead: tokens.cache?.read ?? 0, - cacheWrite: tokens.cache?.write ?? 0, + cacheRead: tokens.cache.read, + cacheWrite: tokens.cache.write, }, } }) @@ -359,11 +371,11 @@ export async function fetchSessionOutput( totalCost += msg.info.cost ?? 0 const tokens = msg.info.tokens if (tokens) { - totalInputTokens += tokens.input ?? 0 - totalOutputTokens += tokens.output ?? 0 - totalReasoningTokens += tokens.reasoning ?? 0 - totalCacheRead += tokens.cache?.read ?? 0 - totalCacheWrite += tokens.cache?.write ?? 0 + totalInputTokens += tokens.input + totalOutputTokens += tokens.output + totalReasoningTokens += tokens.reasoning + totalCacheRead += tokens.cache.read + totalCacheWrite += tokens.cache.write } } diff --git a/src/storage/vec-client.ts b/src/storage/vec-client.ts index cd7ba15..4e005bf 100644 --- a/src/storage/vec-client.ts +++ b/src/storage/vec-client.ts @@ -59,17 +59,7 @@ async function isWorkerRunning(pidPath: string, socketPath: string): Promise> { - const sandboxEnabled = ctx.config.sandbox?.mode === 'docker' && !!ctx.sandboxManager + const sandboxEnabled = isSandboxEnabled(ctx.config, ctx.sandboxManager) return { ...createMemoryTools(ctx), ...createKvTools(ctx), diff --git a/src/tools/loop.ts b/src/tools/loop.ts index d86d74d..d8ecd3e 100644 --- a/src/tools/loop.ts +++ b/src/tools/loop.ts @@ -1,24 +1,24 @@ import { tool } from '@opencode-ai/plugin' import { execSync, spawnSync } from 'child_process' -import { existsSync, writeFileSync } from 'fs' -import { join } from 'path' +import { existsSync } from 'fs' import { resolve } from 'path' import type { ToolContext } from './types' -import { withDimensionWarning } from './types' + import { parseModelString, retryWithModelFallback } from '../utils/model-fallback' import { slugify } from '../utils/logger' import { findPartialMatch } from '../utils/partial-match' import { formatSessionOutput, formatAuditResult } from '../utils/loop-format' -import { fetchSessionOutput, MAX_RETRIES, type LoopState, type LoopSessionOutput } from '../services/loop' +import { fetchSessionOutput, MAX_RETRIES, LOOP_PERMISSION_RULESET, buildCompletionSignalInstructions, DEFAULT_COMPLETION_SIGNAL, type LoopState, type LoopSessionOutput } from '../services/loop' +import { isSandboxEnabled } from '../sandbox/context' +import { formatDuration, computeElapsedSeconds } from '../utils/loop-helpers' const z = tool.schema -const DEFAULT_PLAN_COMPLETION_PROMISE = 'ALL_PHASES_COMPLETE' interface LoopSetupOptions { prompt: string sessionTitle: string worktreeName?: string - completionPromise: string | null + completionSignal: string | null maxIterations: number audit: boolean agent?: string @@ -31,7 +31,7 @@ async function setupLoop( ctx: ToolContext, options: LoopSetupOptions, ): Promise { - const { v2, directory, config, loopService, loopHandler, logger, sandboxManager } = ctx + const { v2, directory, config, loopService, logger, sandboxManager } = ctx const autoWorktreeName = options.worktreeName ?? `loop-${slugify(options.sessionTitle.replace(/^Loop:\s*/i, ''))}` const projectDir = directory const maxIter = options.maxIterations ?? config.loop?.defaultMaxIterations ?? 0 @@ -49,13 +49,14 @@ async function setupLoop( let currentBranch: string | undefined try { currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectDir, encoding: 'utf-8' }).trim() - } catch (err) { + } catch (_err) { logger.log(`loop: no git branch detected, running without branch info`) } const createResult = await v2.session.create({ title: options.sessionTitle, directory: projectDir, + permission: LOOP_PERMISSION_RULESET, }) if (createResult.error || !createResult.data) { @@ -85,6 +86,7 @@ async function setupLoop( const createResult = await v2.session.create({ title: options.sessionTitle, directory: worktreeInfo.directory, + permission: LOOP_PERMISSION_RULESET, }) if (createResult.error || !createResult.data) { @@ -105,23 +107,8 @@ async function setupLoop( } } - if (loopContext.worktree) { - try { - const loopConfig = JSON.stringify({ - permission: { - bash: { '*': 'allow', 'git push *': 'deny' }, - external_directory: { '*': 'deny' }, - }, - }, null, 2) - writeFileSync(join(loopContext.directory, 'opencode.jsonc'), loopConfig) - logger.log(`loop: wrote loop opencode.jsonc to ${loopContext.directory}`) - } catch (err) { - logger.error(`loop: failed to write opencode.jsonc`, err) - } - } - let sandboxContainerName: string | undefined - const sandboxEnabled = config.sandbox?.mode === 'docker' && !!sandboxManager && !!options.worktree + const sandboxEnabled = isSandboxEnabled(config, sandboxManager) && !!options.worktree if (sandboxEnabled) { try { @@ -143,7 +130,7 @@ async function setupLoop( worktreeBranch: loopContext.branch, iteration: 1, maxIterations: maxIter, - completionPromise: options.completionPromise, + completionSignal: options.completionSignal, startedAt: new Date().toISOString(), prompt: options.prompt, phase: 'coding', @@ -160,8 +147,8 @@ async function setupLoop( logger.log(`loop: state stored for worktree=${autoWorktreeName}`) let promptText = options.prompt - if (options.completionPromise) { - promptText += `\n\n---\n\n**IMPORTANT - Completion Signal:** When you have completed ALL phases of this plan successfully, you MUST output the following phrase exactly: ${options.completionPromise}\n\nBefore outputting the completion signal, you MUST:\n1. Verify each phase's acceptance criteria are met\n2. Run all verification commands listed in the plan and confirm they pass\n3. If tests were required, confirm they exist AND pass\n\nDo NOT output this phrase until every phase is truly complete and all verification steps pass. The loop will continue until this signal is detected.` + if (options.completionSignal) { + promptText += buildCompletionSignalInstructions(options.completionSignal) } const { result: promptResult, usedModel: actualModel } = await retryWithModelFallback( @@ -237,7 +224,7 @@ async function setupLoop( lines.push( `Model: ${modelInfo}`, `Max iterations: ${maxInfo}`, - `Completion promise: ${options.completionPromise ?? 'none'}`, + `Completion promise: ${options.completionSignal ?? 'none'}`, `Audit: ${auditInfo}`, '', 'The loop will automatically continue when the session goes idle.', @@ -249,7 +236,7 @@ async function setupLoop( } export function createLoopTools(ctx: ToolContext): Record> { - const { v2, loopService, loopHandler, config, directory, logger } = ctx + const { v2, loopService, loopHandler, config, logger } = ctx return { 'memory-loop': tool({ @@ -259,7 +246,7 @@ export function createLoopTools(ctx: ToolContext): Record { + execute: async (args, _context) => { if (config.loop?.enabled === false) { return 'Loops are disabled in plugin config. Use memory-plan-execute instead.' } @@ -273,7 +260,7 @@ export function createLoopTools(ctx: ToolContext): Record { - const duration = s.completedAt && s.startedAt - ? Math.round((new Date(s.completedAt).getTime() - new Date(s.startedAt).getTime()) / 1000) - : 0 - const minutes = Math.floor(duration / 60) - const seconds = duration % 60 - const durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + const durationStr = formatDuration(computeElapsedSeconds(s.startedAt, s.completedAt)) lines.push(`${i + 1}. ${s.worktreeName}`) lines.push(` Reason: ${s.terminationReason ?? 'unknown'} | Iterations: ${s.iteration} | Duration: ${durationStr} | Completed: ${s.completedAt ?? 'unknown'}`) lines.push('') @@ -514,7 +497,7 @@ export function createLoopTools(ctx: ToolContext): Record = {} + const statuses: Record = {} try { const uniqueDirs = [...new Set(active.map((s) => s.worktreeDir).filter(Boolean))] const results = await Promise.allSettled( @@ -530,10 +513,7 @@ export function createLoopTools(ctx: ToolContext): Record { - const elapsed = s.startedAt ? Math.round((Date.now() - new Date(s.startedAt).getTime()) / 1000) : 0 - const minutes = Math.floor(elapsed / 60) - const seconds = elapsed % 60 - const duration = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + const duration = formatDuration(computeElapsedSeconds(s.startedAt)) const iterInfo = s.maxIterations && s.maxIterations > 0 ? `${s.iteration} / ${s.maxIterations}` : `${s.iteration} (unlimited)` const sessionStatus = statuses[s.sessionId]?.type ?? 'unavailable' const modeIndicator = !s.worktree ? ' (in-place)' : '' @@ -550,12 +530,7 @@ export function createLoopTools(ctx: ToolContext): Record { - const duration = s.completedAt && s.startedAt - ? Math.round((new Date(s.completedAt).getTime() - new Date(s.startedAt).getTime()) / 1000) - : 0 - const minutes = Math.floor(duration / 60) - const seconds = duration % 60 - const durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + const durationStr = formatDuration(computeElapsedSeconds(s.startedAt, s.completedAt)) lines.push(`${i + 1}. ${s.worktreeName}`) lines.push(` Reason: ${s.terminationReason ?? 'unknown'} | Iterations: ${s.iteration} | Duration: ${durationStr} | Completed: ${s.completedAt ?? 'unknown'}`) lines.push('') @@ -581,12 +556,7 @@ export function createLoopTools(ctx: ToolContext): Record 0 ? `${state.iteration} / ${state.maxIterations}` : `${state.iteration} (unlimited)` - const duration = state.completedAt && state.startedAt - ? Math.round((new Date(state.completedAt).getTime() - new Date(state.startedAt).getTime()) / 1000) - : 0 - const minutes = Math.floor(duration / 60) - const seconds = duration % 60 - const durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + const durationStr = formatDuration(computeElapsedSeconds(state.startedAt, state.completedAt)) const statusLines: string[] = [ 'Loop Status (Inactive)', @@ -643,10 +613,7 @@ export function createLoopTools(ctx: ToolContext): Record 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + const duration = formatDuration(computeElapsedSeconds(state.startedAt)) const stallInfo = loopHandler.getStallInfo(state.worktreeName) const secondsSinceActivity = stallInfo @@ -696,7 +663,7 @@ export function createLoopTools(ctx: ToolContext): Record 0 ? [`Error count: ${state.errorCount} (retries before termination: ${MAX_RETRIES})`] : []), `Audit count: ${state.auditCount ?? 0}`, diff --git a/src/tools/plan-approval.ts b/src/tools/plan-approval.ts index 7fbbcef..c22fe60 100644 --- a/src/tools/plan-approval.ts +++ b/src/tools/plan-approval.ts @@ -47,7 +47,7 @@ export function createToolExecuteBeforeHook(ctx: ToolContext): Hooks['tool.execu return async ( input: { tool: string; sessionID: string; callID: string }, - output: { args: unknown } + _output: { args: unknown } ) => { const worktreeName = loopService.resolveWorktreeName(input.sessionID) const state = worktreeName ? loopService.getActiveState(worktreeName) : null diff --git a/src/tools/plan-execute.ts b/src/tools/plan-execute.ts index 4b1e8f3..e04c3ae 100644 --- a/src/tools/plan-execute.ts +++ b/src/tools/plan-execute.ts @@ -1,7 +1,6 @@ import { tool } from '@opencode-ai/plugin' import type { ToolContext } from './types' import { parseModelString, retryWithModelFallback } from '../utils/model-fallback' -import { stripPromiseTags } from '../utils/strip-promise-tags' const z = tool.schema @@ -52,10 +51,7 @@ export function createPlanExecuteTools(ctx: ToolContext): Record tags from plan text`) - } + const planText = args.plan const createResult = await v2.session.create({ title: sessionTitle, diff --git a/src/tools/sandbox-fs.ts b/src/tools/sandbox-fs.ts index 84805e2..5f5d5e5 100644 --- a/src/tools/sandbox-fs.ts +++ b/src/tools/sandbox-fs.ts @@ -1,28 +1,10 @@ import { tool } from '@opencode-ai/plugin' import type { ToolContext } from './types' import { toContainerPath, rewriteOutput } from '../sandbox/path' +import { getSandboxForSession } from '../sandbox/context' const z = tool.schema -function getSandboxForSession(ctx: ToolContext, sessionId: string) { - if (!ctx.sandboxManager) return null - - const worktreeName = ctx.loopService.resolveWorktreeName(sessionId) - if (!worktreeName) return null - - const state = ctx.loopService.getActiveState(worktreeName) - if (!state?.active || !state.sandbox) return null - - const active = ctx.sandboxManager.getActive(worktreeName) - if (!active) return null - - return { - docker: ctx.sandboxManager.docker, - containerName: active.containerName, - hostDir: active.projectDir, - } -} - export function createSandboxFsTools(ctx: ToolContext): Record> { return { glob: tool({ @@ -124,7 +106,6 @@ export function createSandboxFsTools(ctx: ToolContext): Record 0 ? `${minutes}m ${secs}s` : `${secs}s` +} + +export function computeElapsedSeconds(startedAt?: string, endedAt?: string): number { + if (!startedAt) return 0 + const start = new Date(startedAt).getTime() + const end = endedAt ? new Date(endedAt).getTime() : Date.now() + return Math.round((end - start) / 1000) +} diff --git a/src/utils/strip-promise-tags.ts b/src/utils/strip-promise-tags.ts deleted file mode 100644 index 2cc41b5..0000000 --- a/src/utils/strip-promise-tags.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function stripPromiseTags(text: string): { cleaned: string; stripped: boolean } { - let cleaned = text.replace(/\n*---\n\n\*\*IMPORTANT - Completion Signal:\*\*[\s\S]*?[\s\S]*?<\/promise>[\s\S]*?(?:until this signal is detected\.|$)/g, '') - cleaned = cleaned.replace(/[\s\S]*?<\/promise>/g, '') - cleaned = cleaned.trimEnd() - return { cleaned, stripped: cleaned !== text.trimEnd() } -} diff --git a/src/version.ts b/src/version.ts index 83bb812..f009d9f 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.0.28' +export const VERSION = '0.0.29' diff --git a/test/cli-cancel.test.ts b/test/cli-cancel.test.ts index d52e82b..868afda 100644 --- a/test/cli-cancel.test.ts +++ b/test/cli-cancel.test.ts @@ -3,26 +3,7 @@ import { Database } from 'bun:sqlite' import { existsSync } from 'fs' import { join } from 'path' import { mkdtempSync, rmSync } from 'fs' - -interface LoopState { - sessionId: string - worktreeName: string - worktreeBranch: string - worktreeDir: string - worktree: boolean - iteration: number - maxIterations: number - phase: 'coding' | 'auditing' - startedAt: string - completedAt?: string - terminationReason?: string - active: boolean - audit: boolean - errorCount: number - auditCount: number - completionPromise?: string - lastAuditResult?: string -} +import { type LoopState } from '../src/services/loop' function createTestKvDb(tempDir: string): Database { const dbPath = join(tempDir, 'memory.db') @@ -57,6 +38,8 @@ function insertLoopState(db: Database, projectId: string, worktreeName: string, audit: false, errorCount: 0, auditCount: 0, + completionSignal: null, + lastAuditResult: undefined, ...state, } diff --git a/test/cli-status.test.ts b/test/cli-status.test.ts index d6cf2ff..a2d3d8d 100644 --- a/test/cli-status.test.ts +++ b/test/cli-status.test.ts @@ -3,26 +3,7 @@ import { Database } from 'bun:sqlite' import { existsSync } from 'fs' import { join } from 'path' import { mkdtempSync, rmSync } from 'fs' - -interface LoopState { - sessionId: string - worktreeName: string - worktreeBranch: string - worktreeDir: string - worktree: boolean - iteration: number - maxIterations: number - phase: 'coding' | 'auditing' - startedAt: string - completedAt?: string - terminationReason?: string - active: boolean - audit: boolean - errorCount: number - auditCount: number - completionPromise?: string - lastAuditResult?: string -} +import { type LoopState } from '../src/services/loop' function createTestKvDb(tempDir: string): Database { const dbPath = join(tempDir, 'memory.db') @@ -57,6 +38,7 @@ function insertLoopState(db: Database, projectId: string, worktreeName: string, audit: false, errorCount: 0, auditCount: 0, + completionSignal: null, ...state, } @@ -127,6 +109,7 @@ describe('CLI Status - list-worktrees', () => { audit: false, errorCount: 0, auditCount: 0, + completionSignal: null, } db2.run( diff --git a/test/loop-helpers.test.ts b/test/loop-helpers.test.ts new file mode 100644 index 0000000..c27b4ec --- /dev/null +++ b/test/loop-helpers.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'bun:test' +import { resolveLoopModel, formatDuration, computeElapsedSeconds } from '../src/utils/loop-helpers' +import type { PluginConfig } from '../src/types' + +describe('resolveLoopModel', () => { + const mockLoopService = { + getActiveState: (name: string) => name === 'failed-worktree' + ? { active: true, modelFailed: true } + : { active: true, modelFailed: false }, + } as any + + it('returns undefined when modelFailed is true', () => { + const config = { loop: { model: 'provider/model' } } as PluginConfig + const result = resolveLoopModel(config, mockLoopService, 'failed-worktree') + expect(result).toBeUndefined() + }) + + it('returns parsed model when available', () => { + const config = { loop: { model: 'provider/model' } } as PluginConfig + const result = resolveLoopModel(config, mockLoopService, 'valid-worktree') + expect(result).toEqual({ providerID: 'provider', modelID: 'model' }) + }) + + it('returns undefined when no model configured', () => { + const config = {} as PluginConfig + const result = resolveLoopModel(config, mockLoopService, 'valid-worktree') + expect(result).toBeUndefined() + }) +}) + +describe('formatDuration', () => { + it('formats seconds-only', () => { + expect(formatDuration(45)).toBe('45s') + }) + + it('formats minutes+seconds', () => { + expect(formatDuration(125)).toBe('2m 5s') + }) + + it('handles zero', () => { + expect(formatDuration(0)).toBe('0s') + }) + + it('handles exact minutes', () => { + expect(formatDuration(180)).toBe('3m 0s') + }) +}) + +describe('computeElapsedSeconds', () => { + it('handles both timestamps', () => { + const start = new Date('2024-01-01T00:00:00Z').toISOString() + const end = new Date('2024-01-01T00:01:30Z').toISOString() + expect(computeElapsedSeconds(start, end)).toBe(90) + }) + + it('handles missing start', () => { + expect(computeElapsedSeconds(undefined, new Date().toISOString())).toBe(0) + }) + + it('handles missing end (uses Date.now)', () => { + const start = new Date(Date.now() - 5000).toISOString() + const elapsed = computeElapsedSeconds(start, undefined) + expect(elapsed).toBeGreaterThanOrEqual(4) + expect(elapsed).toBeLessThanOrEqual(6) + }) +}) diff --git a/test/loop.test.ts b/test/loop.test.ts index 7bb5c1e..09b5193 100644 --- a/test/loop.test.ts +++ b/test/loop.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test' import { Database } from 'bun:sqlite' import { createKvQuery } from '../src/storage/kv-queries' import { createKvService } from '../src/services/kv' -import { createLoopService } from '../src/services/loop' +import { createLoopService, migrateRalphKeys, buildCompletionSignalInstructions, fetchSessionOutput, type LoopState } from '../src/services/loop' const TEST_DIR = '/tmp/opencode-manager-loop-test-' + Date.now() @@ -56,7 +56,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 5, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -87,7 +87,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -106,24 +106,29 @@ describe('LoopService', () => { expect(retrieved).toBeNull() }) - test('checkCompletionPromise matches exact phrase', () => { - const text = 'Some response text ALL_PHASES_COMPLETE more text' - expect(loopService.checkCompletionPromise(text, 'ALL_PHASES_COMPLETE')).toBe(true) + test('checkCompletionSignal matches exact phrase', () => { + const text = 'Some response text ALL_PHASES_COMPLETE more text' + expect(loopService.checkCompletionSignal(text, 'ALL_PHASES_COMPLETE')).toBe(true) }) - test('checkCompletionPromise returns false when phrase not present', () => { + test('checkCompletionSignal returns false when phrase not present', () => { const text = 'Some response text without the phrase' - expect(loopService.checkCompletionPromise(text, 'ALL_PHASES_COMPLETE')).toBe(false) + expect(loopService.checkCompletionSignal(text, 'ALL_PHASES_COMPLETE')).toBe(false) }) - test('checkCompletionPromise returns false when phrase does not match', () => { - const text = 'Some response NOT_COMPLETE text' - expect(loopService.checkCompletionPromise(text, 'ALL_PHASES_COMPLETE')).toBe(false) + test('checkCompletionSignal returns false when phrase does not match', () => { + const text = 'Some response NOT_COMPLETE text' + expect(loopService.checkCompletionSignal(text, 'ALL_PHASES_COMPLETE')).toBe(false) }) - test('checkCompletionPromise requires exact match', () => { - const text = 'Response ALL_PHASES_COMPLETE text' - expect(loopService.checkCompletionPromise(text, 'NOT_COMPLETE')).toBe(false) + test('checkCompletionSignal requires exact match', () => { + const text = 'Response ALL_PHASES_COMPLETE text' + expect(loopService.checkCompletionSignal(text, 'NOT_COMPLETE')).toBe(false) + }) + + test('checkCompletionSignal is case-insensitive', () => { + const text = 'Some response all_phases_complete more text' + expect(loopService.checkCompletionSignal(text, 'ALL_PHASES_COMPLETE')).toBe(true) }) test('buildContinuationPrompt includes iteration number', () => { @@ -135,7 +140,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 3, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'My test prompt', phase: 'coding' as const, @@ -158,7 +163,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 0, - completionPromise: 'COMPLETE_TASK', + completionSignal: 'COMPLETE_TASK', startedAt: new Date().toISOString(), prompt: 'My test prompt', phase: 'coding' as const, @@ -168,7 +173,7 @@ describe('LoopService', () => { } const prompt = loopService.buildContinuationPrompt(state) - expect(prompt).toContain('[Loop iteration 1 | To stop: output COMPLETE_TASK (ONLY after all verification commands pass AND all phase acceptance criteria are met)]') + expect(prompt).toContain('[Loop iteration 1 | To stop: output COMPLETE_TASK (ONLY after all verification commands pass AND all phase acceptance criteria are met)]') }) test('buildContinuationPrompt includes max iterations when no promise', () => { @@ -180,7 +185,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 2, maxIterations: 10, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'My test prompt', phase: 'coding' as const, @@ -202,7 +207,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'My test prompt', phase: 'coding' as const, @@ -224,7 +229,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 5, maxIterations: 10, - completionPromise: 'PERSIST_TEST', + completionSignal: 'PERSIST_TEST', startedAt: new Date().toISOString(), prompt: 'Persistence test', phase: 'coding' as const, @@ -251,7 +256,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -276,7 +281,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 2, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -303,7 +308,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 2, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -327,7 +332,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 2, maxIterations: 0, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -351,7 +356,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-worktree-1', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Active prompt 1', phase: 'coding' as const, @@ -368,7 +373,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-worktree-2', iteration: 2, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Active prompt 2', phase: 'coding' as const, @@ -385,7 +390,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-worktree-3', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Inactive prompt', phase: 'coding' as const, @@ -414,7 +419,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-unique-worktree-name', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -441,7 +446,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 5, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -465,7 +470,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -488,7 +493,7 @@ describe('LoopService', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'In-place test prompt', phase: 'coding' as const, @@ -512,7 +517,7 @@ describe('LoopService', () => { worktreeBranch: 'develop', iteration: 2, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -536,7 +541,7 @@ describe('LoopService', () => { worktreeBranch: 'main', iteration: 3, maxIterations: 0, - completionPromise: 'COMPLETE', + completionSignal: 'COMPLETE', startedAt: new Date().toISOString(), prompt: 'In-place prompt test', phase: 'coding' as const, @@ -548,7 +553,7 @@ describe('LoopService', () => { const prompt = loopService.buildContinuationPrompt(inPlaceState) expect(prompt).toContain('Loop iteration 3') expect(prompt).toContain('In-place prompt test') - expect(prompt).toContain('COMPLETE') + expect(prompt).toContain('COMPLETE') }) test('buildContinuationPrompt with audit findings works with inPlace state', () => { @@ -560,7 +565,7 @@ describe('LoopService', () => { worktreeBranch: 'main', iteration: 2, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'In-place audit test', phase: 'coding' as const, @@ -671,7 +676,7 @@ describe('Stall Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'test', phase: 'coding', @@ -734,7 +739,7 @@ describe('Stall Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'test', phase: 'coding', @@ -806,7 +811,7 @@ describe('Stall Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'test', phase: 'coding', @@ -877,7 +882,7 @@ describe('Stall Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'test', phase: 'coding', @@ -928,7 +933,7 @@ describe('reconcileStale', () => { worktreeBranch: 'main', iteration: 3, maxIterations: 10, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1056,7 +1061,7 @@ describe('buildContinuationPrompt with outstanding findings', () => { worktreeBranch: 'opencode/loop-test', iteration: 3, maxIterations: 0, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1084,7 +1089,7 @@ describe('buildContinuationPrompt with outstanding findings', () => { worktreeBranch: 'opencode/loop-test', iteration: 2, maxIterations: 0, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1106,7 +1111,7 @@ describe('buildContinuationPrompt with outstanding findings', () => { worktreeBranch: 'opencode/loop-test', iteration: 3, maxIterations: 0, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1133,7 +1138,7 @@ describe('buildContinuationPrompt with outstanding findings', () => { worktreeBranch: 'opencode/loop-test', iteration: 2, maxIterations: 0, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1206,7 +1211,7 @@ describe('session rotation', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1290,7 +1295,7 @@ describe('session rotation', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'auditing' as const, @@ -1356,7 +1361,7 @@ describe('session rotation', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1448,7 +1453,7 @@ describe('Assistant Error Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1522,7 +1527,7 @@ describe('Assistant Error Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'auditing' as const, @@ -1586,7 +1591,7 @@ describe('Assistant Error Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1655,7 +1660,7 @@ describe('Assistant Error Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1732,7 +1737,7 @@ describe('Assistant Error Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1794,7 +1799,7 @@ describe('Assistant Error Detection', () => { worktreeBranch: 'main', iteration: 2, maxIterations: 10, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1868,7 +1873,7 @@ describe('Assistant Error Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1941,7 +1946,7 @@ describe('Force-restart behavior', () => { worktreeBranch: 'main', iteration: 3, maxIterations: 10, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), completedAt: new Date().toISOString(), terminationReason: 'cancelled', @@ -1968,7 +1973,7 @@ describe('Force-restart behavior', () => { worktreeBranch: 'main', iteration: 5, maxIterations: 10, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), completedAt: new Date().toISOString(), terminationReason: 'completed', @@ -1995,7 +2000,7 @@ describe('Force-restart behavior', () => { worktreeBranch: 'main', iteration: 2, maxIterations: 10, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -2024,7 +2029,7 @@ describe('Force-restart behavior', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -2054,7 +2059,7 @@ describe('Force-restart behavior', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -2074,3 +2079,421 @@ describe('Force-restart behavior', () => { expect(retrieved).toBeNull() }) }) + +describe('migrateRalphKeys', () => { + let db: Database + let kvService: ReturnType + const projectId = 'test-project' + + beforeEach(() => { + db = createTestDb() + kvService = createKvService(db) + }) + + afterEach(() => { + db.close() + }) + + test('migrates ralph: entries to loop: prefix', () => { + const logger = createMockLogger() + kvService.set(projectId, 'ralph:foo', { value: 'bar' }) + kvService.set(projectId, 'ralph:bar', { value: 'baz' }) + + migrateRalphKeys(kvService, projectId, logger) + + expect(kvService.get(projectId, 'loop:foo')).toEqual({ value: 'bar' }) + expect(kvService.get(projectId, 'loop:bar')).toEqual({ value: 'baz' }) + expect(kvService.get(projectId, 'ralph:foo')).toBeNull() + expect(kvService.get(projectId, 'ralph:bar')).toBeNull() + }) + + test('converts inPlace true to worktree false', () => { + const logger = createMockLogger() + kvService.set(projectId, 'ralph:test', { inPlace: true, sessionId: 'abc' }) + + migrateRalphKeys(kvService, projectId, logger) + + const migrated = kvService.get(projectId, 'loop:test') + expect(migrated).toEqual({ worktree: false, sessionId: 'abc' }) + expect((migrated as any)?.inPlace).toBeUndefined() + }) + + test('converts inPlace false to worktree true', () => { + const logger = createMockLogger() + kvService.set(projectId, 'ralph:test', { inPlace: false, sessionId: 'abc' }) + + migrateRalphKeys(kvService, projectId, logger) + + const migrated = kvService.get(projectId, 'loop:test') + expect(migrated).toEqual({ worktree: true, sessionId: 'abc' }) + expect((migrated as any)?.inPlace).toBeUndefined() + }) + + test('migrates ralph-session: entries to loop-session: prefix', () => { + const logger = createMockLogger() + kvService.set(projectId, 'ralph:dummy', { dummy: true }) + kvService.set(projectId, 'ralph-session:s1', 'worktree-1') + + migrateRalphKeys(kvService, projectId, logger) + + expect(kvService.get(projectId, 'loop-session:s1')).toBe('worktree-1') + expect(kvService.get(projectId, 'ralph-session:s1')).toBeNull() + }) + + test('no-op when no ralph entries exist', () => { + const logger = createMockLogger() + + expect(() => migrateRalphKeys(kvService, projectId, logger)).not.toThrow() + expect(kvService.listByPrefix(projectId, 'loop:').length).toBe(0) + }) + + test('logs migration count', () => { + const logs: string[] = [] + const logger = { + log: (msg: string) => logs.push(msg), + error: () => {}, + debug: () => {}, + } + kvService.set(projectId, 'ralph:foo', { value: 'bar' }) + kvService.set(projectId, 'ralph:bar', { value: 'baz' }) + + migrateRalphKeys(kvService, projectId, logger) + + expect(logs.some(log => log.includes('Migrating') && log.includes('2'))).toBe(true) + }) +}) + +describe('buildCompletionSignalInstructions', () => { + test('returns string containing the signal', () => { + const result = buildCompletionSignalInstructions('MY_SIGNAL') + expect(result).toContain('MY_SIGNAL') + }) + + test('contains verification instructions', () => { + const result = buildCompletionSignalInstructions('MY_SIGNAL') + expect(result).toContain('Verify each phase') + }) + + test('contains IMPORTANT header', () => { + const result = buildCompletionSignalInstructions('MY_SIGNAL') + expect(result).toContain('IMPORTANT') + }) +}) + +describe('terminateAll', () => { + let db: Database + let kvService: ReturnType + let loopService: ReturnType + const projectId = 'test-project' + + beforeEach(() => { + db = createTestDb() + kvService = createKvService(db) + loopService = createLoopService(kvService, projectId, createMockLogger()) + }) + + afterEach(() => { + db.close() + }) + + function createActiveState(name: string, sessionId: string): LoopState { + return { + active: true, + sessionId, + worktreeName: name, + worktreeDir: `/tmp/${name}`, + worktreeBranch: 'main', + iteration: 1, + maxIterations: 5, + completionSignal: null, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + auditCount: 0, + } + } + + test('marks all active loops as shutdown', () => { + const state1 = createActiveState('worktree-1', 'session-1') + const state2 = createActiveState('worktree-2', 'session-2') + + loopService.setState('worktree-1', state1) + loopService.setState('worktree-2', state2) + + loopService.terminateAll() + + const updated1 = loopService.getAnyState('worktree-1') + const updated2 = loopService.getAnyState('worktree-2') + + expect(updated1?.active).toBe(false) + expect(updated1?.terminationReason).toBe('shutdown') + expect(updated1?.completedAt).toBeDefined() + + expect(updated2?.active).toBe(false) + expect(updated2?.terminationReason).toBe('shutdown') + expect(updated2?.completedAt).toBeDefined() + }) + + test('does not affect inactive loops', () => { + const activeState = createActiveState('active', 'session-active') + const inactiveState: LoopState = { + ...createActiveState('inactive', 'session-inactive'), + active: false, + terminationReason: 'completed', + } + + loopService.setState('active', activeState) + loopService.setState('inactive', inactiveState) + + loopService.terminateAll() + + const inactive = loopService.getAnyState('inactive') + expect(inactive?.terminationReason).toBe('completed') + }) + + test('no-op with no active loops', () => { + expect(() => loopService.terminateAll()).not.toThrow() + }) +}) + +describe('listRecent', () => { + let db: Database + let kvService: ReturnType + let loopService: ReturnType + const projectId = 'test-project' + + beforeEach(() => { + db = createTestDb() + kvService = createKvService(db) + loopService = createLoopService(kvService, projectId, createMockLogger()) + }) + + afterEach(() => { + db.close() + }) + + function createActiveState(name: string, sessionId: string): LoopState { + return { + active: true, + sessionId, + worktreeName: name, + worktreeDir: `/tmp/${name}`, + worktreeBranch: 'main', + iteration: 1, + maxIterations: 5, + completionSignal: null, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + auditCount: 0, + } + } + + function createInactiveState(name: string, sessionId: string): LoopState { + return { + active: false, + sessionId, + worktreeName: name, + worktreeDir: `/tmp/${name}`, + worktreeBranch: 'main', + iteration: 1, + maxIterations: 5, + completionSignal: null, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + auditCount: 0, + } + } + + test('returns only inactive states', () => { + loopService.setState('active-1', createActiveState('active-1', 'session-1')) + loopService.setState('active-2', createActiveState('active-2', 'session-2')) + loopService.setState('inactive-1', createInactiveState('inactive-1', 'session-3')) + + const recent = loopService.listRecent() + + expect(recent.length).toBe(1) + expect(recent[0].worktreeName).toBe('inactive-1') + }) + + test('returns empty array when no inactive states', () => { + loopService.setState('active-1', createActiveState('active-1', 'session-1')) + loopService.setState('active-2', createActiveState('active-2', 'session-2')) + + const recent = loopService.listRecent() + + expect(recent).toEqual([]) + }) +}) + +describe('findCandidatesByPartialName', () => { + let db: Database + let kvService: ReturnType + let loopService: ReturnType + const projectId = 'test-project' + + beforeEach(() => { + db = createTestDb() + kvService = createKvService(db) + loopService = createLoopService(kvService, projectId, createMockLogger()) + }) + + afterEach(() => { + db.close() + }) + + function createActiveState(name: string, sessionId: string): LoopState { + return { + active: true, + sessionId, + worktreeName: name, + worktreeDir: `/tmp/${name}`, + worktreeBranch: 'main', + iteration: 1, + maxIterations: 5, + completionSignal: null, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + auditCount: 0, + } + } + + test('returns multiple candidates for ambiguous match', () => { + loopService.setState('feature-auth', createActiveState('feature-auth', 'session-1')) + loopService.setState('feature-api', createActiveState('feature-api', 'session-2')) + + const candidates = loopService.findCandidatesByPartialName('feature') + + expect(candidates.length).toBe(2) + }) + + test('returns empty array when no matches', () => { + loopService.setState('feature-auth', createActiveState('feature-auth', 'session-1')) + + const candidates = loopService.findCandidatesByPartialName('nonexistent') + + expect(candidates).toEqual([]) + }) +}) + +describe('fetchSessionOutput', () => { + function createMockLogger() { + return { + log: () => {}, + error: () => {}, + debug: () => {}, + } + } + + const createMockV2Client = (messages: any[] = [], session: any = {}) => ({ + session: { + messages: async () => ({ data: messages }), + get: async () => ({ data: session }), + }, + } as any) + + test('returns null when directory is empty', async () => { + const mockClient = createMockV2Client() + const logger = createMockLogger() + + const result = await fetchSessionOutput(mockClient, 'session-1', '', logger) + + expect(result).toBeNull() + }) + + test('returns null when sessionId is empty', async () => { + const mockClient = createMockV2Client() + const logger = createMockLogger() + + const result = await fetchSessionOutput(mockClient, '', '/dir', logger) + + expect(result).toBeNull() + }) + + test('extracts messages from assistant responses', async () => { + const messages = [ + { + info: { role: 'assistant', cost: 0.01, tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } } }, + parts: [{ type: 'text', text: 'Hello from assistant' }], + }, + { + info: { role: 'user', cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }, + parts: [{ type: 'text', text: 'User message' }], + }, + ] + const mockClient = createMockV2Client(messages) + const logger = createMockLogger() + + const result = await fetchSessionOutput(mockClient, 'session-1', '/tmp/test', logger) + + expect(result).not.toBeNull() + expect(result?.messages.length).toBe(1) + expect(result?.messages[0].text).toContain('Hello from assistant') + }) + + test('calculates total cost and tokens', async () => { + const messages = [ + { + info: { role: 'assistant', cost: 0.01, tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 5, write: 2 } } }, + parts: [{ type: 'text', text: 'First message' }], + }, + { + info: { role: 'assistant', cost: 0.02, tokens: { input: 200, output: 100, reasoning: 20, cache: { read: 10, write: 4 } } }, + parts: [{ type: 'text', text: 'Second message' }], + }, + ] + const mockClient = createMockV2Client(messages) + const logger = createMockLogger() + + const result = await fetchSessionOutput(mockClient, 'session-1', '/tmp/test', logger) + + expect(result?.totalCost).toBe(0.03) + expect(result?.totalTokens.input).toBe(300) + expect(result?.totalTokens.output).toBe(150) + expect(result?.totalTokens.reasoning).toBe(30) + expect(result?.totalTokens.cacheRead).toBe(15) + expect(result?.totalTokens.cacheWrite).toBe(6) + }) + + test('includes file changes from session summary', async () => { + const messages = [ + { + info: { role: 'assistant', cost: 0.01, tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } } }, + parts: [{ type: 'text', text: 'Message' }], + }, + ] + const session = { + summary: { additions: 10, deletions: 5, files: 3 }, + } + const mockClient = createMockV2Client(messages, session) + const logger = createMockLogger() + + const result = await fetchSessionOutput(mockClient, 'session-1', '/tmp/test', logger) + + expect(result?.fileChanges).toEqual({ additions: 10, deletions: 5, files: 3 }) + }) + + test('returns null on API error', async () => { + const mockClient = { + session: { + messages: async () => { throw new Error('API error') }, + get: async () => { throw new Error('API error') }, + }, + } as any + const logger = createMockLogger() + + const result = await fetchSessionOutput(mockClient, 'session-1', '/tmp/test', logger) + + expect(result).toBeNull() + }) +}) diff --git a/test/plan-approval.test.ts b/test/plan-approval.test.ts index d5e31a2..b3c28d0 100644 --- a/test/plan-approval.test.ts +++ b/test/plan-approval.test.ts @@ -82,32 +82,32 @@ Do NOT output text without also making this tool call. db.close() }) - function simulateToolExecuteAfter( - tool: string, - args: unknown, - output: { title: string; output: string; metadata: unknown }, - sessionActive = false - ) { - if (sessionActive) { - const state = { - active: true, - sessionId: sessionID, - worktreeName: 'test-worktree', - worktreeDir: '/test/worktree', - worktreeBranch: 'opencode/loop-test', - iteration: 1, - maxIterations: 5, - completionPromise: 'ALL_PHASES_COMPLETE', - startedAt: new Date().toISOString(), - prompt: 'Test prompt', - phase: 'coding' as const, - audit: false, - errorCount: 0, - auditCount: 0, - worktree: true, + function simulateToolExecuteAfter( + tool: string, + args: unknown, + output: { title: string; output: string; metadata: unknown }, + sessionActive = false + ) { + if (sessionActive) { + const state = { + active: true, + sessionId: sessionID, + worktreeName: 'test-worktree', + worktreeDir: '/test/worktree', + worktreeBranch: 'opencode/loop-test', + iteration: 1, + maxIterations: 5, + completionSignal: 'ALL_PHASES_COMPLETE', + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + auditCount: 0, + worktree: true, + } + loopService.setState(sessionID, state) } - loopService.setState(sessionID, state) - } if (tool === 'question') { const questionArgs = args as { questions?: Array<{ options?: Array<{ label: string }> }> } | undefined diff --git a/test/sandbox-context.test.ts b/test/sandbox-context.test.ts new file mode 100644 index 0000000..13dd62c --- /dev/null +++ b/test/sandbox-context.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'bun:test' +import { getSandboxForSession, isSandboxEnabled } from '../src/sandbox/context' +import type { PluginConfig } from '../src/types' + +describe('getSandboxForSession', () => { + const mockDocker = {} as any + const mockLoopService = { + resolveWorktreeName: (sessionId: string) => sessionId === 'valid-session' ? 'test-worktree' : null, + getActiveState: (name: string) => name === 'test-worktree' ? { active: true, sandbox: true, worktreeDir: '/test' } : null, + } as any + + it('returns null when sandboxManager is null', () => { + const result = getSandboxForSession( + { sandboxManager: null, loopService: mockLoopService }, + 'valid-session' + ) + expect(result).toBeNull() + }) + + it('returns null when worktreeName not found', () => { + const mockSandboxManager = { docker: mockDocker, getActive: () => null } as any + const result = getSandboxForSession( + { sandboxManager: mockSandboxManager, loopService: { ...mockLoopService, resolveWorktreeName: () => null } }, + 'invalid-session' + ) + expect(result).toBeNull() + }) + + it('returns null when state is not active', () => { + const mockSandboxManager = { docker: mockDocker, getActive: () => null } as any + const result = getSandboxForSession( + { sandboxManager: mockSandboxManager, loopService: { ...mockLoopService, getActiveState: () => ({ active: false, sandbox: true }) } }, + 'valid-session' + ) + expect(result).toBeNull() + }) + + it('returns null when sandbox is false', () => { + const mockSandboxManager = { docker: mockDocker, getActive: () => null } as any + const result = getSandboxForSession( + { sandboxManager: mockSandboxManager, loopService: { ...mockLoopService, getActiveState: () => ({ active: true, sandbox: false }) } }, + 'valid-session' + ) + expect(result).toBeNull() + }) + + it('returns context when all conditions met', () => { + const mockSandboxManager = { + docker: mockDocker, + getActive: (name: string) => name === 'test-worktree' ? { containerName: 'test-container', projectDir: '/test/project' } : null, + } as any + const result = getSandboxForSession( + { sandboxManager: mockSandboxManager, loopService: mockLoopService }, + 'valid-session' + ) + expect(result).toEqual({ + docker: mockDocker, + containerName: 'test-container', + hostDir: '/test/project', + }) + }) +}) + +describe('isSandboxEnabled', () => { + it('returns false when mode is off', () => { + const config = { sandbox: { mode: 'off' as const } } as PluginConfig + expect(isSandboxEnabled(config, {})).toBe(false) + }) + + it('returns false when sandboxManager is null', () => { + const config = { sandbox: { mode: 'docker' as const } } as PluginConfig + expect(isSandboxEnabled(config, null)).toBe(false) + }) + + it('returns true when mode is docker and manager exists', () => { + const config = { sandbox: { mode: 'docker' as const } } as PluginConfig + expect(isSandboxEnabled(config, {})).toBe(true) + }) +}) diff --git a/test/sandbox-docker.test.ts b/test/sandbox-docker.test.ts new file mode 100644 index 0000000..bb2e2dd --- /dev/null +++ b/test/sandbox-docker.test.ts @@ -0,0 +1,30 @@ +import { describe, test, expect } from 'bun:test' +import { createDockerService } from '../src/sandbox/docker' + +function createMockLogger() { + return { + log: () => {}, + error: () => {}, + debug: () => {}, + } +} + +describe('DockerService containerName', () => { + const logger = createMockLogger() + const docker = createDockerService(logger) + + test('containerName returns ocm-sandbox- prefixed name', () => { + const result = docker.containerName('my-worktree') + expect(result).toBe('ocm-sandbox-my-worktree') + }) + + test('containerName handles names with special characters', () => { + const result = docker.containerName('feature/test-123') + expect(result).toBe('ocm-sandbox-feature/test-123') + }) + + test('containerName handles empty string', () => { + const result = docker.containerName('') + expect(result).toBe('ocm-sandbox-') + }) +}) diff --git a/test/sandbox-manager.test.ts b/test/sandbox-manager.test.ts index ded47f6..2f9211e 100644 --- a/test/sandbox-manager.test.ts +++ b/test/sandbox-manager.test.ts @@ -18,6 +18,7 @@ function createMockDockerService() { let runningContainers = new Set() let shouldDockerBeAvailable = true let shouldImageExist = true + let shouldRemoveThrow = false const mock = { checkDocker: async () => shouldDockerBeAvailable, @@ -29,6 +30,9 @@ function createMockDockerService() { }, removeContainer: async (name: string) => { removeContainerCalls.push(name) + if (shouldRemoveThrow) { + throw new Error('Failed to remove container') + } }, exec: async () => ({ stdout: '', stderr: '', exitCode: 0 }), execPipe: async () => ({ stdout: '', stderr: '', exitCode: 0 }), @@ -55,6 +59,9 @@ function createMockDockerService() { setImageExists: (exists: boolean) => { shouldImageExist = exists }, + setRemoveThrow: (shouldThrow: boolean) => { + shouldRemoveThrow = shouldThrow + }, } return mock } @@ -184,4 +191,196 @@ describe('SandboxManager', () => { expect(active?.startedAt).toBe(originalStartedAt) }) }) + + describe('start', () => { + test('throws when Docker is not available', async () => { + const mockDocker = createMockDockerService() + mockDocker.setDockerAvailable(false) + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + await expect(() => manager.start('test', '/path')).toThrow('Docker is not available') + }) + + test('throws when image does not exist', async () => { + const mockDocker = createMockDockerService() + mockDocker.setImageExists(false) + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + await expect(() => manager.start('test', '/path')).toThrow('not found') + }) + + test('returns early when container already running', async () => { + const mockDocker = createMockDockerService() + mockDocker.setRunning('ocm-sandbox-test', true) + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + const result = await manager.start('test', '/path') + + expect(mockDocker.getCreateContainerCalls().length).toBe(0) + expect(result).toEqual({ containerName: 'ocm-sandbox-test' }) + }) + + test('creates container and populates active map', async () => { + const mockDocker = createMockDockerService() + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + const result = await manager.start('test', '/path') + + expect(mockDocker.getCreateContainerCalls().length).toBe(1) + expect(manager.isActive('test')).toBe(true) + const active = manager.getActive('test') + expect(active).not.toBeNull() + expect(active?.containerName).toBe('ocm-sandbox-test') + }) + }) + + describe('stop', () => { + test('removes container and clears active map', async () => { + const mockDocker = createMockDockerService() + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + await manager.start('test', '/path') + await manager.stop('test') + + expect(mockDocker.getRemoveContainerCalls()).toContain('ocm-sandbox-test') + expect(manager.isActive('test')).toBe(false) + }) + + test('clears active map even when removeContainer throws', async () => { + const mockDocker = createMockDockerService() + mockDocker.setRemoveThrow(true) + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + await manager.start('test', '/path') + await manager.stop('test') + + expect(manager.isActive('test')).toBe(false) + }) + + test('uses containerName fallback when not in active map', async () => { + const mockDocker = createMockDockerService() + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + await manager.stop('unknown') + + expect(mockDocker.getRemoveContainerCalls()).toContain('ocm-sandbox-unknown') + }) + }) + + describe('getActive and isActive', () => { + test('returns null and false for unknown worktree', () => { + const mockDocker = createMockDockerService() + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + expect(manager.getActive('unknown')).toBeNull() + expect(manager.isActive('unknown')).toBe(false) + }) + + test('returns active sandbox after start', async () => { + const mockDocker = createMockDockerService() + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + await manager.start('test', '/path') + + const active = manager.getActive('test') + expect(active).not.toBeNull() + expect(manager.isActive('test')).toBe(true) + }) + + test('returns null and false after stop', async () => { + const mockDocker = createMockDockerService() + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + await manager.start('test', '/path') + await manager.stop('test') + + expect(manager.getActive('test')).toBeNull() + expect(manager.isActive('test')).toBe(false) + }) + }) + + describe('cleanupOrphans additional', () => { + test('handles empty container list', async () => { + const mockDocker = createMockDockerService() + mockDocker.setContainers([]) + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + const removed = await manager.cleanupOrphans() + + expect(removed).toBe(0) + }) + + test('continues cleanup when removal fails', async () => { + const mockDocker = createMockDockerService() + mockDocker.setContainers(['ocm-sandbox-first', 'ocm-sandbox-second']) + mockDocker.setRemoveThrow(true) + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + await manager.cleanupOrphans() + + const calls = mockDocker.getRemoveContainerCalls() + expect(calls).toContain('ocm-sandbox-first') + expect(calls).toContain('ocm-sandbox-second') + }) + }) }) diff --git a/test/sandbox-path.test.ts b/test/sandbox-path.test.ts new file mode 100644 index 0000000..8515937 --- /dev/null +++ b/test/sandbox-path.test.ts @@ -0,0 +1,79 @@ +import { describe, test, expect } from 'bun:test' +import { toContainerPath, toHostPath, rewriteOutput } from '../src/sandbox/path' + +describe('toContainerPath', () => { + test('converts host path to container path', () => { + const result = toContainerPath('/home/user/project/src/file.ts', '/home/user/project') + expect(result).toBe('/workspace/src/file.ts') + }) + + test('returns path as-is when already a container path', () => { + const result = toContainerPath('/workspace/src/file.ts', '/home/user/project') + expect(result).toBe('/workspace/src/file.ts') + }) + + test('returns path as-is when unrelated to hostDir', () => { + const result = toContainerPath('/usr/bin/node', '/home/user/project') + expect(result).toBe('/usr/bin/node') + }) + + test('converts exact hostDir to /workspace', () => { + const result = toContainerPath('/home/user/project', '/home/user/project') + expect(result).toBe('/workspace') + }) +}) + +describe('toHostPath', () => { + test('converts container path to host path', () => { + const result = toHostPath('/workspace/src/file.ts', '/home/user/project') + expect(result).toBe('/home/user/project/src/file.ts') + }) + + test('returns absolute non-workspace paths unchanged', () => { + const result = toHostPath('/usr/bin/node', '/home/user/project') + expect(result).toBe('/usr/bin/node') + }) + + test('treats relative paths as relative to workspace', () => { + const result = toHostPath('src/file.ts', '/home/user/project') + expect(result).toBe('/home/user/project/src/file.ts') + }) + + test('converts exact /workspace to hostDir', () => { + const result = toHostPath('/workspace', '/home/user/project') + expect(result).toBe('/home/user/project') + }) +}) + +describe('rewriteOutput', () => { + test('replaces /workspace/ with hostDir/', () => { + const result = rewriteOutput('Error at /workspace/src/file.ts:10', '/home/user/project') + expect(result).toBe('Error at /home/user/project/src/file.ts:10') + }) + + test('replaces /workspace at end of line', () => { + const result = rewriteOutput('Working dir: /workspace', '/home/user/project') + expect(result).toBe('Working dir: /home/user/project') + }) + + test('handles multi-line output', () => { + const input = `Error at /workspace/src/file.ts:10 + at /workspace/lib/utils.ts:25 + Working dir: /workspace` + const expected = `Error at /home/user/project/src/file.ts:10 + at /home/user/project/lib/utils.ts:25 + Working dir: /home/user/project` + const result = rewriteOutput(input, '/home/user/project') + expect(result).toBe(expected) + }) + + test('returns empty string for empty input', () => { + const result = rewriteOutput('', '/home/user/project') + expect(result).toBe('') + }) + + test('handles multiple occurrences on same line', () => { + const result = rewriteOutput('/workspace/a and /workspace/b', '/home/user/project') + expect(result).toBe('/home/user/project/a and /home/user/project/b') + }) +}) diff --git a/test/strip-promise-tags.test.ts b/test/strip-promise-tags.test.ts deleted file mode 100644 index 7a8efc6..0000000 --- a/test/strip-promise-tags.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, test, expect } from 'vitest' -import { stripPromiseTags } from '../src/utils/strip-promise-tags' - -describe('stripPromiseTags', () => { - test('returns unchanged text when no promise tags present', () => { - const text = 'This is a normal plan without any special tags' - const { cleaned, stripped } = stripPromiseTags(text) - expect(cleaned).toBe(text) - expect(stripped).toBe(false) - }) - - test('strips bare promise tags', () => { - const text = 'Plan text here All phases of the plan have been completed successfully' - const { cleaned, stripped } = stripPromiseTags(text) - expect(cleaned).toBe('Plan text here') - expect(stripped).toBe(true) - expect(cleaned).not.toContain('') - }) - - test('strips full instruction block with promise tags', () => { - const text = `Plan text here - ---- - -**IMPORTANT - Completion Signal:** When you have completed ALL phases of this plan successfully, you MUST output the following tag exactly: All phases of the plan have been completed successfully - -Do NOT output this tag until every phase is truly complete. The loop will continue until this signal is detected.` - const { cleaned, stripped } = stripPromiseTags(text) - expect(cleaned).toBe('Plan text here') - expect(stripped).toBe(true) - expect(cleaned).not.toContain('') - expect(cleaned).not.toContain('Completion Signal') - }) - - test('preserves plan content before promise tags', () => { - const plan = `## Phase 1 -Do something - -## Phase 2 -Do something else - -ALL_PHASES_COMPLETE` - const { cleaned, stripped } = stripPromiseTags(plan) - expect(cleaned).toContain('## Phase 1') - expect(cleaned).toContain('## Phase 2') - expect(cleaned).not.toContain('') - expect(stripped).toBe(true) - }) - - test('handles promise tags with multiline content', () => { - const text = 'Plan \nMulti\nLine\nContent\n end' - const { cleaned, stripped } = stripPromiseTags(text) - expect(cleaned).not.toContain('') - expect(stripped).toBe(true) - }) -}) diff --git a/test/tool-blocking.test.ts b/test/tool-blocking.test.ts index 8bd79e2..745c1bc 100644 --- a/test/tool-blocking.test.ts +++ b/test/tool-blocking.test.ts @@ -57,7 +57,7 @@ describe('Tool Blocking Logic', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 5, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -87,7 +87,7 @@ describe('Tool Blocking Logic', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 5, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, diff --git a/tsconfig.json b/tsconfig.json index 7ab45d3..ba0b3ff 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,11 @@ "noEmit": true, "jsx": "react-jsx", "jsxImportSource": "@opentui/solid", - "types": ["bun-types"] + "types": ["bun-types"], + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true }, "include": ["src/**/*"] }