diff --git a/.gitignore b/.gitignore index 8c136325be..50d91c2a02 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ yarn-error.log* .vscode/ .VSCodeCounter .qodo + +# Claude Code local settings +.claude/settings.local.json diff --git a/package-lock.json b/package-lock.json index 7482623e10..52ae046858 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.15.0", + "version": "0.15.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.15.0", + "version": "0.15.1", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { @@ -264,6 +264,7 @@ "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", @@ -667,6 +668,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -710,6 +712,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2283,6 +2286,7 @@ "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" } @@ -2304,6 +2308,7 @@ "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" }, @@ -2316,6 +2321,7 @@ "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" }, @@ -2331,6 +2337,7 @@ "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", @@ -2718,6 +2725,7 @@ "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" @@ -2734,6 +2742,7 @@ "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", @@ -2751,6 +2760,7 @@ "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" } @@ -3809,8 +3819,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4348,6 +4357,7 @@ "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" @@ -4359,6 +4369,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4484,6 +4495,7 @@ "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", @@ -4914,6 +4926,7 @@ "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" }, @@ -4995,6 +5008,7 @@ "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", @@ -5998,6 +6012,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -6480,6 +6495,7 @@ "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", @@ -7205,6 +7221,7 @@ "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" } @@ -7614,6 +7631,7 @@ "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" } @@ -8111,6 +8129,7 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -8206,8 +8225,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dompurify": { "version": "3.3.0", @@ -8351,7 +8369,6 @@ "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -8365,7 +8382,6 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -8385,7 +8401,6 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -8408,7 +8423,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8425,7 +8439,6 @@ "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", @@ -8442,7 +8455,6 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -8457,7 +8469,6 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -8473,7 +8484,6 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -8486,8 +8496,7 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/electron-builder-squirrel-windows/node_modules/string_decoder": { "version": "1.1.1", @@ -8495,7 +8504,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -8506,7 +8514,6 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -8517,7 +8524,6 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -8533,7 +8539,6 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -9215,6 +9220,7 @@ "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", @@ -11134,6 +11140,7 @@ "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" @@ -11954,6 +11961,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -12423,16 +12431,14 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "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", - "peer": true + "license": "MIT" }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", @@ -12445,8 +12451,7 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isequal": { "version": "4.5.0", @@ -12460,8 +12465,7 @@ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -12475,8 +12479,7 @@ "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", - "peer": true + "license": "MIT" }, "node_modules/log-symbols": { "version": "4.1.0", @@ -12567,7 +12570,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -15065,6 +15067,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15305,7 +15308,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -15321,7 +15323,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -15666,6 +15667,7 @@ "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" }, @@ -15695,6 +15697,7 @@ "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" @@ -15742,6 +15745,7 @@ "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" @@ -15928,7 +15932,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -17685,6 +17690,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17995,6 +18001,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18368,6 +18375,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -18873,6 +18881,7 @@ "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", @@ -19463,6 +19472,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -19476,6 +19486,7 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -20073,6 +20084,7 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/__tests__/main/agents/session-storage.test.ts b/src/__tests__/main/agents/session-storage.test.ts index 28ff3f88b3..f1abcae301 100644 --- a/src/__tests__/main/agents/session-storage.test.ts +++ b/src/__tests__/main/agents/session-storage.test.ts @@ -22,6 +22,7 @@ vi.mock('os', async () => { const mocked = { ...actual, homedir: vi.fn(() => '/tmp/maestro-session-storage-home'), + tmpdir: vi.fn(() => '/tmp'), }; return { ...mocked, diff --git a/src/__tests__/main/ipc/handlers/groupChat.test.ts b/src/__tests__/main/ipc/handlers/groupChat.test.ts index 4d3fffc46d..df1fe4b672 100644 --- a/src/__tests__/main/ipc/handlers/groupChat.test.ts +++ b/src/__tests__/main/ipc/handlers/groupChat.test.ts @@ -70,6 +70,10 @@ vi.mock('../../../../main/group-chat/group-chat-agent', () => ({ // Mock group-chat-router vi.mock('../../../../main/group-chat/group-chat-router', () => ({ routeUserMessage: vi.fn(), + clearPendingParticipants: vi.fn(), + routeAgentResponse: vi.fn(), + markParticipantResponded: vi.fn(), + spawnModeratorSynthesis: vi.fn(), })); // Mock agent-detector @@ -169,6 +173,8 @@ describe('groupChat IPC handlers', () => { 'groupChat:startModerator', 'groupChat:sendToModerator', 'groupChat:stopModerator', + 'groupChat:stopAll', + 'groupChat:reportAutoRunComplete', 'groupChat:getModeratorSessionId', // Participant handlers 'groupChat:addParticipant', @@ -987,6 +993,113 @@ describe('groupChat IPC handlers', () => { }); }); + describe('groupChat:stopAll', () => { + it('should kill moderator, clear participant sessions, and emit idle states', async () => { + const mockChat: GroupChat = { + id: 'gc-stop-all', + name: 'Stop All Chat', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'session-stop', + participants: [ + { + name: 'Worker 1', + agentId: 'claude-code', + sessionId: 'p-1', + addedAt: Date.now(), + }, + { + name: 'Worker 2', + agentId: 'claude-code', + sessionId: 'p-2', + addedAt: Date.now(), + }, + ], + logPath: '/path/stop', + imagesDir: '/images/stop', + }; + + vi.mocked(groupChatModerator.killModerator).mockResolvedValue(undefined); + vi.mocked(groupChatAgent.clearAllParticipantSessions).mockResolvedValue(undefined); + vi.mocked(groupChatStorage.loadGroupChat).mockResolvedValue(mockChat); + + const handler = handlers.get('groupChat:stopAll'); + await handler!({} as any, 'gc-stop-all'); + + expect(groupChatModerator.killModerator).toHaveBeenCalledWith( + 'gc-stop-all', + mockProcessManager + ); + expect(groupChatAgent.clearAllParticipantSessions).toHaveBeenCalledWith( + 'gc-stop-all', + mockProcessManager + ); + expect(groupChatRouter.clearPendingParticipants).toHaveBeenCalledWith('gc-stop-all'); + }); + + it('should handle null process manager', async () => { + const depsNoProcessManager: GroupChatHandlerDependencies = { + ...mockDeps, + getProcessManager: () => null, + }; + + handlers.clear(); + registerGroupChatHandlers(depsNoProcessManager); + + vi.mocked(groupChatModerator.killModerator).mockResolvedValue(undefined); + vi.mocked(groupChatAgent.clearAllParticipantSessions).mockResolvedValue(undefined); + vi.mocked(groupChatStorage.loadGroupChat).mockResolvedValue(null); + + const handler = handlers.get('groupChat:stopAll'); + await handler!({} as any, 'gc-stop-null'); + + expect(groupChatModerator.killModerator).toHaveBeenCalledWith('gc-stop-null', undefined); + expect(groupChatAgent.clearAllParticipantSessions).toHaveBeenCalledWith( + 'gc-stop-null', + undefined + ); + }); + }); + + describe('groupChat:reportAutoRunComplete', () => { + it('should route agent response and mark participant as responded', async () => { + vi.mocked(groupChatRouter.routeAgentResponse).mockResolvedValue(undefined); + vi.mocked(groupChatRouter.markParticipantResponded).mockReturnValue({ + allResponded: false, + isLastParticipant: false, + } as any); + + const handler = handlers.get('groupChat:reportAutoRunComplete'); + await handler!({} as any, 'gc-autorun', 'Worker 1', 'Task completed successfully'); + + expect(groupChatRouter.routeAgentResponse).toHaveBeenCalledWith( + 'gc-autorun', + 'Worker 1', + 'Task completed successfully', + mockProcessManager + ); + }); + + it('should trigger synthesis when all participants have responded', async () => { + vi.mocked(groupChatRouter.routeAgentResponse).mockResolvedValue(undefined); + vi.mocked(groupChatRouter.markParticipantResponded).mockReturnValue({ + allResponded: true, + isLastParticipant: true, + } as any); + vi.mocked(groupChatRouter.spawnModeratorSynthesis).mockResolvedValue(undefined); + + const handler = handlers.get('groupChat:reportAutoRunComplete'); + await handler!({} as any, 'gc-autorun-done', 'Worker 1', 'All done'); + + expect(groupChatRouter.routeAgentResponse).toHaveBeenCalled(); + expect(groupChatRouter.markParticipantResponded).toHaveBeenCalledWith( + 'gc-autorun-done', + 'Worker 1' + ); + }); + }); + describe('event emitters', () => { it('should set up emitMessage emitter', () => { expect(groupChatEmitters.emitMessage).toBeDefined(); diff --git a/src/__tests__/renderer/components/GroupChatHeader.test.tsx b/src/__tests__/renderer/components/GroupChatHeader.test.tsx index 74ac05bba6..70111b81d5 100644 --- a/src/__tests__/renderer/components/GroupChatHeader.test.tsx +++ b/src/__tests__/renderer/components/GroupChatHeader.test.tsx @@ -24,6 +24,11 @@ vi.mock('lucide-react', () => ({ $ ), + StopCircle: ({ className }: { className?: string }) => ( + + ⏹ + + ), })); const mockTheme = { @@ -44,6 +49,8 @@ const defaultProps = { theme: mockTheme, name: 'Test Chat', participantCount: 3, + state: 'idle' as const, + onStopAll: vi.fn(), onRename: vi.fn(), onShowInfo: vi.fn(), rightPanelOpen: false, @@ -97,4 +104,23 @@ describe('GroupChatHeader', () => { render(); expect(screen.getByText('1 participant')).toBeTruthy(); }); + + it('shows Stop All button when state is not idle', () => { + render(); + expect(screen.getByText('Stop All')).toBeTruthy(); + }); + + it('hides Stop All button when state is idle', () => { + render(); + expect(screen.queryByText('Stop All')).toBeNull(); + }); + + it('calls onStopAll when Stop All button is clicked', () => { + const onStopAll = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Stop All')); + expect(onStopAll).toHaveBeenCalledOnce(); + }); }); diff --git a/src/main/group-chat/group-chat-router.ts b/src/main/group-chat/group-chat-router.ts index 1ac924f6ee..cf80701e66 100644 --- a/src/main/group-chat/group-chat-router.ts +++ b/src/main/group-chat/group-chat-router.ts @@ -72,6 +72,8 @@ export interface SessionInfo { remoteId: string | null; workingDirOverride?: string; }; + /** Auto Run folder path for this session */ + autoRunFolderPath?: string; } /** @@ -84,6 +86,10 @@ export type GetSessionsCallback = () => SessionInfo[]; */ export type GetCustomEnvVarsCallback = (agentId: string) => Record | undefined; export type GetAgentConfigCallback = (agentId: string) => Record | undefined; +export type GetModeratorSettingsCallback = () => { + standingInstructions: string; + conductorProfile: string; +}; // Module-level callback for session lookup let getSessionsCallback: GetSessionsCallback | null = null; @@ -92,6 +98,9 @@ let getSessionsCallback: GetSessionsCallback | null = null; let getCustomEnvVarsCallback: GetCustomEnvVarsCallback | null = null; let getAgentConfigCallback: GetAgentConfigCallback | null = null; +// Module-level callback for moderator settings (standing instructions + conductor profile) +let getModeratorSettingsCallback: GetModeratorSettingsCallback | null = null; + // Module-level SSH store for remote execution support let sshStore: SshRemoteSettingsStore | null = null; @@ -102,6 +111,96 @@ let sshStore: SshRemoteSettingsStore | null = null; */ const pendingParticipantResponses = new Map>(); +/** + * Tracks per-participant response timeout handles. + * Maps `${groupChatId}:${participantName}` -> NodeJS.Timeout + * Timeouts fire if a participant never responds (hung process, lost IPC, etc.) + */ +const participantTimeouts = new Map>(); + +/** How long to wait for a participant before treating them as timed-out (10 minutes). */ +const PARTICIPANT_RESPONSE_TIMEOUT_MS = 10 * 60 * 1000; + +function getParticipantTimeoutKey(groupChatId: string, participantName: string): string { + return `${groupChatId}:${participantName}`; +} + +/** + * Registers a response timeout for a participant. + * If the participant doesn't respond in PARTICIPANT_RESPONSE_TIMEOUT_MS, they are + * force-marked as responded so synthesis can proceed and the chat doesn't hang forever. + */ +function setParticipantResponseTimeout( + groupChatId: string, + participantName: string, + processManager: IProcessManager | undefined, + agentDetector: AgentDetector | undefined +): void { + const key = getParticipantTimeoutKey(groupChatId, participantName); + // Clear any existing timeout for this participant + const existing = participantTimeouts.get(key); + if (existing) clearTimeout(existing); + + const handle = setTimeout(async () => { + participantTimeouts.delete(key); + const pending = pendingParticipantResponses.get(groupChatId); + if (!pending?.has(participantName)) return; // Already responded + + console.warn( + `[GroupChat:Debug] Participant ${participantName} timed out after ${PARTICIPANT_RESPONSE_TIMEOUT_MS / 1000}s — force-completing` + ); + groupChatEmitters.emitMessage?.(groupChatId, { + timestamp: new Date().toISOString(), + from: 'system', + content: `⚠️ @${participantName} did not respond within ${PARTICIPANT_RESPONSE_TIMEOUT_MS / 60000} minutes and has been marked as timed out.`, + }); + + // Log a timeout response so the moderator knows what happened + try { + const { loadGroupChat } = await import('./group-chat-storage'); + const { appendToLog } = await import('./group-chat-log'); + const chat = await loadGroupChat(groupChatId); + if (chat) { + await appendToLog( + chat.logPath, + participantName, + `[Timed out — no response after ${PARTICIPANT_RESPONSE_TIMEOUT_MS / 60000} minutes]` + ); + } + } catch { + // Non-critical — synthesize anyway + } + + // Reset participant state and force-complete the batch so the AUTO badge + // and progress bar clear immediately — the batch loop may still be awaiting + // a process exit that will never come. + groupChatEmitters.emitParticipantState?.(groupChatId, participantName, 'idle'); + groupChatEmitters.emitAutoRunBatchComplete?.(groupChatId, participantName); + + const isLast = markParticipantResponded(groupChatId, participantName); + if (isLast && processManager && agentDetector) { + spawnModeratorSynthesis(groupChatId, processManager, agentDetector).catch((err) => { + console.error('[GroupChat:Debug] Synthesis after timeout failed:', err); + groupChatEmitters.emitStateChange?.(groupChatId, 'idle'); + }); + } + }, PARTICIPANT_RESPONSE_TIMEOUT_MS); + + participantTimeouts.set(key, handle); +} + +/** + * Cancels the response timeout for a participant (called when they do respond). + */ +function clearParticipantResponseTimeout(groupChatId: string, participantName: string): void { + const key = getParticipantTimeoutKey(groupChatId, participantName); + const handle = participantTimeouts.get(key); + if (handle) { + clearTimeout(handle); + participantTimeouts.delete(key); + } +} + /** * Tracks read-only mode state for each group chat. * Set when user sends a message with readOnly flag, cleared on next non-readOnly message. @@ -131,17 +230,26 @@ export function getPendingParticipants(groupChatId: string): Set { } /** - * Clears all pending participants for a group chat. + * Clears all pending participants for a group chat (and their timeouts). */ export function clearPendingParticipants(groupChatId: string): void { + // Cancel all timeouts for this chat before clearing + const pending = pendingParticipantResponses.get(groupChatId); + if (pending) { + for (const name of pending) { + clearParticipantResponseTimeout(groupChatId, name); + } + } pendingParticipantResponses.delete(groupChatId); } /** - * Marks a participant as having responded (removes from pending). + * Marks a participant as having responded (removes from pending, cancels timeout). * Returns true if this was the last pending participant. */ export function markParticipantResponded(groupChatId: string, participantName: string): boolean { + clearParticipantResponseTimeout(groupChatId, participantName); + const pending = pendingParticipantResponses.get(groupChatId); if (!pending) return false; @@ -174,6 +282,14 @@ export function setGetAgentConfigCallback(callback: GetAgentConfigCallback): voi getAgentConfigCallback = callback; } +/** + * Sets the callback for getting moderator settings (standing instructions + conductor profile). + * Called from index.ts during initialization. + */ +export function setGetModeratorSettingsCallback(callback: GetModeratorSettingsCallback): void { + getModeratorSettingsCallback = callback; +} + /** * Sets the SSH store for remote execution support. * Called from index.ts during initialization. @@ -240,6 +356,52 @@ export function extractAllMentions(text: string): string[] { return mentions; } +/** + * Extracts !autorun directives from moderator output. + * Matches `!autorun @AgentName` patterns. + * + * @param text - The moderator's message text + * @returns Object with autorun participant names and cleaned message text + */ +export interface AutoRunDirective { + participantName: string; + /** Specific filename to run, if specified (e.g. `!autorun @Agent:plan.md`). When present, + * only that document is executed instead of all docs in the folder. */ + filename?: string; +} + +export function extractAutoRunDirectives(text: string): { + autoRunDirectives: AutoRunDirective[]; + /** @deprecated use autoRunDirectives */ + autoRunParticipants: string[]; + cleanedText: string; +} { + const autoRunDirectives: AutoRunDirective[] = []; + // Matches: !autorun @AgentName OR !autorun @AgentName:filename.md + const autoRunPattern = /!autorun\s+@([^\s@:,;!?()\[\]{}'"<>]+)(?::([^\s,;!?()\[\]{}'"<>]+))?/g; + let match; + + while ((match = autoRunPattern.exec(text)) !== null) { + const participantName = match[1]; + const filename = match[2]; // undefined when no :filename suffix + if (!autoRunDirectives.some((d) => d.participantName === participantName)) { + autoRunDirectives.push({ participantName, filename }); + } + } + + // Remove !autorun lines from the message for display + const cleanedText = text + .replace(/^.*!autorun\s+@[^\s@:,;!?()\[\]{}'"<>]+.*$/gm, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + return { + autoRunDirectives, + autoRunParticipants: autoRunDirectives.map((d) => d.participantName), + cleanedText, + }; +} + /** * Routes a user message to the moderator. * @@ -414,7 +576,9 @@ export async function routeUserMessage( const participantContext = chat.participants.length > 0 ? chat.participants - .map((p) => `- @${normalizeMentionName(p.name)} (${p.agentId} session)`) + .map((p) => { + return `- @${normalizeMentionName(p.name)} (${p.agentId} session)`; + }) .join('\n') : '(No agents currently in this group chat)'; @@ -444,7 +608,24 @@ export async function routeUserMessage( .map((m) => `[${m.from}]: ${m.content}`) .join('\n'); - const fullPrompt = `${getModeratorSystemPrompt()} + // Get moderator settings for prompt customization + const moderatorSettings = getModeratorSettingsCallback?.() ?? { + standingInstructions: '', + conductorProfile: '', + }; + + // Substitute {{CONDUCTOR_PROFILE}} template variable + const baseSystemPrompt = getModeratorSystemPrompt().replace( + '{{CONDUCTOR_PROFILE}}', + moderatorSettings.conductorProfile || '(No conductor profile set)' + ); + + // Build standing instructions section if configured + const standingInstructionsSection = moderatorSettings.standingInstructions + ? `\n\n## Standing Instructions\n\nThe following instructions apply to ALL group chat sessions. Follow them consistently:\n\n${moderatorSettings.standingInstructions}` + : ''; + + const fullPrompt = `${baseSystemPrompt}${standingInstructionsSection} ## Current Participants: ${participantContext}${availableSessionsContext} @@ -633,29 +814,38 @@ export async function routeModeratorResponse( console.log(`[GroupChat:Debug] Chat loaded: "${chat.name}"`); - // Log the message as coming from moderator - await appendToLog(chat.logPath, 'moderator', message); + // Strip internal !autorun directives from the message before logging/display. + // These are machine-to-machine commands; storing them in the chat log causes + // the synthesis moderator to see them in history and potentially re-trigger them. + const { + autoRunDirectives, + autoRunParticipants, + cleanedText: displayMessage, + } = extractAutoRunDirectives(message); + + // Log the message as coming from moderator (cleaned of !autorun directives) + await appendToLog(chat.logPath, 'moderator', displayMessage); console.log(`[GroupChat:Debug] Message appended to log`); // Emit message event to renderer so it shows immediately const moderatorMessage: GroupChatMessage = { timestamp: new Date().toISOString(), from: 'moderator', - content: message, + content: displayMessage, }; groupChatEmitters.emitMessage?.(groupChatId, moderatorMessage); console.log(`[GroupChat:Debug] Emitted moderator message to renderer`); // Add history entry for moderator response try { - const summary = extractFirstSentence(message); + const summary = extractFirstSentence(displayMessage); const historyEntry = await addGroupChatHistoryEntry(groupChatId, { timestamp: Date.now(), summary, participantName: 'Moderator', participantColor: '#808080', // Gray for moderator type: 'response', - fullResponse: message, + fullResponse: displayMessage, }); // Emit history entry event to renderer @@ -771,10 +961,71 @@ export async function routeModeratorResponse( // Track participants that will need to respond for synthesis round const participantsToRespond = new Set(); - // Spawn batch processes for each mentioned participant - if (processManager && agentDetector && mentions.length > 0) { + // Use the !autorun directives already extracted above (same `message` input) + if (autoRunDirectives.length > 0) { + console.log( + `[GroupChat:Debug] Found !autorun directives for: ${autoRunDirectives.map((d) => (d.filename ? `${d.participantName}:${d.filename}` : d.participantName)).join(', ')}` + ); + } + + // Trigger Auto Run for participants via the renderer's batch processor + // This delegates to the renderer so the full useBatchProcessor pipeline runs: + // progress indicators, multi-document sequencing, task checking, achievements, etc. + if (autoRunDirectives.length > 0) { + console.log(`[GroupChat:Debug] ========== TRIGGERING AUTORUN VIA RENDERER ==========`); + const sessions = getSessionsCallback?.() || []; + + for (const directive of autoRunDirectives) { + const { participantName: autoRunName, filename: targetFilename } = directive; + const participant = updatedChat.participants.find((p) => mentionMatches(autoRunName, p.name)); + if (!participant) { + console.warn( + `[GroupChat:Debug] Autorun participant ${autoRunName} not found in chat - skipping` + ); + groupChatEmitters.emitMessage?.(groupChatId, { + timestamp: new Date().toISOString(), + from: 'system', + content: `⚠️ Could not find participant @${autoRunName} for !autorun. Make sure the agent is added to the group chat.`, + }); + continue; + } + + const matchingSession = sessions.find( + (s) => mentionMatches(s.name, participant.name) || s.name === participant.name + ); + + if (!matchingSession?.autoRunFolderPath) { + console.warn( + `[GroupChat:Debug] No autoRunFolderPath configured for ${participant.name} - skipping` + ); + groupChatEmitters.emitMessage?.(groupChatId, { + timestamp: new Date().toISOString(), + from: 'system', + content: `⚠️ No Auto Run folder configured for @${participant.name}. Open the agent in Maestro, go to the Auto Run tab, and configure a folder first.`, + }); + continue; + } + + // Emit event to renderer — the renderer will call startBatchRun via useBatchProcessor. + // When the batch completes, the renderer calls groupChat:reportAutoRunComplete which + // invokes routeAgentResponse to trigger the synthesis round. + groupChatEmitters.emitParticipantState?.(groupChatId, participant.name, 'working'); + groupChatEmitters.emitAutoRunTriggered?.(groupChatId, participant.name, targetFilename); + participantsToRespond.add(participant.name); + console.log( + `[GroupChat:Debug] Emitted autoRunTriggered for @${participant.name}${targetFilename ? `:${targetFilename}` : ''} in chat ${groupChatId}` + ); + } + console.log(`[GroupChat:Debug] =================================================`); + } + + // Spawn batch processes for each mentioned participant (exclude autorun participants) + const mentionsToSpawn = mentions.filter( + (name) => !autoRunParticipants.some((arName) => mentionMatches(arName, name)) + ); + if (processManager && agentDetector && mentionsToSpawn.length > 0) { console.log(`[GroupChat:Debug] ========== SPAWNING PARTICIPANT AGENTS ==========`); - console.log(`[GroupChat:Debug] Will spawn ${mentions.length} participant agent(s)`); + console.log(`[GroupChat:Debug] Will spawn ${mentionsToSpawn.length} participant agent(s)`); // Get available sessions for cwd lookup const sessions = getSessionsCallback?.() || []; @@ -788,7 +1039,7 @@ export async function routeModeratorResponse( ) .join('\n'); - for (const participantName of mentions) { + for (const participantName of mentionsToSpawn) { console.log(`[GroupChat:Debug] --- Spawning participant: @${participantName} ---`); // Find the participant info @@ -985,8 +1236,10 @@ export async function routeModeratorResponse( } } console.log(`[GroupChat:Debug] =================================================`); - } else if (mentions.length === 0) { - console.log(`[GroupChat:Debug] No participant @mentions found - moderator response is final`); + } else if (mentionsToSpawn.length === 0 && autoRunParticipants.length === 0) { + console.log( + `[GroupChat:Debug] No participant @mentions or autorun directives found - moderator response is final` + ); // Set state back to idle since no agents are being spawned groupChatEmitters.emitStateChange?.(groupChatId, 'idle'); console.log(`[GroupChat:Debug] Emitted state change: idle`); @@ -994,12 +1247,22 @@ export async function routeModeratorResponse( powerManager.removeBlockReason(`groupchat:${groupChatId}`); } - // Store pending participants for synthesis tracking + // Store pending participants for synthesis tracking and install response timeouts if (participantsToRespond.size > 0) { pendingParticipantResponses.set(groupChatId, participantsToRespond); console.log( `[GroupChat:Debug] Waiting for ${participantsToRespond.size} participant(s) to respond: ${[...participantsToRespond].join(', ')}` ); + // Install a per-participant timeout so a hung/unresponsive participant can't block + // synthesis indefinitely. The timeout fires after PARTICIPANT_RESPONSE_TIMEOUT_MS. + for (const participantName of participantsToRespond) { + setParticipantResponseTimeout( + groupChatId, + participantName, + processManager ?? undefined, + agentDetector ?? undefined + ); + } // Set state to show agents are working groupChatEmitters.emitStateChange?.(groupChatId, 'agent-working'); console.log(`[GroupChat:Debug] Emitted state change: agent-working`); @@ -1217,11 +1480,26 @@ export async function spawnModeratorSynthesis( const participantContext = chat.participants.length > 0 ? chat.participants - .map((p) => `- @${normalizeMentionName(p.name)} (${p.agentId} session)`) + .map((p) => { + return `- @${normalizeMentionName(p.name)} (${p.agentId} session)`; + }) .join('\n') : '(No agents currently in this group chat)'; - const synthesisPrompt = `${getModeratorSystemPrompt()} + // Get moderator settings for prompt customization + const synthModeratorSettings = getModeratorSettingsCallback?.() ?? { + standingInstructions: '', + conductorProfile: '', + }; + const synthBasePrompt = getModeratorSystemPrompt().replace( + '{{CONDUCTOR_PROFILE}}', + synthModeratorSettings.conductorProfile || '(No conductor profile set)' + ); + const synthStandingInstructions = synthModeratorSettings.standingInstructions + ? `\n\n## Standing Instructions\n\nThe following instructions apply to ALL group chat sessions. Follow them consistently:\n\n${synthModeratorSettings.standingInstructions}` + : ''; + + const synthesisPrompt = `${synthBasePrompt}${synthStandingInstructions} ${getModeratorSynthesisPrompt()} @@ -1233,8 +1511,10 @@ ${historyContext} ## Your Task: Review the agent responses above. Either: -1. Synthesize into a final answer for the user (NO @mentions) if the question is fully answered -2. @mention specific agents for follow-up if you need more information`; +1. Synthesize into a final answer for the user (NO @mentions, NO !autorun) if the question is fully answered +2. @mention specific agents for follow-up if you need more information + +**IMPORTANT: Do NOT include any !autorun directives in this synthesis response.**`; const agentConfigValues = getAgentConfigCallback?.(chat.moderatorAgentId) || {}; const baseArgs = buildAgentArgs(agent, { diff --git a/src/main/index.ts b/src/main/index.ts index 577f81530f..22f612bb36 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -66,6 +66,7 @@ import { setGetSessionsCallback, setGetCustomEnvVarsCallback, setGetAgentConfigCallback, + setGetModeratorSettingsCallback, setSshStore, setGetCustomShellPathCallback, markParticipantResponded, @@ -626,6 +627,8 @@ function setupIpcHandlers() { sshRemoteName, // Pass full SSH config for remote execution support sshRemoteConfig: s.sessionSshRemoteConfig, + autoRunFolderPath: s.autoRunFolderPath, + worktreeBasePath: s.worktreeConfig?.basePath, }; }); }); @@ -634,6 +637,12 @@ function setupIpcHandlers() { setGetCustomEnvVarsCallback(getCustomEnvVarsForAgent); setGetAgentConfigCallback(getAgentConfigForAgent); + // Set up callback for group chat router to get moderator standing instructions + conductor profile + setGetModeratorSettingsCallback(() => ({ + standingInstructions: (store.get('moderatorStandingInstructions', '') as string) || '', + conductorProfile: (store.get('conductorProfile', '') as string) || '', + })); + // Set up SSH store for group chat SSH remote execution support setSshStore(createSshRemoteStoreAdapter(store)); diff --git a/src/main/ipc/handlers/groupChat.ts b/src/main/ipc/handlers/groupChat.ts index a2a97622b3..2a42209d4c 100644 --- a/src/main/ipc/handlers/groupChat.ts +++ b/src/main/ipc/handlers/groupChat.ts @@ -61,7 +61,13 @@ import { } from '../../group-chat/group-chat-agent'; // Group chat router imports -import { routeUserMessage } from '../../group-chat/group-chat-router'; +import { + routeUserMessage, + clearPendingParticipants, + routeAgentResponse, + markParticipantResponded, + spawnModeratorSynthesis, +} from '../../group-chat/group-chat-router'; // Agent detector import import { AgentDetector } from '../../agents'; @@ -100,6 +106,10 @@ export const groupChatEmitters: { state: ParticipantState ) => void; emitModeratorSessionIdChanged?: (groupChatId: string, sessionId: string) => void; + emitParticipantLiveOutput?: (groupChatId: string, participantName: string, chunk: string) => void; + emitAutoRunTriggered?: (groupChatId: string, participantName: string, filename?: string) => void; + /** Tells the renderer to force-complete the batch run for a participant (clears stuck AUTO badge). */ + emitAutoRunBatchComplete?: (groupChatId: string, participantName: string) => void; } = {}; // Helper to create handler options with consistent context @@ -464,6 +474,87 @@ export function registerGroupChatHandlers(deps: GroupChatHandlerDependencies): v }) ); + // Stop all activity in a group chat (moderator + all participants) + ipcMain.handle( + 'groupChat:stopAll', + withIpcErrorLogging(handlerOpts('stopAll'), async (id: string): Promise => { + const processManager = getProcessManager(); + logger.info(`Stopping all activity in group chat: ${id}`, LOG_CONTEXT); + + // Kill moderator and all participant sessions + await killModerator(id, processManager ?? undefined); + await clearAllParticipantSessions(id, processManager ?? undefined); + + // Clear pending participant tracking so next round starts clean. + // Without this, a subsequent user message would inherit the old pending Set + // and trigger synthesis prematurely when those (now-dead) processes "respond". + clearPendingParticipants(id); + + // Load participants to emit idle states for each + const chat = await loadGroupChat(id); + if (chat) { + for (const participant of chat.participants) { + groupChatEmitters.emitParticipantState?.(id, participant.name, 'idle'); + } + } + + // Emit idle state for the group chat + groupChatEmitters.emitStateChange?.(id, 'idle'); + + logger.info(`Stopped all activity in group chat: ${id}`, LOG_CONTEXT); + }) + ); + + // Report that an Auto Run batch triggered by !autorun has completed + // Called by the renderer's batch processor onComplete handler to notify the + // group chat router so it can trigger the synthesis round. + ipcMain.handle( + 'groupChat:reportAutoRunComplete', + withIpcErrorLogging( + handlerOpts('reportAutoRunComplete'), + async (groupChatId: string, participantName: string, summary: string): Promise => { + logger.info( + `Auto Run complete for participant ${participantName} in ${groupChatId}`, + LOG_CONTEXT + ); + const processManager = getProcessManager(); + + // Log the autorun summary as the participant's response + await routeAgentResponse( + groupChatId, + participantName, + summary, + processManager ?? undefined + ); + + // Reset participant state to idle (mirrors what exit-listener does for regular participants). + // Without this the participant card stays "Working" because no process exit fires for + // autorun participants (the batch runs in a separate Maestro session, not a group-chat session). + groupChatEmitters.emitParticipantState?.(groupChatId, participantName, 'idle'); + + // Signal the renderer to definitively complete the batch run for this participant. + // In the happy path this is a no-op (COMPLETE_BATCH was already dispatched by startBatchRun). + // In edge cases (synthesis re-triggered a second batch, or the process was slow to exit) + // this ensures the AUTO badge and progress bar are always cleared. + groupChatEmitters.emitAutoRunBatchComplete?.(groupChatId, participantName); + + // Mark participant as done and trigger synthesis if all participants have responded. + // Unlike regular participants (whose process exit triggers this via exit-listener), + // autorun participants never exit a group-chat process — the batch runs as a separate + // Maestro session — so we must call markParticipantResponded here. + const agentDetector = getAgentDetector(); + const isLast = markParticipantResponded(groupChatId, participantName); + if (isLast && processManager && agentDetector) { + logger.info( + `All participants responded after autorun, spawning synthesis for ${groupChatId}`, + LOG_CONTEXT + ); + await spawnModeratorSynthesis(groupChatId, processManager, agentDetector); + } + } + ) + ); + // Get the moderator session ID (for checking if active) ipcMain.handle( 'groupChat:getModeratorSessionId', @@ -872,5 +963,62 @@ Respond with ONLY the summary text, no additional commentary.`; } }; + /** + * Emit an Auto Run trigger event to the renderer. + * Called when the moderator issues !autorun @AgentName so the renderer can + * start a proper batch run through useBatchProcessor for full UI feedback. + */ + groupChatEmitters.emitAutoRunTriggered = ( + groupChatId: string, + participantName: string, + filename?: string + ): void => { + const mainWindow = getMainWindow(); + if (isWebContentsAvailable(mainWindow)) { + mainWindow.webContents.send( + 'groupChat:autoRunTriggered', + groupChatId, + participantName, + filename + ); + } + }; + + /** + * Tell the renderer to force-complete the batch run for an autorun participant. + * Fired on both normal completion (reportAutoRunComplete) and on the timeout path, + * so the AUTO badge and progress bar are always cleaned up regardless of how the + * participant's involvement ends. + */ + groupChatEmitters.emitAutoRunBatchComplete = ( + groupChatId: string, + participantName: string + ): void => { + const mainWindow = getMainWindow(); + if (isWebContentsAvailable(mainWindow)) { + mainWindow.webContents.send('groupChat:autoRunBatchComplete', groupChatId, participantName); + } + }; + + /** + * Emit live output chunks from a participant to the renderer. + * Called as data streams in from participant processes. + */ + groupChatEmitters.emitParticipantLiveOutput = ( + groupChatId: string, + participantName: string, + chunk: string + ): void => { + const mainWindow = getMainWindow(); + if (isWebContentsAvailable(mainWindow)) { + mainWindow.webContents.send( + 'groupChat:participantLiveOutput', + groupChatId, + participantName, + chunk + ); + } + }; + logger.info('Registered Group Chat IPC handlers', LOG_CONTEXT); } diff --git a/src/main/preload/groupChat.ts b/src/main/preload/groupChat.ts index 692883c45b..9dfd8c791d 100644 --- a/src/main/preload/groupChat.ts +++ b/src/main/preload/groupChat.ts @@ -108,6 +108,11 @@ export function createGroupChatApi() { stopModerator: (id: string) => ipcRenderer.invoke('groupChat:stopModerator', id), + stopAll: (id: string) => ipcRenderer.invoke('groupChat:stopAll', id), + + reportAutoRunComplete: (groupChatId: string, participantName: string, summary: string) => + ipcRenderer.invoke('groupChat:reportAutoRunComplete', groupChatId, participantName, summary), + getModeratorSessionId: (id: string) => ipcRenderer.invoke('groupChat:getModeratorSessionId', id), @@ -204,6 +209,31 @@ export function createGroupChatApi() { return () => ipcRenderer.removeListener('groupChat:participantState', handler); }, + onParticipantLiveOutput: ( + callback: (groupChatId: string, participantName: string, chunk: string) => void + ) => { + const handler = (_: any, groupChatId: string, participantName: string, chunk: string) => + callback(groupChatId, participantName, chunk); + ipcRenderer.on('groupChat:participantLiveOutput', handler); + return () => ipcRenderer.removeListener('groupChat:participantLiveOutput', handler); + }, + + onAutoRunTriggered: ( + callback: (groupChatId: string, participantName: string, filename?: string) => void + ) => { + const handler = (_: any, groupChatId: string, participantName: string, filename?: string) => + callback(groupChatId, participantName, filename); + ipcRenderer.on('groupChat:autoRunTriggered', handler); + return () => ipcRenderer.removeListener('groupChat:autoRunTriggered', handler); + }, + + onAutoRunBatchComplete: (callback: (groupChatId: string, participantName: string) => void) => { + const handler = (_: any, groupChatId: string, participantName: string) => + callback(groupChatId, participantName); + ipcRenderer.on('groupChat:autoRunBatchComplete', handler); + return () => ipcRenderer.removeListener('groupChat:autoRunBatchComplete', handler); + }, + onModeratorSessionIdChanged: (callback: (groupChatId: string, sessionId: string) => void) => { const handler = (_: any, groupChatId: string, sessionId: string) => callback(groupChatId, sessionId); diff --git a/src/main/process-listeners/data-listener.ts b/src/main/process-listeners/data-listener.ts index b958029467..32ff39bb2d 100644 --- a/src/main/process-listeners/data-listener.ts +++ b/src/main/process-listeners/data-listener.ts @@ -5,6 +5,7 @@ import type { ProcessManager } from '../process-manager'; import { GROUP_CHAT_PREFIX, type ProcessListenerDependencies } from './types'; +import { groupChatEmitters } from '../ipc/handlers/groupChat'; /** * Maximum buffer size per session (10MB). @@ -41,6 +42,21 @@ export function setupDataListener( REGEX_SYNOPSIS_SESSION, } = patterns; + // Listen to raw stdout for live output streaming to group chat participant peek panels. + // The 'data' event for stream-json sessions only fires at turn completion (result ready), + // so we need raw-stdout to stream chunks in real time during agent work. + processManager.on('raw-stdout', (sessionId: string, chunk: string) => { + if (!sessionId.startsWith(GROUP_CHAT_PREFIX)) return; + const participantInfo = outputParser.parseParticipantSessionId(sessionId); + if (participantInfo) { + groupChatEmitters.emitParticipantLiveOutput?.( + participantInfo.groupChatId, + participantInfo.participantName, + chunk + ); + } + }); + processManager.on('data', (sessionId: string, data: string) => { // Fast path: skip regex for non-group-chat sessions (performance optimization) // Most sessions don't start with 'group-chat-', so this avoids expensive regex matching @@ -91,6 +107,7 @@ export function setupDataListener( `WARNING: Buffer size ${totalLength} exceeds ${MAX_BUFFER_SIZE} bytes for participant ${participantInfo.participantName}` ); } + // Note: live output is streamed via raw-stdout listener above (fires per chunk during work). return; // Don't send to regular process:data handler } diff --git a/src/main/process-listeners/exit-listener.ts b/src/main/process-listeners/exit-listener.ts index b06b5a5963..a96d1a1500 100644 --- a/src/main/process-listeners/exit-listener.ts +++ b/src/main/process-listeners/exit-listener.ts @@ -5,6 +5,7 @@ */ import type { ProcessManager } from '../process-manager'; +import { captureException } from '../utils/sentry'; import { GROUP_CHAT_PREFIX, type ProcessListenerDependencies } from './types'; /** @@ -243,6 +244,17 @@ export function setupExitListener( error: String(err), groupChatId, }); + // Reset to idle so user is not stuck waiting indefinitely + groupChatEmitters.emitStateChange?.(groupChatId, 'idle'); + groupChatEmitters.emitMessage?.(groupChatId, { + timestamp: new Date().toISOString(), + from: 'system', + content: `⚠️ Synthesis failed. You can send another message to continue.`, + }); + captureException(err, { + operation: 'groupChat:spawnModeratorSynthesis', + groupChatId, + }); }); } else if (!isLastParticipant) { // More participants pending diff --git a/src/main/process-manager/spawners/ChildProcessSpawner.ts b/src/main/process-manager/spawners/ChildProcessSpawner.ts index b66f9c7531..ddded18792 100644 --- a/src/main/process-manager/spawners/ChildProcessSpawner.ts +++ b/src/main/process-manager/spawners/ChildProcessSpawner.ts @@ -429,6 +429,16 @@ export class ChildProcessSpawner { }); childProcess.stdout.on('data', (data: Buffer | string) => { const output = data.toString(); + // Emit raw stdout before processing for live-streaming consumers (e.g., group chat peek). + // Wrapped in try/catch so a failing listener cannot prevent stdoutHandler from running. + try { + this.emitter.emit('raw-stdout', sessionId, output); + } catch (err) { + logger.error('[ProcessManager] raw-stdout listener error', 'ProcessManager', { + sessionId, + error: String(err), + }); + } this.stdoutHandler.handleData(sessionId, output); }); } else { diff --git a/src/prompts/group-chat-moderator-system.md b/src/prompts/group-chat-moderator-system.md index 9e6f3da2d8..1ae3b4c9c1 100644 --- a/src/prompts/group-chat-moderator-system.md +++ b/src/prompts/group-chat-moderator-system.md @@ -29,3 +29,23 @@ Your role is to: - If you need multiple rounds of work, keep @mentioning agents until the task is complete - Only return to the user when you have a complete, actionable answer - When you're done and ready to hand back to the user, provide a summary WITHOUT any @mentions + +## Auto Run Execution: + +- Use `!autorun @AgentName:filename.md` to trigger execution of a **specific** Auto Run document the agent just created or updated +- Use `!autorun @AgentName` (without filename) only when you want to run ALL documents in the agent's Auto Run folder +- **Always prefer the specific filename form** after an agent confirms creating or updating a document — this guarantees the right file is executed +- Multiple agents can be triggered in parallel: + !autorun @Agent1:frontend-plan.md + !autorun @Agent2:backend-plan.md +- Use this AFTER agents have confirmed their implementation plans as Auto Run documents +- Do NOT combine !autorun with a regular @mention for the same agent in the same message +- **Important**: Ask the agent to confirm the exact filename of the document it created before issuing !autorun + +## Commit & Switch Branch: + +- When the user sends `!commit`, instruct ALL participating agents to: + 1. Commit all staged and unstaged changes on their current branch with a descriptive commit message +- @mention each agent with clear, specific instructions +- After all agents respond, provide a summary with each agent's branch name and commit status +- If an agent reports conflicts or errors, relay them clearly to the user diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 667edde5c0..e2c29b9076 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -143,6 +143,7 @@ import { useModalActions, useModalStore } from './stores/modalStore'; import { GitStatusProvider } from './contexts/GitStatusContext'; import { InputProvider, useInputContext } from './contexts/InputContext'; import { useGroupChatStore } from './stores/groupChatStore'; +import { registerGroupChatAutoRun } from './utils/groupChatAutoRunRegistry'; import { useBatchStore } from './stores/batchStore'; // All session state is read directly from useSessionStore in MaestroConsoleInner. import { useSessionStore, selectActiveSession } from './stores/sessionStore'; @@ -808,6 +809,7 @@ function MaestroConsoleInner() { handleOpenModeratorSession, handleJumpToGroupChatMessage, handleGroupChatRightTabChange, + handleStopAll, handleSendGroupChatMessage, handleGroupChatDraftChange, handleRemoveGroupChatQueueItem, @@ -1219,6 +1221,99 @@ function MaestroConsoleInner() { handleClearAgentError, }); + // --- GROUP CHAT AUTO RUN BRIDGE --- + // When the moderator issues !autorun @AgentName, the main process emits + // groupChat:autoRunTriggered. Here we intercept that, find the session, + // and start a proper batch run via useBatchProcessor for full UI feedback. + const startBatchRunRef = useRef(startBatchRun); + startBatchRunRef.current = startBatchRun; + + useEffect(() => { + const unsub = window.maestro.groupChat.onAutoRunTriggered?.( + (groupChatId, participantName, targetFilename) => { + // Helper: report failure back to the group chat as a system message so the + // moderator and user can see what went wrong and take corrective action. + const reportFailure = (reason: string) => { + console.warn(`[GroupChat:AutoRun] ${reason}`); + window.maestro.groupChat + .reportAutoRunComplete( + groupChatId, + participantName, + `⚠️ Auto Run could not start for @${participantName}: ${reason}` + ) + .catch((e) => + console.error('[GroupChat:AutoRun] Failed to report failure to moderator:', e) + ); + }; + + const sessions = useSessionStore.getState().sessions; + const session = sessions.find((s) => s.name === participantName); + if (!session) { + reportFailure( + `No Maestro agent named "${participantName}" found. Make sure the agent exists and is open.` + ); + return; + } + if (!session.autoRunFolderPath) { + reportFailure( + `Agent "${participantName}" has no Auto Run folder configured. Open the agent, go to the Auto Run tab, and configure a folder first.` + ); + return; + } + + // Fetch the document list, then start the batch run + window.maestro.autorun + .listDocs(session.autoRunFolderPath, session.sshRemoteId || undefined) + .then((result) => { + const allFiles = result.files || []; + if (allFiles.length === 0) { + reportFailure( + `No Auto Run documents found in "${session.autoRunFolderPath}". Create a document in the Auto Run tab first.` + ); + return; + } + + // If a specific filename was given (e.g. !autorun @Agent:plan.md), run only that doc. + // Otherwise run all docs in the folder. + let files: string[]; + if (targetFilename) { + if (allFiles.includes(targetFilename)) { + files = [targetFilename]; + } else { + // Specified file not found — report failure so the moderator can react + reportFailure( + `Specified file "${targetFilename}" not found in "${session.autoRunFolderPath}" for "${participantName}". Available files: ${allFiles.join(', ')}` + ); + return; + } + } else { + files = allFiles; + } + + const documents = files.map((filename, i) => ({ + id: `${session.id}-${i}`, + filename, + resetOnCompletion: false, + isDuplicate: false, + })); + const config = { + documents, + prompt: '', + loopEnabled: false, + maxLoops: null, + }; + // Register AFTER validating docs exist so no stale entry on failure + registerGroupChatAutoRun(session.id, groupChatId, participantName); + startBatchRunRef.current(session.id, config, session.autoRunFolderPath!); + }) + .catch((err) => { + reportFailure(`Failed to read Auto Run folder: ${String(err)}`); + }); + } + ); + return () => unsub?.(); + }, []); // Stable — reads sessions and startBatchRun from refs/store at call time + // --- AGENT IPC LISTENERS --- // Extracted hook for all window.maestro.process.onXxx listeners // (onData, onExit, onSessionId, onSlashCommands, onStderr, onCommandExit, @@ -3039,6 +3134,7 @@ function MaestroConsoleInner() { return anyParticipantMissingCost || moderatorMissingCost; })()} onSendMessage={handleSendGroupChatMessage} + onStopAll={handleStopAll} onRename={() => activeGroupChatId && handleOpenRenameGroupChatModal(activeGroupChatId) } diff --git a/src/renderer/components/GroupChatHeader.tsx b/src/renderer/components/GroupChatHeader.tsx index 637a899b5c..436df04982 100644 --- a/src/renderer/components/GroupChatHeader.tsx +++ b/src/renderer/components/GroupChatHeader.tsx @@ -5,8 +5,8 @@ * and provides actions for rename and info. */ -import { Info, Edit2, Columns, DollarSign } from 'lucide-react'; -import type { Theme, Shortcut } from '../types'; +import { Info, Edit2, Columns, DollarSign, StopCircle } from 'lucide-react'; +import type { Theme, Shortcut, GroupChatState } from '../types'; import { formatShortcutKeys } from '../utils/shortcutFormatter'; interface GroupChatHeaderProps { @@ -17,6 +17,8 @@ interface GroupChatHeaderProps { totalCost?: number; /** True if one or more participants don't have cost data (makes total incomplete) */ costIncomplete?: boolean; + state: GroupChatState; + onStopAll: () => void; onRename: () => void; onShowInfo: () => void; rightPanelOpen: boolean; @@ -30,6 +32,8 @@ export function GroupChatHeader({ participantCount, totalCost, costIncomplete, + state, + onStopAll, onRename, onShowInfo, rightPanelOpen, @@ -72,6 +76,22 @@ export function GroupChatHeader({
+ {/* Stop All button - only shown when active */} + {state !== 'idle' && ( + + )} void; + onStopAll: () => void; onRename: () => void; onShowInfo: () => void; rightPanelOpen: boolean; @@ -80,6 +81,7 @@ export function GroupChatPanel({ totalCost, costIncomplete, onSendMessage, + onStopAll, onRename, onShowInfo, rightPanelOpen, @@ -117,6 +119,8 @@ export function GroupChatPanel({ participantCount={groupChat.participants.length} totalCost={totalCost} costIncomplete={costIncomplete} + state={state} + onStopAll={onStopAll} onRename={onRename} onShowInfo={onShowInfo} rightPanelOpen={rightPanelOpen} diff --git a/src/renderer/components/GroupChatParticipants.tsx b/src/renderer/components/GroupChatParticipants.tsx index 362db0fda8..27e9d12610 100644 --- a/src/renderer/components/GroupChatParticipants.tsx +++ b/src/renderer/components/GroupChatParticipants.tsx @@ -13,6 +13,7 @@ import { ParticipantCard } from './ParticipantCard'; import { formatShortcutKeys } from '../utils/shortcutFormatter'; import { buildParticipantColorMap } from '../utils/participantColors'; import { useResizablePanel } from '../hooks'; +import { useGroupChatStore } from '../stores/groupChatStore'; interface GroupChatParticipantsProps { theme: Theme; @@ -62,6 +63,8 @@ export function GroupChatParticipants({ side: 'right', }); + const participantLiveOutput = useGroupChatStore((s) => s.participantLiveOutput); + // Generate consistent colors for all participants (including "Moderator" for the moderator card) const participantColors = useMemo(() => { return buildParticipantColorMap(['Moderator', ...participants.map((p) => p.name)], theme); @@ -164,10 +167,11 @@ export function GroupChatParticipants({ key={participant.sessionId} theme={theme} participant={participant} - state={participantStates.get(participant.sessionId) || 'idle'} + state={participantStates.get(participant.name) || 'idle'} color={participantColors[participant.name]} groupChatId={groupChatId} onContextReset={handleContextReset} + liveOutput={participantLiveOutput.get(participant.name)} /> )) )} diff --git a/src/renderer/components/GroupChatRightPanel.tsx b/src/renderer/components/GroupChatRightPanel.tsx index b8a2ee41c3..cbdf0b8406 100644 --- a/src/renderer/components/GroupChatRightPanel.tsx +++ b/src/renderer/components/GroupChatRightPanel.tsx @@ -20,6 +20,7 @@ import { type ParticipantColorInfo, } from '../utils/participantColors'; import { useResizablePanel } from '../hooks'; +import { useGroupChatStore } from '../stores/groupChatStore'; export type GroupChatRightTab = 'participants' | 'history'; @@ -80,6 +81,8 @@ export function GroupChatRightPanel({ onJumpToMessage, onColorsComputed, }: GroupChatRightPanelProps): JSX.Element | null { + const participantLiveOutput = useGroupChatStore((s) => s.participantLiveOutput); + // Color preferences state const [colorPreferences, setColorPreferences] = useState>({}); const { panelRef, onResizeStart, transitionClass } = useResizablePanel({ @@ -322,6 +325,7 @@ export function GroupChatRightPanel({ color={participantColors[participant.name]} groupChatId={groupChatId} onContextReset={handleContextReset} + liveOutput={participantLiveOutput.get(`${groupChatId}:${participant.name}`)} /> ); }) diff --git a/src/renderer/components/ParticipantCard.tsx b/src/renderer/components/ParticipantCard.tsx index 2aec2b916c..c31e4f2de1 100644 --- a/src/renderer/components/ParticipantCard.tsx +++ b/src/renderer/components/ParticipantCard.tsx @@ -5,8 +5,17 @@ * session ID, context usage, stats, and cost. */ -import { MessageSquare, Copy, Check, DollarSign, RotateCcw, Server } from 'lucide-react'; -import { useState, useCallback } from 'react'; +import { + MessageSquare, + Copy, + Check, + DollarSign, + RotateCcw, + Server, + Eye, + EyeOff, +} from 'lucide-react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import type { Theme, GroupChatParticipant, SessionState } from '../types'; import { getStatusColor } from '../utils/theme'; import { formatCost } from '../utils/formatters'; @@ -19,6 +28,7 @@ interface ParticipantCardProps { color?: string; groupChatId?: string; onContextReset?: (participantName: string) => void; + liveOutput?: string; } /** @@ -39,9 +49,19 @@ export function ParticipantCard({ color, groupChatId, onContextReset, + liveOutput, }: ParticipantCardProps): JSX.Element { const [copied, setCopied] = useState(false); const [isResetting, setIsResetting] = useState(false); + const [peekOpen, setPeekOpen] = useState(false); + const peekRef = useRef(null); + + // Auto-scroll peek output to bottom + useEffect(() => { + if (peekOpen && peekRef.current) { + peekRef.current.scrollTop = peekRef.current.scrollHeight; + } + }, [peekOpen, liveOutput]); // Use agent's session ID (clean GUID) when available, otherwise show pending const agentSessionId = participant.agentSessionId; @@ -236,7 +256,41 @@ export function ParticipantCard({ Resetting... )} + {/* Peek button - always visible */} +
+ + {/* Live output peek panel */} + {peekOpen && ( +
+					{liveOutput
+						? liveOutput.length > 4096
+							? liveOutput.slice(-4096)
+							: liveOutput
+						: '(no live output yet)'}
+				
+ )} ); } diff --git a/src/renderer/components/Settings/SettingsModal.tsx b/src/renderer/components/Settings/SettingsModal.tsx index a1d665dc0a..2aaa2a9b50 100644 --- a/src/renderer/components/Settings/SettingsModal.tsx +++ b/src/renderer/components/Settings/SettingsModal.tsx @@ -10,6 +10,7 @@ import { FlaskConical, Server, Monitor, + Users, } from 'lucide-react'; import { useSettings } from '../../hooks'; import type { Theme, LLMProvider } from '../../types'; @@ -45,6 +46,7 @@ interface SettingsModalProps { | 'theme' | 'notifications' | 'aicommands' + | 'groupchat' | 'ssh' | 'encore'; hasNoAgents?: boolean; @@ -92,6 +94,9 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro setSshRemoteIgnorePatterns, sshRemoteHonorGitignore, setSshRemoteHonorGitignore, + // Group Chat settings + moderatorStandingInstructions, + setModeratorStandingInstructions, } = useSettings(); const [activeTab, setActiveTab] = useState< @@ -102,6 +107,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro | 'theme' | 'notifications' | 'aicommands' + | 'groupchat' | 'ssh' | 'encore' >('general'); @@ -166,6 +172,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro | 'theme' | 'notifications' | 'aicommands' + | 'groupchat' | 'ssh' | 'encore' > = FEATURE_FLAGS.LLM_SETTINGS @@ -177,6 +184,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro 'theme', 'notifications', 'aicommands', + 'groupchat', 'ssh', 'encore', ] @@ -187,6 +195,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro 'theme', 'notifications', 'aicommands', + 'groupchat', 'ssh', 'encore', ]; @@ -391,6 +400,14 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro {activeTab === 'aicommands' && AI Commands} +