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
+
+
+
+
+
+
+
+
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) => (
-
- {action.icon || }
-
-
{action.label}
- {action.description && (
-
- {action.description}
-
- )}
-
-
- ))}
+ {recoveryActions.map((action, index) => {
+ const isRetry = action.id === 'retry';
+ const isDisabled = isRetry && retryInvoked;
+ return (
+
{
+ if (isRetry) {
+ if (retryInvoked) return;
+ setRetryInvoked(true);
+ }
+ action.onClick();
+ }}
+ className={`w-full flex items-center gap-3 px-4 py-3 rounded border transition-colors text-left ${
+ action.primary && !isDisabled
+ ? 'hover:brightness-110'
+ : !isDisabled
+ ? 'hover:bg-white/5'
+ : ''
+ }`}
+ style={{
+ backgroundColor: action.primary ? theme.colors.accent : 'transparent',
+ borderColor: action.primary ? theme.colors.accent : theme.colors.border,
+ color: action.primary ? theme.colors.accentForeground : theme.colors.textMain,
+ opacity: isDisabled ? 0.5 : 1,
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
+ }}
+ >
+ {action.icon || }
+
+
{action.label}
+ {action.description && (
+
+ {action.description}
+
+ )}
+
+
+ );
+ })}
)}
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 && (
- Exchange
+ PlayBooks
)}
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 */}
-
-
setMenuOpen(!menuOpen)}
- className="p-2 rounded hover:bg-white/10 transition-colors"
- style={{ color: theme.colors.textDim }}
- title="Menu"
+ {/* Hamburger Menu */}
+
+
setMenuOpen(!menuOpen)}
+ className="p-2 rounded hover:bg-white/10 transition-colors"
+ style={{ color: theme.colors.textDim }}
+ title="Menu"
+ >
+
+
+ {/* Menu Overlay */}
+ {menuOpen && (
+
-
-
- {/* Menu Overlay */}
- {menuOpen && (
-
-
-
- )}
-
+
+
+ )}
>
) : (
-
+
setMenuOpen(!menuOpen)}
className="p-2 rounded hover:bg-white/10 transition-colors"
@@ -868,7 +868,7 @@ function SessionListInner(props: SessionListProps) {
{/* Menu Overlay for Collapsed Sidebar */}
{menuOpen && (
t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
+ const escapedTerms = terms.map((t) => t.replace(REGEX_ESCAPE, '\\$&'));
const regex = new RegExp(`(${escapedTerms.join('|')})`, 'gi');
const parts = text.split(regex);
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index ea75b671fb..4615760030 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -453,6 +453,7 @@ interface MaestroAPI {
agentId: string;
sessionId?: string;
timestamp: number;
+ rateLimitResetAt?: number;
raw?: {
exitCode?: number;
stderr?: string;
diff --git a/src/renderer/hooks/agent/useAgentListeners.ts b/src/renderer/hooks/agent/useAgentListeners.ts
index 6f7c0a7ed3..79b61cdcbb 100644
--- a/src/renderer/hooks/agent/useAgentListeners.ts
+++ b/src/renderer/hooks/agent/useAgentListeners.ts
@@ -1142,6 +1142,7 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void {
agentId: error.agentId,
sessionId: error.sessionId,
timestamp: error.timestamp,
+ rateLimitResetAt: error.rateLimitResetAt,
raw: error.raw,
parsedJson: error.parsedJson,
};
diff --git a/src/renderer/hooks/remote/useAppRemoteEventListeners.ts b/src/renderer/hooks/remote/useAppRemoteEventListeners.ts
index e32e46a1e4..4b3a994d5c 100644
--- a/src/renderer/hooks/remote/useAppRemoteEventListeners.ts
+++ b/src/renderer/hooks/remote/useAppRemoteEventListeners.ts
@@ -421,6 +421,7 @@ export function useAppRemoteEventListeners(deps: UseAppRemoteEventListenersDeps)
unifiedClosedTabHistory: [],
groupId: groupId || undefined,
autoRunFolderPath: `${cwd}/${PLAYBOOKS_DIR}`,
+ createdAt: Date.now(),
};
setSessions((prev: Session[]) => [...prev, newSession]);
diff --git a/src/renderer/hooks/remote/useLiveOverlay.ts b/src/renderer/hooks/remote/useLiveOverlay.ts
index d8d0c25c4b..800be993af 100644
--- a/src/renderer/hooks/remote/useLiveOverlay.ts
+++ b/src/renderer/hooks/remote/useLiveOverlay.ts
@@ -119,6 +119,56 @@ export function useLiveOverlay(isLiveMode: boolean): UseLiveOverlayReturn {
}
}, [isLiveMode]);
+ // Keep tunnel UI aligned with the actual cloudflared process state.
+ useEffect(() => {
+ if (!isLiveMode || (tunnelStatus !== 'starting' && tunnelStatus !== 'connected')) {
+ return;
+ }
+
+ let cancelled = false;
+ const syncStatus = async () => {
+ try {
+ const status = await window.maestro.tunnel.getStatus();
+ if (cancelled) return;
+
+ if (status.isRunning && status.url) {
+ setTunnelStatus('connected');
+ setTunnelUrl(status.url);
+ setTunnelError(null);
+ return;
+ }
+
+ if (status.error) {
+ setTunnelStatus('error');
+ setTunnelError(status.error);
+ } else {
+ setTunnelStatus('off');
+ }
+ setTunnelUrl(null);
+ setActiveUrlTab('local');
+ } catch (error) {
+ if (cancelled) return;
+ setTunnelStatus('error');
+ setTunnelError(error instanceof Error ? error.message : 'Failed to read tunnel status');
+ setTunnelUrl(null);
+ setActiveUrlTab('local');
+ }
+ };
+
+ void syncStatus();
+ const intervalId = window.setInterval(
+ () => {
+ void syncStatus();
+ },
+ tunnelStatus === 'starting' ? 500 : 2000
+ );
+
+ return () => {
+ cancelled = true;
+ window.clearInterval(intervalId);
+ };
+ }, [isLiveMode, tunnelStatus]);
+
// Handle tunnel toggle (start/stop remote access)
const handleTunnelToggle = useCallback(async () => {
if (tunnelStatus === 'connected') {
diff --git a/src/renderer/hooks/session/useSessionCrud.ts b/src/renderer/hooks/session/useSessionCrud.ts
index 718d9c5af0..d3a8674062 100644
--- a/src/renderer/hooks/session/useSessionCrud.ts
+++ b/src/renderer/hooks/session/useSessionCrud.ts
@@ -212,6 +212,7 @@ export function useSessionCrud(deps: UseSessionCrudDeps): UseSessionCrudReturn {
cwd: workingDir,
fullPath: workingDir,
projectRoot: workingDir,
+ createdAt: Date.now(),
isGitRepo,
gitBranches,
gitTags,
diff --git a/src/renderer/hooks/symphony/useSymphonyContribution.ts b/src/renderer/hooks/symphony/useSymphonyContribution.ts
index b429c46cea..48e4f5a49a 100644
--- a/src/renderer/hooks/symphony/useSymphonyContribution.ts
+++ b/src/renderer/hooks/symphony/useSymphonyContribution.ts
@@ -137,6 +137,7 @@ export function useSymphonyContribution(
cwd: data.localPath,
fullPath: data.localPath,
projectRoot: data.localPath,
+ createdAt: Date.now(),
isGitRepo,
gitBranches,
gitTags,
diff --git a/src/renderer/hooks/wizard/useWizardHandlers.ts b/src/renderer/hooks/wizard/useWizardHandlers.ts
index 1bf3df5a91..bd16661525 100644
--- a/src/renderer/hooks/wizard/useWizardHandlers.ts
+++ b/src/renderer/hooks/wizard/useWizardHandlers.ts
@@ -1147,6 +1147,7 @@ export function useWizardHandlers(deps: UseWizardHandlersDeps): UseWizardHandler
cwd: directoryPath,
fullPath: directoryPath,
projectRoot: directoryPath,
+ createdAt: Date.now(),
isGitRepo,
gitBranches,
gitTags,
diff --git a/src/renderer/stores/agentStore.ts b/src/renderer/stores/agentStore.ts
index b3a85cc1a8..56b784c52a 100644
--- a/src/renderer/stores/agentStore.ts
+++ b/src/renderer/stores/agentStore.ts
@@ -30,6 +30,7 @@ import { createTab, getActiveTab } from '../utils/tabHelpers';
import { getStdinFlags } from '../utils/spawnHelpers';
import { generateId } from '../utils/ids';
import { useSessionStore } from './sessionStore';
+import { useSettingsStore } from './settingsStore';
import { DEFAULT_IMAGE_ONLY_PROMPT } from '../hooks/input/useInputProcessing';
import { maestroSystemPrompt } from '../../prompts';
import { substituteTemplateVariables } from '../utils/templateVariables';
@@ -204,7 +205,131 @@ export const useAgentStore = create
()((set, get) => ({
},
retryAfterError: (sessionId) => {
+ const session = getSession(sessionId);
+ if (!session) return;
+
+ // 1. Find the target tab so we can grab the last user message
+ const targetTabId = session.agentErrorTabId;
+ const targetTab = targetTabId
+ ? session.aiTabs.find((tab) => tab.id === targetTabId)
+ : getActiveTab(session);
+
+ if (!targetTab) return;
+
+ // 2. Clear the error state (sets session to idle)
get().clearAgentError(sessionId);
+
+ // 3. Find the last user string in the logs
+ const logs = targetTab.logs || [];
+ const lastUserLog = [...logs].reverse().find((l) => l.source === 'user');
+
+ if (!lastUserLog) {
+ console.warn('[retryAfterError] No user message found to retry.');
+ return;
+ }
+
+ const hasImages = Array.isArray(lastUserLog.images) && lastUserLog.images.length > 0;
+ const hasText = !!lastUserLog.text?.trim();
+
+ if (!hasText && !hasImages) {
+ console.warn('[retryAfterError] Last user message is empty (no text, no images).');
+ return;
+ }
+
+ // 4. Re-construct a QueuedItem 'message' to re-dispatch.
+ // By sending it as a 'message' (even if it originally was a command),
+ // we avoid double-substituting template variables, as the text in the log
+ // is already the final rendered prompt. Also, processQueuedItem does not
+ // push duplicate logs for 'message' types.
+ const queuedItem: QueuedItem = {
+ id: generateId(),
+ timestamp: Date.now(),
+ tabId: targetTab.id,
+ type: 'message',
+ text: !hasText && hasImages ? DEFAULT_IMAGE_ONLY_PROMPT : lastUserLog.text!,
+ images: lastUserLog.images,
+ tabName:
+ targetTab.name ||
+ (targetTab.agentSessionId ? targetTab.agentSessionId.split('-')[0].toUpperCase() : 'New'),
+ readOnlyMode: targetTab.readOnlyMode,
+ };
+
+ // 5. Gather required deps (we only strictly need conductorProfile for plain messages)
+ const settings = useSettingsStore.getState();
+ const deps: ProcessQueuedItemDeps = {
+ conductorProfile: settings.conductorProfile,
+ customAICommands: settings.customAICommands,
+ speckitCommands: [],
+ openspecCommands: [],
+ bmadCommands: [],
+ };
+
+ // 6. Reset session to busy/thinking state so the UI reflects the retry
+ useSessionStore.getState().setSessions((prev) =>
+ prev.map((s) => {
+ if (s.id !== sessionId) return s;
+ const updatedAiTabs = s.aiTabs?.map((tab) =>
+ tab.id === targetTab.id
+ ? {
+ ...tab,
+ state: 'busy' as const,
+ thinkingStartTime: Date.now(),
+ }
+ : tab
+ );
+ return {
+ ...s,
+ state: 'busy' as SessionState,
+ busySource: 'ai',
+ aiTabs: updatedAiTabs,
+ };
+ })
+ );
+
+ // 7. Dispatch to agent!
+ setTimeout(() => {
+ get()
+ .processQueuedItem(sessionId, queuedItem, deps)
+ .catch((err) => {
+ console.error('[retryAfterError] Failed to retry item:', err);
+ import('../utils/sentry').then((mod) => {
+ mod.captureException(err, { extra: { operation: 'retryAfterError' } });
+ });
+
+ // Re-surface the error modal
+ import('./sessionStore').then(({ useSessionStore }) => {
+ useSessionStore.getState().setSessions((prev) =>
+ prev.map((s) => {
+ if (s.id !== sessionId) return s;
+ // Put the error back on the session and target tab
+ const updatedAiTabs = s.aiTabs?.map((tab) =>
+ tab.id === targetTab.id
+ ? {
+ ...tab,
+ state: 'idle' as const,
+ }
+ : tab
+ );
+ return {
+ ...s,
+ state: 'error' as SessionState,
+ busySource: undefined,
+ aiTabs: updatedAiTabs,
+ agentError: {
+ type: 'agent_crashed',
+ message: err instanceof Error ? err.message : String(err),
+ recoverable: true,
+ agentId: s.toolType,
+ timestamp: Date.now(),
+ },
+ agentErrorTabId: targetTab.id,
+ agentErrorPaused: true,
+ };
+ })
+ );
+ });
+ });
+ }, 0);
},
restartAgentAfterError: async (sessionId) => {
diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts
index e983280567..d3ae4f7d09 100644
--- a/src/renderer/types/index.ts
+++ b/src/renderer/types/index.ts
@@ -533,6 +533,7 @@ export interface Session {
cwd: string;
fullPath: string;
projectRoot: string; // The initial working directory (never changes, used for Claude session storage)
+ createdAt: number; // Timestamp when the session was created
aiLogs: LogEntry[];
// DEPRECATED: Legacy shell output logs — terminal tabs use xterm.js with direct PTY streaming
shellLogs: LogEntry[];
diff --git a/src/renderer/utils/contextUsage.ts b/src/renderer/utils/contextUsage.ts
index 762aa59638..ce6a47c2b0 100644
--- a/src/renderer/utils/contextUsage.ts
+++ b/src/renderer/utils/contextUsage.ts
@@ -168,11 +168,17 @@ export function calculateContextDisplay(
let tokens = raw;
if (raw > contextWindow) {
- if (fallbackPercentage != null && fallbackPercentage >= 0) {
+ if (
+ fallbackPercentage != null &&
+ Number.isFinite(fallbackPercentage) &&
+ fallbackPercentage >= 0
+ ) {
// Accumulated multi-tool turn: derive tokens from preserved percentage
- tokens = Math.round((fallbackPercentage / 100) * contextWindow);
+ const boundedFallback = Math.min(100, fallbackPercentage);
+ tokens = Math.round((boundedFallback / 100) * contextWindow);
} else {
- // No fallback available: cap to context window
+ // We don't have a trustworthy fallback percentage yet, so clamp to the
+ // configured window instead of displaying an impossible token total.
tokens = contextWindow;
}
}
diff --git a/src/renderer/utils/tabHelpers.ts b/src/renderer/utils/tabHelpers.ts
index b2d3eeea28..4c33986360 100644
--- a/src/renderer/utils/tabHelpers.ts
+++ b/src/renderer/utils/tabHelpers.ts
@@ -1996,6 +1996,7 @@ export function createMergedSession(
cwd: projectRoot,
fullPath: projectRoot,
projectRoot, // Never changes, used for session storage
+ createdAt: Date.now(),
isGitRepo: false, // Will be updated by caller if needed
aiLogs: [], // Deprecated - logs are in aiTabs
shellLogs: [
diff --git a/src/renderer/utils/worktreeSession.ts b/src/renderer/utils/worktreeSession.ts
index e7e956879a..97c6f3072d 100644
--- a/src/renderer/utils/worktreeSession.ts
+++ b/src/renderer/utils/worktreeSession.ts
@@ -61,6 +61,7 @@ export function buildWorktreeSession(params: BuildWorktreeSessionParams): Sessio
cwd: params.path,
fullPath: params.path,
projectRoot: params.path,
+ createdAt: Date.now(),
isGitRepo: true,
gitBranches: params.gitBranches,
gitTags: params.gitTags,
diff --git a/src/shared/agentMetadata.ts b/src/shared/agentMetadata.ts
index dae2f52710..e10af9304e 100644
--- a/src/shared/agentMetadata.ts
+++ b/src/shared/agentMetadata.ts
@@ -64,11 +64,7 @@ export function getReadOnlyModeTooltip(agentId: AgentId | string): string {
* Agents currently in beta/experimental status.
* Used to render "(Beta)" badges throughout the UI.
*/
-export const BETA_AGENTS: ReadonlySet = new Set([
- 'codex',
- 'opencode',
- 'factory-droid',
-]);
+export const BETA_AGENTS: ReadonlySet = new Set(['opencode', 'factory-droid']);
/**
* Check whether an agent is in beta status.
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 09b3eaf5b1..1285ad0f8b 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -196,6 +196,9 @@ export interface AgentError {
/** Timestamp when the error occurred */
timestamp: number;
+ /** Epoch ms when rate limit resets (parsed from error text, e.g. "resets 3pm") */
+ rateLimitResetAt?: number;
+
/** Original error data for debugging (stderr, exit code, etc.) */
raw?: {
exitCode?: number;