diff --git a/package-lock.json b/package-lock.json index 62cc894d0..7f6c48fb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -263,7 +263,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", @@ -667,7 +666,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -711,7 +709,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2285,7 +2282,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" } @@ -2307,7 +2303,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" }, @@ -2320,7 +2315,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" }, @@ -2336,7 +2330,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", @@ -2724,7 +2717,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" @@ -2741,7 +2733,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", @@ -2759,7 +2750,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" } @@ -3818,7 +3808,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", @@ -4356,7 +4347,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" @@ -4368,7 +4358,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4494,7 +4483,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", @@ -4925,7 +4913,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" }, @@ -5007,7 +4994,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", @@ -6011,7 +5997,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -6494,7 +6479,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", @@ -7220,7 +7204,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" } @@ -7630,7 +7613,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" } @@ -8128,7 +8110,6 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -8224,7 +8205,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", @@ -8368,6 +8350,7 @@ "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -8381,6 +8364,7 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -8400,6 +8384,7 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -8422,6 +8407,7 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8438,6 +8424,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", @@ -8454,6 +8441,7 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -8468,6 +8456,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", @@ -8483,6 +8472,7 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -8495,7 +8485,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", @@ -8503,6 +8494,7 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -8513,6 +8505,7 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -8523,6 +8516,7 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -8538,6 +8532,7 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -9219,7 +9214,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", @@ -11123,7 +11117,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" @@ -11944,7 +11937,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -12414,14 +12406,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", @@ -12434,7 +12428,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", @@ -12448,7 +12443,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", @@ -12462,7 +12458,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", @@ -12553,6 +12550,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -15050,7 +15048,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15291,6 +15288,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -15306,6 +15304,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -15650,7 +15649,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" }, @@ -15680,7 +15678,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" @@ -15728,7 +15725,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" @@ -15915,8 +15911,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", @@ -17673,7 +17668,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17984,7 +17978,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18358,7 +18351,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -18864,7 +18856,6 @@ "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", @@ -19455,7 +19446,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -19469,7 +19459,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", @@ -20067,7 +20056,6 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/__tests__/cli/services/account-reader.test.ts b/src/__tests__/cli/services/account-reader.test.ts new file mode 100644 index 000000000..5bf6b6dac --- /dev/null +++ b/src/__tests__/cli/services/account-reader.test.ts @@ -0,0 +1,251 @@ +/** + * @file account-reader.test.ts + * @description Tests for the CLI account reader service + * + * Tests reading account data from the electron-store JSON file, + * filesystem discovery fallback, and account lookup helpers. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import type { AccountProfile } from '../../../shared/account-types'; + +// Mock the fs module +vi.mock('fs', () => ({ + readFileSync: vi.fn(), + promises: { + readdir: vi.fn(), + readFile: vi.fn(), + stat: vi.fn(), + }, +})); + +// Mock the os module +vi.mock('os', () => ({ + platform: vi.fn(), + homedir: vi.fn(), +})); + +import { + readAccountsFromStore, + getDefaultAccount, + getAccountByIdOrName, +} from '../../../cli/services/account-reader'; + +// Helper to build a mock AccountProfile +function mockProfile(overrides: Partial = {}): AccountProfile { + return { + id: 'acc-1', + name: 'personal', + email: 'user@example.com', + configDir: '/home/testuser/.claude-personal', + agentType: 'claude-code', + status: 'active', + authMethod: 'oauth', + addedAt: 1000, + lastUsedAt: 2000, + lastThrottledAt: 0, + tokenLimitPerWindow: 0, + tokenWindowMs: 18000000, + isDefault: true, + autoSwitchEnabled: false, + ...overrides, + }; +} + +describe('account-reader', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(os.homedir).mockReturnValue('/home/testuser'); + }); + + describe('readAccountsFromStore', () => { + it('reads accounts from the store JSON file', async () => { + const profile1 = mockProfile({ id: 'acc-1', name: 'personal', isDefault: true }); + const profile2 = mockProfile({ + id: 'acc-2', + name: 'work', + email: 'work@corp.com', + configDir: '/home/testuser/.claude-work', + isDefault: false, + }); + + const storeData = { + accounts: { + 'acc-1': profile1, + 'acc-2': profile2, + }, + assignments: {}, + switchConfig: {}, + rotationOrder: [], + rotationIndex: 0, + }; + + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData)); + + const accounts = await readAccountsFromStore(); + + expect(accounts).toHaveLength(2); + expect(accounts.find((a) => a.id === 'acc-1')).toMatchObject({ + id: 'acc-1', + name: 'personal', + email: 'user@example.com', + isDefault: true, + status: 'active', + }); + expect(accounts.find((a) => a.id === 'acc-2')).toMatchObject({ + id: 'acc-2', + name: 'work', + email: 'work@corp.com', + isDefault: false, + }); + }); + + it('returns empty array when store has no accounts', async () => { + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ accounts: {}, assignments: {} }) + ); + + const accounts = await readAccountsFromStore(); + expect(accounts).toHaveLength(0); + }); + + it('falls back to filesystem discovery when store file missing', async () => { + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error('ENOENT'); + }); + + vi.mocked(fs.promises.readdir).mockResolvedValue([ + { name: '.claude-personal', isDirectory: () => true } as unknown as fs.Dirent, + { name: '.bashrc', isDirectory: () => false } as unknown as fs.Dirent, + { name: 'Documents', isDirectory: () => true } as unknown as fs.Dirent, + ]); + + vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('ENOENT')); + + const accounts = await readAccountsFromStore(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + id: 'personal', + name: 'personal', + configDir: '/home/testuser/.claude-personal', + status: 'active', + }); + }); + + it('reads email from .claude.json during filesystem discovery', async () => { + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error('ENOENT'); + }); + + vi.mocked(fs.promises.readdir).mockResolvedValue([ + { name: '.claude-work', isDirectory: () => true } as unknown as fs.Dirent, + ]); + + vi.mocked(fs.promises.readFile).mockResolvedValue( + JSON.stringify({ email: 'dev@company.com' }) + ); + + const accounts = await readAccountsFromStore(); + expect(accounts[0].email).toBe('dev@company.com'); + }); + + it('handles macOS store path', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + + const storeData = { + accounts: { 'acc-1': mockProfile() }, + assignments: {}, + }; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData)); + + const accounts = await readAccountsFromStore(); + expect(accounts).toHaveLength(1); + + // Should try macOS path first + expect(fs.readFileSync).toHaveBeenCalledWith( + '/home/testuser/Library/Application Support/Maestro/maestro-accounts.json', + 'utf-8' + ); + }); + }); + + describe('getDefaultAccount', () => { + it('returns the default active account', async () => { + const storeData = { + accounts: { + 'acc-1': mockProfile({ id: 'acc-1', isDefault: false }), + 'acc-2': mockProfile({ id: 'acc-2', isDefault: true }), + }, + }; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData)); + + const account = await getDefaultAccount(); + expect(account?.id).toBe('acc-2'); + }); + + it('returns first active account when no default set', async () => { + const storeData = { + accounts: { + 'acc-1': mockProfile({ id: 'acc-1', isDefault: false }), + }, + }; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData)); + + const account = await getDefaultAccount(); + expect(account?.id).toBe('acc-1'); + }); + + it('skips throttled accounts when looking for default', async () => { + const storeData = { + accounts: { + 'acc-1': mockProfile({ id: 'acc-1', isDefault: true, status: 'throttled' }), + 'acc-2': mockProfile({ id: 'acc-2', isDefault: false, status: 'active' }), + }, + }; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData)); + + const account = await getDefaultAccount(); + expect(account?.id).toBe('acc-2'); + }); + + it('returns null when no accounts exist', async () => { + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ accounts: {} })); + + const account = await getDefaultAccount(); + expect(account).toBeNull(); + }); + }); + + describe('getAccountByIdOrName', () => { + const storeData = { + accounts: { + 'acc-1': mockProfile({ id: 'acc-1', name: 'personal' }), + 'acc-2': mockProfile({ id: 'acc-2', name: 'work' }), + }, + }; + + it('finds by ID', async () => { + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData)); + + const account = await getAccountByIdOrName('acc-2'); + expect(account?.name).toBe('work'); + }); + + it('finds by name', async () => { + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData)); + + const account = await getAccountByIdOrName('personal'); + expect(account?.id).toBe('acc-1'); + }); + + it('returns null when not found', async () => { + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData)); + + const account = await getAccountByIdOrName('nonexistent'); + expect(account).toBeNull(); + }); + }); +}); diff --git a/src/__tests__/main/accounts/account-auth-recovery.test.ts b/src/__tests__/main/accounts/account-auth-recovery.test.ts new file mode 100644 index 000000000..97a2854c9 --- /dev/null +++ b/src/__tests__/main/accounts/account-auth-recovery.test.ts @@ -0,0 +1,495 @@ +/** + * Tests for AccountAuthRecovery. + * Validates auth recovery flow: process killing, claude login spawning, + * timeout handling, credential sync fallback, and respawn orchestration. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { AccountProfile } from '../../../shared/account-types'; + +// Hoist mock functions for use in vi.mock factories +const { + mockSpawn, + mockAccess, + mockSyncCredentialsFromBase, +} = vi.hoisted(() => ({ + mockSpawn: vi.fn(), + mockAccess: vi.fn(), + mockSyncCredentialsFromBase: vi.fn(), +})); + +// Mock child_process.spawn +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { ...actual, spawn: mockSpawn }, + spawn: mockSpawn, + }; +}); + +// Mock fs/promises +vi.mock('fs/promises', () => ({ + default: { access: mockAccess }, + access: mockAccess, +})); + +// Mock logger +vi.mock('../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock account-setup (syncCredentialsFromBase) +vi.mock('../../../main/accounts/account-setup', () => ({ + syncCredentialsFromBase: mockSyncCredentialsFromBase, +})); + +import { AccountAuthRecovery } from '../../../main/accounts/account-auth-recovery'; +import type { ProcessManager } from '../../../main/process-manager/ProcessManager'; +import type { AccountRegistry } from '../../../main/accounts/account-registry'; +import type { AgentDetector } from '../../../main/agents'; +import type { SafeSendFn } from '../../../main/utils/safe-send'; +import { EventEmitter } from 'events'; + +function createMockAccount(overrides: Partial = {}): AccountProfile { + return { + id: 'acct-1', + name: 'Test Account', + email: 'test@example.com', + configDir: '/home/test/.claude-test', + agentType: 'claude-code', + status: 'active', + authMethod: 'oauth', + addedAt: Date.now(), + lastUsedAt: Date.now(), + lastThrottledAt: 0, + tokenLimitPerWindow: 0, + tokenWindowMs: 5 * 60 * 60 * 1000, + isDefault: true, + autoSwitchEnabled: true, + ...overrides, + }; +} + +/** + * Creates a mock child process (EventEmitter) with stdout/stderr streams. + * Returns the child and a helper to simulate exit. + */ +function createMockChildProcess() { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: ReturnType; + pid: number; + }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = vi.fn(); + child.pid = 12345; + return child; +} + +describe('AccountAuthRecovery', () => { + let recovery: AccountAuthRecovery; + let mockProcessManager: { + kill: ReturnType; + }; + let mockAccountRegistry: { + get: ReturnType; + setStatus: ReturnType; + }; + let mockAgentDetector: { + getAgent: ReturnType; + }; + let mockSafeSend: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + mockProcessManager = { + kill: vi.fn().mockReturnValue(true), + }; + + mockAccountRegistry = { + get: vi.fn().mockReturnValue(createMockAccount()), + setStatus: vi.fn(), + }; + + mockAgentDetector = { + getAgent: vi.fn().mockResolvedValue({ path: '/usr/bin/claude', command: 'claude' }), + }; + + mockSafeSend = vi.fn(); + + recovery = new AccountAuthRecovery( + mockProcessManager as unknown as ProcessManager, + mockAccountRegistry as unknown as AccountRegistry, + mockAgentDetector as unknown as AgentDetector, + mockSafeSend as SafeSendFn, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('recoverAuth', () => { + it('should kill the current agent process before starting recovery', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + mockAccess.mockResolvedValue(undefined); + + const promise = recovery.recoverAuth('session-1', 'acct-1'); + + // Advance past KILL_DELAY_MS + await vi.advanceTimersByTimeAsync(1000); + + // Simulate successful login + mockChild.emit('close', 0); + await promise; + + expect(mockProcessManager.kill).toHaveBeenCalledWith('session-1'); + }); + + it('should spawn claude login with correct CLAUDE_CONFIG_DIR', async () => { + const account = createMockAccount({ configDir: '/home/test/.claude-work' }); + mockAccountRegistry.get.mockReturnValue(account); + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + mockAccess.mockResolvedValue(undefined); + + const promise = recovery.recoverAuth('session-1', 'acct-1'); + await vi.advanceTimersByTimeAsync(1000); + + mockChild.emit('close', 0); + await promise; + + expect(mockSpawn).toHaveBeenCalledWith( + '/usr/bin/claude', + ['login'], + expect.objectContaining({ + env: expect.objectContaining({ + CLAUDE_CONFIG_DIR: '/home/test/.claude-work', + }), + }), + ); + }); + + it('should emit auth-recovery-started event', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + mockAccess.mockResolvedValue(undefined); + + const promise = recovery.recoverAuth('session-1', 'acct-1'); + await vi.advanceTimersByTimeAsync(1000); + + mockChild.emit('close', 0); + await promise; + + expect(mockSafeSend).toHaveBeenCalledWith('account:auth-recovery-started', { + sessionId: 'session-1', + accountId: 'acct-1', + accountName: 'Test Account', + }); + }); + + it('should emit auth-recovery-completed on successful login', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + mockAccess.mockResolvedValue(undefined); + + const promise = recovery.recoverAuth('session-1', 'acct-1'); + await vi.advanceTimersByTimeAsync(1000); + + // Login exits successfully and credentials exist + mockChild.emit('close', 0); + + const result = await promise; + + expect(result).toBe(true); + expect(mockSafeSend).toHaveBeenCalledWith('account:auth-recovery-completed', { + sessionId: 'session-1', + accountId: 'acct-1', + accountName: 'Test Account', + }); + }); + + it('should emit auth-recovery-failed on login timeout', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + + const promise = recovery.recoverAuth('session-1', 'acct-1'); + + // Advance past KILL_DELAY_MS + await vi.advanceTimersByTimeAsync(1000); + + // Advance past LOGIN_TIMEOUT_MS (120s) + await vi.advanceTimersByTimeAsync(120_000); + + // Sync fallback also fails + mockSyncCredentialsFromBase.mockResolvedValue({ success: false, error: 'No credentials' }); + + await promise; + + expect(mockChild.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('should fall back to credential sync when login fails', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + mockSyncCredentialsFromBase.mockResolvedValue({ success: true }); + mockAccess.mockResolvedValue(undefined); // credentials exist after sync + + const promise = recovery.recoverAuth('session-1', 'acct-1'); + await vi.advanceTimersByTimeAsync(1000); + + // Login exits with failure code + mockChild.emit('close', 1); + + const result = await promise; + + expect(mockSyncCredentialsFromBase).toHaveBeenCalledWith('/home/test/.claude-test'); + expect(result).toBe(true); + }); + + it('should emit respawn event after successful recovery', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + mockAccess.mockResolvedValue(undefined); + + // Record a prompt before recovery + recovery.recordLastPrompt('session-1', 'Tell me about TypeScript'); + + const promise = recovery.recoverAuth('session-1', 'acct-1'); + await vi.advanceTimersByTimeAsync(1000); + + mockChild.emit('close', 0); + await promise; + + expect(mockSafeSend).toHaveBeenCalledWith('account:switch-respawn', { + sessionId: 'session-1', + toAccountId: 'acct-1', + toAccountName: 'Test Account', + configDir: '/home/test/.claude-test', + lastPrompt: 'Tell me about TypeScript', + reason: 'auth-recovery', + }); + }); + + it('should update account status to active after successful recovery', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + mockAccess.mockResolvedValue(undefined); + + const promise = recovery.recoverAuth('session-1', 'acct-1'); + await vi.advanceTimersByTimeAsync(1000); + + mockChild.emit('close', 0); + await promise; + + // First call sets status to 'expired', second call to 'active' + expect(mockAccountRegistry.setStatus).toHaveBeenCalledWith('acct-1', 'expired'); + expect(mockAccountRegistry.setStatus).toHaveBeenCalledWith('acct-1', 'active'); + }); + + it('should not start concurrent recoveries for same session', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + mockAccess.mockResolvedValue(undefined); + + const promise1 = recovery.recoverAuth('session-1', 'acct-1'); + const result2 = await recovery.recoverAuth('session-1', 'acct-1'); + + expect(result2).toBe(false); + + // Only one spawn should have occurred + // Advance and resolve the first + await vi.advanceTimersByTimeAsync(1000); + mockChild.emit('close', 0); + await promise1; + + // Spawn called only once (for the first call) + expect(mockSpawn).toHaveBeenCalledTimes(1); + }); + + it('should handle missing account gracefully', async () => { + mockAccountRegistry.get.mockReturnValue(null); + + const result = await recovery.recoverAuth('session-1', 'acct-missing'); + + expect(result).toBe(false); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('should mark account as expired at start of recovery', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + mockAccess.mockResolvedValue(undefined); + + const promise = recovery.recoverAuth('session-1', 'acct-1'); + + // Should be called before waiting for login + expect(mockAccountRegistry.setStatus).toHaveBeenCalledWith('acct-1', 'expired'); + + await vi.advanceTimersByTimeAsync(1000); + mockChild.emit('close', 0); + await promise; + }); + + it('should handle spawn errors gracefully', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + mockSyncCredentialsFromBase.mockResolvedValue({ success: false, error: 'No creds' }); + + const promise = recovery.recoverAuth('session-1', 'acct-1'); + await vi.advanceTimersByTimeAsync(1000); + + // Emit spawn error + mockChild.emit('error', new Error('ENOENT: claude not found')); + + const result = await promise; + + expect(result).toBe(false); + }); + + it('should send null lastPrompt when no prompt was recorded', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + mockAccess.mockResolvedValue(undefined); + + const promise = recovery.recoverAuth('session-1', 'acct-1'); + await vi.advanceTimersByTimeAsync(1000); + + mockChild.emit('close', 0); + await promise; + + expect(mockSafeSend).toHaveBeenCalledWith('account:switch-respawn', expect.objectContaining({ + lastPrompt: null, + })); + }); + + it('should emit auth-recovery-failed when both login and sync fail', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + mockSyncCredentialsFromBase.mockResolvedValue({ success: false, error: 'No credentials found' }); + + const promise = recovery.recoverAuth('session-1', 'acct-1'); + await vi.advanceTimersByTimeAsync(1000); + + // Login fails + mockChild.emit('close', 1); + + const result = await promise; + + expect(result).toBe(false); + expect(mockSafeSend).toHaveBeenCalledWith('account:auth-recovery-failed', expect.objectContaining({ + sessionId: 'session-1', + accountId: 'acct-1', + accountName: 'Test Account', + })); + }); + + it('should allow recovery for same session after previous recovery completes', async () => { + const mockChild1 = createMockChildProcess(); + const mockChild2 = createMockChildProcess(); + mockSpawn.mockReturnValueOnce(mockChild1).mockReturnValueOnce(mockChild2); + mockAccess.mockResolvedValue(undefined); + + // First recovery + const promise1 = recovery.recoverAuth('session-1', 'acct-1'); + await vi.advanceTimersByTimeAsync(1000); + mockChild1.emit('close', 0); + await promise1; + + // Second recovery (should work since first completed) + const promise2 = recovery.recoverAuth('session-1', 'acct-1'); + await vi.advanceTimersByTimeAsync(1000); + mockChild2.emit('close', 0); + const result2 = await promise2; + + expect(result2).toBe(true); + expect(mockSpawn).toHaveBeenCalledTimes(2); + }); + + it('should handle login exit code 0 but missing credentials', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + // credentials file does not exist + mockAccess.mockRejectedValue(new Error('ENOENT')); + mockSyncCredentialsFromBase.mockResolvedValue({ success: false, error: 'No creds' }); + + const promise = recovery.recoverAuth('session-1', 'acct-1'); + await vi.advanceTimersByTimeAsync(1000); + + mockChild.emit('close', 0); + + const result = await promise; + + // Should fall through to sync fallback and eventually fail + expect(result).toBe(false); + expect(mockSyncCredentialsFromBase).toHaveBeenCalled(); + }); + + it('should use fallback binary name when agent path is unavailable', async () => { + mockAgentDetector.getAgent.mockResolvedValue(null); + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + mockAccess.mockResolvedValue(undefined); + + const promise = recovery.recoverAuth('session-1', 'acct-1'); + await vi.advanceTimersByTimeAsync(1000); + + mockChild.emit('close', 0); + await promise; + + expect(mockSpawn).toHaveBeenCalledWith( + 'claude', + ['login'], + expect.any(Object), + ); + }); + }); + + describe('recordLastPrompt', () => { + it('should store prompt for later use in respawn events', () => { + recovery.recordLastPrompt('session-1', 'Hello world'); + // Verified indirectly through recoverAuth respawn event + expect(() => recovery.recordLastPrompt('session-1', 'Hello world')).not.toThrow(); + }); + }); + + describe('isRecovering', () => { + it('should return false when no recovery is in progress', () => { + expect(recovery.isRecovering('session-1')).toBe(false); + }); + + it('should return true when recovery is in progress', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + + recovery.recoverAuth('session-1', 'acct-1'); + + expect(recovery.isRecovering('session-1')).toBe(true); + + // Clean up + await vi.advanceTimersByTimeAsync(1000); + mockAccess.mockResolvedValue(undefined); + mockChild.emit('close', 0); + await vi.advanceTimersByTimeAsync(0); + }); + }); + + describe('cleanupSession', () => { + it('should remove tracked data for session', () => { + recovery.recordLastPrompt('session-1', 'Some prompt'); + recovery.cleanupSession('session-1'); + expect(recovery.isRecovering('session-1')).toBe(false); + }); + }); +}); diff --git a/src/__tests__/main/accounts/account-env-injector.test.ts b/src/__tests__/main/accounts/account-env-injector.test.ts new file mode 100644 index 000000000..aeb68e74c --- /dev/null +++ b/src/__tests__/main/accounts/account-env-injector.test.ts @@ -0,0 +1,222 @@ +/** + * Tests for injectAccountEnv. + * Validates account injection, statsDB passthrough to selectNextAccount, + * and fallback behavior when statsDB is unavailable. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { AccountProfile } from '../../../shared/account-types'; +import type { AccountRegistry, AccountUsageStatsProvider } from '../../../main/accounts/account-registry'; + +// Hoist mocks +const { mockExistsSync } = vi.hoisted(() => ({ + mockExistsSync: vi.fn(), +})); + +vi.mock('fs', () => ({ + existsSync: mockExistsSync, + default: { existsSync: mockExistsSync }, +})); + +vi.mock('../../../main/accounts/account-setup', () => ({ + syncCredentialsFromBase: vi.fn().mockResolvedValue({ success: true }), +})); + +vi.mock('../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +function createMockAccount(overrides: Partial = {}): AccountProfile { + return { + id: 'acct-1', + name: 'Test Account', + email: 'test@example.com', + configDir: '/home/test/.claude-test', + agentType: 'claude-code', + status: 'active', + authMethod: 'oauth', + addedAt: Date.now(), + lastUsedAt: 0, + lastThrottledAt: 0, + tokenLimitPerWindow: 0, + tokenWindowMs: 5 * 60 * 60 * 1000, + isDefault: false, + autoSwitchEnabled: true, + ...overrides, + }; +} + +describe('injectAccountEnv', () => { + let mockRegistry: { + getAll: ReturnType; + get: ReturnType; + getAssignment: ReturnType; + getDefaultAccount: ReturnType; + selectNextAccount: ReturnType; + assignToSession: ReturnType; + }; + let mockSafeSend: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockExistsSync.mockReturnValue(true); + + mockRegistry = { + getAll: vi.fn().mockReturnValue([createMockAccount()]), + get: vi.fn().mockReturnValue(createMockAccount()), + getAssignment: vi.fn().mockReturnValue(null), + getDefaultAccount: vi.fn().mockReturnValue(null), + selectNextAccount: vi.fn().mockReturnValue(createMockAccount()), + assignToSession: vi.fn().mockReturnValue({ sessionId: 'sess-1', accountId: 'acct-1', assignedAt: Date.now() }), + }; + + mockSafeSend = vi.fn(); + }); + + async function loadInjector() { + // Dynamic import to get fresh module after mocks are set up + const mod = await import('../../../main/accounts/account-env-injector'); + return mod.injectAccountEnv; + } + + it('should return null for non-claude-code agents', async () => { + const injectAccountEnv = await loadInjector(); + const env: Record = {}; + const result = injectAccountEnv( + 'sess-1', 'terminal', env, + mockRegistry as unknown as AccountRegistry, + ); + expect(result).toBeNull(); + }); + + it('should respect existing CLAUDE_CONFIG_DIR in env', async () => { + const injectAccountEnv = await loadInjector(); + const env: Record = { CLAUDE_CONFIG_DIR: '/custom/dir' }; + const result = injectAccountEnv( + 'sess-1', 'claude-code', env, + mockRegistry as unknown as AccountRegistry, + ); + expect(result).toBeNull(); + expect(env.CLAUDE_CONFIG_DIR).toBe('/custom/dir'); + }); + + it('should return null when no active accounts exist', async () => { + const injectAccountEnv = await loadInjector(); + mockRegistry.getAll.mockReturnValue([]); + const env: Record = {}; + const result = injectAccountEnv( + 'sess-1', 'claude-code', env, + mockRegistry as unknown as AccountRegistry, + ); + expect(result).toBeNull(); + }); + + it('should use provided accountId when specified', async () => { + const injectAccountEnv = await loadInjector(); + const env: Record = {}; + const result = injectAccountEnv( + 'sess-1', 'claude-code', env, + mockRegistry as unknown as AccountRegistry, + 'acct-1', + ); + expect(result).toBe('acct-1'); + expect(env.CLAUDE_CONFIG_DIR).toBe('/home/test/.claude-test'); + expect(mockRegistry.selectNextAccount).not.toHaveBeenCalled(); + }); + + it('should call selectNextAccount without statsDB when getStatsDB is not provided', async () => { + const injectAccountEnv = await loadInjector(); + mockRegistry.getDefaultAccount.mockReturnValue(null); + const env: Record = {}; + + const result = injectAccountEnv( + 'sess-1', 'claude-code', env, + mockRegistry as unknown as AccountRegistry, + undefined, + mockSafeSend, + ); + + expect(result).toBe('acct-1'); + expect(mockRegistry.selectNextAccount).toHaveBeenCalledWith([], undefined); + }); + + it('should pass statsDB to selectNextAccount when getStatsDB is provided', async () => { + const injectAccountEnv = await loadInjector(); + mockRegistry.getDefaultAccount.mockReturnValue(null); + const env: Record = {}; + + const mockStatsDB: AccountUsageStatsProvider = { + getAccountUsageInWindow: vi.fn(), + isReady: vi.fn().mockReturnValue(true), + }; + + const result = injectAccountEnv( + 'sess-1', 'claude-code', env, + mockRegistry as unknown as AccountRegistry, + undefined, + mockSafeSend, + () => mockStatsDB, + ); + + expect(result).toBe('acct-1'); + expect(mockRegistry.selectNextAccount).toHaveBeenCalledWith([], mockStatsDB); + }); + + it('should pass undefined to selectNextAccount when getStatsDB returns null', async () => { + const injectAccountEnv = await loadInjector(); + mockRegistry.getDefaultAccount.mockReturnValue(null); + const env: Record = {}; + + const result = injectAccountEnv( + 'sess-1', 'claude-code', env, + mockRegistry as unknown as AccountRegistry, + undefined, + mockSafeSend, + () => null, + ); + + expect(result).toBe('acct-1'); + expect(mockRegistry.selectNextAccount).toHaveBeenCalledWith([], undefined); + }); + + it('should skip selectNextAccount when default account exists', async () => { + const injectAccountEnv = await loadInjector(); + const defaultAccount = createMockAccount({ id: 'default-1', name: 'Default' }); + mockRegistry.getDefaultAccount.mockReturnValue(defaultAccount); + mockRegistry.get.mockReturnValue(defaultAccount); + const env: Record = {}; + + const result = injectAccountEnv( + 'sess-1', 'claude-code', env, + mockRegistry as unknown as AccountRegistry, + undefined, + mockSafeSend, + ); + + expect(result).toBe('default-1'); + expect(mockRegistry.selectNextAccount).not.toHaveBeenCalled(); + }); + + it('should notify renderer via safeSend when account is assigned', async () => { + const injectAccountEnv = await loadInjector(); + const env: Record = {}; + + injectAccountEnv( + 'sess-1', 'claude-code', env, + mockRegistry as unknown as AccountRegistry, + 'acct-1', + mockSafeSend, + ); + + expect(mockSafeSend).toHaveBeenCalledWith('account:assigned', { + sessionId: 'sess-1', + accountId: 'acct-1', + accountName: 'Test Account', + }); + }); +}); diff --git a/src/__tests__/main/accounts/account-recovery-poller.test.ts b/src/__tests__/main/accounts/account-recovery-poller.test.ts new file mode 100644 index 000000000..2582a85d8 --- /dev/null +++ b/src/__tests__/main/accounts/account-recovery-poller.test.ts @@ -0,0 +1,293 @@ +/** + * Tests for AccountRecoveryPoller. + * Validates timer-based recovery of throttled accounts, + * IPC event broadcasting, and edge cases. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AccountRecoveryPoller } from '../../../main/accounts/account-recovery-poller'; +import type { AccountRegistry } from '../../../main/accounts/account-registry'; +import type { AccountProfile } from '../../../shared/account-types'; + +function createMockAccount(overrides: Partial = {}): AccountProfile { + return { + id: 'acct-1', + name: 'Test Account', + email: 'test@example.com', + configDir: '/home/test/.claude-test', + agentType: 'claude-code', + status: 'active', + authMethod: 'oauth', + addedAt: Date.now(), + lastUsedAt: Date.now(), + lastThrottledAt: 0, + tokenLimitPerWindow: 0, + tokenWindowMs: 5 * 60 * 60 * 1000, // 5 hours + isDefault: true, + autoSwitchEnabled: true, + ...overrides, + }; +} + +describe('AccountRecoveryPoller', () => { + let poller: AccountRecoveryPoller; + let mockRegistry: { + getAll: ReturnType; + setStatus: ReturnType; + }; + let mockSafeSend: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + + mockRegistry = { + getAll: vi.fn().mockReturnValue([]), + setStatus: vi.fn(), + }; + mockSafeSend = vi.fn(); + + poller = new AccountRecoveryPoller( + { + accountRegistry: mockRegistry as unknown as AccountRegistry, + safeSend: mockSafeSend, + }, + 60_000 // 1 minute interval + ); + }); + + afterEach(() => { + poller.stop(); + vi.useRealTimers(); + }); + + it('should return empty array when no throttled accounts exist', () => { + mockRegistry.getAll.mockReturnValue([createMockAccount({ status: 'active' })]); + + const recovered = poller.poll(); + + expect(recovered).toEqual([]); + expect(mockRegistry.setStatus).not.toHaveBeenCalled(); + expect(mockSafeSend).not.toHaveBeenCalled(); + }); + + it('should not recover accounts still within their throttle window', () => { + const now = Date.now(); + const windowMs = 5 * 60 * 60 * 1000; // 5 hours + + mockRegistry.getAll.mockReturnValue([ + createMockAccount({ + id: 'acct-1', + status: 'throttled', + lastThrottledAt: now - (windowMs / 2), // Only halfway through window + tokenWindowMs: windowMs, + }), + ]); + + const recovered = poller.poll(); + + expect(recovered).toEqual([]); + expect(mockRegistry.setStatus).not.toHaveBeenCalled(); + }); + + it('should recover accounts past their window + margin', () => { + const now = Date.now(); + const windowMs = 5 * 60 * 60 * 1000; // 5 hours + const marginMs = 30_000; // 30 seconds + + mockRegistry.getAll.mockReturnValue([ + createMockAccount({ + id: 'acct-1', + name: 'Recovered Account', + status: 'throttled', + lastThrottledAt: now - windowMs - marginMs - 1000, // Past window + margin + tokenWindowMs: windowMs, + }), + ]); + + const recovered = poller.poll(); + + expect(recovered).toEqual(['acct-1']); + expect(mockRegistry.setStatus).toHaveBeenCalledWith('acct-1', 'active'); + }); + + it('should broadcast status-changed and recovery-available events on recovery', () => { + const now = Date.now(); + const windowMs = 5 * 60 * 60 * 1000; + const marginMs = 30_000; + + mockRegistry.getAll.mockReturnValue([ + createMockAccount({ + id: 'acct-1', + name: 'Recovered Account', + status: 'throttled', + lastThrottledAt: now - windowMs - marginMs - 1000, + tokenWindowMs: windowMs, + }), + ]); + + poller.poll(); + + // Should send status-changed per recovered account + expect(mockSafeSend).toHaveBeenCalledWith('account:status-changed', expect.objectContaining({ + accountId: 'acct-1', + accountName: 'Recovered Account', + oldStatus: 'throttled', + newStatus: 'active', + recoveredBy: 'poller', + })); + + // Should send recovery-available summary event + expect(mockSafeSend).toHaveBeenCalledWith('account:recovery-available', expect.objectContaining({ + recoveredAccountIds: ['acct-1'], + recoveredCount: 1, + stillThrottledCount: 0, + totalAccounts: 1, + })); + }); + + it('should recover multiple accounts and report correct counts', () => { + const now = Date.now(); + const windowMs = 5 * 60 * 60 * 1000; + const marginMs = 30_000; + + mockRegistry.getAll.mockReturnValue([ + createMockAccount({ + id: 'acct-1', + name: 'Account 1', + status: 'throttled', + lastThrottledAt: now - windowMs - marginMs - 1000, + tokenWindowMs: windowMs, + }), + createMockAccount({ + id: 'acct-2', + name: 'Account 2', + status: 'throttled', + lastThrottledAt: now - windowMs - marginMs - 2000, + tokenWindowMs: windowMs, + }), + createMockAccount({ + id: 'acct-3', + name: 'Account 3 (still throttled)', + status: 'throttled', + lastThrottledAt: now - (windowMs / 2), // Still within window + tokenWindowMs: windowMs, + }), + ]); + + const recovered = poller.poll(); + + expect(recovered).toEqual(['acct-1', 'acct-2']); + expect(mockRegistry.setStatus).toHaveBeenCalledTimes(2); + + expect(mockSafeSend).toHaveBeenCalledWith('account:recovery-available', expect.objectContaining({ + recoveredAccountIds: ['acct-1', 'acct-2'], + recoveredCount: 2, + stillThrottledCount: 1, + totalAccounts: 3, + })); + }); + + it('should skip throttled accounts with lastThrottledAt of 0', () => { + mockRegistry.getAll.mockReturnValue([ + createMockAccount({ + id: 'acct-1', + status: 'throttled', + lastThrottledAt: 0, // Never throttled (invalid state) + }), + ]); + + const recovered = poller.poll(); + + expect(recovered).toEqual([]); + expect(mockRegistry.setStatus).not.toHaveBeenCalled(); + }); + + it('should use DEFAULT_TOKEN_WINDOW_MS when tokenWindowMs is 0', () => { + const now = Date.now(); + const defaultWindowMs = 5 * 60 * 60 * 1000; // 5 hours (DEFAULT_TOKEN_WINDOW_MS) + const marginMs = 30_000; + + mockRegistry.getAll.mockReturnValue([ + createMockAccount({ + id: 'acct-1', + name: 'No Window Account', + status: 'throttled', + lastThrottledAt: now - defaultWindowMs - marginMs - 1000, + tokenWindowMs: 0, // Will use default + }), + ]); + + const recovered = poller.poll(); + + expect(recovered).toEqual(['acct-1']); + }); + + // --- Start/stop behavior --- + + it('should start and stop cleanly', () => { + expect(poller.isRunning()).toBe(false); + + poller.start(); + expect(poller.isRunning()).toBe(true); + + poller.stop(); + expect(poller.isRunning()).toBe(false); + }); + + it('should be idempotent on start (no double timers)', () => { + mockRegistry.getAll.mockReturnValue([]); + + poller.start(); + poller.start(); // Second call should be no-op + + expect(poller.isRunning()).toBe(true); + + // Advance time and verify poll is called (not doubled) + vi.advanceTimersByTime(60_000); + + // getAll is called once on immediate poll (start), then once per interval tick + // start() → poll() → getAll() [1st call] + // 2nd start() → no-op + // advanceTimersByTime(60_000) → poll() → getAll() [2nd call] + expect(mockRegistry.getAll).toHaveBeenCalledTimes(2); + }); + + it('should stop safely when not running', () => { + expect(() => poller.stop()).not.toThrow(); + }); + + it('should run poll immediately on start', () => { + mockRegistry.getAll.mockReturnValue([]); + + poller.start(); + + // poll() is called immediately in start() + expect(mockRegistry.getAll).toHaveBeenCalledTimes(1); + }); + + it('should poll on interval after start', () => { + mockRegistry.getAll.mockReturnValue([]); + + poller.start(); + expect(mockRegistry.getAll).toHaveBeenCalledTimes(1); // Immediate poll + + vi.advanceTimersByTime(60_000); + expect(mockRegistry.getAll).toHaveBeenCalledTimes(2); // First interval + + vi.advanceTimersByTime(60_000); + expect(mockRegistry.getAll).toHaveBeenCalledTimes(3); // Second interval + }); + + it('should stop polling after stop() is called', () => { + mockRegistry.getAll.mockReturnValue([]); + + poller.start(); + expect(mockRegistry.getAll).toHaveBeenCalledTimes(1); + + poller.stop(); + + vi.advanceTimersByTime(120_000); + expect(mockRegistry.getAll).toHaveBeenCalledTimes(1); // No more calls + }); +}); diff --git a/src/__tests__/main/accounts/account-registry.test.ts b/src/__tests__/main/accounts/account-registry.test.ts new file mode 100644 index 000000000..847ae46d8 --- /dev/null +++ b/src/__tests__/main/accounts/account-registry.test.ts @@ -0,0 +1,533 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AccountRegistry } from '../../../main/accounts/account-registry'; +import type { AccountUsageStatsProvider } from '../../../main/accounts/account-registry'; +import type { AccountStoreData } from '../../../main/stores/account-store-types'; +import { ACCOUNT_SWITCH_DEFAULTS } from '../../../shared/account-types'; + +// Create a mock store that behaves like electron-store (in-memory) +function createMockStore(initial?: Partial) { + const data: AccountStoreData = { + accounts: {}, + assignments: {}, + switchConfig: { ...ACCOUNT_SWITCH_DEFAULTS }, + rotationOrder: [], + rotationIndex: 0, + ...initial, + }; + + return { + get(key: string, defaultValue?: any) { + return (data as any)[key] ?? defaultValue; + }, + set(key: string, value: any) { + (data as any)[key] = value; + }, + _data: data, + } as any; +} + +function makeParams(overrides: Partial<{ name: string; email: string; configDir: string }> = {}) { + return { + name: overrides.name ?? 'Test Account', + email: overrides.email ?? 'test@example.com', + configDir: overrides.configDir ?? '/home/user/.claude-test', + ...overrides, + }; +} + +describe('AccountRegistry', () => { + let store: ReturnType; + let registry: AccountRegistry; + + beforeEach(() => { + store = createMockStore(); + registry = new AccountRegistry(store); + }); + + describe('add', () => { + it('should create a new account with default values', () => { + const profile = registry.add(makeParams()); + + expect(profile.id).toBeTruthy(); + expect(profile.name).toBe('Test Account'); + expect(profile.email).toBe('test@example.com'); + expect(profile.configDir).toBe('/home/user/.claude-test'); + expect(profile.agentType).toBe('claude-code'); + expect(profile.status).toBe('active'); + expect(profile.authMethod).toBe('oauth'); + expect(profile.isDefault).toBe(true); // First account is default + expect(profile.autoSwitchEnabled).toBe(true); + expect(profile.lastUsedAt).toBe(0); + expect(profile.lastThrottledAt).toBe(0); + expect(profile.tokenLimitPerWindow).toBe(0); + }); + + it('should mark only the first account as default', () => { + const first = registry.add(makeParams({ email: 'first@example.com' })); + const second = registry.add(makeParams({ email: 'second@example.com' })); + + expect(first.isDefault).toBe(true); + expect(second.isDefault).toBe(false); + }); + + it('should throw on duplicate email', () => { + registry.add(makeParams({ email: 'dupe@example.com' })); + + expect(() => registry.add(makeParams({ email: 'dupe@example.com' }))).toThrow( + 'Account with email "dupe@example.com" already exists' + ); + }); + + it('should add account to rotation order', () => { + const profile = registry.add(makeParams()); + + const order = store.get('rotationOrder'); + expect(order).toContain(profile.id); + }); + + it('should accept custom agentType and authMethod', () => { + const profile = registry.add({ + ...makeParams(), + agentType: 'claude-code', + authMethod: 'api-key', + }); + + expect(profile.agentType).toBe('claude-code'); + expect(profile.authMethod).toBe('api-key'); + }); + }); + + describe('get / getAll', () => { + it('should return null for non-existent ID', () => { + expect(registry.get('nonexistent')).toBeNull(); + }); + + it('should return the account by ID', () => { + const added = registry.add(makeParams()); + + expect(registry.get(added.id)).toEqual(added); + }); + + it('should return all accounts', () => { + registry.add(makeParams({ email: 'a@example.com' })); + registry.add(makeParams({ email: 'b@example.com' })); + + expect(registry.getAll()).toHaveLength(2); + }); + }); + + describe('findByEmail / findByConfigDir', () => { + it('should find account by email', () => { + const added = registry.add(makeParams({ email: 'find@example.com' })); + + expect(registry.findByEmail('find@example.com')?.id).toBe(added.id); + expect(registry.findByEmail('notfound@example.com')).toBeNull(); + }); + + it('should find account by configDir', () => { + const added = registry.add(makeParams({ configDir: '/home/user/.claude-special' })); + + expect(registry.findByConfigDir('/home/user/.claude-special')?.id).toBe(added.id); + expect(registry.findByConfigDir('/nonexistent')).toBeNull(); + }); + }); + + describe('update', () => { + it('should return null for non-existent ID', () => { + expect(registry.update('nonexistent', { name: 'Updated' })).toBeNull(); + }); + + it('should update account fields', () => { + const added = registry.add(makeParams()); + const updated = registry.update(added.id, { name: 'New Name' }); + + expect(updated?.name).toBe('New Name'); + expect(updated?.email).toBe('test@example.com'); // unchanged + }); + + it('should clear default from other accounts when setting new default', () => { + const first = registry.add(makeParams({ email: 'a@example.com' })); + registry.add(makeParams({ email: 'b@example.com' })); + const second = registry.getAll().find(a => a.email === 'b@example.com')!; + + registry.update(second.id, { isDefault: true }); + + expect(registry.get(first.id)?.isDefault).toBe(false); + expect(registry.get(second.id)?.isDefault).toBe(true); + }); + }); + + describe('remove', () => { + it('should return false for non-existent ID', () => { + expect(registry.remove('nonexistent')).toBe(false); + }); + + it('should remove account and clean up rotation order', () => { + const added = registry.add(makeParams()); + + expect(registry.remove(added.id)).toBe(true); + expect(registry.get(added.id)).toBeNull(); + expect(store.get('rotationOrder')).not.toContain(added.id); + }); + + it('should remove assignments pointing to the deleted account', () => { + const added = registry.add(makeParams()); + registry.assignToSession('session-1', added.id); + + registry.remove(added.id); + + expect(registry.getAssignment('session-1')).toBeNull(); + }); + }); + + describe('setStatus', () => { + it('should update account status', () => { + const added = registry.add(makeParams()); + + registry.setStatus(added.id, 'disabled'); + + expect(registry.get(added.id)?.status).toBe('disabled'); + }); + + it('should set lastThrottledAt when throttled', () => { + const added = registry.add(makeParams()); + const before = Date.now(); + + registry.setStatus(added.id, 'throttled'); + + const account = registry.get(added.id)!; + expect(account.status).toBe('throttled'); + expect(account.lastThrottledAt).toBeGreaterThanOrEqual(before); + }); + + it('should no-op for non-existent ID', () => { + // Should not throw + registry.setStatus('nonexistent', 'active'); + }); + }); + + describe('touchLastUsed', () => { + it('should update lastUsedAt timestamp', () => { + const added = registry.add(makeParams()); + expect(registry.get(added.id)?.lastUsedAt).toBe(0); + + const before = Date.now(); + registry.touchLastUsed(added.id); + + expect(registry.get(added.id)?.lastUsedAt).toBeGreaterThanOrEqual(before); + }); + }); + + describe('assignments', () => { + it('should assign account to session', () => { + const added = registry.add(makeParams()); + const assignment = registry.assignToSession('session-1', added.id); + + expect(assignment.sessionId).toBe('session-1'); + expect(assignment.accountId).toBe(added.id); + expect(assignment.assignedAt).toBeGreaterThan(0); + }); + + it('should get assignment by session ID', () => { + const added = registry.add(makeParams()); + registry.assignToSession('session-1', added.id); + + const assignment = registry.getAssignment('session-1'); + expect(assignment?.accountId).toBe(added.id); + }); + + it('should return null for unassigned session', () => { + expect(registry.getAssignment('unknown')).toBeNull(); + }); + + it('should remove assignment', () => { + const added = registry.add(makeParams()); + registry.assignToSession('session-1', added.id); + + registry.removeAssignment('session-1'); + + expect(registry.getAssignment('session-1')).toBeNull(); + }); + + it('should get all assignments', () => { + const added = registry.add(makeParams()); + registry.assignToSession('session-1', added.id); + registry.assignToSession('session-2', added.id); + + expect(registry.getAllAssignments()).toHaveLength(2); + }); + + it('should touch lastUsedAt on assignment', () => { + const added = registry.add(makeParams()); + expect(registry.get(added.id)?.lastUsedAt).toBe(0); + + registry.assignToSession('session-1', added.id); + + expect(registry.get(added.id)?.lastUsedAt).toBeGreaterThan(0); + }); + }); + + describe('getDefaultAccount', () => { + it('should return null when no accounts exist', () => { + expect(registry.getDefaultAccount()).toBeNull(); + }); + + it('should return the default active account', () => { + const added = registry.add(makeParams()); + + expect(registry.getDefaultAccount()?.id).toBe(added.id); + }); + + it('should fall back to first active account if default is disabled', () => { + const first = registry.add(makeParams({ email: 'a@example.com' })); + registry.add(makeParams({ email: 'b@example.com' })); + + registry.setStatus(first.id, 'disabled'); + + const defaultAcct = registry.getDefaultAccount(); + expect(defaultAcct?.email).toBe('b@example.com'); + }); + }); + + describe('selectNextAccount', () => { + it('should return null when no accounts available', () => { + expect(registry.selectNextAccount()).toBeNull(); + }); + + it('should select least-used account by default', () => { + const a = registry.add(makeParams({ email: 'a@example.com' })); + const b = registry.add(makeParams({ email: 'b@example.com' })); + + // Touch a so b is least-used + registry.touchLastUsed(a.id); + + const next = registry.selectNextAccount(); + expect(next?.id).toBe(b.id); + }); + + it('should exclude specified account IDs', () => { + const a = registry.add(makeParams({ email: 'a@example.com' })); + const b = registry.add(makeParams({ email: 'b@example.com' })); + + const next = registry.selectNextAccount([a.id]); + expect(next?.id).toBe(b.id); + }); + + it('should return null when all accounts are excluded', () => { + const a = registry.add(makeParams({ email: 'a@example.com' })); + + expect(registry.selectNextAccount([a.id])).toBeNull(); + }); + + it('should skip disabled accounts', () => { + const a = registry.add(makeParams({ email: 'a@example.com' })); + const b = registry.add(makeParams({ email: 'b@example.com' })); + + registry.setStatus(a.id, 'disabled'); + + const next = registry.selectNextAccount(); + expect(next?.id).toBe(b.id); + }); + + it('should use round-robin when configured', () => { + registry.updateSwitchConfig({ selectionStrategy: 'round-robin' }); + + const a = registry.add(makeParams({ email: 'a@example.com' })); + const b = registry.add(makeParams({ email: 'b@example.com' })); + + const first = registry.selectNextAccount(); + const second = registry.selectNextAccount(); + + // Should cycle through accounts + expect([first?.id, second?.id]).toContain(a.id); + expect([first?.id, second?.id]).toContain(b.id); + }); + + describe('capacity-aware selection', () => { + function createMockStatsDB(usageMap: Record): AccountUsageStatsProvider { + return { + isReady: () => true, + getAccountUsageInWindow: (id: string) => { + const total = usageMap[id] ?? 0; + return { + inputTokens: total, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }; + }, + }; + } + + it('should select account with most remaining capacity', () => { + const a = registry.add(makeParams({ email: 'a@example.com' })); + registry.update(a.id, { tokenLimitPerWindow: 10000 }); + const b = registry.add(makeParams({ email: 'b@example.com' })); + registry.update(b.id, { tokenLimitPerWindow: 10000 }); + + // A used 8000, B used 2000 → B has more remaining + const statsDB = createMockStatsDB({ [a.id]: 8000, [b.id]: 2000 }); + const next = registry.selectNextAccount([], statsDB); + + expect(next?.id).toBe(b.id); + }); + + it('should fall back to LRU when statsDB is not ready', () => { + const a = registry.add(makeParams({ email: 'a@example.com' })); + registry.update(a.id, { tokenLimitPerWindow: 10000 }); + const b = registry.add(makeParams({ email: 'b@example.com' })); + registry.update(b.id, { tokenLimitPerWindow: 10000 }); + + registry.touchLastUsed(a.id); + + const statsDB: AccountUsageStatsProvider = { + isReady: () => false, + getAccountUsageInWindow: () => ({ + inputTokens: 0, outputTokens: 0, + cacheReadTokens: 0, cacheCreationTokens: 0, + }), + }; + + const next = registry.selectNextAccount([], statsDB); + expect(next?.id).toBe(b.id); // b has lower lastUsedAt + }); + + it('should deprioritize recently throttled accounts', () => { + const a = registry.add(makeParams({ email: 'a@example.com' })); + registry.update(a.id, { tokenLimitPerWindow: 10000 }); + const b = registry.add(makeParams({ email: 'b@example.com' })); + registry.update(b.id, { tokenLimitPerWindow: 10000 }); + + // A has more remaining capacity but was recently throttled + registry.update(a.id, { lastThrottledAt: Date.now() - 60_000 }); // 1 min ago + const statsDB = createMockStatsDB({ [a.id]: 2000, [b.id]: 4000 }); + + const next = registry.selectNextAccount([], statsDB); + // A remaining: (10000-2000)*0.5 = 4000 (penalized) + // B remaining: 10000-4000 = 6000 + expect(next?.id).toBe(b.id); + }); + + it('should prefer accounts with configured limits over unlimited', () => { + const a = registry.add(makeParams({ email: 'a@example.com' })); + registry.update(a.id, { tokenLimitPerWindow: 10000 }); + const b = registry.add(makeParams({ email: 'b@example.com' })); + // b has no limit (tokenLimitPerWindow = 0) + + const statsDB = createMockStatsDB({ [a.id]: 2000, [b.id]: 0 }); + const next = registry.selectNextAccount([], statsDB); + + // A has known remaining capacity (8000), B is Infinity but deprioritized + expect(next?.id).toBe(a.id); + }); + + it('should fall back to LRU when no accounts have limits', () => { + const a = registry.add(makeParams({ email: 'a@example.com' })); + const b = registry.add(makeParams({ email: 'b@example.com' })); + // Neither has tokenLimitPerWindow set + + registry.touchLastUsed(a.id); + + const statsDB = createMockStatsDB({ [a.id]: 5000, [b.id]: 1000 }); + const next = registry.selectNextAccount([], statsDB); + + // Both have Infinity remaining → falls back to LRU → b (lastUsedAt=0) wins + expect(next?.id).toBe(b.id); + }); + + it('should select only remaining account when one is at capacity', () => { + const a = registry.add(makeParams({ email: 'a@example.com' })); + registry.update(a.id, { tokenLimitPerWindow: 10000 }); + const b = registry.add(makeParams({ email: 'b@example.com' })); + registry.update(b.id, { tokenLimitPerWindow: 10000 }); + + // A is at 100% capacity, B has headroom + const statsDB = createMockStatsDB({ [a.id]: 15000, [b.id]: 3000 }); + const next = registry.selectNextAccount([], statsDB); + + expect(next?.id).toBe(b.id); + }); + + it('should not use capacity-aware selection for round-robin strategy', () => { + registry.updateSwitchConfig({ selectionStrategy: 'round-robin' }); + + const a = registry.add(makeParams({ email: 'a@example.com' })); + registry.update(a.id, { tokenLimitPerWindow: 10000 }); + const b = registry.add(makeParams({ email: 'b@example.com' })); + registry.update(b.id, { tokenLimitPerWindow: 10000 }); + + // Even though A has way more usage, round-robin should still cycle deterministically + const statsDB = createMockStatsDB({ [a.id]: 9000, [b.id]: 1000 }); + const first = registry.selectNextAccount([], statsDB); + const second = registry.selectNextAccount([], statsDB); + + // Both should appear (round-robin cycles) + expect([first?.id, second?.id]).toContain(a.id); + expect([first?.id, second?.id]).toContain(b.id); + }); + }); + }); + + describe('reconcileAssignments', () => { + it('should remove assignments for sessions not in the active set', () => { + const account = registry.add(makeParams()); + registry.assignToSession('session-1', account.id); + registry.assignToSession('session-2', account.id); + registry.assignToSession('session-3', account.id); + + // Only session-1 and session-3 are still active + const removed = registry.reconcileAssignments(new Set(['session-1', 'session-3'])); + + expect(removed).toBe(1); + expect(registry.getAssignment('session-1')).not.toBeNull(); + expect(registry.getAssignment('session-2')).toBeNull(); + expect(registry.getAssignment('session-3')).not.toBeNull(); + }); + + it('should return 0 when all assignments are valid', () => { + const account = registry.add(makeParams()); + registry.assignToSession('session-1', account.id); + + const removed = registry.reconcileAssignments(new Set(['session-1'])); + + expect(removed).toBe(0); + expect(registry.getAssignment('session-1')).not.toBeNull(); + }); + + it('should handle empty assignment map', () => { + const removed = registry.reconcileAssignments(new Set(['session-1'])); + + expect(removed).toBe(0); + }); + + it('should remove all assignments when no sessions are active', () => { + const account = registry.add(makeParams()); + registry.assignToSession('session-1', account.id); + registry.assignToSession('session-2', account.id); + + const removed = registry.reconcileAssignments(new Set()); + + expect(removed).toBe(2); + expect(registry.getAllAssignments()).toHaveLength(0); + }); + }); + + describe('switchConfig', () => { + it('should return defaults initially', () => { + const config = registry.getSwitchConfig(); + + expect(config).toEqual(ACCOUNT_SWITCH_DEFAULTS); + }); + + it('should update config partially', () => { + const updated = registry.updateSwitchConfig({ + enabled: true, + warningThresholdPercent: 70, + }); + + expect(updated.enabled).toBe(true); + expect(updated.warningThresholdPercent).toBe(70); + expect(updated.promptBeforeSwitch).toBe(true); // unchanged default + }); + }); +}); diff --git a/src/__tests__/main/accounts/account-setup.test.ts b/src/__tests__/main/accounts/account-setup.test.ts new file mode 100644 index 000000000..6a404b5a7 --- /dev/null +++ b/src/__tests__/main/accounts/account-setup.test.ts @@ -0,0 +1,424 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as path from 'path'; + +// Hoist mock functions so they can be used in vi.mock factories +const { + TEST_HOME, + mockStat, mockAccess, mockReadFile, mockReaddir, + mockMkdir, mockLstat, mockSymlink, mockUnlink, mockRm, + mockExecFile, +} = vi.hoisted(() => ({ + TEST_HOME: '/home/testuser', + mockStat: vi.fn(), + mockAccess: vi.fn(), + mockReadFile: vi.fn(), + mockReaddir: vi.fn(), + mockMkdir: vi.fn(), + mockLstat: vi.fn(), + mockSymlink: vi.fn(), + mockUnlink: vi.fn(), + mockRm: vi.fn(), + mockExecFile: vi.fn(), +})); + +// Mock fs/promises module +vi.mock('fs/promises', () => ({ + default: { + stat: mockStat, + access: mockAccess, + readFile: mockReadFile, + readdir: mockReaddir, + mkdir: mockMkdir, + lstat: mockLstat, + symlink: mockSymlink, + unlink: mockUnlink, + rm: mockRm, + }, + stat: mockStat, + access: mockAccess, + readFile: mockReadFile, + readdir: mockReaddir, + mkdir: mockMkdir, + lstat: mockLstat, + symlink: mockSymlink, + unlink: mockUnlink, + rm: mockRm, +})); + +// Mock os module +vi.mock('os', () => ({ + default: { + homedir: vi.fn().mockReturnValue(TEST_HOME), + }, + homedir: vi.fn().mockReturnValue(TEST_HOME), +})); + +// Mock logger +vi.mock('../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock child_process for SSH validation +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual, + execFile: mockExecFile, + }, + execFile: mockExecFile, + }; +}); + +// Mock util.promisify for execFile +vi.mock('util', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual, + promisify: (fn: any) => { + if (fn === mockExecFile) { + return async (...args: any[]) => { + return new Promise((resolve, reject) => { + mockExecFile(...args, (error: Error | null, stdout: string, stderr: string) => { + if (error) reject(error); + else resolve({ stdout, stderr }); + }); + }); + }; + } + return actual.promisify(fn); + }, + }, + promisify: (fn: any) => { + if (fn === mockExecFile) { + return async (...args: any[]) => { + return new Promise((resolve, reject) => { + mockExecFile(...args, (error: Error | null, stdout: string, stderr: string) => { + if (error) reject(error); + else resolve({ stdout, stderr }); + }); + }); + }; + } + return actual.promisify(fn); + }, + }; +}); + +import { + validateBaseClaudeDir, + discoverExistingAccounts, + readAccountEmail, + createAccountDirectory, + validateAccountSymlinks, + repairAccountSymlinks, + buildLoginCommand, + removeAccountDirectory, + validateRemoteAccountDir, +} from '../../../main/accounts/account-setup'; + +describe('account-setup', () => { + const baseDir = path.join(TEST_HOME, '.claude'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('validateBaseClaudeDir', () => { + it('should return valid when .claude dir and .claude.json exist', async () => { + mockStat.mockResolvedValue({ isDirectory: () => true }); + mockAccess.mockResolvedValue(undefined); + + const result = await validateBaseClaudeDir(); + expect(result.valid).toBe(true); + expect(result.baseDir).toBe(baseDir); + expect(result.errors).toHaveLength(0); + }); + + it('should return errors when .claude dir does not exist', async () => { + mockStat.mockRejectedValue(new Error('ENOENT')); + mockAccess.mockRejectedValue(new Error('ENOENT')); + + const result = await validateBaseClaudeDir(); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0]).toContain('does not exist'); + }); + + it('should report missing credentials files', async () => { + mockStat.mockResolvedValue({ isDirectory: () => true }); + mockAccess.mockRejectedValue(new Error('ENOENT')); + + const result = await validateBaseClaudeDir(); + expect(result.valid).toBe(false); + expect(result.errors).toContain('No .credentials.json or .claude.json found — Claude Code may not be authenticated.'); + }); + }); + + describe('readAccountEmail', () => { + it('should extract email from .claude.json', async () => { + mockReadFile.mockResolvedValue(JSON.stringify({ email: 'user@example.com' })); + const email = await readAccountEmail('/fake/.claude-test'); + expect(email).toBe('user@example.com'); + }); + + it('should try alternative field names', async () => { + mockReadFile.mockResolvedValue(JSON.stringify({ accountEmail: 'alt@example.com' })); + const email = await readAccountEmail('/fake/.claude-test'); + expect(email).toBe('alt@example.com'); + }); + + it('should return null for unreadable file', async () => { + mockReadFile.mockRejectedValue(new Error('ENOENT')); + const email = await readAccountEmail('/fake/.claude-test'); + expect(email).toBeNull(); + }); + + it('should return null for invalid JSON', async () => { + mockReadFile.mockResolvedValue('not-json'); + const email = await readAccountEmail('/fake/.claude-test'); + expect(email).toBeNull(); + }); + + it('should extract nested email from oauthAccount', async () => { + mockReadFile.mockResolvedValue(JSON.stringify({ + oauthAccount: { email: 'nested@example.com' }, + })); + const email = await readAccountEmail('/fake/.claude-test'); + expect(email).toBe('nested@example.com'); + }); + }); + + describe('buildLoginCommand', () => { + it('should build command with default binary', () => { + const cmd = buildLoginCommand('/home/user/.claude-work'); + expect(cmd).toBe('CLAUDE_CONFIG_DIR="/home/user/.claude-work" claude login'); + }); + + it('should build command with custom binary path', () => { + const cmd = buildLoginCommand('/home/user/.claude-work', '/usr/local/bin/claude'); + expect(cmd).toBe('CLAUDE_CONFIG_DIR="/home/user/.claude-work" /usr/local/bin/claude login'); + }); + }); + + describe('createAccountDirectory', () => { + it('should fail if directory already exists', async () => { + mockAccess.mockResolvedValue(undefined); + + const result = await createAccountDirectory('test'); + expect(result.success).toBe(false); + expect(result.error).toContain('already exists'); + }); + + it('should fail if base dir validation fails', async () => { + mockAccess.mockRejectedValue(new Error('ENOENT')); + mockStat.mockRejectedValue(new Error('ENOENT')); + + const result = await createAccountDirectory('test'); + expect(result.success).toBe(false); + expect(result.error).toContain('does not exist'); + }); + + it('should create directory and symlinks when base dir is valid', async () => { + mockAccess.mockImplementation(async (p: string) => { + const pStr = String(p); + if (pStr.endsWith('.claude-newacct')) { + throw new Error('ENOENT'); + } + return undefined; + }); + mockStat.mockResolvedValue({ isDirectory: () => true }); + mockMkdir.mockResolvedValue(undefined); + mockLstat.mockRejectedValue(new Error('ENOENT')); + mockSymlink.mockResolvedValue(undefined); + + const result = await createAccountDirectory('newacct'); + expect(result.success).toBe(true); + expect(result.configDir).toBe(path.join(TEST_HOME, '.claude-newacct')); + expect(mockMkdir).toHaveBeenCalled(); + expect(mockSymlink).toHaveBeenCalled(); + }); + }); + + describe('validateAccountSymlinks', () => { + it('should report valid when all symlinks are intact', async () => { + mockLstat.mockResolvedValue({ isSymbolicLink: () => true }); + mockStat.mockResolvedValue({}); + + const result = await validateAccountSymlinks('/fake/.claude-test'); + expect(result.valid).toBe(true); + expect(result.broken).toHaveLength(0); + expect(result.missing).toHaveLength(0); + }); + + it('should report broken symlinks', async () => { + mockLstat.mockResolvedValue({ isSymbolicLink: () => true }); + mockStat.mockRejectedValue(new Error('ENOENT')); + + const result = await validateAccountSymlinks('/fake/.claude-test'); + expect(result.valid).toBe(false); + expect(result.broken.length).toBeGreaterThan(0); + }); + + it('should report missing symlinks when source exists', async () => { + mockLstat.mockRejectedValue(new Error('ENOENT')); + mockAccess.mockResolvedValue(undefined); + + const result = await validateAccountSymlinks('/fake/.claude-test'); + expect(result.valid).toBe(false); + expect(result.missing.length).toBeGreaterThan(0); + }); + }); + + describe('removeAccountDirectory', () => { + it('should reject non-.claude- directories', async () => { + const result = await removeAccountDirectory('/home/user/important-stuff'); + expect(result.success).toBe(false); + expect(result.error).toContain('Safety check'); + }); + + it('should remove valid .claude- directories', async () => { + mockRm.mockResolvedValue(undefined); + + const result = await removeAccountDirectory(path.join(TEST_HOME, '.claude-test')); + expect(result.success).toBe(true); + }); + + it('should handle rm errors gracefully', async () => { + mockRm.mockRejectedValue(new Error('Permission denied')); + + const result = await removeAccountDirectory(path.join(TEST_HOME, '.claude-test')); + expect(result.success).toBe(false); + expect(result.error).toContain('Permission denied'); + }); + }); + + describe('discoverExistingAccounts', () => { + it('should find .claude-* directories', async () => { + mockReaddir.mockResolvedValue([ + { name: '.claude-work', isDirectory: () => true, isSymbolicLink: () => false }, + { name: '.claude-personal', isDirectory: () => true, isSymbolicLink: () => false }, + { name: '.bashrc', isDirectory: () => false, isSymbolicLink: () => false }, + { name: 'Documents', isDirectory: () => true, isSymbolicLink: () => false }, + ]); + + mockReadFile.mockImplementation(async (p: string) => { + if (String(p).includes('.claude-work')) { + return JSON.stringify({ email: 'work@example.com' }); + } + throw new Error('ENOENT'); + }); + + const accounts = await discoverExistingAccounts(); + expect(accounts).toHaveLength(2); + expect(accounts[0].name).toBe('work'); + expect(accounts[0].email).toBe('work@example.com'); + expect(accounts[0].hasAuth).toBe(true); + expect(accounts[1].name).toBe('personal'); + expect(accounts[1].email).toBeNull(); + expect(accounts[1].hasAuth).toBe(false); + }); + }); + + describe('repairAccountSymlinks', () => { + it('should repair broken and missing symlinks', async () => { + mockLstat.mockImplementation(async (p: string) => { + const pStr = String(p); + if (pStr.endsWith('/commands')) { + return { isSymbolicLink: () => true }; + } + throw new Error('ENOENT'); + }); + mockStat.mockRejectedValue(new Error('ENOENT')); + mockAccess.mockResolvedValue(undefined); + mockUnlink.mockResolvedValue(undefined); + mockSymlink.mockResolvedValue(undefined); + + const result = await repairAccountSymlinks('/fake/.claude-test'); + expect(result.errors).toHaveLength(0); + expect(result.repaired.length).toBeGreaterThan(0); + }); + }); + + describe('validateRemoteAccountDir', () => { + it('should validate existing remote directory', async () => { + mockExecFile.mockImplementation( + (_cmd: string, args: string[], _opts: any, callback: any) => { + const command = args[args.length - 1]; + if (command.includes('DIR_EXISTS')) { + callback(null, 'DIR_EXISTS\n', ''); + } else if (command.includes('AUTH_EXISTS')) { + callback(null, 'AUTH_EXISTS\n', ''); + } else if (command.includes('SYMLINKS_OK')) { + callback(null, 'SYMLINKS_OK\n', ''); + } + }, + ); + + const result = await validateRemoteAccountDir( + { host: 'example.com', user: 'dev' }, + '~/.claude-work', + ); + + expect(result.exists).toBe(true); + expect(result.hasAuth).toBe(true); + expect(result.symlinksValid).toBe(true); + }); + + it('should detect missing remote directory', async () => { + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], _opts: any, callback: any) => { + callback(null, 'DIR_MISSING\n', ''); + }, + ); + + const result = await validateRemoteAccountDir( + { host: 'example.com' }, + '~/.claude-work', + ); + + expect(result.exists).toBe(false); + expect(result.hasAuth).toBe(false); + expect(result.symlinksValid).toBe(false); + }); + + it('should handle SSH connection errors', async () => { + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], _opts: any, callback: any) => { + callback(new Error('Connection refused'), '', ''); + }, + ); + + const result = await validateRemoteAccountDir( + { host: 'example.com', user: 'dev', port: 2222 }, + '~/.claude-work', + ); + + expect(result.exists).toBe(false); + expect(result.error).toContain('Connection refused'); + }); + + it('should include port in SSH args', async () => { + mockExecFile.mockImplementation( + (_cmd: string, args: string[], _opts: any, callback: any) => { + expect(args).toContain('-p'); + expect(args).toContain('2222'); + callback(null, 'DIR_MISSING\n', ''); + }, + ); + + await validateRemoteAccountDir( + { host: 'example.com', port: 2222 }, + '~/.claude-work', + ); + }); + }); +}); diff --git a/src/__tests__/main/accounts/account-switcher-wiring.test.ts b/src/__tests__/main/accounts/account-switcher-wiring.test.ts new file mode 100644 index 000000000..9d785f40f --- /dev/null +++ b/src/__tests__/main/accounts/account-switcher-wiring.test.ts @@ -0,0 +1,288 @@ +/** + * Integration tests for AccountSwitcher wiring. + * + * Verifies that registerAccountHandlers and registerProcessHandlers + * properly call through to the AccountSwitcher when the getter is provided, + * and gracefully degrade when it is not. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ipcMain } from 'electron'; +import { registerAccountHandlers } from '../../../main/ipc/handlers/accounts'; +import { registerProcessHandlers } from '../../../main/ipc/handlers/process'; + +// Mock electron's ipcMain +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn(), + }, +})); + +// Mock the stats module +vi.mock('../../../main/stats', () => ({ + getStatsDB: vi.fn(() => ({ + isReady: () => false, + getAccountUsageInWindow: vi.fn(), + getThrottleEvents: vi.fn().mockReturnValue([]), + insertThrottleEvent: vi.fn(), + })), +})); + +// Mock the logger +vi.mock('../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock account-setup module (required by accounts handler) +vi.mock('../../../main/accounts/account-setup', () => ({ + validateBaseClaudeDir: vi.fn(), + discoverExistingAccounts: vi.fn(), + createAccountDirectory: vi.fn(), + validateAccountSymlinks: vi.fn(), + repairAccountSymlinks: vi.fn(), + readAccountEmail: vi.fn(), + buildLoginCommand: vi.fn(), + removeAccountDirectory: vi.fn(), + validateRemoteAccountDir: vi.fn(), + syncCredentialsFromBase: vi.fn(), +})); + +// Mock agent-args utilities (required by process handler) +vi.mock('../../../main/utils/agent-args', () => ({ + buildAgentArgs: vi.fn((_agent: unknown, opts: { baseArgs?: string[] }) => opts.baseArgs || []), + applyAgentConfigOverrides: vi.fn((_agent: unknown, args: string[]) => ({ + args, + modelSource: 'none' as const, + customArgsSource: 'none' as const, + customEnvSource: 'none' as const, + effectiveCustomEnvVars: undefined, + })), + getContextWindowValue: vi.fn(() => 0), +})); + +// Mock node-pty +vi.mock('node-pty', () => ({ + spawn: vi.fn(), +})); + +// Mock streamJsonBuilder +vi.mock('../../../main/process-manager/utils/streamJsonBuilder', () => ({ + buildStreamJsonMessage: vi.fn(), +})); + +// Mock ssh-command-builder +vi.mock('../../../main/utils/ssh-command-builder', () => ({ + buildSshCommandWithStdin: vi.fn(), +})); + +function createMinimalAccountRegistry() { + return { + getAll: vi.fn().mockReturnValue([]), + get: vi.fn(), + add: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + setStatus: vi.fn(), + getDefaultAccount: vi.fn(), + selectNextAccount: vi.fn(), + getSwitchConfig: vi.fn().mockReturnValue({ enabled: false }), + updateSwitchConfig: vi.fn(), + assignToSession: vi.fn(), + getAssignment: vi.fn(), + getAllAssignments: vi.fn().mockReturnValue([]), + removeAssignment: vi.fn(), + reconcileAssignments: vi.fn().mockReturnValue(0), + } as any; +} + +describe('AccountSwitcher wiring', () => { + let handlers: Map; + + beforeEach(() => { + vi.clearAllMocks(); + handlers = new Map(); + + // Capture registered handlers + vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Function) => { + handlers.set(channel, handler); + return undefined as any; + }); + }); + + describe('accounts:execute-switch', () => { + it('should execute switch when getAccountSwitcher returns an instance', async () => { + const mockSwitcher = { + executeSwitch: vi.fn().mockResolvedValue({ + sessionId: 'session-1', + fromAccountId: 'acct-1', + toAccountId: 'acct-2', + reason: 'manual', + automatic: false, + timestamp: Date.now(), + }), + recordLastPrompt: vi.fn(), + cleanupSession: vi.fn(), + }; + + registerAccountHandlers({ + getAccountRegistry: () => createMinimalAccountRegistry(), + getAccountSwitcher: () => mockSwitcher as any, + }); + + const handler = handlers.get('accounts:execute-switch')!; + expect(handler).toBeDefined(); + + const result = await handler({}, { + sessionId: 'session-1', + fromAccountId: 'acct-1', + toAccountId: 'acct-2', + reason: 'manual', + automatic: false, + }); + + expect(result.success).toBe(true); + expect(result.event).toBeDefined(); + expect(mockSwitcher.executeSwitch).toHaveBeenCalledWith({ + sessionId: 'session-1', + fromAccountId: 'acct-1', + toAccountId: 'acct-2', + reason: 'manual', + automatic: false, + }); + }); + + it('should return error when getAccountSwitcher returns null', async () => { + registerAccountHandlers({ + getAccountRegistry: () => createMinimalAccountRegistry(), + getAccountSwitcher: () => null, + }); + + const handler = handlers.get('accounts:execute-switch')!; + const result = await handler({}, { + sessionId: 'session-1', + fromAccountId: 'acct-1', + toAccountId: 'acct-2', + reason: 'manual', + automatic: false, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Account switcher not initialized'); + }); + + it('should return error when getAccountSwitcher is not provided', async () => { + registerAccountHandlers({ + getAccountRegistry: () => createMinimalAccountRegistry(), + // No getAccountSwitcher provided + }); + + const handler = handlers.get('accounts:execute-switch')!; + const result = await handler({}, { + sessionId: 'session-1', + fromAccountId: 'acct-1', + toAccountId: 'acct-2', + reason: 'manual', + automatic: false, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Account switcher not initialized'); + }); + }); + + describe('accounts:cleanup-session', () => { + it('should call switcher.cleanupSession when switcher is available', async () => { + const mockSwitcher = { + executeSwitch: vi.fn(), + recordLastPrompt: vi.fn(), + cleanupSession: vi.fn(), + }; + + registerAccountHandlers({ + getAccountRegistry: () => createMinimalAccountRegistry(), + getAccountSwitcher: () => mockSwitcher as any, + }); + + const handler = handlers.get('accounts:cleanup-session')!; + expect(handler).toBeDefined(); + + const result = await handler({}, 'session-1'); + + expect(result.success).toBe(true); + expect(mockSwitcher.cleanupSession).toHaveBeenCalledWith('session-1'); + }); + }); + + describe('process:write recordLastPrompt', () => { + it('should record last prompt on process:write when switcher available', async () => { + const mockSwitcher = { + executeSwitch: vi.fn(), + recordLastPrompt: vi.fn(), + cleanupSession: vi.fn(), + }; + + const mockProcessManager = { + write: vi.fn().mockReturnValue(true), + spawn: vi.fn(), + kill: vi.fn(), + interrupt: vi.fn(), + resize: vi.fn(), + getActiveProcesses: vi.fn().mockReturnValue([]), + }; + + registerProcessHandlers({ + getProcessManager: () => mockProcessManager as any, + getAgentDetector: () => null, + agentConfigsStore: { get: vi.fn().mockReturnValue({}), set: vi.fn(), onDidChange: vi.fn() } as any, + settingsStore: { get: vi.fn().mockReturnValue({}), set: vi.fn(), onDidChange: vi.fn() } as any, + getMainWindow: () => null, + sessionsStore: { get: vi.fn().mockReturnValue({ sessions: [] }), set: vi.fn(), onDidChange: vi.fn() } as any, + getAccountSwitcher: () => mockSwitcher as any, + safeSend: vi.fn(), + }); + + const handler = handlers.get('process:write')!; + expect(handler).toBeDefined(); + + await handler({}, 'session-1', 'Hello, fix the bug'); + + expect(mockSwitcher.recordLastPrompt).toHaveBeenCalledWith('session-1', 'Hello, fix the bug'); + expect(mockProcessManager.write).toHaveBeenCalledWith('session-1', 'Hello, fix the bug'); + }); + + it('should not fail on process:write when switcher is not available', async () => { + const mockProcessManager = { + write: vi.fn().mockReturnValue(true), + spawn: vi.fn(), + kill: vi.fn(), + interrupt: vi.fn(), + resize: vi.fn(), + getActiveProcesses: vi.fn().mockReturnValue([]), + }; + + registerProcessHandlers({ + getProcessManager: () => mockProcessManager as any, + getAgentDetector: () => null, + agentConfigsStore: { get: vi.fn().mockReturnValue({}), set: vi.fn(), onDidChange: vi.fn() } as any, + settingsStore: { get: vi.fn().mockReturnValue({}), set: vi.fn(), onDidChange: vi.fn() } as any, + getMainWindow: () => null, + sessionsStore: { get: vi.fn().mockReturnValue({ sessions: [] }), set: vi.fn(), onDidChange: vi.fn() } as any, + // No getAccountSwitcher provided + safeSend: vi.fn(), + }); + + const handler = handlers.get('process:write')!; + const result = await handler({}, 'session-1', 'Hello'); + + // Should succeed without error — graceful degradation + expect(result).toBe(true); + expect(mockProcessManager.write).toHaveBeenCalledWith('session-1', 'Hello'); + }); + }); +}); diff --git a/src/__tests__/main/accounts/account-switcher.test.ts b/src/__tests__/main/accounts/account-switcher.test.ts new file mode 100644 index 000000000..1ab0c4dbf --- /dev/null +++ b/src/__tests__/main/accounts/account-switcher.test.ts @@ -0,0 +1,239 @@ +/** + * Tests for AccountSwitcher. + * Validates the account switch execution flow: kill → wait → reassign → respawn → notify. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AccountSwitcher } from '../../../main/accounts/account-switcher'; +import type { ProcessManager } from '../../../main/process-manager/ProcessManager'; +import type { AccountRegistry } from '../../../main/accounts/account-registry'; +import type { AccountProfile } from '../../../shared/account-types'; + +function createMockAccount(overrides: Partial = {}): AccountProfile { + return { + id: 'acct-1', + name: 'Test Account', + email: 'test@example.com', + configDir: '/home/test/.claude-test', + agentType: 'claude-code', + status: 'active', + authMethod: 'oauth', + addedAt: Date.now(), + lastUsedAt: Date.now(), + lastThrottledAt: 0, + tokenLimitPerWindow: 0, + tokenWindowMs: 5 * 60 * 60 * 1000, + isDefault: true, + autoSwitchEnabled: true, + ...overrides, + }; +} + +describe('AccountSwitcher', () => { + let switcher: AccountSwitcher; + let mockProcessManager: { + kill: ReturnType; + }; + let mockRegistry: { + get: ReturnType; + assignToSession: ReturnType; + }; + let mockSafeSend: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + mockProcessManager = { + kill: vi.fn().mockReturnValue(true), + }; + + mockRegistry = { + get: vi.fn(), + assignToSession: vi.fn(), + }; + + mockSafeSend = vi.fn(); + + switcher = new AccountSwitcher( + mockProcessManager as unknown as ProcessManager, + mockRegistry as unknown as AccountRegistry, + mockSafeSend, + ); + }); + + it('should record and retrieve last prompts', () => { + switcher.recordLastPrompt('session-1', 'Hello, world'); + + // Internal state - verified indirectly via executeSwitch sending lastPrompt + switcher.cleanupSession('session-1'); + + // After cleanup, the prompt should be gone (no way to verify directly, + // but ensures no memory leak) + }); + + it('should execute a successful switch', async () => { + const fromAccount = createMockAccount({ id: 'acct-1', name: 'Account One' }); + const toAccount = createMockAccount({ id: 'acct-2', name: 'Account Two', configDir: '/home/test/.claude-two' }); + + mockRegistry.get.mockImplementation((id: string) => { + if (id === 'acct-1') return fromAccount; + if (id === 'acct-2') return toAccount; + return null; + }); + + switcher.recordLastPrompt('session-1', 'Fix the bug'); + + const switchPromise = switcher.executeSwitch({ + sessionId: 'session-1', + fromAccountId: 'acct-1', + toAccountId: 'acct-2', + reason: 'throttled', + automatic: true, + }); + + // Advance past SWITCH_DELAY_MS (1000ms) + await vi.advanceTimersByTimeAsync(1100); + + const result = await switchPromise; + + expect(result).not.toBeNull(); + expect(result!.sessionId).toBe('session-1'); + expect(result!.fromAccountId).toBe('acct-1'); + expect(result!.toAccountId).toBe('acct-2'); + expect(result!.reason).toBe('throttled'); + expect(result!.automatic).toBe(true); + expect(result!.timestamp).toBeGreaterThan(0); + + // Verify process was killed + expect(mockProcessManager.kill).toHaveBeenCalledWith('session-1'); + + // Verify account assignment was updated + expect(mockRegistry.assignToSession).toHaveBeenCalledWith('session-1', 'acct-2'); + + // Verify switch-started notification + expect(mockSafeSend).toHaveBeenCalledWith('account:switch-started', expect.objectContaining({ + sessionId: 'session-1', + fromAccountId: 'acct-1', + toAccountId: 'acct-2', + toAccountName: 'Account Two', + })); + + // Verify switch-respawn notification with lastPrompt + expect(mockSafeSend).toHaveBeenCalledWith('account:switch-respawn', expect.objectContaining({ + sessionId: 'session-1', + toAccountId: 'acct-2', + toAccountName: 'Account Two', + configDir: '/home/test/.claude-two', + lastPrompt: 'Fix the bug', + reason: 'throttled', + })); + + // Verify switch-completed notification + expect(mockSafeSend).toHaveBeenCalledWith('account:switch-completed', expect.objectContaining({ + sessionId: 'session-1', + fromAccountId: 'acct-1', + toAccountId: 'acct-2', + fromAccountName: 'Account One', + toAccountName: 'Account Two', + })); + }); + + it('should return null when target account is not found', async () => { + mockRegistry.get.mockReturnValue(null); + + const result = await switcher.executeSwitch({ + sessionId: 'session-1', + fromAccountId: 'acct-1', + toAccountId: 'acct-nonexistent', + reason: 'throttled', + automatic: true, + }); + + expect(result).toBeNull(); + expect(mockProcessManager.kill).not.toHaveBeenCalled(); + }); + + it('should continue even if process kill fails', async () => { + const toAccount = createMockAccount({ id: 'acct-2', name: 'Account Two', configDir: '/home/test/.claude-two' }); + mockRegistry.get.mockImplementation((id: string) => { + if (id === 'acct-2') return toAccount; + return null; + }); + mockProcessManager.kill.mockReturnValue(false); + + const switchPromise = switcher.executeSwitch({ + sessionId: 'session-1', + fromAccountId: 'acct-1', + toAccountId: 'acct-2', + reason: 'throttled', + automatic: false, + }); + + await vi.advanceTimersByTimeAsync(1100); + const result = await switchPromise; + + expect(result).not.toBeNull(); + expect(mockProcessManager.kill).toHaveBeenCalledWith('session-1'); + expect(mockRegistry.assignToSession).toHaveBeenCalledWith('session-1', 'acct-2'); + }); + + it('should send null lastPrompt when no prompt was recorded', async () => { + const toAccount = createMockAccount({ id: 'acct-2', name: 'Account Two', configDir: '/home/test/.claude-two' }); + mockRegistry.get.mockImplementation((id: string) => { + if (id === 'acct-2') return toAccount; + return null; + }); + + const switchPromise = switcher.executeSwitch({ + sessionId: 'session-1', + fromAccountId: 'acct-1', + toAccountId: 'acct-2', + reason: 'manual', + automatic: false, + }); + + await vi.advanceTimersByTimeAsync(1100); + await switchPromise; + + expect(mockSafeSend).toHaveBeenCalledWith('account:switch-respawn', expect.objectContaining({ + lastPrompt: null, + })); + }); + + it('should send switch-failed notification on error', async () => { + mockRegistry.get.mockImplementation((id: string) => { + if (id === 'acct-2') return createMockAccount({ id: 'acct-2' }); + return null; + }); + mockProcessManager.kill.mockImplementation(() => { + throw new Error('Kill failed'); + }); + + const result = await switcher.executeSwitch({ + sessionId: 'session-1', + fromAccountId: 'acct-1', + toAccountId: 'acct-2', + reason: 'throttled', + automatic: true, + }); + + expect(result).toBeNull(); + expect(mockSafeSend).toHaveBeenCalledWith('account:switch-failed', expect.objectContaining({ + sessionId: 'session-1', + fromAccountId: 'acct-1', + toAccountId: 'acct-2', + error: expect.stringContaining('Kill failed'), + })); + }); + + it('should clean up session tracking data', () => { + switcher.recordLastPrompt('session-1', 'Some prompt'); + switcher.recordLastPrompt('session-2', 'Another prompt'); + + switcher.cleanupSession('session-1'); + + // session-2 should still be tracked (verified indirectly) + // session-1 should be cleaned up + }); +}); diff --git a/src/__tests__/main/accounts/account-throttle-handler.test.ts b/src/__tests__/main/accounts/account-throttle-handler.test.ts new file mode 100644 index 000000000..5502c3cb1 --- /dev/null +++ b/src/__tests__/main/accounts/account-throttle-handler.test.ts @@ -0,0 +1,264 @@ +/** + * Tests for AccountThrottleHandler. + * Validates throttle detection, stats recording, account status updates, + * and switch recommendation logic. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AccountThrottleHandler } from '../../../main/accounts/account-throttle-handler'; +import type { AccountRegistry } from '../../../main/accounts/account-registry'; +import type { StatsDB } from '../../../main/stats'; +import type { AccountProfile, AccountSwitchConfig } from '../../../shared/account-types'; + +function createMockAccount(overrides: Partial = {}): AccountProfile { + return { + id: 'acct-1', + name: 'Test Account', + email: 'test@example.com', + configDir: '/home/test/.claude-test', + agentType: 'claude-code', + status: 'active', + authMethod: 'oauth', + addedAt: Date.now(), + lastUsedAt: Date.now(), + lastThrottledAt: 0, + tokenLimitPerWindow: 0, + tokenWindowMs: 5 * 60 * 60 * 1000, + isDefault: true, + autoSwitchEnabled: true, + ...overrides, + }; +} + +function createMockSwitchConfig(overrides: Partial = {}): AccountSwitchConfig { + return { + enabled: true, + promptBeforeSwitch: true, + autoSwitchThresholdPercent: 90, + warningThresholdPercent: 75, + selectionStrategy: 'round-robin', + ...overrides, + }; +} + +describe('AccountThrottleHandler', () => { + let handler: AccountThrottleHandler; + let mockRegistry: { + get: ReturnType; + setStatus: ReturnType; + getSwitchConfig: ReturnType; + selectNextAccount: ReturnType; + getAssignment: ReturnType; + }; + let mockStatsDb: { + isReady: ReturnType; + getAccountUsageInWindow: ReturnType; + insertThrottleEvent: ReturnType; + }; + let mockSafeSend: ReturnType; + let mockLogger: { + info: ReturnType; + error: ReturnType; + warn: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRegistry = { + get: vi.fn(), + setStatus: vi.fn(), + getSwitchConfig: vi.fn().mockReturnValue(createMockSwitchConfig()), + selectNextAccount: vi.fn(), + getAssignment: vi.fn(), + }; + + mockStatsDb = { + isReady: vi.fn().mockReturnValue(true), + getAccountUsageInWindow: vi.fn().mockReturnValue({ + inputTokens: 50000, + outputTokens: 20000, + cacheReadTokens: 10000, + cacheCreationTokens: 5000, + costUsd: 1.5, + queryCount: 10, + }), + insertThrottleEvent: vi.fn().mockReturnValue('event-1'), + }; + + mockSafeSend = vi.fn(); + mockLogger = { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }; + + handler = new AccountThrottleHandler( + mockRegistry as unknown as AccountRegistry, + () => mockStatsDb as unknown as StatsDB, + mockSafeSend, + mockLogger, + ); + }); + + it('should record throttle event and mark account as throttled', () => { + const account = createMockAccount(); + mockRegistry.get.mockReturnValue(account); + mockRegistry.getSwitchConfig.mockReturnValue(createMockSwitchConfig({ enabled: false })); + + handler.handleThrottle({ + sessionId: 'session-1', + accountId: 'acct-1', + errorType: 'rate_limited', + errorMessage: 'Too many requests', + }); + + expect(mockStatsDb.insertThrottleEvent).toHaveBeenCalledWith( + 'acct-1', 'session-1', 'rate_limited', + 85000, // 50000 + 20000 + 10000 + 5000 + expect.any(Number), expect.any(Number) + ); + + expect(mockRegistry.setStatus).toHaveBeenCalledWith('acct-1', 'throttled'); + }); + + it('should send throttled notification when auto-switch is disabled', () => { + const account = createMockAccount(); + mockRegistry.get.mockReturnValue(account); + mockRegistry.getSwitchConfig.mockReturnValue(createMockSwitchConfig({ enabled: false })); + + handler.handleThrottle({ + sessionId: 'session-1', + accountId: 'acct-1', + errorType: 'rate_limited', + errorMessage: 'Too many requests', + }); + + expect(mockSafeSend).toHaveBeenCalledWith('account:throttled', expect.objectContaining({ + accountId: 'acct-1', + accountName: 'Test Account', + autoSwitchAvailable: false, + })); + }); + + it('should send throttled notification with noAlternatives when no accounts available', () => { + const account = createMockAccount(); + mockRegistry.get.mockReturnValue(account); + mockRegistry.getSwitchConfig.mockReturnValue(createMockSwitchConfig({ enabled: true })); + mockRegistry.selectNextAccount.mockReturnValue(null); + + handler.handleThrottle({ + sessionId: 'session-1', + accountId: 'acct-1', + errorType: 'rate_limited', + errorMessage: 'Too many requests', + }); + + expect(mockSafeSend).toHaveBeenCalledWith('account:throttled', expect.objectContaining({ + accountId: 'acct-1', + noAlternatives: true, + })); + }); + + it('should send switch-prompt when promptBeforeSwitch is true', () => { + const account = createMockAccount(); + const nextAccount = createMockAccount({ id: 'acct-2', name: 'Second Account' }); + mockRegistry.get.mockReturnValue(account); + mockRegistry.getSwitchConfig.mockReturnValue(createMockSwitchConfig({ + enabled: true, + promptBeforeSwitch: true, + })); + mockRegistry.selectNextAccount.mockReturnValue(nextAccount); + + handler.handleThrottle({ + sessionId: 'session-1', + accountId: 'acct-1', + errorType: 'rate_limited', + errorMessage: 'Too many requests', + }); + + expect(mockSafeSend).toHaveBeenCalledWith('account:switch-prompt', expect.objectContaining({ + sessionId: 'session-1', + fromAccountId: 'acct-1', + fromAccountName: 'Test Account', + toAccountId: 'acct-2', + toAccountName: 'Second Account', + reason: 'rate_limited', + })); + }); + + it('should send switch-execute when promptBeforeSwitch is false', () => { + const account = createMockAccount(); + const nextAccount = createMockAccount({ id: 'acct-2', name: 'Second Account' }); + mockRegistry.get.mockReturnValue(account); + mockRegistry.getSwitchConfig.mockReturnValue(createMockSwitchConfig({ + enabled: true, + promptBeforeSwitch: false, + })); + mockRegistry.selectNextAccount.mockReturnValue(nextAccount); + + handler.handleThrottle({ + sessionId: 'session-1', + accountId: 'acct-1', + errorType: 'rate_limited', + errorMessage: 'Too many requests', + }); + + expect(mockSafeSend).toHaveBeenCalledWith('account:switch-execute', expect.objectContaining({ + sessionId: 'session-1', + fromAccountId: 'acct-1', + toAccountId: 'acct-2', + automatic: true, + })); + }); + + it('should skip if account not found', () => { + mockRegistry.get.mockReturnValue(null); + + handler.handleThrottle({ + sessionId: 'session-1', + accountId: 'acct-unknown', + errorType: 'rate_limited', + errorMessage: 'Too many requests', + }); + + expect(mockStatsDb.insertThrottleEvent).not.toHaveBeenCalled(); + expect(mockRegistry.setStatus).not.toHaveBeenCalled(); + }); + + it('should skip if stats DB is not ready', () => { + const account = createMockAccount(); + mockRegistry.get.mockReturnValue(account); + mockStatsDb.isReady.mockReturnValue(false); + + handler.handleThrottle({ + sessionId: 'session-1', + accountId: 'acct-1', + errorType: 'rate_limited', + errorMessage: 'Too many requests', + }); + + expect(mockStatsDb.insertThrottleEvent).not.toHaveBeenCalled(); + expect(mockRegistry.setStatus).not.toHaveBeenCalled(); + }); + + it('should catch and log errors without throwing', () => { + mockRegistry.get.mockImplementation(() => { + throw new Error('Test error'); + }); + + expect(() => { + handler.handleThrottle({ + sessionId: 'session-1', + accountId: 'acct-1', + errorType: 'rate_limited', + errorMessage: 'Too many requests', + }); + }).not.toThrow(); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to handle throttle', 'account-throttle', + expect.objectContaining({ error: 'Error: Test error' }) + ); + }); +}); diff --git a/src/__tests__/main/ipc/handlers/accounts.test.ts b/src/__tests__/main/ipc/handlers/accounts.test.ts new file mode 100644 index 000000000..f5481276d --- /dev/null +++ b/src/__tests__/main/ipc/handlers/accounts.test.ts @@ -0,0 +1,186 @@ +/** + * Tests for the Account IPC handlers + * + * Focused on the accounts:check-recovery handler + * which allows manual triggering of recovery polls. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ipcMain } from 'electron'; +import { registerAccountHandlers } from '../../../../main/ipc/handlers/accounts'; + +// Mock electron's ipcMain +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn(), + }, +})); + +// Mock the stats module +vi.mock('../../../../main/stats', () => ({ + getStatsDB: vi.fn(() => ({ + isReady: () => false, + getAccountUsageInWindow: vi.fn(), + getThrottleEvents: vi.fn().mockReturnValue([]), + insertThrottleEvent: vi.fn(), + })), +})); + +// Mock the logger +vi.mock('../../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock account-setup module +vi.mock('../../../../main/accounts/account-setup', () => ({ + validateBaseClaudeDir: vi.fn(), + discoverExistingAccounts: vi.fn(), + createAccountDirectory: vi.fn(), + validateAccountSymlinks: vi.fn(), + repairAccountSymlinks: vi.fn(), + readAccountEmail: vi.fn(), + buildLoginCommand: vi.fn(), + removeAccountDirectory: vi.fn(), + validateRemoteAccountDir: vi.fn(), + syncCredentialsFromBase: vi.fn(), +})); + +describe('accounts IPC handlers', () => { + let handlers: Map; + let mockRecoveryPoller: { poll: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + handlers = new Map(); + + // Capture registered handlers + vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Function) => { + handlers.set(channel, handler); + return undefined as any; + }); + + mockRecoveryPoller = { + poll: vi.fn().mockReturnValue(['account-1', 'account-2']), + }; + + registerAccountHandlers({ + getAccountRegistry: () => ({ + getAll: vi.fn().mockReturnValue([]), + get: vi.fn(), + add: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + setStatus: vi.fn(), + getDefaultAccount: vi.fn(), + selectNextAccount: vi.fn(), + getSwitchConfig: vi.fn().mockReturnValue({ enabled: false }), + updateSwitchConfig: vi.fn(), + assignToSession: vi.fn(), + getAssignment: vi.fn(), + getAllAssignments: vi.fn().mockReturnValue([]), + removeAssignment: vi.fn(), + reconcileAssignments: vi.fn().mockReturnValue(0), + } as any), + getRecoveryPoller: () => mockRecoveryPoller as any, + }); + }); + + describe('accounts:check-recovery', () => { + it('registers the handler', () => { + expect(handlers.has('accounts:check-recovery')).toBe(true); + }); + + it('returns recovered account IDs from poller', async () => { + const handler = handlers.get('accounts:check-recovery')!; + const result = await handler({}); + + expect(mockRecoveryPoller.poll).toHaveBeenCalledTimes(1); + expect(result).toEqual({ recovered: ['account-1', 'account-2'] }); + }); + + it('returns empty array when no accounts recovered', async () => { + mockRecoveryPoller.poll.mockReturnValue([]); + const handler = handlers.get('accounts:check-recovery')!; + const result = await handler({}); + + expect(result).toEqual({ recovered: [] }); + }); + + it('returns empty array when poller is not available', async () => { + // Re-register without poller + handlers.clear(); + registerAccountHandlers({ + getAccountRegistry: () => ({ + getAll: vi.fn().mockReturnValue([]), + get: vi.fn(), + add: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + setStatus: vi.fn(), + getDefaultAccount: vi.fn(), + selectNextAccount: vi.fn(), + getSwitchConfig: vi.fn().mockReturnValue({ enabled: false }), + updateSwitchConfig: vi.fn(), + assignToSession: vi.fn(), + getAssignment: vi.fn(), + getAllAssignments: vi.fn().mockReturnValue([]), + removeAssignment: vi.fn(), + reconcileAssignments: vi.fn().mockReturnValue(0), + } as any), + // No getRecoveryPoller provided + }); + + const handler = handlers.get('accounts:check-recovery')!; + const result = await handler({}); + + expect(result).toEqual({ recovered: [] }); + }); + + it('returns empty array when getRecoveryPoller returns null', async () => { + // Re-register with poller returning null + handlers.clear(); + registerAccountHandlers({ + getAccountRegistry: () => ({ + getAll: vi.fn().mockReturnValue([]), + get: vi.fn(), + add: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + setStatus: vi.fn(), + getDefaultAccount: vi.fn(), + selectNextAccount: vi.fn(), + getSwitchConfig: vi.fn().mockReturnValue({ enabled: false }), + updateSwitchConfig: vi.fn(), + assignToSession: vi.fn(), + getAssignment: vi.fn(), + getAllAssignments: vi.fn().mockReturnValue([]), + removeAssignment: vi.fn(), + reconcileAssignments: vi.fn().mockReturnValue(0), + } as any), + getRecoveryPoller: () => null, + }); + + const handler = handlers.get('accounts:check-recovery')!; + const result = await handler({}); + + expect(result).toEqual({ recovered: [] }); + }); + + it('handles errors gracefully', async () => { + mockRecoveryPoller.poll.mockImplementation(() => { + throw new Error('Poll failed'); + }); + + const handler = handlers.get('accounts:check-recovery')!; + const result = await handler({}); + + expect(result).toEqual({ recovered: [] }); + }); + }); +}); diff --git a/src/__tests__/main/stats/paths.test.ts b/src/__tests__/main/stats/paths.test.ts index 6e94cfc6d..147e7b3c7 100644 --- a/src/__tests__/main/stats/paths.test.ts +++ b/src/__tests__/main/stats/paths.test.ts @@ -714,7 +714,7 @@ describe('File path normalization in database (forward slashes consistently)', ( }); // Verify that the statement was called with normalized path - // insertQueryEvent now has 9 parameters: id, sessionId, agentType, source, startTime, duration, projectPath, tabId, isRemote + // insertQueryEvent now has 15 parameters: id, sessionId, agentType, source, startTime, duration, projectPath, tabId, isRemote, accountId, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, costUsd expect(mockStatement.run).toHaveBeenCalledWith( expect.any(String), // id 'session-1', @@ -724,7 +724,13 @@ describe('File path normalization in database (forward slashes consistently)', ( 5000, 'C:/Users/TestUser/Projects/MyApp', // normalized path 'tab-1', - null // isRemote (undefined → null) + null, // isRemote (undefined → null) + null, // accountId + 0, // inputTokens + 0, // outputTokens + 0, // cacheReadTokens + 0, // cacheCreationTokens + 0 // costUsd ); }); @@ -743,7 +749,7 @@ describe('File path normalization in database (forward slashes consistently)', ( tabId: 'tab-1', }); - // insertQueryEvent now has 9 parameters including isRemote + // insertQueryEvent now has 15 parameters including isRemote and account usage fields expect(mockStatement.run).toHaveBeenCalledWith( expect.any(String), 'session-1', @@ -753,7 +759,13 @@ describe('File path normalization in database (forward slashes consistently)', ( 5000, '/Users/testuser/Projects/MyApp', // unchanged 'tab-1', - null // isRemote (undefined → null) + null, // isRemote (undefined → null) + null, // accountId + 0, // inputTokens + 0, // outputTokens + 0, // cacheReadTokens + 0, // cacheCreationTokens + 0 // costUsd ); }); @@ -771,7 +783,7 @@ describe('File path normalization in database (forward slashes consistently)', ( // projectPath is undefined }); - // insertQueryEvent now has 9 parameters including isRemote + // insertQueryEvent now has 15 parameters including isRemote and account usage fields expect(mockStatement.run).toHaveBeenCalledWith( expect.any(String), 'session-1', @@ -781,7 +793,13 @@ describe('File path normalization in database (forward slashes consistently)', ( 5000, null, // undefined becomes null null, // tabId undefined → null - null // isRemote undefined → null + null, // isRemote undefined → null + null, // accountId + 0, // inputTokens + 0, // outputTokens + 0, // cacheReadTokens + 0, // cacheCreationTokens + 0 // costUsd ); }); }); diff --git a/src/__tests__/main/stats/stats-db.test.ts b/src/__tests__/main/stats/stats-db.test.ts index 237184014..ac1daddc9 100644 --- a/src/__tests__/main/stats/stats-db.test.ts +++ b/src/__tests__/main/stats/stats-db.test.ts @@ -284,13 +284,13 @@ describe('StatsDB class (mocked)', () => { const db = new StatsDB(); db.initialize(); - // Currently we have version 3 migration (v1: initial schema, v2: is_remote column, v3: session_lifecycle table) - expect(db.getTargetVersion()).toBe(3); + // Currently we have version 4 migration (v1: initial schema, v2: is_remote column, v3: session_lifecycle table, v4: account usage tracking) + expect(db.getTargetVersion()).toBe(4); }); it('should return false from hasPendingMigrations() when up to date', async () => { mockDb.pragma.mockImplementation((sql: string) => { - if (sql === 'user_version') return [{ user_version: 3 }]; + if (sql === 'user_version') return [{ user_version: 4 }]; return undefined; }); @@ -305,8 +305,8 @@ describe('StatsDB class (mocked)', () => { // This test verifies the hasPendingMigrations() logic // by checking current version < target version - // Simulate a database that's already at version 3 (target version) - let currentVersion = 3; + // Simulate a database that's already at version 4 (target version) + let currentVersion = 4; mockDb.pragma.mockImplementation((sql: string) => { if (sql === 'user_version') return [{ user_version: currentVersion }]; // Handle version updates from migration @@ -320,9 +320,9 @@ describe('StatsDB class (mocked)', () => { const db = new StatsDB(); db.initialize(); - // At version 3, target is 3, so no pending migrations - expect(db.getCurrentVersion()).toBe(3); - expect(db.getTargetVersion()).toBe(3); + // At version 4, target is 4, so no pending migrations + expect(db.getCurrentVersion()).toBe(4); + expect(db.getTargetVersion()).toBe(4); expect(db.hasPendingMigrations()).toBe(false); }); diff --git a/src/__tests__/main/stores/instances.test.ts b/src/__tests__/main/stores/instances.test.ts index 212d0e150..8179dd90e 100644 --- a/src/__tests__/main/stores/instances.test.ts +++ b/src/__tests__/main/stores/instances.test.ts @@ -60,8 +60,8 @@ describe('stores/instances', () => { it('should initialize all stores', () => { const result = initializeStores({ productionDataPath: '/mock/production/path' }); - // Should create 8 stores - expect(mockStoreConstructorCalls).toHaveLength(8); + // Should create 9 stores + expect(mockStoreConstructorCalls).toHaveLength(9); // Should return syncPath and bootstrapStore expect(result.syncPath).toBe('/mock/user/data'); @@ -167,6 +167,7 @@ describe('stores/instances', () => { expect(instances.windowStateStore).toBeDefined(); expect(instances.claudeSessionOriginsStore).toBeDefined(); expect(instances.agentSessionOriginsStore).toBeDefined(); + expect(instances.accountStore).toBeDefined(); }); }); diff --git a/src/__tests__/renderer/components/AccountSelector.test.tsx b/src/__tests__/renderer/components/AccountSelector.test.tsx new file mode 100644 index 000000000..e3bde6e38 --- /dev/null +++ b/src/__tests__/renderer/components/AccountSelector.test.tsx @@ -0,0 +1,434 @@ +/** + * @file AccountSelector.test.tsx + * @description Tests for AccountSelector component + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import type { Theme } from '../../../shared/theme-types'; +import type { AccountProfile } from '../../../shared/account-types'; + +// Mock settingsStore to enable virtuosos feature flag +vi.mock('../../../renderer/stores/settingsStore', () => ({ + useSettingsStore: (selector: any) => selector({ encoreFeatures: { virtuosos: true } }), +})); + +// Mock useAccountUsage before importing the component +vi.mock('../../../renderer/hooks/useAccountUsage', () => ({ + useAccountUsage: vi.fn().mockReturnValue({ + metrics: {}, + loading: false, + refresh: vi.fn(), + }), + formatTimeRemaining: vi.fn((ms: number) => ms > 0 ? '1h 30m' : '—'), + formatTokenCount: vi.fn((tokens: number) => { + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; + if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K`; + return String(tokens); + }), +})); + +import { AccountSelector } from '../../../renderer/components/AccountSelector'; +import { useAccountUsage } from '../../../renderer/hooks/useAccountUsage'; + +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#44475a', + border: '#6272a4', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentDim: '#bd93f920', + accentText: '#bd93f9', + accentForeground: '#ffffff', + success: '#50fa7b', + warning: '#f1fa8c', + error: '#ff5555', + }, +}; + +const mockAccounts: AccountProfile[] = [ + { + id: 'acc-1', + name: 'work-account', + email: 'work@example.com', + configDir: '/home/user/.claude-work', + isDefault: true, + status: 'active', + autoSwitchEnabled: true, + tokenLimitPerWindow: 19000, + tokenWindowMs: 5 * 60 * 60 * 1000, + createdAt: Date.now(), + }, + { + id: 'acc-2', + name: 'personal-account', + email: 'personal@example.com', + configDir: '/home/user/.claude-personal', + isDefault: false, + status: 'active', + autoSwitchEnabled: false, + tokenLimitPerWindow: 88000, + tokenWindowMs: 5 * 60 * 60 * 1000, + createdAt: Date.now(), + }, + { + id: 'acc-3', + name: 'team-account', + email: 'team@example.com', + configDir: '/home/user/.claude-team', + isDefault: false, + status: 'throttled', + autoSwitchEnabled: true, + tokenLimitPerWindow: 220000, + tokenWindowMs: 5 * 60 * 60 * 1000, + createdAt: Date.now(), + }, +]; + +describe('AccountSelector', () => { + let onSwitchAccount: ReturnType; + let onManageAccounts: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + onSwitchAccount = vi.fn(); + onManageAccounts = vi.fn(); + vi.mocked(window.maestro.accounts.list).mockResolvedValue(mockAccounts); + vi.mocked(useAccountUsage).mockReturnValue({ + metrics: {}, + loading: false, + refresh: vi.fn(), + }); + }); + + it('should export the component', async () => { + const mod = await import('../../../renderer/components/AccountSelector'); + expect(mod.AccountSelector).toBeDefined(); + expect(typeof mod.AccountSelector).toBe('function'); + }); + + it('should render compact mode with abbreviated account name', async () => { + await act(async () => { + render( + + ); + }); + + // After accounts load, displayName becomes account.name ("work-account") + // split('@')[0] on "work-account" yields "work-account" + await waitFor(() => { + expect(screen.getByText('work-account')).toBeInTheDocument(); + }); + }); + + it('should show dropdown with all accounts on click', async () => { + await act(async () => { + render( + + ); + }); + + // Wait for accounts to load + await waitFor(() => { + expect(window.maestro.accounts.list).toHaveBeenCalled(); + }); + + // Click the selector button to open dropdown + const trigger = screen.getByRole('button'); + await act(async () => { + fireEvent.click(trigger); + }); + + // All 3 accounts should be visible in the dropdown + // work-account may appear in both trigger and dropdown, so use getAllByText + await waitFor(() => { + expect(screen.getAllByText('work-account').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('personal-account')).toBeInTheDocument(); + expect(screen.getByText('team-account')).toBeInTheDocument(); + }); + }); + + it('should show usage bars with correct theme colors', async () => { + vi.mocked(useAccountUsage).mockReturnValue({ + metrics: { + 'acc-1': { + accountId: 'acc-1', + totalTokens: 9500, + limitTokens: 19000, + usagePercent: 50, + costUsd: 1.50, + queryCount: 10, + windowStart: Date.now() - 1000000, + windowEnd: Date.now() + 1000000, + timeRemainingMs: 1000000, + burnRatePerHour: 5000, + estimatedTimeToLimitMs: 2000000, + status: 'active', + prediction: { + linearTimeToLimitMs: null, + weightedTimeToLimitMs: null, + p90TokensPerWindow: 0, + avgTokensPerWindow: 0, + confidence: 'low', + windowsRemainingP90: null, + }, + }, + 'acc-2': { + accountId: 'acc-2', + totalTokens: 74800, + limitTokens: 88000, + usagePercent: 85, + costUsd: 12.00, + queryCount: 50, + windowStart: Date.now() - 1000000, + windowEnd: Date.now() + 1000000, + timeRemainingMs: 1000000, + burnRatePerHour: 40000, + estimatedTimeToLimitMs: 500000, + status: 'active', + prediction: { + linearTimeToLimitMs: null, + weightedTimeToLimitMs: null, + p90TokensPerWindow: 0, + avgTokensPerWindow: 0, + confidence: 'low', + windowsRemainingP90: null, + }, + }, + 'acc-3': { + accountId: 'acc-3', + totalTokens: 211200, + limitTokens: 220000, + usagePercent: 96, + costUsd: 30.00, + queryCount: 100, + windowStart: Date.now() - 1000000, + windowEnd: Date.now() + 1000000, + timeRemainingMs: 1000000, + burnRatePerHour: 100000, + estimatedTimeToLimitMs: 100000, + status: 'throttled', + prediction: { + linearTimeToLimitMs: null, + weightedTimeToLimitMs: null, + p90TokensPerWindow: 0, + avgTokensPerWindow: 0, + confidence: 'low', + windowsRemainingP90: null, + }, + }, + }, + loading: false, + refresh: vi.fn(), + }); + + await act(async () => { + render( + + ); + }); + + await waitFor(() => { + expect(window.maestro.accounts.list).toHaveBeenCalled(); + }); + + // Open dropdown + const trigger = screen.getByRole('button'); + await act(async () => { + fireEvent.click(trigger); + }); + + // Verify accounts are rendered with usage bars + // work-account may appear in both trigger and dropdown, so use getAllByText + await waitFor(() => { + expect(screen.getAllByText('work-account').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('personal-account')).toBeInTheDocument(); + expect(screen.getByText('team-account')).toBeInTheDocument(); + }); + }); + + it('should call onSwitchAccount when different account selected', async () => { + await act(async () => { + render( + + ); + }); + + await waitFor(() => { + expect(window.maestro.accounts.list).toHaveBeenCalled(); + }); + + // Open dropdown + const trigger = screen.getByRole('button'); + await act(async () => { + fireEvent.click(trigger); + }); + + // Click account B + await waitFor(() => { + expect(screen.getByText('personal-account')).toBeInTheDocument(); + }); + await act(async () => { + fireEvent.click(screen.getByText('personal-account')); + }); + + expect(onSwitchAccount).toHaveBeenCalledWith('acc-2'); + }); + + it('should show "Manage Virtuosos" footer linking to VirtuososModal', async () => { + await act(async () => { + render( + + ); + }); + + await waitFor(() => { + expect(window.maestro.accounts.list).toHaveBeenCalled(); + }); + + // Open dropdown + const trigger = screen.getByRole('button'); + await act(async () => { + fireEvent.click(trigger); + }); + + // Assert "Manage Virtuosos" item present + await waitFor(() => { + expect(screen.getByText('Manage Virtuosos')).toBeInTheDocument(); + }); + + // Click it + await act(async () => { + fireEvent.click(screen.getByText('Manage Virtuosos')); + }); + + expect(onManageAccounts).toHaveBeenCalled(); + }); + + it('should close dropdown on Escape key', async () => { + await act(async () => { + render( + + ); + }); + + await waitFor(() => { + expect(window.maestro.accounts.list).toHaveBeenCalled(); + }); + + // Open dropdown + const trigger = screen.getByRole('button'); + await act(async () => { + fireEvent.click(trigger); + }); + + // Verify dropdown is open + await waitFor(() => { + expect(screen.getByText('Manage Virtuosos')).toBeInTheDocument(); + }); + + // Press Escape + await act(async () => { + fireEvent.keyDown(document, { key: 'Escape' }); + }); + + // Dropdown should be closed + await waitFor(() => { + expect(screen.queryByText('Manage Virtuosos')).not.toBeInTheDocument(); + }); + }); + + it('should close dropdown on click outside', async () => { + await act(async () => { + render( +
+
Outside area
+ +
+ ); + }); + + await waitFor(() => { + expect(window.maestro.accounts.list).toHaveBeenCalled(); + }); + + // Open dropdown + const triggers = screen.getAllByRole('button'); + const selectorTrigger = triggers.find( + (btn) => btn.textContent?.includes('work') + ) ?? triggers[0]; + await act(async () => { + fireEvent.click(selectorTrigger); + }); + + // Verify dropdown is open + await waitFor(() => { + expect(screen.getByText('Manage Virtuosos')).toBeInTheDocument(); + }); + + // Click outside + await act(async () => { + fireEvent.mouseDown(screen.getByTestId('outside')); + }); + + // Dropdown should be closed + await waitFor(() => { + expect(screen.queryByText('Manage Virtuosos')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/renderer/components/AccountSwitchModal.test.tsx b/src/__tests__/renderer/components/AccountSwitchModal.test.tsx new file mode 100644 index 000000000..670607ee7 --- /dev/null +++ b/src/__tests__/renderer/components/AccountSwitchModal.test.tsx @@ -0,0 +1,210 @@ +/** + * @file AccountSwitchModal.test.tsx + * @description Tests for AccountSwitchModal component + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { LayerStackProvider } from '../../../renderer/contexts/LayerStackContext'; +import { AccountSwitchModal } from '../../../renderer/components/AccountSwitchModal'; +import type { Theme } from '../../../shared/theme-types'; + +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#44475a', + border: '#6272a4', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentDim: '#bd93f920', + accentText: '#bd93f9', + accentForeground: '#ffffff', + success: '#50fa7b', + warning: '#f1fa8c', + error: '#ff5555', + }, +}; + +const renderWithLayerStack = (ui: React.ReactElement) => { + return render({ui}); +}; + +const baseSwitchData = { + sessionId: 'session-1', + fromAccountId: 'acc-1', + fromAccountName: 'Account 1', + toAccountId: 'acc-2', + toAccountName: 'Account 2', + reason: 'throttled', +}; + +describe('AccountSwitchModal', () => { + let onClose: ReturnType; + let onConfirmSwitch: ReturnType; + let onViewDashboard: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + onClose = vi.fn(); + onConfirmSwitch = vi.fn(); + onViewDashboard = vi.fn(); + }); + + it('should export the component', async () => { + const mod = await import('../../../renderer/components/AccountSwitchModal'); + expect(mod.AccountSwitchModal).toBeDefined(); + expect(typeof mod.AccountSwitchModal).toBe('function'); + }); + + it('should return null when isOpen is false', () => { + const { container } = renderWithLayerStack( + + ); + expect(container.innerHTML).toBe(''); + }); + + it('should display throttle reason with warning styling', () => { + renderWithLayerStack( + + ); + + // Header should show the throttled title + expect(screen.getByText('Virtuoso Throttled')).toBeInTheDocument(); + // Description should mention rate limiting + expect(screen.getByText('Virtuoso Account 1 has been rate limited')).toBeInTheDocument(); + }); + + it('should display auth-expired reason with error styling', () => { + renderWithLayerStack( + + ); + + expect(screen.getByText('Authentication Expired')).toBeInTheDocument(); + expect(screen.getByText('Virtuoso Account 1 authentication has expired')).toBeInTheDocument(); + }); + + it('should call onConfirmSwitch on "Switch Virtuoso" click', async () => { + renderWithLayerStack( + + ); + + const switchButton = screen.getByText('Switch Virtuoso'); + await act(async () => { + fireEvent.click(switchButton); + }); + + expect(onConfirmSwitch).toHaveBeenCalledTimes(1); + }); + + it('should dismiss on "Stay on Current" click', async () => { + renderWithLayerStack( + + ); + + const stayButton = screen.getByText('Stay on Current'); + await act(async () => { + fireEvent.click(stayButton); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should call onViewDashboard on "View All Virtuosos" click', async () => { + renderWithLayerStack( + + ); + + const viewButton = screen.getByText('View All Virtuosos'); + await act(async () => { + fireEvent.click(viewButton); + }); + + expect(onViewDashboard).toHaveBeenCalledTimes(1); + }); + + it('should display both account names', () => { + renderWithLayerStack( + + ); + + expect(screen.getByText('Account 1')).toBeInTheDocument(); + expect(screen.getByText('Account 2')).toBeInTheDocument(); + expect(screen.getByText('Current virtuoso')).toBeInTheDocument(); + expect(screen.getByText('Recommended switch target')).toBeInTheDocument(); + }); + + it('should display limit-approaching reason with usage percent', () => { + renderWithLayerStack( + + ); + + expect(screen.getByText('Virtuoso Limit Reached')).toBeInTheDocument(); + expect(screen.getByText('Virtuoso Account 1 is at 87% of its token limit')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/components/SessionList.test.tsx b/src/__tests__/renderer/components/SessionList.test.tsx index 4e8910ac7..c695df1b6 100644 --- a/src/__tests__/renderer/components/SessionList.test.tsx +++ b/src/__tests__/renderer/components/SessionList.test.tsx @@ -64,6 +64,7 @@ vi.mock('lucide-react', () => ({ Server: () => , Music: () => , Command: () => , + Users: () => , })); // Mock gitService diff --git a/src/__tests__/renderer/components/UsageDashboardModal.test.tsx b/src/__tests__/renderer/components/UsageDashboardModal.test.tsx index 0786a566e..04372974f 100644 --- a/src/__tests__/renderer/components/UsageDashboardModal.test.tsx +++ b/src/__tests__/renderer/components/UsageDashboardModal.test.tsx @@ -53,6 +53,10 @@ vi.mock('lucide-react', () => { // WeekdayComparisonChart icons Briefcase: createIcon('briefcase', '💼'), Coffee: createIcon('coffee', '☕'), + // AccountUsageDashboard icons + Activity: createIcon('activity', '📈'), + ArrowRightLeft: createIcon('arrow-right-left', '↔️'), + TrendingUp: createIcon('trending-up', '📈'), }; }); @@ -94,6 +98,13 @@ const mockMaestro = { fs: { writeFile: mockWriteFile, }, + accounts: { + list: vi.fn(() => Promise.resolve([])), + getAllUsage: vi.fn(() => Promise.resolve({})), + getAllAssignments: vi.fn(() => Promise.resolve([])), + getThrottleEvents: vi.fn(() => Promise.resolve([])), + onUsageUpdate: vi.fn(() => vi.fn()), + }, }; // Set up window.maestro mock @@ -216,11 +227,12 @@ describe('UsageDashboardModal', () => { await waitFor(() => { // Use getAllByRole('tab') to find tabs - there may be multiple elements with text 'Agents' const tabs = screen.getAllByRole('tab'); - expect(tabs).toHaveLength(4); + expect(tabs).toHaveLength(5); expect(tabs[0]).toHaveTextContent('Overview'); expect(tabs[1]).toHaveTextContent('Agents'); expect(tabs[2]).toHaveTextContent('Activity'); expect(tabs[3]).toHaveTextContent('Auto Run'); + expect(tabs[4]).toHaveTextContent('Virtuosos'); }); }); @@ -1541,7 +1553,7 @@ describe('UsageDashboardModal', () => { await waitFor(() => { const tabs = screen.getAllByRole('tab'); - expect(tabs).toHaveLength(4); + expect(tabs).toHaveLength(5); // First tab (Overview) should be selected expect(tabs[0]).toHaveAttribute('aria-selected', 'true'); @@ -1552,6 +1564,7 @@ describe('UsageDashboardModal', () => { expect(tabs[1]).toHaveAttribute('aria-selected', 'false'); expect(tabs[2]).toHaveAttribute('aria-selected', 'false'); expect(tabs[3]).toHaveAttribute('aria-selected', 'false'); + expect(tabs[4]).toHaveAttribute('aria-selected', 'false'); }); }); @@ -1610,12 +1623,12 @@ describe('UsageDashboardModal', () => { const tablist = screen.getByTestId('view-mode-tabs'); - // Press ArrowLeft while on first tab - should wrap to last tab (Auto Run) + // Press ArrowLeft while on first tab - should wrap to last tab (Virtuosos) fireEvent.keyDown(tablist, { key: 'ArrowLeft' }); await waitFor(() => { const tabs = screen.getAllByRole('tab'); - expect(tabs[3]).toHaveAttribute('aria-selected', 'true'); // Auto Run tab + expect(tabs[4]).toHaveAttribute('aria-selected', 'true'); // Virtuosos tab expect(tabs[0]).toHaveAttribute('aria-selected', 'false'); }); }); @@ -1629,11 +1642,11 @@ describe('UsageDashboardModal', () => { const tablist = screen.getByTestId('view-mode-tabs'); - // Navigate to last tab (Auto Run) + // Navigate to last tab (Virtuosos) fireEvent.keyDown(tablist, { key: 'ArrowLeft' }); // Wraps to last await waitFor(() => { - expect(screen.getAllByRole('tab')[3]).toHaveAttribute('aria-selected', 'true'); + expect(screen.getAllByRole('tab')[4]).toHaveAttribute('aria-selected', 'true'); }); // Press ArrowRight - should wrap to first tab (Overview) @@ -1642,7 +1655,7 @@ describe('UsageDashboardModal', () => { await waitFor(() => { const tabs = screen.getAllByRole('tab'); expect(tabs[0]).toHaveAttribute('aria-selected', 'true'); - expect(tabs[3]).toHaveAttribute('aria-selected', 'false'); + expect(tabs[4]).toHaveAttribute('aria-selected', 'false'); }); }); diff --git a/src/__tests__/renderer/components/VirtuososModal.test.tsx b/src/__tests__/renderer/components/VirtuososModal.test.tsx new file mode 100644 index 000000000..69d55d5ea --- /dev/null +++ b/src/__tests__/renderer/components/VirtuososModal.test.tsx @@ -0,0 +1,247 @@ +/** + * @fileoverview Tests for VirtuososModal component + * Tests: tab rendering, tab switching, keyboard navigation, tab state persistence + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import type { Theme } from '../../../renderer/types'; + +// Mock Modal component to avoid layer stack / hook dependencies +vi.mock('../../../renderer/components/ui/Modal', () => ({ + Modal: ({ + children, + title, + }: { + children: React.ReactNode; + title: string; + [key: string]: unknown; + }) => ( +
+ {children} +
+ ), +})); + +// Mock child components to isolate VirtuososModal tab logic +vi.mock('../../../renderer/components/AccountsPanel', () => ({ + AccountsPanel: () =>
AccountsPanel
, +})); + +vi.mock('../../../renderer/components/ProviderPanel', () => ({ + ProviderPanel: () =>
ProviderPanel
, +})); + +vi.mock('../../../renderer/components/VirtuosoUsageView', () => ({ + VirtuosoUsageView: () => ( +
VirtuosoUsageView
+ ), +})); + +// Import after mocks +import { VirtuososModal } from '../../../renderer/components/VirtuososModal'; + +const createTheme = (): Theme => ({ + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#1a1a2e', + bgSidebar: '#16213e', + bgActivity: '#0f3460', + textMain: '#e8e8e8', + textDim: '#888888', + accent: '#7b2cbf', + accentDim: '#7b2cbf40', + accentText: '#7b2cbf', + accentForeground: '#ffffff', + border: '#333355', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, +}); + +describe('VirtuososModal', () => { + const theme = createTheme(); + const onClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders nothing when isOpen is false', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders Accounts tab by default', () => { + render(); + + const accountsTab = screen.getByRole('tab', { name: /Accounts/i }); + const providersTab = screen.getByRole('tab', { name: /Providers/i }); + const usageTab = screen.getByRole('tab', { name: /Usage/i }); + + expect(accountsTab).toBeDefined(); + expect(providersTab).toBeDefined(); + expect(usageTab).toBeDefined(); + + expect(accountsTab.getAttribute('aria-selected')).toBe('true'); + expect(providersTab.getAttribute('aria-selected')).toBe('false'); + expect(usageTab.getAttribute('aria-selected')).toBe('false'); + + expect(screen.getByTestId('accounts-panel')).toBeDefined(); + }); + + it('switches to Providers tab on click', () => { + render(); + + const providersTab = screen.getByRole('tab', { name: /Providers/i }); + fireEvent.click(providersTab); + + expect(providersTab.getAttribute('aria-selected')).toBe('true'); + + const accountsTab = screen.getByRole('tab', { name: /Accounts/i }); + expect(accountsTab.getAttribute('aria-selected')).toBe('false'); + + expect(screen.getByTestId('provider-panel')).toBeDefined(); + }); + + it('switches to Usage tab on click', () => { + render(); + + const usageTab = screen.getByRole('tab', { name: /Usage/i }); + fireEvent.click(usageTab); + + expect(usageTab.getAttribute('aria-selected')).toBe('true'); + + const accountsTab = screen.getByRole('tab', { name: /Accounts/i }); + expect(accountsTab.getAttribute('aria-selected')).toBe('false'); + + expect(screen.getByTestId('virtuoso-usage-view')).toBeDefined(); + }); + + it('cycles tabs with Cmd+Shift+]', async () => { + render(); + + const accountsTab = screen.getByRole('tab', { name: /Accounts/i }); + const providersTab = screen.getByRole('tab', { name: /Providers/i }); + const usageTab = screen.getByRole('tab', { name: /Usage/i }); + + expect(accountsTab.getAttribute('aria-selected')).toBe('true'); + + // Accounts -> Providers + fireEvent.keyDown(window, { + key: ']', + metaKey: true, + shiftKey: true, + }); + + await waitFor(() => { + expect(providersTab.getAttribute('aria-selected')).toBe('true'); + }); + + // Providers -> Usage + fireEvent.keyDown(window, { + key: ']', + metaKey: true, + shiftKey: true, + }); + + await waitFor(() => { + expect(usageTab.getAttribute('aria-selected')).toBe('true'); + }); + }); + + it('cycles tabs with Cmd+Shift+[', async () => { + render(); + + const usageTab = screen.getByRole('tab', { name: /Usage/i }); + fireEvent.click(usageTab); + expect(usageTab.getAttribute('aria-selected')).toBe('true'); + + // Usage -> Providers + fireEvent.keyDown(window, { + key: '[', + metaKey: true, + shiftKey: true, + }); + + const providersTab = screen.getByRole('tab', { name: /Providers/i }); + await waitFor(() => { + expect(providersTab.getAttribute('aria-selected')).toBe('true'); + }); + + // Providers -> Accounts + fireEvent.keyDown(window, { + key: '[', + metaKey: true, + shiftKey: true, + }); + + const accountsTab = screen.getByRole('tab', { name: /Accounts/i }); + await waitFor(() => { + expect(accountsTab.getAttribute('aria-selected')).toBe('true'); + }); + }); + + it('preserves tab state when modal stays open', () => { + render(); + + const usageTab = screen.getByRole('tab', { name: /Usage/i }); + fireEvent.click(usageTab); + expect(usageTab.getAttribute('aria-selected')).toBe('true'); + + // State persists without external reset + expect(usageTab.getAttribute('aria-selected')).toBe('true'); + }); + + it('renders tablist with correct aria attributes', () => { + render(); + + const tablist = screen.getByRole('tablist'); + expect(tablist).toBeDefined(); + expect(tablist.getAttribute('aria-label')).toBe('Virtuosos view'); + }); + + it('wraps around when cycling past last tab', async () => { + render(); + + const usageTab = screen.getByRole('tab', { name: /Usage/i }); + fireEvent.click(usageTab); + + // Press ] to wrap from last to first + fireEvent.keyDown(window, { + key: ']', + metaKey: true, + shiftKey: true, + }); + + const accountsTab = screen.getByRole('tab', { name: /Accounts/i }); + await waitFor(() => { + expect(accountsTab.getAttribute('aria-selected')).toBe('true'); + }); + }); + + it('wraps around when cycling before first tab', async () => { + render(); + + // Accounts is first, press [ to wrap to Usage (last) + fireEvent.keyDown(window, { + key: '[', + metaKey: true, + shiftKey: true, + }); + + const usageTab = screen.getByRole('tab', { name: /Usage/i }); + await waitFor(() => { + expect(usageTab.getAttribute('aria-selected')).toBe('true'); + }); + }); +}); diff --git a/src/__tests__/renderer/hooks/useAccountUsage.test.ts b/src/__tests__/renderer/hooks/useAccountUsage.test.ts new file mode 100644 index 000000000..a85ef4fb2 --- /dev/null +++ b/src/__tests__/renderer/hooks/useAccountUsage.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useAccountUsage, formatTimeRemaining, formatTokenCount, calculatePrediction } from '../../../renderer/hooks/useAccountUsage'; + +describe('useAccountUsage', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns loading true initially and false after fetch', async () => { + vi.mocked(window.maestro.accounts.getAllUsage).mockResolvedValue({}); + + const { result } = renderHook(() => useAccountUsage()); + + expect(result.current.loading).toBe(true); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + }); + + it('fetches usage data on mount and calculates derived metrics', async () => { + const now = Date.now(); + vi.mocked(window.maestro.accounts.getAllUsage).mockResolvedValue({ + 'acc-1': { + totalTokens: 142000, + account: { tokenLimitPerWindow: 220000, status: 'active' }, + usagePercent: 64.5, + costUsd: 3.47, + queryCount: 28, + windowStart: now - 2 * 60 * 60 * 1000, // 2 hours ago + windowEnd: now + 3 * 60 * 60 * 1000, // 3 hours from now + }, + }); + + const { result } = renderHook(() => useAccountUsage()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const metrics = result.current.metrics['acc-1']; + expect(metrics).toBeDefined(); + expect(metrics.accountId).toBe('acc-1'); + expect(metrics.totalTokens).toBe(142000); + expect(metrics.limitTokens).toBe(220000); + expect(metrics.usagePercent).toBe(64.5); + expect(metrics.costUsd).toBe(3.47); + expect(metrics.queryCount).toBe(28); + expect(metrics.status).toBe('active'); + expect(metrics.burnRatePerHour).toBeGreaterThan(0); + expect(metrics.timeRemainingMs).toBeGreaterThan(0); + expect(metrics.estimatedTimeToLimitMs).toBeGreaterThan(0); + }); + + it('handles accounts with no limit configured', async () => { + const now = Date.now(); + vi.mocked(window.maestro.accounts.getAllUsage).mockResolvedValue({ + 'acc-no-limit': { + totalTokens: 50000, + account: { tokenLimitPerWindow: 0, status: 'active' }, + usagePercent: null, + costUsd: 1.23, + queryCount: 10, + windowStart: now - 60 * 60 * 1000, + windowEnd: now + 4 * 60 * 60 * 1000, + }, + }); + + const { result } = renderHook(() => useAccountUsage()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const metrics = result.current.metrics['acc-no-limit']; + expect(metrics.usagePercent).toBeNull(); + expect(metrics.limitTokens).toBe(0); + expect(metrics.estimatedTimeToLimitMs).toBeNull(); + }); + + it('handles zero tokens used', async () => { + const now = Date.now(); + vi.mocked(window.maestro.accounts.getAllUsage).mockResolvedValue({ + 'acc-zero': { + totalTokens: 0, + account: { tokenLimitPerWindow: 220000, status: 'active' }, + usagePercent: 0, + costUsd: 0, + queryCount: 0, + windowStart: now - 30 * 60 * 1000, + windowEnd: now + 4.5 * 60 * 60 * 1000, + }, + }); + + const { result } = renderHook(() => useAccountUsage()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const metrics = result.current.metrics['acc-zero']; + expect(metrics.totalTokens).toBe(0); + expect(metrics.burnRatePerHour).toBe(0); + // With 0 burn rate and limit configured, estimatedTimeToLimitMs should be null + expect(metrics.estimatedTimeToLimitMs).toBeNull(); + }); + + it('subscribes to real-time usage updates', async () => { + const now = Date.now(); + let capturedHandler: ((data: any) => void) | null = null; + + vi.mocked(window.maestro.accounts.onUsageUpdate).mockImplementation((handler) => { + capturedHandler = handler; + return () => {}; + }); + + vi.mocked(window.maestro.accounts.getAllUsage).mockResolvedValue({}); + + const { result } = renderHook(() => useAccountUsage()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(capturedHandler).not.toBeNull(); + + // Simulate a real-time update + act(() => { + capturedHandler!({ + accountId: 'acc-rt', + totalTokens: 5000, + limitTokens: 100000, + usagePercent: 5, + costUsd: 0.15, + queryCount: 3, + windowStart: now - 30 * 60 * 1000, + windowEnd: now + 4.5 * 60 * 60 * 1000, + }); + }); + + expect(result.current.metrics['acc-rt']).toBeDefined(); + expect(result.current.metrics['acc-rt'].totalTokens).toBe(5000); + expect(result.current.metrics['acc-rt'].usagePercent).toBe(5); + }); + + it('cleans up subscription on unmount', async () => { + const cleanup = vi.fn(); + vi.mocked(window.maestro.accounts.onUsageUpdate).mockReturnValue(cleanup); + vi.mocked(window.maestro.accounts.getAllUsage).mockResolvedValue({}); + + const { unmount } = renderHook(() => useAccountUsage()); + + await waitFor(() => { + expect(window.maestro.accounts.getAllUsage).toHaveBeenCalled(); + }); + + unmount(); + expect(cleanup).toHaveBeenCalled(); + }); + + it('handles fetch error gracefully', async () => { + vi.mocked(window.maestro.accounts.getAllUsage).mockRejectedValue(new Error('IPC error')); + + const { result } = renderHook(() => useAccountUsage()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.metrics).toEqual({}); + }); + + it('provides a refresh function', async () => { + vi.mocked(window.maestro.accounts.getAllUsage).mockResolvedValue({}); + + const { result } = renderHook(() => useAccountUsage()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(typeof result.current.refresh).toBe('function'); + }); +}); + +describe('calculatePrediction', () => { + const FIVE_HOURS_MS = 5 * 60 * 60 * 1000; + + it('returns empty prediction with no window history', () => { + const result = calculatePrediction([], 0, 100_000, FIVE_HOURS_MS); + expect(result.confidence).toBe('low'); + expect(result.linearTimeToLimitMs).toBeNull(); + expect(result.weightedTimeToLimitMs).toBeNull(); + expect(result.p90TokensPerWindow).toBe(0); + expect(result.avgTokensPerWindow).toBe(0); + expect(result.windowsRemainingP90).toBeNull(); + }); + + it('returns low confidence with fewer than 5 windows', () => { + const history = [ + { totalTokens: 10_000, windowStart: 0, windowEnd: FIVE_HOURS_MS }, + { totalTokens: 12_000, windowStart: FIVE_HOURS_MS, windowEnd: 2 * FIVE_HOURS_MS }, + ]; + const result = calculatePrediction(history, 5_000, 100_000, FIVE_HOURS_MS); + expect(result.confidence).toBe('low'); + }); + + it('returns medium confidence with 5-15 windows', () => { + const history = Array.from({ length: 8 }, (_, i) => ({ + totalTokens: 10_000 + i * 1000, + windowStart: i * FIVE_HOURS_MS, + windowEnd: (i + 1) * FIVE_HOURS_MS, + })); + const result = calculatePrediction(history, 5_000, 100_000, FIVE_HOURS_MS); + expect(result.confidence).toBe('medium'); + }); + + it('returns high confidence with more than 15 windows', () => { + const history = Array.from({ length: 20 }, (_, i) => ({ + totalTokens: 10_000, + windowStart: i * FIVE_HOURS_MS, + windowEnd: (i + 1) * FIVE_HOURS_MS, + })); + const result = calculatePrediction(history, 5_000, 100_000, FIVE_HOURS_MS); + expect(result.confidence).toBe('high'); + }); + + it('calculates correct average', () => { + const history = [ + { totalTokens: 10_000, windowStart: 0, windowEnd: FIVE_HOURS_MS }, + { totalTokens: 20_000, windowStart: FIVE_HOURS_MS, windowEnd: 2 * FIVE_HOURS_MS }, + { totalTokens: 30_000, windowStart: 2 * FIVE_HOURS_MS, windowEnd: 3 * FIVE_HOURS_MS }, + ]; + const result = calculatePrediction(history, 0, 100_000, FIVE_HOURS_MS); + expect(result.avgTokensPerWindow).toBe(20_000); + }); + + it('calculates P90 as the 90th percentile', () => { + const history = Array.from({ length: 10 }, (_, i) => ({ + totalTokens: (i + 1) * 1000, + windowStart: i * FIVE_HOURS_MS, + windowEnd: (i + 1) * FIVE_HOURS_MS, + })); + const result = calculatePrediction(history, 0, 100_000, FIVE_HOURS_MS); + // sorted: [1K, 2K, ..., 10K], p90Index = floor(10*0.9) = 9 => 10K + expect(result.p90TokensPerWindow).toBe(10_000); + }); + + it('P90 prediction is more conservative than linear', () => { + const history = [ + { totalTokens: 5_000, windowStart: 0, windowEnd: FIVE_HOURS_MS }, + { totalTokens: 5_000, windowStart: FIVE_HOURS_MS, windowEnd: 2 * FIVE_HOURS_MS }, + { totalTokens: 5_000, windowStart: 2 * FIVE_HOURS_MS, windowEnd: 3 * FIVE_HOURS_MS }, + { totalTokens: 5_000, windowStart: 3 * FIVE_HOURS_MS, windowEnd: 4 * FIVE_HOURS_MS }, + { totalTokens: 50_000, windowStart: 4 * FIVE_HOURS_MS, windowEnd: 5 * FIVE_HOURS_MS }, + ]; + const result = calculatePrediction(history, 10_000, 100_000, FIVE_HOURS_MS); + expect(result.windowsRemainingP90).not.toBeNull(); + expect(result.linearTimeToLimitMs).not.toBeNull(); + if (result.windowsRemainingP90 !== null && result.linearTimeToLimitMs !== null) { + const linearWindows = result.linearTimeToLimitMs / FIVE_HOURS_MS; + expect(result.windowsRemainingP90).toBeLessThanOrEqual(linearWindows); + } + }); + + it('returns null predictions when no limit is configured', () => { + const history = [ + { totalTokens: 10_000, windowStart: 0, windowEnd: FIVE_HOURS_MS }, + ]; + const result = calculatePrediction(history, 5_000, 0, FIVE_HOURS_MS); + expect(result.linearTimeToLimitMs).toBeNull(); + expect(result.weightedTimeToLimitMs).toBeNull(); + expect(result.windowsRemainingP90).toBeNull(); + expect(result.avgTokensPerWindow).toBe(10_000); + }); + + it('recent windows weigh more heavily in weighted average', () => { + const history = [ + { totalTokens: 1_000, windowStart: 0, windowEnd: FIVE_HOURS_MS }, + { totalTokens: 1_000, windowStart: FIVE_HOURS_MS, windowEnd: 2 * FIVE_HOURS_MS }, + { totalTokens: 1_000, windowStart: 2 * FIVE_HOURS_MS, windowEnd: 3 * FIVE_HOURS_MS }, + { totalTokens: 50_000, windowStart: 3 * FIVE_HOURS_MS, windowEnd: 4 * FIVE_HOURS_MS }, + { totalTokens: 50_000, windowStart: 4 * FIVE_HOURS_MS, windowEnd: 5 * FIVE_HOURS_MS }, + ]; + const result = calculatePrediction(history, 10_000, 200_000, FIVE_HOURS_MS); + // Weighted time should be shorter than linear (recent high usage pushes prediction down) + expect(result.weightedTimeToLimitMs).not.toBeNull(); + expect(result.linearTimeToLimitMs).not.toBeNull(); + if (result.weightedTimeToLimitMs !== null && result.linearTimeToLimitMs !== null) { + expect(result.weightedTimeToLimitMs).toBeLessThan(result.linearTimeToLimitMs); + } + }); +}); + +describe('formatTimeRemaining', () => { + it('returns "—" for zero or negative values', () => { + expect(formatTimeRemaining(0)).toBe('—'); + expect(formatTimeRemaining(-1000)).toBe('—'); + }); + + it('formats hours and minutes', () => { + expect(formatTimeRemaining(2 * 60 * 60 * 1000 + 34 * 60 * 1000)).toBe('2h 34m'); + expect(formatTimeRemaining(1 * 60 * 60 * 1000)).toBe('1h 0m'); + }); + + it('formats minutes only', () => { + expect(formatTimeRemaining(45 * 60 * 1000)).toBe('45m'); + expect(formatTimeRemaining(5 * 60 * 1000)).toBe('5m'); + }); + + it('formats sub-5-minute with seconds', () => { + expect(formatTimeRemaining(4 * 60 * 1000 + 32 * 1000)).toBe('4m 32s'); + expect(formatTimeRemaining(1 * 60 * 1000 + 15 * 1000)).toBe('1m 15s'); + }); + + it('returns "< 1m" for very small values', () => { + expect(formatTimeRemaining(30 * 1000)).toBe('< 1m'); + expect(formatTimeRemaining(500)).toBe('< 1m'); + }); +}); + +describe('formatTokenCount', () => { + it('returns raw number for small values', () => { + expect(formatTokenCount(0)).toBe('0'); + expect(formatTokenCount(856)).toBe('856'); + expect(formatTokenCount(999)).toBe('999'); + }); + + it('formats thousands with K suffix', () => { + expect(formatTokenCount(1000)).toBe('1.0K'); + expect(formatTokenCount(1500)).toBe('1.5K'); + expect(formatTokenCount(9999)).toBe('10.0K'); + }); + + it('formats tens of thousands with K suffix (no decimal)', () => { + expect(formatTokenCount(10000)).toBe('10K'); + expect(formatTokenCount(142000)).toBe('142K'); + expect(formatTokenCount(999999)).toBe('1000K'); + }); + + it('formats millions with M suffix', () => { + expect(formatTokenCount(1000000)).toBe('1.0M'); + expect(formatTokenCount(1200000)).toBe('1.2M'); + expect(formatTokenCount(15000000)).toBe('15.0M'); + }); +}); diff --git a/src/__tests__/renderer/hooks/useAgentErrorRecovery.test.ts b/src/__tests__/renderer/hooks/useAgentErrorRecovery.test.ts index 9caba0f65..0c063819f 100644 --- a/src/__tests__/renderer/hooks/useAgentErrorRecovery.test.ts +++ b/src/__tests__/renderer/hooks/useAgentErrorRecovery.test.ts @@ -29,7 +29,7 @@ describe('useAgentErrorRecovery', () => { const [authAction, newSessionAction] = result.current.recoveryActions; expect(authAction.id).toBe('authenticate'); - expect(authAction.label).toBe('Use Terminal'); + expect(authAction.label).toBe('Re-authenticate'); expect(authAction.primary).toBe(true); expect(newSessionAction.id).toBe('new-session'); diff --git a/src/__tests__/renderer/hooks/useProviderHealth.test.ts b/src/__tests__/renderer/hooks/useProviderHealth.test.ts new file mode 100644 index 000000000..4265f678b --- /dev/null +++ b/src/__tests__/renderer/hooks/useProviderHealth.test.ts @@ -0,0 +1,313 @@ +/** + * Tests for useProviderHealth hook. + * Validates health computation, status determination, and failover event subscription. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useProviderHealth } from '../../../renderer/hooks/useProviderHealth'; +import type { Session } from '../../../renderer/types'; +import type { ProviderErrorStats } from '../../../shared/account-types'; + +// ── Mock data ──────────────────────────────────────────────────────────────── + +const mockAgents = [ + { id: 'claude-code', name: 'Claude Code', available: true, hidden: false }, + { id: 'opencode', name: 'OpenCode', available: true, hidden: false }, + { id: 'codex', name: 'Codex', available: false, hidden: false }, + { id: 'terminal', name: 'Terminal', available: true, hidden: false }, +]; + +const emptyErrorStats: Record = {}; + +function createSession(id: string, toolType: string, overrides?: Partial): Session { + return { + id, + name: `Session ${id}`, + toolType: toolType as any, + state: 'idle', + cwd: '/test', + fullPath: '/test', + projectRoot: '/test', + aiLogs: [], + shellLogs: [], + workLog: [], + contextUsage: 0, + inputMode: 'ai', + aiPid: 0, + terminalPid: 0, + port: 0, + isLive: false, + changedFiles: [], + isGitRepo: false, + fileTree: [], + fileExplorerExpanded: [], + fileExplorerScrollPos: 0, + activeTimeMs: 0, + executionQueue: [], + aiTabs: [], + activeTabId: '', + closedTabHistory: [], + ...overrides, + }; +} + +// ── Mocks ────────────────────────────────────────────────────────────────── + +let failoverCallback: (() => void) | null = null; + +beforeEach(() => { + vi.clearAllMocks(); + failoverCallback = null; + + vi.mocked(window.maestro.agents.detect).mockResolvedValue(mockAgents); + vi.mocked(window.maestro.providers.getAllErrorStats).mockResolvedValue(emptyErrorStats); + vi.mocked(window.maestro.settings.get).mockResolvedValue(null); + vi.mocked(window.maestro.providers.onFailoverSuggest).mockImplementation((handler: any) => { + failoverCallback = handler; + return () => { failoverCallback = null; }; + }); +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('useProviderHealth', () => { + it('loads providers on mount and sets loading state', async () => { + const sessions = [createSession('s1', 'claude-code')]; + const { result } = renderHook(() => useProviderHealth(sessions, 600_000)); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Should filter out terminal + expect(result.current.providers).toHaveLength(3); + expect(result.current.providers.map((p) => p.toolType)).toEqual([ + 'claude-code', + 'opencode', + 'codex', + ]); + }); + + it('computes healthy status for available providers with 0 errors', async () => { + const sessions = [createSession('s1', 'claude-code')]; + const { result } = renderHook(() => useProviderHealth(sessions, 600_000)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const claude = result.current.providers.find((p) => p.toolType === 'claude-code')!; + expect(claude.status).toBe('healthy'); + expect(claude.healthPercent).toBe(100); + expect(claude.activeSessionCount).toBe(1); + }); + + it('computes not_installed status for unavailable providers', async () => { + const sessions: Session[] = []; + const { result } = renderHook(() => useProviderHealth(sessions, 600_000)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const codex = result.current.providers.find((p) => p.toolType === 'codex')!; + expect(codex.status).toBe('not_installed'); + expect(codex.healthPercent).toBe(0); + }); + + it('computes idle status for available providers with 0 sessions', async () => { + const sessions: Session[] = []; + const { result } = renderHook(() => useProviderHealth(sessions, 600_000)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const opencode = result.current.providers.find((p) => p.toolType === 'opencode')!; + expect(opencode.status).toBe('idle'); + expect(opencode.healthPercent).toBe(100); + }); + + it('computes degraded status when errors exist below threshold', async () => { + const errorStats: Record = { + 'claude-code': { + toolType: 'claude-code', + activeErrorCount: 1, + totalErrorsInWindow: 1, + lastErrorAt: Date.now() - 30_000, + sessionsWithErrors: 1, + }, + }; + vi.mocked(window.maestro.providers.getAllErrorStats).mockResolvedValue(errorStats); + + const sessions = [createSession('s1', 'claude-code')]; + const { result } = renderHook(() => useProviderHealth(sessions, 600_000)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const claude = result.current.providers.find((p) => p.toolType === 'claude-code')!; + expect(claude.status).toBe('degraded'); + expect(claude.healthPercent).toBe(67); // 100 - (1/3)*100 = 67 + expect(result.current.hasDegradedProvider).toBe(true); + expect(result.current.hasFailingProvider).toBe(false); + }); + + it('computes failing status when errors meet threshold', async () => { + const errorStats: Record = { + 'claude-code': { + toolType: 'claude-code', + activeErrorCount: 3, + totalErrorsInWindow: 3, + lastErrorAt: Date.now() - 10_000, + sessionsWithErrors: 1, + }, + }; + vi.mocked(window.maestro.providers.getAllErrorStats).mockResolvedValue(errorStats); + + const sessions = [createSession('s1', 'claude-code')]; + const { result } = renderHook(() => useProviderHealth(sessions, 600_000)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const claude = result.current.providers.find((p) => p.toolType === 'claude-code')!; + expect(claude.status).toBe('failing'); + expect(claude.healthPercent).toBe(0); + expect(result.current.hasFailingProvider).toBe(true); + }); + + it('counts active sessions excluding archived migrations', async () => { + const sessions = [ + createSession('s1', 'claude-code'), + createSession('s2', 'claude-code'), + createSession('s3', 'claude-code', { archivedByMigration: true }), + ]; + const { result } = renderHook(() => useProviderHealth(sessions, 600_000)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const claude = result.current.providers.find((p) => p.toolType === 'claude-code')!; + expect(claude.activeSessionCount).toBe(2); + }); + + it('reads failover threshold from saved config', async () => { + vi.mocked(window.maestro.settings.get).mockResolvedValue({ + errorThreshold: 5, + }); + + const sessions = [createSession('s1', 'claude-code')]; + const { result } = renderHook(() => useProviderHealth(sessions, 600_000)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.failoverThreshold).toBe(5); + }); + + it('subscribes to failover suggest events', async () => { + const sessions = [createSession('s1', 'claude-code')]; + renderHook(() => useProviderHealth(sessions, 600_000)); + + await waitFor(() => { + expect(window.maestro.providers.onFailoverSuggest).toHaveBeenCalled(); + }); + }); + + it('refreshes on failover suggest event', async () => { + const sessions = [createSession('s1', 'claude-code')]; + const { result } = renderHook(() => useProviderHealth(sessions, 600_000)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Change error stats + const errorStats: Record = { + 'claude-code': { + toolType: 'claude-code', + activeErrorCount: 2, + totalErrorsInWindow: 2, + lastErrorAt: Date.now(), + sessionsWithErrors: 1, + }, + }; + vi.mocked(window.maestro.providers.getAllErrorStats).mockResolvedValue(errorStats); + + // Simulate failover event + await act(async () => { + failoverCallback?.(); + }); + + await waitFor(() => { + const claude = result.current.providers.find((p) => p.toolType === 'claude-code')!; + expect(claude.status).toBe('degraded'); + }); + }); + + it('sets lastUpdated timestamp after refresh', async () => { + const sessions: Session[] = []; + const { result } = renderHook(() => useProviderHealth(sessions, 600_000)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.lastUpdated).toBeTypeOf('number'); + expect(result.current.lastUpdated!).toBeGreaterThan(0); + }); + + it('provides a manual refresh function', async () => { + const sessions: Session[] = []; + const { result } = renderHook(() => useProviderHealth(sessions, 600_000)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const callCount = vi.mocked(window.maestro.agents.detect).mock.calls.length; + + await act(async () => { + result.current.refresh(); + }); + + expect(vi.mocked(window.maestro.agents.detect).mock.calls.length).toBeGreaterThan(callCount); + }); + + it('computes health percent correctly with custom threshold', async () => { + vi.mocked(window.maestro.settings.get).mockResolvedValue({ + errorThreshold: 10, + }); + + const errorStats: Record = { + 'claude-code': { + toolType: 'claude-code', + activeErrorCount: 3, + totalErrorsInWindow: 3, + lastErrorAt: Date.now(), + sessionsWithErrors: 1, + }, + }; + vi.mocked(window.maestro.providers.getAllErrorStats).mockResolvedValue(errorStats); + + const sessions = [createSession('s1', 'claude-code')]; + const { result } = renderHook(() => useProviderHealth(sessions, 600_000)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const claude = result.current.providers.find((p) => p.toolType === 'claude-code')!; + // With threshold 10: 100 - (3/10)*100 = 70 + expect(claude.healthPercent).toBe(70); + expect(claude.status).toBe('degraded'); + }); +}); diff --git a/src/__tests__/renderer/stores/agentStore.test.ts b/src/__tests__/renderer/stores/agentStore.test.ts index ec9496531..e52ce7f90 100644 --- a/src/__tests__/renderer/stores/agentStore.test.ts +++ b/src/__tests__/renderer/stores/agentStore.test.ts @@ -90,6 +90,7 @@ const mockInterrupt = vi.fn().mockResolvedValue(true); const mockDetect = vi.fn().mockResolvedValue([]); const mockGetAgent = vi.fn().mockResolvedValue(null); const mockClearError = vi.fn().mockResolvedValue(undefined); +const mockTriggerAuthRecovery = vi.fn().mockResolvedValue({ success: true }); (window as any).maestro = { process: { @@ -104,6 +105,9 @@ const mockClearError = vi.fn().mockResolvedValue(undefined); agentError: { clearError: mockClearError, }, + accounts: { + triggerAuthRecovery: mockTriggerAuthRecovery, + }, }; // Mock gitService @@ -740,7 +744,7 @@ describe('agentStore', () => { }); describe('authenticateAfterError', () => { - it('clears error, sets active session, and switches to terminal mode', () => { + it('clears error and triggers auth recovery via IPC', () => { const session = createMockSession({ id: 'session-1', state: 'error', @@ -754,22 +758,22 @@ describe('agentStore', () => { const updated = useSessionStore.getState().sessions[0]; expect(updated.state).toBe('idle'); - expect(updated.inputMode).toBe('terminal'); expect(updated.agentError).toBeUndefined(); - expect(useSessionStore.getState().activeSessionId).toBe('session-1'); + expect(mockTriggerAuthRecovery).toHaveBeenCalledWith('session-1'); }); it('does nothing if session not found', () => { useAgentStore.getState().authenticateAfterError('nonexistent'); // No crash, no IPC calls expect(mockClearError).not.toHaveBeenCalled(); + expect(mockTriggerAuthRecovery).not.toHaveBeenCalled(); }); - it('is idempotent when session is already in terminal mode', () => { + it('is idempotent on repeated calls', () => { const session = createMockSession({ id: 'session-1', state: 'error', - inputMode: 'terminal', + inputMode: 'ai', }); useSessionStore.getState().setSessions([session]); @@ -778,10 +782,10 @@ describe('agentStore', () => { const updated = useSessionStore.getState().sessions[0]; expect(updated.state).toBe('idle'); - expect(updated.inputMode).toBe('terminal'); + expect(mockTriggerAuthRecovery).toHaveBeenCalledWith('session-1'); }); - it('switches active session even if it was already active', () => { + it('triggers recovery regardless of current input mode', () => { const session = createMockSession({ id: 'session-1', state: 'error', @@ -793,8 +797,7 @@ describe('agentStore', () => { useAgentStore.getState().authenticateAfterError('session-1'); - expect(useSessionStore.getState().activeSessionId).toBe('session-1'); - expect(useSessionStore.getState().sessions[0].inputMode).toBe('terminal'); + expect(mockTriggerAuthRecovery).toHaveBeenCalledWith('session-1'); }); it('calls IPC clearError via delegation', () => { @@ -1085,10 +1088,9 @@ describe('agentStore', () => { useAgentStore.getState().authenticateAfterError('session-2'); - // Active session switched to session-2 - expect(useSessionStore.getState().activeSessionId).toBe('session-2'); - // session-2 is now in terminal mode - expect(useSessionStore.getState().sessions[1].inputMode).toBe('terminal'); + // session-2 error cleared, auth recovery triggered + expect(useSessionStore.getState().sessions[1].state).toBe('idle'); + expect(mockTriggerAuthRecovery).toHaveBeenCalledWith('session-2'); }); it('double clear is idempotent', () => { @@ -1132,7 +1134,7 @@ describe('agentStore', () => { expect(updated[0].state).toBe('idle'); expect(updated[0].agentError).toBeUndefined(); expect(updated[1].state).toBe('idle'); - expect(updated[1].inputMode).toBe('terminal'); + expect(mockTriggerAuthRecovery).toHaveBeenCalledWith('session-2'); }); it('recovery after restart then new session', async () => { diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 4082f34b0..5f9d7835c 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -525,6 +525,64 @@ const mockMaestro = { onUpdated: vi.fn().mockReturnValue(() => {}), onContributionStarted: vi.fn().mockReturnValue(() => {}), }, + accounts: { + list: vi.fn().mockResolvedValue([]), + get: vi.fn().mockResolvedValue(null), + add: vi.fn().mockResolvedValue({}), + update: vi.fn().mockResolvedValue({}), + remove: vi.fn().mockResolvedValue({}), + setDefault: vi.fn().mockResolvedValue({}), + assign: vi.fn().mockResolvedValue({}), + getAssignment: vi.fn().mockResolvedValue(null), + getAllAssignments: vi.fn().mockResolvedValue([]), + getUsage: vi.fn().mockResolvedValue({}), + getAllUsage: vi.fn().mockResolvedValue({}), + getThrottleEvents: vi.fn().mockResolvedValue([]), + getDailyUsage: vi.fn().mockResolvedValue([]), + getMonthlyUsage: vi.fn().mockResolvedValue([]), + getWindowHistory: vi.fn().mockResolvedValue([]), + getSwitchConfig: vi.fn().mockResolvedValue({}), + updateSwitchConfig: vi.fn().mockResolvedValue({}), + getDefault: vi.fn().mockResolvedValue(null), + selectNext: vi.fn().mockResolvedValue(null), + validateBaseDir: vi.fn().mockResolvedValue({ valid: true, baseDir: '/home/test/.claude', errors: [] }), + discoverExisting: vi.fn().mockResolvedValue([]), + createDirectory: vi.fn().mockResolvedValue({ success: true, configDir: '/home/test/.claude-test' }), + validateSymlinks: vi.fn().mockResolvedValue({ valid: true, broken: [], missing: [] }), + repairSymlinks: vi.fn().mockResolvedValue({ repaired: [], errors: [] }), + readEmail: vi.fn().mockResolvedValue(null), + getLoginCommand: vi.fn().mockResolvedValue(null), + removeDirectory: vi.fn().mockResolvedValue({ success: true }), + validateRemoteDir: vi.fn().mockResolvedValue({ exists: true, hasAuth: true, symlinksValid: true }), + syncCredentials: vi.fn().mockResolvedValue({ success: true }), + onUsageUpdate: vi.fn().mockReturnValue(() => {}), + onLimitWarning: vi.fn().mockReturnValue(() => {}), + onLimitReached: vi.fn().mockReturnValue(() => {}), + onThrottled: vi.fn().mockReturnValue(() => {}), + onSwitchPrompt: vi.fn().mockReturnValue(() => {}), + onSwitchExecute: vi.fn().mockReturnValue(() => {}), + onStatusChanged: vi.fn().mockReturnValue(() => {}), + onAssigned: vi.fn().mockReturnValue(() => {}), + reconcileSessions: vi.fn().mockResolvedValue({ success: true, removed: 0, corrections: [] }), + cleanupSession: vi.fn().mockResolvedValue({ success: true }), + executeSwitch: vi.fn().mockResolvedValue({ success: true }), + onSwitchStarted: vi.fn().mockReturnValue(() => {}), + onSwitchRespawn: vi.fn().mockReturnValue(() => {}), + onSwitchCompleted: vi.fn().mockReturnValue(() => {}), + onSwitchFailed: vi.fn().mockReturnValue(() => {}), + triggerAuthRecovery: vi.fn().mockResolvedValue({ success: true }), + onAuthRecoveryStarted: vi.fn().mockReturnValue(() => {}), + onAuthRecoveryCompleted: vi.fn().mockReturnValue(() => {}), + onAuthRecoveryFailed: vi.fn().mockReturnValue(() => {}), + onRecoveryAvailable: vi.fn().mockReturnValue(() => {}), + checkRecovery: vi.fn().mockResolvedValue({ recovered: [] }), + }, + providers: { + getErrorStats: vi.fn().mockResolvedValue(null), + getAllErrorStats: vi.fn().mockResolvedValue({}), + clearSessionErrors: vi.fn().mockResolvedValue(undefined), + onFailoverSuggest: vi.fn().mockReturnValue(() => {}), + }, app: { onQuitConfirmationRequest: vi.fn().mockReturnValue(() => {}), confirmQuit: vi.fn(), diff --git a/src/cli/commands/accounts.ts b/src/cli/commands/accounts.ts new file mode 100644 index 000000000..b64b352d6 --- /dev/null +++ b/src/cli/commands/accounts.ts @@ -0,0 +1,30 @@ +// Accounts command - list configured Claude accounts + +import { readAccountsFromStore } from '../services/account-reader'; + +export async function listAccounts(): Promise { + const accounts = await readAccountsFromStore(); + + if (accounts.length === 0) { + console.log('No accounts configured. Use Maestro Settings > Accounts to add accounts.'); + return; + } + + console.log('\nConfigured Accounts:'); + console.log('\u2500'.repeat(60)); + + for (const account of accounts) { + const defaultBadge = account.isDefault ? ' [DEFAULT]' : ''; + const statusIcon = + account.status === 'active' + ? '\u2713' + : account.status === 'throttled' + ? '\u26A0' + : '\u2717'; + console.log(` ${statusIcon} ${account.name}${defaultBadge}`); + console.log(` Email: ${account.email || 'unknown'}`); + console.log(` Dir: ${account.configDir}`); + console.log(` Status: ${account.status}`); + console.log(''); + } +} diff --git a/src/cli/commands/run-playbook.ts b/src/cli/commands/run-playbook.ts index 34a26bd95..b4e1d157d 100644 --- a/src/cli/commands/run-playbook.ts +++ b/src/cli/commands/run-playbook.ts @@ -69,6 +69,8 @@ interface RunPlaybookOptions { debug?: boolean; verbose?: boolean; wait?: boolean; + account?: string; + accountRotation?: boolean; } /** @@ -265,6 +267,8 @@ export async function runPlaybook(playbookId: string, options: RunPlaybookOption writeHistory: options.history !== false, // --no-history sets history to false debug: options.debug, verbose: options.verbose, + account: options.account, + accountRotation: options.accountRotation, }); for await (const event of generator) { diff --git a/src/cli/index.ts b/src/cli/index.ts index 95c99bdce..82bb61f20 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -13,6 +13,7 @@ import { showAgent } from './commands/show-agent'; import { cleanPlaybooks } from './commands/clean-playbooks'; import { send } from './commands/send'; import { listSessions } from './commands/list-sessions'; +import { listAccounts } from './commands/accounts'; // Read version from package.json at runtime function getVersion(): string { @@ -87,6 +88,8 @@ program .option('--debug', 'Show detailed debug output for troubleshooting') .option('--verbose', 'Show full prompt sent to agent on each iteration') .option('--wait', 'Wait for agent to become available if busy') + .option('--account ', 'Claude account name or ID to use for all spawned agents') + .option('--account-rotation', 'Rotate through available accounts for parallel tasks') .action(async (playbookId: string, options: Record) => { const { runPlaybook } = await import('./commands/run-playbook'); return runPlaybook(playbookId, options); @@ -109,4 +112,12 @@ program .option('-s, --session ', 'Resume an existing agent session (for multi-turn conversations)') .action(send); +// Accounts command +program + .command('accounts') + .description('List configured Claude accounts') + .action(async () => { + await listAccounts(); + }); + program.parse(); diff --git a/src/cli/services/account-reader.ts b/src/cli/services/account-reader.ts new file mode 100644 index 000000000..32592b7cc --- /dev/null +++ b/src/cli/services/account-reader.ts @@ -0,0 +1,156 @@ +// CLI-compatible account reader +// Reads account data directly from the filesystem since CLI runs outside Electron + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { AccountProfile, AccountStatus } from '../../shared/account-types'; +import type { AccountStoreData } from '../../main/stores/account-store-types'; + +export interface CLIAccountInfo { + id: string; + name: string; + email: string; + configDir: string; + status: AccountStatus; + isDefault: boolean; +} + +/** + * Get possible paths for the Maestro accounts store file. + * electron-store may use either capitalized or lowercase directory name + * depending on platform and configuration. + */ +function getAccountStorePaths(): string[] { + const platform = os.platform(); + const home = os.homedir(); + const paths: string[] = []; + + if (platform === 'darwin') { + paths.push(path.join(home, 'Library', 'Application Support', 'Maestro', 'maestro-accounts.json')); + paths.push(path.join(home, 'Library', 'Application Support', 'maestro', 'maestro-accounts.json')); + } else if (platform === 'win32') { + const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); + paths.push(path.join(appData, 'Maestro', 'maestro-accounts.json')); + paths.push(path.join(appData, 'maestro', 'maestro-accounts.json')); + } else { + const configBase = process.env.XDG_CONFIG_HOME || path.join(home, '.config'); + paths.push(path.join(configBase, 'Maestro', 'maestro-accounts.json')); + paths.push(path.join(configBase, 'maestro', 'maestro-accounts.json')); + } + + return paths; +} + +/** + * Convert an AccountProfile from the store into a CLIAccountInfo. + */ +function profileToCliInfo(profile: AccountProfile): CLIAccountInfo { + return { + id: profile.id, + name: profile.name, + email: profile.email || '', + configDir: profile.configDir, + status: profile.status || 'active', + isDefault: profile.isDefault || false, + }; +} + +/** + * Read account profiles from the Maestro electron-store JSON file. + * Falls back to filesystem discovery if the store file doesn't exist. + */ +export async function readAccountsFromStore(): Promise { + const storePaths = getAccountStorePaths(); + + for (const storePath of storePaths) { + try { + const content = fs.readFileSync(storePath, 'utf-8'); + const store: AccountStoreData = JSON.parse(content); + const accounts: CLIAccountInfo[] = []; + + if (store.accounts && typeof store.accounts === 'object') { + for (const profile of Object.values(store.accounts)) { + accounts.push(profileToCliInfo(profile)); + } + } + + return accounts; + } catch { + // Try next path + continue; + } + } + + // Store file doesn't exist at any path — try filesystem discovery + return discoverAccountsFromFilesystem(); +} + +/** + * Discover accounts by scanning for ~/.claude-* directories. + * Fallback when electron-store is not available. + */ +async function discoverAccountsFromFilesystem(): Promise { + const homeDir = os.homedir(); + + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(homeDir, { withFileTypes: true }); + } catch { + return []; + } + + const accounts: CLIAccountInfo[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.startsWith('.claude-')) continue; + + const configDir = path.join(homeDir, entry.name); + const name = entry.name.replace('.claude-', ''); + + // Check for auth info + let email = ''; + try { + const authContent = await fs.promises.readFile( + path.join(configDir, '.claude.json'), + 'utf-8' + ); + const json = JSON.parse(authContent); + email = json.email || json.accountEmail || json.primaryEmail || ''; + } catch { + // no auth file + } + + accounts.push({ + id: name, + name, + email, + configDir, + status: 'active', + isDefault: false, + }); + } + + return accounts; +} + +/** + * Get the default account, or the first active account, or null. + */ +export async function getDefaultAccount(): Promise { + const accounts = await readAccountsFromStore(); + return ( + accounts.find((a) => a.isDefault && a.status === 'active') || + accounts.find((a) => a.status === 'active') || + null + ); +} + +/** + * Get a specific account by ID or name. + */ +export async function getAccountByIdOrName(idOrName: string): Promise { + const accounts = await readAccountsFromStore(); + return accounts.find((a) => a.id === idOrName || a.name === idOrName) || null; +} diff --git a/src/cli/services/agent-spawner.ts b/src/cli/services/agent-spawner.ts index c07555acd..aa113d849 100644 --- a/src/cli/services/agent-spawner.ts +++ b/src/cli/services/agent-spawner.ts @@ -223,11 +223,17 @@ export function getCodexCommand(): string { async function spawnClaudeAgent( cwd: string, prompt: string, - agentSessionId?: string + agentSessionId?: string, + configDir?: string ): Promise { return new Promise((resolve) => { const env = buildExpandedEnv(); + // Inject account config dir if provided (account multiplexing) + if (configDir) { + env.CLAUDE_CONFIG_DIR = configDir; + } + // Build args: base args + session handling + prompt const args = [...CLAUDE_ARGS]; @@ -473,14 +479,15 @@ export async function spawnAgent( toolType: ToolType, cwd: string, prompt: string, - agentSessionId?: string + agentSessionId?: string, + configDir?: string ): Promise { if (toolType === 'codex') { return spawnCodexAgent(cwd, prompt, agentSessionId); } if (toolType === 'claude-code') { - return spawnClaudeAgent(cwd, prompt, agentSessionId); + return spawnClaudeAgent(cwd, prompt, agentSessionId, configDir); } return { diff --git a/src/cli/services/batch-processor.ts b/src/cli/services/batch-processor.ts index 3127cac6a..0a7f52f90 100644 --- a/src/cli/services/batch-processor.ts +++ b/src/cli/services/batch-processor.ts @@ -11,6 +11,8 @@ import { uncheckAllTasks, writeDoc, } from './agent-spawner'; +import { readAccountsFromStore, getAccountByIdOrName, getDefaultAccount } from './account-reader'; +import type { CLIAccountInfo } from './account-reader'; import { addHistoryEntry, readGroups } from './storage'; import { substituteTemplateVariables, TemplateContext } from '../../shared/templateVariables'; import { registerCliActivity, unregisterCliActivity } from '../../shared/cli-activity'; @@ -55,6 +57,35 @@ function isGitRepo(cwd: string): boolean { } } +/** + * Resolve the account configDir for a given task, based on CLI options. + * - If --account is set, use that specific account. + * - If --account-rotation is set, round-robin through active accounts. + * - Otherwise, use the default account if one exists. + */ +async function resolveAccountConfigDir( + taskIndex: number, + accountOption?: string, + accountRotation?: boolean, + cachedAccounts?: CLIAccountInfo[] | null, +): Promise { + if (accountOption) { + const account = await getAccountByIdOrName(accountOption); + return account?.configDir; + } + + if (accountRotation) { + const accounts = cachedAccounts ?? await readAccountsFromStore(); + const activeAccounts = accounts.filter((a) => a.status === 'active'); + if (activeAccounts.length === 0) return undefined; + const account = activeAccounts[taskIndex % activeAccounts.length]; + return account.configDir; + } + + const defaultAccount = await getDefaultAccount(); + return defaultAccount?.configDir; +} + /** * Process a playbook and yield JSONL events */ @@ -67,9 +98,18 @@ export async function* runPlaybook( writeHistory?: boolean; debug?: boolean; verbose?: boolean; + account?: string; + accountRotation?: boolean; } = {} ): AsyncGenerator { - const { dryRun = false, writeHistory = true, debug = false, verbose = false } = options; + const { + dryRun = false, + writeHistory = true, + debug = false, + verbose = false, + account: accountOption, + accountRotation = false, + } = options; const batchStartTime = Date.now(); // Get git branch and group name for template variable substitution @@ -79,6 +119,9 @@ export async function* runPlaybook( const sessionGroup = groups.find((g) => g.id === session.groupId); const groupName = sessionGroup?.name; + // Pre-cache accounts for rotation if enabled (avoids re-reading store per task) + const cachedAccounts = accountRotation ? await readAccountsFromStore() : null; + // Register CLI activity so desktop app knows this session is busy registerCliActivity({ sessionId: session.id, @@ -218,6 +261,7 @@ export async function* runPlaybook( let totalCompletedTasks = 0; let totalCost = 0; let loopIteration = 0; + let globalTaskIndex = 0; // Used for account rotation round-robin // Per-loop tracking let loopStartTime = Date.now(); @@ -438,8 +482,17 @@ export async function* runPlaybook( }; } + // Resolve account for this task (account multiplexing) + const configDir = await resolveAccountConfigDir( + globalTaskIndex, + accountOption, + accountRotation, + cachedAccounts, + ); + globalTaskIndex++; + // Spawn agent with combined prompt + document - const result = await spawnAgent(session.toolType, session.cwd, finalPrompt); + const result = await spawnAgent(session.toolType, session.cwd, finalPrompt, undefined, configDir); const elapsedMs = Date.now() - taskStartTime; @@ -471,12 +524,13 @@ export async function* runPlaybook( let fullSynopsis = shortSummary; if (result.success && result.agentSessionId) { - // Request synopsis from the agent + // Request synopsis from the agent (same account as the task) const synopsisResult = await spawnAgent( session.toolType, session.cwd, BATCH_SYNOPSIS_PROMPT, - result.agentSessionId + result.agentSessionId, + configDir ); if (synopsisResult.success && synopsisResult.response) { diff --git a/src/main/accounts/account-auth-recovery.ts b/src/main/accounts/account-auth-recovery.ts new file mode 100644 index 000000000..00b388427 --- /dev/null +++ b/src/main/accounts/account-auth-recovery.ts @@ -0,0 +1,282 @@ +/** + * Account Auth Recovery Service + * + * Orchestrates automatic re-authentication when an agent encounters + * an expired OAuth token: + * 1. Kills the failed agent process + * 2. Spawns `claude login` with the account's CLAUDE_CONFIG_DIR + * 3. Browser opens for OAuth — user clicks "Authorize" + * 4. Credentials are refreshed in the account directory + * 5. Sends respawn event to renderer (reuses account:switch-respawn channel) + * + * Fallback: if `claude login` fails, attempts to sync credentials + * from the base ~/.claude directory. + */ + +import { spawn } from 'child_process'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import type { ProcessManager } from '../process-manager/ProcessManager'; +import type { AccountRegistry } from './account-registry'; +import type { AgentDetector } from '../agents'; +import type { SafeSendFn } from '../utils/safe-send'; +import { syncCredentialsFromBase } from './account-setup'; +import { logger } from '../utils/logger'; + +const LOG_CONTEXT = 'account-auth-recovery'; + +/** Timeout for `claude login` to complete (user must authorize in browser) */ +const LOGIN_TIMEOUT_MS = 120_000; + +/** Delay between killing old process and starting login (ms) */ +const KILL_DELAY_MS = 1000; + +/** Set of session IDs currently undergoing auth recovery (prevents double-fire) */ +const activeRecoveries = new Set(); + +export class AccountAuthRecovery { + /** Tracks the last user prompt per session for re-sending after recovery */ + private lastPrompts = new Map(); + + constructor( + private processManager: ProcessManager, + private accountRegistry: AccountRegistry, + private agentDetector: AgentDetector, + private safeSend: SafeSendFn, + ) {} + + /** + * Record the last user prompt sent to a session. + * Called by the process write handler so we can re-send after recovery. + */ + recordLastPrompt(sessionId: string, prompt: string): void { + this.lastPrompts.set(sessionId, prompt); + } + + /** + * Check if a session is currently undergoing auth recovery. + */ + isRecovering(sessionId: string): boolean { + return activeRecoveries.has(sessionId); + } + + /** + * Main entry point: recover authentication for a session. + * + * @param sessionId - The session that hit an auth error + * @param accountId - The account assigned to that session + * @returns true if recovery succeeded and respawn was triggered + */ + async recoverAuth(sessionId: string, accountId: string): Promise { + // Prevent double-fire if error listener fires multiple times + if (activeRecoveries.has(sessionId)) { + logger.warn('Auth recovery already in progress for session', LOG_CONTEXT, { sessionId }); + return false; + } + + activeRecoveries.add(sessionId); + + try { + const account = this.accountRegistry.get(accountId); + if (!account) { + logger.error('Account not found for auth recovery', LOG_CONTEXT, { accountId }); + return false; + } + + logger.info(`Starting auth recovery for account ${account.name}`, LOG_CONTEXT, { + sessionId, accountId, configDir: account.configDir, + }); + + // 1. Mark account as expired + this.accountRegistry.setStatus(accountId, 'expired'); + + // 2. Kill the current agent process + const killed = this.processManager.kill(sessionId); + if (!killed) { + logger.warn('Could not kill process (may have already exited)', LOG_CONTEXT, { sessionId }); + } + + // 3. Notify renderer that recovery is starting + this.safeSend('account:auth-recovery-started', { + sessionId, + accountId, + accountName: account.name, + }); + + // Wait for process cleanup + await new Promise(resolve => setTimeout(resolve, KILL_DELAY_MS)); + + // 4. Attempt `claude login` + const loginSuccess = await this.runClaudeLogin(account.configDir); + + if (loginSuccess) { + return this.handleLoginSuccess(sessionId, accountId, account.configDir, account.name); + } + + // 5. Fallback: sync credentials from base ~/.claude directory + logger.info('Login failed, attempting credential sync from base dir', LOG_CONTEXT); + const syncResult = await syncCredentialsFromBase(account.configDir); + + if (syncResult.success) { + logger.info('Credential sync from base succeeded', LOG_CONTEXT); + return this.handleLoginSuccess(sessionId, accountId, account.configDir, account.name); + } + + // 6. All recovery failed + logger.error('All auth recovery methods failed', LOG_CONTEXT, { + sessionId, accountId, syncError: syncResult.error, + }); + + this.safeSend('account:auth-recovery-failed', { + sessionId, + accountId, + accountName: account.name, + error: 'Authentication failed. Please run "claude login" manually in a terminal.', + }); + + return false; + + } catch (error) { + logger.error('Auth recovery threw unexpectedly', LOG_CONTEXT, { + error: String(error), sessionId, accountId, + }); + + this.safeSend('account:auth-recovery-failed', { + sessionId, + accountId, + error: String(error), + }); + + return false; + } finally { + activeRecoveries.delete(sessionId); + } + } + + /** + * Handle successful credential refresh: mark active, send respawn event. + */ + private handleLoginSuccess( + sessionId: string, + accountId: string, + configDir: string, + accountName: string, + ): boolean { + // Mark account as active again + this.accountRegistry.setStatus(accountId, 'active'); + + const lastPrompt = this.lastPrompts.get(sessionId); + + // Notify renderer that recovery completed + this.safeSend('account:auth-recovery-completed', { + sessionId, + accountId, + accountName, + }); + + // Reuse the switch-respawn channel — renderer already handles it + this.safeSend('account:switch-respawn', { + sessionId, + toAccountId: accountId, + toAccountName: accountName, + configDir, + lastPrompt: lastPrompt ?? null, + reason: 'auth-recovery', + }); + + logger.info(`Auth recovery completed for account ${accountName}`, LOG_CONTEXT, { + sessionId, accountId, + }); + + return true; + } + + /** + * Spawn `claude login` with the account's CLAUDE_CONFIG_DIR. + * Opens a browser for OAuth. Returns true if login exited successfully. + */ + private async runClaudeLogin(configDir: string): Promise { + // Resolve the claude binary path + const agent = await this.agentDetector.getAgent('claude-code'); + const claudeBinary = agent?.path ?? agent?.command ?? 'claude'; + + logger.info(`Spawning claude login with binary: ${claudeBinary}`, LOG_CONTEXT, { configDir }); + + return new Promise((resolve) => { + const child = spawn(claudeBinary, ['login'], { + env: { + ...process.env, + CLAUDE_CONFIG_DIR: configDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data) => { + stdout += data.toString(); + logger.debug(`claude login stdout: ${data.toString().trim()}`, LOG_CONTEXT); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + logger.debug(`claude login stderr: ${data.toString().trim()}`, LOG_CONTEXT); + }); + + // Timeout: if user doesn't authorize in time + const timeout = setTimeout(() => { + logger.warn('claude login timed out', LOG_CONTEXT, { configDir }); + child.kill('SIGTERM'); + resolve(false); + }, LOGIN_TIMEOUT_MS); + + child.on('close', async (code) => { + clearTimeout(timeout); + + if (code === 0) { + // Verify credentials were actually written + const credsExist = await this.verifyCredentials(configDir); + if (credsExist) { + logger.info('claude login succeeded', LOG_CONTEXT, { configDir }); + resolve(true); + } else { + logger.warn('claude login exited 0 but no credentials found', LOG_CONTEXT); + resolve(false); + } + } else { + logger.warn(`claude login exited with code ${code}`, LOG_CONTEXT, { + stderr: stderr.slice(0, 500), + }); + resolve(false); + } + }); + + child.on('error', (err) => { + clearTimeout(timeout); + logger.error(`claude login spawn error: ${err.message}`, LOG_CONTEXT); + resolve(false); + }); + }); + } + + /** + * Verify that .credentials.json exists in the account directory + * after a login attempt. + */ + private async verifyCredentials(configDir: string): Promise { + try { + const credPath = path.join(configDir, '.credentials.json'); + await fs.access(credPath); + return true; + } catch { + return false; + } + } + + /** Clean up tracking data when a session is closed */ + cleanupSession(sessionId: string): void { + this.lastPrompts.delete(sessionId); + activeRecoveries.delete(sessionId); + } +} diff --git a/src/main/accounts/account-env-injector.ts b/src/main/accounts/account-env-injector.ts new file mode 100644 index 000000000..a184bd176 --- /dev/null +++ b/src/main/accounts/account-env-injector.ts @@ -0,0 +1,120 @@ +/** + * Account Environment Injector + * + * Shared utility for injecting CLAUDE_CONFIG_DIR into spawn environments. + * Called by ALL code paths that spawn Claude Code agents: + * - Standard process:spawn handler + * - Group Chat participants and moderators + * - Context Grooming + * - Session resume + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { AccountRegistry, AccountUsageStatsProvider } from './account-registry'; +import type { SafeSendFn } from '../utils/safe-send'; +import { syncCredentialsFromBase } from './account-setup'; +import { logger } from '../utils/logger'; + +const LOG_CONTEXT = 'account-env-injector'; + +interface SpawnEnv { + [key: string]: string | undefined; +} + +/** + * Injects CLAUDE_CONFIG_DIR into spawn environment for account multiplexing. + * Called by all code paths that spawn Claude Code agents. + * + * Does NOT validate credential freshness — Claude Code handles its own + * token refresh via the OAuth refresh token in .credentials.json. + * If the refresh fails, the error listener catches the auth error. + * + * @param sessionId - The session ID being spawned + * @param agentType - The agent type (only 'claude-code' is handled) + * @param env - Mutable env object to inject into + * @param accountRegistry - The account registry instance + * @param accountId - Pre-assigned account ID (optional, auto-assigns if missing) + * @param safeSend - Optional safeSend function to notify renderer of assignment + * @param getStatsDB - Optional function to get stats DB for capacity-aware selection + * @returns The account ID used (or null if no accounts configured) + */ +export function injectAccountEnv( + sessionId: string, + agentType: string, + env: SpawnEnv, + accountRegistry: AccountRegistry, + accountId?: string | null, + safeSend?: SafeSendFn, + getStatsDB?: () => AccountUsageStatsProvider | null, +): string | null { + if (agentType !== 'claude-code') return null; + + // If CLAUDE_CONFIG_DIR is already explicitly set in customEnvVars, respect it + if (env.CLAUDE_CONFIG_DIR) { + logger.info('CLAUDE_CONFIG_DIR already set, skipping account injection', LOG_CONTEXT, { sessionId }); + return null; + } + + const accounts = accountRegistry.getAll().filter(a => a.status === 'active'); + if (accounts.length === 0) return null; + + // Use provided accountId, check for existing assignment, or auto-assign + let resolvedAccountId = accountId; + if (!resolvedAccountId) { + // Check for existing assignment (e.g., session resume) + const existingAssignment = accountRegistry.getAssignment(sessionId); + if (existingAssignment) { + const existingAccount = accountRegistry.get(existingAssignment.accountId); + if (existingAccount && existingAccount.status === 'active') { + resolvedAccountId = existingAssignment.accountId; + logger.info(`Reusing existing assignment for session ${sessionId}`, LOG_CONTEXT); + } + } + } + if (!resolvedAccountId) { + const defaultAccount = accountRegistry.getDefaultAccount(); + const statsDB = getStatsDB?.() ?? undefined; + const selected = defaultAccount ?? accountRegistry.selectNextAccount([], statsDB ?? undefined); + if (!selected) return null; + resolvedAccountId = selected.id; + } + + const account = accountRegistry.get(resolvedAccountId); + if (!account) return null; + + // Ensure credentials exist in the account dir before spawning. + // If missing, attempt a best-effort sync from base ~/.claude dir. + const credPath = path.join(account.configDir, '.credentials.json'); + if (!fs.existsSync(credPath)) { + logger.info('No .credentials.json in account dir, attempting sync from base', LOG_CONTEXT, { + sessionId, configDir: account.configDir, + }); + // Fire-and-forget — don't block spawn on this + syncCredentialsFromBase(account.configDir).then((result) => { + if (result.success) { + logger.info('Auto-synced credentials from base dir', LOG_CONTEXT); + } else { + logger.warn(`Credential sync failed: ${result.error}`, LOG_CONTEXT); + } + }).catch(() => {}); + } + + // Inject the env var + env.CLAUDE_CONFIG_DIR = account.configDir; + + // Create/update assignment + accountRegistry.assignToSession(sessionId, resolvedAccountId); + + // Notify renderer if safeSend is available + if (safeSend) { + safeSend('account:assigned', { + sessionId, + accountId: resolvedAccountId, + accountName: account.name, + }); + } + + logger.info(`Assigned account ${account.name} to session ${sessionId}`, LOG_CONTEXT); + return resolvedAccountId; +} diff --git a/src/main/accounts/account-recovery-poller.ts b/src/main/accounts/account-recovery-poller.ts new file mode 100644 index 000000000..88c48a2bd --- /dev/null +++ b/src/main/accounts/account-recovery-poller.ts @@ -0,0 +1,134 @@ +/** + * Account Recovery Poller + * + * Timer-based service that proactively checks whether throttled accounts + * have passed their rate-limit window and can be recovered to active status. + * + * This solves the "all accounts exhausted" deadlock: when every configured + * account is throttled, no usage events fire (no agents running), so the + * passive recovery in account-usage-listener never triggers. This poller + * runs independently on a fixed interval. + */ + +import type { AccountRegistry } from './account-registry'; +import type { AccountProfile } from '../../shared/account-types'; +import { DEFAULT_TOKEN_WINDOW_MS } from '../../shared/account-types'; +import type { SafeSendFn } from '../utils/safe-send'; +import { logger } from '../utils/logger'; + +const LOG_CONTEXT = 'account-recovery-poller'; + +/** How often to check throttled accounts (ms) */ +const DEFAULT_POLL_INTERVAL_MS = 60_000; // 1 minute + +/** Minimum time after throttle before considering recovery (safety margin) */ +const RECOVERY_MARGIN_MS = 30_000; // 30 seconds past window + +export interface AccountRecoveryPollerDeps { + accountRegistry: AccountRegistry; + safeSend: SafeSendFn; +} + +export class AccountRecoveryPoller { + private timer: ReturnType | null = null; + private pollIntervalMs: number; + private deps: AccountRecoveryPollerDeps; + + constructor(deps: AccountRecoveryPollerDeps, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS) { + this.deps = deps; + this.pollIntervalMs = pollIntervalMs; + } + + /** + * Start the poller. Safe to call multiple times (idempotent). + */ + start(): void { + if (this.timer) return; + + logger.info('Starting account recovery poller', LOG_CONTEXT, { + intervalMs: this.pollIntervalMs, + }); + + // Run immediately on start, then on interval + this.poll(); + this.timer = setInterval(() => this.poll(), this.pollIntervalMs); + } + + /** + * Stop the poller. Safe to call when not running. + */ + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + logger.info('Stopped account recovery poller', LOG_CONTEXT); + } + } + + /** + * Check all throttled accounts and recover those past their window. + * Returns the list of recovered account IDs. + */ + poll(): string[] { + const { accountRegistry, safeSend } = this.deps; + const now = Date.now(); + const recovered: string[] = []; + + const throttledAccounts = accountRegistry.getAll().filter( + (a: AccountProfile) => a.status === 'throttled' && a.lastThrottledAt > 0 + ); + + if (throttledAccounts.length === 0) return recovered; + + for (const account of throttledAccounts) { + const windowMs = account.tokenWindowMs || DEFAULT_TOKEN_WINDOW_MS; + const timeSinceThrottle = now - account.lastThrottledAt; + + // Recover if enough time has passed (window + safety margin) + if (timeSinceThrottle > windowMs + RECOVERY_MARGIN_MS) { + accountRegistry.setStatus(account.id, 'active'); + recovered.push(account.id); + + logger.info(`Account ${account.name} recovered from throttle via poller`, LOG_CONTEXT, { + accountId: account.id, + timeSinceThrottleMs: timeSinceThrottle, + windowMs, + }); + + safeSend('account:status-changed', { + accountId: account.id, + accountName: account.name, + oldStatus: 'throttled', + newStatus: 'active', + recoveredBy: 'poller', + }); + } + } + + // If any accounts recovered, also broadcast a recovery summary event + // so the renderer can auto-resume paused sessions + if (recovered.length > 0) { + const totalAccounts = accountRegistry.getAll().length; + const stillThrottled = throttledAccounts.length - recovered.length; + + safeSend('account:recovery-available', { + recoveredAccountIds: recovered, + recoveredCount: recovered.length, + stillThrottledCount: stillThrottled, + totalAccounts, + }); + + logger.info(`Recovery poll: ${recovered.length} account(s) recovered`, LOG_CONTEXT, { + recovered: recovered.length, + stillThrottled, + }); + } + + return recovered; + } + + /** Check if the poller is currently running */ + isRunning(): boolean { + return this.timer !== null; + } +} diff --git a/src/main/accounts/account-registry.ts b/src/main/accounts/account-registry.ts new file mode 100644 index 000000000..5b8a8f0d5 --- /dev/null +++ b/src/main/accounts/account-registry.ts @@ -0,0 +1,330 @@ +import type Store from 'electron-store'; +import type { AccountStoreData } from '../stores/account-store-types'; +import type { + AccountProfile, + AccountAssignment, + AccountSwitchConfig, + AccountId, + AccountStatus, + MultiplexableAgent, +} from '../../shared/account-types'; +import { DEFAULT_TOKEN_WINDOW_MS, ACCOUNT_SWITCH_DEFAULTS } from '../../shared/account-types'; +import { generateUUID } from '../../shared/uuid'; +import { logger } from '../utils/logger'; +import { getWindowBounds } from './account-utils'; + +/** Minimal interface for usage stats queries — avoids hard dependency on StatsDB */ +export interface AccountUsageStatsProvider { + getAccountUsageInWindow(id: string, start: number, end: number): { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + }; + isReady(): boolean; +} + +const LOG_CONTEXT = 'AccountRegistry'; + +export class AccountRegistry { + constructor(private store: Store) {} + + // --- Account CRUD --- + + /** Get all registered accounts */ + getAll(): AccountProfile[] { + const accounts = this.store.get('accounts', {}); + return Object.values(accounts); + } + + /** Get a single account by ID */ + get(id: AccountId): AccountProfile | null { + const accounts = this.store.get('accounts', {}); + return accounts[id] ?? null; + } + + /** Find account by email */ + findByEmail(email: string): AccountProfile | null { + return this.getAll().find(a => a.email === email) ?? null; + } + + /** Find account by config directory path */ + findByConfigDir(configDir: string): AccountProfile | null { + return this.getAll().find(a => a.configDir === configDir) ?? null; + } + + /** Register a new account. Returns the created profile. */ + add(params: { + name: string; + email: string; + configDir: string; + agentType?: MultiplexableAgent; + authMethod?: 'oauth' | 'api-key'; + }): AccountProfile { + // Check for duplicate email + const existing = this.findByEmail(params.email); + if (existing) { + throw new Error(`Account with email "${params.email}" already exists (ID: ${existing.id})`); + } + + const now = Date.now(); + const isFirst = this.getAll().length === 0; + const profile: AccountProfile = { + id: generateUUID(), + name: params.name, + email: params.email, + configDir: params.configDir, + agentType: params.agentType ?? 'claude-code', + status: 'active', + authMethod: params.authMethod ?? 'oauth', + addedAt: now, + lastUsedAt: 0, + lastThrottledAt: 0, + tokenLimitPerWindow: 0, + tokenWindowMs: DEFAULT_TOKEN_WINDOW_MS, + isDefault: isFirst, // First account is default + autoSwitchEnabled: true, + }; + + const accounts = this.store.get('accounts', {}); + accounts[profile.id] = profile; + this.store.set('accounts', accounts); + + // Add to rotation order + const order = this.store.get('rotationOrder', []); + order.push(profile.id); + this.store.set('rotationOrder', order); + + return profile; + } + + /** Update an existing account profile. Returns updated profile or null if not found. */ + update(id: AccountId, updates: Partial>): AccountProfile | null { + const accounts = this.store.get('accounts', {}); + const existing = accounts[id]; + if (!existing) return null; + + // If setting this as default, clear default from others + if (updates.isDefault) { + for (const acct of Object.values(accounts)) { + acct.isDefault = false; + } + } + + accounts[id] = { ...existing, ...updates }; + this.store.set('accounts', accounts); + return accounts[id]; + } + + /** Remove an account. Returns true if found and removed. */ + remove(id: AccountId): boolean { + const accounts = this.store.get('accounts', {}); + if (!accounts[id]) return false; + + delete accounts[id]; + this.store.set('accounts', accounts); + + // Remove from rotation order + const order = this.store.get('rotationOrder', []); + this.store.set('rotationOrder', order.filter(aid => aid !== id)); + + // Remove any assignments pointing to this account + const assignments = this.store.get('assignments', {}); + for (const [sid, assignment] of Object.entries(assignments)) { + if (assignment.accountId === id) { + delete assignments[sid]; + } + } + this.store.set('assignments', assignments); + + return true; + } + + /** Update account status (active, throttled, expired, disabled) */ + setStatus(id: AccountId, status: AccountStatus): void { + const accounts = this.store.get('accounts', {}); + if (!accounts[id]) return; + accounts[id].status = status; + if (status === 'throttled') { + accounts[id].lastThrottledAt = Date.now(); + } + this.store.set('accounts', accounts); + } + + /** Mark account as recently used */ + touchLastUsed(id: AccountId): void { + const accounts = this.store.get('accounts', {}); + if (!accounts[id]) return; + accounts[id].lastUsedAt = Date.now(); + this.store.set('accounts', accounts); + } + + // --- Assignments --- + + /** Assign an account to a session */ + assignToSession(sessionId: string, accountId: AccountId): AccountAssignment { + const assignment: AccountAssignment = { + sessionId, + accountId, + assignedAt: Date.now(), + }; + const assignments = this.store.get('assignments', {}); + assignments[sessionId] = assignment; + this.store.set('assignments', assignments); + this.touchLastUsed(accountId); + return assignment; + } + + /** Get the account assigned to a session */ + getAssignment(sessionId: string): AccountAssignment | null { + const assignments = this.store.get('assignments', {}); + return assignments[sessionId] ?? null; + } + + /** Remove a session assignment (e.g., when session is closed) */ + removeAssignment(sessionId: string): void { + const assignments = this.store.get('assignments', {}); + delete assignments[sessionId]; + this.store.set('assignments', assignments); + } + + /** Get all current assignments */ + getAllAssignments(): AccountAssignment[] { + return Object.values(this.store.get('assignments', {})); + } + + /** Get the default account (first one marked isDefault, or first active) */ + getDefaultAccount(): AccountProfile | null { + const all = this.getAll(); + return all.find(a => a.isDefault && a.status === 'active') + ?? all.find(a => a.status === 'active') + ?? null; + } + + /** + * Select the next account using the configured strategy. + * When statsDB is provided, uses actual token consumption for routing. + * Falls back to lastUsedAt-based selection when statsDB is unavailable. + */ + selectNextAccount(excludeIds: AccountId[] = [], statsDB?: AccountUsageStatsProvider): AccountProfile | null { + const config = this.getSwitchConfig(); + const available = this.getAll().filter( + a => a.status === 'active' && a.autoSwitchEnabled && !excludeIds.includes(a.id) + ); + if (available.length === 0) return null; + + if (config.selectionStrategy === 'round-robin') { + const order = this.store.get('rotationOrder', []).filter( + id => available.some(a => a.id === id) + ); + if (order.length === 0) return available[0]; + const idx = (this.store.get('rotationIndex', 0) + 1) % order.length; + this.store.set('rotationIndex', idx); + return available.find(a => a.id === order[idx]) ?? available[0]; + } + + // least-used: prefer capacity-aware selection when statsDB is available + if (statsDB && statsDB.isReady()) { + return this.selectByRemainingCapacity(available, statsDB); + } + + // Fallback: sort by lastUsedAt ascending (least recently used first) + available.sort((a, b) => a.lastUsedAt - b.lastUsedAt); + return available[0]; + } + + /** + * Select the account with the most remaining capacity in its current window. + * Accounts without configured limits are treated as having infinite remaining capacity, + * but deprioritized behind accounts with known remaining capacity. + */ + private selectByRemainingCapacity( + accounts: AccountProfile[], + statsDB: AccountUsageStatsProvider, + ): AccountProfile { + const now = Date.now(); + + const scored = accounts.map(account => { + const windowMs = account.tokenWindowMs || DEFAULT_TOKEN_WINDOW_MS; + const { start: windowStart, end: windowEnd } = getWindowBounds(now, windowMs); + + const usage = statsDB.getAccountUsageInWindow(account.id, windowStart, windowEnd); + const totalTokens = usage.inputTokens + usage.outputTokens + + usage.cacheReadTokens + usage.cacheCreationTokens; + + let remainingCapacity: number; + + if (account.tokenLimitPerWindow > 0) { + remainingCapacity = Math.max(0, account.tokenLimitPerWindow - totalTokens); + } else { + remainingCapacity = Infinity; + } + + // Deprioritize accounts that were recently throttled (within last 2 windows) + const recentThrottlePenalty = account.lastThrottledAt > 0 + && (now - account.lastThrottledAt) < windowMs * 2 + ? 0.5 + : 1.0; + + return { + account, + remainingCapacity: remainingCapacity === Infinity + ? Infinity + : remainingCapacity * recentThrottlePenalty, + }; + }); + + // Sort: most remaining capacity first + const hasLimits = scored.some(s => s.remainingCapacity !== Infinity); + + if (hasLimits) { + scored.sort((a, b) => { + // Finite capacity always before infinite + if (a.remainingCapacity === Infinity && b.remainingCapacity !== Infinity) return 1; + if (a.remainingCapacity !== Infinity && b.remainingCapacity === Infinity) return -1; + // Both finite: higher remaining first + return (b.remainingCapacity as number) - (a.remainingCapacity as number); + }); + } else { + // No limits configured on any account — fall back to LRU + scored.sort((a, b) => a.account.lastUsedAt - b.account.lastUsedAt); + } + + return scored[0].account; + } + + // --- Reconciliation --- + + /** + * Reconcile assignments with a list of active session IDs. + * Removes assignments for sessions that no longer exist. + * Called on app startup after session restore. + */ + reconcileAssignments(activeSessionIds: Set): number { + const assignments = this.getAllAssignments(); + let removed = 0; + for (const assignment of assignments) { + if (!activeSessionIds.has(assignment.sessionId)) { + this.removeAssignment(assignment.sessionId); + removed++; + } + } + if (removed > 0) { + logger.info(`Reconciled ${removed} stale account assignments`, LOG_CONTEXT); + } + return removed; + } + + // --- Switch Config --- + + getSwitchConfig(): AccountSwitchConfig { + return this.store.get('switchConfig', ACCOUNT_SWITCH_DEFAULTS); + } + + updateSwitchConfig(updates: Partial): AccountSwitchConfig { + const current = this.getSwitchConfig(); + const updated = { ...current, ...updates }; + this.store.set('switchConfig', updated); + return updated; + } +} diff --git a/src/main/accounts/account-setup.ts b/src/main/accounts/account-setup.ts new file mode 100644 index 000000000..fea3441bc --- /dev/null +++ b/src/main/accounts/account-setup.ts @@ -0,0 +1,489 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { logger } from '../utils/logger'; + +const LOG_CONTEXT = 'account-setup'; +const execFileAsync = promisify(execFile); + +/** Resources that are symlinked from ~/.claude to each account directory */ +const SHARED_SYMLINKS = [ + 'commands', + 'ide', + 'plans', + 'plugins', + 'settings.json', + 'CLAUDE.md', + 'todos', + 'session-env', + 'projects', +]; + +/** + * Validate that the base ~/.claude directory exists and has the expected structure. + */ +export async function validateBaseClaudeDir(): Promise<{ + valid: boolean; + baseDir: string; + errors: string[]; +}> { + const baseDir = path.join(os.homedir(), '.claude'); + const errors: string[] = []; + + try { + const stat = await fs.stat(baseDir); + if (!stat.isDirectory()) { + errors.push(`${baseDir} exists but is not a directory`); + } + } catch { + errors.push(`${baseDir} does not exist. Run 'claude' at least once to create it.`); + } + + // Check for auth tokens — Claude Code uses .credentials.json (current) or .claude.json (legacy) + try { + await fs.access(path.join(baseDir, '.credentials.json')); + } catch { + try { + await fs.access(path.join(baseDir, '.claude.json')); + } catch { + errors.push('No .credentials.json or .claude.json found — Claude Code may not be authenticated.'); + } + } + + return { valid: errors.length === 0, baseDir, errors }; +} + +/** + * Provider-specific config for discovery. + * Each entry describes where to find accounts and how to extract identity. + */ +interface ProviderDiscoveryConfig { + agentType: string; + /** Directory prefix to scan in home dir (e.g., '.claude-' matches ~/.claude-work) */ + dirPrefix?: string; + /** Single config dir to detect as an account (e.g., '.codex' matches ~/.codex) */ + singleDir?: string; + /** Auth files to check (relative to config dir) — first found wins */ + authFiles: string[]; + /** Extract identity from auth/config file content */ + extractIdentity: (content: string) => string | null; +} + +const PROVIDER_DISCOVERY: ProviderDiscoveryConfig[] = [ + { + agentType: 'claude-code', + dirPrefix: '.claude-', + authFiles: ['.claude.json', '.credentials.json'], + extractIdentity: extractEmailFromClaudeJson, + }, + { + agentType: 'codex', + singleDir: '.codex', + authFiles: ['auth.json', 'config.toml'], + extractIdentity: extractCodexIdentity, + }, + { + agentType: 'opencode', + singleDir: '.opencode', + authFiles: ['config.json', 'auth.json'], + extractIdentity: () => null, + }, + { + agentType: 'gemini-cli', + singleDir: '.gemini', + authFiles: ['oauth_creds.json', 'google_accounts.json'], + extractIdentity: extractGeminiIdentity, + }, +]; + +/** + * Discover existing provider account directories by scanning the home directory + * for known config directory patterns across all supported providers. + */ +export async function discoverExistingAccounts(): Promise> { + const homeDir = os.homedir(); + const entries = await fs.readdir(homeDir, { withFileTypes: true }); + const accounts: Array<{ configDir: string; name: string; email: string | null; hasAuth: boolean; agentType: string }> = []; + + for (const provider of PROVIDER_DISCOVERY) { + // Scan for prefix-based directories (e.g., ~/.claude-work, ~/.claude-personal) + if (provider.dirPrefix) { + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + if (!entry.name.startsWith(provider.dirPrefix)) continue; + + const configDir = path.join(homeDir, entry.name); + const name = entry.name.replace(provider.dirPrefix, ''); + const authResult = await checkProviderAuth(configDir, provider); + + accounts.push({ + configDir, + name, + email: authResult.email, + hasAuth: authResult.hasAuth, + agentType: provider.agentType, + }); + } + } + + // Check for single config directory (e.g., ~/.codex) + if (provider.singleDir) { + const configDir = path.join(homeDir, provider.singleDir); + try { + const stat = await fs.stat(configDir); + if (stat.isDirectory()) { + const authResult = await checkProviderAuth(configDir, provider); + accounts.push({ + configDir, + name: provider.singleDir.replace('.', ''), + email: authResult.email, + hasAuth: authResult.hasAuth, + agentType: provider.agentType, + }); + } + } catch { + // Directory doesn't exist — skip + } + } + } + + return accounts; +} + +/** Check auth files for a provider directory and extract identity */ +async function checkProviderAuth( + configDir: string, + provider: ProviderDiscoveryConfig, +): Promise<{ hasAuth: boolean; email: string | null }> { + for (const authFile of provider.authFiles) { + try { + const content = await fs.readFile(path.join(configDir, authFile), 'utf-8'); + return { + hasAuth: true, + email: provider.extractIdentity(content), + }; + } catch { + // Try next auth file + } + } + return { hasAuth: false, email: null }; +} + +/** Extract identity from Codex auth.json or config.toml */ +function extractCodexIdentity(content: string): string | null { + try { + // auth.json has account info + const json = JSON.parse(content); + return json.email || json.user?.email || json.account?.email || null; + } catch { + // Not JSON — might be config.toml, no identity info there + return null; + } +} + +/** Extract identity from Gemini google_accounts.json or oauth_creds.json */ +function extractGeminiIdentity(content: string): string | null { + try { + const json = JSON.parse(content); + // google_accounts.json has { active: "email@example.com" } + return json.active || json.email || null; + } catch { + return null; + } +} + +/** + * Extract the email address from a .claude.json file content. + * The structure may vary — look for common fields like "email", "accountEmail", etc. + */ +function extractEmailFromClaudeJson(content: string): string | null { + try { + const json = JSON.parse(content); + // Try common field names where email might be stored + // Claude Code stores it at oauthAccount.emailAddress + return json.email + || json.accountEmail + || json.primaryEmail + || json.oauthAccount?.emailAddress + || json.oauthAccount?.email + || json.account?.email + || null; + } catch { + return null; + } +} + +/** + * Read the email identity from an account's .claude.json file. + */ +export async function readAccountEmail(configDir: string): Promise { + try { + const authFile = path.join(configDir, '.claude.json'); + const content = await fs.readFile(authFile, 'utf-8'); + return extractEmailFromClaudeJson(content); + } catch { + return null; + } +} + +/** + * Create a new Claude account directory with symlinks to shared resources. + * Does NOT authenticate — that requires running `claude login` separately. + */ +export async function createAccountDirectory(accountName: string): Promise<{ + success: boolean; + configDir: string; + error?: string; +}> { + const homeDir = os.homedir(); + const baseDir = path.join(homeDir, '.claude'); + const configDir = path.join(homeDir, `.claude-${accountName}`); + + try { + // Check if directory already exists + try { + await fs.access(configDir); + return { success: false, configDir, error: `Directory ${configDir} already exists` }; + } catch { + // Good — doesn't exist yet + } + + // Validate base directory + const validation = await validateBaseClaudeDir(); + if (!validation.valid) { + return { success: false, configDir, error: validation.errors.join('; ') }; + } + + // Create the account directory + await fs.mkdir(configDir, { recursive: true }); + logger.info(`Created account directory: ${configDir}`, LOG_CONTEXT); + + // Create symlinks for shared resources + for (const resource of SHARED_SYMLINKS) { + const source = path.join(baseDir, resource); + const target = path.join(configDir, resource); + + try { + await fs.access(source); + // Check if target already exists + try { + await fs.lstat(target); + // Already exists (maybe from a previous attempt) — skip + continue; + } catch { + // Doesn't exist — create symlink + } + await fs.symlink(source, target); + logger.info(`Symlinked ${resource}`, LOG_CONTEXT); + } catch { + // Source doesn't exist — not all resources are required + logger.warn(`Skipped symlink for ${resource} (source not found)`, LOG_CONTEXT); + } + } + + return { success: true, configDir }; + } catch (error) { + logger.error('Failed to create account directory', LOG_CONTEXT, { error: String(error) }); + return { success: false, configDir, error: String(error) }; + } +} + +/** + * Validate an account directory's symlinks are intact. + * Returns list of broken or missing symlinks. + */ +export async function validateAccountSymlinks(configDir: string): Promise<{ + valid: boolean; + broken: string[]; + missing: string[]; +}> { + const baseDir = path.join(os.homedir(), '.claude'); + const broken: string[] = []; + const missing: string[] = []; + + for (const resource of SHARED_SYMLINKS) { + const target = path.join(configDir, resource); + try { + const stat = await fs.lstat(target); + if (stat.isSymbolicLink()) { + // Check if symlink target exists + try { + await fs.stat(target); // follows symlink + } catch { + broken.push(resource); + } + } + // Not a symlink — could be a real file/dir, which is fine + } catch { + // Missing entirely — check if source exists + try { + await fs.access(path.join(baseDir, resource)); + missing.push(resource); + } catch { + // Source also doesn't exist — OK, resource is optional + } + } + } + + return { valid: broken.length === 0 && missing.length === 0, broken, missing }; +} + +/** + * Repair broken or missing symlinks for an account directory. + */ +export async function repairAccountSymlinks(configDir: string): Promise<{ + repaired: string[]; + errors: string[]; +}> { + const baseDir = path.join(os.homedir(), '.claude'); + const { broken, missing } = await validateAccountSymlinks(configDir); + const repaired: string[] = []; + const errors: string[] = []; + + for (const resource of [...broken, ...missing]) { + const source = path.join(baseDir, resource); + const target = path.join(configDir, resource); + try { + // Remove broken symlink if exists + try { await fs.unlink(target); } catch { /* didn't exist */ } + await fs.symlink(source, target); + repaired.push(resource); + } catch (err) { + errors.push(`Failed to repair ${resource}: ${err}`); + } + } + + return { repaired, errors }; +} + +/** + * Sync credentials from the base ~/.claude directory to an account directory. + * Used after the user runs `claude login` in the base dir to propagate + * fresh OAuth tokens to the account directory. + * + * Copies .credentials.json from ~/.claude to the target configDir. + */ +export async function syncCredentialsFromBase(configDir: string): Promise<{ + success: boolean; + error?: string; +}> { + const baseDir = path.join(os.homedir(), '.claude'); + const baseCreds = path.join(baseDir, '.credentials.json'); + const targetCreds = path.join(configDir, '.credentials.json'); + + try { + // Verify base credentials exist + try { + await fs.access(baseCreds); + } catch { + return { success: false, error: 'No .credentials.json found in base ~/.claude directory' }; + } + + // Verify target directory exists + try { + const stat = await fs.stat(configDir); + if (!stat.isDirectory()) { + return { success: false, error: `${configDir} is not a directory` }; + } + } catch { + return { success: false, error: `${configDir} does not exist` }; + } + + // Copy the credentials + const content = await fs.readFile(baseCreds, 'utf-8'); + await fs.writeFile(targetCreds, content, 'utf-8'); + + logger.info(`Synced credentials from ${baseCreds} to ${targetCreds}`, LOG_CONTEXT); + return { success: true }; + } catch (error) { + logger.error('Failed to sync credentials', LOG_CONTEXT, { error: String(error) }); + return { success: false, error: String(error) }; + } +} + +/** + * Build the command string to launch `claude login` for a specific account. + * This should be run in a Maestro terminal session. + */ +export function buildLoginCommand(configDir: string, claudeBinaryPath?: string): string { + const binary = claudeBinaryPath || 'claude'; + return `CLAUDE_CONFIG_DIR="${configDir}" ${binary} login`; +} + +/** + * Remove an account directory. Does NOT remove symlink targets (shared resources). + * Only removes the account-specific directory and its contents. + */ +export async function removeAccountDirectory(configDir: string): Promise<{ + success: boolean; + error?: string; +}> { + try { + // Safety check: only remove directories matching ~/.claude-* pattern + const basename = path.basename(configDir); + if (!basename.startsWith('.claude-')) { + return { success: false, error: 'Safety check failed: directory name must start with .claude-' }; + } + + await fs.rm(configDir, { recursive: true, force: true }); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } +} + +/** + * Validate that an account directory exists on a remote host. + * Uses SSH to check directory existence and symlink integrity. + * Called before spawning an SSH session with a specific account. + * + * @param sshConfig - The SSH remote config from the session + * @param configDir - The CLAUDE_CONFIG_DIR path (e.g., ~/.claude-work) + * @returns Validation result with details about remote directory state + */ +export async function validateRemoteAccountDir( + sshConfig: { host: string; user?: string; port?: number }, + configDir: string, +): Promise<{ + exists: boolean; + hasAuth: boolean; + symlinksValid: boolean; + error?: string; +}> { + const sshTarget = sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host; + const sshArgs: string[] = []; + if (sshConfig.port) sshArgs.push('-p', String(sshConfig.port)); + sshArgs.push(sshTarget); + + try { + // Check directory exists + const checkCmd = `test -d "${configDir}" && echo "DIR_EXISTS" || echo "DIR_MISSING"`; + const { stdout: dirCheck } = await execFileAsync('ssh', [...sshArgs, checkCmd], { timeout: 10000 }); + + if (dirCheck.trim() === 'DIR_MISSING') { + return { exists: false, hasAuth: false, symlinksValid: false }; + } + + // Check .claude.json exists (auth) + const authCmd = `test -f "${configDir}/.claude.json" && echo "AUTH_EXISTS" || echo "AUTH_MISSING"`; + const { stdout: authCheck } = await execFileAsync('ssh', [...sshArgs, authCmd], { timeout: 10000 }); + const hasAuth = authCheck.trim() === 'AUTH_EXISTS'; + + // Check symlinks (projects/ is the critical one for --resume) + const symlinkCmd = `test -L "${configDir}/projects" && test -d "${configDir}/projects" && echo "SYMLINKS_OK" || echo "SYMLINKS_BROKEN"`; + const { stdout: symlinkCheck } = await execFileAsync('ssh', [...sshArgs, symlinkCmd], { timeout: 10000 }); + const symlinksValid = symlinkCheck.trim() === 'SYMLINKS_OK'; + + return { exists: true, hasAuth, symlinksValid }; + } catch (error) { + return { exists: false, hasAuth: false, symlinksValid: false, error: String(error) }; + } +} diff --git a/src/main/accounts/account-switcher.ts b/src/main/accounts/account-switcher.ts new file mode 100644 index 000000000..8d4ccd626 --- /dev/null +++ b/src/main/accounts/account-switcher.ts @@ -0,0 +1,145 @@ +/** + * Account Switcher Service + * + * Orchestrates the actual account switch for a session: + * 1. Kills the current agent process + * 2. Updates the session's account assignment + * 3. Sends respawn event to renderer (which handles spawn with --resume + new CLAUDE_CONFIG_DIR) + * 4. Notifies renderer of switch completion + */ + +import type { ProcessManager } from '../process-manager/ProcessManager'; +import type { AccountRegistry } from './account-registry'; +import type { AccountSwitchEvent } from '../../shared/account-types'; +import type { SafeSendFn } from '../utils/safe-send'; +import { logger } from '../utils/logger'; + +const LOG_CONTEXT = 'account-switcher'; + +/** Delay between killing old process and sending respawn event (ms) */ +const SWITCH_DELAY_MS = 1000; + +export class AccountSwitcher { + /** Tracks the last user prompt per session for re-sending after switch */ + private lastPrompts = new Map(); + + constructor( + private processManager: ProcessManager, + private accountRegistry: AccountRegistry, + private safeSend: SafeSendFn, + ) {} + + /** + * Record the last user prompt sent to a session. + * Called by the process write handler so we can re-send after switching. + */ + recordLastPrompt(sessionId: string, prompt: string): void { + this.lastPrompts.set(sessionId, prompt); + } + + /** + * Execute an account switch for a session. + * 1. Kill the current agent process + * 2. Update the session's account assignment + * 3. Restart with --resume using the new account's CLAUDE_CONFIG_DIR + * 4. Re-send the last user prompt + * + * Returns the switch event on success, or null on failure. + */ + async executeSwitch(params: { + sessionId: string; + fromAccountId: string; + toAccountId: string; + reason: AccountSwitchEvent['reason']; + automatic: boolean; + }): Promise { + const { sessionId, fromAccountId, toAccountId, reason, automatic } = params; + + try { + const toAccount = this.accountRegistry.get(toAccountId); + if (!toAccount) { + logger.error('Target account not found', LOG_CONTEXT, { toAccountId }); + return null; + } + + const fromAccount = this.accountRegistry.get(fromAccountId); + const lastPrompt = this.lastPrompts.get(sessionId); + + logger.info(`Switching session ${sessionId} from ${fromAccount?.name ?? fromAccountId} to ${toAccount.name}`, LOG_CONTEXT); + + // Notify renderer that switch is starting + this.safeSend('account:switch-started', { + sessionId, + fromAccountId, + toAccountId, + toAccountName: toAccount.name, + }); + + // 1. Kill the current agent process + const killed = this.processManager.kill(sessionId); + if (!killed) { + logger.warn('Could not kill process (may have already exited)', LOG_CONTEXT, { sessionId }); + } + + // Wait for process cleanup + await new Promise(resolve => setTimeout(resolve, SWITCH_DELAY_MS)); + + // 2. Update the account assignment + this.accountRegistry.assignToSession(sessionId, toAccountId); + + // 3. Send respawn event to renderer with the new account config. + // The renderer has access to the full session config and will call process:spawn + // with the correct parameters including --resume and the new CLAUDE_CONFIG_DIR. + this.safeSend('account:switch-respawn', { + sessionId, + toAccountId, + toAccountName: toAccount.name, + configDir: toAccount.configDir, + lastPrompt: lastPrompt ?? null, + reason, + }); + + // 4. Create the switch event + const switchEvent: AccountSwitchEvent = { + sessionId, + fromAccountId, + toAccountId, + reason, + automatic, + timestamp: Date.now(), + }; + + // Notify renderer that switch is complete + this.safeSend('account:switch-completed', { + ...switchEvent, + fromAccountName: fromAccount?.name ?? fromAccountId, + toAccountName: toAccount.name, + }); + + logger.info(`Account switch completed for session ${sessionId}`, LOG_CONTEXT, { + from: fromAccount?.name, to: toAccount.name, reason, + }); + + return switchEvent; + + } catch (error) { + logger.error('Account switch failed', LOG_CONTEXT, { + error: String(error), sessionId, fromAccountId, toAccountId, + }); + + this.safeSend('account:switch-failed', { + sessionId, + fromAccountId, + toAccountId, + error: String(error), + }); + + return null; + } + } + + /** Clean up tracking data when a session is closed */ + cleanupSession(sessionId: string): void { + this.lastPrompts.delete(sessionId); + } +} diff --git a/src/main/accounts/account-throttle-handler.ts b/src/main/accounts/account-throttle-handler.ts new file mode 100644 index 000000000..f8808213d --- /dev/null +++ b/src/main/accounts/account-throttle-handler.ts @@ -0,0 +1,143 @@ +/** + * Account Throttle Handler + * + * Handles throttle/rate-limit detection for account multiplexing. + * When a throttle is detected: + * 1. Records the throttle event in stats DB + * 2. Marks the account as throttled + * 3. Determines if auto-switch should occur + * 4. Notifies the renderer with switch recommendation + */ + +import type { AccountRegistry } from './account-registry'; +import type { StatsDB } from '../stats'; +import { DEFAULT_TOKEN_WINDOW_MS } from '../../shared/account-types'; +import { getWindowBounds } from './account-utils'; + +const LOG_CONTEXT = 'account-throttle'; + +export interface ThrottleContext { + sessionId: string; + accountId: string; + errorType: string; + errorMessage: string; +} + +export class AccountThrottleHandler { + constructor( + private accountRegistry: AccountRegistry, + private getStatsDB: () => StatsDB, + private safeSend: (channel: string, ...args: unknown[]) => void, + private logger: { + info: (message: string, context: string, data?: Record) => void; + error: (message: string, context: string, data?: Record) => void; + warn: (message: string, context: string, data?: Record) => void; + }, + ) {} + + /** + * Called when a rate_limited or similar error is detected on a session + * that has an account assignment. + */ + handleThrottle(context: ThrottleContext): void { + const { sessionId, accountId, errorType, errorMessage } = context; + + try { + // 1. Look up the account + const account = this.accountRegistry.get(accountId); + if (!account) return; + + const statsDb = this.getStatsDB(); + if (!statsDb.isReady()) return; + + const windowMs = account.tokenWindowMs || DEFAULT_TOKEN_WINDOW_MS; + const now = Date.now(); + const { start, end } = getWindowBounds(now, windowMs); + + // Get tokens at time of throttle + const usage = statsDb.getAccountUsageInWindow(accountId, start, end); + const tokensAtThrottle = usage.inputTokens + usage.outputTokens + + usage.cacheReadTokens + usage.cacheCreationTokens; + + // Record throttle event + statsDb.insertThrottleEvent( + accountId, sessionId, errorType, + tokensAtThrottle, start, end + ); + + // 2. Mark account as throttled + this.accountRegistry.setStatus(accountId, 'throttled'); + this.logger.warn(`Account ${account.name} throttled`, LOG_CONTEXT, { + reason: errorType, tokens: tokensAtThrottle, sessionId, + }); + + // 3. Determine if auto-switch should occur + const switchConfig = this.accountRegistry.getSwitchConfig(); + if (!switchConfig.enabled) { + // Auto-switching disabled — just notify + this.safeSend('account:throttled', { + accountId, + accountName: account.name, + sessionId, + reason: errorType, + message: errorMessage, + tokensAtThrottle, + autoSwitchAvailable: false, + }); + return; + } + + // 4. Find next available account (capacity-aware when stats are available) + const statsDb2 = this.getStatsDB(); + const nextAccount = this.accountRegistry.selectNextAccount( + [accountId], + statsDb2.isReady() ? statsDb2 : undefined + ); + if (!nextAccount) { + // No alternative accounts available + this.safeSend('account:throttled', { + accountId, + accountName: account.name, + sessionId, + reason: errorType, + message: errorMessage, + tokensAtThrottle, + autoSwitchAvailable: false, + noAlternatives: true, + }); + this.logger.warn('No alternative accounts available for switching', LOG_CONTEXT); + return; + } + + // 5. Notify renderer with switch recommendation + if (switchConfig.promptBeforeSwitch) { + // Prompt mode: ask user to confirm switch + this.safeSend('account:switch-prompt', { + sessionId, + fromAccountId: accountId, + fromAccountName: account.name, + toAccountId: nextAccount.id, + toAccountName: nextAccount.name, + reason: errorType, + tokensAtThrottle, + }); + } else { + // Auto mode: tell renderer to execute switch immediately + this.safeSend('account:switch-execute', { + sessionId, + fromAccountId: accountId, + fromAccountName: account.name, + toAccountId: nextAccount.id, + toAccountName: nextAccount.name, + reason: errorType, + automatic: true, + }); + } + + } catch (error) { + this.logger.error('Failed to handle throttle', LOG_CONTEXT, { + error: String(error), sessionId, accountId, + }); + } + } +} diff --git a/src/main/accounts/account-utils.ts b/src/main/accounts/account-utils.ts new file mode 100644 index 000000000..5aad7e41d --- /dev/null +++ b/src/main/accounts/account-utils.ts @@ -0,0 +1,17 @@ +/** + * Shared utilities for account multiplexing. + */ + +/** + * Calculate the window boundaries for a given timestamp and window size. + * Windows are aligned to fixed intervals from midnight. + */ +export function getWindowBounds(timestamp: number, windowMs: number): { start: number; end: number } { + const dayStart = new Date(timestamp); + dayStart.setHours(0, 0, 0, 0); + const dayStartMs = dayStart.getTime(); + const windowsSinceDayStart = Math.floor((timestamp - dayStartMs) / windowMs); + const start = dayStartMs + windowsSinceDayStart * windowMs; + const end = start + windowMs; + return { start, end }; +} diff --git a/src/main/constants.ts b/src/main/constants.ts index 7ea4307dd..490fefb21 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -32,6 +32,10 @@ export const REGEX_AI_TAB_ID = /-ai-(.+)$/; export const REGEX_BATCH_SESSION = /-batch-\d+$/; export const REGEX_SYNOPSIS_SESSION = /-synopsis-\d+$/; +// Combined pattern to strip all session ID suffixes back to the base session ID. +// Matches: -ai-{tabId}, -terminal, -batch-{timestamp}, -synopsis-{timestamp} +export const REGEX_SESSION_SUFFIX = /-ai-.+$|-terminal$|-batch-\d+$|-synopsis-\d+$/; + // ============================================================================ // Buffer Size Limits // ============================================================================ diff --git a/src/main/group-chat/group-chat-agent.ts b/src/main/group-chat/group-chat-agent.ts index 8e42179a4..4cca3bbd5 100644 --- a/src/main/group-chat/group-chat-agent.ts +++ b/src/main/group-chat/group-chat-agent.ts @@ -29,6 +29,8 @@ import { groupChatParticipantPrompt } from '../../prompts'; import { wrapSpawnWithSsh } from '../utils/ssh-spawn-wrapper'; import type { SshRemoteSettingsStore } from '../utils/ssh-remote-resolver'; import { getWindowsSpawnConfig } from './group-chat-config'; +import type { AccountRegistry } from '../accounts/account-registry'; +import { injectAccountEnv } from '../accounts/account-env-injector'; /** * In-memory store for active participant sessions. @@ -88,6 +90,8 @@ export interface SessionOverrides { * @param customEnvVars - Optional custom environment variables for the agent (deprecated, use sessionOverrides) * @param sessionOverrides - Optional session-specific overrides (customModel, customArgs, customEnvVars, sshRemoteConfig) * @param sshStore - Optional SSH settings store for remote execution support + * @param accountRegistry - Optional account registry for account multiplexing + * @param accountId - Optional account ID to use for this participant * @returns The created participant */ export async function addParticipant( @@ -100,7 +104,9 @@ export async function addParticipant( agentConfigValues?: Record, customEnvVars?: Record, sessionOverrides?: SessionOverrides, - sshStore?: SshRemoteSettingsStore + sshStore?: SshRemoteSettingsStore, + accountRegistry?: AccountRegistry, + accountId?: string, ): Promise { console.log(`[GroupChat:Debug] ========== ADD PARTICIPANT ==========`); console.log(`[GroupChat:Debug] Group Chat ID: ${groupChatId}`); @@ -184,6 +190,21 @@ export async function addParticipant( let spawnShell: string | undefined; let spawnRunInShell = false; + // Inject CLAUDE_CONFIG_DIR for account multiplexing + if (accountRegistry) { + const envToInject: Record = spawnEnvVars ? { ...spawnEnvVars } : {}; + const assignedId = injectAccountEnv( + sessionId, + agentId, + envToInject, + accountRegistry, + accountId, + ); + if (assignedId) { + spawnEnvVars = envToInject; + } + } + // Apply SSH wrapping if SSH is configured and store is available if (sshStore && sessionOverrides?.sshRemoteConfig) { console.log(`[GroupChat:Debug] Applying SSH wrapping for participant...`); diff --git a/src/main/group-chat/group-chat-router.ts b/src/main/group-chat/group-chat-router.ts index 42dcc5718..f87a51cda 100644 --- a/src/main/group-chat/group-chat-router.ts +++ b/src/main/group-chat/group-chat-router.ts @@ -44,6 +44,8 @@ import { groupChatParticipantRequestPrompt } from '../../prompts'; import { wrapSpawnWithSsh } from '../utils/ssh-spawn-wrapper'; import type { SshRemoteSettingsStore } from '../utils/ssh-remote-resolver'; import { setGetCustomShellPathCallback, getWindowsSpawnConfig } from './group-chat-config'; +import type { AccountRegistry } from '../accounts/account-registry'; +import { injectAccountEnv } from '../accounts/account-env-injector'; // Import emitters from IPC handlers (will be populated after handlers are registered) import { groupChatEmitters } from '../ipc/handlers/groupChat'; @@ -95,6 +97,9 @@ let getAgentConfigCallback: GetAgentConfigCallback | null = null; // Module-level SSH store for remote execution support let sshStore: SshRemoteSettingsStore | null = null; +// Module-level account registry for account multiplexing +let accountRegistryRef: AccountRegistry | null = null; + /** * Tracks pending participant responses for each group chat. * When all pending participants have responded, we spawn a moderator synthesis round. @@ -182,6 +187,14 @@ export function setSshStore(store: SshRemoteSettingsStore): void { sshStore = store; } +/** + * Sets the account registry for account multiplexing. + * Called from index.ts during initialization. + */ +export function setAccountRegistry(registry: AccountRegistry): void { + accountRegistryRef = registry; +} + /** * Extracts @mentions from text that match known participants. * Supports hyphenated names matching participants with spaces. @@ -333,7 +346,10 @@ export async function routeUserMessage( sshRemoteConfig: matchingSession.sshRemoteConfig, }, // Pass SSH store for remote execution support - sshStore ?? undefined + sshStore ?? undefined, + // Pass account registry and group-level account ID for multiplexing + accountRegistryRef ?? undefined, + chat.accountId, ); existingParticipantNames.add(participantName); @@ -504,6 +520,21 @@ ${message}`; let spawnShell: string | undefined; let spawnRunInShell = false; + // Inject CLAUDE_CONFIG_DIR for account multiplexing (moderator) + if (accountRegistryRef) { + const envToInject: Record = spawnEnvVars ? { ...spawnEnvVars } : {}; + const assignedId = injectAccountEnv( + sessionId, + chat.moderatorAgentId, + envToInject, + accountRegistryRef, + chat.accountId, + ); + if (assignedId) { + spawnEnvVars = envToInject; + } + } + // Apply SSH wrapping if configured if (sshStore && chat.moderatorConfig?.sshRemoteConfig) { console.log(`[GroupChat:Debug] Applying SSH wrapping for moderator...`); @@ -719,7 +750,10 @@ export async function routeModeratorResponse( sshRemoteConfig: matchingSession.sshRemoteConfig, }, // Pass SSH store for remote execution support - sshStore ?? undefined + sshStore ?? undefined, + // Pass account registry and group-level account ID for multiplexing + accountRegistryRef ?? undefined, + chat.accountId, ); existingParticipantNames.add(participantName); @@ -890,6 +924,21 @@ export async function routeModeratorResponse( let finalSpawnShell: string | undefined; let finalSpawnRunInShell = false; + // Inject CLAUDE_CONFIG_DIR for account multiplexing (participant batch spawn) + if (accountRegistryRef) { + const envToInject: Record = finalSpawnEnvVars ? { ...finalSpawnEnvVars } : {}; + const assignedId = injectAccountEnv( + sessionId, + participant.agentId, + envToInject, + accountRegistryRef, + updatedChat.accountId, + ); + if (assignedId) { + finalSpawnEnvVars = envToInject; + } + } + // Apply SSH wrapping if configured for this session if (sshStore && matchingSession?.sshRemoteConfig) { console.log( @@ -1262,6 +1311,24 @@ Review the agent responses above. Either: console.log(`[GroupChat:Debug] Windows shell config for synthesis: ${winConfig.shell}`); } + // Inject CLAUDE_CONFIG_DIR for account multiplexing (synthesis moderator) + let synthesisEnvVars = + configResolution.effectiveCustomEnvVars ?? + getCustomEnvVarsCallback?.(chat.moderatorAgentId); + if (accountRegistryRef) { + const envToInject: Record = synthesisEnvVars ? { ...synthesisEnvVars } : {}; + const assignedId = injectAccountEnv( + sessionId, + chat.moderatorAgentId, + envToInject, + accountRegistryRef, + chat.accountId, + ); + if (assignedId) { + synthesisEnvVars = envToInject; + } + } + const spawnResult = processManager.spawn({ sessionId, toolType: chat.moderatorAgentId, @@ -1271,9 +1338,7 @@ Review the agent responses above. Either: readOnlyMode: true, prompt: synthesisPrompt, contextWindow: getContextWindowValue(agent, agentConfigValues), - customEnvVars: - configResolution.effectiveCustomEnvVars ?? - getCustomEnvVarsCallback?.(chat.moderatorAgentId), + customEnvVars: synthesisEnvVars, promptArgs: agent.promptArgs, noPromptSeparator: agent.noPromptSeparator, shell: winConfig.shell, @@ -1422,6 +1487,21 @@ export async function respawnParticipantWithRecovery( let finalSpawnShell: string | undefined; let finalSpawnRunInShell = false; + // Inject CLAUDE_CONFIG_DIR for account multiplexing (recovery spawn) + if (accountRegistryRef) { + const envToInject: Record = finalSpawnEnvVars ? { ...finalSpawnEnvVars } : {}; + const assignedId = injectAccountEnv( + sessionId, + participant.agentId, + envToInject, + accountRegistryRef, + chat.accountId, + ); + if (assignedId) { + finalSpawnEnvVars = envToInject; + } + } + console.log(`[GroupChat:Debug] Recovery spawn command: ${finalSpawnCommand}`); console.log(`[GroupChat:Debug] Recovery spawn args count: ${finalSpawnArgs.length}`); diff --git a/src/main/group-chat/group-chat-storage.ts b/src/main/group-chat/group-chat-storage.ts index d9064c16a..5ab8b3238 100644 --- a/src/main/group-chat/group-chat-storage.ts +++ b/src/main/group-chat/group-chat-storage.ts @@ -128,6 +128,8 @@ export interface GroupChat { participants: GroupChatParticipant[]; logPath: string; imagesDir: string; + /** Account ID for all participants in this group chat */ + accountId?: string; } /** diff --git a/src/main/index.ts b/src/main/index.ts index 2794e1dc5..670efe6c3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -52,12 +52,22 @@ import { registerTabNamingHandlers, registerAgentErrorHandlers, registerDirectorNotesHandlers, + registerAccountHandlers, + registerProviderHandlers, registerWakatimeHandlers, setupLoggerEventForwarding, cleanupAllGroomingSessions, getActiveGroomingSessionCount, } from './ipc/handlers'; import { initializeStatsDB, closeStatsDB, getStatsDB } from './stats'; +import { AccountRegistry } from './accounts/account-registry'; +import { AccountThrottleHandler } from './accounts/account-throttle-handler'; +import { AccountAuthRecovery } from './accounts/account-auth-recovery'; +import { AccountRecoveryPoller } from './accounts/account-recovery-poller'; +import { AccountSwitcher } from './accounts/account-switcher'; +import { ProviderErrorTracker } from './providers/provider-error-tracker'; +import { DEFAULT_PROVIDER_SWITCH_CONFIG } from '../shared/account-types'; +import { getAccountStore } from './stores'; import { groupChatEmitters } from './ipc/handlers/groupChat'; import { routeModeratorResponse, @@ -66,6 +76,7 @@ import { setGetCustomEnvVarsCallback, setGetAgentConfigCallback, setSshStore, + setAccountRegistry as setGroupChatAccountRegistry, setGetCustomShellPathCallback, markParticipantResponded, spawnModeratorSynthesis, @@ -177,6 +188,13 @@ if (store.get('wakatimeEnabled', false)) { wakatimeManager.ensureCliInstalled(); } +// Update provider error tracker when failover config changes +store.onDidChange('providerSwitchConfig' as any, (newValue: any) => { + if (providerErrorTracker && newValue && typeof newValue === 'object') { + providerErrorTracker.updateConfig({ ...DEFAULT_PROVIDER_SWITCH_CONFIG, ...newValue }); + } +}); + // Auto-install WakaTime CLI when user enables the feature store.onDidChange('wakatimeEnabled', (newValue) => { if (newValue === true) { @@ -241,6 +259,12 @@ let mainWindow: BrowserWindow | null = null; let processManager: ProcessManager | null = null; let webServer: WebServer | null = null; let agentDetector: AgentDetector | null = null; +let accountRegistry: AccountRegistry | null = null; +let accountThrottleHandler: AccountThrottleHandler | null = null; +let accountAuthRecovery: AccountAuthRecovery | null = null; +let accountRecoveryPoller: AccountRecoveryPoller | null = null; +let accountSwitcher: AccountSwitcher | null = null; +let providerErrorTracker: ProviderErrorTracker | null = null; // Create safeSend with dependency injection (Phase 2 refactoring) const safeSend = createSafeSend(() => mainWindow); @@ -360,6 +384,82 @@ app.whenReady().then(async () => { logger.warn('Continuing without stats - usage tracking will be unavailable', 'Startup'); } + // Initialize account registry, throttle handler, and auth recovery for account multiplexing + try { + accountRegistry = new AccountRegistry(getAccountStore()); + accountThrottleHandler = new AccountThrottleHandler( + accountRegistry, getStatsDB, safeSend, logger + ); + logger.info('Account registry initialized', 'Startup'); + } catch (error) { + logger.error(`Failed to initialize account registry: ${error}`, 'Startup'); + logger.warn('Continuing without account multiplexing', 'Startup'); + } + + // Initialize auth recovery for automatic re-login on expired tokens + if (accountRegistry && processManager && agentDetector) { + try { + accountAuthRecovery = new AccountAuthRecovery( + processManager, accountRegistry, agentDetector, safeSend + ); + logger.info('Account auth recovery initialized', 'Startup'); + } catch (error) { + logger.error(`Failed to initialize auth recovery: ${error}`, 'Startup'); + } + } + + // Initialize recovery poller for timer-based throttle recovery + if (accountRegistry) { + try { + accountRecoveryPoller = new AccountRecoveryPoller({ + accountRegistry, + safeSend, + }); + accountRecoveryPoller.start(); + logger.info('Account recovery poller started', 'Startup'); + } catch (error) { + logger.error(`Failed to initialize recovery poller: ${error}`, 'Startup'); + } + } + + // Initialize account switcher for manual account switching from renderer + if (accountRegistry && processManager) { + try { + accountSwitcher = new AccountSwitcher( + processManager, + accountRegistry, + safeSend, + ); + logger.info('Account switcher initialized', 'Startup'); + } catch (error) { + logger.error(`Failed to initialize account switcher: ${error}`, 'Startup'); + } + } + + // Initialize provider error tracker for Virtuosos failover detection + try { + const savedConfig = store.get('providerSwitchConfig') as any; + const config = savedConfig + ? { ...DEFAULT_PROVIDER_SWITCH_CONFIG, ...savedConfig } + : DEFAULT_PROVIDER_SWITCH_CONFIG; + providerErrorTracker = new ProviderErrorTracker( + config, + (suggestion) => { + // Send failover suggestion to renderer + safeSend('provider:failover-suggest', suggestion); + }, + (sessionId) => { + // Resolve session name from sessions store + const sessions = sessionsStore.get('sessions', []) as any[]; + const session = sessions.find((s: any) => s.id === sessionId); + return session?.name || sessionId; + }, + ); + logger.info('Provider error tracker initialized', 'Startup'); + } catch (error) { + logger.error(`Failed to initialize provider error tracker: ${error}`, 'Startup'); + } + // Set up IPC handlers logger.debug('Setting up IPC handlers', 'Startup'); setupIpcHandlers(); @@ -417,6 +517,11 @@ const quitHandler = createQuitHandler({ }); quitHandler.setup(); +// Stop recovery poller on quit (must run before the quit handler's cleanup) +app.on('before-quit', () => { + accountRecoveryPoller?.stop(); +}); + // startCliActivityWatcher is now handled by cliWatcher (Phase 4 refactoring) function setupIpcHandlers() { @@ -476,6 +581,10 @@ function setupIpcHandlers() { settingsStore: store, getMainWindow: () => mainWindow, sessionsStore, + getAccountRegistry: () => accountRegistry, + getAccountAuthRecovery: () => accountAuthRecovery, + getAccountSwitcher: () => accountSwitcher, + safeSend, }); // Persistence operations - extracted to src/main/ipc/handlers/persistence.ts @@ -555,6 +664,7 @@ function setupIpcHandlers() { getMainWindow: () => mainWindow, getProcessManager: () => processManager, getAgentDetector: () => agentDetector, + getAccountRegistry: () => accountRegistry, }); // Register Marketplace handlers for fetching and importing playbooks @@ -569,6 +679,19 @@ function setupIpcHandlers() { settingsStore: store, }); + // Register Account Multiplexing handlers (CRUD, assignments, usage queries) + registerAccountHandlers({ + getAccountRegistry: () => accountRegistry, + getAccountAuthRecovery: () => accountAuthRecovery, + getRecoveryPoller: () => accountRecoveryPoller, + getAccountSwitcher: () => accountSwitcher, + }); + + // Register Provider Error Tracking handlers (stats queries, error clearing) + registerProviderHandlers({ + getProviderErrorTracker: () => providerErrorTracker, + }); + // Register Document Graph handlers for file watching registerDocumentGraphHandlers({ getMainWindow: () => mainWindow, @@ -612,6 +735,11 @@ function setupIpcHandlers() { // Set up SSH store for group chat SSH remote execution support setSshStore(createSshRemoteStoreAdapter(store)); + // Set up account registry for group chat account multiplexing + if (accountRegistry) { + setGroupChatAccountRegistry(accountRegistry); + } + // Set up callback for group chat to get custom shell path (for Windows PowerShell preference) // This is used by both group-chat-router.ts and group-chat-agent.ts via the shared config module const getCustomShellPathFn = () => store.get('customShellPath', '') as string | undefined; @@ -703,6 +831,10 @@ function setupProcessListeners() { calculateContextTokens, }, getStatsDB, + getAccountRegistry: () => accountRegistry, + getThrottleHandler: () => accountThrottleHandler, + getAuthRecovery: () => accountAuthRecovery, + getProviderErrorTracker: () => providerErrorTracker, debugLog, patterns: { REGEX_MODERATOR_SESSION, diff --git a/src/main/ipc/handlers/accounts.ts b/src/main/ipc/handlers/accounts.ts new file mode 100644 index 000000000..3abfce5e3 --- /dev/null +++ b/src/main/ipc/handlers/accounts.ts @@ -0,0 +1,530 @@ +/** + * Account Multiplexing IPC Handlers + * + * Registers IPC handlers for all account management operations: + * - CRUD operations for account profiles + * - Session-to-account assignments + * - Usage queries (windowed token consumption) + * - Throttle event queries (capacity planning) + * - Switch configuration management + * - Account selection (default, next available) + */ + +import { ipcMain } from 'electron'; +import type { AccountRegistry } from '../../accounts/account-registry'; +import type { AccountSwitcher } from '../../accounts/account-switcher'; +import type { AccountAuthRecovery } from '../../accounts/account-auth-recovery'; +import type { AccountRecoveryPoller } from '../../accounts/account-recovery-poller'; +import type { AccountSwitchConfig, AccountSwitchEvent, MultiplexableAgent } from '../../../shared/account-types'; +import { getStatsDB } from '../../stats'; +import { logger } from '../../utils/logger'; +import { + validateBaseClaudeDir, + discoverExistingAccounts, + createAccountDirectory, + validateAccountSymlinks, + repairAccountSymlinks, + readAccountEmail, + buildLoginCommand, + removeAccountDirectory, + validateRemoteAccountDir, + syncCredentialsFromBase, +} from '../../accounts/account-setup'; + +const LOG_CONTEXT = '[Accounts]'; + +/** + * Dependencies for account handlers + */ +export interface AccountHandlerDependencies { + getAccountRegistry: () => AccountRegistry | null; + getAccountSwitcher?: () => AccountSwitcher | null; + getAccountAuthRecovery?: () => AccountAuthRecovery | null; + getRecoveryPoller?: () => AccountRecoveryPoller | null; +} + +/** + * Register all account multiplexing IPC handlers. + */ +export function registerAccountHandlers(deps: AccountHandlerDependencies): void { + const { getAccountRegistry, getAccountSwitcher, getAccountAuthRecovery, getRecoveryPoller } = deps; + + /** Get the account registry or throw if not initialized */ + function requireRegistry(): AccountRegistry { + const registry = getAccountRegistry(); + if (!registry) { + throw new Error('Account registry not initialized'); + } + return registry; + } + + // --- Account CRUD --- + + ipcMain.handle('accounts:list', async () => { + try { + return requireRegistry().getAll(); + } catch (error) { + logger.error('list accounts error', LOG_CONTEXT, { error: String(error) }); + return []; + } + }); + + ipcMain.handle('accounts:get', async (_event, accountId: string) => { + try { + return requireRegistry().get(accountId); + } catch (error) { + logger.error('get account error', LOG_CONTEXT, { error: String(error) }); + return null; + } + }); + + ipcMain.handle('accounts:add', async (_event, params: { + name: string; email: string; configDir: string; agentType?: MultiplexableAgent; + }) => { + try { + const profile = requireRegistry().add(params); + return { success: true, account: profile }; + } catch (error) { + logger.error('add account error', LOG_CONTEXT, { error: String(error) }); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle('accounts:update', async (_event, accountId: string, updates: Record) => { + try { + const updated = requireRegistry().update(accountId, updates); + if (!updated) return { success: false, error: 'Account not found' }; + return { success: true, account: updated }; + } catch (error) { + logger.error('update account error', LOG_CONTEXT, { error: String(error) }); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle('accounts:remove', async (_event, accountId: string) => { + try { + const removed = requireRegistry().remove(accountId); + return { success: removed, error: removed ? undefined : 'Account not found' }; + } catch (error) { + logger.error('remove account error', LOG_CONTEXT, { error: String(error) }); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle('accounts:set-default', async (_event, accountId: string) => { + try { + const updated = requireRegistry().update(accountId, { isDefault: true }); + return { success: !!updated }; + } catch (error) { + logger.error('set default error', LOG_CONTEXT, { error: String(error) }); + return { success: false, error: String(error) }; + } + }); + + // --- Assignments --- + + ipcMain.handle('accounts:assign', async (_event, sessionId: string, accountId: string) => { + try { + const assignment = requireRegistry().assignToSession(sessionId, accountId); + return { success: true, assignment }; + } catch (error) { + logger.error('assign account error', LOG_CONTEXT, { error: String(error) }); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle('accounts:get-assignment', async (_event, sessionId: string) => { + try { + return requireRegistry().getAssignment(sessionId); + } catch (error) { + logger.error('get assignment error', LOG_CONTEXT, { error: String(error) }); + return null; + } + }); + + ipcMain.handle('accounts:get-all-assignments', async () => { + try { + return requireRegistry().getAllAssignments(); + } catch (error) { + logger.error('get all assignments error', LOG_CONTEXT, { error: String(error) }); + return []; + } + }); + + // --- Usage Queries --- + + ipcMain.handle('accounts:get-usage', async (_event, accountId: string, windowStart: number, windowEnd: number) => { + try { + const db = getStatsDB(); + return db.getAccountUsageInWindow(accountId, windowStart, windowEnd); + } catch (error) { + logger.error('get usage error', LOG_CONTEXT, { error: String(error) }); + return null; + } + }); + + ipcMain.handle('accounts:get-all-usage', async () => { + try { + const registry = requireRegistry(); + const db = getStatsDB(); + const accounts = registry.getAll(); + const now = Date.now(); + const results: Record = {}; + + for (const account of accounts) { + const windowMs = account.tokenWindowMs || 5 * 60 * 60 * 1000; + // Align to window boundaries from midnight + const dayStart = new Date(now); + dayStart.setHours(0, 0, 0, 0); + const dayStartMs = dayStart.getTime(); + const windowsSinceDayStart = Math.floor((now - dayStartMs) / windowMs); + const windowStart = dayStartMs + windowsSinceDayStart * windowMs; + const windowEnd = windowStart + windowMs; + + const usage = db.getAccountUsageInWindow(account.id, windowStart, windowEnd); + const totalTokens = usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheCreationTokens; + + results[account.id] = { + ...usage, + totalTokens, + usagePercent: account.tokenLimitPerWindow > 0 + ? Math.min(100, (totalTokens / account.tokenLimitPerWindow) * 100) + : null, + windowStart, + windowEnd, + account, + }; + } + return results; + } catch (error) { + logger.error('get all usage error', LOG_CONTEXT, { error: String(error) }); + return {}; + } + }); + + ipcMain.handle('accounts:get-throttle-events', async (_event, accountId?: string, since?: number) => { + try { + const db = getStatsDB(); + return db.getThrottleEvents(accountId, since); + } catch (error) { + logger.error('get throttle events error', LOG_CONTEXT, { error: String(error) }); + return []; + } + }); + + ipcMain.handle('accounts:get-daily-usage', async (_event, accountId: string, days: number = 30) => { + try { + const db = getStatsDB(); + if (!db?.isReady()) return []; + const now = Date.now(); + const sinceMs = now - days * 24 * 60 * 60 * 1000; + return db.getAccountDailyUsage(accountId, sinceMs, now); + } catch (error) { + logger.error('get daily usage error', LOG_CONTEXT, { error: String(error) }); + return []; + } + }); + + ipcMain.handle('accounts:get-monthly-usage', async (_event, accountId: string, months: number = 6) => { + try { + const db = getStatsDB(); + if (!db?.isReady()) return []; + const now = Date.now(); + const sinceMs = now - months * 30 * 24 * 60 * 60 * 1000; + return db.getAccountMonthlyUsage(accountId, sinceMs, now); + } catch (error) { + logger.error('get monthly usage error', LOG_CONTEXT, { error: String(error) }); + return []; + } + }); + + ipcMain.handle('accounts:get-window-history', async (_event, accountId: string, windowCount: number = 40) => { + try { + const db = getStatsDB(); + if (!db?.isReady()) return []; + return db.getAccountWindowHistory(accountId, windowCount); + } catch (error) { + logger.error('get window history error', LOG_CONTEXT, { error: String(error) }); + return []; + } + }); + + // --- Switch Configuration --- + + ipcMain.handle('accounts:get-switch-config', async () => { + try { + return requireRegistry().getSwitchConfig(); + } catch (error) { + logger.error('get switch config error', LOG_CONTEXT, { error: String(error) }); + return null; + } + }); + + ipcMain.handle('accounts:update-switch-config', async (_event, updates: Partial) => { + try { + const updated = requireRegistry().updateSwitchConfig(updates); + return { success: true, config: updated }; + } catch (error) { + logger.error('update switch config error', LOG_CONTEXT, { error: String(error) }); + return { success: false, error: String(error) }; + } + }); + + // --- Account Selection --- + + ipcMain.handle('accounts:get-default', async () => { + try { + return requireRegistry().getDefaultAccount(); + } catch (error) { + logger.error('get default error', LOG_CONTEXT, { error: String(error) }); + return null; + } + }); + + ipcMain.handle('accounts:select-next', async (_event, excludeIds?: string[]) => { + try { + const db = getStatsDB(); + return requireRegistry().selectNextAccount(excludeIds, db.isReady() ? db : undefined); + } catch (error) { + logger.error('select next error', LOG_CONTEXT, { error: String(error) }); + return null; + } + }); + + // --- Account Setup --- + + ipcMain.handle('accounts:validate-base-dir', async () => { + try { + return await validateBaseClaudeDir(); + } catch (error) { + logger.error('validate base dir error', LOG_CONTEXT, { error: String(error) }); + return { valid: false, baseDir: '', errors: [String(error)] }; + } + }); + + ipcMain.handle('accounts:discover-existing', async () => { + try { + return await discoverExistingAccounts(); + } catch (error) { + logger.error('discover accounts error', LOG_CONTEXT, { error: String(error) }); + return []; + } + }); + + ipcMain.handle('accounts:create-directory', async (_event, accountName: string) => { + try { + return await createAccountDirectory(accountName); + } catch (error) { + logger.error('create directory error', LOG_CONTEXT, { error: String(error) }); + return { success: false, configDir: '', error: String(error) }; + } + }); + + ipcMain.handle('accounts:validate-symlinks', async (_event, configDir: string) => { + try { + return await validateAccountSymlinks(configDir); + } catch (error) { + logger.error('validate symlinks error', LOG_CONTEXT, { error: String(error) }); + return { valid: false, broken: [], missing: [] }; + } + }); + + ipcMain.handle('accounts:repair-symlinks', async (_event, configDir: string) => { + try { + return await repairAccountSymlinks(configDir); + } catch (error) { + logger.error('repair symlinks error', LOG_CONTEXT, { error: String(error) }); + return { repaired: [], errors: [String(error)] }; + } + }); + + ipcMain.handle('accounts:read-email', async (_event, configDir: string) => { + try { + return await readAccountEmail(configDir); + } catch (error) { + logger.error('read email error', LOG_CONTEXT, { error: String(error) }); + return null; + } + }); + + ipcMain.handle('accounts:get-login-command', async (_event, configDir: string) => { + try { + return buildLoginCommand(configDir); + } catch (error) { + logger.error('get login command error', LOG_CONTEXT, { error: String(error) }); + return null; + } + }); + + ipcMain.handle('accounts:remove-directory', async (_event, configDir: string) => { + try { + return await removeAccountDirectory(configDir); + } catch (error) { + logger.error('remove directory error', LOG_CONTEXT, { error: String(error) }); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle('accounts:validate-remote-dir', async (_event, params: { + sshConfig: { host: string; user?: string; port?: number }; + configDir: string; + }) => { + try { + return await validateRemoteAccountDir(params.sshConfig, params.configDir); + } catch (error) { + logger.error('validate remote dir error', LOG_CONTEXT, { error: String(error) }); + return { exists: false, hasAuth: false, symlinksValid: false, error: String(error) }; + } + }); + + ipcMain.handle('accounts:sync-credentials', async (_event, configDir: string) => { + try { + return await syncCredentialsFromBase(configDir); + } catch (error) { + logger.error('sync credentials error', LOG_CONTEXT, { error: String(error) }); + return { success: false, error: String(error) }; + } + }); + + // --- Session Cleanup --- + + ipcMain.handle('accounts:cleanup-session', async (_event, sessionId: string) => { + try { + const registry = getAccountRegistry(); + if (registry) { + registry.removeAssignment(sessionId); + } + const switcher = getAccountSwitcher?.(); + if (switcher) { + switcher.cleanupSession(sessionId); + } + const authRecovery = getAccountAuthRecovery?.(); + if (authRecovery) { + authRecovery.cleanupSession(sessionId); + } + return { success: true }; + } catch (error) { + logger.error('cleanup session error', LOG_CONTEXT, { error: String(error), sessionId }); + return { success: false, error: String(error) }; + } + }); + + // --- Startup Reconciliation --- + + ipcMain.handle('accounts:reconcile-sessions', async (_event, activeSessionIds: string[]) => { + try { + const registry = requireRegistry(); + const idSet = new Set(activeSessionIds); + + // Remove stale assignments for sessions that no longer exist + const removed = registry.reconcileAssignments(idSet); + + // For each active session with an assignment, validate the account still exists + // Return corrections for sessions whose accounts were removed + const corrections: Array<{ + sessionId: string; + accountId: string | null; + accountName: string | null; + configDir: string | null; + status: 'valid' | 'removed' | 'inactive'; + }> = []; + + for (const sessionId of activeSessionIds) { + const assignment = registry.getAssignment(sessionId); + if (!assignment) continue; + + const account = registry.get(assignment.accountId); + if (!account) { + // Account was removed — clear the assignment + registry.removeAssignment(sessionId); + corrections.push({ + sessionId, + accountId: null, + accountName: null, + configDir: null, + status: 'removed', + }); + } else if (account.status !== 'active') { + // Account exists but is throttled/disabled — still usable but warn + corrections.push({ + sessionId, + accountId: account.id, + accountName: account.name, + configDir: account.configDir, + status: 'inactive', + }); + } else { + corrections.push({ + sessionId, + accountId: account.id, + accountName: account.name, + configDir: account.configDir, + status: 'valid', + }); + } + } + + return { success: true, removed, corrections }; + } catch (error) { + logger.error('reconcile sessions error', LOG_CONTEXT, { error: String(error) }); + return { success: false, removed: 0, corrections: [], error: String(error) }; + } + }); + + // --- Account Switching --- + + ipcMain.handle('accounts:execute-switch', async (_event, params: { + sessionId: string; + fromAccountId: string; + toAccountId: string; + reason: AccountSwitchEvent['reason']; + automatic: boolean; + }) => { + try { + const switcher = getAccountSwitcher?.(); + if (!switcher) { + return { success: false, error: 'Account switcher not initialized' }; + } + const result = await switcher.executeSwitch(params); + return { success: !!result, event: result }; + } catch (error) { + logger.error('execute switch error', LOG_CONTEXT, { error: String(error) }); + return { success: false, error: String(error) }; + } + }); + + // --- Auth Recovery --- + + ipcMain.handle('accounts:trigger-auth-recovery', async (_event, sessionId: string) => { + try { + const authRecovery = getAccountAuthRecovery?.(); + if (!authRecovery) { + return { success: false, error: 'Auth recovery not initialized' }; + } + const registry = requireRegistry(); + const assignment = registry.getAssignment(sessionId); + if (!assignment) { + return { success: false, error: 'No account assigned to session' }; + } + const result = await authRecovery.recoverAuth(sessionId, assignment.accountId); + return { success: result }; + } catch (error) { + logger.error('trigger auth recovery error', LOG_CONTEXT, { error: String(error) }); + return { success: false, error: String(error) }; + } + }); + + // --- Recovery Poller --- + + ipcMain.handle('accounts:check-recovery', async () => { + try { + const poller = getRecoveryPoller?.(); + if (!poller) return { recovered: [] }; + const recovered = poller.poll(); + return { recovered }; + } catch (error) { + logger.error('check recovery error', LOG_CONTEXT, { error: String(error) }); + return { recovered: [] }; + } + }); +} diff --git a/src/main/ipc/handlers/context.ts b/src/main/ipc/handlers/context.ts index c427e8183..d73f757e9 100644 --- a/src/main/ipc/handlers/context.ts +++ b/src/main/ipc/handlers/context.ts @@ -24,6 +24,7 @@ import { getSessionStorage, type SessionMessagesResult } from '../../agents'; import { groomContext, cancelAllGroomingSessions } from '../../utils/context-groomer'; import type { ProcessManager } from '../../process-manager'; import type { AgentDetector } from '../../agents'; +import type { AccountRegistry } from '../../accounts/account-registry'; const LOG_CONTEXT = '[ContextMerge]'; @@ -47,6 +48,7 @@ export interface ContextHandlerDependencies { getMainWindow: () => BrowserWindow | null; getProcessManager: () => ProcessManager | null; getAgentDetector: () => AgentDetector | null; + getAccountRegistry: () => AccountRegistry | null; } /** @@ -77,7 +79,7 @@ const GROOMING_TIMEOUT_MS = 5 * 60 * 1000; * - cleanupGroomingSession: Clean up a temporary grooming session */ export function registerContextHandlers(deps: ContextHandlerDependencies): void { - const { getProcessManager, getAgentDetector } = deps; + const { getProcessManager, getAgentDetector, getAccountRegistry } = deps; logger.info('Registering context IPC handlers', LOG_CONTEXT); console.log('[ContextMerge] Registering context IPC handlers (v2 with response collection)'); @@ -145,6 +147,7 @@ export function registerContextHandlers(deps: ContextHandlerDependencies): void customPath?: string; customArgs?: string; customEnvVars?: Record; + accountId?: string; } ): Promise => { const processManager = requireDependency(getProcessManager, 'Process manager'); @@ -161,6 +164,8 @@ export function registerContextHandlers(deps: ContextHandlerDependencies): void sessionCustomPath: options?.customPath, sessionCustomArgs: options?.customArgs, sessionCustomEnvVars: options?.customEnvVars, + accountRegistry: getAccountRegistry() || undefined, + accountId: options?.accountId, }, processManager, agentDetector diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index ba41c326b..9a61ff9b6 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -52,8 +52,11 @@ import { registerSymphonyHandlers, SymphonyHandlerDependencies } from './symphon import { registerAgentErrorHandlers } from './agent-error'; import { registerTabNamingHandlers, TabNamingHandlerDependencies } from './tabNaming'; import { registerDirectorNotesHandlers, DirectorNotesHandlerDependencies } from './director-notes'; +import { registerAccountHandlers, AccountHandlerDependencies } from './accounts'; +import { registerProviderHandlers, ProviderHandlerDependencies } from './providers'; import { registerWakatimeHandlers } from './wakatime'; import { AgentDetector } from '../../agents'; +import type { AccountRegistry } from '../../accounts/account-registry'; import { ProcessManager } from '../../process-manager'; import { WebServer } from '../../web-server'; import { tunnelManager as tunnelManagerInstance } from '../../tunnel-manager'; @@ -96,6 +99,10 @@ export { registerTabNamingHandlers }; export type { TabNamingHandlerDependencies }; export { registerDirectorNotesHandlers }; export type { DirectorNotesHandlerDependencies }; +export { registerAccountHandlers }; +export type { AccountHandlerDependencies }; +export { registerProviderHandlers }; +export type { ProviderHandlerDependencies }; export { registerWakatimeHandlers }; export type { AgentsHandlerDependencies }; export type { ProcessHandlerDependencies }; @@ -155,6 +162,7 @@ export interface HandlerDependencies { tunnelManager: TunnelManagerType; // Claude-specific dependencies claudeSessionOriginsStore: Store; + getAccountRegistry?: () => AccountRegistry | null; } /** @@ -228,6 +236,7 @@ export function registerAllHandlers(deps: HandlerDependencies): void { getMainWindow: deps.getMainWindow, getProcessManager: deps.getProcessManager, getAgentDetector: deps.getAgentDetector, + getAccountRegistry: deps.getAccountRegistry || (() => null), }); // Register marketplace handlers registerMarketplaceHandlers({ diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts index 1808b33f7..6478e914e 100644 --- a/src/main/ipc/handlers/process.ts +++ b/src/main/ipc/handlers/process.ts @@ -3,7 +3,13 @@ import Store from 'electron-store'; import * as os from 'os'; import { ProcessManager } from '../../process-manager'; import { AgentDetector } from '../../agents'; +import type { AccountSwitcher } from '../../accounts/account-switcher'; +import type { AccountAuthRecovery } from '../../accounts/account-auth-recovery'; +import type { AccountRegistry } from '../../accounts/account-registry'; +import { injectAccountEnv } from '../../accounts/account-env-injector'; +import { getStatsDB } from '../../stats'; import { logger } from '../../utils/logger'; +import type { SafeSendFn } from '../../utils/safe-send'; import { addBreadcrumb } from '../../utils/sentry'; import { isWebContentsAvailable } from '../../utils/safe-send'; import { @@ -22,6 +28,7 @@ import { buildSshCommandWithStdin } from '../../utils/ssh-command-builder'; import { buildStreamJsonMessage } from '../../process-manager/utils/streamJsonBuilder'; import { getWindowsShellForAgentExecution } from '../../process-manager/utils/shellEscape'; import { buildExpandedEnv } from '../../../shared/pathUtils'; +import { REGEX_SESSION_SUFFIX } from '../../constants'; import type { SshRemoteConfig } from '../../../shared/types'; import { powerManager } from '../../power-manager'; import { MaestroSettings } from './persistence'; @@ -57,6 +64,10 @@ export interface ProcessHandlerDependencies { settingsStore: Store; getMainWindow: () => BrowserWindow | null; sessionsStore: Store<{ sessions: any[] }>; + getAccountSwitcher?: () => AccountSwitcher | null; + getAccountAuthRecovery?: () => AccountAuthRecovery | null; + getAccountRegistry?: () => AccountRegistry | null; + safeSend?: SafeSendFn; } /** @@ -72,7 +83,7 @@ export interface ProcessHandlerDependencies { * - runCommand: Execute a single command and capture output */ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void { - const { getProcessManager, getAgentDetector, agentConfigsStore, settingsStore, getMainWindow } = + const { getProcessManager, getAgentDetector, agentConfigsStore, settingsStore, getMainWindow, getAccountSwitcher, getAccountAuthRecovery, getAccountRegistry, safeSend: depsSafeSend } = deps; // Spawn a new process for a session @@ -110,6 +121,8 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void remoteId: string | null; workingDirOverride?: string; }; + // Account multiplexing + accountId?: string; // Account to use for this session // Stats tracking options querySource?: 'user' | 'auto'; // Whether this query is user-initiated or from Auto Run tabId?: string; // Tab ID for multi-tab tracking @@ -279,6 +292,31 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void let customEnvVarsToPass: Record | undefined = effectiveCustomEnvVars; let sshStdinScript: string | undefined; + // ======================================================================== + // Account Multiplexing: Inject CLAUDE_CONFIG_DIR for account assignment + // Must happen before SSH command building so the env var is included + // ======================================================================== + const registry = getAccountRegistry?.(); + if (registry) { + const envToInject: Record = customEnvVarsToPass ? { ...customEnvVarsToPass } : {}; + // Use base session ID for assignment (strip -ai-{tabId} etc.) so + // assignments are keyed consistently regardless of spawn vs restore. + const baseSessionIdForAccount = config.sessionId + .replace(REGEX_SESSION_SUFFIX, ''); + const assignedAccountId = injectAccountEnv( + baseSessionIdForAccount, + config.toolType, + envToInject, + registry, + config.accountId, // May be passed from renderer + depsSafeSend, + () => { const db = getStatsDB(); return db.isReady() ? db : null; }, + ); + if (assignedAccountId) { + customEnvVarsToPass = envToInject; + } + } + if (config.sessionCustomPath) { logger.debug(`Using session-level custom path for ${config.toolType}`, LOG_CONTEXT, { customPath: config.sessionCustomPath, @@ -541,7 +579,19 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void sessionId, dataLength: data.length, }); - return processManager.write(sessionId, data); + const result = processManager.write(sessionId, data); + + // Record the last prompt for account switching/auth recovery resume + const accountSwitcher = getAccountSwitcher?.(); + if (accountSwitcher) { + accountSwitcher.recordLastPrompt(sessionId, data); + } + const authRecovery = getAccountAuthRecovery?.(); + if (authRecovery) { + authRecovery.recordLastPrompt(sessionId, data); + } + + return result; }) ); diff --git a/src/main/ipc/handlers/providers.ts b/src/main/ipc/handlers/providers.ts new file mode 100644 index 000000000..2a649b54a --- /dev/null +++ b/src/main/ipc/handlers/providers.ts @@ -0,0 +1,66 @@ +/** + * Provider Error Tracking IPC Handlers + * + * Registers IPC handlers for provider error stats queries: + * - Get error stats for a specific provider + * - Get error stats for all providers + * - Clear error tracking for a session (after manual provider switch) + */ + +import { ipcMain } from 'electron'; +import type { ProviderErrorTracker } from '../../providers/provider-error-tracker'; +import type { ToolType } from '../../../shared/types'; +import type { ProviderErrorStats } from '../../../shared/account-types'; +import { logger } from '../../utils/logger'; + +const LOG_CONTEXT = 'Providers'; + +/** + * Dependencies for provider error tracking handlers + */ +export interface ProviderHandlerDependencies { + getProviderErrorTracker: () => ProviderErrorTracker | null; +} + +/** + * Register all provider error tracking IPC handlers. + */ +export function registerProviderHandlers(deps: ProviderHandlerDependencies): void { + const { getProviderErrorTracker } = deps; + + // Get error stats for a specific provider + ipcMain.handle( + 'providers:get-error-stats', + async (_event, toolType: string): Promise => { + const tracker = getProviderErrorTracker(); + if (!tracker) return null; + return tracker.getProviderStats(toolType as ToolType); + } + ); + + // Get error stats for all providers + ipcMain.handle( + 'providers:get-all-error-stats', + async (): Promise> => { + const tracker = getProviderErrorTracker(); + if (!tracker) return {}; + const stats = tracker.getAllStats(); + const result: Record = {}; + for (const [key, value] of stats) { + result[key] = value; + } + return result; + } + ); + + // Clear error tracking for a session (e.g., after manual provider switch) + ipcMain.handle( + 'providers:clear-session-errors', + async (_event, sessionId: string): Promise => { + const tracker = getProviderErrorTracker(); + if (!tracker) return; + logger.debug('Clearing provider errors for session', LOG_CONTEXT, { sessionId }); + tracker.clearSession(sessionId); + } + ); +} diff --git a/src/main/preload/accounts.ts b/src/main/preload/accounts.ts new file mode 100644 index 000000000..27c3a6b39 --- /dev/null +++ b/src/main/preload/accounts.ts @@ -0,0 +1,409 @@ +/** + * Preload API for account multiplexing + * + * Provides the window.maestro.accounts namespace for: + * - Account CRUD operations (list, get, add, update, remove) + * - Session-to-account assignments + * - Usage queries (windowed token consumption) + * - Throttle event queries (capacity planning) + * - Switch configuration management + * - Account selection (default, next available) + * - Real-time account usage updates + * - Account limit warnings and reached notifications + */ + +import { ipcRenderer } from 'electron'; + +/** + * Account usage update data broadcast from the account usage listener + */ +export interface AccountUsageUpdate { + accountId: string; + usagePercent: number | null; + totalTokens: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + limitTokens: number; + windowStart: number; + windowEnd: number; + queryCount: number; + costUsd: number; +} + +/** + * Account limit warning/reached data + */ +export interface AccountLimitEvent { + accountId: string; + accountName: string; + usagePercent: number; + sessionId: string; +} + +/** + * Creates the accounts API object for preload exposure + */ +export function createAccountsApi() { + return { + // --- Account CRUD --- + + /** List all registered accounts */ + list: (): Promise => ipcRenderer.invoke('accounts:list'), + + /** Get a single account by ID */ + get: (id: string): Promise => ipcRenderer.invoke('accounts:get', id), + + /** Add a new account */ + add: (params: { name: string; email: string; configDir: string; agentType?: string }): Promise => + ipcRenderer.invoke('accounts:add', params), + + /** Update an existing account */ + update: (id: string, updates: Record): Promise => + ipcRenderer.invoke('accounts:update', id, updates), + + /** Remove an account */ + remove: (id: string): Promise => ipcRenderer.invoke('accounts:remove', id), + + /** Set an account as the default */ + setDefault: (id: string): Promise => ipcRenderer.invoke('accounts:set-default', id), + + // --- Assignments --- + + /** Assign an account to a session */ + assign: (sessionId: string, accountId: string): Promise => + ipcRenderer.invoke('accounts:assign', sessionId, accountId), + + /** Get the account assigned to a session */ + getAssignment: (sessionId: string): Promise => + ipcRenderer.invoke('accounts:get-assignment', sessionId), + + /** Get all current session-to-account assignments */ + getAllAssignments: (): Promise => ipcRenderer.invoke('accounts:get-all-assignments'), + + // --- Usage Queries --- + + /** Get usage for an account within a specific time window */ + getUsage: (accountId: string, windowStart: number, windowEnd: number): Promise => + ipcRenderer.invoke('accounts:get-usage', accountId, windowStart, windowEnd), + + /** Get usage for all accounts in their current windows */ + getAllUsage: (): Promise => ipcRenderer.invoke('accounts:get-all-usage'), + + /** Get throttle events for capacity planning */ + getThrottleEvents: (accountId?: string, since?: number): Promise => + ipcRenderer.invoke('accounts:get-throttle-events', accountId, since), + + /** Get daily usage aggregation for an account */ + getDailyUsage: (accountId: string, days?: number): Promise => + ipcRenderer.invoke('accounts:get-daily-usage', accountId, days), + + /** Get monthly usage aggregation for an account */ + getMonthlyUsage: (accountId: string, months?: number): Promise => + ipcRenderer.invoke('accounts:get-monthly-usage', accountId, months), + + /** Get billing window history for an account (for P90 predictions) */ + getWindowHistory: (accountId: string, windowCount?: number): Promise => + ipcRenderer.invoke('accounts:get-window-history', accountId, windowCount), + + // --- Switch Configuration --- + + /** Get the current account switching configuration */ + getSwitchConfig: (): Promise => ipcRenderer.invoke('accounts:get-switch-config'), + + /** Update account switching configuration */ + updateSwitchConfig: (updates: Record): Promise => + ipcRenderer.invoke('accounts:update-switch-config', updates), + + // --- Account Selection --- + + /** Get the default account */ + getDefault: (): Promise => ipcRenderer.invoke('accounts:get-default'), + + /** Select the next available account (for auto-switching) */ + selectNext: (excludeIds?: string[]): Promise => + ipcRenderer.invoke('accounts:select-next', excludeIds), + + // --- Account Setup --- + + /** Validate that the base ~/.claude directory exists */ + validateBaseDir: (): Promise<{ valid: boolean; baseDir: string; errors: string[] }> => + ipcRenderer.invoke('accounts:validate-base-dir'), + + /** Discover existing provider account directories */ + discoverExisting: (): Promise> => + ipcRenderer.invoke('accounts:discover-existing'), + + /** Create a new account directory with symlinks */ + createDirectory: (name: string): Promise<{ success: boolean; configDir: string; error?: string }> => + ipcRenderer.invoke('accounts:create-directory', name), + + /** Validate symlinks in an account directory */ + validateSymlinks: (configDir: string): Promise<{ valid: boolean; broken: string[]; missing: string[] }> => + ipcRenderer.invoke('accounts:validate-symlinks', configDir), + + /** Repair broken or missing symlinks */ + repairSymlinks: (configDir: string): Promise<{ repaired: string[]; errors: string[] }> => + ipcRenderer.invoke('accounts:repair-symlinks', configDir), + + /** Read the email from an account's .claude.json */ + readEmail: (configDir: string): Promise => + ipcRenderer.invoke('accounts:read-email', configDir), + + /** Get the login command string for an account */ + getLoginCommand: (configDir: string): Promise => + ipcRenderer.invoke('accounts:get-login-command', configDir), + + /** Remove an account directory */ + removeDirectory: (configDir: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('accounts:remove-directory', configDir), + + /** Validate an account directory on a remote SSH host */ + validateRemoteDir: (params: { sshConfig: { host: string; user?: string; port?: number }; configDir: string }): Promise<{ exists: boolean; hasAuth: boolean; symlinksValid: boolean; error?: string }> => + ipcRenderer.invoke('accounts:validate-remote-dir', params), + + /** Sync credentials from base ~/.claude to an account directory */ + syncCredentials: (configDir: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('accounts:sync-credentials', configDir), + + // --- Event Listeners --- + + /** + * Subscribe to real-time account usage updates + * @param handler - Callback with usage data + * @returns Cleanup function to unsubscribe + */ + onUsageUpdate: (handler: (data: AccountUsageUpdate) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, data: AccountUsageUpdate) => + handler(data); + ipcRenderer.on('account:usage-update', wrappedHandler); + return () => ipcRenderer.removeListener('account:usage-update', wrappedHandler); + }, + + /** + * Subscribe to account limit warning events (usage approaching threshold) + * @param handler - Callback with limit event data + * @returns Cleanup function to unsubscribe + */ + onLimitWarning: (handler: (data: AccountLimitEvent) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, data: AccountLimitEvent) => + handler(data); + ipcRenderer.on('account:limit-warning', wrappedHandler); + return () => ipcRenderer.removeListener('account:limit-warning', wrappedHandler); + }, + + /** + * Subscribe to account limit reached events (auto-switch threshold exceeded) + * @param handler - Callback with limit event data + * @returns Cleanup function to unsubscribe + */ + onLimitReached: (handler: (data: AccountLimitEvent) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, data: AccountLimitEvent) => + handler(data); + ipcRenderer.on('account:limit-reached', wrappedHandler); + return () => ipcRenderer.removeListener('account:limit-reached', wrappedHandler); + }, + + /** + * Subscribe to account throttled events (rate limit detected) + * @param handler - Callback with throttle data + * @returns Cleanup function to unsubscribe + */ + onThrottled: (handler: (data: Record) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, data: Record) => + handler(data); + ipcRenderer.on('account:throttled', wrappedHandler); + return () => ipcRenderer.removeListener('account:throttled', wrappedHandler); + }, + + /** + * Subscribe to account switch prompt events (user confirmation needed) + * @param handler - Callback with switch prompt data + * @returns Cleanup function to unsubscribe + */ + onSwitchPrompt: (handler: (data: Record) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, data: Record) => + handler(data); + ipcRenderer.on('account:switch-prompt', wrappedHandler); + return () => ipcRenderer.removeListener('account:switch-prompt', wrappedHandler); + }, + + /** + * Subscribe to automatic account switch events (no confirmation needed) + * @param handler - Callback with switch execution data + * @returns Cleanup function to unsubscribe + */ + onSwitchExecute: (handler: (data: Record) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, data: Record) => + handler(data); + ipcRenderer.on('account:switch-execute', wrappedHandler); + return () => ipcRenderer.removeListener('account:switch-execute', wrappedHandler); + }, + + /** + * Subscribe to account status change events (e.g., throttled -> active recovery) + * @param handler - Callback with status change data + * @returns Cleanup function to unsubscribe + */ + onStatusChanged: (handler: (data: Record) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, data: Record) => + handler(data); + ipcRenderer.on('account:status-changed', wrappedHandler); + return () => ipcRenderer.removeListener('account:status-changed', wrappedHandler); + }, + + /** + * Subscribe to account assignment events (when a session is assigned an account during spawn) + * @param handler - Callback with assignment data + * @returns Cleanup function to unsubscribe + */ + onAssigned: (handler: (data: { sessionId: string; accountId: string; accountName: string }) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, data: { sessionId: string; accountId: string; accountName: string }) => + handler(data); + ipcRenderer.on('account:assigned', wrappedHandler); + return () => ipcRenderer.removeListener('account:assigned', wrappedHandler); + }, + + // --- Session Reconciliation --- + + /** Reconcile account assignments after session restore on startup. + * Removes stale assignments and returns account validation for sessions with accountId. */ + reconcileSessions: (activeSessionIds: string[]): Promise<{ + success: boolean; + removed: number; + corrections: Array<{ + sessionId: string; + accountId: string | null; + accountName: string | null; + configDir: string | null; + status: 'valid' | 'removed' | 'inactive'; + }>; + error?: string; + }> => ipcRenderer.invoke('accounts:reconcile-sessions', activeSessionIds), + + // --- Session Cleanup --- + + /** Clean up account data when a session is closed */ + cleanupSession: (sessionId: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('accounts:cleanup-session', sessionId), + + // --- Account Switching --- + + /** Execute an account switch for a session */ + executeSwitch: (params: { + sessionId: string; + fromAccountId: string; + toAccountId: string; + reason: string; + automatic: boolean; + }): Promise<{ success: boolean; event?: unknown; error?: string }> => + ipcRenderer.invoke('accounts:execute-switch', params), + + /** Subscribe to switch-started events */ + onSwitchStarted: (handler: (data: Record) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, data: Record) => + handler(data); + ipcRenderer.on('account:switch-started', wrappedHandler); + return () => ipcRenderer.removeListener('account:switch-started', wrappedHandler); + }, + + /** Subscribe to switch-respawn events (renderer must respawn the agent) */ + onSwitchRespawn: (handler: (data: { + sessionId: string; + toAccountId: string; + toAccountName: string; + configDir: string; + lastPrompt: string | null; + reason: string; + }) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, data: any) => + handler(data); + ipcRenderer.on('account:switch-respawn', wrappedHandler); + return () => ipcRenderer.removeListener('account:switch-respawn', wrappedHandler); + }, + + /** Subscribe to switch-completed events */ + onSwitchCompleted: (handler: (data: Record) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, data: Record) => + handler(data); + ipcRenderer.on('account:switch-completed', wrappedHandler); + return () => ipcRenderer.removeListener('account:switch-completed', wrappedHandler); + }, + + /** Subscribe to switch-failed events */ + onSwitchFailed: (handler: (data: Record) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, data: Record) => + handler(data); + ipcRenderer.on('account:switch-failed', wrappedHandler); + return () => ipcRenderer.removeListener('account:switch-failed', wrappedHandler); + }, + + // --- Auth Recovery --- + + /** Manually trigger auth recovery for a session */ + triggerAuthRecovery: (sessionId: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('accounts:trigger-auth-recovery', sessionId), + + /** Subscribe to auth recovery started events */ + onAuthRecoveryStarted: (handler: (data: { + sessionId: string; + accountId: string; + accountName: string; + }) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, data: any) => + handler(data); + ipcRenderer.on('account:auth-recovery-started', wrappedHandler); + return () => ipcRenderer.removeListener('account:auth-recovery-started', wrappedHandler); + }, + + /** Subscribe to auth recovery completed events */ + onAuthRecoveryCompleted: (handler: (data: { + sessionId: string; + accountId: string; + accountName: string; + }) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, data: any) => + handler(data); + ipcRenderer.on('account:auth-recovery-completed', wrappedHandler); + return () => ipcRenderer.removeListener('account:auth-recovery-completed', wrappedHandler); + }, + + /** Subscribe to auth recovery failed events */ + onAuthRecoveryFailed: (handler: (data: { + sessionId: string; + accountId: string; + accountName?: string; + error: string; + }) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, data: any) => + handler(data); + ipcRenderer.on('account:auth-recovery-failed', wrappedHandler); + return () => ipcRenderer.removeListener('account:auth-recovery-failed', wrappedHandler); + }, + + // --- Recovery Poller --- + + /** Subscribe to account recovery available events (throttled accounts recovered by timer) */ + onRecoveryAvailable: (handler: (data: { + recoveredAccountIds: string[]; + recoveredCount: number; + stillThrottledCount: number; + totalAccounts: number; + }) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, data: any) => + handler(data); + ipcRenderer.on('account:recovery-available', wrappedHandler); + return () => ipcRenderer.removeListener('account:recovery-available', wrappedHandler); + }, + + /** Manually trigger a recovery check (e.g., from a "Check Now" button) */ + checkRecovery: (): Promise<{ recovered: string[] }> => + ipcRenderer.invoke('accounts:check-recovery'), + }; +} + +/** + * TypeScript type for the accounts API + */ +export type AccountsApi = ReturnType; diff --git a/src/main/preload/context.ts b/src/main/preload/context.ts index 59042bf1b..925335e7f 100644 --- a/src/main/preload/context.ts +++ b/src/main/preload/context.ts @@ -58,6 +58,8 @@ export function createContextApi() { customPath?: string; customArgs?: string; customEnvVars?: Record; + // Account multiplexing + accountId?: string; } ): Promise => ipcRenderer.invoke('context:groomContext', projectRoot, agentType, prompt, options), diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts index 55ed62a7d..22632a755 100644 --- a/src/main/preload/index.ts +++ b/src/main/preload/index.ts @@ -49,6 +49,8 @@ import { createAgentsApi } from './agents'; import { createSymphonyApi } from './symphony'; import { createTabNamingApi } from './tabNaming'; import { createDirectorNotesApi } from './directorNotes'; +import { createAccountsApi } from './accounts'; +import { createProvidersApi } from './providers'; import { createWakatimeApi } from './wakatime'; // Expose protected methods that allow the renderer process to use @@ -186,6 +188,12 @@ contextBridge.exposeInMainWorld('maestro', { // Director's Notes API (unified history + synopsis) directorNotes: createDirectorNotesApi(), + // Account Multiplexing API (usage events, limit warnings) + accounts: createAccountsApi(), + + // Provider Error Tracking API (error stats, failover suggestions) + providers: createProvidersApi(), + // WakaTime API (CLI check, API key validation) wakatime: createWakatimeApi(), }); @@ -259,6 +267,10 @@ export { createTabNamingApi, // Director's Notes createDirectorNotesApi, + // Accounts + createAccountsApi, + // Providers + createProvidersApi, // WakaTime createWakatimeApi, }; @@ -465,6 +477,16 @@ export type { SynopsisResult, SynopsisStats, } from './directorNotes'; +export type { + // From accounts + AccountsApi, + AccountUsageUpdate, + AccountLimitEvent, +} from './accounts'; +export type { + // From providers + ProvidersApi, +} from './providers'; export type { // From wakatime WakatimeApi, diff --git a/src/main/preload/providers.ts b/src/main/preload/providers.ts new file mode 100644 index 000000000..1e2f1ad01 --- /dev/null +++ b/src/main/preload/providers.ts @@ -0,0 +1,43 @@ +/** + * Preload API for provider error tracking + * + * Provides the window.maestro.providers namespace for: + * - Querying error stats per provider (for ProviderPanel health dashboard) + * - Clearing error tracking for a session (after manual provider switch) + * - Subscribing to failover suggestion events + */ + +import { ipcRenderer } from 'electron'; +import type { ProviderErrorStats, FailoverSuggestion } from '../../shared/account-types'; + +/** + * Creates the providers API object for preload exposure + */ +export function createProvidersApi() { + return { + /** Get error stats for a specific provider */ + getErrorStats: (toolType: string): Promise => + ipcRenderer.invoke('providers:get-error-stats', toolType), + + /** Get error stats for all providers */ + getAllErrorStats: (): Promise> => + ipcRenderer.invoke('providers:get-all-error-stats'), + + /** Clear error tracking for a session (e.g., after manual provider switch) */ + clearSessionErrors: (sessionId: string): Promise => + ipcRenderer.invoke('providers:clear-session-errors', sessionId), + + /** Subscribe to failover suggestion events */ + onFailoverSuggest: (handler: (data: FailoverSuggestion) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, data: FailoverSuggestion) => + handler(data); + ipcRenderer.on('provider:failover-suggest', wrappedHandler); + return () => ipcRenderer.removeListener('provider:failover-suggest', wrappedHandler); + }, + }; +} + +/** + * TypeScript type for the providers API + */ +export type ProvidersApi = ReturnType; diff --git a/src/main/preload/stats.ts b/src/main/preload/stats.ts index 4c44b5ca8..ec8cf2e2f 100644 --- a/src/main/preload/stats.ts +++ b/src/main/preload/stats.ts @@ -21,6 +21,11 @@ export interface QueryEvent { projectPath?: string; tabId?: string; isRemote?: boolean; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheCreationTokens?: number; + costUsd?: number; } /** @@ -112,6 +117,12 @@ export function createStatsApi() { duration: number; projectPath?: string; tabId?: string; + isRemote?: boolean; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheCreationTokens?: number; + costUsd?: number; }> > => ipcRenderer.invoke('stats:get-stats', range, filters), diff --git a/src/main/process-listeners/account-usage-listener.ts b/src/main/process-listeners/account-usage-listener.ts new file mode 100644 index 000000000..14b43e2ad --- /dev/null +++ b/src/main/process-listeners/account-usage-listener.ts @@ -0,0 +1,154 @@ +/** + * Account usage listener. + * Aggregates per-session usage events into per-account usage windows + * for limit tracking and prediction. + */ + +import type { ProcessManager } from '../process-manager'; +import type { AccountRegistry } from '../accounts/account-registry'; +import type { StatsDB } from '../stats'; +import type { UsageStats } from './types'; +import { DEFAULT_TOKEN_WINDOW_MS } from '../../shared/account-types'; +import { getWindowBounds } from '../accounts/account-utils'; +import { REGEX_SESSION_SUFFIX } from '../constants'; + +const LOG_CONTEXT = 'account-usage-listener'; + +/** + * Sets up the account usage listener that aggregates per-session usage events + * into per-account usage windows for limit tracking and prediction. + * + * Only fires when usage events occur for sessions with account assignments, + * so it has zero impact on sessions without accounts. + */ +export function setupAccountUsageListener( + processManager: ProcessManager, + deps: { + getAccountRegistry: () => AccountRegistry | null; + getStatsDB: () => StatsDB; + safeSend: (channel: string, ...args: unknown[]) => void; + logger: { + info?: (message: string, context: string, data?: Record) => void; + error: (message: string, context: string, data?: Record) => void; + debug: (message: string, context: string, data?: Record) => void; + }; + } +): void { + const { getAccountRegistry, getStatsDB, safeSend, logger } = deps; + + processManager.on('usage', (sessionId: string, usageStats: UsageStats) => { + try { + const accountRegistry = getAccountRegistry(); + if (!accountRegistry) { + return; + } + + // Usage events arrive with compound session IDs (e.g. "{id}-ai-{tabId}") + // but assignments may be keyed by base session ID (from reconcileSessions on restore). + // Try compound ID first (from spawn-time assignment), then base ID (from restore). + let assignment = accountRegistry.getAssignment(sessionId); + if (!assignment) { + const baseSessionId = sessionId.replace(REGEX_SESSION_SUFFIX, ''); + if (baseSessionId !== sessionId) { + assignment = accountRegistry.getAssignment(baseSessionId); + } + } + if (!assignment) { + return; + } + + const account = accountRegistry.get(assignment.accountId); + if (!account) { + return; + } + + const statsDb = getStatsDB(); + if (!statsDb.isReady()) { + return; + } + + const windowMs = account.tokenWindowMs || DEFAULT_TOKEN_WINDOW_MS; + const now = Date.now(); + const { start, end } = getWindowBounds(now, windowMs); + + const tokensToWrite = { + inputTokens: usageStats.inputTokens || 0, + outputTokens: usageStats.outputTokens || 0, + cacheReadTokens: usageStats.cacheReadInputTokens || 0, + cacheCreationTokens: usageStats.cacheCreationInputTokens || 0, + costUsd: usageStats.totalCostUsd || 0, + }; + + // Aggregate tokens into the account's current window + statsDb.upsertAccountUsageWindow(account.id, start, end, tokensToWrite); + + // Read back aggregated window usage and broadcast to renderer + const windowUsage = statsDb.getAccountUsageInWindow(account.id, start, end); + const totalTokens = windowUsage.inputTokens + windowUsage.outputTokens + + windowUsage.cacheReadTokens + windowUsage.cacheCreationTokens; + const limitTokens = account.tokenLimitPerWindow || 0; + const usagePercent = limitTokens > 0 + ? Math.min(100, (totalTokens / limitTokens) * 100) + : null; + + // Broadcast usage update to renderer for real-time dashboard + safeSend('account:usage-update', { + accountId: account.id, + usagePercent, + totalTokens, + inputTokens: windowUsage.inputTokens, + outputTokens: windowUsage.outputTokens, + cacheReadTokens: windowUsage.cacheReadTokens, + cacheCreationTokens: windowUsage.cacheCreationTokens, + limitTokens, + windowStart: start, + windowEnd: end, + queryCount: windowUsage.queryCount, + costUsd: windowUsage.costUsd, + }); + + // Check warning/auto-switch thresholds (only if limit is configured) + if (limitTokens > 0 && usagePercent !== null) { + const switchConfig = accountRegistry.getSwitchConfig(); + if (usagePercent >= switchConfig.warningThresholdPercent && usagePercent < switchConfig.autoSwitchThresholdPercent) { + safeSend('account:limit-warning', { + accountId: account.id, + accountName: account.name, + usagePercent, + sessionId, + }); + } + + if (usagePercent >= switchConfig.autoSwitchThresholdPercent) { + safeSend('account:limit-reached', { + accountId: account.id, + accountName: account.name, + usagePercent, + sessionId, + }); + } + } + + // Auto-recover from throttle if window has advanced past throttle point + if (account.status === 'throttled' && account.lastThrottledAt > 0) { + const timeSinceThrottle = now - account.lastThrottledAt; + if (timeSinceThrottle > windowMs) { + accountRegistry.setStatus(account.id, 'active'); + safeSend('account:status-changed', { + accountId: account.id, + accountName: account.name, + oldStatus: 'throttled', + newStatus: 'active', + }); + logger.info?.(`Account ${account.name} recovered from throttle`, LOG_CONTEXT); + } + } + + // Update the account's lastUsedAt + accountRegistry.touchLastUsed(account.id); + + } catch (error) { + logger.error('Failed to track account usage', LOG_CONTEXT, { error: String(error), sessionId }); + } + }); +} diff --git a/src/main/process-listeners/error-listener.ts b/src/main/process-listeners/error-listener.ts index 04ce341c9..57038926a 100644 --- a/src/main/process-listeners/error-listener.ts +++ b/src/main/process-listeners/error-listener.ts @@ -1,19 +1,35 @@ /** * Agent error listener. * Handles agent errors (auth expired, token exhaustion, rate limits, etc.). + * When account multiplexing is active: + * - rate_limited errors → throttle handler (account switching) + * - auth_expired errors → auth recovery (re-login + respawn) */ import type { ProcessManager } from '../process-manager'; -import type { AgentError } from '../../shared/types'; +import type { AgentError, ToolType } from '../../shared/types'; import type { ProcessListenerDependencies } from './types'; +import type { AccountThrottleHandler } from '../accounts/account-throttle-handler'; +import type { AccountAuthRecovery } from '../accounts/account-auth-recovery'; +import type { AccountRegistry } from '../accounts/account-registry'; +import type { ProviderErrorTracker } from '../providers/provider-error-tracker'; +import { REGEX_SESSION_SUFFIX } from '../constants'; /** * Sets up the agent-error listener. * Handles logging and forwarding of agent errors to renderer. + * Optionally triggers throttle handling or auth recovery for account multiplexing. + * Optionally feeds errors into the ProviderErrorTracker for failover detection. */ export function setupErrorListener( processManager: ProcessManager, - deps: Pick + deps: Pick, + accountDeps?: { + getAccountRegistry: () => AccountRegistry | null; + getThrottleHandler: () => AccountThrottleHandler | null; + getAuthRecovery: () => AccountAuthRecovery | null; + }, + providerErrorTracker?: ProviderErrorTracker, ): void { const { safeSend, logger } = deps; @@ -27,5 +43,58 @@ export function setupErrorListener( recoverable: agentError.recoverable, }); safeSend('agent:error', sessionId, agentError); + + // Feed into provider error tracker for failover detection + if (providerErrorTracker && agentError.agentId) { + providerErrorTracker.recordError( + sessionId, + agentError.agentId as ToolType, + { + type: agentError.type, + message: agentError.message, + recoverable: agentError.recoverable, + }, + ); + } + + if (!accountDeps) return; + + const accountRegistry = accountDeps.getAccountRegistry(); + if (!accountRegistry) return; + + // Try compound session ID first, then fall back to base session ID. + // Assignments may be keyed by base ID (from reconcileSessions on restore) + // while error events arrive with compound IDs (e.g. "{id}-ai-{tabId}"). + let assignment = accountRegistry.getAssignment(sessionId); + if (!assignment) { + const baseSessionId = sessionId.replace(REGEX_SESSION_SUFFIX, ''); + if (baseSessionId !== sessionId) { + assignment = accountRegistry.getAssignment(baseSessionId); + } + } + if (!assignment) return; + + if (agentError.type === 'auth_expired') { + // Auth expired → attempt automatic re-login + const authRecovery = accountDeps.getAuthRecovery(); + if (authRecovery) { + authRecovery.recoverAuth(sessionId, assignment.accountId).catch((err) => { + logger.error('Auth recovery failed', 'AgentError', { + error: String(err), sessionId, + }); + }); + } + } else if (agentError.type === 'rate_limited') { + // Rate limited → throttle handler (account switching) + const throttleHandler = accountDeps.getThrottleHandler(); + if (throttleHandler) { + throttleHandler.handleThrottle({ + sessionId, + accountId: assignment.accountId, + errorType: agentError.type, + errorMessage: agentError.message, + }); + } + } }); } diff --git a/src/main/process-listeners/exit-listener.ts b/src/main/process-listeners/exit-listener.ts index dc70000bc..280ce6425 100644 --- a/src/main/process-listeners/exit-listener.ts +++ b/src/main/process-listeners/exit-listener.ts @@ -6,6 +6,7 @@ import type { ProcessManager } from '../process-manager'; import { GROUP_CHAT_PREFIX, type ProcessListenerDependencies } from './types'; +import { REGEX_SESSION_SUFFIX } from '../constants'; /** * Sets up the exit listener for process termination. @@ -433,10 +434,7 @@ export function setupExitListener( const webServer = getWebServer(); if (webServer) { // Extract base session ID from formats: {id}-ai-{tabId}, {id}-terminal, {id}-batch-{timestamp}, {id}-synopsis-{timestamp} - const baseSessionId = sessionId.replace( - /-ai-.+$|-terminal$|-batch-\d+$|-synopsis-\d+$/, - '' - ); + const baseSessionId = sessionId.replace(REGEX_SESSION_SUFFIX, ''); webServer.broadcastToSessionClients(baseSessionId, { type: 'session_exit', sessionId: baseSessionId, diff --git a/src/main/process-listeners/index.ts b/src/main/process-listeners/index.ts index 06882f3df..cc5fb0ebb 100644 --- a/src/main/process-listeners/index.ts +++ b/src/main/process-listeners/index.ts @@ -17,6 +17,7 @@ import { setupSessionIdListener } from './session-id-listener'; import { setupErrorListener } from './error-listener'; import { setupStatsListener } from './stats-listener'; import { setupExitListener } from './exit-listener'; +import { setupAccountUsageListener } from './account-usage-listener'; // Re-export types for consumers export type { ProcessListenerDependencies, ParticipantInfo } from './types'; @@ -44,12 +45,34 @@ export function setupProcessListeners( // Session ID listener (with group chat participant/moderator storage) setupSessionIdListener(processManager, deps); - // Agent error listener - setupErrorListener(processManager, deps); + // Agent error listener (with optional account throttle/auth recovery handling + provider failover) + const providerErrorTracker = deps.getProviderErrorTracker?.() ?? undefined; + setupErrorListener(processManager, deps, deps.getAccountRegistry ? { + getAccountRegistry: deps.getAccountRegistry, + getThrottleHandler: deps.getThrottleHandler ?? (() => null), + getAuthRecovery: deps.getAuthRecovery ?? (() => null), + } : undefined, providerErrorTracker); + + // Reset provider error tracking on successful query completion + if (providerErrorTracker) { + processManager.on('query-complete', (sessionId: string) => { + providerErrorTracker.clearSession(sessionId); + }); + } // Stats/query-complete listener setupStatsListener(processManager, deps); // Exit listener (with group chat routing, recovery, and synthesis) setupExitListener(processManager, deps); + + // Account usage listener (per-account token aggregation for limit tracking) + if (deps.getAccountRegistry) { + setupAccountUsageListener(processManager, { + getAccountRegistry: deps.getAccountRegistry, + getStatsDB: deps.getStatsDB, + safeSend: deps.safeSend, + logger: deps.logger, + }); + } } diff --git a/src/main/process-listeners/stats-listener.ts b/src/main/process-listeners/stats-listener.ts index b37a8c18d..ccf7caf03 100644 --- a/src/main/process-listeners/stats-listener.ts +++ b/src/main/process-listeners/stats-listener.ts @@ -36,6 +36,11 @@ async function insertQueryEventWithRetry( duration: queryData.duration, projectPath: queryData.projectPath, tabId: queryData.tabId, + inputTokens: queryData.inputTokens, + outputTokens: queryData.outputTokens, + cacheReadTokens: queryData.cacheReadTokens, + cacheCreationTokens: queryData.cacheCreationTokens, + costUsd: queryData.costUsd, }); return id; } catch (error) { diff --git a/src/main/process-listeners/types.ts b/src/main/process-listeners/types.ts index bb6a0a252..c784ed79e 100644 --- a/src/main/process-listeners/types.ts +++ b/src/main/process-listeners/types.ts @@ -8,6 +8,10 @@ import type { WebServer } from '../web-server'; import type { AgentDetector } from '../agents'; import type { SafeSendFn } from '../utils/safe-send'; import type { StatsDB } from '../stats'; +import type { AccountRegistry } from '../accounts/account-registry'; +import type { AccountThrottleHandler } from '../accounts/account-throttle-handler'; +import type { AccountAuthRecovery } from '../accounts/account-auth-recovery'; +import type { ProviderErrorTracker } from '../providers/provider-error-tracker'; import type { GroupChat, GroupChatParticipant } from '../group-chat/group-chat-storage'; import type { GroupChatMessage, GroupChatState } from '../../shared/group-chat-types'; import type { ParticipantState } from '../ipc/handlers/groupChat'; @@ -143,6 +147,14 @@ export interface ProcessListenerDependencies { }; /** Stats database getter */ getStatsDB: () => StatsDB; + /** Account registry getter (optional — only needed for account multiplexing) */ + getAccountRegistry?: () => AccountRegistry | null; + /** Account throttle handler getter (optional — only needed for account multiplexing) */ + getThrottleHandler?: () => AccountThrottleHandler | null; + /** Account auth recovery getter (optional — only needed for account multiplexing) */ + getAuthRecovery?: () => AccountAuthRecovery | null; + /** Provider error tracker (optional — only needed for Virtuosos provider failover) */ + getProviderErrorTracker?: () => ProviderErrorTracker | null; /** Debug log function */ debugLog: (prefix: string, message: string, ...args: unknown[]) => void; /** Regex patterns */ diff --git a/src/main/process-manager/handlers/ExitHandler.ts b/src/main/process-manager/handlers/ExitHandler.ts index 985838ce8..c0d0b04c5 100644 --- a/src/main/process-manager/handlers/ExitHandler.ts +++ b/src/main/process-manager/handlers/ExitHandler.ts @@ -229,6 +229,10 @@ export class ExitHandler { duration, projectPath: managedProcess.projectPath, tabId: managedProcess.tabId, + inputTokens: managedProcess.lastUsageTotals?.inputTokens, + outputTokens: managedProcess.lastUsageTotals?.outputTokens, + cacheReadTokens: managedProcess.lastUsageTotals?.cacheReadInputTokens, + cacheCreationTokens: managedProcess.lastUsageTotals?.cacheCreationInputTokens, }); logger.debug('[ProcessManager] Query complete event emitted', 'ProcessManager', { sessionId, diff --git a/src/main/process-manager/handlers/StdoutHandler.ts b/src/main/process-manager/handlers/StdoutHandler.ts index 567f184ae..94a8cd01b 100644 --- a/src/main/process-manager/handlers/StdoutHandler.ts +++ b/src/main/process-manager/handlers/StdoutHandler.ts @@ -237,13 +237,6 @@ export class StdoutHandler { // Extract usage const usage = outputParser.extractUsage(event); if (usage) { - // DEBUG: Log usage extracted from parser - console.log('[StdoutHandler] Usage from parser (line 255 path)', { - sessionId, - toolType: managedProcess.toolType, - parsedUsage: usage, - }); - const usageStats = this.buildUsageStats(managedProcess, usage); // Claude Code's modelUsage reports the ACTUAL context used for each API call: // - inputTokens: new input for this turn @@ -259,12 +252,6 @@ export class StdoutHandler { ? normalizeUsageToDelta(managedProcess, usageStats) : usageStats; - // DEBUG: Log normalized stats being emitted - console.log('[StdoutHandler] Emitting usage (line 255 path)', { - sessionId, - normalizedUsageStats, - }); - this.emitter.emit('usage', sessionId, normalizedUsageStats); } @@ -427,26 +414,12 @@ export class StdoutHandler { } if (msgRecord.modelUsage || msgRecord.usage || msgRecord.total_cost_usd !== undefined) { - // DEBUG: Log raw usage data from Claude Code before aggregation - console.log('[StdoutHandler] Raw usage data from Claude Code', { - sessionId, - modelUsage: msgRecord.modelUsage, - usage: msgRecord.usage, - totalCostUsd: msgRecord.total_cost_usd, - }); - const usageStats = aggregateModelUsage( msgRecord.modelUsage as Record | undefined, (msgRecord.usage as Record) || {}, (msgRecord.total_cost_usd as number) || 0 ); - // DEBUG: Log aggregated result - console.log('[StdoutHandler] Aggregated usage stats', { - sessionId, - usageStats, - }); - this.emitter.emit('usage', sessionId, usageStats); } } diff --git a/src/main/process-manager/types.ts b/src/main/process-manager/types.ts index 6b2ccb03a..8f9b62b06 100644 --- a/src/main/process-manager/types.ts +++ b/src/main/process-manager/types.ts @@ -134,6 +134,11 @@ export interface QueryCompleteData { duration: number; projectPath?: string; tabId?: string; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheCreationTokens?: number; + costUsd?: number; } // Re-export for backwards compatibility diff --git a/src/main/providers/__tests__/provider-error-tracker.test.ts b/src/main/providers/__tests__/provider-error-tracker.test.ts new file mode 100644 index 000000000..6a6c4bf4b --- /dev/null +++ b/src/main/providers/__tests__/provider-error-tracker.test.ts @@ -0,0 +1,425 @@ +/** + * Tests for ProviderErrorTracker. + * Validates sliding window error tracking, failover suggestion logic, + * error type filtering, and session lifecycle management. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ProviderErrorTracker } from '../provider-error-tracker'; +import type { ProviderSwitchConfig, FailoverSuggestion } from '../../../shared/account-types'; + +describe('ProviderErrorTracker', () => { + let tracker: ProviderErrorTracker; + let onFailoverSuggest: ReturnType void>>; + const defaultConfig: ProviderSwitchConfig = { + enabled: true, + promptBeforeSwitch: true, + errorThreshold: 3, + errorWindowMs: 5 * 60 * 1000, // 5 minutes + fallbackProviders: ['claude-code', 'opencode', 'codex'], + switchBehavior: 'merge-back', + }; + + beforeEach(() => { + vi.clearAllMocks(); + onFailoverSuggest = vi.fn(); + tracker = new ProviderErrorTracker(defaultConfig, onFailoverSuggest); + }); + + describe('recordError', () => { + it('should not record errors when disabled', () => { + const disabledTracker = new ProviderErrorTracker( + { ...defaultConfig, enabled: false }, + onFailoverSuggest, + ); + + disabledTracker.recordError('session-1', 'claude-code', { + type: 'rate_limited', + message: 'Rate limited', + recoverable: true, + }); + + const stats = disabledTracker.getProviderStats('claude-code'); + expect(stats.activeErrorCount).toBe(0); + }); + + it('should only count recoverable errors', () => { + tracker.recordError('session-1', 'claude-code', { + type: 'rate_limited', + message: 'Rate limited', + recoverable: false, // Non-recoverable + }); + + const stats = tracker.getProviderStats('claude-code'); + expect(stats.activeErrorCount).toBe(0); + }); + + it('should only count failover-worthy error types', () => { + // token_exhaustion should not count + tracker.recordError('session-1', 'claude-code', { + type: 'token_exhaustion', + message: 'Token limit reached', + recoverable: true, + }); + + // session_not_found should not count + tracker.recordError('session-1', 'claude-code', { + type: 'session_not_found', + message: 'Session not found', + recoverable: true, + }); + + // permission_denied should not count + tracker.recordError('session-1', 'claude-code', { + type: 'permission_denied', + message: 'Permission denied', + recoverable: true, + }); + + const stats = tracker.getProviderStats('claude-code'); + expect(stats.activeErrorCount).toBe(0); + }); + + it('should count rate_limited errors toward threshold', () => { + tracker.recordError('session-1', 'claude-code', { + type: 'rate_limited', + message: 'Rate limited', + recoverable: true, + }); + + const stats = tracker.getProviderStats('claude-code'); + expect(stats.activeErrorCount).toBe(1); + }); + + it('should count network_error toward threshold', () => { + tracker.recordError('session-1', 'claude-code', { + type: 'network_error', + message: 'Connection failed', + recoverable: true, + }); + + const stats = tracker.getProviderStats('claude-code'); + expect(stats.activeErrorCount).toBe(1); + }); + + it('should count agent_crashed toward threshold', () => { + tracker.recordError('session-1', 'claude-code', { + type: 'agent_crashed', + message: 'Process exited', + recoverable: true, + }); + + const stats = tracker.getProviderStats('claude-code'); + expect(stats.activeErrorCount).toBe(1); + }); + + it('should count auth_expired toward threshold', () => { + tracker.recordError('session-1', 'claude-code', { + type: 'auth_expired', + message: 'Auth expired', + recoverable: true, + }); + + const stats = tracker.getProviderStats('claude-code'); + expect(stats.activeErrorCount).toBe(1); + }); + }); + + describe('failover suggestion', () => { + it('should emit failover suggestion when threshold is reached', () => { + for (let i = 0; i < 3; i++) { + tracker.recordError('session-1', 'opencode', { + type: 'rate_limited', + message: `Rate limited ${i + 1}`, + recoverable: true, + }); + } + + expect(onFailoverSuggest).toHaveBeenCalledTimes(1); + const suggestion: FailoverSuggestion = onFailoverSuggest.mock.calls[0][0]; + expect(suggestion.sessionId).toBe('session-1'); + expect(suggestion.currentProvider).toBe('opencode'); + expect(suggestion.suggestedProvider).toBe('claude-code'); // First in fallback list that isn't opencode + expect(suggestion.errorCount).toBe(3); + expect(suggestion.recentErrors).toHaveLength(3); + }); + + it('should not emit duplicate suggestions for the same session', () => { + for (let i = 0; i < 5; i++) { + tracker.recordError('session-1', 'opencode', { + type: 'rate_limited', + message: `Rate limited ${i + 1}`, + recoverable: true, + }); + } + + // Should only be called once despite 5 errors + expect(onFailoverSuggest).toHaveBeenCalledTimes(1); + }); + + it('should not emit suggestion below threshold', () => { + for (let i = 0; i < 2; i++) { + tracker.recordError('session-1', 'opencode', { + type: 'rate_limited', + message: `Rate limited ${i + 1}`, + recoverable: true, + }); + } + + expect(onFailoverSuggest).not.toHaveBeenCalled(); + }); + + it('should not emit suggestion when no fallback providers are available', () => { + const noFallbackTracker = new ProviderErrorTracker( + { ...defaultConfig, fallbackProviders: ['opencode'] }, + onFailoverSuggest, + ); + + // All errors for opencode, but only opencode in fallback list + for (let i = 0; i < 3; i++) { + noFallbackTracker.recordError('session-1', 'opencode', { + type: 'rate_limited', + message: 'Rate limited', + recoverable: true, + }); + } + + expect(onFailoverSuggest).not.toHaveBeenCalled(); + }); + + it('should pick first available fallback provider that differs from current', () => { + const tracker2 = new ProviderErrorTracker( + { ...defaultConfig, fallbackProviders: ['codex', 'opencode', 'claude-code'] }, + onFailoverSuggest, + ); + + for (let i = 0; i < 3; i++) { + tracker2.recordError('session-1', 'codex', { + type: 'rate_limited', + message: 'Rate limited', + recoverable: true, + }); + } + + const suggestion: FailoverSuggestion = onFailoverSuggest.mock.calls[0][0]; + expect(suggestion.suggestedProvider).toBe('opencode'); + }); + }); + + describe('clearSession', () => { + it('should reset error count and allow re-suggestion', () => { + // Hit threshold + for (let i = 0; i < 3; i++) { + tracker.recordError('session-1', 'opencode', { + type: 'rate_limited', + message: 'Rate limited', + recoverable: true, + }); + } + expect(onFailoverSuggest).toHaveBeenCalledTimes(1); + + // Clear session + tracker.clearSession('session-1'); + + const stats = tracker.getProviderStats('opencode'); + expect(stats.activeErrorCount).toBe(0); + + // Should be able to suggest again after clearing + for (let i = 0; i < 3; i++) { + tracker.recordError('session-1', 'opencode', { + type: 'rate_limited', + message: 'Rate limited again', + recoverable: true, + }); + } + expect(onFailoverSuggest).toHaveBeenCalledTimes(2); + }); + }); + + describe('removeSession', () => { + it('should completely remove session from tracking', () => { + tracker.recordError('session-1', 'claude-code', { + type: 'rate_limited', + message: 'Rate limited', + recoverable: true, + }); + + tracker.removeSession('session-1'); + + const stats = tracker.getProviderStats('claude-code'); + expect(stats.activeErrorCount).toBe(0); + expect(stats.sessionsWithErrors).toBe(0); + }); + }); + + describe('getProviderStats', () => { + it('should aggregate stats across multiple sessions for the same provider', () => { + tracker.recordError('session-1', 'claude-code', { + type: 'rate_limited', + message: 'Rate limited', + recoverable: true, + }); + tracker.recordError('session-2', 'claude-code', { + type: 'network_error', + message: 'Network error', + recoverable: true, + }); + tracker.recordError('session-3', 'opencode', { + type: 'rate_limited', + message: 'Rate limited', + recoverable: true, + }); + + const claudeStats = tracker.getProviderStats('claude-code'); + expect(claudeStats.activeErrorCount).toBe(2); + expect(claudeStats.sessionsWithErrors).toBe(2); + + const opencodeStats = tracker.getProviderStats('opencode'); + expect(opencodeStats.activeErrorCount).toBe(1); + expect(opencodeStats.sessionsWithErrors).toBe(1); + }); + + it('should return zero stats for providers with no errors', () => { + const stats = tracker.getProviderStats('codex'); + expect(stats.activeErrorCount).toBe(0); + expect(stats.totalErrorsInWindow).toBe(0); + expect(stats.lastErrorAt).toBeNull(); + expect(stats.sessionsWithErrors).toBe(0); + }); + }); + + describe('getAllStats', () => { + it('should return stats for all tracked providers', () => { + tracker.recordError('session-1', 'claude-code', { + type: 'rate_limited', + message: 'Rate limited', + recoverable: true, + }); + tracker.recordError('session-2', 'opencode', { + type: 'network_error', + message: 'Network error', + recoverable: true, + }); + + const allStats = tracker.getAllStats(); + expect(allStats.size).toBe(2); + expect(allStats.has('claude-code')).toBe(true); + expect(allStats.has('opencode')).toBe(true); + }); + }); + + describe('updateConfig', () => { + it('should update the threshold dynamically', () => { + // With threshold 3, two errors should not trigger + tracker.recordError('session-1', 'opencode', { + type: 'rate_limited', + message: 'Rate limited 1', + recoverable: true, + }); + tracker.recordError('session-1', 'opencode', { + type: 'rate_limited', + message: 'Rate limited 2', + recoverable: true, + }); + expect(onFailoverSuggest).not.toHaveBeenCalled(); + + // Lower threshold to 2 — the session already has 2 errors + // Next error should trigger with the new threshold (need to hit new threshold) + tracker.updateConfig({ ...defaultConfig, errorThreshold: 2 }); + + // The existing 2 errors don't re-check — but the session already has 2 errors, + // and failoverSuggested is still false, so the next check should trigger + // Actually, errors are already recorded. Let's clear and re-record. + tracker.clearSession('session-1'); + tracker.recordError('session-1', 'opencode', { + type: 'rate_limited', + message: 'Rate limited 1', + recoverable: true, + }); + tracker.recordError('session-1', 'opencode', { + type: 'rate_limited', + message: 'Rate limited 2', + recoverable: true, + }); + + expect(onFailoverSuggest).toHaveBeenCalledTimes(1); + }); + }); + + describe('sliding window', () => { + it('should prune errors older than the window', () => { + // Use a short window for testing + const shortWindowTracker = new ProviderErrorTracker( + { ...defaultConfig, errorWindowMs: 100 }, // 100ms window + onFailoverSuggest, + ); + + // Record 2 errors + shortWindowTracker.recordError('session-1', 'claude-code', { + type: 'rate_limited', + message: 'Rate limited 1', + recoverable: true, + }); + shortWindowTracker.recordError('session-1', 'claude-code', { + type: 'rate_limited', + message: 'Rate limited 2', + recoverable: true, + }); + + // Wait for the window to expire + return new Promise((resolve) => { + setTimeout(() => { + // Record another error — old ones should be pruned + shortWindowTracker.recordError('session-1', 'claude-code', { + type: 'rate_limited', + message: 'Rate limited 3', + recoverable: true, + }); + + // Should only have 1 error in window (the new one) + const stats = shortWindowTracker.getProviderStats('claude-code'); + expect(stats.activeErrorCount).toBe(1); + // Should not have triggered failover (never had 3 in window) + expect(onFailoverSuggest).not.toHaveBeenCalled(); + resolve(); + }, 150); + }); + }); + }); + + describe('session name resolution', () => { + it('should use the provided session name resolver', () => { + const nameResolver = vi.fn().mockReturnValue('My Session'); + const tracker2 = new ProviderErrorTracker( + defaultConfig, + onFailoverSuggest, + nameResolver, + ); + + for (let i = 0; i < 3; i++) { + tracker2.recordError('session-1', 'opencode', { + type: 'rate_limited', + message: 'Rate limited', + recoverable: true, + }); + } + + expect(nameResolver).toHaveBeenCalledWith('session-1'); + const suggestion: FailoverSuggestion = onFailoverSuggest.mock.calls[0][0]; + expect(suggestion.sessionName).toBe('My Session'); + }); + + it('should fall back to session ID when no resolver is provided', () => { + for (let i = 0; i < 3; i++) { + tracker.recordError('session-1', 'opencode', { + type: 'rate_limited', + message: 'Rate limited', + recoverable: true, + }); + } + + const suggestion: FailoverSuggestion = onFailoverSuggest.mock.calls[0][0]; + expect(suggestion.sessionName).toBe('session-1'); + }); + }); +}); diff --git a/src/main/providers/provider-error-tracker.ts b/src/main/providers/provider-error-tracker.ts new file mode 100644 index 000000000..2bcb708c5 --- /dev/null +++ b/src/main/providers/provider-error-tracker.ts @@ -0,0 +1,220 @@ +/** + * ProviderErrorTracker + * + * Monitors consecutive agent errors per session in a sliding window. + * When errors exceed the configured threshold, emits a failover suggestion + * so the renderer can open SwitchProviderModal or auto-switch providers. + * + * Only counts recoverable, provider-level errors toward the threshold: + * - rate_limited, network_error, agent_crashed, auth_expired + * + * Does NOT count: + * - token_exhaustion (session issue, not provider) + * - session_not_found (transient) + * - permission_denied (non-recoverable, not provider instability) + * - unknown + */ + +import type { ToolType, AgentErrorType } from '../../shared/types'; +import type { + ProviderSwitchConfig, + FailoverSuggestion, + ProviderErrorStats, +} from '../../shared/account-types'; +import { logger } from '../utils/logger'; + +const LOG_CONTEXT = 'ProviderErrorTracker'; + +/** Error types that indicate provider instability and count toward failover */ +const FAILOVER_WORTHY_ERRORS: Set = new Set([ + 'rate_limited', + 'network_error', + 'agent_crashed', + 'auth_expired', +]); + +interface ErrorEvent { + timestamp: number; + errorType: AgentErrorType; + message: string; + recoverable: boolean; +} + +interface SessionErrorState { + sessionId: string; + toolType: ToolType; + errors: ErrorEvent[]; + failoverSuggested: boolean; +} + +export class ProviderErrorTracker { + private sessions = new Map(); + private config: ProviderSwitchConfig; + private onFailoverSuggest: (data: FailoverSuggestion) => void; + private sessionNameResolver: (sessionId: string) => string; + + constructor( + config: ProviderSwitchConfig, + onFailoverSuggest: (data: FailoverSuggestion) => void, + sessionNameResolver?: (sessionId: string) => string, + ) { + this.config = config; + this.onFailoverSuggest = onFailoverSuggest; + this.sessionNameResolver = sessionNameResolver ?? ((id) => id); + } + + /** Update config at runtime (when user changes settings) */ + updateConfig(config: ProviderSwitchConfig): void { + this.config = config; + } + + /** Record an error for a session */ + recordError(sessionId: string, toolType: ToolType, error: { + type: AgentErrorType; + message: string; + recoverable: boolean; + }): void { + if (!this.config.enabled) return; + + // Only count recoverable, failover-worthy errors + if (!error.recoverable || !FAILOVER_WORTHY_ERRORS.has(error.type)) { + return; + } + + // Get or create session error state + let state = this.sessions.get(sessionId); + if (!state) { + state = { + sessionId, + toolType, + errors: [], + failoverSuggested: false, + }; + this.sessions.set(sessionId, state); + } + + const now = Date.now(); + + // Add the error + state.errors.push({ + timestamp: now, + errorType: error.type, + message: error.message, + recoverable: error.recoverable, + }); + + // Prune errors older than the window + const windowStart = now - this.config.errorWindowMs; + state.errors = state.errors.filter(e => e.timestamp >= windowStart); + + // Check threshold + const errorCount = state.errors.length; + if (errorCount >= this.config.errorThreshold && !state.failoverSuggested) { + state.failoverSuggested = true; + + // Determine target provider from fallback list + const suggestedProvider = this.config.fallbackProviders.find(p => p !== toolType); + if (!suggestedProvider) { + logger.warn('No fallback provider available for failover', LOG_CONTEXT, { + sessionId, + toolType, + errorCount, + }); + return; + } + + const suggestion: FailoverSuggestion = { + sessionId, + sessionName: this.sessionNameResolver(sessionId), + currentProvider: toolType, + suggestedProvider, + errorCount, + windowMs: this.config.errorWindowMs, + recentErrors: state.errors.map(e => ({ + type: e.errorType, + message: e.message, + timestamp: e.timestamp, + })), + }; + + logger.info('Failover threshold reached, suggesting switch', LOG_CONTEXT, { + sessionId, + currentProvider: toolType, + suggestedProvider, + errorCount, + threshold: this.config.errorThreshold, + }); + + this.onFailoverSuggest(suggestion); + } + } + + /** Clear errors for a session (e.g., after successful response) */ + clearSession(sessionId: string): void { + const state = this.sessions.get(sessionId); + if (state) { + state.errors = []; + state.failoverSuggested = false; + } + } + + /** Remove a session entirely (on close) */ + removeSession(sessionId: string): void { + this.sessions.delete(sessionId); + } + + /** Get error stats for a provider type (for health dashboard) */ + getProviderStats(toolType: ToolType): ProviderErrorStats { + const now = Date.now(); + const windowStart = now - this.config.errorWindowMs; + + let activeErrorCount = 0; + let totalErrorsInWindow = 0; + let lastErrorAt: number | null = null; + let sessionsWithErrors = 0; + const errorsByType: Partial> = {}; + + for (const state of this.sessions.values()) { + if (state.toolType !== toolType) continue; + + // Prune stale errors + const active = state.errors.filter(e => e.timestamp >= windowStart); + if (active.length > 0) { + sessionsWithErrors++; + totalErrorsInWindow += active.length; + activeErrorCount += active.length; + const latest = active[active.length - 1].timestamp; + if (lastErrorAt === null || latest > lastErrorAt) { + lastErrorAt = latest; + } + // Accumulate per-type counts + for (const err of active) { + errorsByType[err.errorType] = (errorsByType[err.errorType] ?? 0) + 1; + } + } + } + + return { + toolType, + activeErrorCount, + totalErrorsInWindow, + lastErrorAt, + sessionsWithErrors, + errorsByType, + }; + } + + /** Get all provider stats */ + getAllStats(): Map { + const toolTypes = new Set(); + for (const state of this.sessions.values()) { + toolTypes.add(state.toolType); + } + + const result = new Map(); + for (const toolType of toolTypes) { + result.set(toolType, this.getProviderStats(toolType)); + } + return result; + } +} diff --git a/src/main/stats/account-usage.ts b/src/main/stats/account-usage.ts new file mode 100644 index 000000000..030f49b50 --- /dev/null +++ b/src/main/stats/account-usage.ts @@ -0,0 +1,339 @@ +/** + * Account Usage Tracking Operations + * + * Handles windowed usage aggregation per account and throttle event recording + * for capacity planning and account multiplexing. + */ + +import type Database from 'better-sqlite3'; +import { generateId, LOG_CONTEXT } from './utils'; +import { StatementCache } from './utils'; +import { logger } from '../utils/logger'; + +const stmtCache = new StatementCache(); + +// ============================================================================ +// Account Usage Windows +// ============================================================================ + +export interface AccountUsageTokens { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + costUsd: number; +} + +export interface AccountUsageSummary { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + costUsd: number; + queryCount: number; +} + +export interface ThrottleEvent { + id: string; + accountId: string; + sessionId: string | null; + timestamp: number; + reason: string; + tokensAtThrottle: number; +} + +const UPSERT_CHECK_SQL = ` + SELECT id, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, cost_usd, query_count + FROM account_usage_windows WHERE account_id = ? AND window_start = ? +`; + +const UPDATE_WINDOW_SQL = ` + UPDATE account_usage_windows SET + input_tokens = input_tokens + ?, + output_tokens = output_tokens + ?, + cache_read_tokens = cache_read_tokens + ?, + cache_creation_tokens = cache_creation_tokens + ?, + cost_usd = cost_usd + ?, + query_count = query_count + 1 + WHERE id = ? +`; + +const INSERT_WINDOW_SQL = ` + INSERT INTO account_usage_windows (id, account_id, window_start, window_end, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, cost_usd, query_count, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) +`; + +const GET_USAGE_SQL = ` + SELECT + COALESCE(SUM(input_tokens), 0) as inputTokens, + COALESCE(SUM(output_tokens), 0) as outputTokens, + COALESCE(SUM(cache_read_tokens), 0) as cacheReadTokens, + COALESCE(SUM(cache_creation_tokens), 0) as cacheCreationTokens, + COALESCE(SUM(cost_usd), 0) as costUsd, + COALESCE(SUM(query_count), 0) as queryCount + FROM account_usage_windows + WHERE account_id = ? AND window_start >= ? AND window_end <= ? +`; + +const INSERT_THROTTLE_SQL = ` + INSERT INTO account_throttle_events (id, account_id, session_id, timestamp, reason, tokens_at_throttle, window_start, window_end) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) +`; + +/** + * Record or update a usage window for an account. + * If a window with the same account_id and window_start exists, increments the totals. + * Otherwise, inserts a new window record. + */ +export function upsertAccountUsageWindow( + db: Database.Database, + accountId: string, + windowStart: number, + windowEnd: number, + tokens: AccountUsageTokens +): void { + const existing = stmtCache.get(db, UPSERT_CHECK_SQL).get(accountId, windowStart) as + | { id: string } + | undefined; + + if (existing) { + stmtCache.get(db, UPDATE_WINDOW_SQL).run( + tokens.inputTokens, + tokens.outputTokens, + tokens.cacheReadTokens, + tokens.cacheCreationTokens, + tokens.costUsd, + existing.id + ); + logger.debug(`Updated usage window ${existing.id} for account ${accountId}`, LOG_CONTEXT); + } else { + const id = generateId(); + stmtCache.get(db, INSERT_WINDOW_SQL).run( + id, + accountId, + windowStart, + windowEnd, + tokens.inputTokens, + tokens.outputTokens, + tokens.cacheReadTokens, + tokens.cacheCreationTokens, + tokens.costUsd, + Date.now() + ); + logger.debug(`Inserted usage window ${id} for account ${accountId}`, LOG_CONTEXT); + } +} + +/** + * Get usage for an account within a specific time window. + */ +export function getAccountUsageInWindow( + db: Database.Database, + accountId: string, + windowStart: number, + windowEnd: number +): AccountUsageSummary { + const result = stmtCache.get(db, GET_USAGE_SQL).get(accountId, windowStart, windowEnd) as AccountUsageSummary; + return result; +} + +// ============================================================================ +// Throttle Events +// ============================================================================ + +/** + * Record a throttle event for capacity planning. + */ +export function insertThrottleEvent( + db: Database.Database, + accountId: string, + sessionId: string | null, + reason: string, + tokensAtThrottle: number, + windowStart?: number, + windowEnd?: number +): string { + const id = generateId(); + stmtCache.get(db, INSERT_THROTTLE_SQL).run( + id, + accountId, + sessionId, + Date.now(), + reason, + tokensAtThrottle, + windowStart ?? null, + windowEnd ?? null + ); + logger.debug(`Inserted throttle event ${id} for account ${accountId}`, LOG_CONTEXT); + return id; +} + +/** + * Get throttle events for capacity planning, optionally filtered by account and time. + */ +export function getThrottleEvents( + db: Database.Database, + accountId?: string, + since?: number +): ThrottleEvent[] { + let sql = 'SELECT * FROM account_throttle_events WHERE 1=1'; + const params: (string | number)[] = []; + + if (accountId) { + sql += ' AND account_id = ?'; + params.push(accountId); + } + if (since) { + sql += ' AND timestamp >= ?'; + params.push(since); + } + sql += ' ORDER BY timestamp DESC'; + + const rows = db.prepare(sql).all(...params) as Array<{ + id: string; + account_id: string; + session_id: string | null; + timestamp: number; + reason: string; + tokens_at_throttle: number; + }>; + + return rows.map((row) => ({ + id: row.id, + accountId: row.account_id, + sessionId: row.session_id, + timestamp: row.timestamp, + reason: row.reason, + tokensAtThrottle: row.tokens_at_throttle, + })); +} + +// ============================================================================ +// Historical Aggregations +// ============================================================================ + +export interface AccountDailyUsage { + date: string; // YYYY-MM-DD + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalTokens: number; + costUsd: number; + queryCount: number; +} + +export interface AccountMonthlyUsage { + month: string; // YYYY-MM + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalTokens: number; + costUsd: number; + queryCount: number; + daysActive: number; +} + +const DAILY_USAGE_SQL = ` + SELECT + date(window_start / 1000, 'unixepoch', 'localtime') as date, + COALESCE(SUM(input_tokens), 0) as inputTokens, + COALESCE(SUM(output_tokens), 0) as outputTokens, + COALESCE(SUM(cache_read_tokens), 0) as cacheReadTokens, + COALESCE(SUM(cache_creation_tokens), 0) as cacheCreationTokens, + COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_creation_tokens), 0) as totalTokens, + COALESCE(SUM(cost_usd), 0) as costUsd, + COALESCE(SUM(query_count), 0) as queryCount + FROM account_usage_windows + WHERE account_id = ? AND window_start >= ? AND window_start < ? + GROUP BY date + ORDER BY date ASC +`; + +const MONTHLY_USAGE_SQL = ` + SELECT + strftime('%Y-%m', window_start / 1000, 'unixepoch', 'localtime') as month, + COALESCE(SUM(input_tokens), 0) as inputTokens, + COALESCE(SUM(output_tokens), 0) as outputTokens, + COALESCE(SUM(cache_read_tokens), 0) as cacheReadTokens, + COALESCE(SUM(cache_creation_tokens), 0) as cacheCreationTokens, + COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_creation_tokens), 0) as totalTokens, + COALESCE(SUM(cost_usd), 0) as costUsd, + COALESCE(SUM(query_count), 0) as queryCount, + COUNT(DISTINCT date(window_start / 1000, 'unixepoch', 'localtime')) as daysActive + FROM account_usage_windows + WHERE account_id = ? AND window_start >= ? AND window_start < ? + GROUP BY month + ORDER BY month ASC +`; + +/** + * Get daily token usage for an account over a date range. + * Returns one row per day with non-zero usage. + */ +export function getAccountDailyUsage( + db: Database.Database, + accountId: string, + sinceMs: number, + untilMs: number +): AccountDailyUsage[] { + return stmtCache.get(db, DAILY_USAGE_SQL).all(accountId, sinceMs, untilMs) as AccountDailyUsage[]; +} + +/** + * Get monthly token usage for an account over a date range. + * Returns one row per month with non-zero usage. + */ +export function getAccountMonthlyUsage( + db: Database.Database, + accountId: string, + sinceMs: number, + untilMs: number +): AccountMonthlyUsage[] { + return stmtCache.get(db, MONTHLY_USAGE_SQL).all(accountId, sinceMs, untilMs) as AccountMonthlyUsage[]; +} + +/** + * Get the 5-hour window usage history for an account (last N windows). + * Used for billing-window analysis and P90 prediction. + */ +export function getAccountWindowHistory( + db: Database.Database, + accountId: string, + windowCount: number = 40 // ~8 days of 5-hour windows +): Array<{ + windowStart: number; + windowEnd: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + costUsd: number; + queryCount: number; +}> { + const sql = ` + SELECT window_start as windowStart, window_end as windowEnd, + input_tokens as inputTokens, output_tokens as outputTokens, + cache_read_tokens as cacheReadTokens, cache_creation_tokens as cacheCreationTokens, + cost_usd as costUsd, query_count as queryCount + FROM account_usage_windows + WHERE account_id = ? + ORDER BY window_start DESC + LIMIT ? + `; + const rows = db.prepare(sql).all(accountId, windowCount) as Array<{ + windowStart: number; windowEnd: number; + inputTokens: number; outputTokens: number; + cacheReadTokens: number; cacheCreationTokens: number; + costUsd: number; queryCount: number; + }>; + return rows.reverse(); // Return chronological order +} + +/** + * Clear the statement cache (call when database is closed) + */ +export function clearAccountUsageCache(): void { + stmtCache.clear(); +} diff --git a/src/main/stats/aggregations.ts b/src/main/stats/aggregations.ts index 68c2ddfdf..cbdef1e9b 100644 --- a/src/main/stats/aggregations.ts +++ b/src/main/stats/aggregations.ts @@ -187,6 +187,42 @@ function queryByHour( return rows; } +function queryByAgentByHour( + db: Database.Database, + startTime: number +): Record> { + const perfStart = perfMetrics.start(); + const rows = db + .prepare( + ` + SELECT agent_type, + CAST(strftime('%H', start_time / 1000, 'unixepoch', 'localtime') AS INTEGER) as hour, + COUNT(*) as count, + SUM(duration) as duration + FROM query_events + WHERE start_time >= ? + GROUP BY agent_type, hour + ORDER BY agent_type, hour ASC + ` + ) + .all(startTime) as Array<{ + agent_type: string; + hour: number; + count: number; + duration: number; + }>; + + const result: Record> = {}; + for (const row of rows) { + if (!result[row.agent_type]) { + result[row.agent_type] = []; + } + result[row.agent_type].push({ hour: row.hour, count: row.count, duration: row.duration }); + } + perfMetrics.end(perfStart, 'getAggregatedStats:byAgentByHour'); + return result; +} + function querySessionStats( db: Database.Database, startTime: number @@ -320,6 +356,7 @@ export function getAggregatedStats(db: Database.Database, range: StatsTimeRange) const byDay = queryByDay(db, startTime); const byAgentByDay = queryByAgentByDay(db, startTime); const byHour = queryByHour(db, startTime); + const byAgentByHour = queryByAgentByHour(db, startTime); const sessionStats = querySessionStats(db, startTime); const bySessionByDay = queryBySessionByDay(db, startTime); @@ -348,6 +385,7 @@ export function getAggregatedStats(db: Database.Database, range: StatsTimeRange) byHour, ...sessionStats, byAgentByDay, + byAgentByHour, bySessionByDay, }; } diff --git a/src/main/stats/migrations.ts b/src/main/stats/migrations.ts index 4d356cee2..45043496d 100644 --- a/src/main/stats/migrations.ts +++ b/src/main/stats/migrations.ts @@ -24,6 +24,10 @@ import { CREATE_AUTO_RUN_TASKS_INDEXES_SQL, CREATE_SESSION_LIFECYCLE_SQL, CREATE_SESSION_LIFECYCLE_INDEXES_SQL, + CREATE_ACCOUNT_USAGE_WINDOWS_SQL, + CREATE_ACCOUNT_USAGE_WINDOWS_INDEXES_SQL, + CREATE_ACCOUNT_THROTTLE_EVENTS_SQL, + CREATE_ACCOUNT_THROTTLE_EVENTS_INDEXES_SQL, runStatements, } from './schema'; import { LOG_CONTEXT } from './utils'; @@ -54,6 +58,11 @@ export function getMigrations(): Migration[] { description: 'Add session_lifecycle table for tracking session creation and closure', up: (db) => migrateV3(db), }, + { + version: 4, + description: 'Add account usage tracking columns and tables', + up: (db) => migrateV4(db), + }, ]; } @@ -232,3 +241,30 @@ function migrateV3(db: Database.Database): void { logger.debug('Created session_lifecycle table', LOG_CONTEXT); } + +/** + * Migration v4: Add account usage tracking columns and tables + * + * - Adds account_id and token/cost columns to query_events + * - Creates account_usage_windows table for windowed aggregation + * - Creates account_throttle_events table for throttle history + */ +function migrateV4(db: Database.Database): void { + // Add account_id and token/cost columns to query_events + db.prepare('ALTER TABLE query_events ADD COLUMN account_id TEXT DEFAULT NULL').run(); + db.prepare('ALTER TABLE query_events ADD COLUMN input_tokens INTEGER DEFAULT 0').run(); + db.prepare('ALTER TABLE query_events ADD COLUMN output_tokens INTEGER DEFAULT 0').run(); + db.prepare('ALTER TABLE query_events ADD COLUMN cache_read_tokens INTEGER DEFAULT 0').run(); + db.prepare('ALTER TABLE query_events ADD COLUMN cache_creation_tokens INTEGER DEFAULT 0').run(); + db.prepare('ALTER TABLE query_events ADD COLUMN cost_usd REAL DEFAULT 0').run(); + + // Create account_usage_windows table + db.prepare(CREATE_ACCOUNT_USAGE_WINDOWS_SQL).run(); + runStatements(db, CREATE_ACCOUNT_USAGE_WINDOWS_INDEXES_SQL); + + // Create account_throttle_events table + db.prepare(CREATE_ACCOUNT_THROTTLE_EVENTS_SQL).run(); + runStatements(db, CREATE_ACCOUNT_THROTTLE_EVENTS_INDEXES_SQL); + + logger.debug('Added account usage tracking columns and tables', LOG_CONTEXT); +} diff --git a/src/main/stats/query-events.ts b/src/main/stats/query-events.ts index c39d7b36d..a9bc66a2b 100644 --- a/src/main/stats/query-events.ts +++ b/src/main/stats/query-events.ts @@ -14,8 +14,8 @@ import { logger } from '../utils/logger'; const stmtCache = new StatementCache(); const INSERT_SQL = ` - INSERT INTO query_events (id, session_id, agent_type, source, start_time, duration, project_path, tab_id, is_remote) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO query_events (id, session_id, agent_type, source, start_time, duration, project_path, tab_id, is_remote, account_id, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, cost_usd) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `; /** @@ -34,7 +34,13 @@ export function insertQueryEvent(db: Database.Database, event: Omit; + /** Current session-to-account assignments, keyed by session ID */ + assignments: Record; + /** Global account switching configuration */ + switchConfig: AccountSwitchConfig; + /** Ordered list of account IDs for round-robin assignment */ + rotationOrder: string[]; + /** Index of the last account used in round-robin rotation */ + rotationIndex: number; +} diff --git a/src/main/stores/getters.ts b/src/main/stores/getters.ts index 84be70710..39c343b32 100644 --- a/src/main/stores/getters.ts +++ b/src/main/stores/getters.ts @@ -17,6 +17,7 @@ import type { ClaudeSessionOriginsData, AgentSessionOriginsData, } from './types'; +import type { AccountStoreData } from './account-store-types'; import type { SshRemoteConfig } from '../../shared/types'; import { isInitialized, getStoreInstances, getCachedPaths } from './instances'; @@ -78,6 +79,11 @@ export function getAgentSessionOriginsStore(): Store { return getStoreInstances().agentSessionOriginsStore!; } +export function getAccountStore(): Store { + ensureInitialized(); + return getStoreInstances().accountStore!; +} + // ============================================================================ // Path Getters // ============================================================================ diff --git a/src/main/stores/index.ts b/src/main/stores/index.ts index f47486596..7c9e4b437 100644 --- a/src/main/stores/index.ts +++ b/src/main/stores/index.ts @@ -25,6 +25,7 @@ // ============================================================================ export * from './types'; +export type { AccountStoreData } from './account-store-types'; // ============================================================================ // Store Initialization @@ -46,6 +47,7 @@ export { getWindowStateStore, getClaudeSessionOriginsStore, getAgentSessionOriginsStore, + getAccountStore, getSyncPath, getProductionDataPath, getSshRemoteById, diff --git a/src/main/stores/instances.ts b/src/main/stores/instances.ts index a38950799..f5989fdaf 100644 --- a/src/main/stores/instances.ts +++ b/src/main/stores/instances.ts @@ -24,6 +24,8 @@ import type { AgentSessionOriginsData, } from './types'; +import type { AccountStoreData } from './account-store-types'; + import { SETTINGS_DEFAULTS, SESSIONS_DEFAULTS, @@ -34,6 +36,8 @@ import { AGENT_SESSION_ORIGINS_DEFAULTS, } from './defaults'; +import { ACCOUNT_SWITCH_DEFAULTS } from '../../shared/account-types'; + import { getCustomSyncPath } from './utils'; // ============================================================================ @@ -48,6 +52,15 @@ let _agentConfigsStore: Store | null = null; let _windowStateStore: Store | null = null; let _claudeSessionOriginsStore: Store | null = null; let _agentSessionOriginsStore: Store | null = null; +let _accountStore: Store | null = null; + +const ACCOUNT_STORE_DEFAULTS: AccountStoreData = { + accounts: {}, + assignments: {}, + switchConfig: ACCOUNT_SWITCH_DEFAULTS, + rotationOrder: [], + rotationIndex: 0, +}; // Cached paths after initialization let _syncPath: string | null = null; @@ -137,6 +150,13 @@ export function initializeStores(options: StoreInitOptions): { defaults: AGENT_SESSION_ORIGINS_DEFAULTS, }); + // Account multiplexing - manages multiple Claude Code accounts + _accountStore = new Store({ + name: 'maestro-accounts', + cwd: _syncPath, + defaults: ACCOUNT_STORE_DEFAULTS, + }); + return { syncPath: _syncPath, bootstrapStore: _bootstrapStore, @@ -163,6 +183,7 @@ export function getStoreInstances() { windowStateStore: _windowStateStore, claudeSessionOriginsStore: _claudeSessionOriginsStore, agentSessionOriginsStore: _agentSessionOriginsStore, + accountStore: _accountStore, }; } diff --git a/src/main/utils/context-groomer.ts b/src/main/utils/context-groomer.ts index e5bc6d06d..1cd270b60 100644 --- a/src/main/utils/context-groomer.ts +++ b/src/main/utils/context-groomer.ts @@ -16,6 +16,8 @@ import { v4 as uuidv4 } from 'uuid'; import { logger } from './logger'; import { buildAgentArgs } from './agent-args'; import type { AgentDetector } from '../agents'; +import type { AccountRegistry } from '../accounts/account-registry'; +import { injectAccountEnv } from '../accounts/account-env-injector'; const LOG_CONTEXT = '[ContextGroomer]'; @@ -129,6 +131,10 @@ export interface GroomContextOptions { sessionCustomArgs?: string; /** Custom environment variables for the agent */ sessionCustomEnvVars?: Record; + /** Account registry for multiplexing (optional) */ + accountRegistry?: AccountRegistry; + /** Account ID to inherit from parent session (optional) */ + accountId?: string; } /** @@ -170,6 +176,8 @@ export async function groomContext( sessionCustomPath, sessionCustomArgs, sessionCustomEnvVars, + accountRegistry: optAccountRegistry, + accountId: optAccountId, } = options; const groomerSessionId = `groomer-${uuidv4()}`; @@ -317,6 +325,22 @@ export async function groomContext( processManager.on('exit', onExit); processManager.on('agent-error', onError); + // Inject CLAUDE_CONFIG_DIR for account multiplexing (grooming inherits parent account) + let effectiveEnvVars = sessionCustomEnvVars; + if (optAccountRegistry) { + const envToInject: Record = effectiveEnvVars ? { ...effectiveEnvVars } : {}; + const assignedId = injectAccountEnv( + groomerSessionId, + agentType, + envToInject, + optAccountRegistry, + optAccountId, + ); + if (assignedId) { + effectiveEnvVars = envToInject; + } + } + // Spawn the process in batch mode const spawnResult = processManager.spawn({ sessionId: groomerSessionId, @@ -331,7 +355,7 @@ export async function groomContext( sessionSshRemoteConfig, sessionCustomPath, sessionCustomArgs, - sessionCustomEnvVars, + sessionCustomEnvVars: effectiveEnvVars, }); if (!spawnResult || spawnResult.pid <= 0) { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 055459886..6b58320f7 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -23,6 +23,7 @@ import { type SendToAgentOptions, } from './components/AppModals'; import { DEFAULT_BATCH_PROMPT } from './components/BatchRunnerModal'; +import * as Sentry from '@sentry/electron/renderer'; import { ErrorBoundary } from './components/ErrorBoundary'; import { MainPanel, type MainPanelHandle } from './components/MainPanel'; import { AppOverlays } from './components/AppOverlays'; @@ -44,6 +45,10 @@ import { TourOverlay } from './components/Wizard/tour'; import { CONDUCTOR_BADGES, getBadgeForTime } from './constants/conductorBadges'; import { EmptyStateView } from './components/EmptyStateView'; import { DeleteAgentConfirmModal } from './components/DeleteAgentConfirmModal'; +import { UnarchiveConflictModal } from './components/UnarchiveConflictModal'; +import { AccountSwitchModal } from './components/AccountSwitchModal'; +import { VirtuososModal } from './components/VirtuososModal'; +import { SwitchProviderModal } from './components/SwitchProviderModal'; // Lazy-loaded components for performance (rarely-used heavy modals) // These are loaded on-demand when the user first opens them @@ -130,10 +135,12 @@ import { import type { TabCompletionSuggestion, TabCompletionFilter } from './hooks'; import { useMainPanelProps, useSessionListProps, useRightPanelProps } from './hooks/props'; import { useAgentListeners } from './hooks/agent/useAgentListeners'; +import { useProviderSwitch } from './hooks/agent/useProviderSwitch'; // Import contexts import { useLayerStack } from './contexts/LayerStackContext'; import { useNotificationStore, notifyToast } from './stores/notificationStore'; +import { useSettingsStore } from './stores/settingsStore'; import { useModalActions, useModalStore } from './stores/modalStore'; import { GitStatusProvider } from './contexts/GitStatusContext'; import { InputProvider, useInputContext } from './contexts/InputContext'; @@ -149,6 +156,7 @@ import { ToastContainer } from './components/Toast'; import { gitService } from './services/git'; import { getSpeckitCommands } from './services/speckit'; import { getOpenSpecCommands } from './services/openspec'; +import { getAgentDisplayName } from './services/contextGroomer'; // Import prompts and synopsis parsing import { autorunSynopsisPrompt, maestroSystemPrompt } from '../prompts'; @@ -380,6 +388,9 @@ function MaestroConsoleInner() { // Director's Notes Modal directorNotesOpen, setDirectorNotesOpen, + // Virtuosos Modal + virtuososOpen, + setVirtuososOpen, } = useModalActions(); // --- MOBILE LANDSCAPE MODE (reading-only view) --- @@ -842,6 +853,27 @@ function MaestroConsoleInner() { // Note: Delete Agent Modal State is now managed by modalStore (Zustand) // See useModalActions() destructuring above for deleteAgentModalOpen / deleteAgentSession + // Account Switch Prompt Modal State + const [switchPromptData, setSwitchPromptData] = useState<{ + sessionId: string; + fromAccountId: string; + fromAccountName: string; + toAccountId: string; + toAccountName: string; + reason: string; + tokensAtThrottle?: number; + usagePercent?: number; + } | null>(null); + + // Provider Switch state + const [switchProviderSession, setSwitchProviderSession] = useState(null); + + // Unarchive conflict state + const [unarchiveConflictState, setUnarchiveConflictState] = useState<{ + archivedSession: Session; + conflictingSession: Session; + } | null>(null); + // Note: Git Diff State, Tour Overlay State, and Git Log Viewer State are from modalStore // Note: Renaming state (editingGroupId/editingSessionId) and drag state (draggingSessionId) @@ -1224,6 +1256,68 @@ function MaestroConsoleInner() { setActiveSessionId(restoredSessions[0].id); } + // Reconcile account assignments after session restore (ACCT-MUX-13) + // This validates accounts still exist and updates customEnvVars accordingly + if (useSettingsStore.getState().encoreFeatures.virtuosos) + try { + const activeIds = restoredSessions.map((s) => s.id); + const reconciliation = await window.maestro.accounts.reconcileSessions(activeIds); + if (reconciliation.success && reconciliation.corrections.length > 0) { + setSessions((prev) => + prev.map((session) => { + const correction = reconciliation.corrections.find( + (c) => c.sessionId === session.id + ); + if (!correction) return session; + + if (correction.status === 'removed') { + // Account was removed — clear session's account fields and CLAUDE_CONFIG_DIR + const cleanedEnvVars = { ...session.customEnvVars }; + delete cleanedEnvVars.CLAUDE_CONFIG_DIR; + return { + ...session, + accountId: undefined, + accountName: undefined, + customEnvVars: + Object.keys(cleanedEnvVars).length > 0 ? cleanedEnvVars : undefined, + }; + } else if (correction.configDir && session.accountId) { + // Account exists — ensure CLAUDE_CONFIG_DIR is current + return { + ...session, + accountId: correction.accountId ?? undefined, + accountName: correction.accountName ?? undefined, + customEnvVars: { + ...session.customEnvVars, + CLAUDE_CONFIG_DIR: correction.configDir, + }, + }; + } + return session; + }) + ); + } + // Re-register assignments for sessions that have accountId but were + // created before the assign() call was added to session creation + for (const session of restoredSessions) { + if (session.accountId && session.toolType === 'claude-code') { + window.maestro.accounts.assign(session.id, session.accountId).catch((err) => { + Sentry.captureException(err, { + extra: { + operation: 'account:reconcileAssign', + sessionId: session.id, + accountId: session.accountId, + }, + }); + }); + } + } + } catch (reconcileError) { + Sentry.captureException(reconcileError, { + extra: { operation: 'account:reconciliation' }, + }); + } + // For remote (SSH) sessions, fetch git info in background to avoid blocking // startup on SSH connection timeouts. This runs after UI is shown. for (const session of restoredSessions) { @@ -1512,6 +1606,7 @@ function MaestroConsoleInner() { | null >(null); const getBatchStateRef = useRef<((sessionId: string) => BatchRunState) | null>(null); + const resumeAfterErrorRef = useRef<((sessionId: string) => void) | null>(null); // Note: thinkingChunkBufferRef and thinkingChunkRafIdRef moved into useAgentListeners hook @@ -1540,6 +1635,342 @@ function MaestroConsoleInner() { }; }, []); + // Subscribe to account limit warning/reached events for toast notifications + useEffect(() => { + if (!encoreFeatures.virtuosos) return; + const unsubWarning = window.maestro.accounts.onLimitWarning((data) => { + notifyToast({ + type: 'warning', + title: 'Virtuoso Limit Warning', + message: `Virtuoso ${data.accountName} is at ${Math.round(data.usagePercent)}% of its token limit`, + duration: 10_000, + }); + }); + + const unsubReached = window.maestro.accounts.onLimitReached((data) => { + notifyToast({ + type: 'error', + title: 'Virtuoso Limit Reached', + message: `Virtuoso ${data.accountName} has reached its token limit (${Math.round(data.usagePercent)}%)`, + duration: 0, // Do NOT auto-dismiss + }); + }); + + return () => { + unsubWarning(); + unsubReached(); + }; + }, [encoreFeatures.virtuosos]); + + // Subscribe to account recovery events for auto-resume of paused Auto Runs + useEffect(() => { + if (!encoreFeatures.virtuosos) return; + const unsubRecovery = window.maestro.accounts.onRecoveryAvailable((data) => { + notifyToast({ + type: 'success', + title: 'Virtuoso Recovered', + message: + data.recoveredCount === 1 + ? 'Virtuoso is available again' + : `${data.recoveredCount} virtuosos are available again`, + duration: 8_000, + }); + + // Auto-resume any Auto Runs that are paused due to rate limiting + const currentSessions = sessionsRef.current; + for (const session of currentSessions) { + const batchState = getBatchStateRef.current?.(session.id); + if (!batchState?.isRunning || batchState.processingState !== 'PAUSED_ERROR') continue; + if (!batchState.error) continue; + + // Check if the pause was due to rate limiting + const isRateLimitPause = + batchState.error.type === 'rate_limited' || + (batchState.error.message?.includes('rate') ?? false) || + (batchState.error.message?.includes('throttle') ?? false) || + (batchState.error.message?.includes('All virtuosos') ?? false); + + if (isRateLimitPause) { + const recoveredForThis = data.recoveredAccountIds.includes(session.accountId || ''); + if (recoveredForThis || data.recoveredAccountIds.length > 0) { + resumeAfterErrorRef.current?.(session.id); + + notifyToast({ + type: 'info', + title: 'Auto Run Resuming', + message: 'Resuming after virtuoso recovery', + duration: 5_000, + }); + } + } + } + }); + + return () => unsubRecovery(); + }, [encoreFeatures.virtuosos]); + + // Subscribe to all-accounts-exhausted throttle events for Auto Run pause + useEffect(() => { + if (!encoreFeatures.virtuosos) return; + const unsubThrottled = window.maestro.accounts.onThrottled((data) => { + if (!data.noAlternatives) return; // Only handle the exhausted case + + const sessionId = data.sessionId as string; + if (!sessionId) return; + + // Check if this session has an active Auto Run + const batchState = getBatchStateRef.current?.(sessionId); + if (!batchState?.isRunning || batchState.errorPaused) return; + + // Pause the batch with a specific rate_limited error so recovery can auto-resolve it + pauseBatchOnErrorRef.current?.( + sessionId, + { + type: 'rate_limited', + message: 'All virtuosos have been rate-limited. Waiting for automatic recovery...', + recoverable: true, + agentId: 'claude-code', + timestamp: Date.now(), + }, + batchState.currentDocumentIndex, + 'Waiting for virtuoso recovery' + ); + }); + + return () => unsubThrottled(); + }, [encoreFeatures.virtuosos]); + + // Subscribe to account assignment events (update session state when main process assigns an account) + useEffect(() => { + if (!encoreFeatures.virtuosos) return; + const unsubAssigned = window.maestro.accounts.onAssigned((data) => { + setSessions((prev) => + prev.map((s) => { + if (!data.sessionId.startsWith(s.id)) return s; + return { ...s, accountId: data.accountId, accountName: data.accountName }; + }) + ); + }); + return () => unsubAssigned(); + }, [encoreFeatures.virtuosos]); + + // Subscribe to account switch events (respawn agent with new account after switch) + useEffect(() => { + if (!encoreFeatures.virtuosos) return; + const unsubRespawn = window.maestro.accounts.onSwitchRespawn(async (data) => { + const { + sessionId: switchSessionId, + toAccountId, + toAccountName, + configDir, + lastPrompt, + reason, + } = data; + + // Find the session that needs respawning (match by base session ID) + const session = sessionsRef.current.find((s) => switchSessionId.startsWith(s.id)); + if (!session) { + Sentry.captureException(new Error('[AccountSwitch] Session not found for respawn'), { + extra: { operation: 'account:switchRespawn', switchSessionId }, + }); + return; + } + + // Update session with new account info and CLAUDE_CONFIG_DIR + setSessions((prev) => + prev.map((s) => { + if (s.id !== session.id) return s; + return { + ...s, + accountId: toAccountId, + accountName: toAccountName, + customEnvVars: { + ...s.customEnvVars, + CLAUDE_CONFIG_DIR: configDir, + }, + }; + }) + ); + + try { + // Get agent config for respawn + const agent = await window.maestro.agents.get(session.toolType); + if (!agent) { + Sentry.captureException(new Error('[AccountSwitch] Agent not found for respawn'), { + extra: { + operation: 'account:switchRespawn', + toolType: session.toolType, + sessionId: session.id, + }, + }); + return; + } + + // Get the active tab's agent session ID for --resume + const tab = getActiveTab(session); + const tabAgentSessionId = tab?.agentSessionId; + const commandToUse = agent.path ?? agent.command; + const agentArgs = agent.args ?? []; + + // Determine the target session ID (with tab suffix) + const targetSessionId = `${session.id}-ai-${tab?.id || 'default'}`; + + // Spawn with --resume and updated env vars + await window.maestro.process.spawn({ + sessionId: targetSessionId, + toolType: session.toolType, + cwd: session.cwd, + command: commandToUse, + args: agentArgs, + agentSessionId: tabAgentSessionId ?? undefined, + sessionCustomPath: session.customPath, + sessionCustomArgs: session.customArgs, + sessionCustomEnvVars: { + ...session.customEnvVars, + CLAUDE_CONFIG_DIR: configDir, + }, + sessionCustomModel: session.customModel, + sessionCustomContextWindow: session.customContextWindow, + accountId: toAccountId, + sessionSshRemoteConfig: session.sessionSshRemoteConfig, + }); + + // Re-send the last prompt after a delay to allow the agent to initialize + if (lastPrompt) { + setTimeout(() => { + window.maestro.process.write(targetSessionId, lastPrompt); + }, 2000); + } + + notifyToast({ + type: 'info', + title: 'Account Switched', + message: `Switched to account ${toAccountName} (${reason})`, + duration: 5_000, + }); + } catch (error) { + Sentry.captureException(error, { + extra: { operation: 'account:switchRespawn', sessionId: session.id, toAccountId }, + }); + notifyToast({ + type: 'error', + title: 'Account Switch Failed', + message: `Failed to respawn agent after account switch: ${String(error)}`, + duration: 0, + }); + } + }); + + const unsubSwitchFailed = window.maestro.accounts.onSwitchFailed((data) => { + notifyToast({ + type: 'error', + title: 'Account Switch Failed', + message: `Failed to switch account: ${data.error || 'Unknown error'}`, + duration: 0, + }); + }); + + const unsubSwitchExecute = window.maestro.accounts.onSwitchExecute(async (data) => { + // Auto-switch mode: execute the switch immediately + const { sessionId: switchSessionId, fromAccountId, toAccountId, reason } = data as any; + try { + await window.maestro.accounts.executeSwitch({ + sessionId: switchSessionId, + fromAccountId, + toAccountId, + reason: reason ?? 'throttled', + automatic: true, + }); + } catch (error) { + Sentry.captureException(error, { + extra: { + operation: 'account:autoSwitchExecution', + switchSessionId, + fromAccountId, + toAccountId, + }, + }); + } + }); + + return () => { + unsubRespawn(); + unsubSwitchFailed(); + unsubSwitchExecute(); + }; + }, [encoreFeatures.virtuosos]); + + // Subscribe to account switch prompt events (user confirmation needed) + useEffect(() => { + if (!encoreFeatures.virtuosos) return; + const unsubSwitchPrompt = window.maestro.accounts.onSwitchPrompt((data: any) => { + setSwitchPromptData({ + sessionId: data.sessionId, + fromAccountId: data.fromAccountId, + fromAccountName: data.fromAccountName ?? data.fromAccountId, + toAccountId: data.toAccountId, + toAccountName: data.toAccountName ?? data.toAccountId, + reason: data.reason ?? 'throttled', + tokensAtThrottle: data.tokensAtThrottle, + usagePercent: data.usagePercent, + }); + }); + + const unsubSwitchCompleted = window.maestro.accounts.onSwitchCompleted((data: any) => { + notifyToast({ + type: 'success', + title: 'Account Switched', + message: `Switched from ${data.fromAccountName ?? data.fromAccountId} to ${data.toAccountName ?? data.toAccountId}`, + duration: 5_000, + }); + }); + + return () => { + unsubSwitchPrompt(); + unsubSwitchCompleted(); + }; + }, [encoreFeatures.virtuosos]); + + // Subscribe to provider failover suggestion events (Virtuosos provider switching) + useEffect(() => { + if (!encoreFeatures.virtuosos) return; + + const cleanup = window.maestro.providers.onFailoverSuggest(async (suggestion) => { + // Find the session + const session = sessionsRef.current.find((s) => s.id === suggestion.sessionId); + if (!session) return; + + // Load provider switch config from settings + let providerConfig: { promptBeforeSwitch?: boolean } = { promptBeforeSwitch: true }; + try { + const saved = await window.maestro.settings.get('providerSwitchConfig'); + if (saved && typeof saved === 'object') { + providerConfig = saved as typeof providerConfig; + } + } catch { + // Use default + } + + // Show toast notification + notifyToast({ + type: 'warning', + title: 'Provider Issues Detected', + message: `${getAgentDisplayName(suggestion.currentProvider as ToolType)} had ${suggestion.errorCount} errors. ${ + providerConfig.promptBeforeSwitch !== false + ? 'Suggesting switch...' + : `Auto-switching to ${getAgentDisplayName(suggestion.suggestedProvider as ToolType)}` + }`, + duration: 8_000, + }); + + // Always open SwitchProviderModal for manual confirmation + // (auto-switch path will be invoked once handleConfirmProviderSwitch is available) + setSwitchProviderSession(session); + }); + + return cleanup; + }, [encoreFeatures.virtuosos]); + // Keyboard navigation state // Note: selectedSidebarIndex/setSelectedSidebarIndex are destructured from useUIStore() above // Note: activeTab is memoized later at line ~3795 - use that for all tab operations @@ -2390,6 +2821,15 @@ You are taking over this conversation. Based on the context above, provide a bri minContextUsagePercent, } = useSummarizeAndContinue(activeSession ?? null); + const { + switchProvider, + transferState: _providerSwitchState, + progress: _providerSwitchProgress, + error: _providerSwitchError, + cancelSwitch: _cancelProviderSwitch, + reset: resetProviderSwitch, + } = useProviderSwitch(); + // Handler for starting summarization (non-blocking - UI remains interactive) const handleSummarizeAndContinue = useCallback( (tabId?: string) => { @@ -3373,6 +3813,7 @@ You are taking over this conversation. Based on the context above, provide a bri // These are used by the agent error handler which runs in a useEffect with empty deps pauseBatchOnErrorRef.current = pauseBatchOnError; getBatchStateRef.current = getBatchState; + resumeAfterErrorRef.current = resumeAfterError; // Get batch state for the current session - used for locking the AutoRun editor // This is session-specific so users can edit docs in other sessions while one runs @@ -4493,8 +4934,10 @@ You are taking over this conversation. Based on the context above, provide a bri enabled: boolean; remoteId: string | null; workingDirOverride?: string; - } + }, + accountId?: string ) => { + // Update session fields immediately setSessions((prev) => prev.map((s) => { if (s.id !== sessionId) return s; @@ -4508,13 +4951,249 @@ You are taking over this conversation. Based on the context above, provide a bri customModel, customContextWindow, sessionSshRemoteConfig, + ...(accountId !== undefined ? { accountId } : {}), }; }) ); + + // Handle account change: resolve name immediately, then trigger switch/assign + if (accountId) { + const currentSession = sessionsRef.current.find((s) => s.id === sessionId); + const fromAccountId = currentSession?.accountId; + + // Resolve account name and update session right away + window.maestro.accounts + .list() + .then((accounts: any[]) => { + const account = accounts.find((a: any) => a.id === accountId); + if (account) { + setSessions((prev) => + prev.map((s) => { + if (s.id !== sessionId) return s; + return { ...s, accountId, accountName: account.name }; + }) + ); + } + }) + .catch((err) => { + Sentry.captureException(err, { + extra: { operation: 'account:resolveNameOnSwitch', sessionId, accountId }, + }); + }); + + if (fromAccountId && fromAccountId !== accountId) { + // Full switch: kills running process, reassigns, respawns with new CLAUDE_CONFIG_DIR + window.maestro.accounts + .executeSwitch({ + sessionId, + fromAccountId, + toAccountId: accountId, + reason: 'manual', + automatic: false, + }) + .catch((err) => { + Sentry.captureException(err, { + extra: { + operation: 'account:executeSwitch', + sessionId, + fromAccountId, + toAccountId: accountId, + }, + }); + }); + } else { + // First assignment or same account — just update registry + window.maestro.accounts.assign(sessionId, accountId).catch((err) => { + Sentry.captureException(err, { + extra: { operation: 'account:assign', sessionId, accountId }, + }); + }); + } + } }, - [] + [sessionsRef] + ); + + // Provider Switch handlers + const handleSwitchProvider = useCallback((sessionId: string) => { + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (session && session.toolType !== 'terminal') { + setSwitchProviderSession(session); + } + }, []); + + const handleConfirmProviderSwitch = useCallback( + async (request: { + targetProvider: ToolType; + groomContext: boolean; + archiveSource: boolean; + mergeBackInto?: Session; + }) => { + if (!switchProviderSession) return; + + const activeTab = getActiveTab(switchProviderSession); + if (!activeTab) return; + + const result = await switchProvider({ + sourceSession: switchProviderSession, + sourceTabId: activeTab.id, + targetProvider: request.targetProvider, + groomContext: request.groomContext, + mergeBackInto: request.mergeBackInto, + }); + + if (result.success && result.newSession) { + if (result.mergedBack && request.mergeBackInto) { + // Merge-back: replace the archived session with the reactivated one + setSessions((prev) => + prev.map((s) => (s.id === request.mergeBackInto!.id ? result.newSession! : s)) + ); + } else { + // Always-new: add the new session to state + setSessions((prev) => [...prev, result.newSession!]); + } + + // Mark source as archived if requested + if (request.archiveSource) { + setSessions((prev) => + prev.map((s) => + s.id === switchProviderSession.id + ? { + ...s, + archivedByMigration: true, + migratedToSessionId: result.newSessionId, + } + : s + ) + ); + } + + // Clear provider error tracking for source session + window.maestro.providers.clearSessionErrors(switchProviderSession.id).catch((err) => { + Sentry.captureException(err, { + extra: { + operation: 'provider:clearSessionErrors', + sessionId: switchProviderSession.id, + }, + }); + }); + + // Navigate to the new/reactivated session + setActiveSessionId(result.newSessionId!); + + // Show success toast + const action = result.mergedBack ? 'Merged back to' : 'Switched to'; + notifyToast({ + type: 'success', + title: 'Provider Switched', + message: `${action} ${getAgentDisplayName(request.targetProvider)}`, + duration: 5_000, + }); + } + + // Close the modal + setSwitchProviderSession(null); + }, + [switchProviderSession, switchProvider, setActiveSessionId] ); + // Unarchive handlers + const handleUnarchive = useCallback((sessionId: string) => { + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (!session || !session.archivedByMigration) return; + + // Check for conflict: another non-archived session with the same name AND toolType + const conflicting = sessionsRef.current.find( + (s) => + s.id !== sessionId && + s.toolType === session.toolType && + s.name === session.name && + !s.archivedByMigration + ); + + if (conflicting) { + setUnarchiveConflictState({ + archivedSession: session, + conflictingSession: conflicting, + }); + } else { + // No conflict — directly unarchive + setSessions((prev) => + prev.map((s) => + s.id === sessionId + ? { ...s, archivedByMigration: false, migratedToSessionId: undefined } + : s + ) + ); + notifyToast({ + type: 'success', + title: 'Agent Unarchived', + message: `${session.name || 'Agent'} has been restored`, + duration: 3_000, + }); + } + }, []); + + const handleUnarchiveWithArchiveConflict = useCallback(() => { + if (!unarchiveConflictState) return; + const { archivedSession, conflictingSession } = unarchiveConflictState; + + setSessions((prev) => + prev.map((s) => { + if (s.id === archivedSession.id) { + return { ...s, archivedByMigration: false, migratedToSessionId: undefined }; + } + if (s.id === conflictingSession.id) { + return { ...s, archivedByMigration: true }; + } + return s; + }) + ); + + notifyToast({ + type: 'success', + title: 'Agent Unarchived', + message: `${archivedSession.name || 'Agent'} restored, ${conflictingSession.name || 'agent'} archived`, + duration: 5_000, + }); + + setUnarchiveConflictState(null); + }, [unarchiveConflictState]); + + const handleUnarchiveWithDeleteConflict = useCallback(() => { + if (!unarchiveConflictState) return; + const { archivedSession, conflictingSession } = unarchiveConflictState; + + setSessions((prev) => + prev + .filter((s) => s.id !== conflictingSession.id) + .map((s) => + s.id === archivedSession.id + ? { ...s, archivedByMigration: false, migratedToSessionId: undefined } + : s + ) + ); + + // Kill process for deleted session if running + window.maestro.process.kill(conflictingSession.id).catch((err) => { + Sentry.captureException(err, { + extra: { + operation: 'account:killConflictingOnUnarchive', + sessionId: conflictingSession.id, + }, + }); + }); + + notifyToast({ + type: 'success', + title: 'Agent Unarchived', + message: `${archivedSession.name || 'Agent'} restored, ${conflictingSession.name || 'agent'} removed`, + duration: 5_000, + }); + + setUnarchiveConflictState(null); + }, [unarchiveConflictState]); + const handleRenameTab = useCallback( (newName: string) => { if (!activeSession || !renameTabId) return; @@ -5038,6 +5717,13 @@ You are taking over this conversation. Based on the context above, provide a bri console.error('Failed to delete playbooks:', error); } + // Clean up account assignment and switcher tracking + try { + await window.maestro.accounts.cleanupSession(id); + } catch (error) { + console.error('Failed to clean up account session:', error); + } + // If this is a worktree session, track its path to prevent re-discovery if (session.worktreeParentPath && session.cwd) { setRemovedWorktreePaths((prev) => new Set([...prev, session.cwd])); @@ -5291,6 +5977,33 @@ You are taking over this conversation. Based on the context above, provide a bri // Default Auto Run folder path (user can change later) autoRunFolderPath: `${workingDir}/${AUTO_RUN_FOLDER_NAME}`, }; + + // Pre-assign account for Claude Code sessions if accounts are configured + if (encoreFeatures.virtuosos && newSession.toolType === 'claude-code') { + try { + const defaultAccount = (await window.maestro.accounts.getDefault()) as { + id: string; + name: string; + } | null; + if (defaultAccount) { + newSession.accountId = defaultAccount.id; + newSession.accountName = defaultAccount.name; + // Register assignment with main process so usage listener tracks this session + window.maestro.accounts.assign(newId, defaultAccount.id).catch((err) => { + Sentry.captureException(err, { + extra: { + operation: 'account:assignOnCreate', + sessionId: newId, + accountId: defaultAccount.id, + }, + }); + }); + } + } catch { + // Accounts not configured or unavailable — proceed without assignment + } + } + setSessions((prev) => [...prev, newSession]); setActiveSessionId(newId); // Track session creation in global stats @@ -8188,6 +8901,7 @@ You are taking over this conversation. Based on the context above, provide a bri setDuplicatingSessionId, setGroupChatsExpanded, setQuickActionOpen, + setVirtuososOpen: encoreFeatures.virtuosos ? setVirtuososOpen : undefined, // Handlers toggleGlobalLive, @@ -8213,6 +8927,8 @@ You are taking over this conversation. Based on the context above, provide a bri handleOpenWorktreeConfigSession, handleDeleteWorktreeSession, handleToggleWorktreeExpanded, + handleSwitchProvider: encoreFeatures.virtuosos ? handleSwitchProvider : undefined, + handleUnarchive: encoreFeatures.virtuosos ? handleUnarchive : undefined, openWizardModal, handleStartTour, @@ -8486,6 +9202,11 @@ You are taking over this conversation. Based on the context above, provide a bri onCloseEditAgentModal={handleCloseEditAgentModal} onSaveEditAgent={handleSaveEditAgent} editAgentSession={editAgentSession} + onSwitchProviderFromEdit={ + encoreFeatures.virtuosos && editAgentSession + ? () => handleSwitchProvider(editAgentSession.id) + : undefined + } renameSessionModalOpen={renameInstanceModalOpen} renameSessionValue={renameInstanceValue} setRenameSessionValue={setRenameInstanceValue} @@ -9156,6 +9877,72 @@ You are taking over this conversation. Based on the context above, provide a bri /> )} + {/* Unarchive Conflict Modal */} + {unarchiveConflictState && ( + setUnarchiveConflictState(null)} + /> + )} + + {/* Account Switch Confirmation Modal */} + {encoreFeatures.virtuosos && switchPromptData && ( + setSwitchPromptData(null)} + switchData={switchPromptData} + onConfirmSwitch={async () => { + await window.maestro.accounts.executeSwitch({ + sessionId: switchPromptData.sessionId, + fromAccountId: switchPromptData.fromAccountId, + toAccountId: switchPromptData.toAccountId, + reason: switchPromptData.reason, + automatic: false, + }); + setSwitchPromptData(null); + }} + onViewDashboard={() => { + setSwitchPromptData(null); + setVirtuososOpen(true); + }} + /> + )} + + {/* Virtuosos Modal */} + {encoreFeatures.virtuosos && ( + setVirtuososOpen(false)} + theme={theme} + sessions={sessions} + onSelectSession={(sessionId) => { + setVirtuososOpen(false); + setActiveSessionId(sessionId); + }} + /> + )} + + {/* Provider Switch Modal */} + {encoreFeatures.virtuosos && switchProviderSession && ( + { + setSwitchProviderSession(null); + resetProviderSwitch(); + }} + sourceSession={switchProviderSession} + sourceTabId={getActiveTab(switchProviderSession)?.id || ''} + sessions={sessions} + onConfirmSwitch={handleConfirmProviderSwitch} + /> + )} + {/* --- EMPTY STATE VIEW (when no sessions) --- */} {sessions.length === 0 && !isMobileLandscape ? ( void; + onManageAccounts?: () => void; + compact?: boolean; +} + +function getStatusColor(status: string, theme: Theme): string { + switch (status) { + case 'active': + return theme.colors.success; + case 'throttled': + return theme.colors.warning; + case 'expired': + case 'disabled': + return theme.colors.error; + default: + return theme.colors.textDim; + } +} + +export function AccountSelector({ + theme, + sessionId: _sessionId, + currentAccountId, + currentAccountName, + onSwitchAccount, + onManageAccounts, + compact = false, +}: AccountSelectorProps) { + const virtuososEnabled = useSettingsStore((state) => state.encoreFeatures.virtuosos); + const [accounts, setAccounts] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + const { metrics: usageMetrics } = useAccountUsage(); + + // Fetch accounts on mount and when dropdown opens (refresh) + useEffect(() => { + let cancelled = false; + (async () => { + try { + const list = (await window.maestro.accounts.list()) as AccountProfile[]; + if (!cancelled) setAccounts(list); + } catch (err) { + Sentry.captureException(err, { extra: { operation: 'account:fetchAccountList' } }); + } + })(); + return () => { + cancelled = true; + }; + }, [isOpen, currentAccountId]); + + // Close dropdown on outside click + useEffect(() => { + if (!isOpen) return; + const handleClick = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [isOpen]); + + // Close on Escape + useEffect(() => { + if (!isOpen) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + setIsOpen(false); + } + }; + document.addEventListener('keydown', handleKey, true); + return () => document.removeEventListener('keydown', handleKey, true); + }, [isOpen]); + + const currentAccount = accounts.find((a) => a.id === currentAccountId); + const displayName = + currentAccount?.name ?? currentAccount?.email ?? currentAccountName ?? 'No Virtuoso'; + + const handleSelect = useCallback( + (accountId: string) => { + if (accountId !== currentAccountId) { + onSwitchAccount(accountId); + } + setIsOpen(false); + }, + [currentAccountId, onSwitchAccount] + ); + + if (!virtuososEnabled) return null; + + return ( +
+ {/* Trigger button */} + {compact ? ( + + ) : ( + + )} + + {/* Dropdown */} + {isOpen && ( +
+
+ {accounts.length === 0 && ( +
+ No virtuosos configured +
+ )} + {accounts.map((account) => { + const isCurrent = account.id === currentAccountId; + const statusColor = getStatusColor(account.status, theme); + return ( + + ); + })} +
+ {/* Manage Accounts link */} + {onManageAccounts && ( +
+ +
+ )} +
+ )} +
+ ); +} diff --git a/src/renderer/components/AccountSwitchModal.tsx b/src/renderer/components/AccountSwitchModal.tsx new file mode 100644 index 000000000..2acb19228 --- /dev/null +++ b/src/renderer/components/AccountSwitchModal.tsx @@ -0,0 +1,208 @@ +/** + * AccountSwitchModal - Confirmation modal for prompted account switches + * + * Appears when `promptBeforeSwitch` is true and a throttle/limit event triggers + * an account switch suggestion. Shows current account status, recommended switch + * target, and action buttons to confirm, dismiss, or view the dashboard. + */ + +import { AlertTriangle, ArrowRightLeft, BarChart3 } from 'lucide-react'; +import type { Theme } from '../types'; +import { Modal } from './ui/Modal'; +import { MODAL_PRIORITIES } from '../constants/modalPriorities'; + +export interface AccountSwitchModalProps { + theme: Theme; + isOpen: boolean; + onClose: () => void; + switchData: { + sessionId: string; + fromAccountId: string; + fromAccountName: string; + toAccountId: string; + toAccountName: string; + reason: string; + tokensAtThrottle?: number; + usagePercent?: number; + }; + onConfirmSwitch: () => void; + onConfirmSwitchAndResume?: () => void; + onViewDashboard: () => void; +} + +function getReasonHeader(reason: string): string { + switch (reason) { + case 'throttled': + return 'Virtuoso Throttled'; + case 'limit-approaching': + case 'limit-reached': + return 'Virtuoso Limit Reached'; + case 'auth-expired': + return 'Authentication Expired'; + default: + return 'Virtuoso Switch Recommended'; + } +} + +function getReasonDescription(reason: string, name: string, usagePercent?: number): string { + switch (reason) { + case 'throttled': + return `Virtuoso ${name} has been rate limited`; + case 'limit-approaching': + return `Virtuoso ${name} is at ${usagePercent != null ? Math.round(usagePercent) : '?'}% of its token limit`; + case 'limit-reached': + return `Virtuoso ${name} has reached its token limit (${usagePercent != null ? Math.round(usagePercent) : '?'}%)`; + case 'auth-expired': + return `Virtuoso ${name} authentication has expired`; + default: + return `Virtuoso ${name} needs to be switched`; + } +} + +function getStatusColor(reason: string, theme: Theme): string { + switch (reason) { + case 'throttled': + return theme.colors.warning; + case 'limit-approaching': + return theme.colors.warning; + case 'limit-reached': + case 'auth-expired': + return theme.colors.error; + default: + return theme.colors.textDim; + } +} + +export function AccountSwitchModal({ + theme, + isOpen, + onClose, + switchData, + onConfirmSwitch, + onConfirmSwitchAndResume, + onViewDashboard, +}: AccountSwitchModalProps) { + if (!isOpen) return null; + + const { fromAccountName, toAccountName, reason, usagePercent } = switchData; + + return ( + } + width={440} + closeOnBackdropClick + footer={ +
+ +
+ + {onConfirmSwitchAndResume && ( + + )} + +
+ } + > +
+ {/* Reason explanation */} +

+ {getReasonDescription(reason, fromAccountName, usagePercent)} +

+ + {/* Current virtuoso */} +
+
+
+
+ {fromAccountName} +
+
+ Current virtuoso + {usagePercent != null && ` \u00B7 ${Math.round(usagePercent)}% used`} +
+
+
+ + {/* Arrow */} +
+ +
+ + {/* Recommended account */} +
+
+
+
+ {toAccountName} +
+
+ Recommended switch target +
+
+
+
+ + ); +} diff --git a/src/renderer/components/AccountUsageHistory.tsx b/src/renderer/components/AccountUsageHistory.tsx new file mode 100644 index 000000000..766537c2d --- /dev/null +++ b/src/renderer/components/AccountUsageHistory.tsx @@ -0,0 +1,175 @@ +import { useState, useEffect } from 'react'; +import type { Theme } from '../types'; +import { formatTokenCount } from '../hooks/useAccountUsage'; + +interface AccountDailyUsage { + date: string; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalTokens: number; + costUsd: number; + queryCount: number; +} + +interface AccountMonthlyUsage { + month: string; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalTokens: number; + costUsd: number; + queryCount: number; + daysActive: number; +} + +type ViewMode = '7d' | '30d' | 'monthly'; + +function formatDateLabel(label: string, view: ViewMode): string { + if (view === 'monthly') { + // YYYY-MM -> "Jan 26", "Feb 26", etc. + const [year, month] = label.split('-'); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return `${months[parseInt(month, 10) - 1]} ${year.slice(2)}`; + } + // YYYY-MM-DD -> "Feb 15", etc. + const [, month, day] = label.split('-'); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return `${months[parseInt(month, 10) - 1]} ${parseInt(day, 10)}`; +} + +export function AccountUsageHistory({ accountId, theme }: { accountId: string; theme: Theme }) { + const [view, setView] = useState('7d'); + const [dailyData, setDailyData] = useState([]); + const [monthlyData, setMonthlyData] = useState([]); + const [throttleCount, setThrottleCount] = useState(0); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function load() { + setLoading(true); + try { + if (view === 'monthly') { + const data = await window.maestro.accounts.getMonthlyUsage(accountId, 6); + setMonthlyData(data as AccountMonthlyUsage[]); + } else { + const days = view === '7d' ? 7 : 30; + const data = await window.maestro.accounts.getDailyUsage(accountId, days); + setDailyData(data as AccountDailyUsage[]); + } + + // Fetch throttle events for displayed time range + const sinceMs = view === 'monthly' + ? Date.now() - 6 * 30 * 24 * 60 * 60 * 1000 + : Date.now() - (view === '7d' ? 7 : 30) * 24 * 60 * 60 * 1000; + const events = await window.maestro.accounts.getThrottleEvents(accountId, sinceMs); + setThrottleCount(events.length); + } catch (err) { console.warn('[AccountUsageHistory] Failed to load usage history:', err); } + setLoading(false); + } + load(); + }, [accountId, view]); + + const data: Array<{ inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number; totalTokens: number; costUsd: number; queryCount: number }> & Array = + view === 'monthly' ? monthlyData : dailyData; + const totalTokens = data.reduce((sum, d) => sum + d.totalTokens, 0); + const avgTokens = data.length > 0 ? totalTokens / data.length : 0; + const peakTokens = data.length > 0 ? Math.max(...data.map(d => d.totalTokens)) : 0; + const maxTokens = peakTokens || 1; + + return ( +
+ {/* View tabs */} +
+ {(['7d', '30d', 'monthly'] as const).map(v => ( + + ))} +
+ + {/* Bar legend */} +
+ + In + + + Out + + + Cache R + + + Cache W + +
+ + {/* Data rows */} + {loading ? ( +
Loading...
+ ) : data.length === 0 ? ( +
No usage data yet
+ ) : ( +
+ {data.map((row, i) => { + const label = 'date' in row ? (row as AccountDailyUsage).date : (row as AccountMonthlyUsage).month; + const barWidth = maxTokens > 0 ? (row.totalTokens / maxTokens) * 100 : 0; + const total = row.totalTokens || 1; + const inPct = (row.inputTokens / total) * barWidth; + const outPct = (row.outputTokens / total) * barWidth; + const cacheRPct = (row.cacheReadTokens / total) * barWidth; + const cacheWPct = (row.cacheCreationTokens / total) * barWidth; + const tooltip = `In: ${formatTokenCount(row.inputTokens)} | Out: ${formatTokenCount(row.outputTokens)} | Cache R: ${formatTokenCount(row.cacheReadTokens)} | Cache W: ${formatTokenCount(row.cacheCreationTokens)}`; + return ( +
+ + {formatDateLabel(label, view)} + +
+
+
+
+
+
+ + {formatTokenCount(row.totalTokens)} + + + ${row.costUsd.toFixed(2)} + + + {row.queryCount} qry + +
+ ); + })} +
+ )} + + {/* Summary footer */} + {data.length > 0 && ( +
+ Avg: {formatTokenCount(Math.round(avgTokens))}/{view === 'monthly' ? 'mo' : 'day'} + Peak: {formatTokenCount(peakTokens)} + Throttles: 0 ? theme.colors.error : theme.colors.textMain + }}>{throttleCount} +
+ )} +
+ ); +} diff --git a/src/renderer/components/AccountsPanel.tsx b/src/renderer/components/AccountsPanel.tsx new file mode 100644 index 000000000..5d086c5f2 --- /dev/null +++ b/src/renderer/components/AccountsPanel.tsx @@ -0,0 +1,1343 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + Plus, + Trash2, + Star, + Search, + RefreshCw, + ChevronDown, + ChevronRight, + AlertTriangle, + Check, + Wrench, + Download, + History, +} from 'lucide-react'; +import type { Theme } from '../types'; +import type { AccountProfile, AccountSwitchConfig, MultiplexableAgent } from '../../shared/account-types'; +import { ACCOUNT_SWITCH_DEFAULTS } from '../../shared/account-types'; +import { useAccountUsage, formatTimeRemaining, formatTokenCount } from '../hooks/useAccountUsage'; +import { AccountUsageHistory } from './AccountUsageHistory'; +import { notifyToast } from '../stores/notificationStore'; +/** Provider types that can have accounts in Virtuosos */ +const ACCOUNT_PROVIDERS: MultiplexableAgent[] = ['claude-code', 'codex', 'gemini-cli', 'opencode', 'factory-droid']; + +/** Display names for all multiplexable agents (extends beyond ToolType) */ +const PROVIDER_DISPLAY_NAMES: Record = { + 'claude-code': 'Claude Code', + codex: 'OpenAI Codex', + 'gemini-cli': 'Gemini CLI', + opencode: 'OpenCode', + 'factory-droid': 'Factory Droid', +}; + +const PLAN_PRESETS = [ + { label: 'Custom', tokens: 0, cost: null }, + { label: 'Claude Pro', tokens: 19_000, cost: 18.00 }, + { label: 'Claude Max 5', tokens: 88_000, cost: 35.00 }, + { label: 'Claude Max 20', tokens: 220_000, cost: 140.00 }, +] as const; + +function renderConfidenceDots(confidence: 'low' | 'medium' | 'high'): string { + const filled = confidence === 'high' ? 3 : confidence === 'medium' ? 2 : 1; + return '\u25CF'.repeat(filled) + '\u25CB'.repeat(3 - filled); +} + +interface AccountsPanelProps { + theme: Theme; +} + +interface DiscoveredAccount { + configDir: string; + name: string; + email: string | null; + hasAuth: boolean; + agentType: string; +} + +interface ConflictingSession { + sessionId: string; + sessionName: string; + manualConfigDir: string; +} + +const WINDOW_DURATION_OPTIONS = [ + { label: '1 hour', value: 1 * 60 * 60 * 1000 }, + { label: '2 hours', value: 2 * 60 * 60 * 1000 }, + { label: '5 hours', value: 5 * 60 * 60 * 1000 }, + { label: '8 hours', value: 8 * 60 * 60 * 1000 }, + { label: '24 hours', value: 24 * 60 * 60 * 1000 }, +]; + +export function AccountsPanel({ theme }: AccountsPanelProps) { + const addToast = notifyToast; + const [accounts, setAccounts] = useState([]); + const [switchConfig, setSwitchConfig] = useState(ACCOUNT_SWITCH_DEFAULTS); + const [discoveredAccounts, setDiscoveredAccounts] = useState(null); + const [isDiscovering, setIsDiscovering] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [newAccountName, setNewAccountName] = useState(''); + const [newAccountProvider, setNewAccountProvider] = useState('claude-code'); + const [createStep, setCreateStep] = useState<'idle' | 'created' | 'login-ready'>('idle'); + const [createdConfigDir, setCreatedConfigDir] = useState(''); + const [loginCommand, setLoginCommand] = useState(''); + const [editingAccountId, setEditingAccountId] = useState(null); + const [historyExpandedId, setHistoryExpandedId] = useState(null); + const [conflictingSessions, setConflictingSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(null); + const { metrics: usageMetrics } = useAccountUsage(); + + const refreshAccounts = useCallback(async () => { + try { + const list = (await window.maestro.accounts.list()) as AccountProfile[]; + setAccounts(list); + } catch (err) { + console.error('Failed to load accounts:', err); + } + }, []); + + const refreshSwitchConfig = useCallback(async () => { + try { + const config = (await window.maestro.accounts.getSwitchConfig()) as AccountSwitchConfig; + setSwitchConfig(config); + } catch (err) { + console.error('Failed to load switch config:', err); + } + }, []); + + // Load accounts, switch config, and check for conflicts on mount + useEffect(() => { + const init = async () => { + setLoading(true); + await Promise.all([refreshAccounts(), refreshSwitchConfig()]); + + // Check for sessions with manual CLAUDE_CONFIG_DIR + try { + const sessions = await window.maestro.sessions.getAll(); + const conflicts = sessions + .filter( + (s: any) => s.customEnvVars?.CLAUDE_CONFIG_DIR && !s.accountId + ) + .map((s: any) => ({ + sessionId: s.id, + sessionName: s.name || s.id, + manualConfigDir: s.customEnvVars!.CLAUDE_CONFIG_DIR, + })); + setConflictingSessions(conflicts); + } catch (err) { + console.error('Failed to check session conflicts:', err); + } + + setLoading(false); + }; + init(); + + // Auto-refresh when account status changes (e.g., marked expired during spawn) + const cleanupStatusChanged = window.maestro.accounts.onStatusChanged(() => { + refreshAccounts(); + }); + + return () => { + cleanupStatusChanged(); + }; + }, [refreshAccounts, refreshSwitchConfig]); + + const handleDiscover = async () => { + setIsDiscovering(true); + try { + const found = await window.maestro.accounts.discoverExisting(); + // Filter out already-registered accounts + const existingDirs = new Set(accounts.map((a) => a.configDir)); + setDiscoveredAccounts(found.filter((d) => !existingDirs.has(d.configDir))); + } catch (err) { + console.error('Failed to discover accounts:', err); + } finally { + setIsDiscovering(false); + } + }; + + const handleImportDiscovered = async (discovered: DiscoveredAccount) => { + try { + await window.maestro.accounts.add({ + name: discovered.name, + email: discovered.email || discovered.name, + configDir: discovered.configDir, + agentType: discovered.agentType, + }); + await refreshAccounts(); + // Remove from discovered list + setDiscoveredAccounts((prev) => + prev ? prev.filter((d) => d.configDir !== discovered.configDir) : null + ); + } catch (err) { + console.error('Failed to import account:', err); + } + }; + + const handleCreateAndLogin = async () => { + if (!newAccountName.trim()) return; + setIsCreating(true); + setErrorMessage(null); + try { + const result = await window.maestro.accounts.createDirectory(newAccountName.trim()); + if (!result.success) { + setErrorMessage(`Failed to create account directory: ${result.error}`); + return; + } + setCreatedConfigDir(result.configDir); + + const cmd = await window.maestro.accounts.getLoginCommand(result.configDir); + if (cmd) { + setLoginCommand(cmd); + setCreateStep('login-ready'); + } else { + setCreateStep('created'); + } + } catch (err) { + setErrorMessage(`Failed to create account: ${err}`); + } finally { + setIsCreating(false); + } + }; + + const handleLoginComplete = async () => { + try { + const email = await window.maestro.accounts.readEmail(createdConfigDir); + await window.maestro.accounts.add({ + name: email || newAccountName.trim(), + email: email || newAccountName.trim(), + configDir: createdConfigDir, + agentType: newAccountProvider, + }); + await refreshAccounts(); + // Reset create flow + setNewAccountName(''); + setCreateStep('idle'); + setCreatedConfigDir(''); + setLoginCommand(''); + } catch (err) { + console.error('Failed to register account after login:', err); + } + }; + + const handleRemoveAccount = async (id: string) => { + try { + await window.maestro.accounts.remove(id); + await refreshAccounts(); + } catch (err) { + console.error('Failed to remove account:', err); + } + }; + + const handleSetDefault = async (id: string) => { + try { + await window.maestro.accounts.setDefault(id); + await refreshAccounts(); + } catch (err) { + console.error('Failed to set default:', err); + } + }; + + const handleUpdateAccount = async (id: string, updates: Partial) => { + try { + await window.maestro.accounts.update(id, updates as Record); + await refreshAccounts(); + } catch (err) { + console.error('Failed to update account:', err); + } + }; + + const handleUpdateSwitchConfig = async (updates: Partial) => { + try { + await window.maestro.accounts.updateSwitchConfig(updates as Record); + await refreshSwitchConfig(); + } catch (err) { + console.error('Failed to update switch config:', err); + } + }; + + const handleValidateSymlinks = async (configDir: string) => { + try { + const result = await window.maestro.accounts.validateSymlinks(configDir); + if (result.valid) { + addToast({ type: 'success', title: 'Symlinks Valid', message: 'All symlinks are valid' }); + } else { + addToast({ + type: 'warning', + title: 'Symlink Issues Found', + message: `Broken: ${result.broken.join(', ') || 'none'} · Missing: ${result.missing.join(', ') || 'none'}`, + }); + } + } catch (err) { + console.error('Failed to validate symlinks:', err); + } + }; + + const handleRepairSymlinks = async (configDir: string) => { + try { + const result = await window.maestro.accounts.repairSymlinks(configDir); + if (result.errors.length === 0) { + addToast({ type: 'success', title: 'Symlinks Repaired', message: `Repaired: ${result.repaired.join(', ') || 'none needed'}` }); + } else { + addToast({ type: 'error', title: 'Repair Failed', message: `Repair errors: ${result.errors.join(', ')}` }); + } + await refreshAccounts(); + } catch (err) { + console.error('Failed to repair symlinks:', err); + } + }; + + const handleSyncCredentials = async (configDir: string) => { + try { + const result = await window.maestro.accounts.syncCredentials(configDir); + if (result.success) { + setErrorMessage(null); + addToast({ type: 'success', title: 'Credentials Synced', message: 'Credentials synced from base ~/.claude directory' }); + } else { + setErrorMessage(`Sync failed: ${result.error}`); + } + } catch (err) { + setErrorMessage(`Failed to sync credentials: ${err}`); + } + }; + + const statusBadge = (status: AccountProfile['status']) => { + const styles: Record< + string, + { bg: string; fg: string } + > = { + active: { bg: theme.colors.success + '20', fg: theme.colors.success }, + throttled: { bg: theme.colors.warning + '20', fg: theme.colors.warning }, + expired: { bg: theme.colors.error + '20', fg: theme.colors.error }, + disabled: { bg: theme.colors.error + '20', fg: theme.colors.error }, + }; + const s = styles[status] || styles.disabled; + return ( + + {status} + + ); + }; + + if (loading) { + return ( +
+ Loading accounts... +
+ ); + } + + return ( +
+ {/* Conflict Warning Banner */} + {conflictingSessions.length > 0 && ( +
+
+ + Manual CLAUDE_CONFIG_DIR Detected +
+
+ {conflictingSessions.length} session(s) have CLAUDE_CONFIG_DIR set manually in + custom env vars. These sessions will not be managed by the account system. + Consider migrating them to managed accounts. +
+ {conflictingSessions.map((s) => ( +
+ • {s.sessionName}: {s.manualConfigDir} +
+ ))} +
+ )} + + {/* Registered Accounts */} +
+
+
+ +

+ AI Provider Accounts +

+
+ +
+ + {accounts.length === 0 ? ( +
+ No virtuosos registered. Use "Discover Existing" or "Create + New" below. +
+ ) : ( +
+ {(() => { + // Group accounts by provider type + const grouped = new Map(); + for (const account of accounts) { + const key = account.agentType || 'claude-code'; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push(account); + } + // Sort providers: providers with accounts first, in ACCOUNT_PROVIDERS order + const orderedProviders = ACCOUNT_PROVIDERS.filter(p => grouped.has(p)); + return orderedProviders.map((providerType) => { + const providerAccounts = grouped.get(providerType) || []; + return ( +
+
+ + {PROVIDER_DISPLAY_NAMES[providerType] || providerType} + + + {providerAccounts.length} account{providerAccounts.length !== 1 ? 's' : ''} + +
+
+ {providerAccounts.map((account) => ( +
+
+
+
+
+ + {account.email || account.name} + + {account.isDefault && ( + + )} + {statusBadge(account.status)} +
+ {account.status === 'expired' && ( +
+ + OAuth token expired — run:{' '} + + CLAUDE_CONFIG_DIR="{account.configDir}" claude login + +
+ )} +
+ {account.configDir} + {account.tokenLimitPerWindow > 0 && ( + + {' '} + · Limit:{' '} + {account.tokenLimitPerWindow.toLocaleString()}{' '} + tokens + + )} +
+
+
+ {/* Inline usage metrics */} + {(() => { + const usage = usageMetrics[account.id]; + if (!usage) return null; + return ( +
+ {/* Usage bar */} + {usage.usagePercent !== null && ( +
+
+
= 95 + ? theme.colors.error + : usage.usagePercent >= 80 + ? theme.colors.warning + : theme.colors.accent, + }} + /> +
+ + {Math.round(usage.usagePercent)}% + +
+ )} + + {/* Metrics grid */} +
+
+ Tokens: + {formatTokenCount(usage.totalTokens)} + {usage.limitTokens > 0 && ` / ${formatTokenCount(usage.limitTokens)}`} + +
+
+ Cost: + ${usage.costUsd.toFixed(2)} + +
+
+ Queries: + {usage.queryCount} + +
+
+ Resets in: + {formatTimeRemaining(usage.timeRemainingMs)} + +
+ {usage.burnRatePerHour > 0 && ( +
+ Burn rate: + ~{formatTokenCount(Math.round(usage.burnRatePerHour))}/hr + +
+ )} + {usage.estimatedTimeToLimitMs !== null && ( +
+ To limit: + ~{formatTimeRemaining(usage.estimatedTimeToLimitMs)} + +
+ )} +
+ + {/* Prediction section */} + {usage.prediction && usage.limitTokens > 0 && ( +
+
Prediction
+
+
+ Current rate:{' '} + + {usage.prediction.linearTimeToLimitMs !== null + ? `~${formatTimeRemaining(usage.prediction.linearTimeToLimitMs)} to limit` + : '\u2014'} + +
+
+ Conservative (P90):{' '} + + {usage.prediction.windowsRemainingP90 !== null + ? `~${usage.prediction.windowsRemainingP90.toFixed(1)} windows` + : '\u2014'} + +
+
+ Confidence:{' '} + + {renderConfidenceDots(usage.prediction.confidence)} + + + {usage.prediction.confidence === 'high' ? 'High' : usage.prediction.confidence === 'medium' ? 'Medium' : 'Low'} + +
+
+ Avg/window:{' '} + + {formatTokenCount(Math.round(usage.prediction.avgTokensPerWindow))} + +
+
+
+ )} + + {/* Usage History toggle */} + + {historyExpandedId === account.id && ( + + )} +
+ ); + })()} +
+ + {account.status === 'expired' && ( + + )} + {!account.isDefault && ( + + )} + +
+
+ + {/* Expanded per-account configuration */} + {editingAccountId === account.id && ( +
+ {/* Plan preset + token limit */} +
+ +
+ + + handleUpdateAccount(account.id, { + tokenLimitPerWindow: + parseInt(e.target.value) || 0, + }) + } + placeholder="Custom limit" + className="w-28 p-2 rounded border bg-transparent outline-none text-xs font-mono" + style={{ + borderColor: theme.colors.border, + color: theme.colors.textMain, + }} + min={0} + /> +
+
+ +
+
+ + +
+
+ +
+ + +
+ +
+ + + +
+
+ )} +
+ ))} +
+
+ ); + }); + })()} +
+ )} +
+ + {/* Add Virtuoso Section */} +
+ + +
+ +
+ + {/* Discovered accounts */} + {discoveredAccounts !== null && ( +
+ {discoveredAccounts.length === 0 ? ( +
+ No unregistered account directories found. +
+ ) : ( +
+ {discoveredAccounts.map((d) => ( +
+
+
+ {d.email || d.name} + + {PROVIDER_DISPLAY_NAMES[d.agentType as MultiplexableAgent] || d.agentType} + +
+
+ {d.configDir} + {d.hasAuth && ( + + {' '} + · Authenticated + + )} +
+
+ +
+ ))} +
+ )} +
+ )} + + {/* Error message */} + {errorMessage && ( +
+ +
+
+ {errorMessage} +
+ +
+
+ )} + + {/* Create new account */} +
+ + + {createStep === 'idle' && ( +
+
+ + setNewAccountName(e.target.value)} + placeholder="Virtuoso name (e.g., work, personal)" + className="flex-1 p-2 rounded border bg-transparent outline-none text-xs font-mono" + style={{ + borderColor: theme.colors.border, + color: theme.colors.textMain, + }} + onKeyDown={(e) => e.key === 'Enter' && handleCreateAndLogin()} + /> + +
+
+ )} + + {createStep === 'login-ready' && ( +
+
+ Directory created at{' '} + {createdConfigDir}. Run the + following command in a terminal to log in: +
+
+ {loginCommand} +
+
+ + +
+
+ )} + + {createStep === 'created' && ( +
+
+ Directory created at{' '} + {createdConfigDir}. Could + not determine login command. Please authenticate manually and + click "Login Complete". +
+
+ + +
+
+ )} +
+
+ + {/* Global Switch Configuration */} +
+ + +
+ {/* Enable/disable auto-switching */} +
+ + +
+ + {/* Prompt before switch */} +
+ + +
+ + {/* Warning threshold */} +
+
+ + + {switchConfig.warningThresholdPercent}% + +
+ + handleUpdateSwitchConfig({ + warningThresholdPercent: parseInt(e.target.value), + }) + } + className="w-full" + style={{ accentColor: theme.colors.accent }} + /> +
+ + {/* Auto-switch threshold */} +
+
+ + + {switchConfig.autoSwitchThresholdPercent}% + +
+ + handleUpdateSwitchConfig({ + autoSwitchThresholdPercent: parseInt(e.target.value), + }) + } + className="w-full" + style={{ accentColor: theme.colors.accent }} + /> +
+ + {/* Selection strategy */} +
+ + +
+
+
+
+ ); +} diff --git a/src/renderer/components/AppModals.tsx b/src/renderer/components/AppModals.tsx index c1e5a956f..107e3cc34 100644 --- a/src/renderer/components/AppModals.tsx +++ b/src/renderer/components/AppModals.tsx @@ -261,9 +261,9 @@ export function AppInfoModals({ isOpen={usageDashboardOpen} onClose={onCloseUsageDashboard} theme={theme} + sessions={sessions} defaultTimeRange={defaultStatsTimeRange} colorBlindMode={colorBlindMode} - sessions={sessions} /> )} @@ -417,6 +417,7 @@ export interface AppSessionModalsProps { customContextWindow?: number ) => void; editAgentSession: Session | null; + onSwitchProviderFromEdit?: () => void; // Opens SwitchProviderModal (Virtuosos) // RenameSessionModal renameSessionModalOpen: boolean; @@ -460,6 +461,7 @@ export function AppSessionModals({ onCloseEditAgentModal, onSaveEditAgent, editAgentSession, + onSwitchProviderFromEdit, // RenameSessionModal renameSessionModalOpen, renameSessionValue, @@ -495,6 +497,7 @@ export function AppSessionModals({ theme={theme} session={editAgentSession} existingSessions={existingSessions} + onSwitchProvider={onSwitchProviderFromEdit} /> {/* --- RENAME SESSION MODAL --- */} @@ -1836,6 +1839,7 @@ export interface AppModalsProps { customContextWindow?: number ) => void; editAgentSession: Session | null; + onSwitchProviderFromEdit?: () => void; // Opens SwitchProviderModal (Virtuosos) renameSessionModalOpen: boolean; renameSessionValue: string; setRenameSessionValue: (value: string) => void; @@ -2174,6 +2178,7 @@ export function AppModals(props: AppModalsProps) { onCloseEditAgentModal, onSaveEditAgent, editAgentSession, + onSwitchProviderFromEdit, renameSessionModalOpen, renameSessionValue, setRenameSessionValue, @@ -2464,6 +2469,7 @@ export function AppModals(props: AppModalsProps) { onCloseEditAgentModal={onCloseEditAgentModal} onSaveEditAgent={onSaveEditAgent} editAgentSession={editAgentSession} + onSwitchProviderFromEdit={onSwitchProviderFromEdit} renameSessionModalOpen={renameSessionModalOpen} renameSessionValue={renameSessionValue} setRenameSessionValue={setRenameSessionValue} diff --git a/src/renderer/components/AutoRun.tsx b/src/renderer/components/AutoRun.tsx index aac413ffd..2f3ad56a9 100644 --- a/src/renderer/components/AutoRun.tsx +++ b/src/renderer/components/AutoRun.tsx @@ -1807,8 +1807,56 @@ const AutoRunInner = forwardRef(function AutoRunInn
)} + {/* Recovery Indicator - shown when all accounts are rate-limited and waiting for recovery */} + {isErrorPaused && batchError && batchError.type === 'rate_limited' && batchError.message?.includes('All virtuosos') && ( +
+
+ + + + + + Waiting for virtuoso recovery — will auto-resume + + + {onAbortBatchOnError && ( + + )} +
+
+ )} + {/* Error Banner (Phase 5.10) - shown when batch is paused due to agent error */} - {isErrorPaused && batchError && ( + {isErrorPaused && batchError && !(batchError.type === 'rate_limited' && batchError.message?.includes('All virtuosos')) && (
+ {/* Account selector - AI mode + claude-code only */} + {session.inputMode === 'ai' && session.toolType === 'claude-code' && ( + { + const currentAccountId = session.accountId; + if (currentAccountId && currentAccountId !== toAccountId) { + await window.maestro.accounts.executeSwitch({ + sessionId: session.id, + fromAccountId: currentAccountId, + toAccountId, + reason: 'manual', + automatic: false, + }); + } else { + await window.maestro.accounts.assign(session.id, toAccountId); + } + }} + onManageAccounts={() => getModalActions().setVirtuososOpen(true)} + compact + /> + )} {/* Save to History toggle - AI mode only */} {session.inputMode === 'ai' && onToggleTabSaveToHistory && (
+ {sourceSession?.accountId && ( +
+ + Account: {sourceSession.accountName || sourceSession.accountId} +
+ )} {(selectedTarget || (viewMode === 'paste' && pastedIdMatch)) && ( <> @@ -993,6 +1041,35 @@ export function MergeSessionModal({ )}
+ {/* Account mismatch warning */} + {(() => { + const target = viewMode === 'paste' ? pastedIdMatch : selectedTarget; + if ( + sourceSession?.accountId && + target?.accountId && + sourceSession.accountId !== target.accountId + ) { + return ( +
+ Note: Source and target sessions use different accounts ( + {sourceSession.accountName || sourceSession.accountId} →{' '} + {target.accountName || target.accountId}). Session files are shared via symlinks, + so this merge should work seamlessly. +
+ ); + } + return null; + })()} + {/* Options */}
Merge options diff --git a/src/renderer/components/NewInstanceModal.tsx b/src/renderer/components/NewInstanceModal.tsx index c7c505be1..3de98d743 100644 --- a/src/renderer/components/NewInstanceModal.tsx +++ b/src/renderer/components/NewInstanceModal.tsx @@ -7,6 +7,7 @@ import { validateNewSession, validateEditSession } from '../utils/sessionValidat import { FormInput } from './ui/FormInput'; import { Modal, ModalFooter } from './ui/Modal'; import { AgentConfigPanel } from './shared/AgentConfigPanel'; +import { AccountSelector } from './AccountSelector'; import { SshRemoteSelector } from './shared/SshRemoteSelector'; import { formatShortcutKeys } from '../utils/shortcutFormatter'; @@ -66,11 +67,13 @@ interface EditAgentModalProps { enabled: boolean; remoteId: string | null; workingDirOverride?: string; - } + }, + accountId?: string, ) => void; theme: any; session: Session | null; existingSessions: Session[]; + onSwitchProvider?: () => void; // Opens SwitchProviderModal (Virtuosos) } // Supported agents that are fully implemented @@ -1198,6 +1201,7 @@ export function EditAgentModal({ theme, session, existingSessions, + onSwitchProvider, }: EditAgentModalProps) { const [instanceName, setInstanceName] = useState(''); const [nudgeMessage, setNudgeMessage] = useState(''); @@ -1211,6 +1215,8 @@ export function EditAgentModal({ const [_customModel, setCustomModel] = useState(''); const [refreshingAgent, setRefreshingAgent] = useState(false); const [copiedId, setCopiedId] = useState(false); + // Account multiplexing + const [selectedAccountId, setSelectedAccountId] = useState(undefined); // SSH Remote configuration const [sshRemotes, setSshRemotes] = useState([]); const [sshRemoteConfig, setSshRemoteConfig] = useState( @@ -1292,6 +1298,9 @@ export function EditAgentModal({ setCustomArgs(session.customArgs ?? ''); setCustomEnvVars(session.customEnvVars ?? {}); setCustomModel(session.customModel ?? ''); + + // Load account assignment + setSelectedAccountId(session.accountId); } }, [isOpen, session]); @@ -1423,7 +1432,8 @@ export function EditAgentModal({ Object.keys(customEnvVars).length > 0 ? customEnvVars : undefined, modelValue, contextWindowValue, - sessionSshRemoteConfig + sessionSshRemoteConfig, + selectedAccountId || undefined, ); onClose(); }, [ @@ -1435,6 +1445,7 @@ export function EditAgentModal({ customEnvVars, agentConfig, sshRemoteConfig, + selectedAccountId, onSave, onClose, existingSessions, @@ -1469,6 +1480,12 @@ export function EditAgentModal({ } }, [session]); + // Handle account selection in edit modal (local state only — actual switch happens on save) + const handleSwitchAccount = useCallback((toAccountId: string) => { + if (!session || toAccountId === selectedAccountId) return; + setSelectedAccountId(toAccountId); + }, [session, selectedAccountId]); + // Check if form is valid for submission const isFormValid = useMemo(() => { // Remote path validation is informational only - don't block save @@ -1573,7 +1590,7 @@ export function EditAgentModal({ heightClass="p-2" /> - {/* Agent Provider (read-only) */} + {/* Agent Provider (read-only, with optional switch button) */}
-
- {agentName} +
+
+ {agentName} +
+ {onSwitchProvider && ( + + )}

- Provider cannot be changed after creation. + {onSwitchProvider + ? 'Switch to a different provider. Context will be transferred.' + : 'Provider cannot be changed after creation.'}

@@ -1651,6 +1683,28 @@ export function EditAgentModal({ )}
+ {/* Account Selector (Claude Code only) */} + {session.toolType === 'claude-code' && ( +
+ + +

+ Claude account used for this agent. Changing takes effect on next message. +

+
+ )} + {/* Nudge Message */}
+ + {/* Account Info */} + {(() => { + const sess = sessions.find(s => s.id === detailView.agentSessionId); + if (sess?.accountId) { + return ( +
+
+ + + Virtuoso + +
+ + {sess.accountName || sess.accountId} + +
+ ); + } + return null; + })()}
diff --git a/src/renderer/components/ProviderDetailCharts.tsx b/src/renderer/components/ProviderDetailCharts.tsx new file mode 100644 index 000000000..04fe84820 --- /dev/null +++ b/src/renderer/components/ProviderDetailCharts.tsx @@ -0,0 +1,1017 @@ +/** + * ProviderDetailCharts - Charts and visualizations for the provider detail view + * + * Renders inside ProviderDetailView below the summary stats. + * Layout: 2-column grid of chart panels. + * + * Charts: + * 1. Query Volume Trend (SVG line chart) + * 2. Response Time Trend (SVG line chart with p95 band) + * 3. Activity Heatmap (24-hour bar chart) + * 4. Token Breakdown (horizontal stacked bar) + * 5. Source & Location Split (two donut charts) + */ + +import React, { useState, useMemo, useCallback } from 'react'; +import type { Theme } from '../types'; +import type { ProviderDetail } from '../hooks/useProviderDetail'; +import { formatTokenCount } from '../hooks/useAccountUsage'; + +// ============================================================================ +// Types +// ============================================================================ + +interface ProviderDetailChartsProps { + theme: Theme; + detail: ProviderDetail; +} + +// ============================================================================ +// Shared helpers +// ============================================================================ + +function formatDurationMs(ms: number): string { + if (ms === 0) return '0s'; + if (ms < 1000) return `${Math.round(ms)}ms`; + const s = ms / 1000; + if (s < 60) return `${s.toFixed(1)}s`; + const m = Math.floor(s / 60); + const rem = Math.round(s % 60); + return rem > 0 ? `${m}m ${rem}s` : `${m}m`; +} + +function formatHour(hour: number): string { + if (hour === 0) return '12am'; + if (hour === 12) return '12pm'; + if (hour < 12) return `${hour}am`; + return `${hour - 12}pm`; +} + +function parseHexColor(hex: string): { r: number; g: number; b: number } { + const clean = hex.startsWith('#') ? hex.slice(1) : hex; + return { + r: parseInt(clean.slice(0, 2), 16) || 100, + g: parseInt(clean.slice(2, 4), 16) || 149, + b: parseInt(clean.slice(4, 6), 16) || 237, + }; +} + +// ============================================================================ +// Chart wrapper +// ============================================================================ + +function ChartPanel({ + theme, + title, + children, +}: { + theme: Theme; + title: string; + children: React.ReactNode; +}) { + return ( +
+
+ {title} +
+ {children} +
+ ); +} + +// ============================================================================ +// Chart 1: Query Volume Trend (SVG line chart) +// ============================================================================ + +function QueryVolumeTrendChart({ + theme, + dailyTrend, +}: { + theme: Theme; + dailyTrend: ProviderDetail['dailyTrend']; +}) { + const [hoveredIdx, setHoveredIdx] = useState(null); + const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null); + + const chartWidth = 500; + const chartHeight = 140; + const pad = { top: 14, right: 20, bottom: 28, left: 40 }; + const innerW = chartWidth - pad.left - pad.right; + const innerH = chartHeight - pad.top - pad.bottom; + + const maxVal = useMemo( + () => Math.max(...dailyTrend.map((d) => d.queryCount), 1), + [dailyTrend], + ); + + const xScale = useCallback( + (i: number) => pad.left + (i / Math.max(dailyTrend.length - 1, 1)) * innerW, + [dailyTrend.length, innerW, pad.left], + ); + + const yScale = useCallback( + (v: number) => chartHeight - pad.bottom - (v / (maxVal * 1.1)) * innerH, + [maxVal, innerH, chartHeight, pad.bottom], + ); + + const linePath = useMemo(() => { + if (dailyTrend.length === 0) return ''; + return dailyTrend + .map((d, i) => `${i === 0 ? 'M' : 'L'} ${xScale(i)} ${yScale(d.queryCount)}`) + .join(' '); + }, [dailyTrend, xScale, yScale]); + + const areaPath = useMemo(() => { + if (dailyTrend.length === 0) return ''; + const line = dailyTrend + .map((d, i) => `${i === 0 ? 'M' : 'L'} ${xScale(i)} ${yScale(d.queryCount)}`) + .join(' '); + const lastX = xScale(dailyTrend.length - 1); + const firstX = xScale(0); + const baseline = chartHeight - pad.bottom; + return `${line} L ${lastX} ${baseline} L ${firstX} ${baseline} Z`; + }, [dailyTrend, xScale, yScale, chartHeight, pad.bottom]); + + const accent = parseHexColor(theme.colors.accent); + const gradientId = 'qvt-grad'; + + // Y-axis ticks + const yTicks = useMemo(() => { + const tickCount = 4; + const yMax = maxVal * 1.1; + return Array.from({ length: tickCount }, (_, i) => Math.round((yMax / (tickCount - 1)) * i)); + }, [maxVal]); + + // X-axis labels — show max 7 + const xLabelInterval = useMemo( + () => Math.max(1, Math.ceil(dailyTrend.length / 7)), + [dailyTrend.length], + ); + + if (dailyTrend.length === 0) { + return ( +
+ No trend data available +
+ ); + } + + return ( +
+ + + + + + + + + {/* Grid lines + Y labels */} + {yTicks.map((tick, i) => ( + + + + {tick} + + + ))} + + {/* X labels */} + {dailyTrend.map((d, i) => { + if (i % xLabelInterval !== 0 && i !== dailyTrend.length - 1) return null; + const label = d.date.slice(5); // MM-DD + return ( + + {label} + + ); + })} + + {/* Area fill */} + + + {/* Line */} + + + {/* Data points */} + {dailyTrend.map((d, i) => { + const isHovered = hoveredIdx === i; + return ( + { + setHoveredIdx(i); + const rect = e.currentTarget.getBoundingClientRect(); + setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); + }} + onMouseLeave={() => { + setHoveredIdx(null); + setTooltipPos(null); + }} + /> + ); + })} + + + {/* Tooltip */} + {hoveredIdx !== null && tooltipPos && ( +
+
{dailyTrend[hoveredIdx].date}
+
+ {dailyTrend[hoveredIdx].queryCount} queries +
+
+ )} +
+ ); +} + +// ============================================================================ +// Chart 2: Response Time Trend (SVG line chart with p95 band) +// ============================================================================ + +function ResponseTimeTrendChart({ + theme, + dailyTrend, + p95ResponseTimeMs, +}: { + theme: Theme; + dailyTrend: ProviderDetail['dailyTrend']; + p95ResponseTimeMs: number; +}) { + const [hoveredIdx, setHoveredIdx] = useState(null); + const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null); + + const chartWidth = 500; + const chartHeight = 140; + const pad = { top: 14, right: 20, bottom: 28, left: 44 }; + const innerW = chartWidth - pad.left - pad.right; + const innerH = chartHeight - pad.top - pad.bottom; + + const maxVal = useMemo(() => { + const maxAvg = Math.max(...dailyTrend.map((d) => d.avgDurationMs), 1); + return Math.max(maxAvg, p95ResponseTimeMs) * 1.1; + }, [dailyTrend, p95ResponseTimeMs]); + + const xScale = useCallback( + (i: number) => pad.left + (i / Math.max(dailyTrend.length - 1, 1)) * innerW, + [dailyTrend.length, innerW, pad.left], + ); + + const yScale = useCallback( + (v: number) => chartHeight - pad.bottom - (v / maxVal) * innerH, + [maxVal, innerH, chartHeight, pad.bottom], + ); + + const linePath = useMemo(() => { + if (dailyTrend.length === 0) return ''; + return dailyTrend + .map((d, i) => `${i === 0 ? 'M' : 'L'} ${xScale(i)} ${yScale(d.avgDurationMs)}`) + .join(' '); + }, [dailyTrend, xScale, yScale]); + + // Y-axis ticks + const yTicks = useMemo(() => { + const tickCount = 4; + return Array.from({ length: tickCount }, (_, i) => Math.round((maxVal / (tickCount - 1)) * i)); + }, [maxVal]); + + const xLabelInterval = useMemo( + () => Math.max(1, Math.ceil(dailyTrend.length / 7)), + [dailyTrend.length], + ); + + if (dailyTrend.length === 0) { + return ( +
+ No response time data available +
+ ); + } + + return ( +
+ + {/* Grid lines + Y labels */} + {yTicks.map((tick, i) => ( + + + + {formatDurationMs(tick)} + + + ))} + + {/* X labels */} + {dailyTrend.map((d, i) => { + if (i % xLabelInterval !== 0 && i !== dailyTrend.length - 1) return null; + return ( + + {d.date.slice(5)} + + ); + })} + + {/* P95 reference band */} + {p95ResponseTimeMs > 0 && ( + <> + + + p95 + + + )} + + {/* Line */} + + + {/* Data points */} + {dailyTrend.map((d, i) => { + const isHovered = hoveredIdx === i; + return ( + { + setHoveredIdx(i); + const rect = e.currentTarget.getBoundingClientRect(); + setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); + }} + onMouseLeave={() => { + setHoveredIdx(null); + setTooltipPos(null); + }} + /> + ); + })} + + + {/* Tooltip */} + {hoveredIdx !== null && tooltipPos && ( +
+
{dailyTrend[hoveredIdx].date}
+
+ Avg: {formatDurationMs(dailyTrend[hoveredIdx].avgDurationMs)} +
+
+ )} + + {/* Legend */} +
+
+
+ Avg Duration +
+ {p95ResponseTimeMs > 0 && ( +
+
+ P95 ({formatDurationMs(p95ResponseTimeMs)}) +
+ )} +
+
+ ); +} + +// ============================================================================ +// Chart 3: Activity Heatmap (24-hour bar chart) +// ============================================================================ + +function ActivityHoursChart({ + theme, + hourlyPattern, +}: { + theme: Theme; + hourlyPattern: ProviderDetail['hourlyPattern']; +}) { + const [hoveredHour, setHoveredHour] = useState(null); + + const maxCount = useMemo( + () => Math.max(...hourlyPattern.map((h) => h.queryCount), 1), + [hourlyPattern], + ); + + const peakHour = useMemo(() => { + let peak = { hour: 0, count: 0 }; + for (const h of hourlyPattern) { + if (h.queryCount > peak.count) { + peak = { hour: h.hour, count: h.queryCount }; + } + } + return peak.hour; + }, [hourlyPattern]); + + const hasData = hourlyPattern.some((h) => h.queryCount > 0); + const chartHeight = 100; + + if (!hasData) { + return ( +
+ No hourly data available +
+ ); + } + + return ( +
+ {/* Bars */} +
+ {hourlyPattern.map((h) => { + const height = maxCount > 0 ? (h.queryCount / maxCount) * 100 : 0; + const isPeak = h.hour === peakHour && h.queryCount > 0; + const isHovered = hoveredHour === h.hour; + + return ( +
setHoveredHour(h.hour)} + onMouseLeave={() => setHoveredHour(null)} + > +
0 ? 2 : 0)}%`, + backgroundColor: isPeak + ? theme.colors.accent + : isHovered + ? `${theme.colors.accent}90` + : `${theme.colors.accent}50`, + transform: isHovered ? 'scaleY(1.05)' : 'scaleY(1)', + transformOrigin: 'bottom', + }} + /> + + {/* Tooltip */} + {isHovered && h.queryCount > 0 && ( +
+
{formatHour(h.hour)}
+
+ {h.queryCount} queries +
+ {h.avgDurationMs > 0 && ( +
+ Avg: {formatDurationMs(h.avgDurationMs)} +
+ )} +
+ )} +
+ ); + })} +
+ + {/* X-axis labels (every 4 hours) */} +
+ {[0, 4, 8, 12, 16, 20].map((hour) => ( +
+ {formatHour(hour)} +
+ ))} +
+ + {/* Peak indicator */} +
+ Peak: + + {formatHour(peakHour)} + +
+
+ ); +} + +// ============================================================================ +// Chart 4: Token Breakdown (horizontal stacked bar) +// ============================================================================ + +function TokenBreakdownChart({ + theme, + tokenBreakdown, +}: { + theme: Theme; + tokenBreakdown: ProviderDetail['tokenBreakdown']; +}) { + const segments = useMemo(() => { + const items = [ + { + label: 'Input', + tokens: tokenBreakdown.inputTokens, + cost: tokenBreakdown.inputCostUsd, + color: theme.colors.accent, + }, + { + label: 'Output', + tokens: tokenBreakdown.outputTokens, + cost: tokenBreakdown.outputCostUsd, + color: theme.colors.success, + }, + { + label: 'Cache Read', + tokens: tokenBreakdown.cacheReadTokens, + cost: tokenBreakdown.cacheReadCostUsd, + color: theme.colors.warning, + }, + { + label: 'Cache Write', + tokens: tokenBreakdown.cacheCreationTokens, + cost: tokenBreakdown.cacheCreationCostUsd, + color: '#8b5cf6', // purple + }, + ]; + + const totalTokens = items.reduce((sum, s) => sum + s.tokens, 0); + return items.map((s) => ({ + ...s, + percent: totalTokens > 0 ? (s.tokens / totalTokens) * 100 : 0, + })); + }, [tokenBreakdown, theme]); + + const totalTokens = segments.reduce((sum, s) => sum + s.tokens, 0); + + if (totalTokens === 0) { + return ( +
+ No token data available +
+ ); + } + + return ( +
+ {/* Stacked bar */} +
+ {segments.map((seg) => + seg.percent > 0 ? ( +
0 ? 2 : 0, + }} + title={`${seg.label}: ${formatTokenCount(seg.tokens)} (${seg.percent.toFixed(1)}%)`} + /> + ) : null, + )} +
+ + {/* Legend with costs */} +
+ {segments.map((seg) => ( +
+
+
+ + {seg.label}: + {' '} + + {formatTokenCount(seg.tokens)} + + {seg.cost > 0 && ( + + {' '}(${seg.cost.toFixed(2)}) + + )} +
+
+ ))} +
+
+ ); +} + +// ============================================================================ +// Chart 5: Source & Location Split (two donut charts) +// ============================================================================ + +function DonutChart({ + theme, + slices, + size = 64, +}: { + theme: Theme; + slices: Array<{ label: string; value: number; color: string }>; + size?: number; +}) { + const total = slices.reduce((sum, s) => sum + s.value, 0); + const radius = size / 2 - 4; + const innerRadius = radius * 0.55; + const cx = size / 2; + const cy = size / 2; + + if (total === 0) { + return ( + + + + ); + } + + let currentAngle = -Math.PI / 2; // Start at top + const arcs = slices.map((slice) => { + const angle = (slice.value / total) * Math.PI * 2; + const startAngle = currentAngle; + const endAngle = currentAngle + angle; + currentAngle = endAngle; + + const largeArcFlag = angle > Math.PI ? 1 : 0; + const midRadius = (radius + innerRadius) / 2; + const thickness = radius - innerRadius; + + const x1 = cx + midRadius * Math.cos(startAngle); + const y1 = cy + midRadius * Math.sin(startAngle); + const x2 = cx + midRadius * Math.cos(endAngle); + const y2 = cy + midRadius * Math.sin(endAngle); + + // For a full circle (100%), use two arcs + if (angle >= Math.PI * 2 - 0.01) { + return ( + + ); + } + + return ( + + ); + }); + + return ( + + {arcs} + + ); +} + +function SourceLocationSplitChart({ + theme, + queriesBySource, + queriesByLocation, +}: { + theme: Theme; + queriesBySource: ProviderDetail['queriesBySource']; + queriesByLocation: ProviderDetail['queriesByLocation']; +}) { + const sourceTotal = queriesBySource.user + queriesBySource.auto; + const locationTotal = queriesByLocation.local + queriesByLocation.remote; + + const noData = sourceTotal === 0 && locationTotal === 0; + + if (noData) { + return ( +
+ No query data available +
+ ); + } + + return ( +
+ {/* Source split */} +
+ +
+
+ + {queriesBySource.user} user +
+
+ + {queriesBySource.auto} auto +
+
+
+ + {/* Location split */} +
+ +
+
+ + {queriesByLocation.local} local +
+
+ + {queriesByLocation.remote} remote +
+
+
+
+ ); +} + +// ============================================================================ +// Main component +// ============================================================================ + +export function ProviderDetailCharts({ theme, detail }: ProviderDetailChartsProps) { + return ( +
+ {/* Chart 1: Query Volume Trend */} + + + + + {/* Chart 2: Response Time Trend */} + + + + + {/* Chart 3: Activity Heatmap */} + + + + + {/* Chart 4: Token Breakdown */} + + + + + {/* Chart 5: Source & Location Split */} + + + +
+ ); +} + +export default ProviderDetailCharts; diff --git a/src/renderer/components/ProviderDetailView.tsx b/src/renderer/components/ProviderDetailView.tsx new file mode 100644 index 000000000..96e02d934 --- /dev/null +++ b/src/renderer/components/ProviderDetailView.tsx @@ -0,0 +1,759 @@ +/** + * ProviderDetailView - Full-width detail view for a single provider + * + * Shows provider header with health status, 8 summary metrics, + * and navigates back to the card grid on back button or Escape key. + */ + +import { useEffect } from 'react'; +import { ArrowLeft, ArrowRightLeft } from 'lucide-react'; +import type { Theme, Session } from '../types'; +import type { ToolType, AgentErrorType } from '../../shared/types'; +import type { StatsTimeRange } from '../../shared/stats-types'; +import { getAgentIcon } from '../constants/agentIcons'; +import { formatTokenCount } from '../hooks/useAccountUsage'; +import { useProviderDetail, type ProviderDetail } from '../hooks/useProviderDetail'; +import type { HealthStatus } from './ProviderHealthCard'; +import { ProviderDetailCharts } from './ProviderDetailCharts'; +import { getAgentDisplayName } from '../services/contextGroomer'; + +// ============================================================================ +// Types +// ============================================================================ + +interface ProviderDetailViewProps { + theme: Theme; + toolType: ToolType; + sessions: Session[]; + timeRange: StatsTimeRange; + setTimeRange: (range: StatsTimeRange) => void; + onBack: () => void; + onSelectSession?: (sessionId: string) => void; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function getStatusLabel(status: HealthStatus): string { + switch (status) { + case 'healthy': return 'Healthy'; + case 'degraded': return 'Degraded'; + case 'failing': return 'Failing'; + case 'not_installed': return 'Not Installed'; + case 'idle': return 'Idle'; + } +} + +function getStatusColor(status: HealthStatus, theme: Theme): string { + switch (status) { + case 'healthy': return theme.colors.success; + case 'degraded': return theme.colors.warning; + case 'failing': return theme.colors.error; + case 'not_installed': return theme.colors.textDim; + case 'idle': return theme.colors.accent; + } +} + +function formatDurationMs(ms: number): string { + if (ms === 0) return '—'; + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function formatMigrationTime(timestamp: number): string { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffHours = diffMs / (1000 * 60 * 60); + + if (diffHours < 24) { + return date.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + }); + } + + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +// ============================================================================ +// Component +// ============================================================================ + +export function ProviderDetailView({ + theme, + toolType, + sessions, + timeRange, + setTimeRange: _setTimeRange, + onBack, + onSelectSession, +}: ProviderDetailViewProps) { + const { detail, isLoading } = useProviderDetail(toolType, sessions, timeRange); + + // Handle Escape key to go back + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onBack(); + } + } + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onBack]); + + const statusColor = detail ? getStatusColor(detail.status, theme) : theme.colors.textDim; + + // Loading skeleton + if (isLoading && !detail) { + return ( +
+ +
+
+
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+
+
+ ); + } + + if (!detail) { + return ( +
+ +
+ Failed to load provider details +
+
+ ); + } + + const reliabilityDisplay = detail.usage.queryCount > 0 + ? `${detail.reliability.successRate.toFixed(1)}%` + : 'N/A'; + const errorRateDisplay = detail.usage.queryCount > 0 + ? `${detail.reliability.errorRate.toFixed(1)}%` + : 'N/A'; + + return ( +
+ {/* Back button */} + + + {/* Header: icon + name + status */} +
+
+ {getAgentIcon(toolType)} + + {detail.displayName} + +
+
+ + + {getStatusLabel(detail.status)} + +
+
+ + {/* Summary stats row — 8 key metrics */} +
+ + + + + = 95 + ? theme.colors.success + : detail.reliability.successRate >= 85 + ? theme.colors.warning + : theme.colors.error + } + /> + + + +
+ + {/* Avg Response Time row */} +
+ + + + +
+ + {/* Error type breakdown (compact inline, only when errors exist) */} + {detail.reliability.totalErrors > 0 && ( + + )} + + {/* Charts and visualizations */} +
+ +
+ + {/* Comparison Bar */} + + + {/* Active Sessions */} + + + {/* Migration History */} + +
+ ); +} + +// ============================================================================ +// Sub-components +// ============================================================================ + +const ERROR_TYPE_LABELS: Record = { + auth_expired: 'Auth Expired', + token_exhaustion: 'Token Exhaustion', + rate_limited: 'Rate Limited', + network_error: 'Network Error', + agent_crashed: 'Agent Crashed', + permission_denied: 'Permission Denied', + session_not_found: 'Session Not Found', + unknown: 'Unknown', +}; + +function ErrorBreakdownBar({ + theme, + errorsByType, +}: { + theme: Theme; + errorsByType: Partial>; +}) { + const entries = Object.entries(errorsByType) + .filter(([, count]) => count && count > 0) + .sort(([, a], [, b]) => (b ?? 0) - (a ?? 0)) as Array<[AgentErrorType, number]>; + + if (entries.length === 0) return null; + + return ( +
+ Errors: + {entries.map(([type, count]) => ( + + {ERROR_TYPE_LABELS[type] ?? type}: {count} + + ))} +
+ ); +} + +function MetricCard({ + theme, + label, + value, + valueColor, +}: { + theme: Theme; + label: string; + value: string; + valueColor?: string; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +// ============================================================================ +// ComparisonBar — benchmarks vs other providers +// ============================================================================ + +function ComparisonBar({ + theme, + detail, +}: { + theme: Theme; + detail: ProviderDetail; +}) { + const { comparison } = detail; + if (comparison.totalQueriesAllProviders === 0) return null; + + const fastest = comparison.avgResponseRanking[0]; + const slowest = comparison.avgResponseRanking[comparison.avgResponseRanking.length - 1]; + const highestReliability = comparison.reliabilityRanking[0]; + const lowestReliability = comparison.reliabilityRanking[comparison.reliabilityRanking.length - 1]; + + return ( +
+
+ How does {detail.displayName} compare? +
+ + {/* Query share progress bar */} + + + {/* Cost share progress bar */} + + + {/* Avg Response Time comparison */} + {comparison.avgResponseRanking.length > 1 && fastest && slowest && ( +
+ Avg Response + + {formatDurationMs(detail.reliability.avgResponseTimeMs)} + + + (fastest: {fastest.provider} {formatDurationMs(fastest.avgMs)}, + slowest: {slowest.provider} {formatDurationMs(slowest.avgMs)}) + +
+ )} + + {/* Reliability comparison */} + {comparison.reliabilityRanking.length > 1 && highestReliability && lowestReliability && ( +
+ Reliability + = 95 + ? theme.colors.success + : detail.reliability.successRate >= 85 + ? theme.colors.warning + : theme.colors.error, + }} + > + {detail.usage.queryCount > 0 ? `${detail.reliability.successRate.toFixed(1)}%` : 'N/A'} + + + (highest: {highestReliability.provider} {highestReliability.rate.toFixed(1)}%, + lowest: {lowestReliability.provider} {lowestReliability.rate.toFixed(1)}%) + +
+ )} +
+ ); +} + +function ComparisonRow({ + theme, + label, + percent, + detail, +}: { + theme: Theme; + label: string; + percent: number; + detail: string; +}) { + return ( +
+
+ {label} + {detail} +
+
+
+
+
+ ); +} + +// ============================================================================ +// ActiveSessionsList — clickable sessions that navigate to the session +// ============================================================================ + +function ActiveSessionsList({ + theme, + sessions, + onSelectSession, +}: { + theme: Theme; + sessions: Array<{ id: string; name: string; projectRoot: string; state: string }>; + onSelectSession?: (sessionId: string) => void; +}) { + if (sessions.length === 0) return null; + + return ( +
+
+ Active Sessions ({sessions.length}) +
+ {sessions.map((s) => ( +
onSelectSession?.(s.id)} + onMouseEnter={(e) => { + if (onSelectSession) { + e.currentTarget.style.backgroundColor = `${theme.colors.accent}10`; + } + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent'; + }} + > + + {s.name} + + + {s.projectRoot} + + + ● {s.state} + +
+ ))} +
+ ); +} + +// ============================================================================ +// MigrationTimeline — provider-scoped migration history +// ============================================================================ + +function MigrationTimeline({ + theme, + migrations, +}: { + theme: Theme; + migrations: Array<{ + timestamp: number; + sessionName: string; + direction: 'from' | 'to'; + otherProvider: ToolType; + generation: number; + }>; +}) { + if (migrations.length === 0) return null; + + return ( +
+
+ Migration History +
+ {migrations.slice(0, 10).map((m, i) => ( +
+ + {formatMigrationTime(m.timestamp)} + + + {m.sessionName}: + + + + {' '} + {m.direction === 'from' ? 'Switched TO' : 'Switched FROM'}{' '} + {getAgentDisplayName(m.otherProvider)} + + {m.generation > 1 && ( + + (gen {m.generation}) + + )} +
+ ))} +
+ ); +} + +export default ProviderDetailView; diff --git a/src/renderer/components/ProviderHealthCard.tsx b/src/renderer/components/ProviderHealthCard.tsx new file mode 100644 index 000000000..c7064c4fb --- /dev/null +++ b/src/renderer/components/ProviderHealthCard.tsx @@ -0,0 +1,328 @@ +/** + * ProviderHealthCard - Individual provider health status card + * + * Displays: + * - Provider icon and name + * - Health status badge (Healthy/Degraded/Failing/Not Installed/Idle) + * - Stats grid: sessions, queries, tokens, cost, errors, last error + * - Health bar at bottom (green/yellow/red gradient) + */ + +import type { Theme } from '../types'; +import type { ToolType } from '../../shared/types'; +import type { ProviderErrorStats } from '../../shared/account-types'; +import type { ProviderUsageStats } from '../hooks/useProviderHealth'; +import { getAgentIcon } from '../constants/agentIcons'; +import { getAgentDisplayName } from '../services/contextGroomer'; +import { formatTokenCount } from '../hooks/useAccountUsage'; + +// ============================================================================ +// Types +// ============================================================================ + +export type HealthStatus = 'healthy' | 'degraded' | 'failing' | 'not_installed' | 'idle'; + +export interface ProviderHealthCardProps { + theme: Theme; + toolType: ToolType; + available: boolean; + activeSessionCount: number; + errorStats: ProviderErrorStats | null; + usageStats: ProviderUsageStats; + failoverThreshold: number; + healthPercent: number; + status: HealthStatus; + onSelect?: () => void; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function getStatusLabel(status: HealthStatus, errorCount: number): string { + switch (status) { + case 'healthy': + return 'Healthy'; + case 'degraded': + return `Degraded (${errorCount} error${errorCount !== 1 ? 's' : ''})`; + case 'failing': + return 'Failing'; + case 'not_installed': + return 'Not Installed'; + case 'idle': + return 'No Sessions'; + } +} + +function getStatusColor(status: HealthStatus, theme: Theme): string { + switch (status) { + case 'healthy': + return theme.colors.success; + case 'degraded': + return theme.colors.warning; + case 'failing': + return theme.colors.error; + case 'not_installed': + return theme.colors.textDim; + case 'idle': + return theme.colors.accent; + } +} + +function getStatusBgTint(status: HealthStatus, theme: Theme): string { + switch (status) { + case 'healthy': + return theme.colors.success + '08'; + case 'degraded': + return theme.colors.warning + '08'; + case 'failing': + return theme.colors.error + '08'; + default: + return 'transparent'; + } +} + +function getHealthBarColor(healthPercent: number, theme: Theme): string { + if (healthPercent >= 80) return theme.colors.success; + if (healthPercent >= 50) return theme.colors.warning; + return theme.colors.error; +} + +function formatRelativeTime(timestamp: number | null): string { + if (!timestamp) return '\u2014'; + const diffMs = Date.now() - timestamp; + if (diffMs < 0) return 'just now'; + const seconds = Math.floor(diffMs / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; +} + +function formatWindowDuration(ms: number): string { + const minutes = Math.round(ms / 60000); + return `${minutes}m`; +} + +// ============================================================================ +// Component +// ============================================================================ + +export function ProviderHealthCard({ + theme, + toolType, + available: _available, + activeSessionCount, + errorStats, + usageStats, + failoverThreshold: _failoverThreshold, + healthPercent, + status, + onSelect, +}: ProviderHealthCardProps) { + const errorCount = errorStats?.totalErrorsInWindow ?? 0; + const windowMs = 5 * 60 * 1000; // Default 5m window display + const statusColor = getStatusColor(status, theme); + const bgTint = getStatusBgTint(status, theme); + const barColor = status === 'not_installed' + ? theme.colors.textDim + '30' + : getHealthBarColor(healthPercent, theme); + + const isUnavailable = status === 'not_installed'; + const dash = '\u2014'; + + return ( +
{ + e.currentTarget.style.boxShadow = `0 2px 8px ${theme.colors.border}80`; + }} + onMouseLeave={(e) => { + e.currentTarget.style.boxShadow = 'none'; + }} + > + {/* Header: icon + name */} +
+ {getAgentIcon(toolType)} + + {getAgentDisplayName(toolType)} + +
+ + {/* Status badge */} +
+ + + {getStatusLabel(status, errorCount)} + +
+ + {/* Stats grid */} +
+ + + + + + +
+ + {/* Health bar */} +
+
+
+
+ + {status === 'not_installed' ? 'N/A' : `${Math.round(healthPercent)}%`} + + {onSelect && ( + { + e.stopPropagation(); + onSelect(); + }} + > + Details → + + )} +
+
+ ); +} + +// ============================================================================ +// Sub-components +// ============================================================================ + +function StatRow({ + theme, + label, + value, +}: { + theme: Theme; + label: string; + value: string; +}) { + return ( +
+ + {label}: + + + {value} + +
+ ); +} + +export default ProviderHealthCard; diff --git a/src/renderer/components/ProviderPanel.tsx b/src/renderer/components/ProviderPanel.tsx new file mode 100644 index 000000000..987d5935b --- /dev/null +++ b/src/renderer/components/ProviderPanel.tsx @@ -0,0 +1,807 @@ +/** + * ProviderPanel - Provider status, failover configuration, and migration history + * + * Three sections: + * 1. Provider Status Grid — shows detected agents with availability and session counts + * 2. Failover Configuration — controls for automatic provider failover + * 3. Migration History — timeline of past provider switches + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { + ChevronDown, + ChevronUp, + Plus, + X, + ArrowRightLeft, + RefreshCw, +} from 'lucide-react'; +import type { Theme, Session } from '../types'; +import type { ToolType } from '../../shared/types'; +import type { StatsTimeRange } from '../../shared/stats-types'; +import type { ProviderSwitchConfig } from '../../shared/account-types'; +import { DEFAULT_PROVIDER_SWITCH_CONFIG } from '../../shared/account-types'; +import { getAgentIcon } from '../constants/agentIcons'; +import { getAgentDisplayName } from '../services/contextGroomer'; +import { ProviderHealthCard } from './ProviderHealthCard'; +import { ProviderDetailView } from './ProviderDetailView'; +import { useProviderHealth } from '../hooks/useProviderHealth'; +import { formatTokenCount } from '../hooks/useAccountUsage'; + +// ============================================================================ +// Types +// ============================================================================ + +interface ProviderPanelProps { + theme: Theme; + sessions?: Session[]; + onSelectSession?: (sessionId: string) => void; +} + +interface MigrationEntry { + timestamp: number; + sessionName: string; + sourceProvider: ToolType; + targetProvider: ToolType; + generation: number; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const ERROR_WINDOW_OPTIONS = [ + { label: '1 minute', value: 1 * 60 * 1000 }, + { label: '2 minutes', value: 2 * 60 * 1000 }, + { label: '5 minutes', value: 5 * 60 * 1000 }, + { label: '10 minutes', value: 10 * 60 * 1000 }, + { label: '15 minutes', value: 15 * 60 * 1000 }, +]; + +const MIGRATION_HISTORY_LIMIT = 20; + +const TIME_RANGE_OPTIONS: { label: string; value: StatsTimeRange }[] = [ + { label: 'Today', value: 'day' }, + { label: 'This Week', value: 'week' }, + { label: 'This Month', value: 'month' }, + { label: 'This Quarter', value: 'quarter' }, + { label: 'All Time', value: 'all' }, +]; + +// ============================================================================ +// Helpers +// ============================================================================ + +function formatMigrationTime(timestamp: number): string { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffHours = diffMs / (1000 * 60 * 60); + + if (diffHours < 24) { + return date.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + }); + } + + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +function ordinalSuffix(n: number): string { + const s = ['th', 'st', 'nd', 'rd']; + const v = n % 100; + return n + (s[(v - 20) % 10] || s[v] || s[0]); +} + +// ============================================================================ +// Component +// ============================================================================ + +export function ProviderPanel({ theme, sessions = [], onSelectSession }: ProviderPanelProps) { + const { + providers: healthProviders, + isLoading: healthLoading, + lastUpdated: _lastUpdated, + timeRange, + setTimeRange, + refresh: refreshHealth, + failoverThreshold, + totals, + } = useProviderHealth(sessions); + const [config, setConfig] = useState(DEFAULT_PROVIDER_SWITCH_CONFIG); + const [showMoreHistory, setShowMoreHistory] = useState(false); + const [selectedProvider, setSelectedProvider] = useState(null); + + // ── Load failover config ──────────────────────────────────────────── + useEffect(() => { + async function loadConfig() { + try { + const saved = await window.maestro.settings.get('providerSwitchConfig'); + if (saved && typeof saved === 'object') { + setConfig({ ...DEFAULT_PROVIDER_SWITCH_CONFIG, ...(saved as Partial) }); + } + } catch { + // Use defaults + } + } + loadConfig(); + }, []); + + const saveConfig = useCallback(async (updates: Partial) => { + const updated = { ...config, ...updates }; + setConfig(updated); + try { + await window.maestro.settings.set('providerSwitchConfig', updated); + } catch (err) { + console.error('Failed to save provider switch config:', err); + } + }, [config]); + + // ── Build migration history ───────────────────────────────────────── + const migrations: MigrationEntry[] = React.useMemo(() => { + const entries: MigrationEntry[] = []; + + for (const session of sessions) { + if (session.migratedFromSessionId && session.migratedAt) { + // This session was created by migration — find source + const source = sessions.find((s) => s.id === session.migratedFromSessionId); + if (source) { + entries.push({ + timestamp: session.migratedAt, + sessionName: session.name || 'Unnamed Agent', + sourceProvider: source.toolType as ToolType, + targetProvider: session.toolType as ToolType, + generation: session.migrationGeneration || 1, + }); + } + } + } + + entries.sort((a, b) => b.timestamp - a.timestamp); + return entries; + }, [sessions]); + + const visibleMigrations = showMoreHistory + ? migrations + : migrations.slice(0, MIGRATION_HISTORY_LIMIT); + const hasMoreMigrations = migrations.length > MIGRATION_HISTORY_LIMIT; + + // ── Fallback provider management ──────────────────────────────────── + const availableForFallback = healthProviders + .filter((p) => !config.fallbackProviders.includes(p.toolType)) + .map((p) => ({ id: p.toolType, name: p.displayName, icon: getAgentIcon(p.toolType), available: p.available })); + + const handleAddFallback = (toolType: ToolType) => { + saveConfig({ fallbackProviders: [...config.fallbackProviders, toolType] }); + }; + + const handleRemoveFallback = (toolType: ToolType) => { + saveConfig({ + fallbackProviders: config.fallbackProviders.filter((p) => p !== toolType), + }); + }; + + const handleMoveFallback = (index: number, direction: 'up' | 'down') => { + const list = [...config.fallbackProviders]; + const swapIndex = direction === 'up' ? index - 1 : index + 1; + if (swapIndex < 0 || swapIndex >= list.length) return; + [list[index], list[swapIndex]] = [list[swapIndex], list[index]]; + saveConfig({ fallbackProviders: list }); + }; + + // ── Styles ────────────────────────────────────────────────────────── + const sectionStyle: React.CSSProperties = { + backgroundColor: theme.colors.bgSidebar, + borderRadius: 8, + padding: '16px', + marginBottom: 16, + }; + + const sectionTitleStyle: React.CSSProperties = { + color: theme.colors.textMain, + fontSize: 13, + fontWeight: 600, + marginBottom: 12, + }; + + const labelStyle: React.CSSProperties = { + color: theme.colors.textMain, + fontSize: 12, + }; + + const dimStyle: React.CSSProperties = { + color: theme.colors.textDim, + fontSize: 11, + }; + + const timeRangeLabel = TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)?.label?.toLowerCase() ?? 'today'; + + // ── Render ─────────────────────────────────────────────────────────── + + // Detail view for a selected provider + if (selectedProvider) { + return ( +
+
+ setSelectedProvider(null)} + onSelectSession={onSelectSession} + /> +
+
+ ); + } + + return ( +
+ {/* Provider Health Dashboard */} +
+
Provider Health
+ + {/* Totals summary bar */} + {!healthLoading && healthProviders.length > 0 && ( +
+ + Total {timeRangeLabel}: + + + {totals.queryCount.toLocaleString()} queries + + · + + {formatTokenCount(totals.totalTokens)} tokens + + · + + ${totals.totalCostUsd.toFixed(2)} cost + +
+ )} + + {healthLoading && healthProviders.length === 0 ? ( +
+ {[0, 1].map((i) => ( +
+
+
+
+
+ ))} +
+ ) : ( +
+ {healthProviders.map((provider) => ( + setSelectedProvider(provider.toolType)} + /> + ))} + {healthProviders.length === 0 && ( +
No providers detected
+ )} +
+ )} + + {/* Footer: time range selector, auto-refresh, refresh button */} +
+
+ Time range: + +
+
+ + Auto-refresh: 10s + + +
+
+
+ + {/* Failover Configuration */} +
+
Automatic Failover
+ + {/* Enable automatic failover toggle */} +
+
+
Enable automatic failover
+
+ When a provider hits repeated errors, suggest switching to an + alternative provider. +
+
+ +
+ + {/* Prompt before switching toggle */} +
+
+
Prompt before switching
+
+ Ask for confirmation before auto-switching. Uncheck for fully + automatic failover. +
+
+ +
+ + {/* Error threshold and window */} +
+
+ Error threshold: + + consecutive errors +
+
+ Error window: + +
+
+ + {/* Fallback priority list */} +
+
+ Fallback priority: +
+ {config.fallbackProviders.length === 0 && ( +
+ No fallback providers configured +
+ )} + {config.fallbackProviders.map((toolType, index) => ( +
+ {index + 1}. + + {getAgentIcon(toolType)} + + + {getAgentDisplayName(toolType)} + + + + +
+ ))} + + {/* Add provider dropdown */} + {availableForFallback.length > 0 && ( +
+ +
+ )} +
+
+ + {/* Switch Behavior */} +
+
Switch Behavior
+
+ When switching back to a provider that already has an archived session: +
+
+ + +
+
+ + {/* Migration History */} +
+
Migration History
+ {migrations.length === 0 ? ( +
+ No provider switches yet +
+ ) : ( +
+ {visibleMigrations.map((entry, i) => ( +
+
+ {formatMigrationTime(entry.timestamp)} +
+
+ {entry.sessionName}:{' '} + + {getAgentDisplayName(entry.sourceProvider)} + + {' '} + + {' '} + + {getAgentDisplayName(entry.targetProvider)} + +
+ {entry.generation > 1 && ( +
+ {ordinalSuffix(entry.generation)} switch +
+ )} +
+ ))} + {hasMoreMigrations && !showMoreHistory && ( + + )} + {showMoreHistory && hasMoreMigrations && ( + + )} +
+ )} +
+
+ ); +} + +// ============================================================================ +// Sub-components +// ============================================================================ + +function AddProviderDropdown({ + theme, + providers, + onAdd, +}: { + theme: Theme; + providers: { id: ToolType; name: string; icon: string; available: boolean }[]; + onAdd: (toolType: ToolType) => void; +}) { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + {isOpen && ( +
+ {providers.map((p) => ( + + ))} +
+ )} +
+ ); +} + +export default ProviderPanel; diff --git a/src/renderer/components/SessionItem.tsx b/src/renderer/components/SessionItem.tsx index 2554071e4..c842d9093 100644 --- a/src/renderer/components/SessionItem.tsx +++ b/src/renderer/components/SessionItem.tsx @@ -1,5 +1,5 @@ import React, { memo } from 'react'; -import { Activity, GitBranch, Bot, Bookmark, AlertCircle, Server } from 'lucide-react'; +import { Activity, GitBranch, Bot, Bookmark, AlertCircle, Server, RefreshCw } from 'lucide-react'; import type { Session, Group, Theme } from '../types'; import { getStatusColor } from '../utils/theme'; @@ -35,6 +35,7 @@ export interface SessionItemProps { gitFileCount?: number; isInBatch?: boolean; jumpNumber?: string | null; // Session jump shortcut number (1-9, 0) + accountUsagePercent?: number | null; // Usage % for assigned account (passed from parent to avoid N hook instances) // Handlers onSelect: () => void; @@ -75,6 +76,7 @@ export const SessionItem = memo(function SessionItem({ gitFileCount, isInBatch = false, jumpNumber, + accountUsagePercent, onSelect, onDragStart, onDragOver, @@ -119,6 +121,7 @@ export const SessionItem = memo(function SessionItem({ : isKeyboardSelected ? theme.colors.bgActivity + '40' : 'transparent', + opacity: session.archivedByMigration ? 0.4 : undefined, }} > {/* Left side: Session name and metadata */} @@ -176,6 +179,19 @@ export const SessionItem = memo(function SessionItem({ )} {session.toolType} {session.sessionSshRemoteConfig?.enabled ? ' (SSH)' : ''} + {/* Account assignment badge */} + {session.accountId && session.accountName && ( + + {session.accountName.split('@')[0]?.slice(0, 10)?.toUpperCase() || 'ACC'} + + )} {/* Group badge (only in bookmark variant when session belongs to a group) */} {variant === 'bookmark' && group && ( )} + + {/* Migration archive indicator */} + {session.archivedByMigration && ( +
+ Provider switched — archived +
+ )} + {/* Merge-back refresh indicator (session was reactivated with new context) */} + {!session.archivedByMigration && session.lastMergeBackAt && ( +
+ + Context refreshed +
+ )}
{/* Right side: Indicators and actions */} @@ -316,30 +352,34 @@ export const SessionItem = memo(function SessionItem({ {/* AI Status Indicator with Unread Badge - ml-auto ensures it aligns to right edge */}
{/* Unread Notification Badge */} diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index e3f322375..f251f1a27 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -36,6 +36,10 @@ import { Server, Music, Command, + User, + Users, + ArrowRightLeft, + ArchiveRestore, } from 'lucide-react'; import { QRCodeSVG } from 'qrcode.react'; import type { @@ -53,6 +57,7 @@ import { getBadgeForTime } from '../constants/conductorBadges'; import { getStatusColor, getContextColor, formatActiveTime } from '../utils/theme'; import { formatShortcutKeys } from '../utils/shortcutFormatter'; import { SessionItem } from './SessionItem'; +import { useAccountUsage } from '../hooks/useAccountUsage'; import { GroupChatList } from './GroupChatList'; import { useLiveOverlay, useClickOutside, useResizablePanel } from '../hooks'; import { useGitFileStatus } from '../contexts/GitStatusContext'; @@ -81,6 +86,8 @@ interface SessionContextMenuProps { onConfigureWorktrees?: () => void; // Opens full worktree config modal onDeleteWorktree?: () => void; // For worktree child sessions to delete onCreateGroup?: () => void; // Creates a new group from the Move to Group submenu + onSwitchProvider?: () => void; // Opens SwitchProviderModal (Virtuosos) + onUnarchive?: () => void; // Unarchive a migration-archived session (Virtuosos) } function SessionContextMenu({ @@ -102,6 +109,8 @@ function SessionContextMenu({ onConfigureWorktrees, onDeleteWorktree, onCreateGroup, + onSwitchProvider, + onUnarchive, }: SessionContextMenuProps) { const menuRef = useRef(null); const moveToGroupRef = useRef(null); @@ -209,6 +218,47 @@ function SessionContextMenu({ Edit Agent... + {/* Switch Provider (Virtuosos vertical swapping) */} + {onSwitchProvider && session.toolType !== 'terminal' && ( + + )} + + {/* Unarchive (only for migration-archived sessions) */} + {onUnarchive && session.archivedByMigration && ( + + )} + + {/* Account info - non-clickable info item */} + {session.accountId && ( +
+ + Account: {session.accountName || session.accountId} +
+ )} + {/* Duplicate */} + {setVirtuososOpen && ( + + )}
+ {(session.accountName || session.accountId) && ( +
+ Account:{' '} + + {session.accountName || session.accountId} + +
+ )}
void; setDirectorNotesOpen?: (open: boolean) => void; setQuickActionOpen: (open: boolean) => void; + setVirtuososOpen?: (open: boolean) => void; toggleGroup: (groupId: string) => void; handleDragStart: (sessionId: string) => void; handleDragOver: (e: React.DragEvent) => void; @@ -1100,6 +1180,10 @@ interface SessionListProps { onDeleteSession?: (id: string) => void; onDeleteWorktreeGroup?: (groupId: string) => void; + // Provider switching (Virtuosos) + onSwitchProvider?: (sessionId: string) => void; + onUnarchive?: (sessionId: string) => void; + // Rename modal handlers (for context menu rename) setRenameInstanceModalOpen: (open: boolean) => void; setRenameInstanceValue: (value: string) => void; @@ -1206,6 +1290,7 @@ function SessionListInner(props: SessionListProps) { setSymphonyModalOpen, setDirectorNotesOpen, setQuickActionOpen, + setVirtuososOpen, toggleGroup, handleDragStart, handleDragOver, @@ -1223,6 +1308,8 @@ function SessionListInner(props: SessionListProps) { addNewSession, onDeleteSession, onDeleteWorktreeGroup, + onSwitchProvider, + onUnarchive, setRenameInstanceModalOpen, setRenameInstanceValue, setRenameInstanceSessionId, @@ -1265,6 +1352,9 @@ function SessionListInner(props: SessionListProps) { [sessions, activeBatchSessionIds] ); + // Account usage metrics for SessionItem badge tooltips + const { metrics: accountUsageMetrics } = useAccountUsage(); + const [sessionFilter, setSessionFilter] = useState(''); const { onResizeStart: onSidebarResizeStart, transitionClass: sidebarTransitionClass } = useResizablePanel({ @@ -1627,6 +1717,9 @@ function SessionListInner(props: SessionListProps) { gitFileCount={getFileCount(session.id)} isInBatch={activeBatchSessionIds.includes(session.id)} jumpNumber={getSessionJumpNumber(session.id)} + accountUsagePercent={ + session.accountId ? accountUsageMetrics[session.accountId]?.usagePercent : undefined + } onSelect={selectHandlers.get(session.id)!} onDragStart={dragStartHandlers.get(session.id)!} onDragOver={handleDragOver} @@ -1689,6 +1782,11 @@ function SessionListInner(props: SessionListProps) { gitFileCount={getFileCount(child.id)} isInBatch={activeBatchSessionIds.includes(child.id)} jumpNumber={getSessionJumpNumber(child.id)} + accountUsagePercent={ + child.accountId + ? accountUsageMetrics[child.accountId]?.usagePercent + : undefined + } onSelect={selectHandlers.get(child.id)!} onDragStart={dragStartHandlers.get(child.id)!} onContextMenu={contextMenuHandlers.get(child.id)!} @@ -2507,6 +2605,7 @@ function SessionListInner(props: SessionListProps) { setAboutModalOpen={setAboutModalOpen} setMenuOpen={setMenuOpen} setQuickActionOpen={setQuickActionOpen} + setVirtuososOpen={setVirtuososOpen} />
)} @@ -2552,6 +2651,7 @@ function SessionListInner(props: SessionListProps) { setAboutModalOpen={setAboutModalOpen} setMenuOpen={setMenuOpen} setQuickActionOpen={setQuickActionOpen} + setVirtuososOpen={setVirtuososOpen} />
)} @@ -3058,6 +3158,10 @@ function SessionListInner(props: SessionListProps) { ? () => onCreateGroupAndMove(contextMenuSession.id) : createNewGroup } + onSwitchProvider={ + onSwitchProvider ? () => onSwitchProvider(contextMenuSession.id) : undefined + } + onUnarchive={onUnarchive ? () => onUnarchive(contextMenuSession.id) : undefined} /> )}
diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index 0f2a2c77b..4e6f758d4 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -35,6 +35,7 @@ import { User, ArrowDownToLine, Clapperboard, + Users, } from 'lucide-react'; import { useSettings } from '../hooks'; import type { @@ -3317,6 +3318,41 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro ); })()}
+ + {/* Virtuosos Feature Section */} +
+ +
)}
diff --git a/src/renderer/components/SwitchProviderModal.tsx b/src/renderer/components/SwitchProviderModal.tsx new file mode 100644 index 000000000..9acd362e7 --- /dev/null +++ b/src/renderer/components/SwitchProviderModal.tsx @@ -0,0 +1,594 @@ +/** + * SwitchProviderModal - Confirmation modal for Virtuosos provider switching + * + * Lets users select a target provider and configure switch options (groom context, + * archive source) before initiating a provider switch. Shows current provider, + * available targets with availability status, and estimated token count. + * + * When a target provider is selected and an archived session on that provider + * exists in the provenance chain, shows a merge-back panel letting users choose + * to reactivate the existing session instead of creating a new one. + * + * Pattern references: + * - AccountSwitchModal for themed modal structure + * - SendToAgentModal for agent selection + keyboard navigation + * - Modal base component for consistent chrome + layer stack + */ + +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import * as Sentry from '@sentry/electron/renderer'; +import { ArrowDown, Shuffle, Info } from 'lucide-react'; +import type { Theme, Session, ToolType, AgentConfig } from '../types'; +import type { ProviderSwitchBehavior } from '../../shared/account-types'; +import { DEFAULT_PROVIDER_SWITCH_CONFIG } from '../../shared/account-types'; +import { Modal } from './ui/Modal'; +import { MODAL_PRIORITIES } from '../constants/modalPriorities'; +import { getAgentIcon } from '../constants/agentIcons'; +import { getAgentDisplayName } from '../services/contextGroomer'; +import { formatTokensCompact } from '../utils/formatters'; +import { findArchivedPredecessor } from '../hooks/agent/useProviderSwitch'; + +export interface SwitchProviderModalProps { + theme: Theme; + isOpen: boolean; + onClose: () => void; + /** The session being switched */ + sourceSession: Session; + /** Active tab ID for context extraction */ + sourceTabId: string; + /** All sessions (for provenance chain walking) */ + sessions: Session[]; + /** Callback when user confirms the switch */ + onConfirmSwitch: (request: { + targetProvider: ToolType; + groomContext: boolean; + archiveSource: boolean; + /** If set, merge back into this session instead of creating new */ + mergeBackInto?: Session; + }) => void; +} + +interface ProviderOption { + id: ToolType; + name: string; + available: boolean; +} + +/** + * Estimate token count from tab log entries. + * Uses ~4 characters per token heuristic (same as SendToAgentModal). + */ +function estimateTokensFromLogs(logs: { text: string }[]): number { + const totalChars = logs.reduce((sum, log) => sum + (log.text?.length || 0), 0); + return Math.round(totalChars / 4); +} + +/** Format a timestamp as relative time (e.g., "2 hours ago"). */ +function formatRelativeTime(timestamp: number): string { + const diff = Date.now() - timestamp; + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`; + const days = Math.floor(hours / 24); + return `${days} day${days === 1 ? '' : 's'} ago`; +} + +export function SwitchProviderModal({ + theme, + isOpen, + onClose, + sourceSession, + sourceTabId, + sessions, + onConfirmSwitch, +}: SwitchProviderModalProps) { + // Provider selection + const [selectedProvider, setSelectedProvider] = useState(null); + const [highlightedIndex, setHighlightedIndex] = useState(0); + + // Options + const [groomContext, setGroomContext] = useState(true); + const [archiveSource, setArchiveSource] = useState(true); + + // Merge-back choice: 'new' = create new session, 'merge' = reactivate archived + const [mergeChoice, setMergeChoice] = useState<'new' | 'merge'>('merge'); + + // Detected agents + const [providers, setProviders] = useState([]); + + // Stored switch behavior preference + const [switchBehavior, setSwitchBehavior] = useState( + DEFAULT_PROVIDER_SWITCH_CONFIG.switchBehavior + ); + + // Ref for scrolling highlighted item into view + const highlightedRef = useRef(null); + + // Detect available providers when modal opens + useEffect(() => { + if (!isOpen) return; + + let mounted = true; + + (async () => { + try { + const agents: AgentConfig[] = await window.maestro.agents.detect(); + + if (!mounted) return; + + const options: ProviderOption[] = agents + // Filter out: current provider, terminal, hidden agents + .filter((a) => { + if (a.id === sourceSession.toolType) return false; + if (a.id === 'terminal') return false; + if (a.hidden) return false; + return true; + }) + .map((a) => ({ + id: a.id as ToolType, + name: a.name || getAgentDisplayName(a.id as ToolType), + available: a.available, + })) + // Sort: available first, then alphabetically + .sort((a, b) => { + if (a.available !== b.available) return a.available ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + setProviders(options); + } catch (err) { + Sentry.captureException(err, { + extra: { + operation: 'provider:detectAgentsForSwitch', + sourceToolType: sourceSession.toolType, + }, + }); + } + })(); + + return () => { + mounted = false; + }; + }, [isOpen, sourceSession.toolType]); + + // Load switch behavior preference + useEffect(() => { + if (!isOpen) return; + + (async () => { + try { + const saved = await window.maestro.settings.get('providerSwitchConfig'); + if ( + saved && + typeof saved === 'object' && + 'switchBehavior' in (saved as Record) + ) { + setSwitchBehavior((saved as { switchBehavior: ProviderSwitchBehavior }).switchBehavior); + } + } catch { + // Use default + } + })(); + }, [isOpen]); + + // Reset state when modal opens + useEffect(() => { + if (isOpen) { + setSelectedProvider(null); + setHighlightedIndex(0); + setGroomContext(true); + setArchiveSource(true); + setMergeChoice(switchBehavior === 'merge-back' ? 'merge' : 'new'); + } + }, [isOpen, switchBehavior]); + + // Scroll highlighted item into view + useEffect(() => { + highlightedRef.current?.scrollIntoView({ block: 'nearest' }); + }, [highlightedIndex]); + + // Token estimate from active tab + const tokenEstimate = useMemo(() => { + const tab = sourceSession.aiTabs.find((t) => t.id === sourceTabId); + if (!tab) return 0; + return estimateTokensFromLogs(tab.logs); + }, [sourceSession, sourceTabId]); + + // Available (selectable) providers for keyboard nav + const selectableProviders = useMemo(() => providers.filter((p) => p.available), [providers]); + + // Find archived predecessor when target provider changes + const archivedPredecessor = useMemo(() => { + if (!selectedProvider) return null; + return findArchivedPredecessor(sessions, sourceSession, selectedProvider); + }, [sessions, sourceSession, selectedProvider]); + + // Reset merge choice default when predecessor changes + useEffect(() => { + if (archivedPredecessor) { + setMergeChoice(switchBehavior === 'merge-back' ? 'merge' : 'new'); + } + }, [archivedPredecessor, switchBehavior]); + + // Handle confirm + const handleConfirm = useCallback(() => { + if (!selectedProvider) return; + onConfirmSwitch({ + targetProvider: selectedProvider, + groomContext, + archiveSource, + mergeBackInto: + archivedPredecessor && mergeChoice === 'merge' ? archivedPredecessor : undefined, + }); + }, [ + selectedProvider, + groomContext, + archiveSource, + archivedPredecessor, + mergeChoice, + onConfirmSwitch, + ]); + + // Keyboard navigation handler + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlightedIndex((prev) => (prev + 1 < selectableProviders.length ? prev + 1 : prev)); + return; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightedIndex((prev) => (prev - 1 >= 0 ? prev - 1 : prev)); + return; + } + + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + if (selectedProvider) { + handleConfirm(); + } else if (selectableProviders[highlightedIndex]) { + setSelectedProvider(selectableProviders[highlightedIndex].id); + } + return; + } + + if (e.key === ' ') { + e.preventDefault(); + if (selectableProviders[highlightedIndex]) { + setSelectedProvider(selectableProviders[highlightedIndex].id); + } + return; + } + }, + [selectableProviders, highlightedIndex, selectedProvider, handleConfirm] + ); + + if (!isOpen) return null; + + const currentProviderName = getAgentDisplayName(sourceSession.toolType); + const currentProviderIcon = getAgentIcon(sourceSession.toolType); + + return ( + } + width={480} + closeOnBackdropClick + footer={ +
+
+ + +
+ } + > +
el?.focus()} + > + {/* Current Provider */} +
+
+ Current Provider +
+
+ {currentProviderIcon} +
+
+ {currentProviderName} +
+
+ + active + +
+
+ + {/* Arrow */} +
+ +
+ + {/* Target Provider Selection */} +
+
+ Select target provider: +
+
+ {providers.length === 0 ? ( +
+ No other providers detected +
+ ) : ( + providers.map((provider) => { + const isSelected = selectedProvider === provider.id; + const isAvailable = provider.available; + const selectableIndex = selectableProviders.findIndex((p) => p.id === provider.id); + const isHighlighted = isAvailable && selectableIndex === highlightedIndex; + + return ( + + ); + }) + )} +
+
+ + {/* Merge-back panel (when archived predecessor found) */} + {archivedPredecessor && selectedProvider && ( +
+
+ +
+ + Previous {getAgentDisplayName(selectedProvider)} session found + +
+ “{archivedPredecessor.name || 'Unnamed Agent'}” was previously on{' '} + {getAgentDisplayName(selectedProvider)} before switching to {currentProviderName} + {archivedPredecessor.migratedAt + ? ` ${formatRelativeTime(archivedPredecessor.migratedAt)}` + : ''} + . +
+
+
+ + {/* Merge-back radio options */} +
+ + +
+
+ )} + + {/* Options */} +
+
+ Options +
+
+ + +
+
+ + {/* Token estimate */} +
+ Context size: ~{formatTokensCompact(tokenEstimate)} tokens +
+
+ + ); +} + +export default SwitchProviderModal; diff --git a/src/renderer/components/SymphonyModal.tsx b/src/renderer/components/SymphonyModal.tsx index d03a4e088..607c91d56 100644 --- a/src/renderer/components/SymphonyModal.tsx +++ b/src/renderer/components/SymphonyModal.tsx @@ -969,6 +969,7 @@ function ActiveContributionCard({ onSync, isSyncing, sessionName, + accountName, onNavigateToSession, }: { contribution: ActiveContribution; @@ -977,6 +978,7 @@ function ActiveContributionCard({ onSync: () => void; isSyncing: boolean; sessionName: string | null; + accountName?: string | null; onNavigateToSession: () => void; }) { const statusInfo = getStatusInfo(contribution.status); @@ -1013,15 +1015,30 @@ function ActiveContributionCard({ {contribution.repoSlug}

{sessionName && ( - +
+ + {accountName && ( + + {accountName} + + )} +
)}
@@ -2107,6 +2124,7 @@ export function SymphonyModal({ onSync={() => handleSyncContribution(contribution.id)} isSyncing={syncingContributionId === contribution.id} sessionName={session?.name ?? null} + accountName={session?.accountName} onNavigateToSession={() => { if (session) { onSelectSession(session.id); diff --git a/src/renderer/components/UnarchiveConflictModal.tsx b/src/renderer/components/UnarchiveConflictModal.tsx new file mode 100644 index 000000000..23f569503 --- /dev/null +++ b/src/renderer/components/UnarchiveConflictModal.tsx @@ -0,0 +1,122 @@ +import React, { useRef, useCallback } from 'react'; +import { AlertTriangle, ArchiveRestore } from 'lucide-react'; +import type { Theme, Session } from '../types'; +import { MODAL_PRIORITIES } from '../constants/modalPriorities'; +import { Modal } from './ui/Modal'; +import { getAgentDisplayName } from '../services/contextGroomer'; + +interface UnarchiveConflictModalProps { + theme: Theme; + /** The archived session the user wants to unarchive */ + archivedSession: Session; + /** The existing non-archived session that conflicts (same toolType) */ + conflictingSession: Session; + /** Called when user chooses to archive the conflicting agent, then unarchive the target */ + onArchiveConflicting: () => void; + /** Called when user chooses to delete the conflicting agent, then unarchive the target */ + onDeleteConflicting: () => void; + onClose: () => void; +} + +export function UnarchiveConflictModal({ + theme, + archivedSession, + conflictingSession, + onArchiveConflicting, + onDeleteConflicting, + onClose, +}: UnarchiveConflictModalProps) { + const archiveButtonRef = useRef(null); + + const handleArchiveConflicting = useCallback(() => { + onArchiveConflicting(); + onClose(); + }, [onArchiveConflicting, onClose]); + + const handleDeleteConflicting = useCallback(() => { + onDeleteConflicting(); + onClose(); + }, [onDeleteConflicting, onClose]); + + const handleKeyDown = (e: React.KeyboardEvent, action: () => void) => { + if (e.key === 'Enter') { + e.stopPropagation(); + action(); + } + }; + + const providerName = getAgentDisplayName(archivedSession.toolType); + const conflictName = conflictingSession.name || 'Unnamed Agent'; + + return ( + } + width={500} + zIndex={10000} + initialFocusRef={archiveButtonRef} + footer={ +
+ + + +
+ } + > +
+
+ +
+
+

+ Another active {providerName} agent already exists: “{conflictName}”. +

+

+ To unarchive this agent, you must first archive or delete the + conflicting agent. +

+
+
+
+ ); +} diff --git a/src/renderer/components/UsageDashboard/AccountRateMetrics.tsx b/src/renderer/components/UsageDashboard/AccountRateMetrics.tsx new file mode 100644 index 000000000..68e7f3c7d --- /dev/null +++ b/src/renderer/components/UsageDashboard/AccountRateMetrics.tsx @@ -0,0 +1,87 @@ +/** + * AccountRateMetrics - Compact panel showing token consumption rates + * at three time scales with period-over-period deltas and trend indicator. + */ + +import { TrendingUp, TrendingDown, Minus } from 'lucide-react'; +import type { Theme } from '../../types'; +import type { RateMetrics } from '../../hooks/useAccountUsage'; +import { formatTokenCount } from '../../hooks/useAccountUsage'; + +interface AccountRateMetricsProps { + rateMetrics: RateMetrics; + theme: Theme; +} + +function formatDelta(delta: number, theme: Theme): { text: string; color: string } { + if (delta === 0 || isNaN(delta)) { + return { text: '\u2014', color: theme.colors.textDim }; + } + const capped = Math.max(-999, Math.min(999, delta)); + if (capped > 0) { + return { text: `+${capped.toFixed(0)}%`, color: theme.colors.error }; + } + return { text: `${capped.toFixed(0)}%`, color: theme.colors.success }; +} + +export function AccountRateMetrics({ rateMetrics, theme }: AccountRateMetricsProps) { + const trendConfig = { + up: { Icon: TrendingUp, label: 'Trending up', color: theme.colors.warning }, + stable: { Icon: Minus, label: 'Stable', color: theme.colors.textDim }, + down: { Icon: TrendingDown, label: 'Trending down', color: theme.colors.success }, + }; + + const { Icon: TrendIcon, label: trendLabel, color: trendColor } = trendConfig[rateMetrics.trend]; + const dailyDelta = formatDelta(rateMetrics.dailyDelta, theme); + const weeklyDelta = formatDelta(rateMetrics.weeklyDelta, theme); + + return ( +
+ {/* Row 1: Rate metrics grid */} +
+
+
Tokens/hr
+
+ + {formatTokenCount(Math.round(rateMetrics.tokensPerHour))} + +
+
+
+
Tokens/day
+
+ + {formatTokenCount(Math.round(rateMetrics.tokensPerDay))} + + + {dailyDelta.text} + +
+
+
+
Tokens/wk
+
+ + {formatTokenCount(Math.round(rateMetrics.tokensPerWeek))} + + + {weeklyDelta.text} + +
+
+
+ + {/* Row 2: Trend indicator */} +
+ + {trendLabel} +
+
+ ); +} diff --git a/src/renderer/components/UsageDashboard/AccountTrendChart.tsx b/src/renderer/components/UsageDashboard/AccountTrendChart.tsx new file mode 100644 index 000000000..6d6a57cc1 --- /dev/null +++ b/src/renderer/components/UsageDashboard/AccountTrendChart.tsx @@ -0,0 +1,373 @@ +/** + * AccountTrendChart - SVG line chart for account token usage over time. + * Supports full chart mode (axes, labels, tooltip, time range toggles) + * and compact sparkline mode (with small pill toggles). + * + * Time ranges: + * - 24h: billing window history (~5-hour granularity) + * - 7d / 30d: daily aggregation + * - Monthly: monthly aggregation + */ + +import { useState, useEffect, useMemo } from 'react'; +import type { Theme } from '../../types'; +import { formatTokenCount } from '../../hooks/useAccountUsage'; + +type TimeRange = '24h' | '7d' | '30d' | 'monthly'; + +const TIME_RANGE_LABELS: Record = { + '24h': '24h', + '7d': '7d', + '30d': '30d', + 'monthly': 'Mo', +}; + +interface DataPoint { + label: string; + totalTokens: number; + costUsd: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; +} + +interface AccountTrendChartProps { + accountId: string; + theme: Theme; + /** Default time range to display */ + defaultRange?: TimeRange; + compact?: boolean; + limitTokensPerWindow?: number; +} + +/** + * Fetch data for the selected time range. + * Returns a normalized array of DataPoint regardless of source. + */ +async function fetchRangeData(accountId: string, range: TimeRange): Promise { + if (range === '24h') { + // Use billing window history (5-hour windows) + const windows = await window.maestro.accounts.getWindowHistory(accountId, 10) as Array<{ + windowStart: number; windowEnd: number; + inputTokens: number; outputTokens: number; + cacheReadTokens: number; cacheCreationTokens: number; + costUsd: number; + }>; + return (windows || []).map(w => { + const d = new Date(w.windowStart); + const hours = d.getHours(); + const label = `${d.getMonth() + 1}/${d.getDate()} ${hours}:00`; + return { + label, + totalTokens: w.inputTokens + w.outputTokens + w.cacheReadTokens + w.cacheCreationTokens, + costUsd: w.costUsd, + inputTokens: w.inputTokens, + outputTokens: w.outputTokens, + cacheReadTokens: w.cacheReadTokens, + cacheCreationTokens: w.cacheCreationTokens, + }; + }); + } + + if (range === 'monthly') { + const monthly = await window.maestro.accounts.getMonthlyUsage(accountId, 6) as Array<{ + month: string; + inputTokens: number; outputTokens: number; + cacheReadTokens: number; cacheCreationTokens: number; + totalTokens: number; costUsd: number; + }>; + return (monthly || []).map(m => ({ + label: m.month, + totalTokens: m.totalTokens, + costUsd: m.costUsd, + inputTokens: m.inputTokens, + outputTokens: m.outputTokens, + cacheReadTokens: m.cacheReadTokens, + cacheCreationTokens: m.cacheCreationTokens, + })); + } + + // 7d or 30d — daily aggregation + const days = range === '7d' ? 7 : 30; + const daily = await window.maestro.accounts.getDailyUsage(accountId, days) as Array<{ + date: string; + inputTokens: number; outputTokens: number; + cacheReadTokens: number; cacheCreationTokens: number; + totalTokens: number; costUsd: number; + }>; + return (daily || []).map(d => ({ + label: d.date, + totalTokens: d.totalTokens, + costUsd: d.costUsd, + inputTokens: d.inputTokens, + outputTokens: d.outputTokens, + cacheReadTokens: d.cacheReadTokens, + cacheCreationTokens: d.cacheCreationTokens, + })); +} + +/** Format label for x-axis display based on time range */ +function formatXLabel(label: string, range: TimeRange): string { + if (range === '24h') { + // Already formatted as "M/D HH:00" + return label; + } + if (range === 'monthly') { + // "YYYY-MM" → "Jan 26" + const [year, month] = label.split('-'); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return `${months[parseInt(month, 10) - 1]} ${year.slice(2)}`; + } + // Daily: "YYYY-MM-DD" → "M/D" + const parts = label.split('-'); + return `${parseInt(parts[1])}/${parseInt(parts[2])}`; +} + +export function AccountTrendChart({ + accountId, + theme, + defaultRange = '7d', + compact = false, + limitTokensPerWindow, +}: AccountTrendChartProps) { + const [range, setRange] = useState(defaultRange); + const [data, setData] = useState([]); + const [hoveredIndex, setHoveredIndex] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const result = await fetchRangeData(accountId, range); + if (!cancelled) setData(result); + } catch (err) { + console.warn('[AccountTrendChart] Failed to fetch usage data:', err); + } + })(); + return () => { cancelled = true; }; + }, [accountId, range]); + + const chart = useMemo(() => { + const width = compact ? 120 : 560; + const height = compact ? 24 : 160; + const paddingLeft = compact ? 0 : 48; + const paddingRight = compact ? 0 : 12; + const paddingTop = compact ? 2 : 16; + const paddingBottom = compact ? 2 : 24; + const chartWidth = width - paddingLeft - paddingRight; + const chartHeight = height - paddingTop - paddingBottom; + const maxTokens = Math.max(...data.map(d => d.totalTokens), 1); + const avgTokens = data.length > 0 + ? data.reduce((s, d) => s + d.totalTokens, 0) / data.length + : 0; + + const points = data.map((d, i) => { + const x = paddingLeft + (data.length > 1 ? (i / (data.length - 1)) * chartWidth : chartWidth / 2); + const y = paddingTop + chartHeight - (d.totalTokens / maxTokens) * chartHeight; + return { x, y, data: d }; + }); + + const linePoints = points.map(p => `${p.x},${p.y}`).join(' '); + const areaPoints = `${points.map(p => `${p.x},${p.y}`).join(' ')} ${paddingLeft + chartWidth},${paddingTop + chartHeight} ${paddingLeft},${paddingTop + chartHeight}`; + + return { width, height, paddingLeft, paddingTop, paddingBottom, chartWidth, chartHeight, maxTokens, avgTokens, points, linePoints, areaPoints }; + }, [data, compact]); + + const rangeToggle = (small: boolean) => ( +
+ {(Object.keys(TIME_RANGE_LABELS) as TimeRange[]).map(r => ( + + ))} +
+ ); + + if (data.length === 0) { + if (compact) { + return ( +
+ {rangeToggle(true)} + +
+ ); + } + return ( +
+ {rangeToggle(false)} +
+ No usage data +
+
+ ); + } + + // Compact sparkline mode + if (compact) { + return ( +
+ {rangeToggle(true)} + + + + +
+ ); + } + + // Full mode + const avgY = chart.paddingTop + chart.chartHeight - (chart.avgTokens / chart.maxTokens) * chart.chartHeight; + const hovered = hoveredIndex !== null ? chart.points[hoveredIndex] : null; + + // X-axis date labels (first, middle, last) + const dateLabels: Array<{ x: number; label: string }> = []; + if (data.length > 0) { + const indices = data.length <= 2 + ? data.map((_, i) => i) + : [0, Math.floor(data.length / 2), data.length - 1]; + for (const idx of indices) { + dateLabels.push({ + x: chart.points[idx].x, + label: formatXLabel(data[idx].label, range), + }); + } + } + + return ( +
+ {rangeToggle(false)} + setHoveredIndex(null)} + > + {/* Area fill */} + + + {/* Average line */} + + + {/* Data line */} + + + {/* Limit threshold line */} + {limitTokensPerWindow != null && limitTokensPerWindow > 0 && (() => { + const limitY = chart.paddingTop + chart.chartHeight - (limitTokensPerWindow / chart.maxTokens) * chart.chartHeight; + if (limitY < chart.paddingTop) return null; + return ( + + ); + })()} + + {/* Y-axis labels */} + + {formatTokenCount(chart.maxTokens)} + + + 0 + + + {/* X-axis labels */} + {dateLabels.map((dl, i) => ( + + {dl.label} + + ))} + + {/* Hover rects */} + {chart.points.map((p, i) => ( + setHoveredIndex(i)} + /> + ))} + + {/* Hover dot + tooltip */} + {hovered && hoveredIndex !== null && ( + <> + + 60 ? hovered.y - 52 : hovered.y + 8} + width={110} + height={44} + > +
+
{formatXLabel(hovered.data.label, range)}
+
{formatTokenCount(hovered.data.totalTokens)} tokens
+
${hovered.data.costUsd.toFixed(2)}
+
+
+ + )} +
+
+ ); +} diff --git a/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx b/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx new file mode 100644 index 000000000..fd52f2a3d --- /dev/null +++ b/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx @@ -0,0 +1,803 @@ +/** + * AccountUsageDashboard + * + * Real-time account usage monitoring panel that shows per-account token consumption, + * limit progress bars, active session assignments, and throttle history. + * Integrated as a tab within the existing Usage Dashboard. + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { Users, Activity, AlertTriangle, Zap, TrendingUp, ArrowRightLeft } from 'lucide-react'; +import { useAccountUsage } from '../../hooks/useAccountUsage'; +import { AccountTrendChart } from './AccountTrendChart'; +import type { Theme, Session } from '../../types'; +import type { + AccountProfile, + AccountUsageSnapshot, + AccountAssignment, + AccountCapacityMetrics, +} from '../../../shared/account-types'; + +interface AccountUsageDashboardProps { + theme: Theme; + sessions?: Session[]; + onClose: () => void; +} + +/** Format token counts with K/M suffixes */ +function formatTokens(n: number): string { + if (n == null || isNaN(n)) return '0'; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toString(); +} + +/** Format cost in USD */ +function formatCost(usd: number): string { + if (usd == null || isNaN(usd)) return '$0.00'; + if (usd === 0) return '$0.00'; + if (usd < 0.01) return '<$0.01'; + return `$${usd.toFixed(2)}`; +} + +/** Format remaining time from ms */ +function formatTimeRemaining(ms: number): string { + if (ms <= 0) return 'Expired'; + const hours = Math.floor(ms / 3_600_000); + const minutes = Math.floor((ms % 3_600_000) / 60_000); + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +/** Get usage color based on percentage */ +function getUsageColor(percent: number | null, theme: Theme): string { + if (percent === null) return theme.colors.accent; + if (percent > 95) return theme.colors.error; + if (percent > 80) return '#f97316'; // orange + if (percent > 60) return theme.colors.warning; + return theme.colors.success; +} + +/** Get status badge style */ +function getStatusStyle(status: string, theme: Theme): { bg: string; fg: string } { + const styles: Record = { + active: { bg: theme.colors.success + '20', fg: theme.colors.success }, + throttled: { bg: theme.colors.warning + '20', fg: theme.colors.warning }, + expired: { bg: theme.colors.error + '20', fg: theme.colors.error }, + disabled: { bg: theme.colors.error + '20', fg: theme.colors.error }, + }; + return styles[status] || styles.disabled; +} + +interface ThrottleEvent { + id: string; + timestamp: number; + accountId: string; + sessionId: string | null; + accountName?: string; + reason: string; + tokensAtThrottle: number; +} + +export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDashboardProps) { + const [accounts, setAccounts] = useState([]); + const [usageData, setUsageData] = useState>({}); + const [assignments, setAssignments] = useState([]); + const [throttleEvents, setThrottleEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const { metrics: accountMetrics } = useAccountUsage(); + + // Fetch all data on mount + const fetchData = useCallback(async () => { + try { + const [accountList, allUsage, allAssignments, events] = await Promise.all([ + window.maestro.accounts.list() as Promise, + window.maestro.accounts.getAllUsage() as Promise>, + window.maestro.accounts.getAllAssignments() as Promise, + window.maestro.accounts.getThrottleEvents() as Promise, + ]); + setAccounts(accountList); + setUsageData(allUsage || {}); + setAssignments(allAssignments || []); + setThrottleEvents(events || []); + setError(null); + } catch (err) { + console.error('Failed to fetch account usage data:', err); + setError(err instanceof Error ? err.message : 'Failed to load account data'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + + // Poll every 30 seconds + const pollInterval = setInterval(fetchData, 30_000); + + // Listen for real-time usage updates + const unsubUsage = window.maestro.accounts.onUsageUpdate((data) => { + setUsageData((prev) => { + const defaults: AccountUsageSnapshot = { + accountId: data.accountId, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + costUsd: 0, + windowStart: 0, + windowEnd: 0, + queryCount: 0, + usagePercent: null, + }; + return { + ...prev, + [data.accountId]: { + ...defaults, + ...prev[data.accountId], + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens: data.cacheReadTokens, + cacheCreationTokens: data.cacheCreationTokens, + costUsd: data.costUsd, + windowStart: data.windowStart, + windowEnd: data.windowEnd, + queryCount: data.queryCount, + usagePercent: data.usagePercent, + }, + }; + }); + }); + + return () => { + clearInterval(pollInterval); + unsubUsage(); + }; + }, [fetchData]); + + // Build session lookup map + const sessionMap = useMemo(() => { + const map = new Map(); + for (const s of sessions) { + map.set(s.id, s); + } + return map; + }, [sessions]); + + // Build account lookup map + const accountMap = useMemo(() => { + const map = new Map(); + for (const a of accounts) { + map.set(a.id, a); + } + return map; + }, [accounts]); + + // Capacity metrics (derived) + const capacityMetrics = useMemo((): AccountCapacityMetrics | null => { + if (accounts.length === 0) return null; + + const totalTokens = Object.values(usageData).reduce((sum, u) => { + return sum + (u.inputTokens || 0) + (u.outputTokens || 0) + (u.cacheReadTokens || 0); + }, 0); + + // Estimate tokens/hour based on current window + const windowMs = accounts[0]?.tokenWindowMs || 5 * 60 * 60 * 1000; + const hoursInWindow = windowMs / 3_600_000; + const avgTokensPerHour = + hoursInWindow > 0 ? Math.round(totalTokens / hoursInWindow / accounts.length) : 0; + const peakTokensPerHour = avgTokensPerHour * 1.5; // estimate + + // Recommend accounts based on usage + const maxTokensPerAccountPerHour = accounts[0]?.tokenLimitPerWindow + ? accounts[0].tokenLimitPerWindow / hoursInWindow + : 200_000; + const recommended = + maxTokensPerAccountPerHour > 0 + ? Math.max(1, Math.ceil(peakTokensPerHour / maxTokensPerAccountPerHour)) + : 1; + + return { + avgTokensPerHour, + peakTokensPerHour: Math.round(peakTokensPerHour), + throttleEvents: throttleEvents.length, + recommendedAccountCount: recommended, + analysisWindowMs: windowMs, + }; + }, [accounts, usageData, throttleEvents]); + + if (loading) { + return ( +
+ Loading virtuoso usage data... +
+ ); + } + + if (error) { + return ( +
+

Failed to load virtuoso data: {error}

+ +
+ ); + } + + if (accounts.length === 0) { + return ( +
+ +

No virtuosos registered

+

+ Add virtuosos via the Virtuosos menu to start tracking usage +

+
+ ); + } + + return ( +
+ {/* Section 1: Virtuoso Overview Cards */} +
+

+ + Virtuoso Overview +

+
+ {accounts.map((account) => { + const usage = usageData[account.id]; + const percent = usage?.usagePercent ?? null; + const totalTokens = usage + ? (usage.inputTokens || 0) + (usage.outputTokens || 0) + (usage.cacheReadTokens || 0) + : 0; + const timeRemaining = usage?.windowEnd ? usage.windowEnd - Date.now() : 0; + const activeSessionCount = assignments.filter((a) => a.accountId === account.id).length; + const usageColor = getUsageColor(percent, theme); + const statusStyle = getStatusStyle(account.status, theme); + + return ( +
+ {/* Header: name + status */} +
+
+ + {account.email || account.name} + + {account.isDefault && ( + + DEFAULT + + )} +
+ + {account.status} + +
+ + {/* Progress bar */} +
+
+ + {percent !== null ? `${Math.round(percent)}% of limit` : 'No limit set'} + + + {formatTokens(totalTokens)} + {account.tokenLimitPerWindow > 0 && ( + + {' / '} + {formatTokens(account.tokenLimitPerWindow)} + + )} + +
+
+
+
+
+ + {/* Sparkline */} +
+ +
+ + {/* Stats grid */} +
+
+
Window
+
+ {timeRemaining > 0 ? formatTimeRemaining(timeRemaining) : '—'} +
+
+
+
Sessions
+
+ {activeSessionCount} +
+
+
+
Cost
+
+ {formatCost(usage?.costUsd ?? 0)} +
+
+
+ + {/* Prediction row */} + {(() => { + const acctMetrics = accountMetrics[account.id]; + if (!acctMetrics || acctMetrics.burnRatePerHour <= 0) return null; + return ( +
+
+
Burn
+
+ ~{formatTokens(Math.round(acctMetrics.burnRatePerHour))}/hr +
+
+
+
TTL
+
+ {acctMetrics.estimatedTimeToLimitMs !== null + ? formatTimeRemaining(acctMetrics.estimatedTimeToLimitMs) + : '—'} +
+
+
+
Confidence
+
+ {acctMetrics.prediction.confidence} +
+
+
+ ); + })()} +
+ ); + })} +
+
+ + {/* Section 2: Active Assignments Table */} +
+

+ + Active Assignments +

+ {assignments.length === 0 ? ( +
+ No active account assignments +
+ ) : ( +
+ + + + + + + + + + + + {assignments.map((assignment) => { + const session = sessionMap.get(assignment.sessionId); + const account = accountMap.get(assignment.accountId); + return ( + + + + + + + + ); + })} + +
+ Session + + Account + + Agent + + Assigned + + Status +
+ {session?.name || assignment.sessionId.slice(0, 8)} + + {account?.email || assignment.accountId.slice(0, 8)} + + {session?.toolType || '—'} + + {new Date(assignment.assignedAt).toLocaleTimeString()} + + + {session?.state || 'unknown'} + +
+
+ )} +
+ + {/* Section 3: Usage Timeline (simplified — bar chart per account) */} +
+

+ + Usage by Account +

+
+ {accounts.map((account) => { + const usage = usageData[account.id]; + const totalTokens = usage + ? (usage.inputTokens || 0) + (usage.outputTokens || 0) + (usage.cacheReadTokens || 0) + : 0; + const maxTokens = Math.max( + ...accounts.map((a) => { + const u = usageData[a.id]; + return u + ? (u.inputTokens || 0) + (u.outputTokens || 0) + (u.cacheReadTokens || 0) + : 0; + }), + 1 + ); + const barPercent = (totalTokens / maxTokens) * 100; + const usageColor = getUsageColor(usage?.usagePercent ?? null, theme); + + return ( +
+ + {(account.email || account.name).split('@')[0]} + +
+
+
+ + {formatTokens(totalTokens)} + +
+
+ {/* Limit line */} + {account.tokenLimitPerWindow > 0 && ( +
+ )} +
+ {(() => { + const m = accountMetrics[account.id]; + if (!m?.rateMetrics) return null; + const trendSymbol = + m.rateMetrics.trend === 'up' + ? '\u2197' + : m.rateMetrics.trend === 'down' + ? '\u2198' + : '\u2192'; + const trendColor = + m.rateMetrics.trend === 'up' + ? theme.colors.warning + : m.rateMetrics.trend === 'down' + ? theme.colors.success + : theme.colors.textDim; + return ( + + {trendSymbol} {formatTokens(Math.round(m.rateMetrics.tokensPerDay))}/day + + ); + })()} +
+ ); + })} +
+
+ + {/* Section 4: Throttle History */} +
+

+ + Throttle History +

+ {throttleEvents.length === 0 ? ( +
+ No throttle events recorded +
+ ) : ( +
+ + + + + + + + + + + {throttleEvents + .slice() + .reverse() + .map((event, i) => { + const account = accountMap.get(event.accountId); + return ( + + + + + + + ); + })} + +
+ Time + + Account + + Reason + + Tokens +
+ {new Date(event.timestamp).toLocaleString()} + + {event.accountName || account?.email || event.accountId.slice(0, 8)} + + + {event.reason.replace(/_/g, ' ')} + + + {formatTokens(event.tokensAtThrottle)} +
+
+ )} +
+ + {/* Section 5: Capacity Recommendation */} + {capacityMetrics && ( +
+

+ + Capacity Recommendation +

+
+

+ Based on your usage in the current window: +

+
+
+
Avg tokens/hour
+
+ {formatTokens(capacityMetrics.avgTokensPerHour)} +
+
+
+
Peak tokens/hour
+
+ {formatTokens(capacityMetrics.peakTokensPerHour)} +
+
+
+
Throttle events
+
0 + ? theme.colors.warning + : theme.colors.textMain, + }} + > + {capacityMetrics.throttleEvents} +
+
+
+
Recommended accounts
+
+ {capacityMetrics.recommendedAccountCount} + {capacityMetrics.recommendedAccountCount > accounts.length && ( + + (need {capacityMetrics.recommendedAccountCount - accounts.length} more) + + )} +
+
+
+
+
+ )} +
+ ); +} diff --git a/src/renderer/components/UsageDashboard/ChartSkeletons.tsx b/src/renderer/components/UsageDashboard/ChartSkeletons.tsx index dde602b9d..83283136c 100644 --- a/src/renderer/components/UsageDashboard/ChartSkeletons.tsx +++ b/src/renderer/components/UsageDashboard/ChartSkeletons.tsx @@ -347,7 +347,7 @@ export function DashboardSkeleton({ summaryCardsCols = 5, autoRunStatsCols = 6, }: SkeletonProps & { - viewMode?: 'overview' | 'agents' | 'activity' | 'autorun'; + viewMode?: 'overview' | 'agents' | 'activity' | 'autorun' | 'accounts'; chartGridCols?: number; summaryCardsCols?: number; autoRunStatsCols?: number; diff --git a/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx b/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx index 00091e2fc..95ce5cf20 100644 --- a/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx +++ b/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx @@ -29,6 +29,7 @@ import { AgentEfficiencyChart } from './AgentEfficiencyChart'; import { WeekdayComparisonChart } from './WeekdayComparisonChart'; import { TasksByHourChart } from './TasksByHourChart'; import { LongestAutoRunsTable } from './LongestAutoRunsTable'; +import { AccountUsageDashboard } from './AccountUsageDashboard'; import { EmptyState } from './EmptyState'; import { DashboardSkeleton } from './ChartSkeletons'; import { ChartErrorBoundary } from './ChartErrorBoundary'; @@ -86,7 +87,7 @@ interface StatsAggregation { } // View mode options for the dashboard -type ViewMode = 'overview' | 'agents' | 'activity' | 'autorun'; +type ViewMode = 'overview' | 'agents' | 'activity' | 'autorun' | 'accounts'; interface UsageDashboardModalProps { isOpen: boolean; @@ -133,6 +134,7 @@ const VIEW_MODE_TABS: { value: ViewMode; label: string }[] = [ { value: 'agents', label: 'Agents' }, { value: 'activity', label: 'Activity' }, { value: 'autorun', label: 'Auto Run' }, + { value: 'accounts', label: 'Virtuosos' }, ]; export function UsageDashboardModal({ @@ -1180,6 +1182,14 @@ export function UsageDashboardModal({
)} + + {viewMode === 'accounts' && ( + + )}
)}
diff --git a/src/renderer/components/VirtuosoUsageView.tsx b/src/renderer/components/VirtuosoUsageView.tsx new file mode 100644 index 000000000..b47ac845f --- /dev/null +++ b/src/renderer/components/VirtuosoUsageView.tsx @@ -0,0 +1,629 @@ +/** + * VirtuosoUsageView - Usage tab content for the VirtuososModal + * + * Presents account usage data in three sections: + * A) Current Window Overview — aggregate summary + per-account usage cards + * B) Predictions — linear/P90 time-to-limit estimates + * C) Historical — per-account expandable history + throttle event timeline + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { + Activity, + AlertTriangle, + ChevronDown, + ChevronRight, + Clock, + TrendingUp, + Users, + BarChart3, +} from 'lucide-react'; +import type { Theme, Session } from '../types'; +import type { AccountProfile } from '../../shared/account-types'; +import { useAccountUsage, formatTimeRemaining, formatTokenCount } from '../hooks/useAccountUsage'; +import { AccountUsageHistory } from './AccountUsageHistory'; +import { AccountTrendChart } from './UsageDashboard/AccountTrendChart'; +import { AccountRateMetrics } from './UsageDashboard/AccountRateMetrics'; + +interface ThrottleEvent { + id: string; + timestamp: number; + accountId: string; + sessionId: string | null; + accountName?: string; + reason: string; + tokensAtThrottle: number; +} + +interface VirtuosoUsageViewProps { + theme: Theme; + sessions?: Session[]; +} + +export function VirtuosoUsageView({ theme, sessions }: VirtuosoUsageViewProps) { + const { metrics, loading } = useAccountUsage(); + const [accounts, setAccounts] = useState([]); + const [throttleEvents, setThrottleEvents] = useState([]); + const [expandedAccountId, setExpandedAccountId] = useState(null); + + const fetchData = useCallback(async () => { + try { + const [accountList, events] = await Promise.all([ + window.maestro.accounts.list() as Promise, + window.maestro.accounts.getThrottleEvents() as Promise, + ]); + setAccounts(accountList || []); + setThrottleEvents(events || []); + } catch (err) { + console.warn('[VirtuosoUsageView] Failed to fetch data:', err); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // Derive aggregate stats from metrics + const metricsArray = useMemo(() => Object.values(metrics), [metrics]); + + const activeCount = useMemo(() => { + return accounts.filter((a) => a.status === 'active').length; + }, [accounts]); + + const totalTokensThisWindow = useMemo(() => { + return metricsArray.reduce((sum, m) => sum + m.totalTokens, 0); + }, [metricsArray]); + + const totalCostThisWindow = useMemo(() => { + return metricsArray.reduce((sum, m) => sum + m.costUsd, 0); + }, [metricsArray]); + + // Count sessions per account + const sessionCountByAccount = useMemo(() => { + if (!sessions) return {}; + const counts: Record = {}; + for (const s of sessions) { + if (s.accountId) { + counts[s.accountId] = (counts[s.accountId] || 0) + 1; + } + } + return counts; + }, [sessions]); + + // Accounts with token limits configured (for predictions section) + const accountsWithLimits = useMemo(() => { + return metricsArray.filter((m) => m.limitTokens > 0); + }, [metricsArray]); + + if (loading && accounts.length === 0) { + return ( +
+ + + Loading usage data... + +
+ ); + } + + if (accounts.length === 0) { + return ( +
+ +

+ No Virtuoso accounts configured. +

+

+ Switch to the Configuration tab to add accounts. +

+
+ ); + } + + return ( +
+ {/* Section A: Aggregate Summary */} +
+
+
+ {accounts.length} +
+
+ Virtuosos +
+
+
+
+ {activeCount} +
+
+ Active +
+
+
+
+ {formatTokenCount(totalTokensThisWindow)} +
+
+ Tokens This Window +
+
+
+
+ ${totalCostThisWindow.toFixed(2)} +
+
+ Cost This Window +
+
+
+ + {/* Section A: Per-Account Usage Cards */} +
+

+ + Current Window +

+
+ {accounts.map((account) => { + const usage = metrics[account.id]; + const severityColor = getSeverityColor(usage?.usagePercent, theme); + const sessionCount = sessionCountByAccount[account.id] || 0; + + return ( +
+ {/* Header */} +
+
+ + {account.name || account.email} + + + {account.status} + +
+ {sessions && sessionCount > 0 && ( + + {sessionCount} session{sessionCount !== 1 ? 's' : ''} + + )} +
+ + {usage ? ( + <> + {/* Usage bar */} + {usage.limitTokens > 0 && ( +
+
+
+
+
+ + {formatTokenCount(usage.totalTokens)} /{' '} + {formatTokenCount(usage.limitTokens)} + + + {usage.usagePercent?.toFixed(0) ?? 0}% + +
+
+ )} + + {/* Token breakdown */} +
+
+ In:{' '} + + {formatTokenCount(usage.inputTokens)} + +
+
+ Out:{' '} + + {formatTokenCount(usage.outputTokens)} + +
+
+ Cache R:{' '} + + {formatTokenCount(usage.cacheReadTokens)} + +
+
+ Cache W:{' '} + + {formatTokenCount(usage.cacheCreationTokens)} + +
+
+ + {/* Metrics grid */} +
+ {usage.limitTokens === 0 && ( +
+ Total:{' '} + + {formatTokenCount(usage.totalTokens)} + +
+ )} +
+ Cost:{' '} + + ${usage.costUsd.toFixed(2)} + +
+
+ Queries:{' '} + {usage.queryCount} +
+
+ Burn:{' '} + + ~{formatTokenCount(Math.round(usage.burnRatePerHour))}/hr + + {usage?.rateMetrics?.trend && usage.rateMetrics.trend !== 'stable' && ( + + {usage.rateMetrics.trend === 'up' ? '\u2197' : '\u2198'} + + )} +
+ {usage.estimatedTimeToLimitMs !== null && ( +
+ TTL:{' '} + + {formatTimeRemaining(usage.estimatedTimeToLimitMs)} + +
+ )} +
+ Reset:{' '} + + {formatTimeRemaining(usage.timeRemainingMs)} + +
+
+ + {/* 7-day sparkline */} + {usage && ( +
+ +
+ )} + + ) : ( +

+ No usage data for current window +

+ )} +
+ ); + })} +
+
+ + {/* Section B: Predictions */} + {accountsWithLimits.length > 0 && ( +
+

+ + Predictions +

+
+ {accountsWithLimits.map((usage) => { + const account = accounts.find((a) => a.id === usage.accountId); + if (!account) return null; + const pred = usage.prediction; + + return ( +
+
+ + {account.name || account.email} + + + {pred.confidence} confidence + +
+
+
+ Linear TTL:{' '} + + {pred.linearTimeToLimitMs + ? formatTimeRemaining(pred.linearTimeToLimitMs) + : '—'} + +
+
+ P90 est:{' '} + + {pred.weightedTimeToLimitMs + ? formatTimeRemaining(pred.weightedTimeToLimitMs) + : '—'} + +
+
+ Avg/window:{' '} + + {formatTokenCount(Math.round(pred.avgTokensPerWindow))} + +
+
+ Windows remaining (P90):{' '} + + {pred.windowsRemainingP90 !== null + ? pred.windowsRemainingP90.toFixed(1) + : '—'} + +
+
+
+ ); + })} + + {/* Aggregate exhaustion warning */} + {(() => { + const exhaustingSoon = accountsWithLimits.filter( + (m) => + m.prediction.linearTimeToLimitMs !== null && + m.prediction.linearTimeToLimitMs < 24 * 60 * 60 * 1000 + ); + if (exhaustingSoon.length === 0) return null; + const soonestMs = Math.min( + ...exhaustingSoon.map((m) => m.prediction.linearTimeToLimitMs!) + ); + return ( +
+ + + At current rates, {exhaustingSoon.length} account + {exhaustingSoon.length !== 1 ? 's' : ''} will reach limit within{' '} + {formatTimeRemaining(soonestMs)} + +
+ ); + })()} +
+
+ )} + + {/* Section B.5: Trends & Analytics */} + {accounts.length > 0 && ( +
+

+ + Trends & Analytics +

+
+ {accounts.map((account) => { + const usage = metrics[account.id]; + if (!usage) return null; + return ( +
+
+ {account.name || account.email} +
+ + {usage.rateMetrics && ( +
+ +
+ )} +
+ ); + })} +
+
+ )} + + {/* Section C: Historical Usage */} +
+

+ + Historical Usage +

+
+ {accounts.map((account) => ( +
+ + {expandedAccountId === account.id && ( +
+ +
+ )} +
+ ))} +
+
+ + {/* Section C: Throttle Event Timeline */} +
+

+ + Recent Throttle Events +

+ {throttleEvents.length === 0 ? ( +

+ No throttle events recorded +

+ ) : ( +
+ {throttleEvents.slice(0, 20).map((event, i) => ( +
+ + {new Date(event.timestamp).toLocaleString()} + + + {event.accountName || event.accountId} + + + {formatTokenCount(event.tokensAtThrottle)} tokens + +
+ ))} +
+ )} +
+
+ ); +} + +// Helpers + +function getSeverityColor(usagePercent: number | null | undefined, theme: Theme): string { + if (usagePercent == null) return theme.colors.accent; + if (usagePercent > 80) return theme.colors.error; + if (usagePercent > 60) return theme.colors.warning; + return theme.colors.success; +} + +function getStatusColor(status: string, theme: Theme): { bg: string; fg: string } { + const styles: Record = { + active: { bg: theme.colors.success + '20', fg: theme.colors.success }, + throttled: { bg: theme.colors.warning + '20', fg: theme.colors.warning }, + disabled: { bg: theme.colors.error + '20', fg: theme.colors.error }, + }; + return styles[status] || styles.disabled; +} diff --git a/src/renderer/components/VirtuososModal.tsx b/src/renderer/components/VirtuososModal.tsx new file mode 100644 index 000000000..6290369ac --- /dev/null +++ b/src/renderer/components/VirtuososModal.tsx @@ -0,0 +1,147 @@ +/** + * VirtuososModal - Standalone modal for account (Virtuoso) management + * + * Three-tab layout: + * 1. Accounts — AccountsPanel (account CRUD, discovery, plan presets, auto-switch) + * 2. Providers — ProviderPanel (provider status, failover config, migration history) + * 3. Usage — VirtuosoUsageView (real-time metrics, predictions, history, throttle events) + */ + +import { useState, useEffect } from 'react'; +import { Users, Settings, BarChart3, ArrowRightLeft } from 'lucide-react'; +import { AccountsPanel } from './AccountsPanel'; +import { ProviderPanel } from './ProviderPanel'; +import { VirtuosoUsageView } from './VirtuosoUsageView'; +import { Modal } from './ui/Modal'; +import { MODAL_PRIORITIES } from '../constants/modalPriorities'; +import { useProviderHealth } from '../hooks/useProviderHealth'; +import type { Theme, Session } from '../types'; + +type VirtuosoTab = 'config' | 'providers' | 'usage'; + +const VIRTUOSO_TABS: { value: VirtuosoTab; label: string; icon: typeof Settings }[] = [ + { value: 'config', label: 'Accounts', icon: Settings }, + { value: 'providers', label: 'Providers', icon: ArrowRightLeft }, + { value: 'usage', label: 'Usage', icon: BarChart3 }, +]; + +interface VirtuososModalProps { + isOpen: boolean; + onClose: () => void; + theme: Theme; + sessions?: Session[]; + onSelectSession?: (sessionId: string) => void; +} + +export function VirtuososModal({ isOpen, onClose, theme, sessions, onSelectSession }: VirtuososModalProps) { + const [activeTab, setActiveTab] = useState('config'); + const { hasDegradedProvider, hasFailingProvider } = useProviderHealth(sessions); + + // Keyboard navigation: Cmd/Ctrl+Shift+[ and Cmd/Ctrl+Shift+] + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === '[' || e.key === ']')) { + e.preventDefault(); + e.stopPropagation(); + const currentIndex = VIRTUOSO_TABS.findIndex((t) => t.value === activeTab); + if (e.key === '[') { + const prev = currentIndex > 0 ? currentIndex - 1 : VIRTUOSO_TABS.length - 1; + setActiveTab(VIRTUOSO_TABS[prev].value); + } else { + const next = + currentIndex < VIRTUOSO_TABS.length - 1 ? currentIndex + 1 : 0; + setActiveTab(VIRTUOSO_TABS[next].value); + } + } + }; + + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [isOpen, activeTab]); + + if (!isOpen) return null; + + const modalWidth = activeTab === 'usage' ? 900 : activeTab === 'providers' ? 860 : 720; + + return ( + } + width={modalWidth} + closeOnBackdropClick + > +
+

+ AI Provider Accounts +

+
+ + {/* Tab bar */} +
+ {VIRTUOSO_TABS.map((tab) => { + const Icon = tab.icon; + return ( + + ); + })} +
+ + {/* Tab content */} + {activeTab === 'config' && } + {activeTab === 'providers' && } + {activeTab === 'usage' && } +
+ ); +} diff --git a/src/renderer/constants/modalPriorities.ts b/src/renderer/constants/modalPriorities.ts index a8222eb46..83fab903d 100644 --- a/src/renderer/constants/modalPriorities.ts +++ b/src/renderer/constants/modalPriorities.ts @@ -29,6 +29,12 @@ export const MODAL_PRIORITIES = { /** Agent error modal - critical, shows recovery options */ AGENT_ERROR: 1010, + /** Account switch confirmation modal - triggered by throttle/limit events */ + ACCOUNT_SWITCH: 1005, + + /** Provider switch modal - Virtuosos vertical swapping between agent types */ + PROVIDER_SWITCH: 1003, + /** Confirmation dialogs - highest priority, always on top */ CONFIRM: 1000, @@ -206,6 +212,9 @@ export const MODAL_PRIORITIES = { /** SSH Remote configuration modal (above settings) */ SSH_REMOTE: 460, + /** Virtuosos (account management) modal */ + VIRTUOSOS: 455, + /** Settings modal */ SETTINGS: 450, diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index e9b113b99..45394c52c 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -36,6 +36,8 @@ interface ProcessConfig { sessionCustomEnvVars?: Record; sessionCustomModel?: string; sessionCustomContextWindow?: number; + // Account multiplexing + accountId?: string; // Per-session SSH remote config (takes precedence over agent-level SSH config) sessionSshRemoteConfig?: { enabled: boolean; @@ -179,6 +181,8 @@ interface MaestroAPI { customPath?: string; customArgs?: string; customEnvVars?: Record; + // Account multiplexing + accountId?: string; } ) => Promise; // Cancel all active grooming sessions @@ -2140,6 +2144,11 @@ interface MaestroAPI { projectPath?: string; tabId?: string; isRemote?: boolean; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheCreationTokens?: number; + costUsd?: number; }) => Promise; // Start an Auto Run session (returns session ID) startAutoRun: (session: { @@ -2182,6 +2191,12 @@ interface MaestroAPI { duration: number; projectPath?: string; tabId?: string; + isRemote?: boolean; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheCreationTokens?: number; + costUsd?: number; }> >; // Get Auto Run sessions within a time range @@ -2635,6 +2650,60 @@ interface MaestroAPI { }) => Promise; }; + // Account Multiplexing API + accounts: { + list: () => Promise; + get: (id: string) => Promise; + add: (params: { name: string; email: string; configDir: string; agentType?: string }) => Promise; + update: (id: string, updates: Record) => Promise; + remove: (id: string) => Promise; + setDefault: (id: string) => Promise; + assign: (sessionId: string, accountId: string) => Promise; + getAssignment: (sessionId: string) => Promise; + getAllAssignments: () => Promise; + getUsage: (accountId: string, windowStart: number, windowEnd: number) => Promise; + getAllUsage: () => Promise; + getThrottleEvents: (accountId?: string, since?: number) => Promise; + getDailyUsage: (accountId: string, days?: number) => Promise; + getMonthlyUsage: (accountId: string, months?: number) => Promise; + getWindowHistory: (accountId: string, windowCount?: number) => Promise; + getSwitchConfig: () => Promise; + updateSwitchConfig: (updates: Record) => Promise; + getDefault: () => Promise; + selectNext: (excludeIds?: string[]) => Promise; + validateBaseDir: () => Promise<{ valid: boolean; baseDir: string; errors: string[] }>; + discoverExisting: () => Promise>; + createDirectory: (name: string) => Promise<{ success: boolean; configDir: string; error?: string }>; + validateSymlinks: (configDir: string) => Promise<{ valid: boolean; broken: string[]; missing: string[] }>; + repairSymlinks: (configDir: string) => Promise<{ repaired: string[]; errors: string[] }>; + readEmail: (configDir: string) => Promise; + getLoginCommand: (configDir: string) => Promise; + removeDirectory: (configDir: string) => Promise<{ success: boolean; error?: string }>; + validateRemoteDir: (params: { sshConfig: { host: string; user?: string; port?: number }; configDir: string }) => Promise<{ exists: boolean; hasAuth: boolean; symlinksValid: boolean; error?: string }>; + syncCredentials: (configDir: string) => Promise<{ success: boolean; error?: string }>; + onUsageUpdate: (handler: (data: { accountId: string; usagePercent: number | null; totalTokens: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number; limitTokens: number; windowStart: number; windowEnd: number; queryCount: number; costUsd: number }) => void) => () => void; + onLimitWarning: (handler: (data: { accountId: string; accountName: string; usagePercent: number; sessionId: string }) => void) => () => void; + onLimitReached: (handler: (data: { accountId: string; accountName: string; usagePercent: number; sessionId: string }) => void) => () => void; + onThrottled: (handler: (data: { accountId: string; accountName: string; sessionId: string; reason: string; message: string; tokensAtThrottle: number; autoSwitchAvailable: boolean; noAlternatives?: boolean }) => void) => () => void; + onSwitchPrompt: (handler: (data: { sessionId: string; fromAccountId: string; fromAccountName: string; toAccountId: string; toAccountName: string; reason: string; tokensAtThrottle: number }) => void) => () => void; + onSwitchExecute: (handler: (data: { sessionId: string; fromAccountId: string; fromAccountName: string; toAccountId: string; toAccountName: string; reason: string; automatic: boolean }) => void) => () => void; + onStatusChanged: (handler: (data: { accountId: string; accountName: string; oldStatus: string; newStatus: string; recoveredBy?: string }) => void) => () => void; + onAssigned: (handler: (data: { sessionId: string; accountId: string; accountName: string }) => void) => () => void; + reconcileSessions: (activeSessionIds: string[]) => Promise<{ success: boolean; removed: number; corrections: Array<{ sessionId: string; accountId: string | null; accountName: string | null; configDir: string | null; status: 'valid' | 'removed' | 'inactive' }>; error?: string }>; + cleanupSession: (sessionId: string) => Promise<{ success: boolean; error?: string }>; + executeSwitch: (params: { sessionId: string; fromAccountId: string; toAccountId: string; reason: string; automatic: boolean }) => Promise<{ success: boolean; event?: unknown; error?: string }>; + onSwitchStarted: (handler: (data: { sessionId: string; fromAccountId: string; toAccountId: string; toAccountName: string }) => void) => () => void; + onSwitchRespawn: (handler: (data: { sessionId: string; toAccountId: string; toAccountName: string; configDir: string; lastPrompt: string | null; reason: string }) => void) => () => void; + onSwitchCompleted: (handler: (data: { sessionId: string; fromAccountId: string; toAccountId: string; reason: string; automatic: boolean; timestamp: number; fromAccountName: string; toAccountName: string }) => void) => () => void; + onSwitchFailed: (handler: (data: { sessionId: string; fromAccountId: string; toAccountId: string; error: string }) => void) => () => void; + triggerAuthRecovery: (sessionId: string) => Promise<{ success: boolean; error?: string }>; + onAuthRecoveryStarted: (handler: (data: { sessionId: string; accountId: string; accountName: string }) => void) => () => void; + onAuthRecoveryCompleted: (handler: (data: { sessionId: string; accountId: string; accountName: string }) => void) => () => void; + onAuthRecoveryFailed: (handler: (data: { sessionId: string; accountId: string; accountName?: string; error: string }) => void) => () => void; + onRecoveryAvailable: (handler: (data: { recoveredAccountIds: string[]; recoveredCount: number; stillThrottledCount: number; totalAccounts: number }) => void) => () => void; + checkRecovery: () => Promise<{ recovered: string[] }>; + }; + // Director's Notes API (unified history + synopsis generation) directorNotes: { getUnifiedHistory: (options: { @@ -2698,6 +2767,38 @@ interface MaestroAPI { }>; }; + // Provider Error Tracking API (error stats, failover suggestions) + providers: { + getErrorStats: (toolType: string) => Promise<{ + toolType: string; + activeErrorCount: number; + totalErrorsInWindow: number; + lastErrorAt: number | null; + sessionsWithErrors: number; + } | null>; + getAllErrorStats: () => Promise>; + clearSessionErrors: (sessionId: string) => Promise; + onFailoverSuggest: (handler: (data: { + sessionId: string; + sessionName: string; + currentProvider: string; + suggestedProvider: string; + errorCount: number; + windowMs: number; + recentErrors: Array<{ + type: string; + message: string; + timestamp: number; + }>; + }) => void) => () => void; + }; + // WakaTime API (CLI check, API key validation) wakatime: { checkCli: () => Promise<{ available: boolean; version?: string }>; diff --git a/src/renderer/hooks/agent/__tests__/useProviderSwitch.test.ts b/src/renderer/hooks/agent/__tests__/useProviderSwitch.test.ts new file mode 100644 index 000000000..14dc991c3 --- /dev/null +++ b/src/renderer/hooks/agent/__tests__/useProviderSwitch.test.ts @@ -0,0 +1,389 @@ +/** + * Tests for useProviderSwitch helpers. + * Validates findArchivedPredecessor provenance-chain walking and + * merge-back session reactivation logic. + */ + +import { describe, it, expect } from 'vitest'; +import { findArchivedPredecessor } from '../useProviderSwitch'; +import type { Session, ToolType } from '../../../types'; + +// --------------------------------------------------------------------------- +// Minimal session factory — only the fields findArchivedPredecessor touches +// --------------------------------------------------------------------------- + +function makeSession(overrides: Partial & { id: string; toolType: ToolType }): Session { + return { + name: overrides.id, + groupId: undefined, + state: 'idle', + cwd: '/tmp', + fullPath: '/tmp', + projectRoot: '/tmp', + aiLogs: [], + shellLogs: [], + workLog: [], + contextUsage: 0, + inputMode: 'ai', + aiPid: 0, + terminalPid: 0, + port: 3000, + isLive: false, + changedFiles: [], + isGitRepo: false, + fileTree: [], + fileExplorerExpanded: [], + fileExplorerScrollPos: 0, + executionQueue: [], + activeTimeMs: 0, + aiTabs: [], + activeTabId: '', + closedTabHistory: [], + filePreviewTabs: [], + activeFileTabId: null, + unifiedTabOrder: [], + unifiedClosedTabHistory: [], + ...overrides, + } as Session; +} + +// --------------------------------------------------------------------------- +// findArchivedPredecessor +// --------------------------------------------------------------------------- + +describe('findArchivedPredecessor', () => { + it('should return null when there is no provenance chain', () => { + const current = makeSession({ id: 'A', toolType: 'claude-code' }); + const result = findArchivedPredecessor([current], current, 'codex'); + expect(result).toBeNull(); + }); + + it('should return null when chain exists but no archived predecessor matches', () => { + const original = makeSession({ + id: 'original', + toolType: 'claude-code', + // Not archived + }); + const current = makeSession({ + id: 'current', + toolType: 'codex', + migratedFromSessionId: 'original', + }); + const sessions = [original, current]; + + const result = findArchivedPredecessor(sessions, current, 'claude-code'); + expect(result).toBeNull(); + }); + + it('should find an archived predecessor matching the target provider', () => { + const original = makeSession({ + id: 'original', + toolType: 'claude-code', + archivedByMigration: true, + migratedToSessionId: 'current', + }); + const current = makeSession({ + id: 'current', + toolType: 'codex', + migratedFromSessionId: 'original', + }); + const sessions = [original, current]; + + const result = findArchivedPredecessor(sessions, current, 'claude-code'); + expect(result).not.toBeNull(); + expect(result!.id).toBe('original'); + }); + + it('should skip non-archived predecessors in the chain', () => { + const grandparent = makeSession({ + id: 'gp', + toolType: 'claude-code', + archivedByMigration: true, + migratedToSessionId: 'parent', + }); + const parent = makeSession({ + id: 'parent', + toolType: 'claude-code', + migratedFromSessionId: 'gp', + // NOT archived — was reactivated + }); + const current = makeSession({ + id: 'current', + toolType: 'codex', + migratedFromSessionId: 'parent', + }); + const sessions = [grandparent, parent, current]; + + // parent is claude-code but NOT archived, so it should skip to grandparent + const result = findArchivedPredecessor(sessions, current, 'claude-code'); + expect(result).not.toBeNull(); + expect(result!.id).toBe('gp'); + }); + + it('should return the first archived match walking backwards', () => { + const gp = makeSession({ + id: 'gp', + toolType: 'claude-code', + archivedByMigration: true, + }); + const parent = makeSession({ + id: 'parent', + toolType: 'claude-code', + archivedByMigration: true, + migratedFromSessionId: 'gp', + }); + const current = makeSession({ + id: 'current', + toolType: 'codex', + migratedFromSessionId: 'parent', + }); + const sessions = [gp, parent, current]; + + // Should find 'parent' first (closest archived predecessor) + const result = findArchivedPredecessor(sessions, current, 'claude-code'); + expect(result).not.toBeNull(); + expect(result!.id).toBe('parent'); + }); + + it('should not return the current session even if it matches', () => { + const current = makeSession({ + id: 'current', + toolType: 'claude-code', + archivedByMigration: true, // Matches everything — but is the current session + }); + const sessions = [current]; + + const result = findArchivedPredecessor(sessions, current, 'claude-code'); + expect(result).toBeNull(); + }); + + it('should handle cycles in the provenance chain without infinite loop', () => { + const a = makeSession({ + id: 'A', + toolType: 'claude-code', + archivedByMigration: false, + migratedFromSessionId: 'B', + }); + const b = makeSession({ + id: 'B', + toolType: 'codex', + archivedByMigration: false, + migratedFromSessionId: 'A', // Cycle: B -> A -> B -> ... + }); + const sessions = [a, b]; + + // Should terminate without hanging + const result = findArchivedPredecessor(sessions, a, 'codex'); + expect(result).toBeNull(); // B is not archived + }); + + it('should handle a missing session in the chain gracefully', () => { + const current = makeSession({ + id: 'current', + toolType: 'codex', + migratedFromSessionId: 'deleted-session', // Not in the sessions array + }); + const sessions = [current]; + + const result = findArchivedPredecessor(sessions, current, 'claude-code'); + expect(result).toBeNull(); + }); + + it('should skip predecessors with wrong toolType', () => { + const original = makeSession({ + id: 'original', + toolType: 'opencode', // Wrong provider type + archivedByMigration: true, + migratedToSessionId: 'current', + }); + const current = makeSession({ + id: 'current', + toolType: 'codex', + migratedFromSessionId: 'original', + }); + const sessions = [original, current]; + + // Looking for claude-code, but predecessor is opencode + const result = findArchivedPredecessor(sessions, current, 'claude-code'); + expect(result).toBeNull(); + }); + + it('should walk a multi-hop chain to find a distant predecessor', () => { + // Chain: D (current, codex) -> C (opencode, not archived) -> B (codex, archived) -> A (claude-code, archived) + const a = makeSession({ + id: 'A', + toolType: 'claude-code', + archivedByMigration: true, + }); + const b = makeSession({ + id: 'B', + toolType: 'codex', + archivedByMigration: true, + migratedFromSessionId: 'A', + }); + const c = makeSession({ + id: 'C', + toolType: 'opencode', + archivedByMigration: false, // Not archived + migratedFromSessionId: 'B', + }); + const d = makeSession({ + id: 'D', + toolType: 'codex', + migratedFromSessionId: 'C', + }); + const sessions = [a, b, c, d]; + + // Looking for claude-code: should walk D -> C -> B -> A and find A + const result = findArchivedPredecessor(sessions, d, 'claude-code'); + expect(result).not.toBeNull(); + expect(result!.id).toBe('A'); + }); +}); + +// --------------------------------------------------------------------------- +// Merge-back session reactivation logic +// --------------------------------------------------------------------------- + +describe('merge-back session construction', () => { + it('reactivated session preserves original identity fields', () => { + const archived = makeSession({ + id: 'original-id', + toolType: 'claude-code', + name: 'My Project', + cwd: '/home/user/project', + projectRoot: '/home/user/project', + groupId: 'group-1', + bookmarked: true, + archivedByMigration: true, + migrationGeneration: 1, + migratedToSessionId: 'source-id', + aiTabs: [{ + id: 'tab-1', + agentSessionId: null, + name: null, + starred: false, + logs: [{ id: 'log-1', timestamp: 1000, source: 'user' as const, text: 'hello' }], + inputValue: '', + stagedImages: [], + createdAt: 1000, + state: 'idle' as const, + saveToHistory: true, + showThinking: 'off' as const, + }], + }); + + const sourceSession = makeSession({ + id: 'source-id', + toolType: 'codex', + name: 'My Project', + migratedFromSessionId: 'original-id', + }); + + // Simulate the reactivation spread from the hook + const reactivated: Session = { + ...archived, + archivedByMigration: false, + migratedFromSessionId: sourceSession.id, + migratedAt: Date.now(), + migrationGeneration: (archived.migrationGeneration || 0) + 1, + migratedToSessionId: undefined, + lastMergeBackAt: Date.now(), + }; + + // Identity preserved + expect(reactivated.id).toBe('original-id'); + expect(reactivated.name).toBe('My Project'); + expect(reactivated.cwd).toBe('/home/user/project'); + expect(reactivated.projectRoot).toBe('/home/user/project'); + expect(reactivated.groupId).toBe('group-1'); + expect(reactivated.bookmarked).toBe(true); + expect(reactivated.toolType).toBe('claude-code'); + + // Provenance updated + expect(reactivated.archivedByMigration).toBe(false); + expect(reactivated.migratedFromSessionId).toBe('source-id'); + expect(reactivated.migrationGeneration).toBe(2); + expect(reactivated.migratedToSessionId).toBeUndefined(); + expect(reactivated.lastMergeBackAt).toBeGreaterThan(0); + }); + + it('context logs are appended with separator to existing tab logs', () => { + const existingLog = { id: 'existing-1', timestamp: 1000, source: 'user' as const, text: 'original context' }; + const archived = makeSession({ + id: 'original-id', + toolType: 'claude-code', + archivedByMigration: true, + aiTabs: [{ + id: 'tab-1', + agentSessionId: null, + name: null, + starred: false, + logs: [existingLog], + inputValue: '', + stagedImages: [], + createdAt: 1000, + state: 'idle' as const, + saveToHistory: true, + showThinking: 'off' as const, + }], + }); + + const newContextLogs = [ + { id: 'new-1', timestamp: 2000, source: 'user' as const, text: 'new context from codex' }, + ]; + + // Simulate the merge-back append logic from the hook + const reactivated = { ...archived, archivedByMigration: false }; + const mergeTab = { ...reactivated.aiTabs[0] }; + + const separator = { + id: `merge-separator-${Date.now()}`, + timestamp: Date.now(), + source: 'system' as const, + text: '── Context merged from Codex session ──', + }; + + const switchNotice = { + id: `provider-switch-notice-${Date.now()}`, + timestamp: Date.now(), + source: 'system' as const, + text: 'Provider switched back from Codex to Claude Code. Context groomed and optimized.', + }; + + mergeTab.logs = [...mergeTab.logs, separator, switchNotice, ...newContextLogs]; + + // Original log preserved at start + expect(mergeTab.logs[0]).toBe(existingLog); + // Separator added + expect(mergeTab.logs[1].source).toBe('system'); + expect(mergeTab.logs[1].text).toContain('Context merged from'); + // Switch notice + expect(mergeTab.logs[2].source).toBe('system'); + expect(mergeTab.logs[2].text).toContain('Provider switched back'); + // New context appended + expect(mergeTab.logs[3].text).toBe('new context from codex'); + // Total: 1 existing + 1 separator + 1 notice + 1 new = 4 + expect(mergeTab.logs).toHaveLength(4); + }); + + it('source session is correctly marked as archived after merge-back', () => { + const source = makeSession({ + id: 'source-id', + toolType: 'codex', + }); + + // Simulate source archiving (done in App.tsx) + const archivedSource: Session = { + ...source, + archivedByMigration: true, + migratedToSessionId: 'target-id', + }; + + expect(archivedSource.archivedByMigration).toBe(true); + expect(archivedSource.migratedToSessionId).toBe('target-id'); + // Original identity preserved + expect(archivedSource.id).toBe('source-id'); + expect(archivedSource.toolType).toBe('codex'); + }); +}); diff --git a/src/renderer/hooks/agent/index.ts b/src/renderer/hooks/agent/index.ts index 3ac7fb563..961082b36 100644 --- a/src/renderer/hooks/agent/index.ts +++ b/src/renderer/hooks/agent/index.ts @@ -84,6 +84,14 @@ export type { UseSendToAgentWithSessionsResult, } from './useSendToAgent'; +// Provider switching (Virtuosos vertical swapping) +export { useProviderSwitch } from './useProviderSwitch'; +export type { + ProviderSwitchRequest, + ProviderSwitchResult, + UseProviderSwitchResult, +} from './useProviderSwitch'; + // Summarize and continue (context compaction) export { useSummarizeAndContinue } from './useSummarizeAndContinue'; export type { diff --git a/src/renderer/hooks/agent/useAgentErrorRecovery.tsx b/src/renderer/hooks/agent/useAgentErrorRecovery.tsx index f86241ff9..754e84d5f 100644 --- a/src/renderer/hooks/agent/useAgentErrorRecovery.tsx +++ b/src/renderer/hooks/agent/useAgentErrorRecovery.tsx @@ -19,7 +19,7 @@ */ import { useMemo, useCallback } from 'react'; -import { KeyRound, MessageSquarePlus, RefreshCw, RotateCcw, Wifi, Terminal } from 'lucide-react'; +import { KeyRound, MessageSquarePlus, RefreshCw, RotateCcw, Wifi } from 'lucide-react'; import type { AgentError, ToolType } from '../../types'; import type { RecoveryAction } from '../../components/AgentErrorModal'; @@ -56,24 +56,21 @@ export interface UseAgentErrorRecoveryResult { */ function getRecoveryActionsForError( error: AgentError, - agentId: ToolType, + _agentId: ToolType, options: UseAgentErrorRecoveryOptions ): RecoveryAction[] { const actions: RecoveryAction[] = []; switch (error.type) { case 'auth_expired': - // Authentication error - offer to re-authenticate or start new session + // Authentication error - trigger automatic re-login if (options.onAuthenticate) { - const isClaude = agentId === 'claude-code'; actions.push({ id: 'authenticate', - label: isClaude ? 'Use Terminal' : 'Re-authenticate', - description: isClaude - ? 'Run "claude login" in terminal' - : 'Log in again to restore access', + label: 'Re-authenticate', + description: 'Opens browser to re-authorize access', primary: true, - icon: isClaude ? : , + icon: , onClick: options.onAuthenticate, }); } diff --git a/src/renderer/hooks/agent/useAgentExecution.ts b/src/renderer/hooks/agent/useAgentExecution.ts index 051897a28..1a7788086 100644 --- a/src/renderer/hooks/agent/useAgentExecution.ts +++ b/src/renderer/hooks/agent/useAgentExecution.ts @@ -252,6 +252,11 @@ export function useAgentExecution(deps: UseAgentExecutionDeps): UseAgentExecutio projectPath: effectiveCwd, tabId: activeTab?.id, isRemote: session.sessionSshRemoteConfig?.enabled ?? false, + inputTokens: taskUsageStats?.inputTokens, + outputTokens: taskUsageStats?.outputTokens, + cacheReadTokens: taskUsageStats?.cacheReadInputTokens, + cacheCreationTokens: taskUsageStats?.cacheCreationInputTokens, + costUsd: taskUsageStats?.totalCostUsd, }) .catch((err) => { // Don't fail the batch flow if stats recording fails diff --git a/src/renderer/hooks/agent/useAgentListeners.ts b/src/renderer/hooks/agent/useAgentListeners.ts index 2ab5b45be..b121b121f 100644 --- a/src/renderer/hooks/agent/useAgentListeners.ts +++ b/src/renderer/hooks/agent/useAgentListeners.ts @@ -743,6 +743,11 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { projectPath: toastData.projectPath, tabId: toastData.tabId, isRemote: toastData.isRemote, + inputTokens: toastData.usageStats?.inputTokens, + outputTokens: toastData.usageStats?.outputTokens, + cacheReadTokens: toastData.usageStats?.cacheReadInputTokens, + cacheCreationTokens: toastData.usageStats?.cacheCreationInputTokens, + costUsd: toastData.usageStats?.totalCostUsd, }) .catch((err) => { console.warn('[onProcessExit] Failed to record query stats:', err); diff --git a/src/renderer/hooks/agent/useMergeSession.ts b/src/renderer/hooks/agent/useMergeSession.ts index 6e330714b..d479c8bad 100644 --- a/src/renderer/hooks/agent/useMergeSession.ts +++ b/src/renderer/hooks/agent/useMergeSession.ts @@ -365,10 +365,14 @@ export function useMergeSession(activeTabId?: string): UseMergeSessionResult { }, }); + // Use target session account, fallback to source + const groomAccountId = targetSession?.accountId || sourceSession?.accountId; + const groomingRequest: MergeRequest = { sources: [sourceContext, targetContext], targetAgent: sourceSession.toolType, targetProjectRoot: sourceSession.projectRoot, + accountId: groomAccountId, }; const groomingResult = await groomingServiceRef.current.groomContexts( @@ -447,6 +451,13 @@ export function useMergeSession(activeTabId?: string): UseMergeSessionResult { saveToHistory: true, }); + // Inherit account from target session, fallback to source + const mergeInheritFrom = targetSession?.accountId ? targetSession : sourceSession; + if (mergeInheritFrom?.accountId) { + mergedSession.accountId = mergeInheritFrom.accountId; + mergedSession.accountName = mergeInheritFrom.accountName; + } + result = { success: true, newSessionId: mergedSession.id, @@ -674,6 +685,13 @@ export function useMergeSessionWithSessions( groupId: sourceSession.groupId, }); + // Inherit account from target session, fallback to source + const inheritFrom = targetSession?.accountId ? targetSession : sourceSession; + if (inheritFrom?.accountId) { + newSession.accountId = inheritFrom.accountId; + newSession.accountName = inheritFrom.accountName; + } + // Add new session to state setSessions((prev) => [...prev, newSession]); diff --git a/src/renderer/hooks/agent/useProviderSwitch.ts b/src/renderer/hooks/agent/useProviderSwitch.ts new file mode 100644 index 000000000..75877d5c5 --- /dev/null +++ b/src/renderer/hooks/agent/useProviderSwitch.ts @@ -0,0 +1,505 @@ +/** + * useProviderSwitch Hook + * + * Orchestrates the provider switch workflow for Virtuosos vertical swapping. + * Creates a new session with a different agent type while preserving: + * - Session identity (name, cwd, group, bookmarks, SSH config, nudge, auto-run path) + * - Conversation context (optionally groomed for the target provider) + * - Provenance chain (migratedFromSessionId, migratedAt, migrationGeneration) + * + * Key differences from useSendToAgent: + * - Session name is preserved (not "Source → Target") + * - Full identity carry-over (not just groupId) + * - Context is pre-loaded in tab logs (not auto-sent as first message) + * - Source session can be archived with back-link + * - Provenance fields are set on the new session + * + * State lives in operationStore (Zustand); this hook owns orchestration only. + */ + +import { useCallback, useRef } from 'react'; +import * as Sentry from '@sentry/electron/renderer'; +import type { Session, LogEntry, ToolType } from '../../types'; +import type { GroomingProgress, MergeRequest } from '../../types/contextMerge'; +import type { TransferState, TransferLastRequest } from '../../stores/operationStore'; +import { + ContextGroomingService, + contextGroomingService, + buildContextTransferPrompt, + getAgentDisplayName, +} from '../../services/contextGroomer'; +import { extractTabContext } from '../../utils/contextExtractor'; +import { createMergedSession } from '../../utils/tabHelpers'; +import { classifyTransferError } from '../../components/TransferErrorModal'; +import { useOperationStore } from '../../stores/operationStore'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ProviderSwitchRequest { + /** Source session to switch from */ + sourceSession: Session; + /** Tab ID within source session (active tab) */ + sourceTabId: string; + /** Target provider to switch to */ + targetProvider: ToolType; + /** Whether to groom context for target provider */ + groomContext: boolean; + /** + * When set, reactivate this archived session instead of creating a new one. + * The groomed context from the source is appended to the target session's logs. + * Mutually exclusive with createMergedSession — uses session mutation instead. + */ + mergeBackInto?: Session; +} + +export interface ProviderSwitchResult { + success: boolean; + /** The complete new session object (caller adds to state) */ + newSession?: Session; + /** New session ID (if successful) */ + newSessionId?: string; + /** New tab ID within new session */ + newTabId?: string; + /** Tokens saved via grooming */ + tokensSaved?: number; + /** Whether this was a merge-back into an existing session */ + mergedBack?: boolean; + /** Error message (if failed) */ + error?: string; +} + +export interface UseProviderSwitchResult { + switchProvider: (request: ProviderSwitchRequest) => Promise; + transferState: TransferState; + progress: GroomingProgress | null; + error: string | null; + cancelSwitch: () => void; + reset: () => void; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Walk the provenance chain backwards from `currentSession` to find + * an archived session running `targetProvider`. + */ +export function findArchivedPredecessor( + sessions: Session[], + currentSession: Session, + targetProvider: ToolType +): Session | null { + let cursor: Session | undefined = currentSession; + const visited = new Set(); + + while (cursor) { + if (visited.has(cursor.id)) break; // prevent cycles + visited.add(cursor.id); + + if ( + cursor.archivedByMigration && + cursor.toolType === targetProvider && + cursor.id !== currentSession.id + ) { + return cursor; + } + + if (cursor.migratedFromSessionId) { + cursor = sessions.find((s) => s.id === cursor!.migratedFromSessionId); + } else { + break; + } + } + return null; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const INITIAL_PROGRESS: GroomingProgress = { + stage: 'collecting', + progress: 0, + message: 'Preparing provider switch...', +}; + +// ============================================================================ +// Hook +// ============================================================================ + +/** + * Hook for managing provider switch operations (Virtuosos vertical swapping). + * + * @example + * const { switchProvider, transferState, progress, cancelSwitch } = useProviderSwitch(); + * + * const result = await switchProvider({ + * sourceSession, + * sourceTabId: activeTabId, + * targetProvider: 'codex', + * groomContext: true, + * }); + * + * if (result.success && result.newSession) { + * setSessions(prev => [...prev, result.newSession!]); + * setActiveSessionId(result.newSessionId); + * } + */ +export function useProviderSwitch(): UseProviderSwitchResult { + // State from operationStore (reuses transfer state) + const transferState = useOperationStore((s) => s.transferState); + const progress = useOperationStore((s) => s.transferProgress); + const error = useOperationStore((s) => s.transferError); + + // Refs for cancellation + const cancelledRef = useRef(false); + const groomingServiceRef = useRef(contextGroomingService); + const switchStartTimeRef = useRef(0); + + /** + * Reset hook state to idle. + */ + const reset = useCallback(() => { + useOperationStore.getState().resetTransferState(); + cancelledRef.current = false; + }, []); + + /** + * Cancel an in-progress switch operation. + */ + const cancelSwitch = useCallback(() => { + cancelledRef.current = true; + groomingServiceRef.current.cancelGrooming(); + + useOperationStore.getState().setTransferState({ + state: 'idle', + progress: null, + error: 'Provider switch cancelled by user', + transferError: null, + }); + }, []); + + /** + * Execute the provider switch workflow. + */ + const switchProvider = useCallback( + async (request: ProviderSwitchRequest): Promise => { + const { sourceSession, sourceTabId, targetProvider, groomContext, mergeBackInto } = request; + + const store = useOperationStore.getState(); + + // Prevent concurrent operations + if (store.globalTransferInProgress) { + return { + success: false, + error: 'A transfer operation is already in progress. Please wait for it to complete.', + }; + } + + // Set global flag + store.setGlobalTransferInProgress(true); + + // Reset and start + cancelledRef.current = false; + switchStartTimeRef.current = Date.now(); + + const minimalRequest: TransferLastRequest = { + sourceSessionId: sourceSession.id, + sourceTabId, + targetAgent: targetProvider, + skipGrooming: !groomContext, + }; + + store.setTransferState({ + state: 'grooming', + progress: INITIAL_PROGRESS, + error: null, + transferError: null, + lastRequest: minimalRequest, + }); + + try { + // Step 1: Validate inputs + const sourceTab = sourceSession.aiTabs.find((t) => t.id === sourceTabId); + if (!sourceTab) { + throw new Error('Source tab not found'); + } + + if (sourceTab.logs.length === 0) { + throw new Error( + 'Cannot switch provider with empty context - source tab has no conversation history' + ); + } + + // Verify target agent is available + let agentStatus; + try { + agentStatus = await window.maestro.agents.get(targetProvider); + } catch (agentCheckError) { + Sentry.captureException(agentCheckError, { + extra: { + operation: 'agent-availability-check', + targetProvider, + sourceAgent: sourceSession.toolType, + }, + }); + throw new Error( + `Failed to verify ${getAgentDisplayName(targetProvider)} availability. Please try again.` + ); + } + + if (!agentStatus?.available) { + throw new Error( + `${getAgentDisplayName(targetProvider)} is not available. Please install and configure it first.` + ); + } + + if (cancelledRef.current) { + return { success: false, error: 'Provider switch cancelled' }; + } + + // Step 2: Extract context from source tab + useOperationStore.getState().setTransferState({ + progress: { + stage: 'collecting', + progress: 10, + message: 'Extracting source context...', + }, + }); + + const sessionDisplayName = + sourceSession.name || sourceSession.projectRoot.split('/').pop() || 'Unnamed Session'; + + const sourceContext = extractTabContext(sourceTab, sessionDisplayName, sourceSession); + + if (cancelledRef.current) { + return { success: false, error: 'Provider switch cancelled' }; + } + + // Step 3: Groom context if enabled + let contextLogs: LogEntry[]; + let tokensSaved = 0; + + if (groomContext) { + useOperationStore.getState().setTransferState({ + progress: { + stage: 'grooming', + progress: 20, + message: `Grooming context for ${getAgentDisplayName(targetProvider)}...`, + }, + }); + + const transferPrompt = buildContextTransferPrompt(sourceSession.toolType, targetProvider); + + const groomingRequest: MergeRequest = { + sources: [sourceContext], + targetAgent: targetProvider, + targetProjectRoot: sourceSession.projectRoot, + groomingPrompt: transferPrompt, + }; + + const groomingResult = await groomingServiceRef.current.groomContexts( + groomingRequest, + (groomProgress) => { + useOperationStore.getState().setTransferState({ + progress: { + ...groomProgress, + message: + groomProgress.stage === 'grooming' + ? `Grooming for ${getAgentDisplayName(targetProvider)}: ${groomProgress.message}` + : groomProgress.message, + }, + }); + } + ); + + if (cancelledRef.current) { + return { success: false, error: 'Provider switch cancelled' }; + } + + if (!groomingResult.success) { + throw new Error(groomingResult.error || 'Context grooming failed'); + } + + contextLogs = groomingResult.groomedLogs; + tokensSaved = groomingResult.tokensSaved; + } else { + useOperationStore.getState().setTransferState({ + progress: { + stage: 'grooming', + progress: 50, + message: 'Preparing context without grooming...', + }, + }); + + contextLogs = [...sourceContext.logs]; + } + + if (cancelledRef.current) { + return { success: false, error: 'Provider switch cancelled' }; + } + + const sourceName = getAgentDisplayName(sourceSession.toolType); + const targetName = getAgentDisplayName(targetProvider); + const groomNote = groomContext + ? 'Context groomed and optimized.' + : 'Context preserved as-is.'; + + let resultSession: Session; + let resultTabId: string; + + if (mergeBackInto) { + // Step 4a: Merge-back mode — reactivate the archived session + useOperationStore.getState().setTransferState({ + state: 'creating', + progress: { + stage: 'creating', + progress: 80, + message: `Reactivating ${targetName} session...`, + }, + }); + + // Reactivate the archived session by mutating its fields + const reactivated: Session = { + ...mergeBackInto, + archivedByMigration: false, + migratedFromSessionId: sourceSession.id, + migratedAt: Date.now(), + migrationGeneration: (mergeBackInto.migrationGeneration || 0) + 1, + migratedToSessionId: undefined, + lastMergeBackAt: Date.now(), + }; + + // Append context logs to the reactivated session's active tab + const mergeTab = reactivated.aiTabs[0]; + if (mergeTab) { + const separator: LogEntry = { + id: `merge-separator-${Date.now()}`, + timestamp: Date.now(), + source: 'system', + text: `── Context merged from ${sourceName} session ──`, + }; + + const switchNotice: LogEntry = { + id: `provider-switch-notice-${Date.now()}`, + timestamp: Date.now(), + source: 'system', + text: `Provider switched back from ${sourceName} to ${targetName}. ${groomNote}`, + }; + + mergeTab.logs = [...mergeTab.logs, separator, switchNotice, ...contextLogs]; + } + + resultSession = reactivated; + resultTabId = mergeTab?.id || reactivated.aiTabs[0]?.id || ''; + } else { + // Step 4b: Create new session via extended createMergedSession + useOperationStore.getState().setTransferState({ + state: 'creating', + progress: { + stage: 'creating', + progress: 80, + message: `Creating ${targetName} session...`, + }, + }); + + const { session: newSession, tabId: newTabId } = createMergedSession({ + name: sourceSession.name, + projectRoot: sourceSession.projectRoot, + toolType: targetProvider, + mergedLogs: contextLogs, + groupId: sourceSession.groupId, + // Identity carry-over + nudgeMessage: sourceSession.nudgeMessage, + bookmarked: sourceSession.bookmarked, + sessionSshRemoteConfig: sourceSession.sessionSshRemoteConfig, + autoRunFolderPath: sourceSession.autoRunFolderPath, + // Provenance + migratedFromSessionId: sourceSession.id, + migratedAt: Date.now(), + migrationGeneration: (sourceSession.migrationGeneration || 0) + 1, + }); + + // Add transfer notice to new session tab + const transferNotice: LogEntry = { + id: `provider-switch-notice-${Date.now()}`, + timestamp: Date.now(), + source: 'system', + text: `Provider switched from ${sourceName} to ${targetName}. ${groomNote}`, + }; + + const activeTab = newSession.aiTabs.find((t) => t.id === newTabId); + if (activeTab) { + activeTab.logs = [transferNotice, ...activeTab.logs]; + } + + resultSession = newSession; + resultTabId = newTabId; + } + + // Step 5: Complete + useOperationStore.getState().setTransferState({ + state: 'complete', + progress: { + stage: 'complete', + progress: 100, + message: `Provider switch complete!${tokensSaved > 0 ? ` Saved ~${tokensSaved} tokens` : ''}`, + }, + }); + + return { + success: true, + newSession: resultSession, + newSessionId: resultSession.id, + newTabId: resultTabId, + tokensSaved, + mergedBack: !!mergeBackInto, + }; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Unknown error during provider switch'; + const elapsedTimeMs = Date.now() - switchStartTimeRef.current; + + const classifiedError = classifyTransferError(errorMessage, { + sourceAgent: sourceSession.toolType, + targetAgent: targetProvider, + wasGrooming: groomContext, + elapsedTimeMs, + }); + + useOperationStore.getState().setTransferState({ + state: 'error', + error: errorMessage, + transferError: classifiedError, + progress: { + stage: 'complete', + progress: 100, + message: `Provider switch failed: ${errorMessage}`, + }, + }); + + return { + success: false, + error: errorMessage, + }; + } finally { + useOperationStore.getState().setGlobalTransferInProgress(false); + } + }, + [] + ); + + return { + switchProvider, + transferState, + progress, + error, + cancelSwitch, + reset, + }; +} + +export default useProviderSwitch; diff --git a/src/renderer/hooks/input/useInputProcessing.ts b/src/renderer/hooks/input/useInputProcessing.ts index f6eba0970..9022b9613 100644 --- a/src/renderer/hooks/input/useInputProcessing.ts +++ b/src/renderer/hooks/input/useInputProcessing.ts @@ -1005,6 +1005,8 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces sessionCustomEnvVars: freshSession.customEnvVars, sessionCustomModel: freshSession.customModel, sessionCustomContextWindow: freshSession.customContextWindow, + // Account multiplexing - pass accountId so spawn uses the correct account + accountId: freshSession.accountId, // Per-session SSH remote config (takes precedence over agent-level SSH config) sessionSshRemoteConfig: freshSession.sessionSshRemoteConfig, // Windows stdin handling - send prompt via stdin to avoid shell escaping issues diff --git a/src/renderer/hooks/props/useSessionListProps.ts b/src/renderer/hooks/props/useSessionListProps.ts index fc42ddf87..29df7f1ef 100644 --- a/src/renderer/hooks/props/useSessionListProps.ts +++ b/src/renderer/hooks/props/useSessionListProps.ts @@ -100,6 +100,7 @@ export interface UseSessionListPropsDeps { setDuplicatingSessionId: (id: string | null) => void; setGroupChatsExpanded: (expanded: boolean) => void; setQuickActionOpen: (open: boolean) => void; + setVirtuososOpen?: (open: boolean) => void; // Handlers (should be memoized with useCallback) toggleGlobalLive: () => void; @@ -125,6 +126,8 @@ export interface UseSessionListPropsDeps { handleOpenWorktreeConfigSession: (session: Session) => void; handleDeleteWorktreeSession: (session: Session) => void; handleToggleWorktreeExpanded: (sessionId: string) => void; + handleSwitchProvider?: (sessionId: string) => void; + handleUnarchive?: (sessionId: string) => void; openWizardModal: () => void; handleStartTour: () => void; @@ -201,6 +204,7 @@ export function useSessionListProps(deps: UseSessionListPropsDeps) { setSymphonyModalOpen: deps.setSymphonyModalOpen, setDirectorNotesOpen: deps.setDirectorNotesOpen, setQuickActionOpen: deps.setQuickActionOpen, + setVirtuososOpen: deps.setVirtuososOpen, // Handlers toggleGroup: deps.toggleGroup, @@ -240,6 +244,10 @@ export function useSessionListProps(deps: UseSessionListPropsDeps) { onOpenWorktreeConfig: deps.handleOpenWorktreeConfigSession, onDeleteWorktree: deps.handleDeleteWorktreeSession, + // Provider switching (Virtuosos) + onSwitchProvider: deps.handleSwitchProvider, + onUnarchive: deps.handleUnarchive, + // Auto mode activeBatchSessionIds: deps.activeBatchSessionIds, @@ -330,6 +338,7 @@ export function useSessionListProps(deps: UseSessionListPropsDeps) { deps.setSymphonyModalOpen, deps.setDirectorNotesOpen, deps.setQuickActionOpen, + deps.setVirtuososOpen, deps.setGroups, deps.setSessions, deps.setRenameInstanceModalOpen, @@ -360,6 +369,8 @@ export function useSessionListProps(deps: UseSessionListPropsDeps) { deps.handleOpenWorktreeConfigSession, deps.handleDeleteWorktreeSession, deps.handleToggleWorktreeExpanded, + deps.handleSwitchProvider, + deps.handleUnarchive, deps.openWizardModal, deps.handleStartTour, deps.handleOpenGroupChat, diff --git a/src/renderer/hooks/useAccountUsage.ts b/src/renderer/hooks/useAccountUsage.ts new file mode 100644 index 000000000..1aa289b6c --- /dev/null +++ b/src/renderer/hooks/useAccountUsage.ts @@ -0,0 +1,457 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; + +// ============================================================================ +// Prediction Types +// ============================================================================ + +export interface UsagePrediction { + /** Linear estimate: remaining tokens / current burn rate */ + linearTimeToLimitMs: number | null; + /** Weighted estimate using recent window patterns */ + weightedTimeToLimitMs: number | null; + /** P90 tokens per window (90th percentile of recent windows) */ + p90TokensPerWindow: number; + /** Average tokens per window */ + avgTokensPerWindow: number; + /** Confidence: 'low' (<5 windows), 'medium' (5-15), 'high' (>15) */ + confidence: 'low' | 'medium' | 'high'; + /** Predicted number of windows remaining before limit (at P90 rate) */ + windowsRemainingP90: number | null; +} + +// ============================================================================ +// Rate Metrics Types +// ============================================================================ + +export interface RateMetrics { + tokensPerHour: number; + tokensPerDay: number; + tokensPerWeek: number; + dailyDelta: number; + weeklyDelta: number; + trend: 'up' | 'stable' | 'down'; +} + +// ============================================================================ +// Metrics Types +// ============================================================================ + +export interface AccountUsageMetrics { + accountId: string; + totalTokens: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + limitTokens: number; + usagePercent: number | null; + costUsd: number; + queryCount: number; + windowStart: number; + windowEnd: number; + timeRemainingMs: number; + burnRatePerHour: number; + estimatedTimeToLimitMs: number | null; // null if no limit or burn rate is 0 + status: string; + prediction: UsagePrediction; + rateMetrics: RateMetrics; +} + +const DEFAULT_INTERVAL_MS = 30_000; +const URGENT_INTERVAL_MS = 5_000; +const URGENT_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes + +const EMPTY_RATE_METRICS: RateMetrics = { + tokensPerHour: 0, + tokensPerDay: 0, + tokensPerWeek: 0, + dailyDelta: 0, + weeklyDelta: 0, + trend: 'stable', +}; + +// ============================================================================ +// P90 Prediction Calculator +// ============================================================================ + +/** + * Calculate P90-weighted prediction from billing window history. + * Uses exponential weighting so recent windows count more than older ones. + * + * Inspired by Claude-Code-Usage-Monitor's P90 approach but adapted for + * Maestro's multi-account context. + */ +export function calculatePrediction( + windowHistory: Array<{ totalTokens: number; windowStart: number; windowEnd: number }>, + currentWindowTokens: number, + limitTokens: number, + windowMs: number, +): UsagePrediction { + const windowCount = windowHistory.length; + const confidence = windowCount < 5 ? 'low' : windowCount < 15 ? 'medium' : 'high'; + + if (windowCount === 0) { + return { + linearTimeToLimitMs: null, + weightedTimeToLimitMs: null, + p90TokensPerWindow: 0, + avgTokensPerWindow: 0, + confidence, + windowsRemainingP90: null, + }; + } + + // Extract token totals per window + const totals = windowHistory.map(w => w.totalTokens); + + // Calculate average + const avgTokensPerWindow = totals.reduce((a, b) => a + b, 0) / totals.length; + + // Calculate P90 (90th percentile) + const sorted = [...totals].sort((a, b) => a - b); + const p90Index = Math.floor(sorted.length * 0.9); + const p90TokensPerWindow = sorted[Math.min(p90Index, sorted.length - 1)]; + + // Weighted average: exponential decay, most recent windows weighted highest + // Weight = 0.85^(age), so most recent window = 1.0, one back = 0.85, etc. + const DECAY = 0.85; + let weightedSum = 0; + let weightTotal = 0; + for (let i = 0; i < totals.length; i++) { + const age = totals.length - 1 - i; // 0 = most recent + const weight = Math.pow(DECAY, age); + weightedSum += totals[i] * weight; + weightTotal += weight; + } + const weightedAvg = weightedSum / weightTotal; + + // Predictions (only if limit is configured) + let linearTimeToLimitMs: number | null = null; + let weightedTimeToLimitMs: number | null = null; + let windowsRemainingP90: number | null = null; + + if (limitTokens > 0) { + const remaining = Math.max(0, limitTokens - currentWindowTokens); + + // Linear: remaining / (average tokens per window) * window duration + if (avgTokensPerWindow > 0) { + const windowsRemaining = remaining / avgTokensPerWindow; + linearTimeToLimitMs = windowsRemaining * windowMs; + } + + // Weighted: use weighted average for more responsive prediction + if (weightedAvg > 0) { + const windowsRemaining = remaining / weightedAvg; + weightedTimeToLimitMs = windowsRemaining * windowMs; + } + + // P90: conservative estimate + if (p90TokensPerWindow > 0) { + windowsRemainingP90 = remaining / p90TokensPerWindow; + } + } + + return { + linearTimeToLimitMs, + weightedTimeToLimitMs, + p90TokensPerWindow, + avgTokensPerWindow, + confidence, + windowsRemainingP90, + }; +} + +// ============================================================================ +// Rate Metrics Calculator +// ============================================================================ + +/** + * Calculate rate metrics from window history for trend display. + * Provides tokens/hr, tokens/day, tokens/week with period-over-period deltas. + */ +export function calculateRateMetrics( + windowHistory: Array<{ totalTokens: number; windowStart: number; windowEnd: number }>, + burnRatePerHour: number, +): RateMetrics { + if (windowHistory.length === 0) { + return { ...EMPTY_RATE_METRICS, tokensPerHour: burnRatePerHour }; + } + + // ~5 windows/day, ~34 windows/week + const n = windowHistory.length; + + // Tokens/day: sum of last 5 windows (~24h) + const daySlice = windowHistory.slice(Math.max(0, n - 5)); + const tokensPerDay = daySlice.reduce((s, w) => s + w.totalTokens, 0); + + // Tokens/week: sum of last 34 windows (~7 days) + const weekSlice = windowHistory.slice(Math.max(0, n - 34)); + const tokensPerWeek = weekSlice.reduce((s, w) => s + w.totalTokens, 0); + + // Daily delta: compare last 5 vs previous 5 + const prevDaySlice = windowHistory.slice(Math.max(0, n - 10), Math.max(0, n - 5)); + const prevDayTotal = prevDaySlice.reduce((s, w) => s + w.totalTokens, 0); + const dailyDelta = prevDayTotal > 0 ? ((tokensPerDay - prevDayTotal) / prevDayTotal) * 100 : 0; + + // Weekly delta: compare last 34 vs previous 34 + const prevWeekSlice = windowHistory.slice(Math.max(0, n - 68), Math.max(0, n - 34)); + const prevWeekTotal = prevWeekSlice.reduce((s, w) => s + w.totalTokens, 0); + const weeklyDelta = prevWeekTotal > 0 ? ((tokensPerWeek - prevWeekTotal) / prevWeekTotal) * 100 : 0; + + // Trend: linear regression on last 20 windows + const trendSlice = windowHistory.slice(Math.max(0, n - 20)); + let trend: 'up' | 'stable' | 'down' = 'stable'; + if (trendSlice.length >= 2) { + const tLen = trendSlice.length; + let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0; + for (let i = 0; i < tLen; i++) { + sumX += i; + sumY += trendSlice[i].totalTokens; + sumXY += i * trendSlice[i].totalTokens; + sumX2 += i * i; + } + const denom = tLen * sumX2 - sumX * sumX; + if (denom !== 0) { + const slope = (tLen * sumXY - sumX * sumY) / denom; + const meanY = sumY / tLen; + const normalizedSlope = meanY > 0 ? (slope * tLen) / meanY : 0; + if (normalizedSlope > 0.05) trend = 'up'; + else if (normalizedSlope < -0.05) trend = 'down'; + } + } + + return { + tokensPerHour: burnRatePerHour, + tokensPerDay, + tokensPerWeek, + dailyDelta, + weeklyDelta, + trend, + }; +} + +// ============================================================================ +// Hook +// ============================================================================ + +/** + * Hook that provides real-time per-account usage metrics. + * Fetches on mount, subscribes to real-time updates, and recalculates + * derived metrics (burn rate, time to limit) every 30 seconds. + * Switches to 5-second updates when any account is within 5 minutes of reset. + * + * Also fetches billing window history once on mount for P90 prediction + * and recalculates predictions when the current window's usage changes. + */ +export function useAccountUsage(): { + metrics: Record; + loading: boolean; + refresh: () => void; +} { + const [metrics, setMetrics] = useState>({}); + const [loading, setLoading] = useState(true); + const intervalRef = useRef | null>(null); + const currentIntervalMs = useRef(DEFAULT_INTERVAL_MS); + const windowHistoriesRef = useRef>>({}); + + const calculateDerivedMetrics = useCallback((raw: { + accountId: string; + totalTokens: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + limitTokens: number; + usagePercent: number | null; + costUsd: number; + queryCount: number; + windowStart: number; + windowEnd: number; + status: string; + }): AccountUsageMetrics => { + const now = Date.now(); + const timeRemainingMs = Math.max(0, raw.windowEnd - now); + const elapsedMs = Math.max(1, now - raw.windowStart); // avoid divide by zero + const elapsedHours = elapsedMs / (1000 * 60 * 60); + + // Burn rate: tokens consumed per hour in this window + const burnRatePerHour = raw.totalTokens / elapsedHours; + + // Estimated time to hit limit (null if no limit configured) + let estimatedTimeToLimitMs: number | null = null; + if (raw.limitTokens > 0 && burnRatePerHour > 0) { + const remainingTokens = Math.max(0, raw.limitTokens - raw.totalTokens); + const hoursToLimit = remainingTokens / burnRatePerHour; + estimatedTimeToLimitMs = hoursToLimit * 60 * 60 * 1000; + } + + // P90 prediction from window history + const prediction = calculatePrediction( + windowHistoriesRef.current[raw.accountId] || [], + raw.totalTokens, + raw.limitTokens, + raw.windowEnd - raw.windowStart, + ); + + // Rate metrics from window history + const rateMetrics = calculateRateMetrics( + windowHistoriesRef.current[raw.accountId] || [], + burnRatePerHour, + ); + + return { + ...raw, + timeRemainingMs, + burnRatePerHour, + estimatedTimeToLimitMs, + prediction, + rateMetrics, + }; + }, []); + + const recalculate = useCallback(() => { + setMetrics(prev => { + const updated: Record = {}; + for (const [id, m] of Object.entries(prev)) { + updated[id] = calculateDerivedMetrics(m); + } + + // Adaptive interval: switch to 5s when any account is near reset + const hasUrgentCountdown = Object.values(updated).some( + m => m.timeRemainingMs > 0 && m.timeRemainingMs < URGENT_THRESHOLD_MS + ); + const targetInterval = hasUrgentCountdown ? URGENT_INTERVAL_MS : DEFAULT_INTERVAL_MS; + if (targetInterval !== currentIntervalMs.current && intervalRef.current) { + clearInterval(intervalRef.current); + currentIntervalMs.current = targetInterval; + intervalRef.current = setInterval(recalculate, targetInterval); + } + + return updated; + }); + }, [calculateDerivedMetrics]); + + const fetchUsage = useCallback(async () => { + try { + const allUsage = await window.maestro.accounts.getAllUsage(); + if (!allUsage || typeof allUsage !== 'object') return; + + const newMetrics: Record = {}; + for (const [accountId, usage] of Object.entries(allUsage as Record)) { + newMetrics[accountId] = calculateDerivedMetrics({ + accountId, + totalTokens: usage.totalTokens || 0, + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadTokens: usage.cacheReadTokens || 0, + cacheCreationTokens: usage.cacheCreationTokens || 0, + limitTokens: usage.account?.tokenLimitPerWindow || 0, + usagePercent: usage.usagePercent ?? null, + costUsd: usage.costUsd || 0, + queryCount: usage.queryCount || 0, + windowStart: usage.windowStart || Date.now(), + windowEnd: usage.windowEnd || Date.now(), + status: usage.account?.status || 'active', + }); + } + setMetrics(newMetrics); + setLoading(false); + } catch (err) { + console.warn('[useAccountUsage] Failed to fetch usage data:', err); + setLoading(false); + } + }, [calculateDerivedMetrics]); + + // Load window histories once on mount for P90 predictions + useEffect(() => { + async function loadHistories() { + try { + const accounts = await window.maestro.accounts.list(); + const histories: Record> = {}; + for (const account of (accounts || []) as Array<{ id: string }>) { + try { + const history = await window.maestro.accounts.getWindowHistory(account.id, 68) as Array<{ + inputTokens: number; outputTokens: number; + cacheReadTokens: number; cacheCreationTokens: number; + windowStart: number; windowEnd: number; + }>; + histories[account.id] = history.map(w => ({ + totalTokens: w.inputTokens + w.outputTokens + w.cacheReadTokens + w.cacheCreationTokens, + windowStart: w.windowStart, + windowEnd: w.windowEnd, + })); + } catch (err) { console.warn(`[useAccountUsage] Failed to load history for account ${account.id}:`, err); } + } + windowHistoriesRef.current = histories; + } catch (err) { console.warn('[useAccountUsage] Failed to load window histories:', err); } + } + loadHistories(); + }, []); + + useEffect(() => { + fetchUsage(); + + // Subscribe to real-time usage updates + const unsub = window.maestro.accounts.onUsageUpdate((data) => { + const accountId = data.accountId; + if (!accountId) return; + + setMetrics(prev => ({ + ...prev, + [accountId]: calculateDerivedMetrics({ + accountId, + totalTokens: data.totalTokens || 0, + inputTokens: data.inputTokens || 0, + outputTokens: data.outputTokens || 0, + cacheReadTokens: data.cacheReadTokens || 0, + cacheCreationTokens: data.cacheCreationTokens || 0, + limitTokens: data.limitTokens || 0, + usagePercent: data.usagePercent ?? null, + costUsd: data.costUsd || 0, + queryCount: data.queryCount || 0, + windowStart: data.windowStart || Date.now(), + windowEnd: data.windowEnd || Date.now(), + status: prev[accountId]?.status || 'active', + }), + })); + }); + + // Recalculate derived metrics periodically + currentIntervalMs.current = DEFAULT_INTERVAL_MS; + intervalRef.current = setInterval(recalculate, DEFAULT_INTERVAL_MS); + + return () => { + unsub(); + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [fetchUsage, calculateDerivedMetrics, recalculate]); + + return { metrics, loading, refresh: fetchUsage }; +} + +/** + * Format milliseconds into a human-readable time string. + * Examples: "2h 34m", "45m", "4m 32s", "< 1m", "—" (if 0 or negative) + */ +export function formatTimeRemaining(ms: number): string { + if (ms <= 0) return '—'; + const hours = Math.floor(ms / (1000 * 60 * 60)); + const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60)); + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes >= 5) return `${minutes}m`; + // Under 5 minutes: show seconds for precision + const seconds = Math.floor((ms % (1000 * 60)) / 1000); + if (minutes > 0) return `${minutes}m ${seconds}s`; + return '< 1m'; +} + +/** + * Format token count with K/M suffix. + * Examples: "142K", "1.2M", "856" + */ +export function formatTokenCount(tokens: number): string { + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; + if (tokens >= 10_000) return `${Math.round(tokens / 1_000)}K`; + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`; + return String(tokens); +} diff --git a/src/renderer/hooks/useProviderDetail.ts b/src/renderer/hooks/useProviderDetail.ts new file mode 100644 index 000000000..d7db062d2 --- /dev/null +++ b/src/renderer/hooks/useProviderDetail.ts @@ -0,0 +1,447 @@ +/** + * useProviderDetail - Detailed data for a single provider's detail view + * + * Fetches per-provider usage stats, error breakdown, daily trends, hourly patterns, + * active sessions, and migration history for the ProviderDetailView component. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { Session } from '../types'; +import type { ToolType, AgentErrorType } from '../../shared/types'; +import type { ProviderErrorStats, ProviderSwitchConfig } from '../../shared/account-types'; +import { DEFAULT_PROVIDER_SWITCH_CONFIG } from '../../shared/account-types'; +import type { StatsTimeRange, StatsAggregation } from '../../shared/stats-types'; +import type { ProviderUsageStats } from './useProviderHealth'; +import type { HealthStatus } from '../components/ProviderHealthCard'; +import { getAgentDisplayName } from '../services/contextGroomer'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ProviderDetail { + toolType: ToolType; + displayName: string; + available: boolean; + status: HealthStatus; + + // Usage stats (for selected time range) + usage: ProviderUsageStats; + + // Token breakdown (for detail table) + tokenBreakdown: { + inputTokens: number; + inputCostUsd: number; + outputTokens: number; + outputCostUsd: number; + cacheReadTokens: number; + cacheReadCostUsd: number; + cacheCreationTokens: number; + cacheCreationCostUsd: number; + }; + + // Quality / reliability + reliability: { + successRate: number; + errorRate: number; + totalErrors: number; + errorsByType: Partial>; + avgResponseTimeMs: number; + p95ResponseTimeMs: number; + }; + + // Source split + queriesBySource: { user: number; auto: number }; + + // Location split + queriesByLocation: { local: number; remote: number }; + + // Trends (daily data points for charts) + dailyTrend: Array<{ + date: string; + queryCount: number; + durationMs: number; + avgDurationMs: number; + }>; + + // Hourly activity pattern (0-23) + hourlyPattern: Array<{ + hour: number; + queryCount: number; + avgDurationMs: number; + }>; + + // Active sessions using this provider + activeSessions: Array<{ + id: string; + name: string; + projectRoot: string; + state: string; + }>; + + // Migration history involving this provider + migrations: Array<{ + timestamp: number; + sessionName: string; + direction: 'from' | 'to'; + otherProvider: ToolType; + generation: number; + }>; + + // Cross-provider comparison data + comparison: { + totalQueriesAllProviders: number; + totalCostAllProviders: number; + queryShare: number; // 0-100, this provider's % of total queries + costShare: number; // 0-100, this provider's % of total cost + avgResponseRanking: Array<{ provider: string; avgMs: number }>; // sorted fastest first + reliabilityRanking: Array<{ provider: string; rate: number }>; // sorted highest first + }; +} + +export interface UseProviderDetailResult { + detail: ProviderDetail | null; + isLoading: boolean; + refresh: () => void; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +const EMPTY_USAGE: ProviderUsageStats = { + queryCount: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreationTokens: 0, + totalCostUsd: 0, + totalDurationMs: 0, + avgDurationMs: 0, +}; + +function computeP95(durations: number[]): number { + if (durations.length === 0) return 0; + const sorted = [...durations].sort((a, b) => a - b); + const index = Math.floor(sorted.length * 0.95); + return sorted[Math.min(index, sorted.length - 1)]; +} + +// Rough cost estimation per token type (Claude Code default pricing) +// These are approximations — actual costs vary by model +const INPUT_COST_PER_TOKEN = 0.000003; +const OUTPUT_COST_PER_TOKEN = 0.000015; +const CACHE_READ_COST_PER_TOKEN = 0.0000003; +const CACHE_CREATION_COST_PER_TOKEN = 0.00000375; + +function estimateTokenCosts(tokens: { + input: number; + output: number; + cacheRead: number; + cacheCreation: number; + totalCost: number; +}): { inputCost: number; outputCost: number; cacheReadCost: number; cacheCreationCost: number } { + // If we have a total cost, distribute proportionally based on token counts + const rawInput = tokens.input * INPUT_COST_PER_TOKEN; + const rawOutput = tokens.output * OUTPUT_COST_PER_TOKEN; + const rawCacheRead = tokens.cacheRead * CACHE_READ_COST_PER_TOKEN; + const rawCacheCreation = tokens.cacheCreation * CACHE_CREATION_COST_PER_TOKEN; + const rawTotal = rawInput + rawOutput + rawCacheRead + rawCacheCreation; + + if (rawTotal === 0 || tokens.totalCost === 0) { + return { inputCost: rawInput, outputCost: rawOutput, cacheReadCost: rawCacheRead, cacheCreationCost: rawCacheCreation }; + } + + // Scale to match actual total cost + const scale = tokens.totalCost / rawTotal; + return { + inputCost: rawInput * scale, + outputCost: rawOutput * scale, + cacheReadCost: rawCacheRead * scale, + cacheCreationCost: rawCacheCreation * scale, + }; +} + +// ============================================================================ +// Hook +// ============================================================================ + +export function useProviderDetail( + toolType: ToolType, + sessions: Session[], + timeRange: StatsTimeRange, +): UseProviderDetailResult { + const [detail, setDetail] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const mountedRef = useRef(true); + const timeRangeRef = useRef(timeRange); + timeRangeRef.current = timeRange; + + const refresh = useCallback(async () => { + try { + // Fetch all data in parallel — includes failover config for threshold + all error stats for comparison + const [agents, errorStats, allErrorStats, savedConfig, queryEvents, aggregation] = await Promise.all([ + window.maestro.agents.detect() as Promise>, + window.maestro.providers.getErrorStats(toolType) as Promise, + window.maestro.providers.getAllErrorStats() as Promise>, + window.maestro.settings.get('providerSwitchConfig') as Promise | null>, + window.maestro.stats.getStats(timeRangeRef.current, { agentType: toolType }), + window.maestro.stats.getAggregation(timeRangeRef.current) as Promise, + ]); + + if (!mountedRef.current) return; + + const threshold = (savedConfig as Partial)?.errorThreshold + ?? DEFAULT_PROVIDER_SWITCH_CONFIG.errorThreshold; + + const agent = agents.find((a) => a.id === toolType); + const available = agent?.available ?? false; + + // Aggregate usage stats from raw query events + const usage: ProviderUsageStats = { ...EMPTY_USAGE }; + const durations: number[] = []; + let userQueries = 0; + let autoQueries = 0; + let localQueries = 0; + let remoteQueries = 0; + + for (const e of queryEvents) { + usage.queryCount += 1; + usage.totalInputTokens += e.inputTokens ?? 0; + usage.totalOutputTokens += e.outputTokens ?? 0; + usage.totalCacheReadTokens += e.cacheReadTokens ?? 0; + usage.totalCacheCreationTokens += e.cacheCreationTokens ?? 0; + usage.totalCostUsd += e.costUsd ?? 0; + usage.totalDurationMs += e.duration ?? 0; + if (e.duration > 0) durations.push(e.duration); + if (e.source === 'user') userQueries++; + else autoQueries++; + if (e.isRemote) remoteQueries++; + else localQueries++; + } + usage.avgDurationMs = usage.queryCount > 0 + ? Math.round(usage.totalDurationMs / usage.queryCount) + : 0; + + // Token cost breakdown + const costs = estimateTokenCosts({ + input: usage.totalInputTokens, + output: usage.totalOutputTokens, + cacheRead: usage.totalCacheReadTokens, + cacheCreation: usage.totalCacheCreationTokens, + totalCost: usage.totalCostUsd, + }); + + // Error stats + const errorCount = errorStats?.totalErrorsInWindow ?? 0; + const totalQueries = usage.queryCount; + const successRate = totalQueries > 0 + ? ((totalQueries - errorCount) / totalQueries) * 100 + : 0; + const errorRate = totalQueries > 0 + ? (errorCount / totalQueries) * 100 + : 0; + + // Determine status using config-driven threshold + let status: HealthStatus; + const activeCount = sessions.filter( + (s) => s.toolType === toolType && !s.archivedByMigration, + ).length; + if (!available) { + status = 'not_installed'; + } else if (activeCount === 0) { + status = 'idle'; + } else if (errorCount === 0) { + status = 'healthy'; + } else if (errorCount >= threshold) { + status = 'failing'; + } else { + status = 'degraded'; + } + + // Daily trend from aggregation + const dailyData = aggregation.byAgentByDay?.[toolType] ?? []; + const dailyTrend = dailyData.map((d) => ({ + date: d.date, + queryCount: d.count, + durationMs: d.duration, + avgDurationMs: d.count > 0 ? Math.round(d.duration / d.count) : 0, + })); + + // Hourly pattern — use per-agent aggregation from SQL (consistent with daily trend) + const hourlyData = aggregation.byAgentByHour?.[toolType] ?? []; + const hourlyMap = new Map(); + for (let h = 0; h < 24; h++) { + hourlyMap.set(h, { count: 0, duration: 0 }); + } + for (const d of hourlyData) { + hourlyMap.set(d.hour, { count: d.count, duration: d.duration }); + } + const hourlyPattern = Array.from(hourlyMap.entries()).map(([hour, data]) => ({ + hour, + queryCount: data.count, + avgDurationMs: data.count > 0 ? Math.round(data.duration / data.count) : 0, + })); + + // Active sessions + const activeSessions = sessions + .filter((s) => s.toolType === toolType && !s.archivedByMigration) + .map((s) => ({ + id: s.id, + name: s.name || 'Unnamed Agent', + projectRoot: s.projectRoot, + state: s.state, + })); + + // Migration history involving this provider + const migrations: ProviderDetail['migrations'] = []; + for (const s of sessions) { + if (s.migratedFromSessionId && s.migratedAt) { + const source = sessions.find((src) => src.id === s.migratedFromSessionId); + if (source) { + const sourceType = source.toolType as ToolType; + const targetType = s.toolType as ToolType; + if (sourceType === toolType) { + migrations.push({ + timestamp: s.migratedAt, + sessionName: s.name || 'Unnamed Agent', + direction: 'from', + otherProvider: targetType, + generation: s.migrationGeneration || 1, + }); + } else if (targetType === toolType) { + migrations.push({ + timestamp: s.migratedAt, + sessionName: s.name || 'Unnamed Agent', + direction: 'to', + otherProvider: sourceType, + generation: s.migrationGeneration || 1, + }); + } + } + } + } + migrations.sort((a, b) => b.timestamp - a.timestamp); + + // P95 response time — only meaningful with >= 20 data points + const p95 = durations.length >= 20 + ? computeP95(durations) + : usage.avgDurationMs; + + // Cross-provider comparison from byAgent aggregation + const byAgent = aggregation.byAgent ?? {}; + let totalQueriesAll = 0; + let totalCostAll = 0; + const avgResponseRanking: Array<{ provider: string; avgMs: number }> = []; + + for (const [agentId, data] of Object.entries(byAgent)) { + totalQueriesAll += data.count; + // Cost isn't in byAgent — we accumulate a rough estimate from duration ratio + const avgMs = data.count > 0 ? Math.round(data.duration / data.count) : 0; + avgResponseRanking.push({ provider: getAgentDisplayName(agentId as ToolType), avgMs }); + } + + // For cost, we need per-provider data — use usage stats for this provider + // and aggregate from byAgent counts as proxy (actual cost only available for this provider) + // A simpler approach: total cost from aggregation isn't per-provider, so use the + // current provider's cost and approximate others from query ratios + // Actually, we can compute totalCostAll from the allErrorStats + byAgent combo + // Best approach: use totalQueries ratio for cost share approximation + // But we already have the actual cost for THIS provider from queryEvents + totalCostAll = usage.totalCostUsd; // Start with this provider's known cost + for (const [agentId, data] of Object.entries(byAgent)) { + if (agentId !== toolType && data.count > 0 && usage.queryCount > 0) { + // Estimate other providers' cost proportionally to query count + const costPerQuery = usage.totalCostUsd / usage.queryCount; + totalCostAll += data.count * costPerQuery; + } + } + + avgResponseRanking.sort((a, b) => a.avgMs - b.avgMs); // fastest first + + // Reliability ranking from allErrorStats + const reliabilityRanking: Array<{ provider: string; rate: number }> = []; + for (const [agentId, data] of Object.entries(byAgent)) { + const providerErrors = allErrorStats[agentId]?.totalErrorsInWindow ?? 0; + const rate = data.count > 0 + ? ((data.count - providerErrors) / data.count) * 100 + : 100; + reliabilityRanking.push({ provider: getAgentDisplayName(agentId as ToolType), rate }); + } + reliabilityRanking.sort((a, b) => b.rate - a.rate); // highest first + + const queryShare = totalQueriesAll > 0 + ? (usage.queryCount / totalQueriesAll) * 100 + : 0; + const costShare = totalCostAll > 0 + ? (usage.totalCostUsd / totalCostAll) * 100 + : 0; + + const comparison: ProviderDetail['comparison'] = { + totalQueriesAllProviders: totalQueriesAll, + totalCostAllProviders: totalCostAll, + queryShare, + costShare, + avgResponseRanking, + reliabilityRanking, + }; + + const result: ProviderDetail = { + toolType, + displayName: getAgentDisplayName(toolType), + available, + status, + usage, + tokenBreakdown: { + inputTokens: usage.totalInputTokens, + inputCostUsd: costs.inputCost, + outputTokens: usage.totalOutputTokens, + outputCostUsd: costs.outputCost, + cacheReadTokens: usage.totalCacheReadTokens, + cacheReadCostUsd: costs.cacheReadCost, + cacheCreationTokens: usage.totalCacheCreationTokens, + cacheCreationCostUsd: costs.cacheCreationCost, + }, + reliability: { + successRate, + errorRate, + totalErrors: errorCount, + errorsByType: errorStats?.errorsByType ?? {}, + avgResponseTimeMs: usage.avgDurationMs, + p95ResponseTimeMs: p95, + }, + queriesBySource: { user: userQueries, auto: autoQueries }, + queriesByLocation: { local: localQueries, remote: remoteQueries }, + dailyTrend, + hourlyPattern, + activeSessions, + migrations, + comparison, + }; + + setDetail(result); + setIsLoading(false); + } catch (err) { + console.warn('[useProviderDetail] Failed to refresh:', err); + if (mountedRef.current) { + setIsLoading(false); + } + } + }, [toolType, sessions]); + + useEffect(() => { + mountedRef.current = true; + setIsLoading(true); + refresh(); + return () => { + mountedRef.current = false; + }; + }, [refresh]); + + // Re-fetch when time range changes + useEffect(() => { + refresh(); + }, [timeRange]); // eslint-disable-line react-hooks/exhaustive-deps + + return { detail, isLoading, refresh }; +} diff --git a/src/renderer/hooks/useProviderHealth.ts b/src/renderer/hooks/useProviderHealth.ts new file mode 100644 index 000000000..9b2d34654 --- /dev/null +++ b/src/renderer/hooks/useProviderHealth.ts @@ -0,0 +1,281 @@ +/** + * useProviderHealth - Live provider health data with auto-refresh + * + * Combines agent detection, error stats, usage stats, and session counts into + * per-provider health data. Polls on an interval and refreshes + * immediately on failover suggestions and new query events. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { Session, AgentConfig } from '../types'; +import type { ToolType } from '../../shared/types'; +import type { ProviderErrorStats, ProviderSwitchConfig } from '../../shared/account-types'; +import type { StatsTimeRange } from '../../shared/stats-types'; +import { DEFAULT_PROVIDER_SWITCH_CONFIG } from '../../shared/account-types'; +import { getAgentDisplayName } from '../services/contextGroomer'; +import type { HealthStatus } from '../components/ProviderHealthCard'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ProviderUsageStats { + queryCount: number; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheReadTokens: number; + totalCacheCreationTokens: number; + totalCostUsd: number; + totalDurationMs: number; + avgDurationMs: number; +} + +const EMPTY_USAGE_STATS: ProviderUsageStats = { + queryCount: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreationTokens: 0, + totalCostUsd: 0, + totalDurationMs: 0, + avgDurationMs: 0, +}; + +export interface ProviderHealth { + toolType: ToolType; + available: boolean; + displayName: string; + activeSessionCount: number; + errorStats: ProviderErrorStats | null; + usageStats: ProviderUsageStats; + healthPercent: number; + status: HealthStatus; +} + +export interface UsageTotals { + queryCount: number; + totalTokens: number; + totalCostUsd: number; +} + +export interface UseProviderHealthResult { + providers: ProviderHealth[]; + isLoading: boolean; + lastUpdated: number | null; + timeRange: StatsTimeRange; + setTimeRange: (range: StatsTimeRange) => void; + refresh: () => void; + failoverThreshold: number; + hasDegradedProvider: boolean; + hasFailingProvider: boolean; + totals: UsageTotals; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function computeHealthPercent( + available: boolean, + activeSessionCount: number, + errorCount: number, + threshold: number, +): number { + if (!available) return 0; + if (activeSessionCount === 0) return 100; + if (errorCount === 0) return 100; + return Math.max(0, Math.round(100 - (errorCount / threshold) * 100)); +} + +function computeStatus( + available: boolean, + activeSessionCount: number, + errorCount: number, + threshold: number, +): HealthStatus { + if (!available) return 'not_installed'; + if (activeSessionCount === 0) return 'idle'; + if (errorCount === 0) return 'healthy'; + if (errorCount >= threshold) return 'failing'; + return 'degraded'; +} + +// ============================================================================ +// Hook +// ============================================================================ + +const DEFAULT_REFRESH_INTERVAL = 10_000; + +/** Aggregate raw query events into per-provider usage stats */ +function aggregateUsageByProvider( + events: Array<{ agentType: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheCreationTokens?: number; costUsd?: number; duration?: number }>, +): Record { + const byProvider: Record = {}; + + for (const e of events) { + if (!byProvider[e.agentType]) { + byProvider[e.agentType] = { ...EMPTY_USAGE_STATS }; + } + const acc = byProvider[e.agentType]; + acc.queryCount += 1; + acc.totalInputTokens += e.inputTokens ?? 0; + acc.totalOutputTokens += e.outputTokens ?? 0; + acc.totalCacheReadTokens += e.cacheReadTokens ?? 0; + acc.totalCacheCreationTokens += e.cacheCreationTokens ?? 0; + acc.totalCostUsd += e.costUsd ?? 0; + acc.totalDurationMs += e.duration ?? 0; + } + + // Compute averages + for (const stats of Object.values(byProvider)) { + stats.avgDurationMs = stats.queryCount > 0 + ? Math.round(stats.totalDurationMs / stats.queryCount) + : 0; + } + + return byProvider; +} + +export function useProviderHealth( + sessions: Session[] | undefined, + refreshIntervalMs: number = DEFAULT_REFRESH_INTERVAL, +): UseProviderHealthResult { + const [providers, setProviders] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [lastUpdated, setLastUpdated] = useState(null); + const [timeRange, setTimeRange] = useState('day'); + const [totals, setTotals] = useState({ queryCount: 0, totalTokens: 0, totalCostUsd: 0 }); + const [failoverThreshold, setFailoverThreshold] = useState( + DEFAULT_PROVIDER_SWITCH_CONFIG.errorThreshold, + ); + const intervalRef = useRef | null>(null); + const timeRangeRef = useRef(timeRange); + timeRangeRef.current = timeRange; + + const refresh = useCallback(async () => { + try { + // Fetch availability, error stats, failover config, and usage stats in parallel + const [agents, errorStatsRecord, savedConfig, queryEvents] = await Promise.all([ + window.maestro.agents.detect() as Promise, + window.maestro.providers.getAllErrorStats() as Promise>, + window.maestro.settings.get('providerSwitchConfig') as Promise | null>, + window.maestro.stats.getStats(timeRangeRef.current) as Promise>, + ]); + + const threshold = (savedConfig as Partial)?.errorThreshold + ?? DEFAULT_PROVIDER_SWITCH_CONFIG.errorThreshold; + setFailoverThreshold(threshold); + + const usageByProvider = aggregateUsageByProvider(queryEvents); + + // Compute totals across all providers + let totalQueries = 0; + let totalTokens = 0; + let totalCost = 0; + for (const stats of Object.values(usageByProvider)) { + totalQueries += stats.queryCount; + totalTokens += stats.totalInputTokens + stats.totalOutputTokens; + totalCost += stats.totalCostUsd; + } + setTotals({ queryCount: totalQueries, totalTokens, totalCostUsd: totalCost }); + + const sessionList = sessions ?? []; + + const healthData: ProviderHealth[] = agents + .filter((a) => a.id !== 'terminal' && !a.hidden) + .map((agent) => { + const toolType = agent.id as ToolType; + const activeCount = sessionList.filter( + (s) => s.toolType === toolType && !s.archivedByMigration, + ).length; + const errorStats = errorStatsRecord[toolType] ?? null; + const errorCount = errorStats?.totalErrorsInWindow ?? 0; + + const healthPercent = computeHealthPercent( + agent.available, + activeCount, + errorCount, + threshold, + ); + const status = computeStatus( + agent.available, + activeCount, + errorCount, + threshold, + ); + + return { + toolType, + available: agent.available, + displayName: getAgentDisplayName(toolType), + activeSessionCount: activeCount, + errorStats, + usageStats: usageByProvider[toolType] ?? { ...EMPTY_USAGE_STATS }, + healthPercent, + status, + }; + }); + + setProviders(healthData); + setLastUpdated(Date.now()); + setIsLoading(false); + } catch (err) { + console.warn('[useProviderHealth] Failed to refresh:', err); + setIsLoading(false); + } + }, [sessions]); + + // Initial fetch + polling interval + useEffect(() => { + refresh(); + + intervalRef.current = setInterval(refresh, refreshIntervalMs); + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [refresh, refreshIntervalMs]); + + // Re-fetch when time range changes + useEffect(() => { + refresh(); + }, [timeRange]); // eslint-disable-line react-hooks/exhaustive-deps + + // Subscribe to failover suggestions for immediate refresh (Task 4) + useEffect(() => { + const cleanups: (() => void)[] = []; + + const c1 = window.maestro.providers?.onFailoverSuggest?.(() => refresh()); + if (c1) cleanups.push(c1); + + const c2 = window.maestro.stats?.onStatsUpdate?.(() => refresh()); + if (c2) cleanups.push(c2); + + return () => cleanups.forEach((fn) => fn()); + }, [refresh]); + + const hasDegradedProvider = providers.some( + (p) => p.status === 'degraded' || p.status === 'failing', + ); + const hasFailingProvider = providers.some((p) => p.status === 'failing'); + + return { + providers, + isLoading, + lastUpdated, + timeRange, + setTimeRange, + refresh, + failoverThreshold, + hasDegradedProvider, + hasFailingProvider, + totals, + }; +} diff --git a/src/renderer/services/contextGroomer.ts b/src/renderer/services/contextGroomer.ts index c8abc901d..a5474132e 100644 --- a/src/renderer/services/contextGroomer.ts +++ b/src/renderer/services/contextGroomer.ts @@ -287,7 +287,8 @@ export class ContextGroomingService { const groomedText = await window.maestro.context.groomContext( targetProjectRoot, request.targetAgent, - prompt + prompt, + request.accountId ? { accountId: request.accountId } : undefined ); onProgress({ diff --git a/src/renderer/stores/agentStore.ts b/src/renderer/stores/agentStore.ts index 4c99e0cce..9621738b0 100644 --- a/src/renderer/stores/agentStore.ts +++ b/src/renderer/stores/agentStore.ts @@ -224,9 +224,11 @@ export const useAgentStore = create()((set, get) => ({ get().clearAgentError(sessionId); - // Switch to terminal mode for re-auth - useSessionStore.getState().setActiveSessionId(sessionId); - updateSession(sessionId, (s) => ({ ...s, inputMode: 'terminal' })); + // Trigger automatic auth recovery via main process + // This will spawn `claude login`, open browser for OAuth, and respawn the agent + window.maestro.accounts.triggerAuthRecovery(sessionId).catch((err) => { + console.error('[authenticateAfterError] Auth recovery failed:', err); + }); }, processQueuedItem: async (sessionId, item, deps) => { diff --git a/src/renderer/stores/modalStore.ts b/src/renderer/stores/modalStore.ts index b385ea745..59128dab7 100644 --- a/src/renderer/stores/modalStore.ts +++ b/src/renderer/stores/modalStore.ts @@ -218,7 +218,9 @@ export type ModalId = // Platform Warnings | 'windowsWarning' // Director's Notes - | 'directorNotes'; + | 'directorNotes' + // Virtuosos (Account Management) + | 'virtuosos'; /** * Type mapping from ModalId to its data type. @@ -757,6 +759,10 @@ export function getModalActions() { setDirectorNotesOpen: (open: boolean) => open ? openModal('directorNotes') : closeModal('directorNotes'), + // Virtuosos Modal + setVirtuososOpen: (open: boolean) => + open ? openModal('virtuosos') : closeModal('virtuosos'), + // Lightbox refs replacement - use updateModalData instead setLightboxIsGroupChat: (isGroupChat: boolean) => updateModalData('lightbox', { isGroupChat }), setLightboxAllowDelete: (allowDelete: boolean) => updateModalData('lightbox', { allowDelete }), @@ -846,6 +852,7 @@ export function useModalActions() { const symphonyModalOpen = useModalStore(selectModalOpen('symphony')); const windowsWarningModalOpen = useModalStore(selectModalOpen('windowsWarning')); const directorNotesOpen = useModalStore(selectModalOpen('directorNotes')); + const virtuososOpen = useModalStore(selectModalOpen('virtuosos')); // Get stable actions const actions = getModalActions(); @@ -1014,6 +1021,9 @@ export function useModalActions() { // Director's Notes Modal directorNotesOpen, + // Virtuosos Modal + virtuososOpen, + // Lightbox ref replacements (now stored as data) lightboxIsGroupChat: lightboxData?.isGroupChat ?? false, lightboxAllowDelete: lightboxData?.allowDelete ?? false, diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index 9f4d4dbef..3abbb8ac1 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -118,6 +118,7 @@ export const DEFAULT_ONBOARDING_STATS: OnboardingStats = { export const DEFAULT_ENCORE_FEATURES: EncoreFeatureFlags = { directorNotes: false, + virtuosos: false, }; export const DEFAULT_DIRECTOR_NOTES_SETTINGS: DirectorNotesSettings = { diff --git a/src/renderer/types/contextMerge.ts b/src/renderer/types/contextMerge.ts index bb908f235..99a803139 100644 --- a/src/renderer/types/contextMerge.ts +++ b/src/renderer/types/contextMerge.ts @@ -46,6 +46,8 @@ export interface MergeRequest { targetProjectRoot: string; /** Optional custom prompt for the grooming agent */ groomingPrompt?: string; + /** Account ID to inherit for the grooming agent (for account multiplexing) */ + accountId?: string; } /** diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index a195552b0..b2a5952b5 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -705,6 +705,25 @@ export interface Session { // Symphony contribution metadata (only set for Symphony sessions) symphonyMetadata?: SymphonySessionMetadata; + + /** Account ID assigned to this session for multiplexing */ + accountId?: string; + /** Display name of the assigned account (for UI display without lookup) */ + accountName?: string; + + // Provider migration provenance (Virtuosos vertical swapping) + /** ID of the session this was migrated FROM (null if original) */ + migratedFromSessionId?: string; + /** ID of the session this was migrated TO (set on source after switch) */ + migratedToSessionId?: string; + /** Timestamp of the provider migration */ + migratedAt?: number; + /** Whether this session was auto-archived after provider switch */ + archivedByMigration?: boolean; + /** Migration generation counter (0 = original, increments with each switch) */ + migrationGeneration?: number; + /** Timestamp of last merge-back (when an archived session was reactivated with new context) */ + lastMergeBackAt?: number; } export interface AgentConfigOption { @@ -905,6 +924,7 @@ export interface LeaderboardSubmitResponse { // Each key is a feature ID, value indicates whether it's enabled export interface EncoreFeatureFlags { directorNotes: boolean; + virtuosos: boolean; } // Director's Notes settings for synopsis generation diff --git a/src/renderer/utils/tabHelpers.ts b/src/renderer/utils/tabHelpers.ts index 5b8bd3669..75602e39f 100644 --- a/src/renderer/utils/tabHelpers.ts +++ b/src/renderer/utils/tabHelpers.ts @@ -1459,6 +1459,24 @@ export interface CreateMergedSessionOptions { saveToHistory?: boolean; /** Thinking display mode: 'off' | 'on' (temporary) | 'sticky' (persistent) */ showThinking?: ThinkingMode; + + // --- Identity carry-over (provider switching) --- + /** Nudge message from source session */ + nudgeMessage?: string; + /** Whether the session is bookmarked */ + bookmarked?: boolean; + /** SSH remote configuration from source session */ + sessionSshRemoteConfig?: Session['sessionSshRemoteConfig']; + /** Auto Run folder path override (defaults to standard path if not provided) */ + autoRunFolderPath?: string; + + // --- Provenance (provider switching) --- + /** ID of the session this was migrated from */ + migratedFromSessionId?: string; + /** Timestamp of the migration */ + migratedAt?: number; + /** Migration generation counter (0 = original, increments) */ + migrationGeneration?: number; } /** @@ -1572,8 +1590,18 @@ export function createMergedSession( activeFileTabId: null, unifiedTabOrder: [{ type: 'ai' as const, id: tabId }], unifiedClosedTabHistory: [], - // Default Auto Run folder path (user can change later) - autoRunFolderPath: getAutoRunFolderPath(projectRoot), + // Default Auto Run folder path (user can change later, provider switch can override) + autoRunFolderPath: options.autoRunFolderPath ?? getAutoRunFolderPath(projectRoot), + + // Identity carry-over (provider switching) + ...(options.nudgeMessage != null && { nudgeMessage: options.nudgeMessage }), + ...(options.bookmarked != null && { bookmarked: options.bookmarked }), + ...(options.sessionSshRemoteConfig != null && { sessionSshRemoteConfig: options.sessionSshRemoteConfig }), + + // Provenance (provider switching) + ...(options.migratedFromSessionId != null && { migratedFromSessionId: options.migratedFromSessionId }), + ...(options.migratedAt != null && { migratedAt: options.migratedAt }), + ...(options.migrationGeneration != null && { migrationGeneration: options.migrationGeneration }), }; return { session, tabId }; diff --git a/src/shared/account-types.ts b/src/shared/account-types.ts new file mode 100644 index 000000000..50d458199 --- /dev/null +++ b/src/shared/account-types.ts @@ -0,0 +1,195 @@ +/** + * Account multiplexing types for managing multiple Claude Code accounts. + * Supports usage monitoring, limit tracking, and automatic account switching. + */ + +/** Unique identifier for an account (generated UUID) */ +export type AccountId = string; + +/** Current operational status of an account */ +export type AccountStatus = 'active' | 'throttled' | 'expired' | 'disabled'; + +/** How the account was authenticated */ +export type AccountAuthMethod = 'oauth' | 'api-key'; + +/** Agent types that support account multiplexing */ +export type MultiplexableAgent = 'claude-code' | 'codex' | 'opencode' | 'factory-droid' | 'gemini-cli'; + +/** A registered account profile */ +export interface AccountProfile { + id: AccountId; + /** Display name derived from OAuth email (e.g., "dr3@example.com") */ + name: string; + /** OAuth email identity — used as the unique natural key */ + email: string; + /** Absolute path to the account's config directory (e.g., "/home/user/.claude-personal") */ + configDir: string; + /** Agent type this account is for */ + agentType: MultiplexableAgent; + /** Current operational status */ + status: AccountStatus; + /** Authentication method used */ + authMethod: AccountAuthMethod; + /** When the account was added to Maestro (ms timestamp) */ + addedAt: number; + /** When the account was last used (ms timestamp) */ + lastUsedAt: number; + /** When the account was last throttled (ms timestamp, 0 if never) */ + lastThrottledAt: number; + /** User-configured token limit per time window (0 = no limit configured) */ + tokenLimitPerWindow: number; + /** Time window for the token limit in milliseconds (default: 5 hours) */ + tokenWindowMs: number; + /** Whether this account is the default for new sessions */ + isDefault: boolean; + /** Whether auto-switching is enabled for this account */ + autoSwitchEnabled: boolean; +} + +/** Token usage snapshot for a single account within a time window */ +export interface AccountUsageSnapshot { + accountId: AccountId; + /** Total input tokens consumed in the current window */ + inputTokens: number; + /** Total output tokens consumed in the current window */ + outputTokens: number; + /** Total cache read tokens consumed in the current window */ + cacheReadTokens: number; + /** Total cache creation tokens consumed in the current window */ + cacheCreationTokens: number; + /** Estimated cost in USD for the current window */ + costUsd: number; + /** Window start time (ms timestamp) */ + windowStart: number; + /** Window end time (ms timestamp) */ + windowEnd: number; + /** Number of queries made in the current window */ + queryCount: number; + /** Estimated percentage of limit used (0-100, null if no limit configured) */ + usagePercent: number | null; +} + +/** Real-time assignment of an account to a session */ +export interface AccountAssignment { + sessionId: string; + accountId: AccountId; + /** When this assignment was made (ms timestamp) */ + assignedAt: number; +} + +/** Configuration for the account switching behavior */ +export interface AccountSwitchConfig { + /** Whether auto-switching is globally enabled */ + enabled: boolean; + /** Whether to prompt the user before switching (default: true) */ + promptBeforeSwitch: boolean; + /** Usage percentage threshold that triggers a switch warning (default: 80) */ + warningThresholdPercent: number; + /** Usage percentage threshold that triggers auto-switch (default: 95) */ + autoSwitchThresholdPercent: number; + /** Strategy for selecting the next account */ + selectionStrategy: 'least-used' | 'round-robin'; +} + +/** Event emitted when an account switch occurs or is suggested */ +export interface AccountSwitchEvent { + sessionId: string; + fromAccountId: AccountId; + toAccountId: AccountId; + reason: 'throttled' | 'limit-approaching' | 'manual' | 'auth-expired'; + /** Whether the switch was automatic (true) or user-initiated (false) */ + automatic: boolean; + /** Timestamp of the event (ms) */ + timestamp: number; +} + +/** Aggregated usage data for the capacity planner */ +export interface AccountCapacityMetrics { + /** Average tokens per hour across all accounts over the analysis window */ + avgTokensPerHour: number; + /** Peak tokens per hour observed */ + peakTokensPerHour: number; + /** Number of throttle events in the analysis window */ + throttleEvents: number; + /** Estimated accounts needed to avoid interruptions */ + recommendedAccountCount: number; + /** Analysis window duration in milliseconds */ + analysisWindowMs: number; +} + +/** Default values for account switch configuration */ +export const ACCOUNT_SWITCH_DEFAULTS: AccountSwitchConfig = { + enabled: false, + promptBeforeSwitch: true, + warningThresholdPercent: 80, + autoSwitchThresholdPercent: 95, + selectionStrategy: 'least-used', +}; + +/** Default token window: 5 hours in milliseconds */ +export const DEFAULT_TOKEN_WINDOW_MS = 5 * 60 * 60 * 1000; + +import type { ToolType, AgentErrorType } from './types'; + +/** Controls what happens when switching back to a provider with an existing archived session. */ +export type ProviderSwitchBehavior = 'always-new' | 'merge-back'; + +/** + * Configuration for automated provider failover (Virtuosos vertical swapping). + * Stored in settings alongside account switch config. + */ +export interface ProviderSwitchConfig { + /** Whether auto-provider-failover is enabled */ + enabled: boolean; + /** Whether to prompt user before auto-switching */ + promptBeforeSwitch: boolean; + /** Consecutive error count threshold before suggesting failover */ + errorThreshold: number; + /** Time window for error counting (ms) */ + errorWindowMs: number; + /** Ordered list of fallback providers (tried in order) */ + fallbackProviders: ToolType[]; + /** Default behavior when switching back to a provider with an archived session */ + switchBehavior: ProviderSwitchBehavior; +} + +export const DEFAULT_PROVIDER_SWITCH_CONFIG: ProviderSwitchConfig = { + enabled: false, + promptBeforeSwitch: true, + errorThreshold: 3, + errorWindowMs: 5 * 60 * 1000, // 5 minutes + fallbackProviders: [], + switchBehavior: 'merge-back', +}; + +/** + * Failover suggestion emitted when a provider exceeds the error threshold. + * Sent from main process to renderer via IPC to trigger SwitchProviderModal or auto-switch. + */ +export interface FailoverSuggestion { + sessionId: string; + sessionName: string; + currentProvider: ToolType; + suggestedProvider: ToolType; + errorCount: number; + windowMs: number; + recentErrors: Array<{ + type: AgentErrorType; + message: string; + timestamp: number; + }>; +} + +/** + * Error statistics for a single provider type. + * Used by the ProviderPanel health dashboard. + */ +export interface ProviderErrorStats { + toolType: ToolType; + activeErrorCount: number; + totalErrorsInWindow: number; + lastErrorAt: number | null; + sessionsWithErrors: number; + /** Error count breakdown by error type within the window */ + errorsByType?: Partial>; +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 18e2c9a4f..cb71aac10 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -14,3 +14,4 @@ export * from './emojiUtils'; export * from './treeUtils'; export * from './stringUtils'; export * from './pathUtils'; +export * from './account-types'; diff --git a/src/shared/stats-types.ts b/src/shared/stats-types.ts index 82aa0ebee..d1f266a4f 100644 --- a/src/shared/stats-types.ts +++ b/src/shared/stats-types.ts @@ -18,6 +18,18 @@ export interface QueryEvent { tabId?: string; /** Whether this query was executed on a remote SSH session */ isRemote?: boolean; + /** Account ID for per-account usage tracking */ + accountId?: string; + /** Input tokens consumed by this query */ + inputTokens?: number; + /** Output tokens produced by this query */ + outputTokens?: number; + /** Cache read tokens for this query */ + cacheReadTokens?: number; + /** Cache creation tokens for this query */ + cacheCreationTokens?: number; + /** Estimated cost in USD for this query */ + costUsd?: number; } /** @@ -95,6 +107,8 @@ export interface StatsAggregation { avgSessionDuration: number; /** Queries and duration by provider per day (for provider comparison) */ byAgentByDay: Record>; + /** Queries and duration by provider per hour of day (for provider detail hourly chart) */ + byAgentByHour: Record>; /** Queries and duration by Maestro session per day (for agent usage chart) */ bySessionByDay: Record>; } @@ -112,4 +126,4 @@ export interface StatsFilters { /** * Database schema version for migrations */ -export const STATS_DB_VERSION = 3; +export const STATS_DB_VERSION = 4;