diff --git a/.gitignore b/.gitignore index e9079d0..6f140c0 100755 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ dist/ # Turbo cache .turbo/ +.air +.claude diff --git a/bun.lock b/bun.lock index 8093d36..7d8e27d 100755 --- a/bun.lock +++ b/bun.lock @@ -73,7 +73,9 @@ "version": "1.0.0-alpha.0", "dependencies": { "@aircoding/contracts": "workspace:*", - "@aircoding/runtime": "workspace:*", + "@opentui/core": "0.3.0", + "@opentui/solid": "0.3.0", + "solid-js": "1.9.10", }, "devDependencies": { "@types/node": "^25.9.1", @@ -107,6 +109,88 @@ "@aircoding/workers": ["@aircoding/workers@workspace:packages/workers"], + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@opentui/core": ["@opentui/core@0.3.0", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.0", "@opentui/core-darwin-x64": "0.3.0", "@opentui/core-linux-arm64": "0.3.0", "@opentui/core-linux-x64": "0.3.0", "@opentui/core-win32-arm64": "0.3.0", "@opentui/core-win32-x64": "0.3.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wvNESYGYGRLuvarZ3QY4CTB+BziZ/j6Snd9qRKD4fQ7SF6G4UpYElLTFrg7uzRo1v7WJTqbquymcTvWEHMnpYA=="], + + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/eDfAcutAHJqR9spwHMLuo6LMqngymev/m+i6uqlk98gX1EJiJe2pJ16sKbp3RctgH/Gz/8TYOhVHpPGYJl7yQ=="], + + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-/j6EWAvdwhz1wU/mWfXepAf3+NuMYz2Ic5ozaid5LdwIpPomIkM9yCUDm76mQhRBbjsAl/7UeSeUA0qSCMSZBg=="], + + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uUFVT3V35KkM1m8gaLmRcTV9dsJzXnxwM+dv6+NjScx0W/Y0CJKbW9wDYwnLyPnBNgaFUi171zmJra5gTtFTsw=="], + + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-73bNNNU2OaqZQLIlvzDOdAzQmzBAqf+cSilmJ+Y9JnybrBn1d6VShC66+V4xxIgonq1swk7BD+SUHYbwwGilQA=="], + + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jg5KrV/4mVQ0mdkcL9CtQVtBk0NAtQ+2rCKoZ/jNHB6GxGK0ot9vDV6P3X68hZVkvpb2pdXfg6GRsZJ+Np4hZA=="], + + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-kiM3C5bwQBTfrJKAOfb+L3U6MMkPSQlMhAERlLMjqSurc+llcyqygr/wbXSvfAqJtKlIpf3MKJRnVFTyfRIdng=="], + + "@opentui/solid": ["@opentui/solid@0.3.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-AUtNzvgkdW81Ftl0sahAy3tY1LIPSMzBw3APBC8jiDAzzPv4kYVdyWXryTxLbU2q+Pgtr57VwKwHgc5wsNrd2w=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.9.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-jLjApWTSNd7JZ5JaLYfelW1ytnGQOvB7ivl+2RD1xQvJTbi8I9gBjzcga7tDZVPyaxpl10YTfJt3BrYXR18KDw=="], "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YPgrn+5HIGzrx0O2a631SV4MBQUe4W/DafMFUuBVgaU32PW9/OTT0ehviF0QSxTXuRJlHvW2eUTemddF5/spmw=="], @@ -131,8 +215,28 @@ "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.7", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ=="], + + "babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="], + + "babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.34", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw=="], + + "brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -141,14 +245,42 @@ "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "dependency-cruiser": ["dependency-cruiser@17.4.3", "", { "dependencies": { "acorn": "8.16.0", "acorn-jsx": "5.3.2", "acorn-jsx-walk": "2.0.0", "acorn-loose": "8.5.2", "acorn-walk": "8.3.5", "commander": "14.0.3", "enhanced-resolve": "5.22.1", "ignore": "7.0.5", "interpret": "3.1.1", "is-installed-globally": "1.0.0", "json5": "2.2.3", "picomatch": "4.0.4", "prompts": "2.4.2", "rechoir": "0.8.0", "safe-regex": "2.1.1", "semver": "7.8.1", "tsconfig-paths-webpack-plugin": "4.2.0", "watskeburt": "5.0.3" }, "bin": { "depcruise": "bin/dependency-cruise.mjs", "depcruise-fmt": "bin/depcruise-fmt.mjs", "dependency-cruise": "bin/dependency-cruise.mjs", "depcruise-baseline": "bin/depcruise-baseline.mjs", "dependency-cruiser": "bin/dependency-cruise.mjs", "depcruise-wrap-stream-in-html": "bin/wrap-stream-in-html.mjs" } }, "sha512-L4GLuAvmXevWnPCIaFfOz6eD92c+yY+pDgVqgufrLDnW3xYA799CSZQlly2r2N13nhAlnZY6VzY7Rx5pHNvk2w=="], + "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.368", "", {}, "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], + + "find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], + "global-directory": ["global-directory@4.0.1", "", { "dependencies": { "ini": "4.1.1" } }, "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -157,6 +289,8 @@ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="], @@ -169,30 +303,78 @@ "is-path-inside": ["is-path-inside@4.0.0", "", {}, "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + + "minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="], + + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], "rechoir": ["rechoir@0.8.0", "", { "dependencies": { "resolve": "^1.20.0" } }, "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ=="], "regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="], + "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="], + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + "s-js": ["s-js@0.4.9", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="], + "safe-regex": ["safe-regex@2.1.1", "", { "dependencies": { "regexp-tree": "~0.1.1" } }, "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A=="], "semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "seroval": ["seroval@1.3.2", "", {}, "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="], + + "seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="], + + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -211,6 +393,28 @@ "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "watskeburt": ["watskeburt@5.0.3", "", { "bin": { "watskeburt": "dist/run-cli.js" } }, "sha512-g9CXukMjazlJJVQ3OHzXsnG25KFYgSgKMIyoJrD8ggr0DbS9UNF7OzIqWmmKKBMedkxj3T01uqEaGnn+y7QhMA=="], + + "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], } } diff --git a/packages/cli/src/bootstrap/loadConfig.ts b/packages/cli/src/bootstrap/loadConfig.ts index b4995f0..7d14894 100755 --- a/packages/cli/src/bootstrap/loadConfig.ts +++ b/packages/cli/src/bootstrap/loadConfig.ts @@ -30,6 +30,7 @@ const DEFAULT_CONFIG: AirConfig = { export function loadConfig(project_root?: string): AirConfig { let config = { ...DEFAULT_CONFIG } + const resolved_project_root = project_root || process.env.AIRCODING_PROJECT_ROOT // Load global config: ~/.air/config.json const global_path = join(homedir(), '.air', 'config.json') @@ -43,8 +44,8 @@ export function loadConfig(project_root?: string): AirConfig { } // Load project config: .air/local/config.json - if (project_root) { - const project_path = join(project_root, '.air', 'local', 'config.json') + if (resolved_project_root) { + const project_path = join(resolved_project_root, '.air', 'local', 'config.json') if (existsSync(project_path)) { try { const project = JSON.parse(readFileSync(project_path, 'utf-8')) @@ -53,7 +54,7 @@ export function loadConfig(project_root?: string): AirConfig { // Ignore malformed project config } } - config.project_root = project_root + config.project_root = resolved_project_root } return config diff --git a/packages/cli/src/commands/ask.ts b/packages/cli/src/commands/ask.ts index 65f973c..a5b7fc3 100755 --- a/packages/cli/src/commands/ask.ts +++ b/packages/cli/src/commands/ask.ts @@ -1,6 +1,6 @@ /** * AskCommand - Direct AI task execution - * air ask "task description" → MainAgent → LLM → tools → result + * air ask "task description" → MainAgent → Scheduler → Worker → LLM → tools → WorkerResult * * @module packages/cli/src/commands/ask */ @@ -10,9 +10,8 @@ import { createRuntime } from '../bootstrap/createRuntime.js' import { initCommand } from './init.js' import { existsSync } from 'fs' import { join } from 'path' -import { ProviderManager, createAnthropicAdapter, OpenAICompatibleAdapter } from '@aircoding/llm' +import { OpenAICompatibleAdapter } from '@aircoding/llm' import { MainAgent } from '@aircoding/runtime' -import type { ProviderAdapter } from '@aircoding/contracts' export async function askCommand(prompt: string, opts?: { model?: string; maxTurns?: number }): Promise { if (!prompt) { @@ -24,221 +23,97 @@ export async function askCommand(prompt: string, opts?: { model?: string; maxTur const config = loadConfig() const projectRoot = config.project_root || process.cwd() - // Auto-init if not already initialized if (!existsSync(join(projectRoot, '.air', 'shared', 'project.json'))) { console.log('Project not initialized. Running air init first...\n') await initCommand(projectRoot) } const model = opts?.model || process.env.AIRCODING_MODEL || 'glm-5.1' - const maxTurns = opts?.maxTurns || 10 - - console.log('══════════════════════════════════════════════') - console.log(' AirCoding v1.0.0-alpha') - console.log(' Project:', projectRoot) - console.log(' Model:', model) - console.log('══════════════════════════════════════════════\n') - - // Create ProviderManager with available adapter const provider = createProvider(model) - - // Create runtime const runtime = await createRuntime(config) - await runtime.start() - const app = runtime.app - // Create MainAgent with regex classifier (中文 support added) - const agent = new MainAgent({ - session_id: 'ask-session', - project_id: 'ask-project', - classify_mode: 'regex', - provider_manager: provider as any, - context_assembler: app.context_assembler, + app.worker_manager.set_provider_manager(provider as any) + app.worker_manager.set_context({ + session_id: app.session_id, + project_id: app.project_id, project_root: projectRoot, - agent_id: 'ask-agent' as any, - classify_model: model }) - console.log('Task:', prompt) - console.log('') + try { + await runtime.start() - // Classify intent - const classification = await agent.handle_user_message(prompt) - console.log(`[${classification.action}] ${agent.state}`) + const agent = new MainAgent({ + session_id: app.session_id, + project_id: app.project_id, + classify_mode: 'regex', + provider_manager: provider as any, + context_assembler: app.context_assembler, + project_root: projectRoot, + agent_id: 'ask-agent' as any, + classify_model: model, + }) - // Execute based on action - if (classification.action === 'delegate') { - await execute_task(prompt, provider, app.tool_registry, app.context_assembler, model, projectRoot, maxTurns) - } else if (classification.action === 'answer') { - console.log('\n' + (classification.response || 'No response')) - } else { - console.log('Direct mode not yet supported for ask. Use implementation requests.') - } + console.log('══════════════════════════════════════════════') + console.log(' AirCoding v1.0.0-alpha') + console.log(' Project:', projectRoot) + console.log(' Model:', model) + console.log('══════════════════════════════════════════════\n') + console.log('Task:', prompt) + console.log('') - await runtime.shutdown() -} + const classification = await agent.handle_user_message(prompt) + console.log(`[${classification.action}] ${agent.state}`) -async function execute_task( - task: string, - provider: any, - toolRegistry: any, - contextAssembler: any, - model: string, - projectRoot: string, - maxTurns: number -): Promise { - const ctx = { - session_id: 'ask-session', - project_id: 'ask-project', - project_root: projectRoot, - agent_id: 'ask-agent', - agent_type: 'executor' as const, - } - - const systemPrompt = `You are an AI coding assistant. Help the user by reading files, writing code, and running commands. - -Available tools: -- fs.read(path) — Read a file -- fs.write(path, content) — Write/create a file -- fs.edit(path, old_str, new_str) — Edit a file -- fs.list(path, depth?) — List directory contents -- fs.stat(path) — Get file info -- shell.run(command, timeout?) — Run a shell command -- git.status() — Show git status -- project.scan(root) — Scan project for source files -- cpp.detect() — Detect C++ project -- cpp.build(target?) — Build C++ project -- cpp.test(filter?) — Run C++ tests - -To use a tool, output EXACTLY: -\`\`\`json -{"tool": "fs.read", "args": {"path": "file.cpp"}} -\`\`\` - -When you are done, output: -DONE: ` - - const messages: any[] = [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: task } - ] - - let turn = 0 - const changedFiles: string[] = [] - - while (turn < maxTurns) { - turn++ - console.log(`─ Turn ${turn}/${maxTurns} ─`) - - // Call LLM - const result = await provider.complete_text(messages, { model, max_tokens: 4096 }) - let text = result.content || '' - - // Strip GLM reasoning tags if present - text = text.replace(/<\/?think>/g, '') - - // Show LLM response summary - const preview = text.slice(0, 100).replace(/\n/g, ' ') - console.log(` LLM: ${preview}...`) - - // Parse tool calls FIRST (before DONE check — LLM may emit both) - const tools = parseToolCalls(text) - - if (tools.length > 0) { - console.log(` Tools: ${tools.map(t => t.name).join(', ')}`) - - // Add assistant message - messages.push({ role: 'assistant', content: text }) - - // Execute each tool call - for (const tool of tools) { - try { - const toolResult = await toolRegistry.call( - { call_id: `ask-${Date.now()}`, name: tool.name, arguments: tool.args }, - ctx - ) - - const output = toolResult.status === 'ok' - ? JSON.stringify(toolResult.output).slice(0, 500) - : `Error: ${JSON.stringify(toolResult.error)}` - - console.log(` ✓ ${tool.name}: ${output.slice(0, 100)}`) - - if (tool.name === 'fs.write' || tool.name === 'fs.edit') { - changedFiles.push(String(tool.args.path || 'unknown')) - } - - messages.push({ - role: 'user', - content: `Tool ${tool.name} result: ${output}` - }) - } catch (e: any) { - messages.push({ - role: 'user', - content: `Tool ${tool.name} error: ${e.message}` - }) - } - } - - // After executing tools, check for DONE - if (text.includes('DONE:')) { - const summary = text.split('DONE:')[1]?.trim() || 'Task completed' - console.log(`\n✅ ${summary}`) - break - } - - // Ask LLM to continue with remaining work - messages.push({ - role: 'user', - content: 'Tools executed successfully. If the task is complete, respond with DONE: . Otherwise continue with more tool calls.' - }) - continue + if (classification.action === 'answer') { + console.log('\n' + (classification.response || 'No response')) + return } - // No tool calls — LLM is just talking - // Check for DONE (no tools to execute) - if (text.includes('DONE:')) { - const summary = text.split('DONE:')[1]?.trim() || 'Task completed' - console.log(`\n✅ ${summary}`) - break + if (classification.action !== 'delegate') { + console.log(classification.response || 'No task created') + return } - } - if (turn >= maxTurns) { - console.log(`\n⚠️ Reached max ${maxTurns} turns. Task may be incomplete.`) - } + if (agent.state === 'CONFIRMING') { + console.log('\n' + (classification.response || 'Confirmation required')) + console.log('No task was created. Use `air run` for interactive confirmation.') + return + } - if (changedFiles.length > 0) { - console.log(`\nChanged files: ${changedFiles.join(', ')}`) - } -} + const taskId = `ask_${Date.now().toString(36)}` + await app.scheduler.create_tasks([{ + id: taskId as any, + type: 'execute', + title: prompt.slice(0, 80), + description: prompt, + task_spec: { + id: taskId, + title: prompt.slice(0, 80), + description: prompt, + acceptance_criteria: ['Task completed successfully'], + model, + max_turns: opts?.maxTurns, + }, + }]) -function parseToolCalls(text: string): Array<{ name: string; args: Record }> { - const calls: Array<{ name: string; args: Record }> = [] + console.log(`Compiling task ${taskId} through Scheduler/Worker...`) + const finalState = await app.scheduler.run_until_idle() + const workerResult = app.worker_manager.get_result_for_task(taskId) - // Pattern 1: ```tool or ```json code blocks - const blockRe = /```(?:tool|json)\s*\n?([\s\S]*?)```/g - for (const match of text.matchAll(blockRe)) { - try { - const parsed = JSON.parse(match[1].trim()) - if (parsed.tool) calls.push({ name: parsed.tool, args: parsed.args || {} }) - } catch { /* skip */ } - } - - // Pattern 2: {"tool": "..."} inline JSON anywhere in text - const jsonRe = /\{\s*"tool"\s*:\s*"([^"]+)"\s*,\s*"args"\s*:\s*(\{[^}]+\})\s*\}/g - for (const match of text.matchAll(jsonRe)) { - try { - const name = match[1] - const args = JSON.parse(match[2]) - if (!calls.some(c => c.name === name)) { - calls.push({ name, args }) + console.log(`Scheduler state: ${finalState}`) + if (workerResult) { + console.log(`Worker status: ${workerResult.status}`) + if (workerResult.summary) console.log(workerResult.summary) + if (workerResult.changed_files.length > 0) { + console.log(`Changed files: ${workerResult.changed_files.join(', ')}`) } - } catch { /* skip */ } + } else { + console.log('No WorkerResult was returned.') + } + } finally { + await runtime.shutdown() } - - return calls } function createProvider(model: string): any { @@ -246,24 +121,22 @@ function createProvider(model: string): any { const apiUrl = process.env.AIRCODING_API_URL || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1' if (!apiKey) { - console.log('⚠️ No API key found. Set AIRCODING_API_KEY or OPENAI_API_KEY.') - console.log(' Export: export AIRCODING_API_KEY="your-key"') + console.log('No API key found. Set AIRCODING_API_KEY or OPENAI_API_KEY.') process.exit(1) } const adapter = new OpenAICompatibleAdapter({ base_url: apiUrl, api_key: apiKey, - model + model, }) - // Wrap adapter in a simple provider interface return { adapters: new Map([['openai-compatible', adapter]]), current_adapter: adapter, current_model: model, async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number } = {}) { return (adapter as any).complete_text(messages, options) - } + }, } } diff --git a/packages/cli/src/commands/compact.ts b/packages/cli/src/commands/compact.ts index c75d72a..7ee10e5 100755 --- a/packages/cli/src/commands/compact.ts +++ b/packages/cli/src/commands/compact.ts @@ -1,13 +1,56 @@ /** - * CompactCommand - Trigger context compaction + * CompactCommand - Trigger context compaction through Scheduler/Worker. * DD §17. */ -export function compactCommand(target_tokens?: number): void { - const tokens = target_tokens || 80000 - console.log(`Context compaction requested: target ~${tokens} tokens`) - console.log('Compaction will:') - console.log(' 1. Summarize conversation history') - console.log(' 2. Keep recent messages intact') - console.log(' 3. Insert compaction marker') - console.log(`Target: ${tokens} tokens (handled by Compactor worker automatically)`) + +import { randomUUID } from 'crypto' +import { existsSync } from 'fs' +import { join } from 'path' +import { loadConfig } from '../bootstrap/loadConfig.js' +import { createRuntime } from '../bootstrap/createRuntime.js' +import { initCommand } from './init.js' + +export async function compactCommand(target_tokens?: number): Promise { + const config = loadConfig(process.env.AIRCODING_PROJECT_ROOT || process.cwd()) + const project_root = config.project_root || process.cwd() + const tokens = target_tokens || config.token_budget || 80000 + + if (!existsSync(join(project_root, '.air', 'shared', 'project.json'))) { + console.log('Project not initialized. Running air init...\n') + await initCommand(project_root) + } + + const runtime = await createRuntime(config) + const app = runtime.app + app.worker_manager.set_context({ + session_id: app.session_id, + project_id: app.project_id, + project_root, + }) + + await app.start() + try { + const taskId = `compact_${randomUUID().slice(0, 8)}` + await app.scheduler.create_tasks([{ + id: taskId, + type: 'compact', + title: `Compact context to ${tokens} tokens`, + description: `Context compaction requested for target budget ${tokens}`, + task_spec: { + task_id: taskId, + current_tokens: config.token_budget || tokens, + threshold: tokens, + target_budget_tokens: tokens, + source_content: 'CLI-triggered context compaction. Rebuild durable context from event store and summaries.', + }, + }]) + + console.log(`Compaction task ${taskId} created. Dispatching compactor worker...`) + const finalState = await app.scheduler.run_until_idle() + const result = app.worker_manager.get_result_for_task(taskId) + console.log(`Compaction scheduler state: ${finalState}`) + console.log(result?.summary || 'Compaction finished without summary') + } finally { + await app.shutdown() + } } diff --git a/packages/cli/src/commands/e2e.ts b/packages/cli/src/commands/e2e.ts index 2893b07..d192eb6 100755 --- a/packages/cli/src/commands/e2e.ts +++ b/packages/cli/src/commands/e2e.ts @@ -53,6 +53,10 @@ function runCmd(label: string, cmd: string, args: string[], cwd?: string, timeou function runTest(label: string, testPath: string, repoRoot: string): { pass: boolean; detail: string } { const bun = findBun() const paths = testPath.split(' ').filter(p => p.length > 0).map(p => join(repoRoot, p.replace(/^\.\//, ''))) + const missing = paths.filter(p => !existsSync(p)) + if (missing.length > 0) { + return { pass: false, detail: `❌\n ${label} missing test paths:\n ${missing.map(p => p.replace(repoRoot + '/', '')).join('\n ')}` } + } return runCmd(label, bun, ['test', ...paths]) } @@ -111,7 +115,7 @@ export function e2eCommand(): void { { label: 'P0: Release-critical functional gates', fn: () => runTest('P0-REL', './packages/runtime/test/regression/release-critical-gates.test.ts ./packages/cli/test/run-command-regression.test.ts', repoRoot) }, // P1: Storage/Events - { label: 'P1: Storage/Events (test)', fn: () => runTest('P1', './packages/runtime/test/storage/ ./packages/runtime/test/regression/transaction-boundary.test.ts ./packages/runtime/test/regression/event-repository-route.test.ts', repoRoot) }, + { label: 'P1: Storage/Events (test)', fn: () => runTest('P1', './packages/runtime/test/regression/transaction-boundary.test.ts ./packages/runtime/test/regression/event-repository-route.test.ts ./packages/runtime/test/regression/task-attempt-repository.test.ts ./packages/runtime/test/regression/evidence-store-persistence.test.ts', repoRoot) }, // P2: Tools/Permission { label: 'P2: Tools/Permission (test)', fn: () => runTest('P2', './packages/runtime/test/regression/tool-stubs.test.ts ./packages/runtime/test/regression/permission-engine-actions.test.ts ./packages/runtime/test/regression/path-classifier-categories.test.ts ./packages/runtime/test/regression/command-risk-analyzer.test.ts', repoRoot) }, diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index 480c975..a497e47 100755 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -8,14 +8,19 @@ import { loadConfig } from '../bootstrap/loadConfig.js' import { createRuntime } from '../bootstrap/createRuntime.js' import { initCommand } from './init.js' -import { TuiApp } from '@aircoding/tui' -import { MainAgent } from '@aircoding/runtime' +import type { TuiApp as TuiAppInstance } from '@aircoding/tui' +import { MainAgent, eventIngestor } from '@aircoding/runtime' import { OpenAICompatibleAdapter } from '@aircoding/llm' -import { existsSync, mkdirSync } from 'fs' +import { existsSync } from 'fs' import { join } from 'path' -import { createInterface } from 'readline' import { randomUUID } from 'crypto' +type TaskResultSummary = { + title: string + files: Array<{ path: string; size: number }> + state: string +} + export async function runCommand(project_path?: string): Promise { const config = loadConfig(project_path) const project_root = config.project_root || process.cwd() @@ -55,26 +60,6 @@ export async function runCommand(project_path?: string): Promise { await app.start() - // Push initial session projection - runtime.projection_client.receive_snapshot({ - session_id: app.session_id, - project_id: app.project_id, - status: 'running', - title: project_root.split('/').pop() || 'AirCoding', - tasks: [], - agents: [], - tool_runs: [], - command_runs: [], - artifacts: [], - permission_prompts: [], - blockers: [], - updated_at: new Date().toISOString() - }) - - // Start TUI - const tui = new TuiApp({ client: runtime.projection_client }) - await tui.start() - // Create MainAgent const agent = new MainAgent({ session_id: app.session_id, @@ -87,18 +72,21 @@ export async function runCommand(project_path?: string): Promise { classify_model: model }) - const session_id = app.session_id + await import('@aircoding/tui/preload') + const { TuiApp } = await import('@aircoding/tui') - // Track task results for /results command - const taskResults = new Map; state: string }>() + const taskResults = new Map() let pendingConfirmation: string | undefined + let tui: TuiAppInstance + let shuttingDown = false - console.log('') - console.log('══════════════════════════════════════════════') - console.log(' AirCoding v1.0.0-alpha') - if (apiKey) console.log(` Model: ${model} (API ready)`); else console.log(' No API key — AI disabled') - console.log(' Type your task, or /help for commands, Ctrl+C to quit') - console.log('══════════════════════════════════════════════\n') + const shutdown = async () => { + if (shuttingDown) return + shuttingDown = true + tui?.stop() + await app.shutdown() + process.exit(0) + } const dispatchTask = async (input: string) => { const taskId = `task_${randomUUID().slice(0, 8)}` @@ -110,87 +98,37 @@ export async function runCommand(project_path?: string): Promise { }]) console.log(`Task ${taskId} created. Dispatching worker...`) - const runPromise = app.scheduler.run_until_idle() - - let dots = 0 - const progressInterval = setInterval(() => { - dots = (dots + 1) % 4 - process.stdout.write(`\r Running${'.'.repeat(dots)} `) - }, 500) - - let finalState: string - try { - finalState = await runPromise - } finally { - clearInterval(progressInterval) - process.stdout.write('\r \r') - } - + tui.set_status(`Task ${taskId} running`) + const finalState = await app.scheduler.run_until_idle() console.log(`Task complete. Scheduler: ${finalState}`) const workerResult = app.worker_manager.get_result_for_task?.(taskId) const resultFiles = Array.isArray(workerResult?.changed_files) ? workerResult.changed_files : [] - const recentFiles: Array<{ path: string; size: number }> = [] + const producedFiles: Array<{ path: string; size: number }> = [] + if (resultFiles.length > 0) { - const { statSync: st, existsSync: ex } = await import('fs') + const { statSync, existsSync: fileExists } = await import('fs') for (const file of resultFiles) { - if (!file || file.startsWith('.air/') || file.includes('/.air/')) continue - const full = join(project_root, file) - if (!ex(full)) continue - const s = st(full) - if (s.isFile()) recentFiles.push({ path: file, size: s.size }) + if (!file || file.startsWith('.air/') || file.includes('/.air/') || file.split('/').some(e => e.startsWith('.'))) continue + const fullPath = join(project_root, file) + if (!fileExists(fullPath)) continue + const stat = statSync(fullPath) + if (stat.isFile()) producedFiles.push({ path: file, size: stat.size }) } - } else { - const { readdirSync: rd, statSync: st, existsSync: ex } = await import('fs') - const scanDir = (d: string, depth: number) => { - if (depth > 3 || !ex(d)) return - try { - for (const e of rd(d)) { - if (e.startsWith('.')) continue - const p = join(d, e) - try { - const s = st(p) - if (s.isDirectory()) scanDir(p, depth + 1) - else if (s.mtimeMs > Date.now() - 60000) recentFiles.push({ path: p.replace(project_root + '/', ''), size: s.size }) - } catch {} - } - } catch {} - } - scanDir(project_root, 0) } - if (recentFiles.length > 0) { + + if (producedFiles.length > 0) { console.log(' Produced files:') - for (const f of recentFiles.slice(0, 10)) { - console.log(` 📄 ${f.path} (${f.size}B)`) + for (const file of producedFiles.slice(0, 10)) { + console.log(` ${file.path} (${file.size}B)`) } } - taskResults.set(taskId, { title: input.slice(0, 80), files: recentFiles, state: finalState }) - - runtime.projection_client.receive_snapshot({ - session_id, - project_id: app.project_id, - status: finalState === 'COMPLETED' ? 'completed' : 'running', - title: project_root.split('/').pop() || 'AirCoding', - tasks: [{ id: taskId as any, type: 'execute', status: finalState === 'COMPLETED' ? 'completed' : 'running', title: input.slice(0, 80), retry_count: 0, attempts: 1, created_at: new Date().toISOString() }], - agents: [], - tool_runs: [], - command_runs: [], - artifacts: [], - permission_prompts: [], - blockers: [], - updated_at: new Date().toISOString() - }) + taskResults.set(taskId, { title: input.slice(0, 80), files: producedFiles, state: finalState }) + tui.set_status(`Task ${taskId}: ${finalState}`) } - // Interactive input loop - const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' }) - rl.prompt() - - rl.on('line', async (line: string) => { - const input = line.trim() - if (!input) { rl.prompt(); return } - + const handleSubmit = async (input: string) => { if (pendingConfirmation) { if (/^(y|yes|是|确认|确定)$/i.test(input)) { const confirmedInput = pendingConfirmation @@ -201,59 +139,89 @@ export async function runCommand(project_path?: string): Promise { pendingConfirmation = undefined await agent.handle_confirmation(false) console.log('Cancelled. No task was created.\n') + tui.set_status('Cancelled') } else { console.log('Please answer y/n to confirm or cancel the pending destructive request.\n') + tui.set_status('Waiting for confirmation') } - rl.prompt() return } - // Handle slash commands - if (input.startsWith('/')) { - await handleSlashCommand(input, app, runtime, tui, rl, taskResults) - rl.prompt() - return - } - - // Route through MainAgent const classification = await agent.handle_user_message(input) console.log(`[${classification.action}]`) if (classification.action === 'answer') { console.log('\n' + (classification.response || 'No response') + '\n') + tui.set_status('Answered') } else if (classification.action === 'delegate') { if (agent.state === 'CONFIRMING' && classification.response) { pendingConfirmation = input console.log('\n' + classification.response + '\n') + tui.set_status('Waiting for confirmation') } else { await dispatchTask(input) } } else { console.log(`Result: ${classification.response || 'Done'}`) + tui.set_status(classification.response || 'Done') } + } - rl.prompt() + const resolvePermission = async (prompt_id: string, selected_option: string) => { + await eventIngestor.ingest({ + id: `evt_${prompt_id}_resolved_${randomUUID().slice(0, 8)}`, + type: 'permission.prompt.resolved', + version: 1, + session_id: app.session_id, + project_id: app.project_id, + timestamp: new Date().toISOString(), + source: { kind: 'main', id: 'tui' }, + route: ['cli', 'tui', 'permission'], + payload: { + prompt_id, + selected_option, + decision_id: `decision_${randomUUID().slice(0, 8)}`, + resolved_by: 'user', + }, + }) + tui.set_status(`Permission ${selected_option}`) + } + + tui = new TuiApp({ + client: runtime.projection_client, + onSubmit: handleSubmit, + onSlashCommand: (input) => handleSlashCommand(input, app, tui, taskResults, shutdown), + onResolvePermission: resolvePermission, + onExit: shutdown, + }) + await tui.start() + + console.log('') + console.log('══════════════════════════════════════════════') + console.log(' AirCoding v1.0.0-alpha') + if (apiKey) console.log(` Model: ${model} (API ready)`); else console.log(' No API key — AI disabled') + console.log(' Type your task, or /help for commands, Ctrl+C to quit') + console.log('══════════════════════════════════════════════\n') + + process.once('SIGINT', () => { + void shutdown() }) - rl.on('close', async () => { - console.log('\nShutting down...') - tui.stop() - await app.shutdown() - process.exit(0) - }) - - process.on('SIGINT', () => { - rl.close() - }) - - await new Promise(() => {}) // Wait forever + await new Promise(() => {}) } -async function handleSlashCommand(input: string, app: any, runtime: any, tui: any, rl: any, taskResults?: Map): Promise { +async function handleSlashCommand( + input: string, + app: any, + tui: TuiAppInstance, + taskResults: Map, + shutdown: () => Promise, +): Promise { const cmd = input.slice(1).toLowerCase() switch (cmd) { case 'help': + tui.set_view('help') console.log('\nCommands:') console.log(' /help — Show this help') console.log(' /status — Show scheduler and worker status') @@ -264,15 +232,15 @@ async function handleSlashCommand(input: string, app: any, runtime: any, tui: an break case 'results': - if (!taskResults || taskResults.size === 0) { + if (taskResults.size === 0) { console.log(' No task results yet. Submit a task first.\n') } else { console.log('') - for (const [taskId, result] of taskResults) { + for (const [_taskId, result] of taskResults) { console.log(` Task: ${result.title} [${result.state}]`) if (result.files.length > 0) { - for (const f of result.files) { - console.log(` 📄 ${f.path} (${f.size}B)`) + for (const file of result.files) { + console.log(` ${file.path} (${file.size}B)`) } } else { console.log(' (no files produced)') @@ -289,13 +257,14 @@ async function handleSlashCommand(input: string, app: any, runtime: any, tui: an break case 'tools': { + tui.set_view('tools') const tools = app.tool_registry.list() console.log(`\n ${tools.length} tools registered:`) const cats = new Map() - for (const t of tools) { - const list = cats.get(t.category) || [] - list.push(t.name) - cats.set(t.category, list) + for (const tool of tools) { + const list = cats.get(tool.category) || [] + list.push(tool.name) + cats.set(tool.category, list) } for (const [cat, names] of cats) { console.log(` ${cat}: ${names.join(', ')}`) @@ -305,6 +274,7 @@ async function handleSlashCommand(input: string, app: any, runtime: any, tui: an } case 'tasks': { + tui.set_view('tasks') const counts = app.scheduler.get_graph().count_by_status() console.log(`\n Task graph:`) for (const [status, count] of Object.entries(counts)) { @@ -316,7 +286,7 @@ async function handleSlashCommand(input: string, app: any, runtime: any, tui: an case 'quit': case 'exit': - rl.close() + await shutdown() break default: diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c8f40c2..df4f4f8 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -20,7 +20,6 @@ * @module packages/cli */ -import { runCommand } from './commands/run.js' import { initCommand } from './commands/init.js' import { doctorCommand } from './commands/doctor.js' import { providerCommand } from './commands/provider.js' @@ -39,9 +38,11 @@ export async function main(argv: string[]): Promise { const rest = args.slice(1) switch (command) { - case 'run': + case 'run': { + const { runCommand } = await import('./commands/run.js') await runCommand(rest[0]) break + } case 'ask': await askCommand(rest.join(' '), { @@ -71,7 +72,7 @@ export async function main(argv: string[]): Promise { break case 'compact': - compactCommand(rest[0] ? parseInt(rest[0]) : undefined) + await compactCommand(rest[0] ? parseInt(rest[0]) : undefined) break case 'history': diff --git a/packages/cli/test/run-command-regression.test.ts b/packages/cli/test/run-command-regression.test.ts index 9aa4c36..2edcd92 100755 --- a/packages/cli/test/run-command-regression.test.ts +++ b/packages/cli/test/run-command-regression.test.ts @@ -1,6 +1,9 @@ -import { describe, it, expect } from 'bun:test' +import { describe, it, expect, afterEach } from 'bun:test' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' import { readFileSync } from 'fs' import { join } from 'path' +import { loadConfig } from '../src/bootstrap/loadConfig.js' describe('run command result presentation', () => { const source = readFileSync(join(import.meta.dir, '../src/commands/run.ts'), 'utf-8') @@ -22,3 +25,44 @@ describe('run command result presentation', () => { expect(source).toContain('No task was created') }) }) + +describe('ask command architecture boundary', () => { + const source = readFileSync(join(import.meta.dir, '../src/commands/ask.ts'), 'utf-8') + + it('dispatches delegate work through Scheduler and WorkerManager', () => { + expect(source).toContain('app.scheduler.create_tasks') + expect(source).toContain('app.scheduler.run_until_idle') + expect(source).toContain('app.worker_manager.get_result_for_task') + }) + + it('does not implement an inline LLM-to-tool loop', () => { + expect(source).not.toContain('parseToolCalls') + expect(source).not.toContain('toolRegistry.call') + expect(source).not.toContain('while (turn <') + }) +}) + +describe('project root configuration', () => { + const created: string[] = [] + afterEach(() => { + delete process.env.AIRCODING_PROJECT_ROOT + for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true }) + }) + + it('uses AIRCODING_PROJECT_ROOT when no explicit project path is passed', () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'air-load-config-')) + created.push(projectRoot) + process.env.AIRCODING_PROJECT_ROOT = projectRoot + + expect(loadConfig().project_root).toBe(projectRoot) + }) + + it('explicit project path wins over AIRCODING_PROJECT_ROOT', () => { + const envRoot = mkdtempSync(join(tmpdir(), 'air-load-config-env-')) + const explicitRoot = mkdtempSync(join(tmpdir(), 'air-load-config-explicit-')) + created.push(envRoot, explicitRoot) + process.env.AIRCODING_PROJECT_ROOT = envRoot + + expect(loadConfig(explicitRoot).project_root).toBe(explicitRoot) + }) +}) diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 14fc6e1..f910707 100755 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -15,7 +15,8 @@ }, "dependencies": { "@aircoding/contracts": "workspace:*", - "@aircoding/llm": "workspace:*" + "@aircoding/llm": "workspace:*", + "@aircoding/toolchain-cpp": "workspace:*" }, "devDependencies": { "@types/node": "^25.9.1", diff --git a/packages/runtime/src/app/RuntimeApp.ts b/packages/runtime/src/app/RuntimeApp.ts index 77c575d..d1e350a 100755 --- a/packages/runtime/src/app/RuntimeApp.ts +++ b/packages/runtime/src/app/RuntimeApp.ts @@ -20,9 +20,9 @@ import { DatabaseManager } from '../storage/DatabaseManager.js' import { MigrationRunner } from '../storage/MigrationRunner.js' import { ToolRegistry, createToolRegistry } from '../tools/ToolRegistry.js' import { BuiltInToolRegistrar } from '../tools/BuiltInToolRegistrar.js' -import { EventBus } from '../events/EventBus.js' +import { EventBus, eventBus, type Subscription } from '../events/EventBus.js' import { EventStore, eventStore } from '../events/EventStore.js' -import { EventIngestorImpl } from '../events/EventIngestor.js' +import { EventIngestorImpl, eventIngestor } from '../events/EventIngestor.js' import { TaskRepository } from '../storage/repositories/TaskRepository.js' import { MessageRepository } from '../storage/repositories/MessageRepository.js' import { EvidenceRepository } from '../storage/repositories/EvidenceRepository.js' @@ -48,6 +48,8 @@ export interface RuntimeAppConfig { export class RuntimeApp { private config: RuntimeAppConfig + private projection_subscription: Subscription | null = null + private projection_client_unsubscribe: (() => void) | null = null scheduler: Scheduler worker_manager: WorkerManager context_assembler: ContextAssembler @@ -86,18 +88,22 @@ export class RuntimeApp { this.doctor = new DoctorService(config.project_root, this.capability_registry) this.projection_store = new ProjectionStore() this.projection_client = new ProjectionClient() - this.event_bus = new EventBus() + this.event_bus = eventBus const raw_db = this.db.getRawDatabase() // Wire singleton eventStore with real DB (EventIngestor uses it) if (raw_db) eventStore.setTransactionManager(this.db) // Use module singleton eventStore - don't create separate instance this.event_store = eventStore - this.event_ingestor = new EventIngestorImpl() + this.event_ingestor = eventIngestor // Wire ProjectionStore → ProjectionClient (DD §13.2) - this.projection_store.subscribe((projection) => { + this.projection_client_unsubscribe = this.projection_store.subscribe((projection) => { this.projection_client.receive_snapshot(projection) }) + this.projection_subscription = this.event_bus.subscribe( + { session_id: config.session_id }, + (event) => this.projection_store.apply(event), + ) // Wire Scheduler to WorkerManager (DD §7.1) this.scheduler = new Scheduler({ @@ -150,13 +156,19 @@ export class RuntimeApp { registrar.register_all(this.config.project_root) this.logger.info('Built-in tools registered') - // Step 4: Wire EventStore with DB transaction manager + // Step 4: Discover project-local SKILL.md capabilities without executing skill content. + await this.discover_project_skills() + + // Step 4.5: Register cpp toolchain via CapabilityRegistry (INV-4) + await this.register_cpp_toolchain() + + // Step 5: Wire EventStore with DB transaction manager this.event_store.setTransactionManager(this.db) - // Step 5: Hydrate ProjectionStore from DB (INV-5: from SQLite, not EventBus) + // Step 6: Hydrate ProjectionStore from DB (INV-5: from SQLite, not EventBus) this.logger.info('Hydrating projection store', { session_id: this.config.session_id }) - // Step 6: Wire all domain repositories to module singleton EventStore + // Step 7: Wire all domain repositories to module singleton EventStore try { const raw_db = this.db.getRawDatabase() if (raw_db) { @@ -181,6 +193,15 @@ export class RuntimeApp { diagnosticRepo, evidenceRepo, workspaceRepo, summaryRepo, }) + this.projection_store.set_repos({ + session: sessionRepo, + task: taskRepo, + agent: agentRepo, + }) + + await this.ensure_session_created(sessionRepo) + await this.projection_store.rebuild(this.config.session_id) + // Reuse repos for context_assembler and scheduler (replace Step 6 duplicate new) this.context_assembler.set_data_sources({ message_repo: messageRepo, evidence_store: evidenceRepo }) this.scheduler.set_task_repo(taskRepo) @@ -194,12 +215,108 @@ export class RuntimeApp { this.logger.info('RuntimeApp started') } + private async discover_project_skills(): Promise { + const roots = [ + join(this.config.project_root, '.air', 'shared', 'skills'), + join(this.config.project_root, '.air', 'shared', 'skill'), + ].filter((root) => existsSync(root)) + + if (roots.length === 0) return + + const discovered = this.capability_registry.discover_skill_roots(roots) + let registered_count = 0 + + for (const result of discovered) { + if (!result.ok || !result.capability_id) { + this.logger.warn('Skill discovery failed', { error: result.error }) + continue + } + + const validation = this.capability_registry.validate(result.capability_id) + if (!validation.valid) { + this.logger.warn('Skill validation failed', { capability_id: result.capability_id, errors: validation.errors }) + continue + } + + const doctor = await this.capability_registry.doctor_check(result.capability_id) + if (!doctor.ok) { + this.logger.warn('Skill doctor check failed', { capability_id: result.capability_id, error: doctor.error }) + continue + } + + const enabled = this.capability_registry.enable(result.capability_id) + if (!enabled.ok) { + this.logger.warn('Skill enable failed', { capability_id: result.capability_id, error: enabled.error }) + continue + } + + const registered = this.capability_registry.register_tools(result.capability_id) + if (!registered.ok) { + this.logger.warn('Skill tool registration failed', { capability_id: result.capability_id, error: registered.error }) + continue + } + registered_count += registered.registered_count + } + + if (registered_count > 0) this.logger.info('Project skills registered', { registered_count }) + } + + /** + * Register cpp toolchain via CapabilityRegistry (INV-4) + * Uses CppToolRegistrar from toolchain-cpp package. + */ + private async register_cpp_toolchain(): Promise { + try { + // Dynamic import to avoid static dependency (INV-4: single direction) + const cppPkg = await import('@aircoding/toolchain-cpp') + const registrar = new cppPkg.CppToolRegistrar() + + // Register tools through the CppToolRegistrar + // This follows INV-4: registered via capability boundary + registrar.register(this.tool_registry, this.config.project_root) + this.logger.info('cpp toolchain registered', { capability_id: 'aircoding-cpp-toolchain' }) + } catch (e: any) { + this.logger.warn('cpp toolchain registration failed', { error: e.message }) + } + } + + private async ensure_session_created(sessionRepo: SessionRepository): Promise { + const existing = await sessionRepo.get(this.config.session_id) + if (existing) return + + const now = new Date().toISOString() + await this.event_ingestor.ingest({ + id: `evt_${this.config.session_id}_created`, + type: 'session.created', + version: 1, + timestamp: now, + session_id: this.config.session_id, + project_id: this.config.project_id, + source: { kind: 'system' }, + route: ['runtime', 'start'], + payload: { + session_id: this.config.session_id, + project_id: this.config.project_id, + project_root: this.config.project_root, + title: this.config.project_root.split('/').pop() || 'AirCoding', + metadata: {}, + }, + }) + } + /** * Shutdown the runtime: flush logs, close DB, cancel workers. */ async shutdown(): Promise { this.logger.info('RuntimeApp shutting down') + if (this.projection_subscription) { + this.event_bus.unsubscribe(this.projection_subscription) + this.projection_subscription = null + } + this.projection_client_unsubscribe?.() + this.projection_client_unsubscribe = null + // Cancel all running workers try { for (const handle of this.worker_manager.list()) { diff --git a/packages/runtime/src/capabilities/CapabilityRegistry.ts b/packages/runtime/src/capabilities/CapabilityRegistry.ts index 8a660c6..8e6782f 100755 --- a/packages/runtime/src/capabilities/CapabilityRegistry.ts +++ b/packages/runtime/src/capabilities/CapabilityRegistry.ts @@ -11,6 +11,7 @@ import type { ToolDefinition } from '@aircoding/contracts' import { CapabilityManifestValidator, createCapabilityManifestValidator, type CapabilityManifest, type ValidationResult } from './CapabilityManifestValidator.js' +import { loadSkillDirectory, loadSkillsFromRoots, type SkillDefinition } from './SkillLoader.js' export type CapabilityState = 'discovered' | 'validated' | 'doctor_checked' | 'enabled' | 'registered' | 'active' | 'disabled' | 'failed' @@ -62,6 +63,30 @@ export class CapabilityRegistry { return { ok: true, capability_id } } + /** + * Discover one SKILL.md directory as a capability manifest. + */ + discover_skill_directory(skill_dir: string, trusted_roots: string[]): { ok: boolean; capability_id?: string; skill?: SkillDefinition; error?: string } { + try { + const skill = loadSkillDirectory(skill_dir, trusted_roots) + const discovered = this.discover(skill.manifest) + return { ...discovered, skill } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + } + + /** + * Discover all SKILL.md entries under trusted roots. + */ + discover_skill_roots(roots: string[]): Array<{ ok: boolean; capability_id?: string; skill?: SkillDefinition; error?: string }> { + try { + return loadSkillsFromRoots(roots).map((skill) => ({ ...this.discover(skill.manifest), skill })) + } catch (error) { + return [{ ok: false, error: error instanceof Error ? error.message : String(error) }] + } + } + /** * Validate a discovered capability. */ diff --git a/packages/runtime/src/capabilities/SkillLoader.ts b/packages/runtime/src/capabilities/SkillLoader.ts new file mode 100755 index 0000000..0a63b45 --- /dev/null +++ b/packages/runtime/src/capabilities/SkillLoader.ts @@ -0,0 +1,117 @@ +/** + * SkillLoader - SKILL.md capability bridge. + * Loads skill directories into Capability manifests without executing skill content. + */ + +import { existsSync, readFileSync, statSync, readdirSync } from 'fs' +import { resolve, relative, basename } from 'path' +import type { CapabilityManifest } from './CapabilityManifestValidator.js' + +export interface SkillDefinition { + id: string + name: string + description: string + directory: string + content: string + frontmatter: Record + manifest: CapabilityManifest +} + +export function loadSkillDirectory(skill_dir: string, trusted_roots: string[]): SkillDefinition { + const directory = resolve(skill_dir) + ensureTrusted(directory, trusted_roots) + const skill_path = resolve(directory, 'SKILL.md') + if (!existsSync(skill_path) || !statSync(skill_path).isFile()) { + throw new Error(`SKILL.md not found in ${directory}`) + } + + const raw = readFileSync(skill_path, 'utf-8') + const parsed = parseSkillMarkdown(raw) + const name = slug(String(parsed.frontmatter.name || basename(directory))) + const description = String(parsed.frontmatter.description || firstParagraph(parsed.body) || `Skill ${name}`) + const toolName = `skill.${name}` + const manifest: CapabilityManifest = { + schema_version: 1, + name, + version: String(parsed.frontmatter.version || '1.0.0'), + description, + trust_level: 'project_local', + tools: [{ + name: toolName, + category: 'internal', + permissions: { read: true, write: false, network: false }, + input_schema: { + type: 'object', + properties: { + task: { type: 'string' }, + skill_directory: { type: 'string' }, + }, + required: ['task'], + }, + }], + } + + return { id: name, name, description, directory, content: parsed.body, frontmatter: parsed.frontmatter, manifest } +} + +export function loadSkillsFromRoots(roots: string[]): SkillDefinition[] { + const skills: SkillDefinition[] = [] + for (const root of roots.map((r) => resolve(r))) { + if (!existsSync(root) || !statSync(root).isDirectory()) continue + const direct = resolve(root, 'SKILL.md') + if (existsSync(direct)) { + skills.push(loadSkillDirectory(root, roots)) + continue + } + const entries = Array.from(new Set(readDirectoryNames(root))) + for (const entry of entries) { + const dir = resolve(root, entry) + if (existsSync(resolve(dir, 'SKILL.md'))) skills.push(loadSkillDirectory(dir, roots)) + } + } + return skills +} + +function ensureTrusted(path: string, roots: string[]): void { + const trusted = roots.map((root) => resolve(root)).some((root) => { + const rel = relative(root, path) + return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/')) + }) + if (!trusted) throw new Error(`Skill path is outside trusted roots: ${path}`) +} + +function parseSkillMarkdown(raw: string): { frontmatter: Record; body: string } { + if (!raw.startsWith('---\n')) return { frontmatter: {}, body: raw.trim() } + const end = raw.indexOf('\n---\n', 4) + if (end === -1) return { frontmatter: {}, body: raw.trim() } + const frontmatter = parseFrontmatter(raw.slice(4, end)) + return { frontmatter, body: raw.slice(end + 5).trim() } +} + +function parseFrontmatter(text: string): Record { + const out: Record = {} + for (const line of text.split(/\r?\n/)) { + const idx = line.indexOf(':') + if (idx <= 0) continue + const key = line.slice(0, idx).trim() + const value = line.slice(idx + 1).trim().replace(/^['"]|['"]$/g, '') + out[key] = value + } + return out +} + +function firstParagraph(text: string): string { + return text.split(/\n\s*\n/).map((p) => p.trim()).find(Boolean) || '' +} + +function slug(value: string): string { + const next = value.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') + return next || 'skill' +} + +function readDirectoryNames(root: string): string[] { + return readdirSync(root).filter((name) => { + const path = resolve(root, name) + return statSync(path).isDirectory() + }) +} diff --git a/packages/runtime/src/doctor/DoctorService.ts b/packages/runtime/src/doctor/DoctorService.ts index 25be676..5acf2d9 100755 --- a/packages/runtime/src/doctor/DoctorService.ts +++ b/packages/runtime/src/doctor/DoctorService.ts @@ -12,7 +12,7 @@ import { execFileSync } from 'child_process' export interface DoctorCheck { name: string - category: 'self_bootstrap' | 'capability' | 'project' | 'runtime' + category: 'self_bootstrap' | 'capability' | 'project' | 'runtime' | 'toolchain' | 'display' | 'network' | 'provider' passed: boolean message: string fixable: boolean @@ -66,6 +66,14 @@ export class DoctorService { checks.push(this.check_capability_deps()) } + // FR-018/§6.12: toolchain / display / network / provider checks + if (scope === 'all') { + checks.push(...this.check_cpp_toolchain()) + checks.push(this.check_display()) + checks.push(await this.check_network()) + checks.push(...await this.check_provider()) + } + const all_passed = checks.every(c => c.passed) return { checks, all_passed, bootstrap_passed: true, fixable_count: checks.filter(c => c.fixable).length } } @@ -219,4 +227,98 @@ export class DoctorService { return { name: 'capability_deps', category: 'capability', passed: false, message: `Capability check failed: ${e.message}`, fixable: true } } } + + // ===== FR-018/§6.12: 5 new categories ===== + + private check_cpp_toolchain(): DoctorCheck[] { + const tools = ['cmake', 'ninja', 'cppcheck', 'clangd', 'g++'] + const reports: DoctorCheck[] = [] + for (const t of tools) { + try { + const v = execFileSync('which', [t], { stdio: 'pipe', timeout: 3000 }).toString().trim() + reports.push({ name: `toolchain.${t}`, category: 'toolchain', passed: true, message: `${t} found at ${v}`, fixable: false }) + } catch { + reports.push({ name: `toolchain.${t}`, category: 'toolchain', passed: false, message: `${t} not found`, fixable: true, fix: `apt install ${t === 'cmake' ? 'cmake' : t === 'ninja' ? 'ninja-build' : t}` }) + } + } + return reports + } + + private check_display(): DoctorCheck { + const display = process.env.DISPLAY + const wayland = process.env.WAYLAND_DISPLAY + if (!display && !wayland) { + return { name: 'display', category: 'display', passed: false, message: 'No DISPLAY/WAYLAND_DISPLAY (gui.screenshot will fail)', fixable: false } + } + try { + execFileSync('which', ['import'], { stdio: 'pipe' }) + return { name: 'display', category: 'display', passed: true, message: `Display ${display || wayland} + ImageMagick available`, fixable: false } + } catch { + return { name: 'display', category: 'display', passed: false, message: 'ImageMagick not installed', fixable: true, fix: 'apt install imagemagick' } + } + } + + private async check_network(): Promise { + try { + const r = await fetch('https://1.1.1.1', { method: 'HEAD', signal: AbortSignal.timeout(3000) }) + return { name: 'network.internet', category: 'network', passed: r.ok || r.status > 0, message: `HTTP ${r.status}`, fixable: false } + } catch (e: any) { + return { name: 'network.internet', category: 'network', passed: false, message: e.message, fixable: false } + } + } + + private async check_provider(): Promise { + const reports: DoctorCheck[] = [] + const apiKey = process.env.AIRCODING_API_KEY || process.env.OPENAI_API_KEY + const baseUrl = process.env.OPENAI_BASE_URL || process.env.AIRCODING_API_URL + const model = process.env.AIRCODING_MODEL + + reports.push({ + name: 'provider.api_key', + category: 'provider', + passed: Boolean(apiKey), + message: apiKey ? `API key set (${apiKey.slice(0, 7)}...)` : 'No API key set', + fixable: false, + }) + reports.push({ + name: 'provider.base_url', + category: 'provider', + passed: Boolean(baseUrl), + message: baseUrl ? `Base URL: ${baseUrl}` : 'No base URL set', + fixable: false, + }) + reports.push({ + name: 'provider.model', + category: 'provider', + passed: Boolean(model), + message: model || 'No model set', + fixable: false, + }) + + if (apiKey && baseUrl) { + try { + const r = await fetch(`${baseUrl.replace(/\/$/, '')}/v1/models`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(5000), + }) + reports.push({ + name: 'provider.connectivity', + category: 'provider', + passed: r.ok || r.status > 0, + message: `HTTP ${r.status}`, + fixable: false, + }) + } catch (e: any) { + reports.push({ + name: 'provider.connectivity', + category: 'provider', + passed: false, + message: e.message, + fixable: false, + }) + } + } + return reports + } } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 2515bf5..78756c3 100755 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -35,6 +35,8 @@ export { BuiltInToolRegistrar, register_builtin_tools } from './tools/BuiltInToo // Capabilities export { CapabilityManifestValidator, createCapabilityManifestValidator } from './capabilities/CapabilityManifestValidator.js' export { CapabilityRegistry, createCapabilityRegistry } from './capabilities/CapabilityRegistry.js' +export { loadSkillDirectory, loadSkillsFromRoots } from './capabilities/SkillLoader.js' +export type { SkillDefinition } from './capabilities/SkillLoader.js' // Context export { PromptLayerLoader, createPromptLayerLoader } from './context/PromptLayerLoader.js' diff --git a/packages/runtime/src/projection/ProjectionStore.ts b/packages/runtime/src/projection/ProjectionStore.ts index d0aaad0..9c5ad82 100755 --- a/packages/runtime/src/projection/ProjectionStore.ts +++ b/packages/runtime/src/projection/ProjectionStore.ts @@ -65,8 +65,12 @@ export interface ArtifactProjection { export interface PermissionPromptProjection { prompt_id: string - tool_name: string + subject: string + risk_level: string reason: string + options: string[] + default_option?: string + tool_name?: string } export interface BlockerProjection { @@ -291,7 +295,12 @@ export class ProjectionStore { case 'permission.prompt.requested': { proj.permission_prompts.push({ prompt_id: p.prompt_id || `pp_${Date.now()}`, - tool_name: p.tool_name, reason: p.reason || '' + subject: p.subject || p.tool_name || 'permission request', + risk_level: p.risk_level || 'unknown', + reason: p.reason || '', + options: Array.isArray(p.options) ? p.options : [], + default_option: p.default_option, + tool_name: p.tool_name, }) break } @@ -334,14 +343,17 @@ export class ProjectionStore { * Returns the rebuilt projection. */ async rebuild(session_id: string): Promise { + const session = this.repos.session ? await this.repos.session.get(session_id as SessionID) : undefined const tasks = this.repos.task ? await this.repos.task.list_by_status(session_id, ['pending', 'running', 'interrupted', 'completed', 'failed', 'blocked', 'cancelled']) : [] const agents = this.repos.agent ? await this.repos.agent.list_active(session_id) : [] - // Initialize projection with what we have + if (!session && tasks.length === 0 && agents.length === 0) return undefined + const proj: SessionProjection = { session_id, - project_id: '', - status: 'active', + project_id: session?.project_id ?? '', + status: session?.status ?? 'active', + title: session?.title, tasks: tasks.map((t: any) => ({ id: t.id, type: t.type, status: t.status, title: t.title || '', retry_count: t.retry_count || 0, attempts: 0, created_at: t.created_at || '', @@ -359,6 +371,7 @@ export class ProjectionStore { updated_at: new Date().toISOString() } this.snapshot.set(session_id, proj) + this.notify(proj) return proj } diff --git a/packages/runtime/src/scheduler/Scheduler.ts b/packages/runtime/src/scheduler/Scheduler.ts index 0e5bcc0..3ec9c83 100755 --- a/packages/runtime/src/scheduler/Scheduler.ts +++ b/packages/runtime/src/scheduler/Scheduler.ts @@ -14,7 +14,7 @@ import { WavePlanner } from './WavePlanner.js' import { RetryPlanner } from './RetryPlanner.js' import { WorkspaceManager } from './WorkspaceManager.js' import { AgentMonitor } from './AgentMonitor.js' -import { eventIngestor } from '../events/EventIngestor.js' +import { eventIngestor, type IEventIngestor } from '../events/EventIngestor.js' import type { WorkerManager } from '../workers/WorkerManager.js' export type SchedulerState = @@ -48,9 +48,11 @@ export class Scheduler { private context: SchedulerContext private worker_manager?: WorkerManager private task_repo?: any + private event_ingestor: IEventIngestor - constructor(context: SchedulerContext, worker_manager?: WorkerManager) { + constructor(context: SchedulerContext, worker_manager?: WorkerManager, ingestor: IEventIngestor = eventIngestor) { this.context = context + this.event_ingestor = ingestor this.graph = new TaskGraph() this.wave_planner = new WavePlanner() this.retry_planner = new RetryPlanner() @@ -62,18 +64,19 @@ export class Scheduler { /** * Create tasks from specifications. */ - async create_tasks(tasks: Array<{ id: TaskID; type: string; title: string; description?: string; depends_on?: string[] }>): Promise { + async create_tasks(tasks: Array<{ id: TaskID; type: string; title: string; description?: string; depends_on?: string[]; task_spec?: Record }>): Promise { for (const task of tasks) { this.graph.add_task({ id: task.id, status: 'pending', type: task.type, title: task.title, description: task.description, + task_spec: task.task_spec, dependencies: task.depends_on?.map(d => ({ task_id: d, type: 'hard' as const })) || [] }) // Emit task.created events (INV-1: via event store for projection) - await eventIngestor.ingest({ + await this.event_ingestor.ingest({ id: `evt_${task.id}_created`, type: 'task.created', version: 1, @@ -86,7 +89,7 @@ export class Scheduler { task_id: task.id, type: task.type, title: task.title, - task_spec_json: { description: task.description || '' }, + task_spec_json: task.task_spec || { description: task.description || '' }, dependencies: (task.depends_on || []).map(d => ({ depends_on_task_id: d, dependency_type: 'hard', reason: '' })), metadata: {}, } @@ -132,25 +135,23 @@ export class Scheduler { break case 'PLANNING_WAVE': { - // Check if all tasks done const counts = this.graph.count_by_status() - const remaining = (counts.pending || 0) + (counts.running || 0) + const pending = counts.pending || 0 + const running = counts.running || 0 - if (remaining === 0) { - this.state = 'COMPLETED' + if (pending === 0 && running === 0) { + this.state = this.terminal_state_from_counts(counts) + return + } + + if (running > 0) { + this.state = 'MONITORING' return } - // Plan next wave const plan = this.wave_planner.plan(this.graph) if (plan.length === 0) { - // Check for blocked tasks - const pending = this.graph.count_by_status().pending || 0 - if (pending > 0) { - this.state = 'REPAIRING_OR_CONTINUING' - return - } - this.state = 'COMPLETED' + this.state = pending > 0 ? 'REPAIRING_OR_CONTINUING' : this.terminal_state_from_counts(counts) return } @@ -165,7 +166,7 @@ export class Scheduler { // INV-1: Emit task.started event (durable) for projection const now = new Date().toISOString() - await eventIngestor.ingest({ + await this.event_ingestor.ingest({ id: `evt_${task.id}_started`, type: 'task.started', version: 1, @@ -185,7 +186,7 @@ export class Scheduler { session_id: this.context.session_id, project_root: this.context.project_root, task_type: task.type || 'execute', - task_spec: { + task_spec: task.task_spec || { id: task.id, title: task.title || task.id, description: task.description || '', @@ -196,7 +197,7 @@ export class Scheduler { this.agent_monitor.record_heartbeat(agent_id, task.id) // INV-1: Emit agent.started event (durable) for projection - await eventIngestor.ingest({ + await this.event_ingestor.ingest({ id: `evt_${agent_id}_started`, type: 'agent.started', version: 1, @@ -218,7 +219,7 @@ export class Scheduler { }) } catch { // INV-1: emit task.failed event for projection - await eventIngestor.ingest({ + await this.event_ingestor.ingest({ id: `evt_${task.id}_failed`, type: 'task.failed', version: 1, @@ -245,7 +246,7 @@ export class Scheduler { const hb = this.agent_monitor.get(l.agent_id) if (hb) { const now = new Date().toISOString() - await eventIngestor.ingest({ + await this.event_ingestor.ingest({ id: `evt_${hb.task_id}_lost`, type: 'agent.lost', version: 1, @@ -269,7 +270,7 @@ export class Scheduler { case 'hard_cancel': case 'soft_cancel': if (task_id) { - await eventIngestor.ingest({ + await this.event_ingestor.ingest({ id: `evt_${task_id}_cancelled`, type: 'agent.cancelled', version: 1, @@ -299,7 +300,7 @@ export class Scheduler { const attempt_id = `${task.id}_1` if (result.status === 'completed') { - await eventIngestor.ingest({ + await this.event_ingestor.ingest({ id: `evt_${task.id}_completed`, type: 'task.completed', version: 1, @@ -320,7 +321,7 @@ export class Scheduler { }) this.graph.update_status(task.id, 'completed') // INV-1: Emit agent.completed event (durable) for projection - await eventIngestor.ingest({ + await this.event_ingestor.ingest({ id: `evt_${handle.worker_id}_completed`, type: 'agent.completed', version: 1, @@ -333,7 +334,7 @@ export class Scheduler { }) this.agent_monitor.remove(handle.worker_id) } else if (result.status === 'blocked') { - await eventIngestor.ingest({ + await this.event_ingestor.ingest({ id: `evt_${task.id}_blocked`, type: 'task.blocked', version: 1, @@ -347,7 +348,7 @@ export class Scheduler { this.graph.update_status(task.id, 'blocked') this.agent_monitor.remove(handle.worker_id) } else if (result.status === 'cancelled') { - await eventIngestor.ingest({ + await this.event_ingestor.ingest({ id: `evt_${task.id}_cancelled_result`, type: 'task.cancelled', version: 1, @@ -361,7 +362,7 @@ export class Scheduler { this.graph.update_status(task.id, 'cancelled') this.agent_monitor.remove(handle.worker_id) } else { - await eventIngestor.ingest({ + await this.event_ingestor.ingest({ id: `evt_${task.id}_failed_result`, type: 'task.failed', version: 1, @@ -374,7 +375,7 @@ export class Scheduler { }) this.graph.update_status(task.id, 'failed') // INV-1: Emit agent.failed event (durable) for projection - await eventIngestor.ingest({ + await this.event_ingestor.ingest({ id: `evt_${handle.worker_id}_failed`, type: 'agent.failed', version: 1, @@ -440,6 +441,13 @@ export class Scheduler { } } + private terminal_state_from_counts(counts: Record): SchedulerState { + if ((counts.failed || 0) > 0) return 'TERMINATED' + if ((counts.blocked || 0) > 0) return 'BLOCKED' + if ((counts.cancelled || 0) > 0) return 'CANCELLED' + return 'COMPLETED' + } + /** * Rebuild scheduler state from SQLite (INV-5: from EventStore, not EventBus). * Loads pending/running tasks from the tasks table and reconstructs the in-memory graph. diff --git a/packages/runtime/src/scheduler/TaskGraph.ts b/packages/runtime/src/scheduler/TaskGraph.ts index bfd5b8b..225b5ef 100755 --- a/packages/runtime/src/scheduler/TaskGraph.ts +++ b/packages/runtime/src/scheduler/TaskGraph.ts @@ -19,6 +19,7 @@ export interface TaskNode { title?: string description?: string acceptance_criteria?: string[] + task_spec?: Record } export interface GraphValidation { diff --git a/packages/runtime/src/tools/BuiltInToolRegistrar.ts b/packages/runtime/src/tools/BuiltInToolRegistrar.ts index 7b91bba..457aad4 100755 --- a/packages/runtime/src/tools/BuiltInToolRegistrar.ts +++ b/packages/runtime/src/tools/BuiltInToolRegistrar.ts @@ -132,20 +132,6 @@ export class BuiltInToolRegistrar { 'project.profile.write': def('project.profile.write', 'project', 'Write language profile/toolchain configuration', { language: { type: 'string', description: 'Language (cpp/c/rust/python)' }, profile_json: { type: 'object', description: 'Profile configuration' } }, ['language', 'profile_json'], { read: false, write: true, network: false }), - // cpp toolchain - 'cpp.detect': def('cpp.detect', 'debug', 'Detect C++ project structure, toolchain, and source files', - { project_root: { type: 'string', description: 'Project root path' } }, []), - 'cpp.cmake.configure': def('cpp.cmake.configure', 'build', 'Configure C++ build with CMake (Ninja preferred, Make fallback)', - { generator: { type: 'string', description: 'Generator (Ninja/Unix Makefiles)' }, build_type: { type: 'string', description: 'Debug/Release/RelWithDebInfo' } }, [], - { read: true, write: true, network: false }), - 'cpp.build': def('cpp.build', 'build', 'Build C++ project via CMake', - { target: { type: 'string', description: 'Build target' }, config: { type: 'string', description: 'Debug/Release' } }, []), - 'cpp.test': def('cpp.test', 'test', 'Run C++ tests via ctest', - { filter: { type: 'string', description: 'Test filter pattern' } }, []), - 'cpp.static.cppcheck': def('cpp.static.cppcheck', 'static_analysis', 'Run cppcheck static analysis on C++ code', - { path: { type: 'string', description: 'Path to analyze' }, severity: { type: 'string', description: 'Minimum severity' } }, []), - 'cpp.clangd.query': def('cpp.clangd.query', 'static_analysis', 'Query clangd LSP for symbol definition or diagnostics', - { file: { type: 'string', description: 'Source file path' }, line: { type: 'number', description: 'Line number' }, column: { type: 'number', description: 'Column number' } }, ['file']), // debug 'debug.run': def('debug.run', 'debug', 'Run debugger on a target process or binary', { target: { type: 'string', description: 'Binary or process to debug' }, breakpoints: { type: 'array', items: { type: 'string' } } }, ['target']), @@ -257,88 +243,6 @@ export class BuiltInToolRegistrar { } }, - 'cpp.detect': async (call: any) => { - try { - const root = (call.arguments as any)?.project_root || project_root - const cmake = existsSync(join(root, 'CMakeLists.txt')) - const makefile = existsSync(join(root, 'Makefile')) - return { status: "ok", call_id: call.call_id, tool_name, type: 'text', - output: { has_cmake: cmake, has_makefile: makefile, build_system: cmake ? 'cmake' : makefile ? 'make' : 'none' }, - metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } } - } catch (e: any) { - return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name }, - metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } } - } - }, - - 'cpp.cmake.configure': async (call: any) => { - try { - const { generator = 'Ninja', build_type = 'Debug' } = (call.arguments || {}) as any - const buildDir = join(project_root, 'build') - if (!existsSync(buildDir)) mkdirSync(buildDir, { recursive: true }) - execFileSync('cmake', ['-G', generator, '-DCMAKE_BUILD_TYPE=' + build_type, '..'], { cwd: buildDir, stdio: 'pipe' }) - return { status: "ok", call_id: call.call_id, tool_name, type: 'text', output: { generator, build_type, configured: true }, - metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } } - } catch (e: any) { - return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name }, - metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } } - } - }, - - 'cpp.build': async (call: any) => { - try { - const { target, config = 'Debug' } = (call.arguments || {}) as any - const args = target ? ['--build', '.', '--config', config, '--target', target] : ['--build', '.', '--config', config] - const out = execFileSync('cmake', args, { cwd: join(project_root, 'build'), stdio: 'pipe', timeout: 300000 }) - return { status: "ok", call_id: call.call_id, tool_name, type: 'text', - output: { built: true, output: out.toString().slice(-500) }, - metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } } - } catch (e: any) { - return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name }, - metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } } - } - }, - - 'cpp.test': async (call: any) => { - try { - const { filter } = (call.arguments || {}) as any - const args = filter ? ['--output-on-failure', '-R', filter] : ['--output-on-failure'] - const out = execFileSync('ctest', args, { cwd: join(project_root, 'build'), stdio: 'pipe', timeout: 300000 }) - return { status: "ok", call_id: call.call_id, tool_name, type: 'text', - output: { passed: true, output: out.toString().slice(-1000) }, - metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } } - } catch (e: any) { - return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name }, - metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } } - } - }, - - 'cpp.static.cppcheck': async (call: any) => { - try { - const { path = 'src' } = (call.arguments || {}) as any - const out = execFileSync('cppcheck', ['--enable=all', '--quiet', path], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8', timeout: 120000 }) - return { status: "ok", call_id: call.call_id, tool_name, type: 'text', - output: { output: out.toString().slice(-500), issues_found: 0 }, - metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } } - } catch (e: any) { - return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name }, - metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } } - } - }, - - 'cpp.clangd.query': async (call: any) => { - try { - const { file, line = 0, column = 0 } = (call.arguments || {}) as any - const out = execFileSync('clangd', ['--check=' + file], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8', timeout: 30000 }) - return { status: "ok", call_id: call.call_id, tool_name, type: 'text', - output: { file, line, column, diagnostics: out.toString().slice(-1000) }, - metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } } - } catch (e: any) { - return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name }, - metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } } - } - }, - 'debug.run': async (call: any) => { try { const { target } = (call.arguments || {}) as any diff --git a/packages/runtime/src/tools/ToolRegistry.ts b/packages/runtime/src/tools/ToolRegistry.ts index 5c7905b..deb3e4d 100755 --- a/packages/runtime/src/tools/ToolRegistry.ts +++ b/packages/runtime/src/tools/ToolRegistry.ts @@ -11,6 +11,8 @@ import type { ToolDefinition, ToolCall, ToolResultEnvelope, ISOTimeString } from import { PermissionEngine, createPermissionEngine, type PermissionContext, type PermissionDecision, type PermissionAction } from '../security/PermissionEngine.js' import type { AgentType } from '@aircoding/contracts' +import { eventIngestor } from '../events/EventIngestor.js' +import { eventBus, type Subscription } from '../events/EventBus.js' export type ToolExecutionReturn = | ToolResultEnvelope @@ -47,6 +49,7 @@ export class ToolRegistry { private executors: Map = new Map() private permission_engine: PermissionEngine private project_root: string + private readonly permission_timeout_ms = 5 * 60 * 1000 constructor(project_root: string) { this.project_root = project_root @@ -263,9 +266,39 @@ export class ToolRegistry { } } - case 'ask_user': - // Suspend; emit permission.prompt.requested - return create_error_result(call.call_id, 'user_prompt_required', 'User confirmation required') + case 'ask_user': { + const prompt_id = `perm_${crypto.randomUUID()}` + await eventIngestor.ingest({ + id: `evt_${prompt_id}`, + type: 'permission.prompt.requested', + version: 1, + session_id: ctx.session_id, + project_id: ctx.project_id, + timestamp: new Date().toISOString(), + source: { kind: 'tool', id: call.name }, + route: ['tool_registry', 'permission'], + payload: { + prompt_id, + subject: call.name, + risk_level: decision.risk_level, + reason: decision.reason, + options: ['allow_once', 'deny'], + default_option: 'deny', + request_ref: { call_id: call.call_id, tool_name: call.name, agent_id: ctx.agent_id }, + }, + }) + + const selected = await this.wait_for_permission(prompt_id, ctx) + if (selected !== 'allow_once' && selected !== 'allow') { + return create_error_result(call.call_id, 'permission_denied', `User selected ${selected}`) + } + + const executor = this.executors.get(call.name) + if (!executor) { + return create_error_result(call.call_id, 'executor_not_found', 'Executor not registered') + } + return this.execute_executor_final(executor, call, ctx) + } case 'deny': return create_error_result(call.call_id, 'permission_denied', decision.reason) @@ -313,6 +346,45 @@ export class ToolRegistry { return Boolean(value && typeof (value as any)[Symbol.asyncIterator] === 'function') } + private wait_for_permission(prompt_id: string, ctx: ToolExecutionContext): Promise { + return new Promise((resolve) => { + let settled = false + let subscription: Subscription | undefined + const finish = (selected: string) => { + if (settled) return + settled = true + clearTimeout(timeout) + if (subscription) eventBus.unsubscribe(subscription) + resolve(selected) + } + + const timeout = setTimeout(() => { + void eventIngestor.ingest({ + id: `evt_${prompt_id}_timeout`, + type: 'permission.prompt.resolved', + version: 1, + session_id: ctx.session_id, + project_id: ctx.project_id, + timestamp: new Date().toISOString(), + source: { kind: 'tool', id: 'permission_timeout' }, + route: ['tool_registry', 'permission'], + payload: { + prompt_id, + selected_option: 'deny', + decision_id: `decision_${crypto.randomUUID()}`, + resolved_by: 'timeout', + }, + }).catch(() => finish('deny')) + }, this.permission_timeout_ms) + + subscription = eventBus.subscribe({ session_id: ctx.session_id, types: ['permission.prompt.resolved'] }, (event) => { + const payload = event.payload as Record + if (payload.prompt_id !== prompt_id) return + finish(String(payload.selected_option || 'deny')) + }) + }) + } + /** * Execute streaming tool. */ diff --git a/packages/runtime/src/workers/WorkerManager.ts b/packages/runtime/src/workers/WorkerManager.ts index b381eb1..1090cc7 100755 --- a/packages/runtime/src/workers/WorkerManager.ts +++ b/packages/runtime/src/workers/WorkerManager.ts @@ -13,6 +13,8 @@ import type { ChildProcess } from 'child_process' import { WorkerProcess, type WorkerExitCode } from './WorkerProcess.js' import { WorkerProtocol, type WorkerMessage, type WorkerMessageType } from './WorkerProtocol.js' import type { WorkerResult, WorkerStatus, AgentType } from '@aircoding/contracts' +import { eventIngestor } from '../events/EventIngestor.js' +import { eventSchemaRegistry } from '../events/EventSchemaRegistry.js' import type { ToolRegistry } from '../tools/ToolRegistry.js' import type { ProviderManager } from '@aircoding/llm' @@ -85,6 +87,7 @@ export class WorkerManager { state: 'starting', started_at: new Date().toISOString() } + this.workers.set(config.agent_id, handle) // Spawn worker process using Bun // Worker must run from AirCoding repo root so Bun can resolve modules @@ -126,7 +129,6 @@ export class WorkerManager { }) handle.state = 'ready' - this.workers.set(config.agent_id, handle) // Set up timeout if (config.timeout_ms) { @@ -238,6 +240,40 @@ export class WorkerManager { } }) + // Handle worker-emitted RuntimeEvent payloads through the single EventIngestor entry point. + proc.on_message('event', async (msg) => { + try { + const event_type = (msg.payload.event_type || msg.payload.type) as string + if (!event_type || !eventSchemaRegistry.isRegistered(event_type, 1)) { + console.error(`[WM] ignoring unregistered worker event: ${event_type || '(missing)'}`) + return + } + + const { event_type: _eventType, ...restPayload } = msg.payload + const payload = _eventType ? restPayload : (() => { + const { type: _legacyType, ...legacyPayload } = restPayload + return legacyPayload + })() + const event = { + id: (payload.event_id as string) || msg.id, + type: event_type, + version: 1, + timestamp: msg.timestamp || new Date().toISOString(), + session_id: this.execution_context?.session_id || msg.session_id, + project_id: this.execution_context?.project_id || '', + source: { kind: 'agent', id: agent_id, agent_type: this.worker_agent_type(agent_id) }, + route: ['worker', agent_id, event_type], + payload, + } + + const persistence = eventSchemaRegistry.getPersistence(event_type, 1) + if (persistence === 'durable') await eventIngestor.ingest(event as any) + else if (persistence === 'ephemeral') await eventIngestor.ingest_ephemeral(event as any) + } catch (e: any) { + console.error('[WM] worker event ingest error:', e.message) + } + }) + // Handle worker.result → update handle proc.on_message('worker.result', (msg) => { const handle = this.workers.get(agent_id) @@ -324,20 +360,32 @@ export class WorkerManager { * Get result for a task. */ get_result_for_task(task_id: string): WorkerResult | undefined { - return this.list().find(w => w.config.task_spec?.id === task_id)?.result + return this.list().find(w => w.config.task_spec?.id === task_id || w.config.task_spec?.task_id === task_id || w.worker_id === `agent_${task_id}`)?.result } /** * Get handle for a task. */ get_handle_for_task(task_id: string): WorkerHandle | undefined { - return this.list().find(w => w.config.task_spec?.id === task_id) + return this.list().find(w => w.config.task_spec?.id === task_id || w.config.task_spec?.task_id === task_id || w.worker_id === `agent_${task_id}`) } // ============================================================================ // Private // ============================================================================ + private worker_agent_type(agent_id: string): AgentType { + const handle = this.workers.get(agent_id) + const task_type = handle?.config.task_type || 'execute' + switch (task_type) { + case 'review': return 'reviewer' as AgentType + case 'debug': return 'debugger' as AgentType + case 'compact': return 'compactor' as AgentType + case 'mine_experience': return 'experience_miner' as AgentType + default: return 'executor' as AgentType + } + } + private handle_worker_exit(agent_id: string, exit: { code: number | null; signal: NodeJS.Signals | null; semantic: string; description: string }): void { const handle = this.workers.get(agent_id) if (!handle) return @@ -371,9 +419,11 @@ export class WorkerManager { const raw_status = (payload.status as string) || 'completed' const status = raw_status === 'completed' || raw_status === 'cancelled' || raw_status === 'blocked' || raw_status === 'failed' ? raw_status - : raw_status === 'fixed' || raw_status === 'pass' + : raw_status === 'fixed' || raw_status === 'cannot_reproduce' || raw_status === 'pass' || raw_status === 'compacted' || raw_status === 'skipped' || raw_status === 'no_patterns' ? 'completed' - : 'failed' + : raw_status === 'escalated' + ? 'blocked' + : 'failed' const changes = Array.isArray((payload as any).changes) ? (payload as any).changes : [] const changed_files = (payload.changed_files as string[] | undefined) || changes.map((c: any) => String(c.file)).filter(Boolean) const verification_payload = payload.verification as any @@ -381,13 +431,15 @@ export class WorkerManager { : verification_payload ? [{ command: 'worker verification', passed: Boolean(verification_payload.passed), output: String(verification_payload.output || '') }] as any[] : [] const summary = (payload.summary as string) + || (payload.summary_content as string) + || (payload.root_cause as string) || (payload.error ? String(payload.error) : '') || (changed_files.length > 0 ? `Changed files: ${changed_files.join(', ')}` : `Worker ${status}`) return { - task_id: (payload.task_id as string) || (handle.config.task_spec?.id as string) || '' as any, + task_id: (payload.task_id as string) || (handle.config.task_spec?.id as string) || (handle.config.task_spec?.task_id as string) || '' as any, agent_id: (payload.agent_id as string) || handle.config.agent_id as any, - agent_type: (payload.agent_type as AgentType) || 'executor', + agent_type: (payload.agent_type as AgentType) || this.worker_agent_type(handle.config.agent_id), status: status as WorkerStatus, summary, changed_files, diff --git a/packages/runtime/test/regression/evidence-store-persistence.test.ts b/packages/runtime/test/regression/evidence-store-persistence.test.ts index 285b75a..2d2a242 100755 --- a/packages/runtime/test/regression/evidence-store-persistence.test.ts +++ b/packages/runtime/test/regression/evidence-store-persistence.test.ts @@ -1,59 +1,55 @@ -/** - * Regression test: EvidenceStore SQLite persistence - * - * Verifies that EvidenceStore uses SQLite (bun:sqlite) instead of - * in-memory Map for persistent storage. - */ - -import { describe, test, expect } from 'bun:test' -import { readFileSync } from 'fs' +import { afterEach, describe, expect, test } from 'bun:test' +import { Database } from 'bun:sqlite' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' import { join } from 'path' - -const SOURCE_PATH = join( - import.meta.dir, - '..', - '..', - 'src', - 'artifacts', - 'EvidenceStore.ts' -) - -const source = readFileSync(SOURCE_PATH, 'utf-8') +import { createEvidenceStore } from '../../src/artifacts/EvidenceStore.js' +import { createNullEventIngestor } from '../../src/events/EventIngestor.js' describe('EvidenceStore SQLite persistence', () => { - test('EvidenceStore does not use in-memory Map', () => { - // Should not have Map< for storage - expect(source).not.toMatch(/evidenceStore:\s*Map { + for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true }) }) - test('EvidenceStore constructor accepts Database parameter', () => { - // Constructor should accept a Database parameter - expect(source).toContain('db: Database') - // Should import Database from bun:sqlite - expect(source).toContain("from 'bun:sqlite'") - }) + test('persists evidence refs in SQLite and can list them by entity', async () => { + const dir = mkdtempSync(join(tmpdir(), 'air-evidence-store-')) + created.push(dir) + const dbPath = join(dir, 'evidence.db') - test('EvidenceStore has initSchema method', () => { - expect(source).toContain('initSchema()') - // Should be called in constructor - expect(source).toContain('this.initSchema()') - }) + const db1 = new Database(dbPath) + const store1 = createEvidenceStore('session_evidence' as any, db1, createNullEventIngestor() as any) + const createdRef = await store1.create({ + kind: 'command_output', + ref: 'artifact://stdout.txt', + claim: 'command produced expected output', + task_id: 'task_1' as any, + location_json: { line: 1 }, + }) + expect(createdRef.evidence_ref_id).toStartWith('evi_') + expect((await store1.list_for_entity('task', 'task_1'))[0]).toMatchObject({ + evidence_ref_id: createdRef.evidence_ref_id, + kind: 'command_output', + ref: 'artifact://stdout.txt', + claim: 'command produced expected output', + location_json: { line: 1 }, + }) + db1.close() - test('EvidenceStore creates evidence_refs table', () => { - expect(source).toContain('CREATE TABLE IF NOT EXISTS evidence_refs') - // Should have key columns - expect(source).toContain('evidence_ref_id TEXT PRIMARY KEY') - expect(source).toContain('session_id TEXT NOT NULL') - expect(source).toContain('kind TEXT NOT NULL') - }) - - test('EvidenceStore uses INSERT INTO for create', () => { - expect(source).toContain('INSERT INTO evidence_refs') - }) - - test('EvidenceStore applies WAL PRAGMA', () => { - expect(source).toContain('PRAGMA journal_mode = WAL') + const db2 = new Database(dbPath) + const rows = db2.query('SELECT evidence_ref_id, session_id, kind, ref, claim, location_json, task_id FROM evidence_refs').all() as any[] + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ + evidence_ref_id: createdRef.evidence_ref_id, + session_id: 'session_evidence', + kind: 'command_output', + ref: 'artifact://stdout.txt', + claim: 'command produced expected output', + task_id: 'task_1', + }) + expect(JSON.parse(rows[0].location_json)).toEqual({ line: 1 }) + expect(db2.query("PRAGMA journal_mode").get()).toEqual({ journal_mode: 'wal' }) + db2.close() }) }) diff --git a/packages/runtime/test/regression/knowledge-store-schema.test.ts b/packages/runtime/test/regression/knowledge-store-schema.test.ts index a924257..21b81f3 100755 --- a/packages/runtime/test/regression/knowledge-store-schema.test.ts +++ b/packages/runtime/test/regression/knowledge-store-schema.test.ts @@ -1,111 +1,88 @@ -/** - * C1 regression: Knowledge Store schema alignment. - * Bug: DebugKnowledgeStore and LearnedMemoryStore used .air/shared/ paths, - * had non-canonical column names, and were missing PRAGMAs. - * Fix: moved to .air/local/, renamed columns, added WAL/synchronous/foreign_keys PRAGMAs. - */ - -import { describe, it, expect } from 'bun:test' -import { readFileSync } from 'fs' +import { afterEach, describe, expect, it } from 'bun:test' +import { existsSync, mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' import { join } from 'path' +import { DebugKnowledgeStore } from '../../src/knowledge/DebugKnowledgeStore.js' +import { LearnedMemoryStore } from '../../src/knowledge/LearnedMemoryStore.js' describe('C1: Knowledge Store schema alignment', () => { - const debug_src = readFileSync( - join(import.meta.dir, '..', '..', 'src', 'knowledge', 'DebugKnowledgeStore.ts'), - 'utf-8' - ) - const memory_src = readFileSync( - join(import.meta.dir, '..', '..', 'src', 'knowledge', 'LearnedMemoryStore.ts'), - 'utf-8' - ) + const created: string[] = [] - it('DebugKnowledgeStore DB path uses .air/local/ not .air/shared/', () => { - expect(debug_src).toContain("'.air', 'local', 'debug-records.db'") - expect(debug_src).not.toContain("'.air', 'shared', 'debug-records.db'") + afterEach(() => { + for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true }) }) - it('LearnedMemoryStore DB path uses .air/local/ not .air/shared/', () => { - expect(memory_src).toContain("'.air', 'local', 'learned-memory.db'") - expect(memory_src).not.toContain("'.air', 'shared', 'learned-memory.db'") + it('DebugKnowledgeStore stores and queries records from .air/local', () => { + const root = mkdtempSync(join(tmpdir(), 'air-debug-store-')) + created.push(root) + const store = new DebugKnowledgeStore(root) + store.open() + const now = new Date().toISOString() + + store.insert({ + id: 'debug_1', + failure_signature: 'compiler:error:missing-header', + task_id: 'task_1', + root_cause: 'missing include path', + fix_ref: 'fix://1', + summary: 'Add include path before rebuilding', + evidence_json: JSON.stringify(['evi_1']), + verification_json: JSON.stringify(['build passed']), + created_at: now, + updated_at: now, + metadata_json: JSON.stringify({ source: 'test' }), + }) + + expect(existsSync(join(root, '.air', 'local', 'debug-records.db'))).toBe(true) + expect(existsSync(join(root, '.air', 'shared', 'debug-records.db'))).toBe(false) + expect(store.lookup_by_signature('compiler:error:missing-header')).toHaveLength(1) + expect(store.lookup_by_task('task_1')[0]).toMatchObject({ + id: 'debug_1', + failure_signature: 'compiler:error:missing-header', + task_id: 'task_1', + root_cause: 'missing include path', + fix_ref: 'fix://1', + summary: 'Add include path before rebuilding', + }) + + store.update('debug_1', { summary: 'Updated summary', updated_at: now }) + expect(store.lookup_by_signature('compiler:error:missing-header')[0].summary).toBe('Updated summary') }) - it('DebugRecord has failure_signature not signature', () => { - const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/) - expect(iface_match).not.toBeNull() - const iface_body = iface_match![1] + it('LearnedMemoryStore stores candidates/promoted memories from .air/local', () => { + const root = mkdtempSync(join(tmpdir(), 'air-memory-store-')) + created.push(root) + const store = new LearnedMemoryStore(root) + store.open() + const now = new Date().toISOString() - expect(iface_body).toContain('failure_signature') - // Should not have bare 'signature' field (failure_signature contains 'signature' as substring, so check for the exact field pattern) - expect(iface_body).not.toMatch(/^\s*signature\s*:/m) - }) + store.insert({ + id: 'mem_1', + memory_type: 'project_rule', + summary: 'Use Bun for package scripts', + content: 'Project commands should use Bun unless explicitly overridden.', + source_entity_type: 'task', + source_entity_id: 'task_1', + status: 'candidate', + created_at: now, + updated_at: now, + metadata_json: JSON.stringify({ confidence: 0.8 }), + }) - it('DebugRecord has summary and fix_ref fields', () => { - const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/) - expect(iface_match).not.toBeNull() - const iface_body = iface_match![1] + expect(existsSync(join(root, '.air', 'local', 'learned-memory.db'))).toBe(true) + expect(existsSync(join(root, '.air', 'shared', 'learned-memory.db'))).toBe(false) + expect(store.lookup_by_type('project_rule')).toHaveLength(1) + expect(store.lookup_by_type('project_rule')[0]).toMatchObject({ + id: 'mem_1', + memory_type: 'project_rule', + status: 'candidate', + source_entity_type: 'task', + source_entity_id: 'task_1', + }) - expect(iface_body).toContain('summary') - expect(iface_body).toContain('fix_ref') - }) - - it('DebugRecord does not have error_kind or session_id', () => { - const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/) - expect(iface_match).not.toBeNull() - const iface_body = iface_match![1] - - expect(iface_body).not.toContain('error_kind') - expect(iface_body).not.toContain('session_id') - }) - - it('DebugKnowledgeStore applies WAL PRAGMA', () => { - expect(debug_src).toContain('PRAGMA journal_mode = WAL') - }) - - it('LearnedMemoryStore table is learned_memories (plural)', () => { - expect(memory_src).toContain('learned_memories') - // Ensure we don't have the singular form used as table name - expect(memory_src).not.toMatch(/FROM learned_memory\b/) - expect(memory_src).not.toMatch(/INTO learned_memory\b/) - expect(memory_src).not.toMatch(/UPDATE learned_memory\b/) - expect(memory_src).not.toMatch(/TABLE.*learned_memory\b/) - }) - - it('MemoryEntry.memory_type has 4 spec values', () => { - const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/) - expect(iface_match).not.toBeNull() - const iface_body = iface_match![1] - - expect(iface_body).toContain("'project_rule'") - expect(iface_body).toContain("'toolchain_rule'") - expect(iface_body).toContain("'skill_update'") - expect(iface_body).toContain("'debug_experience'") - expect(iface_body).toContain('memory_type') - }) - - it('MemoryEntry.status has 4 spec values: candidate, promoted, archived, rejected', () => { - const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/) - expect(iface_match).not.toBeNull() - const iface_body = iface_match![1] - - expect(iface_body).toContain("'candidate'") - expect(iface_body).toContain("'promoted'") - expect(iface_body).toContain("'archived'") - expect(iface_body).toContain("'rejected'") - }) - - it('MemoryEntry.status default is candidate not draft', () => { - // Check that the CREATE TABLE DDL uses 'candidate' as default - expect(memory_src).toContain("DEFAULT 'candidate'") - expect(memory_src).not.toContain("DEFAULT 'draft'") - }) - - it('MemoryEntry uses source_entity_type + source_entity_id', () => { - const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/) - expect(iface_match).not.toBeNull() - const iface_body = iface_match![1] - - expect(iface_body).toContain('source_entity_type') - expect(iface_body).toContain('source_entity_id') - expect(iface_body).not.toContain('source_task_ids') + store.update_status('mem_1', 'promoted') + expect(store.lookup_by_type('project_rule')[0].status).toBe('promoted') + store.update_status('mem_1', 'archived') + expect(store.lookup_by_type('project_rule')).toHaveLength(0) }) }) diff --git a/packages/runtime/test/regression/projection-store-apply.test.ts b/packages/runtime/test/regression/projection-store-apply.test.ts new file mode 100755 index 0000000..eb4b50f --- /dev/null +++ b/packages/runtime/test/regression/projection-store-apply.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'bun:test' +import { ProjectionStore } from '../../src/projection/ProjectionStore.js' +import type { RuntimeEvent } from '@aircoding/contracts' + +function event(type: string, payload: Record): RuntimeEvent> { + return { + id: `evt_${type}_${Math.random().toString(36).slice(2)}`, + type, + version: 1, + timestamp: new Date().toISOString(), + session_id: 'session_projection_apply' as any, + project_id: 'project_projection_apply' as any, + source: { kind: 'system' }, + route: ['test', type], + payload, + } +} + +describe('ProjectionStore.apply', () => { + it('applies task and agent lifecycle events into a live snapshot', () => { + const store = new ProjectionStore() + const updates: string[] = [] + store.subscribe((projection) => { + updates.push(`${projection.tasks[0]?.status || 'none'}:${projection.agents[0]?.status || 'none'}`) + }) + + store.apply(event('task.created', { + task_id: 'task_1', + type: 'execute', + title: 'Create file', + task_spec_json: {}, + dependencies: [], + metadata: {}, + })) + store.apply(event('task.started', { + task_id: 'task_1', + agent_id: 'agent_task_1', + attempt_id: 'task_1_1', + attempt_index: 0, + workspace_id: 'ws_task_1', + })) + store.apply(event('agent.started', { + agent_id: 'agent_task_1', + agent_type: 'executor', + task_id: 'task_1', + metadata: {}, + })) + store.apply(event('agent.completed', { + agent_id: 'agent_task_1', + task_id: 'task_1', + summary: 'done', + metadata: {}, + })) + store.apply(event('task.completed', { + task_id: 'task_1', + agent_id: 'agent_task_1', + attempt_id: 'task_1_1', + worker_result_json: { status: 'completed' }, + summary: 'done', + changed_files: ['hello.txt'], + evidence_refs: [], + })) + + const snapshot = store.get_snapshot('session_projection_apply') + expect(snapshot).toBeDefined() + expect(snapshot!.tasks).toHaveLength(1) + expect(snapshot!.tasks[0].status).toBe('completed') + expect(snapshot!.tasks[0].agent_id).toBe('agent_task_1') + expect(snapshot!.tasks[0].attempts).toBe(1) + expect(snapshot!.agents).toHaveLength(1) + expect(snapshot!.agents[0].status).toBe('completed') + expect(updates.some((u) => u.startsWith('completed:completed'))).toBe(true) + }) + + it('applies tool, permission, and blocker events', () => { + const store = new ProjectionStore() + + store.apply(event('tool.started', { + tool_run_id: 'tool_1', + tool_name: 'fs.write', + input_json: {}, + metadata: {}, + })) + store.apply(event('tool.completed', { + tool_run_id: 'tool_1', + output_json: { ok: true }, + duration_ms: 12, + artifact_ids: [], + evidence_refs: [], + metadata: {}, + })) + store.apply(event('permission.prompt.requested', { + prompt_id: 'perm_1', + subject: 'shell.run', + risk_level: 'medium', + reason: 'risk score 70 requires user confirmation', + options: ['allow_once', 'deny'], + default_option: 'deny', + request_ref: {}, + })) + store.apply(event('task.created', { + task_id: 'task_blocked', + type: 'execute', + title: 'Blocked task', + task_spec_json: {}, + dependencies: [], + metadata: {}, + })) + store.apply(event('task.blocked', { + task_id: 'task_blocked', + agent_id: 'agent_task_blocked', + reason: 'worker blocked', + blocker_kind: 'worker_blocked', + evidence_refs: [], + suggested_next_step: 'review blocker', + })) + store.apply(event('permission.prompt.resolved', { + prompt_id: 'perm_1', + selected_option: 'deny', + decision_id: 'decision_1', + resolved_by: 'test', + })) + + const snapshot = store.get_snapshot('session_projection_apply') + expect(snapshot).toBeDefined() + expect(snapshot!.tool_runs).toEqual([{ tool_run_id: 'tool_1', tool_name: 'fs.write', status: 'ok', duration_ms: 12 }]) + expect(snapshot!.permission_prompts).toHaveLength(0) + expect(snapshot!.tasks.find((task) => task.id === 'task_blocked')?.status).toBe('blocked') + expect(snapshot!.blockers).toEqual([{ task_id: 'task_blocked', reason: 'worker blocked', blocker_kind: 'worker_blocked' }]) + }) +}) diff --git a/packages/runtime/test/regression/recovery-impl.test.ts b/packages/runtime/test/regression/recovery-impl.test.ts index af661ec..0e5b865 100755 --- a/packages/runtime/test/regression/recovery-impl.test.ts +++ b/packages/runtime/test/regression/recovery-impl.test.ts @@ -1,55 +1,95 @@ -/** - * Regression test: Recovery implementation completeness - * - * Verifies that checkPidLiveness and scanOrphanReferences have real - * implementations, not just stub return values. - */ - -import { describe, test, expect } from 'bun:test' -import { readFileSync } from 'fs' +import { afterEach, describe, expect, test } from 'bun:test' +import { Database } from 'bun:sqlite' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' import { join } from 'path' - -const SOURCE_PATH = join( - import.meta.dir, - '..', - '..', - 'src', - 'storage', - 'Recovery.ts' -) - -const source = readFileSync(SOURCE_PATH, 'utf-8') +import { Recovery } from '../../src/storage/Recovery.js' describe('Recovery implementation', () => { - test('checkPidLiveness is not a stub (has implementation code)', () => { - // Should have actual implementation with loop logic - expect(source).toContain('for (const agent of agents)') - expect(source).toContain("action: alive ? 'keep' : 'mark_lost'") - // Should have more than just a bare return [] - expect(source).toContain('const reports: PidLivenessReport[] = []') + const created: string[] = [] + + afterEach(() => { + for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true }) }) - test('checkPidLiveness uses process.kill for liveness check', () => { - // Should use process.kill(pid, 0) for signal-0 liveness check - expect(source).toContain('process.kill(agent.pid, 0)') + function makeRecovery(): { recovery: Recovery; root: string; artifactRoot: string; dbPath: string } { + const root = mkdtempSync(join(tmpdir(), 'air-recovery-')) + created.push(root) + const artifactRoot = join(root, 'artifacts') + const dbPath = join(root, 'session.db') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE sessions (id TEXT PRIMARY KEY); + CREATE TABLE tasks (id TEXT PRIMARY KEY, session_id TEXT); + CREATE TABLE messages (id TEXT PRIMARY KEY, session_id TEXT); + CREATE TABLE task_attempts (id TEXT PRIMARY KEY, task_id TEXT); + CREATE TABLE agents (id TEXT PRIMARY KEY, session_id TEXT); + CREATE TABLE tool_runs (id TEXT PRIMARY KEY, session_id TEXT); + CREATE TABLE command_runs (id TEXT PRIMARY KEY, session_id TEXT); + CREATE TABLE artifacts (id TEXT PRIMARY KEY, session_id TEXT); + CREATE TABLE evidence_refs (evidence_ref_id TEXT PRIMARY KEY, session_id TEXT); + INSERT INTO tasks (id, session_id) VALUES ('task_orphan', 'missing_session'); + INSERT INTO task_attempts (id, task_id) VALUES ('attempt_orphan', 'missing_task'); + `) + db.close() + return { + recovery: new Recovery({ + sessionId: 'session_recovery' as any, + projectId: 'project_recovery' as any, + artifactRoot, + dbPath, + projectRoot: root, + }), + root, + artifactRoot, + dbPath, + } + } + + test('checks PID liveness with keep/mark_lost actions', () => { + const { recovery } = makeRecovery() + const reports = recovery.checkPidLiveness([ + { agent_id: 'self', pid: process.pid }, + { agent_id: 'missing', pid: 99999999 }, + ]) + recovery.close() + + expect(reports).toEqual([ + { agent_id: 'self', pid: process.pid, alive: true, action: 'keep' }, + { agent_id: 'missing', pid: 99999999, alive: false, action: 'mark_lost' }, + ]) }) - test('scanOrphanReferences returns OrphanReferenceReport structure', () => { - // Should define fkChecks array with the 8 invariant checks - expect(source).toContain('fkChecks') - expect(source).toContain("table: 'tasks'") - expect(source).toContain("table: 'messages'") - expect(source).toContain("table: 'task_attempts'") - expect(source).toContain("table: 'agents'") - expect(source).toContain("table: 'tool_runs'") - expect(source).toContain("table: 'command_runs'") - expect(source).toContain("table: 'artifacts'") - expect(source).toContain("table: 'evidence_refs'") + test('scans orphan references from SQLite tables', async () => { + const { recovery } = makeRecovery() + const report = await recovery.scan() + recovery.close() - // Should iterate over checks - expect(source).toContain('for (const check of fkChecks)') + expect(report.orphanReferences.totalFound).toBeGreaterThanOrEqual(2) + expect(report.orphanReferences.archived).toContainEqual({ + table: 'tasks', + id: 'missing_session', + reason: 'FK-off: session_id → sessions (1 rows)', + }) + expect(report.orphanReferences.archived).toContainEqual({ + table: 'task_attempts', + id: 'missing_task', + reason: 'FK-off: task_id → tasks (1 rows)', + }) + }) - // Should return a proper report - expect(source).toContain('return report') + test('quarantines non-artifact temporary orphan files', async () => { + const { recovery, artifactRoot } = makeRecovery() + const tmpDir = join(artifactRoot, 'tmp') + const orphanPath = join(tmpDir, 'scratch.tmp') + await Bun.write(orphanPath, 'orphan') + + const report = await recovery.scan() + recovery.close() + + expect(report.orphanArtifacts.totalFound).toBe(1) + expect(report.orphanArtifacts.quarantined).toHaveLength(1) + expect(existsSync(report.orphanArtifacts.quarantined[0])).toBe(true) + expect(existsSync(orphanPath)).toBe(false) }) }) diff --git a/packages/runtime/test/regression/release-critical-gates.test.ts b/packages/runtime/test/regression/release-critical-gates.test.ts index 8e15199..bd87843 100755 --- a/packages/runtime/test/regression/release-critical-gates.test.ts +++ b/packages/runtime/test/regression/release-critical-gates.test.ts @@ -7,6 +7,7 @@ import { BuiltInToolRegistrar } from '../../src/tools/BuiltInToolRegistrar.js' import { Scheduler } from '../../src/scheduler/Scheduler.js' import { MainAgent } from '../../src/agents/main/MainAgent.js' import { ContextAssembler } from '../../src/context/ContextAssembler.js' +import { createNullEventIngestor } from '../../src/events/EventIngestor.js' function createRegistry(projectRoot: string): ToolRegistry { const registry = new ToolRegistry(projectRoot) @@ -59,8 +60,8 @@ describe('Release critical gates', () => { get_handle_for_task: () => undefined, get_result_for_task: () => undefined, } - const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any) - scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }]) + const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any, createNullEventIngestor()) + await scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }]) scheduler.get_graph().update_status('task-1' as any, 'running') await scheduler.step() @@ -68,6 +69,34 @@ describe('Release critical gates', () => { expect(scheduler.get_graph().get_tasks_by_status('completed').length).toBe(0) }) + it('scheduler surfaces blocked worker results as BLOCKED, not COMPLETED', async () => { + const workerManager = { + has_running: () => false, + get_handle_for_task: () => ({ worker_id: 'agent-task-1' }), + get_result_for_task: () => ({ + task_id: 'task-1', + agent_id: 'agent-task-1', + agent_type: 'executor', + status: 'blocked', + summary: 'blocked by worker', + changed_files: [], + artifacts: [], + verification: [], + risks: [], + follow_up_tasks: [], + evidence_refs: [], + result: {}, + }), + } + const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any, createNullEventIngestor()) + await scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }]) + scheduler.get_graph().update_status('task-1' as any, 'running') + + const finalState = await scheduler.run_until_idle() + expect(finalState).toBe('BLOCKED') + expect(scheduler.get_graph().get_tasks_by_status('blocked').length).toBe(1) + }) + it('MainAgent answer mode uses assembled project context', async () => { const projectRoot = mkdtempSync(join(tmpdir(), 'air-context-')) writeFileSync(join(projectRoot, 'visible.txt'), 'visible') diff --git a/packages/runtime/test/regression/scheduler-wireup.test.ts b/packages/runtime/test/regression/scheduler-wireup.test.ts index 38cd073..039df6e 100755 --- a/packages/runtime/test/regression/scheduler-wireup.test.ts +++ b/packages/runtime/test/regression/scheduler-wireup.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect } from 'bun:test' import { Scheduler, type SchedulerState } from '../../src/scheduler/Scheduler.js' +import { createNullEventIngestor } from '../../src/events/EventIngestor.js' describe('B1: Scheduler wire-up', () => { it('SchedulerState includes BLOCKED and CANCELLED', () => { @@ -54,10 +55,12 @@ describe('B1: Scheduler wire-up', () => { session_id: 'test-session' as any, project_id: 'test-project' as any, project_root: '/tmp/test' - } + }, + undefined, + createNullEventIngestor(), ) - scheduler.create_tasks([ + await scheduler.create_tasks([ { id: 't1' as any, type: 'code', title: 'Task 1' }, { id: 't2' as any, type: 'code', title: 'Task 2', depends_on: ['t1' as any] } ]) diff --git a/packages/runtime/test/regression/tool-registry-permission.test.ts b/packages/runtime/test/regression/tool-registry-permission.test.ts index 8217eb0..3e6d1bc 100755 --- a/packages/runtime/test/regression/tool-registry-permission.test.ts +++ b/packages/runtime/test/regression/tool-registry-permission.test.ts @@ -50,8 +50,9 @@ describe('A5+A4: ToolRegistry permission fixes', () => { expect(build_match![0]).not.toMatch(/permission_profile:\s*undefined/) }) - it('permission denial branches preserve original call_id', () => { - expect(src).toContain("create_error_result(call.call_id, 'user_prompt_required'") + it('permission branches preserve original call_id', () => { + expect(src).toContain('permission.prompt.requested') + expect(src).toContain('request_ref: { call_id: call.call_id') expect(src).toContain("create_error_result(call.call_id, 'permission_denied'") expect(src).not.toContain("create_error_result('', 'user_prompt_required'") expect(src).not.toContain("create_error_result('', 'permission_denied'") diff --git a/packages/runtime/test/regression/worker-result-envelope.test.ts b/packages/runtime/test/regression/worker-result-envelope.test.ts index d3ed2db..82c2cfd 100755 --- a/packages/runtime/test/regression/worker-result-envelope.test.ts +++ b/packages/runtime/test/regression/worker-result-envelope.test.ts @@ -45,9 +45,13 @@ describe('D3: Worker result envelope', () => { }) it('wrap_worker_result maps role results into WorkerResult with safe defaults', () => { - expect(source).toContain("agent_type: (payload.agent_type as AgentType) || 'executor'") + expect(source).toContain('agent_type: (payload.agent_type as AgentType) || this.worker_agent_type(handle.config.agent_id)') expect(source).toContain("const raw_status = (payload.status as string) || 'completed'") - expect(source).toContain("raw_status === 'fixed' || raw_status === 'pass'") + expect(source).toContain("raw_status === 'fixed'") + expect(source).toContain("raw_status === 'pass'") + expect(source).toContain("raw_status === 'cannot_reproduce'") + expect(source).toContain("raw_status === 'compacted'") + expect(source).toContain("raw_status === 'no_patterns'") expect(source).toContain('changes.map((c: any) => String(c.file))') expect(source).toContain("verification_payload ? [{ command: 'worker verification'") expect(source).toContain('result: (payload.result as unknown) || payload') diff --git a/packages/toolchain-cpp/src/CppToolRegistrar.ts b/packages/toolchain-cpp/src/CppToolRegistrar.ts index dfe37c6..bbce474 100755 --- a/packages/toolchain-cpp/src/CppToolRegistrar.ts +++ b/packages/toolchain-cpp/src/CppToolRegistrar.ts @@ -36,7 +36,7 @@ export class CppToolRegistrar { permissions: { read: true, write: false, network: false }, streaming: false }, async (call) => { const result = detector.detect() - return { call_id: call.id, tool_name: 'cpp.detect', type: 'text', content: result, metadata: { timestamp: new Date().toISOString() } } + return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.detect', output: result, metadata: { timestamp: new Date().toISOString() } } }) // cpp.configure @@ -47,7 +47,11 @@ export class CppToolRegistrar { permissions: { read: true, write: true, network: false }, streaming: false }, async (call) => { const result = configurator.configure({ project_root, generator: call.arguments?.generator as any, build_type: call.arguments?.build_type as any }) - return { call_id: call.id, tool_name: 'cpp.configure', type: result.ok ? 'text' : 'error', content: result, metadata: { timestamp: new Date().toISOString() } } + if (result.ok) { + return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.configure', output: result, metadata: { timestamp: new Date().toISOString() } } + } else { + return { status: 'error', call_id: call.call_id, tool_name: 'cpp.configure', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.error || 'configure failed', retryability: 'not_retryable', semantic_signature: 'cpp.configure' }, metadata: { timestamp: new Date().toISOString() } } + } }) // cpp.build @@ -58,7 +62,11 @@ export class CppToolRegistrar { permissions: { read: true, write: true, network: false }, streaming: false }, async (call) => { const result = builder.build(project_root + '/build', call.arguments?.target as string) - return { call_id: call.id, tool_name: 'cpp.build', type: result.ok ? 'text' : 'error', content: result, metadata: { timestamp: new Date().toISOString() } } + if (result.ok) { + return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.build', output: { built: true, output: result.output, diagnostics: result.diagnostics, elapsed_ms: result.elapsed_ms }, metadata: { timestamp: new Date().toISOString() } } + } else { + return { status: 'error', call_id: call.call_id, tool_name: 'cpp.build', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.output || 'build failed', retryability: 'not_retryable', semantic_signature: 'cpp.build' }, metadata: { timestamp: new Date().toISOString() } } + } }) // cpp.test @@ -69,7 +77,11 @@ export class CppToolRegistrar { permissions: { read: true, write: false, network: false }, streaming: false }, async (call) => { const result = tester.run_tests(project_root + '/build') - return { call_id: call.id, tool_name: 'cpp.test', type: result.ok ? 'text' : 'error', content: result, metadata: { timestamp: new Date().toISOString() } } + if (result.ok) { + return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.test', output: result, metadata: { timestamp: new Date().toISOString() } } + } else { + return { status: 'error', call_id: call.call_id, tool_name: 'cpp.test', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.output || 'test failed', retryability: 'not_retryable', semantic_signature: 'cpp.test' }, metadata: { timestamp: new Date().toISOString() } } + } }) // cpp.cppcheck @@ -80,7 +92,11 @@ export class CppToolRegistrar { permissions: { read: true, write: false, network: false }, streaming: false }, async (call) => { const result = cppcheck.run(project_root, { enable_all: call.arguments?.enable_all as boolean, check_config: call.arguments?.check_config as boolean }) - return { call_id: call.id, tool_name: 'cpp.cppcheck', type: result.ok ? 'text' : 'error', content: result, metadata: { timestamp: new Date().toISOString() } } + if (result.ok) { + return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.cppcheck', output: result, metadata: { timestamp: new Date().toISOString() } } + } else { + return { status: 'error', call_id: call.call_id, tool_name: 'cpp.cppcheck', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.output || 'cppcheck failed', retryability: 'not_retryable', semantic_signature: 'cpp.cppcheck' }, metadata: { timestamp: new Date().toISOString() } } + } }) // cpp.clangd @@ -91,7 +107,11 @@ export class CppToolRegistrar { permissions: { read: true, write: false, network: false }, streaming: false }, async (call) => { const result = await clangd.query_symbol(call.arguments?.file as string, call.arguments?.line as number, call.arguments?.column as number) - return { call_id: call.id, tool_name: 'cpp.clangd', type: 'text', content: result, metadata: { timestamp: new Date().toISOString() } } + if (result.ok) { + return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.clangd', output: result, metadata: { timestamp: new Date().toISOString() } } + } else { + return { status: 'error', call_id: call.call_id, tool_name: 'cpp.clangd', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.error || 'clangd query failed', retryability: 'not_retryable', semantic_signature: 'cpp.clangd' }, metadata: { timestamp: new Date().toISOString() } } + } }) } } diff --git a/packages/tui/package.json b/packages/tui/package.json index 8959ce6..4f77979 100755 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -6,7 +6,8 @@ "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./preload": "./src/preload.ts" }, "scripts": { "typecheck": "tsc --noEmit", @@ -15,7 +16,9 @@ }, "dependencies": { "@aircoding/contracts": "workspace:*", - "@aircoding/runtime": "workspace:*" + "@opentui/core": "0.3.0", + "@opentui/solid": "0.3.0", + "solid-js": "1.9.10" }, "devDependencies": { "@types/node": "^25.9.1", diff --git a/packages/tui/src/ProjectionClient.ts b/packages/tui/src/ProjectionClient.ts index b40983b..3fdadaa 100755 --- a/packages/tui/src/ProjectionClient.ts +++ b/packages/tui/src/ProjectionClient.ts @@ -1,53 +1,28 @@ /** - * ProjectionClient - Local TUI-side projection consumer - * TUI copies minimal projection types from contracts to avoid INV-4 violation - * (TUI must only depend on contracts; dd §13.2 / c4/code-view §2 rule 3). - * - * The runtime package provides the authoritative ProjectionClient in - * `runtime/projection/ProjectionClient.ts`. TUI defines its own local copy - * with the same surface so that subscriptions work in-process. + * ProjectionClient - Local TUI-side projection consumer. + * TUI keeps a contracts-only copy of the ProjectionClient surface so it never imports runtime. * * @module packages/tui/src/ProjectionClient */ -import type { SessionID, ProjectID, TaskID, AgentID, ISOTimeString } from '@aircoding/contracts' +import type { ProjectionSubscriber, SessionProjection } from './types.js' -export interface SessionProjection { - session_id: SessionID - project_id: ProjectID - status: string - title?: string - tasks: TaskProjection[] - agents: AgentProjection[] -} - -export interface TaskProjection { - id: TaskID - type: string - status: string - title: string - retry_count: number - attempts: number - created_at: string -} - -export interface AgentProjection { - id: AgentID - type: string - status: string - task_id?: TaskID - last_heartbeat?: string -} - -export type ProjectionSubscriber = (projection: SessionProjection) => void +export type { + SessionProjection, + TaskProjection, + AgentProjection, + ToolRunProjection, + CommandRunProjection, + ArtifactProjection, + PermissionPromptProjection, + BlockerProjection, + ProjectionSubscriber, +} from './types.js' export class ProjectionClient { private snapshot: SessionProjection | null = null private subscribers: Set = new Set() - /** - * Receive and cache a projection snapshot. - */ receive_snapshot(projection: SessionProjection): void { this.snapshot = projection for (const sub of this.subscribers) { @@ -55,17 +30,11 @@ export class ProjectionClient { } } - /** - * Subscribe to projection updates. - */ subscribe(subscriber: ProjectionSubscriber): () => void { this.subscribers.add(subscriber) return () => this.subscribers.delete(subscriber) } - /** - * Get current snapshot. - */ get_snapshot(): SessionProjection | null { return this.snapshot } diff --git a/packages/tui/src/TuiApp.tsx b/packages/tui/src/TuiApp.tsx index 1c0a636..e88472d 100755 --- a/packages/tui/src/TuiApp.tsx +++ b/packages/tui/src/TuiApp.tsx @@ -1,311 +1,712 @@ +/** @jsxImportSource @opentui/solid */ /** - * TuiApp - Main TUI application shell - * DD §13.2. Real terminal rendering using ANSI escape codes. + * TuiApp - OpenTUI/Solid application shell + * DD §13.2. Projection-only display plus single OpenTUI textarea input owner. * * @module packages/tui/src/TuiApp */ -import { SessionView } from './components/SessionView.js' -import { TaskListView } from './components/TaskListView.js' -import { AgentStatusView } from './components/AgentStatusView.js' -import { HudView } from './components/HudView.js' -import type { SessionProjection } from './ProjectionClient.js' +import { createCliRenderer, type CliRenderer, type TextareaRenderable, type KeyEvent } from '@opentui/core' +import { render, useRenderer, useTerminalDimensions } from '@opentui/solid' +import { createEffect, createSignal, For, onCleanup, onMount, Show } from 'solid-js' +import type { SessionProjection } from './types.js' export interface TuiAppProps { client: { subscribe(handler: (projection: SessionProjection) => void): () => void - receive_snapshot(projection: SessionProjection): void get_snapshot(): SessionProjection | null } + onSubmit?: (input: string) => void | Promise + onSlashCommand?: (input: string) => void | Promise + onResolvePermission?: (prompt_id: string, selected_option: string) => void | Promise + onExit?: () => void | Promise } export interface TuiAppState { projection: SessionProjection | null active_view: 'tasks' | 'agents' | 'tools' | 'diff' | 'help' + busy: boolean + status: string } -const ANSI = { - reset: '\x1b[0m', - bright: '\x1b[1m', - dim: '\x1b[2m', - underscore: '\x1b[4m', - blink: '\x1b[5m', - reverse: '\x1b[7m', - hidden: '\x1b[8m', - fg: { - black: '\x1b[30m', - red: '\x1b[31m', - green: '\x1b[32m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - magenta: '\x1b[35m', - cyan: '\x1b[36m', - white: '\x1b[37m', - gray: '\x1b[90m', - }, - bg: { - black: '\x1b[40m', - red: '\x1b[41m', - green: '\x1b[42m', - yellow: '\x1b[43m', - blue: '\x1b[44m', - magenta: '\x1b[45m', - cyan: '\x1b[46m', - white: '\x1b[47m', - }, - clear: '\x1b[2J\x1b[H', - clearLine: '\x1b[2K', - cursor: { - home: '\x1b[H', - save: '\x1b[s', - restore: '\x1b[u', - hide: '\x1b[?25l', - show: '\x1b[?25h', - up: (n = 1) => `\x1b[${n}A`, - down: (n = 1) => `\x1b[${n}B`, - right: (n = 1) => `\x1b[${n}C`, - left: (n = 1) => `\x1b[${n}D`, +const THEME = { + bg: '#0b0f14', + surface: '#111827', + surface2: '#1f2937', + text: '#e5e7eb', + muted: '#9ca3af', + faint: '#6b7280', + accent: '#22d3ee', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + border: '#374151', +} + +const PROMPT_HISTORY_LIMIT = 200 +const TEXTAREA_MIN_ROWS = 1 +const TEXTAREA_MAX_ROWS = 6 +const EXIT_CONFIRM_MS = 5000 + +type PromptHistoryState = { + items: string[] + index: number | null + draft: string +} + +type PromptHistoryMove = { + state: PromptHistoryState + apply: boolean + text?: string + cursor?: number +} + +function createPromptHistory(): PromptHistoryState { + return { items: [], index: null, draft: '' } +} + +function pushPromptHistory(state: PromptHistoryState, prompt: string): PromptHistoryState { + const text = prompt.trim() + if (!text) return state + if (state.items[state.items.length - 1] === text) { + return { ...state, index: null, draft: '' } } + return { items: [...state.items, text].slice(-PROMPT_HISTORY_LIMIT), index: null, draft: '' } +} + +function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: string, cursor: number): PromptHistoryMove { + if (state.items.length === 0) return { state, apply: false } + if (dir === -1 && cursor !== 0) return { state, apply: false } + if (dir === 1 && cursor !== text.length) return { state, apply: false } + + if (state.index === null) { + if (dir === 1) return { state, apply: false } + const idx = state.items.length - 1 + return { state: { ...state, index: idx, draft: text }, text: state.items[idx], cursor: 0, apply: true } + } + + const idx = state.index + dir + if (idx < 0) return { state, apply: false } + if (idx >= state.items.length) { + return { state: { ...state, index: null }, text: state.draft, cursor: state.draft.length, apply: true } + } + return { state: { ...state, index: idx }, text: state.items[idx], cursor: dir === -1 ? 0 : state.items[idx].length, apply: true } } export class TuiApp { private client: TuiAppProps['client'] - private state: TuiAppState - private unsubscribe: (() => void) | null = null - private running: boolean = false + private onSubmit?: TuiAppProps['onSubmit'] + private onSlashCommand?: TuiAppProps['onSlashCommand'] + private onResolvePermission?: TuiAppProps['onResolvePermission'] + private onExit?: TuiAppProps['onExit'] + private renderer: CliRenderer | null = null + private unsubscribeClient: (() => void) | null = null + private setProjection?: (projection: SessionProjection | null) => void + private setView?: (view: TuiAppState['active_view']) => void + private setBusy?: (busy: boolean) => void + private setStatus?: (status: string) => void constructor(props: TuiAppProps) { this.client = props.client - this.state = { projection: null, active_view: 'tasks' } + this.onSubmit = props.onSubmit + this.onSlashCommand = props.onSlashCommand + this.onResolvePermission = props.onResolvePermission + this.onExit = props.onExit } async start(): Promise { - this.unsubscribe = this.client.subscribe((projection) => { - this.state.projection = projection - this.render() + if (this.renderer) return + + this.renderer = await createCliRenderer({ + targetFps: 30, + maxFps: 60, + useMouse: false, + autoFocus: false, + exitOnCtrlC: false, + screenMode: 'alternate-screen', + externalOutputMode: 'capture-stdout', + consoleMode: 'disabled', + clearOnShutdown: true, + openConsoleOnError: false, + useKittyKeyboard: {}, + backgroundColor: THEME.bg, }) + this.renderer.setBackgroundColor(THEME.bg) - const snapshot = this.client.get_snapshot() - if (snapshot) { - this.state.projection = snapshot - } + await render(() => ( + { + this.setProjection = bindings.setProjection + this.setView = bindings.setView + this.setBusy = bindings.setBusy + this.setStatus = bindings.setStatus + }} + onSubmit={(input) => this.submit(input)} + onSlashCommand={(input) => this.slash(input)} + onResolvePermission={(prompt_id, selected_option) => this.resolvePermission(prompt_id, selected_option)} + onExit={() => this.exit()} + /> + ), this.renderer) - this.running = true - this.setup_input() - this.render() + this.unsubscribeClient = this.client.subscribe((projection) => { + this.setProjection?.(projection) + }) } stop(): void { - this.running = false - this.unsubscribe?.() - this.unsubscribe = null - process.stdout.write(ANSI.cursor.show + ANSI.reset) + this.unsubscribeClient?.() + this.unsubscribeClient = null + this.setProjection = undefined + this.setView = undefined + this.setBusy = undefined + this.setStatus = undefined + + if (this.renderer && !this.renderer.isDestroyed) { + this.renderer.setTerminalTitle('') + this.renderer.externalOutputMode = 'passthrough' + this.renderer.destroy() + } + this.renderer = null } set_view(view: TuiAppState['active_view']): void { - this.state.active_view = view - this.render() + this.setView?.(view) } - private setup_input(): void { - if (process.stdin.isTTY) { - process.stdin.setRawMode(true) - process.stdin.resume() - process.stdin.setEncoding('utf8') + set_busy(busy: boolean, status?: string): void { + this.setBusy?.(busy) + if (status) this.setStatus?.(status) + } - process.stdin.on('data', (key: string) => { - this.handle_input(key) - }) + set_status(status: string): void { + this.setStatus?.(status) + } + + private async submit(input: string): Promise { + const text = input.trim() + if (!text) return + this.setBusy?.(true) + this.setStatus?.(`Running: ${text.slice(0, 72)}`) + try { + await this.onSubmit?.(text) + this.setStatus?.('Ready') + } catch (error) { + this.setStatus?.(error instanceof Error ? error.message : String(error)) + throw error + } finally { + this.setBusy?.(false) } } - private handle_input(key: string): void { - switch (key) { - case 'q': - case '': // Ctrl+C - this.stop() - process.exit(0) - break - case '1': - this.set_view('tasks') - break - case '2': - this.set_view('agents') - break - case '3': - this.set_view('tools') - break - case '4': - this.set_view('diff') - break - case '?': - case 'h': - this.set_view('help') - break + private async slash(input: string): Promise { + const text = input.trim() + if (!text) return + if (text === '/quit' || text === '/exit') { + await this.exit() + return } + this.setStatus?.(`Command: ${text}`) + await this.onSlashCommand?.(text) } - private render(): void { - if (!this.running) return + private async resolvePermission(prompt_id: string, selected_option: string): Promise { + if (!this.onResolvePermission) { + this.setStatus?.('Permission selection requires runtime resolver') + return + } + this.setStatus?.(`Permission: ${selected_option}`) + await this.onResolvePermission(prompt_id, selected_option) + } - const p = this.state.projection - const lines: string[] = [] + private async exit(): Promise { + await this.onExit?.() + } +} - // Header - lines.push(ANSI.clear) - lines.push(ANSI.fg.cyan + ANSI.bright + '═══════════════════════════════════════════════════════════════' + ANSI.reset) - lines.push(ANSI.fg.cyan + ANSI.bright + ' AirCoding v1.0.0-alpha' + ANSI.reset + ANSI.fg.gray + ' │ ' + (p ? `${p.tasks.length} tasks` : 'No session') + ' │ ' + this.get_status_indicator(p) + ANSI.reset) - lines.push(ANSI.fg.cyan + '═══════════════════════════════════════════════════════════════' + ANSI.reset) +type StateBindings = { + setProjection: (projection: SessionProjection | null) => void + setView: (view: TuiAppState['active_view']) => void + setBusy: (busy: boolean) => void + setStatus: (status: string) => void +} - // Navigation hints - lines.push(ANSI.fg.gray + ' [1]Tasks [2]Agents [3]Tools [4]Diff [h]Help [q]Quit' + ANSI.reset) +type FooterPhase = 'idle' | 'running' | 'permission' | 'confirm_exit' | 'error' - // Content area - lines.push('') +function AirCodingView(props: { + initialProjection: SessionProjection | null + bindState: (bindings: StateBindings) => void + onSubmit: (input: string) => void | Promise + onSlashCommand: (input: string) => void | Promise + onResolvePermission: (prompt_id: string, selected_option: string) => void | Promise + onExit: () => void | Promise +}) { + const renderer = useRenderer() + const dimensions = useTerminalDimensions() + const [projection, setProjection] = createSignal(props.initialProjection) + const [activeView, setActiveView] = createSignal('tasks') + const [busy, setBusy] = createSignal(false) + const [status, setStatus] = createSignal('Ready') + const [footerPhase, setFooterPhase] = createSignal('idle') + const [toast, setToast] = createSignal('') + let textarea: TextareaRenderable | undefined + let history = createPromptHistory() + let pasteTick: ReturnType | undefined + let exitConfirmUntil = 0 - if (this.state.active_view === 'help') { - lines.push(...this.render_help()) - } else if (!p) { - lines.push(ANSI.fg.yellow + ' No active session. Run "air init" then "air run".' + ANSI.reset) - lines.push('') - lines.push(ANSI.fg.gray + ' Press q to exit.' + ANSI.reset) + props.bindState({ + setProjection, + setView: setActiveView, + setBusy: (next) => { + setBusy(next) + setFooterPhase(next ? 'running' : 'idle') + }, + setStatus: (next) => { + setStatus(next) + if (/error|failed|blocked|失败|错误/i.test(next)) setFooterPhase('error') + else if (!busy()) setFooterPhase('idle') + }, + }) + + const focusPrompt = () => { + if (textarea && !textarea.isDestroyed) textarea.focus() + } + + const submitPrompt = () => { + if (!textarea || textarea.isDestroyed || busy()) return + const text = textarea.plainText.trim() + if (!text) return + history = pushPromptHistory(history, text) + textarea.setText('') + exitConfirmUntil = 0 + setFooterPhase('running') + setStatus(text.startsWith('/') ? `Command: ${text}` : `Sending: ${text.slice(0, 72)}`) + if (text.startsWith('/')) { + void props.onSlashCommand(text) } else { - switch (this.state.active_view) { - case 'tasks': - lines.push(...this.render_tasks(p)) - break - case 'agents': - lines.push(...this.render_agents(p)) - break - case 'tools': - lines.push(...this.render_tools(p)) - break - case 'diff': - lines.push(...this.render_diff(p)) - break + void props.onSubmit(text) + } + focusPrompt() + } + + const refreshPasteLayout = () => { + if (pasteTick) clearTimeout(pasteTick) + pasteTick = setTimeout(() => { + pasteTick = undefined + if (!textarea || textarea.isDestroyed) return + textarea.getLayoutNode().markDirty() + renderer.requestRender() + void renderer.idle().then(() => renderer.requestRender()).catch(() => {}) + }, 0) + } + + const applyHistoryMove = (dir: -1 | 1) => { + if (!textarea || textarea.isDestroyed) return false + const text = textarea.plainText + const move = movePromptHistory(history, dir, text, textarea.cursorOffset) + history = move.state + if (!move.apply) return false + textarea.setText(move.text ?? '') + textarea.cursorOffset = move.cursor ?? 0 + textarea.getLayoutNode().markDirty() + renderer.requestRender() + return true + } + + const activePermission = () => projection()?.permission_prompts[0] + + const resolvePermissionByIndex = (index: number) => { + const prompt = activePermission() + if (!prompt) return false + const options = prompt.options.length > 0 ? prompt.options : ['allow', 'deny'] + const selected = options[index] + if (!selected) return false + setFooterPhase('permission') + setStatus(`Permission: ${selected}`) + void props.onResolvePermission(prompt.prompt_id, selected) + return true + } + + const handleKeyDown = (event: KeyEvent) => { + const prompt = activePermission() + if (prompt) { + if (event.name === 'left' || event.name === 'h') { + event.preventDefault() + setToast('Use 1/2/3 to choose a permission option') + return + } + if (/^[1-9]$/.test(event.name) && resolvePermissionByIndex(Number(event.name) - 1)) { + event.preventDefault() + return + } + if ((event.name === 'a' || event.name === 'y') && resolvePermissionByIndex(0)) { + event.preventDefault() + return + } + if ((event.name === 'd' || event.name === 'n') && resolvePermissionByIndex(Math.min(1, (prompt.options.length || 2) - 1))) { + event.preventDefault() + return } } - // Footer - lines.push('') - lines.push(ANSI.fg.gray + '─'.repeat(76) + ANSI.reset) - lines.push(ANSI.fg.gray + ' Status: ' + this.get_status_text(p) + ' │ Session: ' + (p?.session_id || 'N/A') + ANSI.reset) + if (event.ctrl && event.name === 'c') { + event.preventDefault() + if (textarea && !textarea.isDestroyed && textarea.plainText.length > 0) { + textarea.setText('') + history = { ...history, index: null, draft: '' } + setFooterPhase('idle') + setStatus('Draft cleared; press Ctrl+C again to exit') + focusPrompt() + return + } + const now = Date.now() + if (now < exitConfirmUntil) { + void props.onExit() + return + } + exitConfirmUntil = now + EXIT_CONFIRM_MS + setFooterPhase('confirm_exit') + setStatus('Press Ctrl+C again within 5s to exit') + return + } - process.stdout.write(lines.join('\n') + '\n') - } + if (event.name === 'up' && applyHistoryMove(-1)) { + event.preventDefault() + return + } - private get_status_indicator(p: SessionProjection | null): string { - if (!p) return ANSI.fg.gray + 'IDLE' + ANSI.reset - switch (p.status) { - case 'running': return ANSI.fg.green + '● RUNNING' + ANSI.reset - case 'completed': return ANSI.fg.blue + '● COMPLETED' + ANSI.reset - case 'error': return ANSI.fg.red + '● ERROR' + ANSI.reset - default: return ANSI.fg.gray + '● ' + p.status.toUpperCase() + ANSI.reset + if (event.name === 'down' && applyHistoryMove(1)) { + event.preventDefault() + return + } + + if (event.name === 'escape') { + event.preventDefault() + exitConfirmUntil = 0 + setActiveView('tasks') + setFooterPhase(busy() ? 'running' : activePermission() ? 'permission' : 'idle') + focusPrompt() + return + } + + if (event.ctrl && event.name === 'l') { + event.preventDefault() + renderer.requestRender() + return + } + + if (!event.ctrl || event.meta) return + + const next = shortcutToView(event.name) + if (next) { + event.preventDefault() + setActiveView(next) + focusPrompt() } } - private get_status_text(p: SessionProjection | null): string { - if (!p) return 'No session' - return `${p.status} | ${p.tasks.length} tasks | ${p.agents.length} agents` + onMount(() => { + renderer.setTerminalTitle('AirCoding') + focusPrompt() + }) + + onCleanup(() => { + if (pasteTick) clearTimeout(pasteTick) + renderer.setTerminalTitle('') + }) + + createEffect(() => { + const hasPermission = (projection()?.permission_prompts.length ?? 0) > 0 + if (hasPermission) setFooterPhase('permission') + else if (!busy() && footerPhase() === 'permission') setFooterPhase('idle') + }) + + createEffect(() => { + projection() + activeView() + busy() + status() + footerPhase() + toast() + renderer.requestRender() + }) + + return ( + +
+