diff --git a/.gitignore b/.gitignore index 9ed34ef4f0..2b0d626eab 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ CUE-NOTES.md CUE-REFACTORING.md Auto\ Run\ Docs/ Work\ Trees/ +.worktrees/ community-data/ .mcp.json specs/ diff --git a/.prettierignore b/.prettierignore index 5c8f23aaf1..6bd3305729 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,7 @@ dist/ release/ node_modules/ coverage/ +.worktrees/ *.min.js .gitignore .husky/_/ diff --git a/README.md b/README.md index 0463ec2a57..5ce1574d13 100644 --- a/README.md +++ b/README.md @@ -181,3 +181,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, architecture detai ## License [AGPL-3.0 License](LICENSE) + +## Star History + + + + + + Star History Chart + + diff --git a/package-lock.json b/package-lock.json index 3f79a06d67..e79c9be0f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.16.1-RC", + "version": "0.16.3-RC", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.16.1-RC", + "version": "0.16.3-RC", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { @@ -274,7 +274,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -678,7 +677,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -722,7 +720,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2296,7 +2293,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2318,7 +2314,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz", "integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2331,7 +2326,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2347,7 +2341,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.208.0", "import-in-the-middle": "^2.0.0", @@ -2735,7 +2728,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2752,7 +2744,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -2770,7 +2761,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz", "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -3829,7 +3819,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4381,7 +4372,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4393,7 +4383,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4519,7 +4508,6 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -4989,7 +4977,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5071,7 +5058,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6087,7 +6073,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -6570,7 +6555,6 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -7296,7 +7280,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -7706,7 +7689,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -8204,7 +8186,6 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -8300,7 +8281,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.3.0", @@ -8444,6 +8426,7 @@ "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -8457,6 +8440,7 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -8476,6 +8460,7 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -8498,6 +8483,7 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8514,6 +8500,7 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -8530,6 +8517,7 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -8544,6 +8532,7 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -8559,6 +8548,7 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -8571,7 +8561,8 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/electron-builder-squirrel-windows/node_modules/string_decoder": { "version": "1.1.1", @@ -8579,6 +8570,7 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -8589,6 +8581,7 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -8599,6 +8592,7 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -8614,6 +8608,7 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -9295,7 +9290,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11215,7 +11209,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -12036,7 +12029,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -12506,14 +12498,16 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", @@ -12526,7 +12520,8 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.isequal": { "version": "4.5.0", @@ -12540,7 +12535,8 @@ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -12554,7 +12550,8 @@ "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/log-symbols": { "version": "4.1.0", @@ -12645,6 +12642,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -14935,7 +14933,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15156,7 +15153,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15397,6 +15393,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -15412,6 +15409,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -15756,7 +15754,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -15786,7 +15783,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -15834,7 +15830,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -16033,8 +16028,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -18088,7 +18082,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18462,7 +18455,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -18968,7 +18960,6 @@ "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", @@ -19559,7 +19550,6 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -20157,7 +20147,6 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index b5e1dc0236..285039ca9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maestro", - "version": "0.16.8-RC", + "version": "0.16.9-RC", "description": "Maestro hones fractured attention into focused intent.", "main": "dist/main/index.js", "author": { @@ -37,7 +37,7 @@ "package:linux": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --linux", "start": "electron .", "clean": "rm -rf dist release node_modules/.vite", - "prepare": "husky || true", + "prepare": "node scripts/setup-git-hooks.mjs", "postinstall": "electron-rebuild -f -w node-pty,better-sqlite3", "lint": "tsc -p tsconfig.lint.json && tsc -p tsconfig.main.json --noEmit && tsc -p tsconfig.cli.json --noEmit", "lint:eslint": "eslint src/", diff --git a/scripts/setup-git-hooks.mjs b/scripts/setup-git-hooks.mjs new file mode 100644 index 0000000000..ef8e86c932 --- /dev/null +++ b/scripts/setup-git-hooks.mjs @@ -0,0 +1,33 @@ +import { existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; + +function runGit(args) { + const result = spawnSync('git', args, { stdio: 'pipe', encoding: 'utf8' }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(result.stderr.trim() || `git ${args.join(' ')} failed`); + } + return result.stdout.trim(); +} + +function getGitConfig(key) { + const result = spawnSync('git', ['config', '--get', key], { stdio: 'pipe', encoding: 'utf8' }); + if (result.error) throw result.error; + if (result.status !== 0) return ''; + return result.stdout.trim(); +} + +if (!existsSync('.git')) { + console.log('[setup-git-hooks] Skipping hook installation because .git is not present.'); + process.exit(0); +} + +const desiredHooksPath = '.husky'; +const currentHooksPath = getGitConfig('core.hooksPath'); + +if (currentHooksPath !== desiredHooksPath) { + runGit(['config', 'core.hooksPath', desiredHooksPath]); + console.log(`[setup-git-hooks] Set core.hooksPath=${desiredHooksPath}`); +} else { + console.log(`[setup-git-hooks] core.hooksPath already set to ${desiredHooksPath}`); +} diff --git a/src/__tests__/main/parsers/rate-limit-event.test.ts b/src/__tests__/main/parsers/rate-limit-event.test.ts new file mode 100644 index 0000000000..014d4ac65a --- /dev/null +++ b/src/__tests__/main/parsers/rate-limit-event.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { ClaudeOutputParser } from '../../../main/parsers/claude-output-parser'; + +describe('rate_limit_event caching', () => { + it('should cache resetsAt from rate_limit_event and attach to next rate_limited error', () => { + const parser = new ClaudeOutputParser(); + const futureResetEpochSeconds = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now + + // Step 1: Process the rate_limit_event line (arrives BEFORE the error) + const rateLimitEvent = { + type: 'rate_limit_event', + rate_limit_info: { + status: 'rejected', + resetsAt: futureResetEpochSeconds, + rateLimitType: 'five_hour', + overageStatus: 'rejected', + overageDisabledReason: 'org_level_disabled', + isUsingOverage: false, + }, + uuid: '50dbdb0f-6850-4c32-bf93-866c1f59cfd3', + session_id: '1ceebdef-cc2a-4d09-b5ac-1191b7042d09', + }; + + // This should cache the resetsAt + const event1 = parser.parseJsonObject(rateLimitEvent); + expect(event1?.type).toBe('system'); + + // Verify internal cache + expect((parser as any).lastRateLimitResetAt).toBe(futureResetEpochSeconds * 1000); + + // Step 2: Process the assistant error line + const assistantError = { + type: 'assistant', + message: { + id: 'b858a1c7-0823-4e5d-8bd1-c82eb350316d', + model: '', + role: 'assistant', + stop_reason: 'stop_sequence', + stop_sequence: '', + type: 'message', + usage: { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + content: [{ type: 'text', text: "You've hit your limit · resets 3pm (America/Winnipeg)" }], + }, + session_id: '1ceebdef-cc2a-4d09-b5ac-1191b7042d09', + uuid: '8b7f641e-799f-4b24-895a-8a66baa75919', + error: 'rate_limit', + }; + + // This should detect the error AND attach rateLimitResetAt + const agentError = parser.detectErrorFromParsed(assistantError); + + expect(agentError).not.toBeNull(); + expect(agentError!.type).toBe('rate_limited'); + expect(agentError!.rateLimitResetAt).toBe(futureResetEpochSeconds * 1000); + + // Cache should be cleared after use + expect((parser as any).lastRateLimitResetAt).toBeNull(); + }); + + it('should fall back to content text parsing if no rate_limit_event was received', () => { + const parser = new ClaudeOutputParser(); + + // No rate_limit_event processed — go straight to assistant error + const assistantError = { + type: 'assistant', + message: { + content: [{ type: 'text', text: "You've hit your limit · resets 3pm (America/Winnipeg)" }], + }, + session_id: 'test', + uuid: 'test', + error: 'rate_limit', + }; + + const agentError = parser.detectErrorFromParsed(assistantError); + + expect(agentError).not.toBeNull(); + expect(agentError!.type).toBe('rate_limited'); + // Should have a reset time from content text parsing + expect(agentError!.rateLimitResetAt).toBeDefined(); + const now = Date.now(); + expect(agentError!.rateLimitResetAt).toBeGreaterThan(now); + expect(agentError!.rateLimitResetAt).toBeLessThan(now + 86400000); + }); +}); diff --git a/src/__tests__/main/stats/integration.test.ts b/src/__tests__/main/stats/integration.test.ts index ca5a04b3ed..9fd361eada 100644 --- a/src/__tests__/main/stats/integration.test.ts +++ b/src/__tests__/main/stats/integration.test.ts @@ -890,16 +890,12 @@ describe('electron-rebuild verification for better-sqlite3', () => { it('should have better-sqlite3 native binding in expected location', async () => { const fs = await import('node:fs'); const path = await import('node:path'); + const betterSqlitePackagePath = require.resolve('better-sqlite3/package.json'); + const betterSqliteDir = path.dirname(betterSqlitePackagePath); // Check if the native binding exists in build/Release (compiled location) const nativeModulePath = path.join( - __dirname, - '..', - '..', - '..', - '..', - 'node_modules', - 'better-sqlite3', + betterSqliteDir, 'build', 'Release', 'better_sqlite3.node' @@ -912,16 +908,7 @@ describe('electron-rebuild verification for better-sqlite3', () => { // If the native module doesn't exist, check if there's a prebuilt binary if (!exists) { // Check for prebuilt binaries in the bin directory - const binDir = path.join( - __dirname, - '..', - '..', - '..', - '..', - 'node_modules', - 'better-sqlite3', - 'bin' - ); + const binDir = path.join(betterSqliteDir, 'bin'); if (fs.existsSync(binDir)) { const binContents = fs.readdirSync(binDir); @@ -937,17 +924,10 @@ describe('electron-rebuild verification for better-sqlite3', () => { it('should verify binding.gyp exists for native compilation', async () => { const fs = await import('node:fs'); const path = await import('node:path'); + const betterSqlitePackagePath = require.resolve('better-sqlite3/package.json'); + const betterSqliteDir = path.dirname(betterSqlitePackagePath); - const bindingGypPath = path.join( - __dirname, - '..', - '..', - '..', - '..', - 'node_modules', - 'better-sqlite3', - 'binding.gyp' - ); + const bindingGypPath = path.join(betterSqliteDir, 'binding.gyp'); // binding.gyp is required for node-gyp compilation expect(fs.existsSync(bindingGypPath)).toBe(true); diff --git a/src/__tests__/main/tunnel-manager.test.ts b/src/__tests__/main/tunnel-manager.test.ts index 620f3b0284..4ae497ddfc 100644 --- a/src/__tests__/main/tunnel-manager.test.ts +++ b/src/__tests__/main/tunnel-manager.test.ts @@ -328,5 +328,20 @@ describe('TunnelManager', () => { expect('url' in status).toBe(true); expect('error' in status).toBe(true); }); + + it('preserves an error after unexpected exit post-connect', async () => { + const startPromise = tunnelManager.start(3000); + await new Promise((resolve) => setImmediate(resolve)); + + mockProcess.stderr.emit('data', Buffer.from('https://abc.trycloudflare.com')); + await startPromise; + + mockProcess.emit('exit', 1); + + const status = tunnelManager.getStatus(); + expect(status.isRunning).toBe(false); + expect(status.url).toBe('https://abc.trycloudflare.com'); + expect(status.error).toContain('cloudflared exited unexpectedly'); + }); }); }); diff --git a/src/__tests__/main/web-server/WebServer.test.ts b/src/__tests__/main/web-server/WebServer.test.ts new file mode 100644 index 0000000000..daab2d2ec2 --- /dev/null +++ b/src/__tests__/main/web-server/WebServer.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import os from 'os'; +import path from 'path'; +import { captureException } from '../../../main/utils/sentry'; +import { WebServer } from '../../../main/web-server/WebServer'; + +vi.mock('../../../main/utils/sentry', () => ({ + captureException: vi.fn(), +})); + +describe('WebServer web asset resolution', () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = mkdtempSync(path.join(os.tmpdir(), 'maestro-web-assets-')); + vi.spyOn(process, 'cwd').mockReturnValue(tempRoot); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('prefers built dist/web assets over the source web index', () => { + const distWebDir = path.join(tempRoot, 'dist', 'web'); + mkdirSync(distWebDir, { recursive: true }); + writeFileSync( + path.join(distWebDir, 'index.html'), + '' + ); + + const server = new WebServer(0); + + expect((server as any).webAssetsPath).toBe(distWebDir); + }); + + it('rejects source web assets that still reference /main.tsx when no built bundle exists', () => { + const server = new WebServer(0); + + expect((server as any).webAssetsPath).toBeNull(); + }); + + it('reports and rethrows unexpected asset inspection failures', () => { + const distWebDir = path.join(tempRoot, 'dist', 'web'); + const indexPath = path.join(distWebDir, 'index.html'); + mkdirSync(indexPath, { recursive: true }); + + expect(() => new WebServer(0)).toThrow(); + + const [[capturedError, captureContext]] = vi.mocked(captureException).mock.calls; + expect((capturedError as NodeJS.ErrnoException).code).toBe('EISDIR'); + expect(captureContext).toEqual({ + operation: 'webServer:isServableWebAssetsPath', + candidatePath: distWebDir, + indexPath, + }); + }); +}); diff --git a/src/__tests__/renderer/components/AgentSessionsBrowser.test.tsx b/src/__tests__/renderer/components/AgentSessionsBrowser.test.tsx index a700fe94fa..e14be9b07d 100644 --- a/src/__tests__/renderer/components/AgentSessionsBrowser.test.tsx +++ b/src/__tests__/renderer/components/AgentSessionsBrowser.test.tsx @@ -76,6 +76,7 @@ const defaultTheme: Theme = { interface ClaudeSession { sessionId: string; projectPath: string; + createdAt: number; timestamp: string; modifiedAt: string; firstMessage: string; @@ -105,6 +106,7 @@ interface SessionMessage { const createMockClaudeSession = (overrides: Partial = {}): ClaudeSession => ({ sessionId: `d02d0bd6-${Math.random().toString(36).substr(2, 6)}-4a01-9123-456789abcdef`, projectPath: '/path/to/project', + createdAt: Date.parse('2025-01-15T09:00:00Z'), timestamp: '2025-01-15T10:00:00Z', modifiedAt: '2025-01-15T11:30:00Z', firstMessage: 'Help me with this code', @@ -133,6 +135,7 @@ const createMockActiveSession = (overrides: Partial = {}): Session => ( id: 'session-1', name: 'Test Project', toolType: 'claude-code', + createdAt: Date.parse('2025-01-15T08:30:00Z'), state: 'idle', inputMode: 'ai', cwd: '/path/to/project', @@ -688,8 +691,10 @@ describe('AgentSessionsBrowser', () => { expect(statsText).toHaveClass('animate-pulse'); }); - it('shows oldest session date', async () => { + it('shows the active session creation date in the since label', async () => { const sessions = [createMockClaudeSession()]; + const createdAt = Date.parse('2026-04-09T12:00:00Z'); + const oldestTimestamp = '2024-06-15T00:00:00Z'; vi.mocked(window.maestro.agentSessions.listPaginated).mockResolvedValue({ sessions, hasMore: false, @@ -698,7 +703,13 @@ describe('AgentSessionsBrowser', () => { }); await act(async () => { - renderWithProvider(); + renderWithProvider( + + ); await vi.runAllTimersAsync(); }); @@ -709,13 +720,18 @@ describe('AgentSessionsBrowser', () => { totalMessages: 10, totalCostUsd: 0.5, totalSizeBytes: 5000, - oldestTimestamp: '2024-06-15T00:00:00Z', + oldestTimestamp, isComplete: true, }); await vi.runAllTimersAsync(); }); - expect(screen.getByText(/Since/i)).toBeInTheDocument(); + expect( + screen.getByText(`Since ${new Date(createdAt).toLocaleDateString()}`) + ).toBeInTheDocument(); + expect( + screen.queryByText(`Since ${new Date(oldestTimestamp).toLocaleDateString()}`) + ).toBeNull(); }); it('ignores stats updates for different project paths', async () => { diff --git a/src/__tests__/renderer/components/GroupChatModals.test.tsx b/src/__tests__/renderer/components/GroupChatModals.test.tsx index d5f7e37c19..168f44fa70 100644 --- a/src/__tests__/renderer/components/GroupChatModals.test.tsx +++ b/src/__tests__/renderer/components/GroupChatModals.test.tsx @@ -228,9 +228,9 @@ describe('GroupChatModal', () => { // Verify all agents appear as options expect(screen.getByRole('option', { name: /Claude Code/i })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: /Codex.*Beta/i })).toBeInTheDocument(); expect(screen.getByRole('option', { name: /OpenCode.*Beta/i })).toBeInTheDocument(); expect(screen.getByRole('option', { name: /Factory Droid.*Beta/i })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /^Codex$/i })).toBeInTheDocument(); }); }); diff --git a/src/__tests__/renderer/components/InputArea.test.tsx b/src/__tests__/renderer/components/InputArea.test.tsx index 935a0f78f3..9dea1b4b8f 100644 --- a/src/__tests__/renderer/components/InputArea.test.tsx +++ b/src/__tests__/renderer/components/InputArea.test.tsx @@ -699,7 +699,7 @@ describe('InputArea', () => { // The second command (/help) should have accent background // Find the parent div that has the background style (px-4 py-3 class) - const helpCmd = screen.getByText('/help').closest('.px-4'); + const helpCmd = screen.getByText('/help').closest('button'); expect(helpCmd).toHaveStyle({ backgroundColor: mockTheme.colors.accent }); }); @@ -712,7 +712,7 @@ describe('InputArea', () => { }); render(); - const helpCmd = screen.getByText('/help').closest('.px-4'); + const helpCmd = screen.getByText('/help').closest('button'); fireEvent.mouseEnter(helpCmd!); expect(setSelectedSlashCommandIndex).toHaveBeenCalledWith(1); @@ -731,7 +731,7 @@ describe('InputArea', () => { }); render(); - const clearCmd = screen.getByText('/clear').closest('.px-4'); + const clearCmd = screen.getByText('/clear').closest('button'); fireEvent.doubleClick(clearCmd!); expect(setInputValue).toHaveBeenCalledWith('/clear'); @@ -815,7 +815,7 @@ describe('InputArea', () => { }); render(); - const helpCmd = screen.getByText('/help').closest('.px-4'); + const helpCmd = screen.getByText('/help').closest('button'); fireEvent.click(helpCmd!); // Single click should update selection @@ -845,11 +845,11 @@ describe('InputArea', () => { render(); // The second item (/help) should NOT have accent background since index 0 is selected - const helpCmd = screen.getByText('/help').closest('.px-4'); + const helpCmd = screen.getByText('/help').closest('button'); // Unselected items don't have the accent color background expect(helpCmd).not.toHaveStyle({ backgroundColor: mockTheme.colors.accent }); // First item (selected) should have accent background - const clearCmd = screen.getByText('/clear').closest('.px-4'); + const clearCmd = screen.getByText('/clear').closest('button'); expect(clearCmd).toHaveStyle({ backgroundColor: mockTheme.colors.accent }); }); diff --git a/src/__tests__/renderer/components/SessionList.test.tsx b/src/__tests__/renderer/components/SessionList.test.tsx index 927dd889eb..b416a85536 100644 --- a/src/__tests__/renderer/components/SessionList.test.tsx +++ b/src/__tests__/renderer/components/SessionList.test.tsx @@ -267,6 +267,7 @@ describe('SessionList', () => { isCloudflaredInstalled: vi.fn().mockResolvedValue(true), start: vi.fn().mockResolvedValue({ success: true, url: 'https://tunnel.example.com' }), stop: vi.fn().mockResolvedValue(undefined), + getStatus: vi.fn().mockResolvedValue({ isRunning: false, url: null, error: null }), }; }); @@ -2163,6 +2164,7 @@ describe('SessionList', () => { isCloudflaredInstalled: mockIsInstalled, start: vi.fn().mockResolvedValue({ success: true, url: 'https://tunnel.example.com' }), stop: vi.fn().mockResolvedValue(undefined), + getStatus: vi.fn().mockResolvedValue({ isRunning: false, url: null, error: null }), }; useUIStore.setState({ leftSidebarOpen: true }); @@ -2186,6 +2188,7 @@ describe('SessionList', () => { isCloudflaredInstalled: mockIsInstalled, start: vi.fn(), stop: vi.fn(), + getStatus: vi.fn().mockResolvedValue({ isRunning: false, url: null, error: null }), }; useUIStore.setState({ leftSidebarOpen: true }); @@ -2210,6 +2213,11 @@ describe('SessionList', () => { isCloudflaredInstalled: vi.fn().mockResolvedValue(true), start: mockStart, stop: vi.fn(), + getStatus: vi.fn().mockResolvedValue({ + isRunning: true, + url: 'https://tunnel.example.com', + error: null, + }), }; useUIStore.setState({ leftSidebarOpen: true }); @@ -2245,6 +2253,11 @@ describe('SessionList', () => { isCloudflaredInstalled: vi.fn().mockResolvedValue(true), start: mockStart, stop: mockStop, + getStatus: vi.fn().mockResolvedValue({ + isRunning: true, + url: 'https://tunnel.example.com', + error: null, + }), }; useUIStore.setState({ leftSidebarOpen: true }); @@ -2282,6 +2295,11 @@ describe('SessionList', () => { isCloudflaredInstalled: vi.fn().mockResolvedValue(true), start: mockStart, stop: vi.fn(), + getStatus: vi.fn().mockResolvedValue({ + isRunning: false, + url: null, + error: 'Connection failed', + }), }; useUIStore.setState({ leftSidebarOpen: true }); @@ -2309,6 +2327,7 @@ describe('SessionList', () => { isCloudflaredInstalled: vi.fn().mockResolvedValue(true), start: mockStart, stop: vi.fn(), + getStatus: vi.fn().mockRejectedValue(new Error('Network error')), }; useUIStore.setState({ leftSidebarOpen: true }); @@ -2338,6 +2357,11 @@ describe('SessionList', () => { isCloudflaredInstalled: vi.fn().mockResolvedValue(true), start: mockStart, stop: vi.fn(), + getStatus: vi.fn().mockResolvedValue({ + isRunning: true, + url: 'https://tunnel.example.com', + error: null, + }), }; useUIStore.setState({ leftSidebarOpen: true }); @@ -2367,6 +2391,11 @@ describe('SessionList', () => { isCloudflaredInstalled: vi.fn().mockResolvedValue(true), start: mockStart, stop: vi.fn(), + getStatus: vi.fn().mockResolvedValue({ + isRunning: true, + url: 'https://tunnel.example.com', + error: null, + }), }; useUIStore.setState({ leftSidebarOpen: true }); @@ -2404,6 +2433,11 @@ describe('SessionList', () => { isCloudflaredInstalled: vi.fn().mockResolvedValue(true), start: mockStart, stop: vi.fn(), + getStatus: vi.fn().mockResolvedValue({ + isRunning: true, + url: 'https://tunnel.example.com', + error: null, + }), }; useUIStore.setState({ leftSidebarOpen: true }); @@ -2438,6 +2472,11 @@ describe('SessionList', () => { isCloudflaredInstalled: vi.fn().mockResolvedValue(true), start: mockStart, stop: vi.fn(), + getStatus: vi.fn().mockResolvedValue({ + isRunning: true, + url: 'https://tunnel.example.com', + error: null, + }), }; useUIStore.setState({ leftSidebarOpen: true }); @@ -3090,6 +3129,7 @@ describe('SessionList', () => { isCloudflaredInstalled: vi.fn().mockResolvedValue(true), start: vi.fn(), stop: vi.fn(), + getStatus: vi.fn().mockResolvedValue({ isRunning: false, url: null, error: null }), }; useUIStore.setState({ leftSidebarOpen: true }); @@ -3116,6 +3156,11 @@ describe('SessionList', () => { isCloudflaredInstalled: vi.fn().mockResolvedValue(true), start: mockStart, stop: vi.fn(), + getStatus: vi.fn().mockResolvedValue({ + isRunning: true, + url: 'https://tunnel.example.com', + error: null, + }), }; useUIStore.setState({ leftSidebarOpen: true }); diff --git a/src/__tests__/renderer/components/SessionList/LiveOverlayPanel.test.tsx b/src/__tests__/renderer/components/SessionList/LiveOverlayPanel.test.tsx index 0ec6bfe2da..32701c9da0 100644 --- a/src/__tests__/renderer/components/SessionList/LiveOverlayPanel.test.tsx +++ b/src/__tests__/renderer/components/SessionList/LiveOverlayPanel.test.tsx @@ -20,6 +20,9 @@ vi.mock('../../../../renderer/utils/clipboard', () => ({ shell: { openExternal: vi.fn(), }, + tunnel: { + getStatus: vi.fn().mockResolvedValue({ isRunning: false, url: null, error: null }), + }, }; const mockTheme: Theme = { @@ -78,6 +81,11 @@ function createDefaultProps(overrides: Partial { beforeEach(() => { vi.clearAllMocks(); + (window as any).maestro.tunnel.getStatus.mockResolvedValue({ + isRunning: false, + url: null, + error: null, + }); }); // ----------------------------------------------------------------------- @@ -192,6 +200,25 @@ describe('LiveOverlayPanel', () => { render(); expect(screen.getByTitle('Disable remote control')).toBeTruthy(); }); + + it('keeps connected state when tunnel status confirms process is running', () => { + (window as any).maestro.tunnel.getStatus.mockResolvedValue({ + isRunning: true, + url: 'https://tunnel.example.com', + error: null, + }); + + render( + + ); + + expect(screen.getByText(/Remote tunnel active/)).toBeTruthy(); + }); }); // ----------------------------------------------------------------------- diff --git a/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx b/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx index 2d1d09c2b1..2777919fbe 100644 --- a/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx +++ b/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx @@ -334,7 +334,7 @@ describe('EncoreTab', () => { expect(options[0]).toHaveValue('claude-code'); expect(options[0]).toHaveTextContent('Claude Code'); expect(options[1]).toHaveValue('codex'); - expect(options[1]).toHaveTextContent('Codex (Beta)'); + expect(options[1]).toHaveTextContent('Codex'); }); it('should call setDirectorNotesSettings on provider change', async () => { diff --git a/src/__tests__/renderer/utils/contextUsage.test.ts b/src/__tests__/renderer/utils/contextUsage.test.ts index 3bbd2e01de..067a825032 100644 --- a/src/__tests__/renderer/utils/contextUsage.test.ts +++ b/src/__tests__/renderer/utils/contextUsage.test.ts @@ -395,7 +395,7 @@ describe('calculateContextDisplay', () => { expect(result.contextWindow).toBe(0); }); - it('should cap tokens to context window when no fallbackPercentage is provided', () => { + it('should clamp tokens to the window when accumulated values exceed it without a fallback', () => { const result = calculateContextDisplay( { inputTokens: 50000, @@ -406,7 +406,22 @@ describe('calculateContextDisplay', () => { 'claude-code' // no fallback ); - // Raw = 1008000 > 200000, no fallback, so tokens capped to context window + // Raw = 1008000 > 200000, but no fallback, so clamp to the configured window + expect(result.tokens).toBe(200000); + expect(result.percentage).toBe(100); + }); + + it('should clamp fallback percentages above 100 before deriving tokens', () => { + const result = calculateContextDisplay( + { + inputTokens: 50000, + cacheReadInputTokens: 758000, + cacheCreationInputTokens: 200000, + }, + 200000, + 'claude-code', + 150 + ); expect(result.tokens).toBe(200000); expect(result.percentage).toBe(100); }); diff --git a/src/__tests__/shared/agentMetadata.test.ts b/src/__tests__/shared/agentMetadata.test.ts index e74ab974f0..f229b4d629 100644 --- a/src/__tests__/shared/agentMetadata.test.ts +++ b/src/__tests__/shared/agentMetadata.test.ts @@ -76,9 +76,9 @@ describe('agentMetadata', () => { }); it('should contain the expected beta agents', () => { - expect(BETA_AGENTS.has('codex')).toBe(true); expect(BETA_AGENTS.has('opencode')).toBe(true); expect(BETA_AGENTS.has('factory-droid')).toBe(true); + expect(BETA_AGENTS.has('codex')).toBe(false); }); it('should not contain non-beta agents', () => { @@ -98,12 +98,12 @@ describe('agentMetadata', () => { describe('isBetaAgent', () => { it('should return true for beta agents', () => { - expect(isBetaAgent('codex')).toBe(true); expect(isBetaAgent('opencode')).toBe(true); expect(isBetaAgent('factory-droid')).toBe(true); }); it('should return false for non-beta agents', () => { + expect(isBetaAgent('codex')).toBe(false); expect(isBetaAgent('claude-code')).toBe(false); expect(isBetaAgent('terminal')).toBe(false); }); diff --git a/src/main/agents/definitions.ts b/src/main/agents/definitions.ts index c00fa7c1ad..172a067b10 100644 --- a/src/main/agents/definitions.ts +++ b/src/main/agents/definitions.ts @@ -170,6 +170,21 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [ default: '', argBuilder: (value: string) => (value && value.trim() ? ['--effort', value.trim()] : []), }, + { + key: 'rateLimitAutoRetry', + type: 'checkbox', + label: 'Auto-retry on Rate Limit', + description: 'Automatically pause and retry when encountering a Claude Code rate limit.', + default: true, + }, + { + key: 'rateLimitFallbackHours', + type: 'number', + label: 'Rate Limit Fallback (Hours)', + description: + 'Wait time to use if the exact rate limit reset time cannot be parsed (leave 0 to disable fallback).', + default: 2, + }, ], }, { diff --git a/src/main/ipc/handlers/autorun.ts b/src/main/ipc/handlers/autorun.ts index 6fb68c1603..538e5fe1de 100644 --- a/src/main/ipc/handlers/autorun.ts +++ b/src/main/ipc/handlers/autorun.ts @@ -73,6 +73,29 @@ interface TreeNode { children?: TreeNode[]; } +/** + * Directories that are never relevant as Auto Run doc sources. + * Prevents scanning virtualenvs, package trees, and build artifacts. + */ +const SCAN_EXCLUDED_DIRS = new Set([ + 'node_modules', + 'venv', + '.venv', + 'env', + '.env', + '__pycache__', + '.git', + 'dist', + 'build', + '.next', + '.nuxt', + 'coverage', + '.cache', + 'target', + '.tox', + 'site-packages', +]); + /** * Recursively scan directory for markdown files */ @@ -82,7 +105,7 @@ async function scanDirectory(dirPath: string, relativePath: string = ''): Promis // Sort entries: folders first, then files, both alphabetically const sortedEntries = entries - .filter((entry) => !entry.name.startsWith('.')) + .filter((entry) => !entry.name.startsWith('.') && !SCAN_EXCLUDED_DIRS.has(entry.name)) .sort((a, b) => { if (a.isDirectory() && !b.isDirectory()) return -1; if (!a.isDirectory() && b.isDirectory()) return 1; @@ -136,7 +159,7 @@ async function scanDirectoryRemote( // Sort entries: folders first, then files, both alphabetically const sortedEntries = result.data - .filter((entry) => !entry.name.startsWith('.')) + .filter((entry) => !entry.name.startsWith('.') && !SCAN_EXCLUDED_DIRS.has(entry.name)) .sort((a, b) => { if (a.isDirectory && !b.isDirectory) return -1; if (!a.isDirectory && b.isDirectory) return 1; @@ -215,8 +238,8 @@ async function checkForMarkdownFiles(dirPath: string): Promise { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { - // Skip hidden files/folders - if (entry.name.startsWith('.')) { + // Skip hidden files/folders and excluded directories + if (entry.name.startsWith('.') || SCAN_EXCLUDED_DIRS.has(entry.name)) { continue; } diff --git a/src/main/parsers/claude-output-parser.ts b/src/main/parsers/claude-output-parser.ts index cf7abb6ac2..acf4f12e3b 100644 --- a/src/main/parsers/claude-output-parser.ts +++ b/src/main/parsers/claude-output-parser.ts @@ -14,7 +14,7 @@ import type { ToolType, AgentError } from '../../shared/types'; import type { AgentOutputParser, ParsedEvent } from './agent-output-parser'; import { aggregateModelUsage, type ModelStats } from './usage-aggregator'; -import { getErrorPatterns, matchErrorPattern } from './error-patterns'; +import { getErrorPatterns, matchErrorPattern, parseRateLimitResetTime } from './error-patterns'; /** * Content block in Claude assistant messages @@ -68,6 +68,13 @@ interface ClaudeRawMessage { export class ClaudeOutputParser implements AgentOutputParser { readonly agentId: ToolType = 'claude-code'; + /** + * Cached rate-limit reset timestamp from a `rate_limit_event` JSON event. + * Claude CLI sends this event (with `resetsAt` as epoch seconds) BEFORE the + * actual error event, so we cache it here and attach it to the next error. + */ + private lastRateLimitResetAt: number | null = null; + /** * Parse a single JSON line from Claude Code output. * Delegates to parseJsonObject after JSON.parse. @@ -189,6 +196,22 @@ export class ClaudeOutputParser implements AgentOutputParser { }; } + // Handle rate_limit_event — cache the reset timestamp for the next error + if (msg.type === 'rate_limit_event') { + const rateLimitInfo = (msg as unknown as Record).rate_limit_info as + | Record + | undefined; + if (rateLimitInfo?.resetsAt && typeof rateLimitInfo.resetsAt === 'number') { + // resetsAt is in epoch seconds — convert to milliseconds + this.lastRateLimitResetAt = rateLimitInfo.resetsAt * 1000; + } + return { + type: 'system', + sessionId: msg.session_id, + raw: msg, + }; + } + // Default: preserve as system event return { type: 'system', @@ -358,7 +381,7 @@ export class ClaudeOutputParser implements AgentOutputParser { const patterns = getErrorPatterns(this.agentId); const match = matchErrorPattern(patterns, errorText); if (match) { - return { + const mixedError: AgentError = { type: match.type, message: match.message, recoverable: match.recoverable, @@ -366,6 +389,23 @@ export class ClaudeOutputParser implements AgentOutputParser { timestamp: Date.now(), raw: { errorLine: line }, }; + + // For rate-limit errors, try to parse the reset time + if (match.type === 'rate_limited') { + let resetAt: number | null = null; + if (this.lastRateLimitResetAt && this.lastRateLimitResetAt > Date.now()) { + resetAt = this.lastRateLimitResetAt; + this.lastRateLimitResetAt = null; + } + if (!resetAt) { + resetAt = parseRateLimitResetTime(errorText); + } + if (resetAt) { + mixedError.rateLimitResetAt = resetAt; + } + } + + return mixedError; } return null; } @@ -406,7 +446,7 @@ export class ClaudeOutputParser implements AgentOutputParser { const match = matchErrorPattern(patterns, errorText); if (match) { - return { + const error: AgentError = { type: match.type, message: match.message, recoverable: match.recoverable, @@ -414,6 +454,46 @@ export class ClaudeOutputParser implements AgentOutputParser { timestamp: Date.now(), parsedJson, }; + + // For rate-limit errors, attach the reset time. + // Priority: (1) cached rate_limit_event.resetsAt, (2) message.content text, (3) errorText + if (match.type === 'rate_limited') { + let resetAt: number | null = null; + + // Best source: the rate_limit_event that arrived just before this error + if (this.lastRateLimitResetAt && this.lastRateLimitResetAt > Date.now()) { + resetAt = this.lastRateLimitResetAt; + this.lastRateLimitResetAt = null; + } + + // Fallback: parse from message.content text blocks + if (!resetAt && obj) { + const message = (obj as Record).message as + | Record + | undefined; + if (message?.content && Array.isArray(message.content)) { + for (const block of message.content) { + if (typeof block === 'string') { + resetAt = parseRateLimitResetTime(block); + } else if (block && typeof block === 'object' && 'text' in block) { + resetAt = parseRateLimitResetTime((block as { text: string }).text); + } + if (resetAt) break; + } + } + } + + // Last resort: try the error text itself + if (!resetAt && errorText) { + resetAt = parseRateLimitResetTime(errorText); + } + + if (resetAt) { + error.rateLimitResetAt = resetAt; + } + } + + return error; } // Structured error event that didn't match a known pattern — diff --git a/src/main/parsers/error-patterns.ts b/src/main/parsers/error-patterns.ts index b027697dae..28356484a1 100644 --- a/src/main/parsers/error-patterns.ts +++ b/src/main/parsers/error-patterns.ts @@ -156,7 +156,7 @@ const CLAUDE_ERROR_PATTERNS: AgentErrorPatterns = { rate_limited: [ { - pattern: /rate limit/i, + pattern: /rate[_ ]limit/i, message: 'Rate limit exceeded. Please wait a moment before trying again.', recoverable: true, }, @@ -1004,3 +1004,116 @@ export function matchSshErrorPattern( export function getSshErrorPatterns(): AgentErrorPatterns { return SSH_ERROR_PATTERNS; } + +// ============================================================================ +// Rate Limit Reset Time Parsing +// ============================================================================ + +/** + * Regex to extract reset time from rate-limit error messages. + * + * Matches patterns like: + * - "resets 3pm (America/Winnipeg)" + * - "resets 3:30pm (America/Chicago)" + * - "resets 15:00 (US/Eastern)" + * - "resets 3:30 PM (America/New_York)" + * + * Capture groups: + * 1: hours (e.g. "3", "15") + * 2: optional ":minutes" (e.g. ":30") + * 3: optional am/pm designator + * 4: IANA timezone (e.g. "America/Winnipeg") + */ +const RATE_LIMIT_RESET_REGEX = /resets?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*\(([A-Za-z_/]+)\)/i; + +/** + * Parse a rate-limit reset time from an error message. + * + * Given text like "You've hit your limit · resets 3pm (America/Winnipeg)", + * returns the epoch-ms timestamp of the next occurrence of that time in + * the specified timezone. Returns null if the text doesn't contain a + * parseable reset time. + * + * Uses Intl.DateTimeFormat for timezone conversion (no external deps). + */ +export function parseRateLimitResetTime(text: string): number | null { + const m = text.match(RATE_LIMIT_RESET_REGEX); + if (!m) return null; + + let hours = parseInt(m[1], 10); + const minutes = m[2] ? parseInt(m[2], 10) : 0; + const ampm = m[3]?.toLowerCase(); + const timezone = m[4]; + + // Convert 12-hour to 24-hour + if (ampm === 'pm' && hours < 12) hours += 12; + if (ampm === 'am' && hours === 12) hours = 0; + + // Validate ranges + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null; + + // Validate timezone by trying to format a date with it + try { + Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(); + } catch { + return null; + } + + // Get current wall-clock time in the target timezone + const now = new Date(); + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + const parts = formatter.formatToParts(now); + const getPart = (type: string) => parseInt(parts.find((p) => p.type === type)?.value || '0', 10); + + const tzHour = getPart('hour'); + const tzMinute = getPart('minute'); + + // Compute the target date in the target timezone. + // Start with today; if that time has already passed, use tomorrow. + let targetDate = now; + const nowMinutes = tzHour * 60 + tzMinute; + const resetMinutes = hours * 60 + minutes; + + if (resetMinutes <= nowMinutes) { + // Reset time already passed today — advance to tomorrow + targetDate = new Date(now.getTime() + 86400000); + } + + const targetParts = formatter.formatToParts(targetDate); + const targetGetPart = (type: string) => + parseInt(targetParts.find((p) => p.type === type)?.value || '0', 10); + + const targetDay = targetGetPart('day'); + const targetMonth = targetGetPart('month'); + const targetYear = targetGetPart('year'); + const targetHour = targetGetPart('hour'); + const targetMinute = targetGetPart('minute'); + const targetSecond = targetGetPart('second'); + + // Find the UTC offset exactly at the target instant + const targetUtcMs = targetDate.getTime(); + const targetWallMs = Date.UTC( + targetYear, + targetMonth - 1, + targetDay, + targetHour, + targetMinute, + targetSecond + ); + const targetTzOffsetMs = targetWallMs - targetUtcMs; + + // Build the exact wall-clock reset target as pseudo-UTC + const wallTargetMs = Date.UTC(targetYear, targetMonth - 1, targetDay, hours, minutes, 0); + + // Revert the offset to get real UTC + return wallTargetMs - targetTzOffsetMs; +} diff --git a/src/main/preload/process.ts b/src/main/preload/process.ts index 5ba1e17a59..3c7e3a3082 100644 --- a/src/main/preload/process.ts +++ b/src/main/preload/process.ts @@ -120,6 +120,7 @@ export interface AgentError { agentId: string; sessionId?: string; timestamp: number; + rateLimitResetAt?: number; raw?: { exitCode?: number; stderr?: string; diff --git a/src/main/process-manager/handlers/StdoutHandler.ts b/src/main/process-manager/handlers/StdoutHandler.ts index 3b90331245..9fd30b3057 100644 --- a/src/main/process-manager/handlers/StdoutHandler.ts +++ b/src/main/process-manager/handlers/StdoutHandler.ts @@ -192,6 +192,7 @@ export class StdoutHandler { sessionId, errorType: agentError.type, errorMessage: agentError.message, + rateLimitResetAt: agentError.rateLimitResetAt, isRemote: !!managedProcess.sshRemoteId, }); this.emitter.emit('agent-error', sessionId, agentError); diff --git a/src/main/process-manager/types.ts b/src/main/process-manager/types.ts index 8f0f5f87bd..c54f3991ba 100644 --- a/src/main/process-manager/types.ts +++ b/src/main/process-manager/types.ts @@ -78,6 +78,8 @@ export interface ManagedProcess { sshRemoteHost?: string; dataBuffer?: string; dataBufferTimeout?: NodeJS.Timeout; + /** Pending rate-limit error waiting for stdin probe response with reset time */ + pendingRateLimitError?: AgentError; } export interface UsageTotals { diff --git a/src/main/tunnel-manager.ts b/src/main/tunnel-manager.ts index fdb43d860b..8f45b5d594 100644 --- a/src/main/tunnel-manager.ts +++ b/src/main/tunnel-manager.ts @@ -18,6 +18,7 @@ class TunnelManager { private process: ChildProcess | null = null; private url: string | null = null; private error: string | null = null; + private stopping = false; async start(port: number): Promise { // Validate port number @@ -37,6 +38,7 @@ class TunnelManager { const cloudflaredBinary = getCloudflaredPath() || 'cloudflared'; return new Promise((resolve) => { + this.stopping = false; logger.info( `Starting cloudflared tunnel for port ${port} using ${cloudflaredBinary}`, 'TunnelManager' @@ -92,10 +94,14 @@ class TunnelManager { clearTimeout(timeout); this.error = `cloudflared exited unexpectedly (code ${code})`; resolve({ success: false, error: this.error }); + } else if (!this.stopping) { + this.error = `cloudflared exited unexpectedly (code ${code})`; + logger.error(this.error, 'TunnelManager'); } // Only clear process reference on exit, not URL // URL is cleared explicitly in stop() to preserve it for display this.process = null; + this.stopping = false; }); }); } @@ -103,6 +109,7 @@ class TunnelManager { async stop(): Promise { if (this.process) { logger.info('Stopping tunnel', 'TunnelManager'); + this.stopping = true; const proc = this.process; proc.kill('SIGTERM'); @@ -126,6 +133,7 @@ class TunnelManager { this.process = null; } + this.stopping = false; this.url = null; this.error = null; } diff --git a/src/main/web-server/WebServer.ts b/src/main/web-server/WebServer.ts index 18e189bcd5..c66474412f 100644 --- a/src/main/web-server/WebServer.ts +++ b/src/main/web-server/WebServer.ts @@ -28,9 +28,10 @@ import fastifyStatic from '@fastify/static'; import { FastifyInstance, FastifyRequest } from 'fastify'; import { randomUUID } from 'crypto'; import path from 'path'; -import { existsSync } from 'fs'; -import { logger } from '../utils/logger'; +import { existsSync, readFileSync } from 'fs'; import { getLocalIpAddress } from '../utils/networkUtils'; +import { logger } from '../utils/logger'; +import { captureException } from '../utils/sentry'; import { WebSocketMessageHandler } from './handlers'; import { BroadcastService } from './services'; import { ApiRoutes, StaticRoutes, WsRoute } from './routes'; @@ -206,16 +207,16 @@ export class WebServer { private resolveWebAssetsPath(): string | null { // Try multiple locations for the web assets const possiblePaths = [ - // Production: relative to the compiled main process - path.join(__dirname, '..', '..', 'web'), // Development: from project root path.join(process.cwd(), 'dist', 'web'), + // Production: relative to the compiled main process + path.join(__dirname, '..', '..', 'web'), // Alternative: relative to __dirname going up to dist path.join(__dirname, '..', 'web'), ]; for (const p of possiblePaths) { - if (existsSync(path.join(p, 'index.html'))) { + if (this.isServableWebAssetsPath(p)) { logger.debug(`Web assets found at: ${p}`, LOG_CONTEXT); return p; } @@ -228,6 +229,38 @@ export class WebServer { return null; } + /** + * Only serve built web assets. Source `src/web/index.html` references `/main.tsx`, + * which the embedded Fastify server cannot compile or serve. + */ + private isServableWebAssetsPath(candidatePath: string): boolean { + const indexPath = path.join(candidatePath, 'index.html'); + if (!existsSync(indexPath)) { + return false; + } + + try { + const html = readFileSync(indexPath, 'utf-8'); + const referencesDevEntrypoint = + html.includes('src="/main.tsx"') || html.includes("src='/main.tsx'"); + return !referencesDevEntrypoint; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOENT') { + logger.warn(`Web assets disappeared while inspecting ${candidatePath}`, LOG_CONTEXT); + return false; + } + + logger.error(`Failed to inspect web assets at ${candidatePath}`, LOG_CONTEXT, error); + captureException(error, { + operation: 'webServer:isServableWebAssetsPath', + candidatePath, + indexPath, + }); + throw error; + } + } + // ============ Live Session Management (Delegated to LiveSessionManager) ============ /** diff --git a/src/renderer/components/AgentErrorModal.tsx b/src/renderer/components/AgentErrorModal.tsx index 77fe58eb6d..3d4479048c 100644 --- a/src/renderer/components/AgentErrorModal.tsx +++ b/src/renderer/components/AgentErrorModal.tsx @@ -12,11 +12,12 @@ * - Clear error description with type indicator * - Collapsible JSON details viewer for structured error data * - Recovery action buttons (re-authenticate, start new session, retry, etc.) + * - Rate-limit countdown with auto-retry * - Dismiss option for non-critical errors * - Auto-focus on primary recovery action */ -import React, { useRef, useMemo, useState } from 'react'; +import React, { useRef, useMemo, useState, useEffect, useCallback } from 'react'; import { AlertCircle, RefreshCw, @@ -29,6 +30,7 @@ import { ChevronDown, ChevronRight, Code2, + Timer, } from 'lucide-react'; import type { Theme, AgentError, AgentErrorType } from '../types'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; @@ -113,6 +115,87 @@ function getErrorColor(error: AgentError, theme: Theme): string { return theme.colors.warning; } +/** + * Format remaining milliseconds as a human-readable countdown string. + */ +function formatCountdown(remainingMs: number): string { + if (remainingMs <= 0) return 'now'; + + const totalSeconds = Math.ceil(remainingMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s`; + } + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + return `${seconds}s`; +} + +/** + * RateLimitCountdown - Live countdown until rate limit resets with auto-retry. + * + * Displays a pulsing timer with the remaining time. When the countdown reaches + * zero, it automatically invokes the onComplete callback to trigger a retry. + */ +function RateLimitCountdown({ + resetAt, + theme, + onComplete, +}: { + resetAt: number; + theme: Theme; + onComplete: () => void; +}) { + const [remainingMs, setRemainingMs] = useState(() => Math.max(0, resetAt - Date.now())); + const completedRef = useRef(false); + + useEffect(() => { + // Reset state when resetAt changes + completedRef.current = false; + setRemainingMs(Math.max(0, resetAt - Date.now())); + + const interval = setInterval(() => { + const remaining = Math.max(0, resetAt - Date.now()); + setRemainingMs(remaining); + + if (remaining <= 0 && !completedRef.current) { + completedRef.current = true; + clearInterval(interval); + onComplete(); + } + }, 1000); + + return () => clearInterval(interval); + }, [resetAt, onComplete]); + + const resetDate = new Date(resetAt); + const resetTimeStr = resetDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + + return ( +
+ +
+
+ {remainingMs > 0 ? `Auto-retrying in ${formatCountdown(remainingMs)}` : 'Retrying now...'} +
+
+ Limit resets at {resetTimeStr} +
+
+
+ ); +} + export function AgentErrorModal({ theme, error, @@ -134,6 +217,75 @@ export function AgentErrorModal({ // Check if we have JSON details to show const hasJsonDetails = error.parsedJson !== undefined; + // Find the retry action from recovery actions for auto-retry + const retryAction = useMemo( + () => recoveryActions.find((a) => a.id === 'retry'), + [recoveryActions] + ); + + const [retryInvoked, setRetryInvoked] = useState(false); + + // Auto-retry handler: when countdown completes, invoke the "retry" recovery action + const handleCountdownComplete = useCallback(() => { + if (retryAction && !retryInvoked) { + setRetryInvoked(true); + retryAction.onClick(); + } + }, [retryAction, retryInvoked]); + + const [autoRetrySettings, setAutoRetrySettings] = useState<{ + enabled: boolean; + fallbackHours: number; + } | null>(null); + + useEffect(() => { + if (error.type === 'rate_limited') { + window.maestro.agents + .getConfig(error.agentId) + .then((config) => { + if (!config) { + setAutoRetrySettings({ enabled: true, fallbackHours: 2 }); + return; + } + + setAutoRetrySettings({ + enabled: config.rateLimitAutoRetry ?? true, + fallbackHours: config.rateLimitFallbackHours ?? 2, + }); + }) + .catch((err) => { + console.error('Failed to load agent config for error modal:', err); + setAutoRetrySettings({ enabled: true, fallbackHours: 2 }); + }); + } + }, [error.type, error.agentId]); + + // For rate-limited errors: use parsed reset time if available, otherwise use configured fallback + const rateLimitResetAt = useMemo(() => { + if (error.type !== 'rate_limited' || !retryAction || !autoRetrySettings) return null; + + // If auto-retry is explicitly disabled by the user, don't show countdown or auto-retry + if (!autoRetrySettings.enabled) return null; + + // Exact parsed reset time is always preferred + if (error.rateLimitResetAt) { + if (error.rateLimitResetAt <= Date.now()) { + return null; // Expired, allow immediate retry + } + return error.rateLimitResetAt; + } + + // If no fallback is configured or available, don't show countdown + if (autoRetrySettings.fallbackHours <= 0) return null; + + // Configure fallback using the user's preferred wait time + const fallbackWaitMs = autoRetrySettings.fallbackHours * 60 * 60_000; + const fallback = error.timestamp + fallbackWaitMs; + return fallback > Date.now() ? fallback : null; + }, [error.type, error.rateLimitResetAt, error.timestamp, retryAction, autoRetrySettings]); + + const showCountdown = rateLimitResetAt !== null; + const errorColor = getErrorColor(error, theme); const errorIcon = getErrorIcon(error.type); const errorTitle = getErrorTitle(error.type); @@ -166,6 +318,15 @@ export function AgentErrorModal({ {error.message}

+ {/* Rate-limit countdown with auto-retry */} + {showCountdown && ( + + )} + {/* Timestamp */}
{new Date(error.timestamp).toLocaleTimeString()} @@ -205,39 +366,58 @@ export function AgentErrorModal({ {/* Recovery Actions - only show if there are actions */} {recoveryActions.length > 0 && (
- {recoveryActions.map((action, index) => ( - - ))} + {recoveryActions.map((action, index) => { + const isRetry = action.id === 'retry'; + const isDisabled = isRetry && retryInvoked; + return ( + + ); + })}
)} diff --git a/src/renderer/components/AgentSessionsBrowser.tsx b/src/renderer/components/AgentSessionsBrowser.tsx index 2d2e1ffe6a..173a625200 100644 --- a/src/renderer/components/AgentSessionsBrowser.tsx +++ b/src/renderer/components/AgentSessionsBrowser.tsx @@ -547,6 +547,11 @@ export function AgentSessionsBrowser({ }; }, [aggregateStats]); + const sessionSinceDate = + typeof activeSession?.createdAt === 'number' && activeSession.createdAt > 0 + ? new Date(activeSession.createdAt) + : stats.oldestSession; + // Keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { if (viewingSession) { @@ -1246,11 +1251,11 @@ export function AgentSessionsBrowser({
)} - {stats.oldestSession && ( + {sessionSinceDate && (
- Since {stats.oldestSession.toLocaleDateString()} + Since {sessionSinceDate.toLocaleDateString()}
)} diff --git a/src/renderer/components/AutoRun/AutoRunExpandedModal.tsx b/src/renderer/components/AutoRun/AutoRunExpandedModal.tsx index dbd7ca16f1..4915be53a8 100644 --- a/src/renderer/components/AutoRun/AutoRunExpandedModal.tsx +++ b/src/renderer/components/AutoRun/AutoRunExpandedModal.tsx @@ -389,7 +389,7 @@ export function AutoRunExpandedModal({ Run )} - {/* Exchange button */} + {/* PlayBooks button */} {onOpenMarketplace && ( )} diff --git a/src/renderer/components/InputArea.tsx b/src/renderer/components/InputArea.tsx index 38a2588cd5..9117800284 100644 --- a/src/renderer/components/InputArea.tsx +++ b/src/renderer/components/InputArea.tsx @@ -40,7 +40,7 @@ import { SummarizeProgressOverlay } from './SummarizeProgressOverlay'; import { WizardInputPanel } from './InlineWizard'; import { useAgentCapabilities, useScrollIntoView } from '../hooks'; import { getProviderDisplayName } from '../utils/sessionValidation'; -import { filterSlashCommands, highlightSlashCommand } from '../utils/search'; +import { filterSlashCommands } from '../utils/search'; import { getReadOnlyModeLabel, getReadOnlyModeTooltip } from '../../shared/agentMetadata'; interface SlashCommand { @@ -556,7 +556,7 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) { style={{ backgroundColor: theme.colors.bgSidebar, borderColor: theme.colors.border }} >
{filteredSlashCommands.map((cmd, idx) => ( @@ -564,7 +564,7 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) { type="button" key={cmd.command} ref={(el) => (slashCommandItemRefs.current[idx] = el)} - className={`w-full px-4 py-3 text-left transition-colors ${ + className={`w-full px-3 py-1 text-left transition-colors ${ idx === safeSelectedIndex ? 'font-semibold' : '' }`} style={{ @@ -583,10 +583,8 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) { }} onMouseEnter={() => setSelectedSlashCommandIndex(idx)} > -
- {highlightSlashCommand(cmd.command, inputValueLower.replace(/^\//, ''))} -
-
{cmd.description}
+
{cmd.command}
+
{cmd.description}
))}
diff --git a/src/renderer/components/SessionList/SessionList.tsx b/src/renderer/components/SessionList/SessionList.tsx index a55d1a6cea..ddf46bf8b6 100644 --- a/src/renderer/components/SessionList/SessionList.tsx +++ b/src/renderer/components/SessionList/SessionList.tsx @@ -444,16 +444,18 @@ function SessionListInner(props: SessionListProps) { sortedGroups, } = useSessionCategories(sessionFilter, sortedSessions, showUnreadAgentsOnly, activeSessionId); - // PERF: Cached callback maps to prevent SessionItem re-renders - // These Maps store stable function references keyed by session/editing ID - // The callbacks themselves are memoized, so the Map values remain stable + // PERF: Stable key derived from session IDs only — changes only when sessions are + // added/removed, NOT when a session's name/status/logs change. This prevents the + // Maps below from regenerating (and SessionItem from re-rendering) on every state update. + const sessionIdsKey = useMemo(() => sessions.map((s) => s.id).join(','), [sessions]); + const selectHandlers = useMemo(() => { const map = new Map void>(); sessions.forEach((s) => { map.set(s.id, () => setActiveSessionId(s.id)); }); return map; - }, [sessions, setActiveSessionId]); + }, [sessionIdsKey, setActiveSessionId]); const dragStartHandlers = useMemo(() => { const map = new Map void>(); @@ -461,7 +463,7 @@ function SessionListInner(props: SessionListProps) { map.set(s.id, () => handleDragStart(s.id)); }); return map; - }, [sessions, handleDragStart]); + }, [sessionIdsKey, handleDragStart]); const contextMenuHandlers = useMemo(() => { const map = new Map void>(); @@ -469,7 +471,7 @@ function SessionListInner(props: SessionListProps) { map.set(s.id, (e: React.MouseEvent) => handleContextMenu(e, s.id)); }); return map; - }, [sessions, handleContextMenu]); + }, [sessionIdsKey, handleContextMenu]); const finishRenameHandlers = useMemo(() => { const map = new Map void>(); @@ -477,7 +479,7 @@ function SessionListInner(props: SessionListProps) { map.set(s.id, (newName: string) => finishRenamingSession(s.id, newName)); }); return map; - }, [sessions, finishRenamingSession]); + }, [sessionIdsKey, finishRenamingSession]); const toggleBookmarkHandlers = useMemo(() => { const map = new Map void>(); @@ -485,7 +487,7 @@ function SessionListInner(props: SessionListProps) { map.set(s.id, () => toggleBookmark(s.id)); }); return map; - }, [sessions, toggleBookmark]); + }, [sessionIdsKey, toggleBookmark]); // Helper: compute navIndexMap key for a session based on render context const getNavKey = (variant: string, session: Session, groupId?: string): string => { @@ -819,42 +821,40 @@ function SessionListInner(props: SessionListProps) { )} -
- {/* Hamburger Menu */} -
- + {/* Menu Overlay */} + {menuOpen && ( +
- - - {/* Menu Overlay */} - {menuOpen && ( -
- -
- )} -
+ +
+ )}
) : ( -
+