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(
+
+ );
+ });
+
+ 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 ? (
+
setIsOpen((v) => !v)}
+ className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-full cursor-pointer transition-all hover:brightness-125"
+ style={{
+ color: currentAccountId ? theme.colors.textMain : theme.colors.textDim,
+ backgroundColor: currentAccountId
+ ? `${theme.colors.accent}20`
+ : `${theme.colors.border}30`,
+ border: currentAccountId
+ ? `1px solid ${theme.colors.accent}50`
+ : `1px solid ${theme.colors.border}60`,
+ }}
+ title={currentAccountId ? `Virtuoso: ${displayName}` : 'Select virtuoso'}
+ >
+
+
+ {currentAccountId ? displayName.split('@')[0] : 'No Virtuoso'}
+
+
+
+ ) : (
+
setIsOpen((v) => !v)}
+ className="flex items-center gap-1.5 text-xs px-2 py-1 rounded border transition-colors hover:bg-white/5"
+ style={{
+ borderColor: theme.colors.border,
+ color: theme.colors.textMain,
+ backgroundColor: theme.colors.bgMain,
+ }}
+ >
+
+ {displayName}
+
+
+ )}
+
+ {/* Dropdown */}
+ {isOpen && (
+
+
+ {accounts.length === 0 && (
+
+ No virtuosos configured
+
+ )}
+ {accounts.map((account) => {
+ const isCurrent = account.id === currentAccountId;
+ const statusColor = getStatusColor(account.status, theme);
+ return (
+
handleSelect(account.id)}
+ className="w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-white/5"
+ style={{
+ backgroundColor: isCurrent ? `${theme.colors.accent}10` : undefined,
+ }}
+ >
+ {/* Status dot */}
+
+ {/* Account info */}
+
+
+ {account.name || account.email}
+
+ {/* Usage bar with real-time data */}
+ {(() => {
+ const usage = usageMetrics[account.id];
+ if (!usage || usage.usagePercent === null) return null;
+ return (
+
+
+
= 95
+ ? theme.colors.error
+ : usage.usagePercent >= 80
+ ? theme.colors.warning
+ : theme.colors.accent,
+ }}
+ />
+
+
+
+ {formatTokenCount(usage.totalTokens)} /{' '}
+ {formatTokenCount(usage.limitTokens)}
+
+ {formatTimeRemaining(usage.timeRemainingMs)}
+
+
+ );
+ })()}
+
+ {/* Current indicator */}
+ {isCurrent && (
+
+ active
+
+ )}
+
+ );
+ })}
+
+ {/* Manage Accounts link */}
+ {onManageAccounts && (
+
+ {
+ setIsOpen(false);
+ onManageAccounts();
+ }}
+ className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors hover:bg-white/5"
+ style={{ color: theme.colors.textDim }}
+ >
+
+ Manage Virtuosos
+
+
+ )}
+
+ )}
+
+ );
+}
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={
+
+
+
+ View All Virtuosos
+
+
+
+ Stay on Current
+
+ {onConfirmSwitchAndResume && (
+
+ Switch & Resume
+
+ )}
+
+ Switch Virtuoso
+
+
+ }
+ >
+
+ {/* 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 => (
+ setView(v)}
+ className={`text-xs px-2 py-1 rounded ${view === v ? 'font-bold' : ''}`}
+ style={{
+ backgroundColor: view === v ? theme.colors.accent + '20' : 'transparent',
+ color: view === v ? theme.colors.accent : theme.colors.textDim,
+ }}
+ >
+ {v === '7d' ? 'Last 7 Days' : v === '30d' ? '30 Days' : 'Monthly'}
+
+ ))}
+
+
+ {/* 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 */}
+
+
+
+
+ Registered Virtuosos
+
+
+ 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 */}
+
setHistoryExpandedId(
+ historyExpandedId === account.id ? null : account.id
+ )}
+ className="mt-2 flex items-center gap-1.5 text-xs hover:underline"
+ style={{ color: theme.colors.textDim }}
+ >
+
+ {historyExpandedId === account.id ? 'Hide' : 'Usage'} History
+ {historyExpandedId === account.id
+ ?
+ :
+ }
+
+ {historyExpandedId === account.id && (
+
+ )}
+
+ );
+ })()}
+
+
+ setEditingAccountId(
+ editingAccountId === account.id
+ ? null
+ : account.id
+ )
+ }
+ className="p-1.5 rounded hover:bg-white/10 transition-colors"
+ title="Configure"
+ style={{ color: theme.colors.textDim }}
+ >
+ {editingAccountId === account.id ? (
+
+ ) : (
+
+ )}
+
+ {account.status === 'expired' && (
+
+ handleUpdateAccount(account.id, {
+ status: 'active',
+ })
+ }
+ className="px-2 py-1 rounded text-xs font-bold transition-colors hover:bg-white/10"
+ title="Mark as active after re-login"
+ style={{
+ color: theme.colors.success,
+ border: `1px solid ${theme.colors.success}`,
+ }}
+ >
+ Reactivate
+
+ )}
+ {!account.isDefault && (
+ handleSetDefault(account.id)}
+ className="p-1.5 rounded hover:bg-white/10 transition-colors"
+ title="Set as default"
+ style={{ color: theme.colors.textDim }}
+ >
+
+
+ )}
+ handleRemoveAccount(account.id)}
+ className="p-1.5 rounded hover:bg-white/10 transition-colors"
+ title="Remove account"
+ style={{ color: theme.colors.textDim }}
+ >
+
+
+
+
+
+ {/* Expanded per-account configuration */}
+ {editingAccountId === account.id && (
+
+ {/* Plan preset + token limit */}
+
+
+ Plan preset / Token limit per window
+
+
+ p.tokens === account.tokenLimitPerWindow)?.label ?? 'Custom'}
+ onChange={(e) => {
+ const preset = PLAN_PRESETS.find(p => p.label === e.target.value);
+ if (preset && preset.tokens > 0) {
+ handleUpdateAccount(account.id, { tokenLimitPerWindow: preset.tokens });
+ }
+ }}
+ className="flex-1 p-2 rounded border bg-transparent outline-none text-xs"
+ style={{
+ borderColor: theme.colors.border,
+ color: theme.colors.textMain,
+ backgroundColor: theme.colors.bgMain,
+ }}
+ >
+ {PLAN_PRESETS.map(p => (
+
+ {p.label}{p.tokens > 0 ? ` (${formatTokenCount(p.tokens)})` : ''}
+
+ ))}
+
+
+ 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}
+ />
+
+
+
+
+
+
+ Window duration
+
+
+ handleUpdateAccount(account.id, {
+ tokenWindowMs: parseInt(e.target.value),
+ })
+ }
+ className="w-full p-2 rounded border bg-transparent outline-none text-xs"
+ style={{
+ borderColor: theme.colors.border,
+ color: theme.colors.textMain,
+ backgroundColor: theme.colors.bgMain,
+ }}
+ >
+ {WINDOW_DURATION_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+
+
+
+ Auto-switch enabled
+
+
+ handleUpdateAccount(account.id, {
+ autoSwitchEnabled: !account.autoSwitchEnabled,
+ })
+ }
+ className="w-8 h-4 rounded-full transition-colors relative"
+ style={{
+ backgroundColor: account.autoSwitchEnabled
+ ? theme.colors.accent
+ : theme.colors.bgActivity,
+ }}
+ >
+
+
+
+
+
+
+ handleSyncCredentials(account.configDir)
+ }
+ className="flex items-center gap-1 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
+ style={{
+ color: theme.colors.accent,
+ border: `1px solid ${theme.colors.accent}`,
+ }}
+ >
+
+ Sync Auth
+
+
+ handleValidateSymlinks(account.configDir)
+ }
+ className="flex items-center gap-1 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
+ style={{
+ color: theme.colors.textDim,
+ border: `1px solid ${theme.colors.border}`,
+ }}
+ >
+
+ Validate Symlinks
+
+
+ handleRepairSymlinks(account.configDir)
+ }
+ className="flex items-center gap-1 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
+ style={{
+ color: theme.colors.textDim,
+ border: `1px solid ${theme.colors.border}`,
+ }}
+ >
+
+ Repair Symlinks
+
+
+
+ )}
+
+ ))}
+
+
+ );
+ });
+ })()}
+
+ )}
+
+
+ {/* Add Virtuoso Section */}
+
+
+ Add Virtuoso
+
+
+
+
+
+ {isDiscovering ? 'Searching...' : 'Discover Existing'}
+
+
+
+ {/* 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
+
+ )}
+
+
+
handleImportDiscovered(d)}
+ className="flex items-center gap-1 px-2 py-1 rounded text-xs font-bold transition-colors"
+ style={{
+ backgroundColor: theme.colors.accent,
+ color: theme.colors.accentForeground,
+ }}
+ >
+
+ Import
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Error message */}
+ {errorMessage && (
+
+
+
+
+ {errorMessage}
+
+
setErrorMessage(null)}
+ className="text-xs mt-1 underline"
+ style={{ color: theme.colors.textDim }}
+ >
+ Dismiss
+
+
+
+ )}
+
+ {/* Create new account */}
+
+
+ Create New Virtuoso
+
+
+ {createStep === 'idle' && (
+
+
+
setNewAccountProvider(e.target.value as MultiplexableAgent)}
+ className="p-2 rounded border bg-transparent outline-none text-xs"
+ style={{
+ borderColor: theme.colors.border,
+ color: theme.colors.textMain,
+ backgroundColor: theme.colors.bgMain,
+ }}
+ >
+ {ACCOUNT_PROVIDERS.map(p => (
+
+ {PROVIDER_DISPLAY_NAMES[p] || p}
+
+ ))}
+
+
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()}
+ />
+
+
+ {isCreating ? 'Creating...' : 'Create & Login'}
+
+
+
+ )}
+
+ {createStep === 'login-ready' && (
+
+
+ Directory created at{' '}
+ {createdConfigDir} . Run the
+ following command in a terminal to log in:
+
+
+ {loginCommand}
+
+
+
+
+ Login Complete
+
+ {
+ setCreateStep('idle');
+ setCreatedConfigDir('');
+ setLoginCommand('');
+ }}
+ className="px-3 py-2 rounded text-xs transition-colors hover:bg-white/10"
+ style={{ color: theme.colors.textDim }}
+ >
+ Cancel
+
+
+
+ )}
+
+ {createStep === 'created' && (
+
+
+ Directory created at{' '}
+ {createdConfigDir} . Could
+ not determine login command. Please authenticate manually and
+ click "Login Complete".
+
+
+
+
+ Login Complete
+
+ {
+ setCreateStep('idle');
+ setCreatedConfigDir('');
+ setLoginCommand('');
+ }}
+ className="px-3 py-2 rounded text-xs transition-colors hover:bg-white/10"
+ style={{ color: theme.colors.textDim }}
+ >
+ Cancel
+
+
+
+ )}
+
+
+
+ {/* Global Switch Configuration */}
+
+
+ Auto-Switch Configuration
+
+
+
+ {/* Enable/disable auto-switching */}
+
+
+ Enable auto-switching
+
+
+ handleUpdateSwitchConfig({ enabled: !switchConfig.enabled })
+ }
+ className="w-8 h-4 rounded-full transition-colors relative"
+ style={{
+ backgroundColor: switchConfig.enabled
+ ? theme.colors.accent
+ : theme.colors.bgActivity,
+ }}
+ >
+
+
+
+
+ {/* Prompt before switch */}
+
+
+ Prompt before switching
+
+
+ handleUpdateSwitchConfig({
+ promptBeforeSwitch: !switchConfig.promptBeforeSwitch,
+ })
+ }
+ className="w-8 h-4 rounded-full transition-colors relative"
+ style={{
+ backgroundColor: switchConfig.promptBeforeSwitch
+ ? theme.colors.accent
+ : theme.colors.bgActivity,
+ }}
+ >
+
+
+
+
+ {/* Warning threshold */}
+
+
+
+ Warning threshold
+
+
+ {switchConfig.warningThresholdPercent}%
+
+
+
+ handleUpdateSwitchConfig({
+ warningThresholdPercent: parseInt(e.target.value),
+ })
+ }
+ className="w-full"
+ style={{ accentColor: theme.colors.accent }}
+ />
+
+
+ {/* Auto-switch threshold */}
+
+
+
+ Auto-switch threshold
+
+
+ {switchConfig.autoSwitchThresholdPercent}%
+
+
+
+ handleUpdateSwitchConfig({
+ autoSwitchThresholdPercent: parseInt(e.target.value),
+ })
+ }
+ className="w-full"
+ style={{ accentColor: theme.colors.accent }}
+ />
+
+
+ {/* Selection strategy */}
+
+
+ Selection strategy
+
+
+ handleUpdateSwitchConfig({
+ selectionStrategy: e.target.value as
+ | 'least-used'
+ | 'round-robin',
+ })
+ }
+ className="w-full p-2 rounded border bg-transparent outline-none text-xs"
+ style={{
+ borderColor: theme.colors.border,
+ color: theme.colors.textMain,
+ backgroundColor: theme.colors.bgMain,
+ }}
+ >
+ Least Used
+ Round Robin
+
+
+
+
+
+ );
+}
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
+
+ window.maestro.accounts.checkRecovery()}
+ className="text-xs underline opacity-70 hover:opacity-100 transition-opacity ml-auto flex-shrink-0"
+ style={{ color: theme.colors.accent }}
+ >
+ Check Now
+
+ {onAbortBatchOnError && (
+
+
+ Abort
+
+ )}
+
+
+ )}
+
{/* 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 && (
0 ? Math.max(...tab.logs.map((l) => l.timestamp)) : tab.createdAt,
+ accountId: session.accountId,
+ accountName: session.accountName,
});
}
}
@@ -455,7 +460,14 @@ export function MergeSessionModal({
await onMerge(target.sessionId, target.tabId, options);
onClose();
} catch (error) {
- console.error('Merge failed:', error);
+ Sentry.captureException(error, {
+ extra: {
+ operation: 'session:merge',
+ viewMode,
+ targetSessionId: target.sessionId,
+ targetTabId: target.tabId,
+ },
+ });
} finally {
setIsMerging(false);
}
@@ -901,6 +913,19 @@ export function MergeSessionModal({
{item.agentSessionId.split('-')[0].toUpperCase()}
)}
+ {item.accountId && (
+
+ ({item.accountName || item.accountId})
+
+ )}
+ {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) */}
Agent Provider
-
- {agentName}
+
+
+ {agentName}
+
+ {onSwitchProvider && (
+
{
+ onSwitchProvider();
+ }}
+ className="px-3 py-2 rounded border text-xs transition-colors hover:bg-white/5"
+ style={{ borderColor: theme.colors.border, color: theme.colors.accent }}
+ >
+ Switch...
+
+ )}
- 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' && (
+
+
+ Account
+
+
+
+ Claude account used for this agent. Changing takes effect on next message.
+
+
+ )}
+
{/* Nudge Message */}
)}
+ {/* Account badge — show if session has an account assigned */}
+ {(() => {
+ const sess = sessions.find(s => s.id === node.agentSessionId);
+ if (sess?.accountName) {
+ return (
+
+ {sess.accountName}
+
+ );
+ }
+ return null;
+ })()}
{/* Kill button */}
{node.processSessionId && (
+
+ {/* 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 */}
+
+
+ {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 (
+
+
+
+ Back to Providers
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+
+
+
+ );
+ }
+
+ if (!detail) {
+ return (
+
+
+
+ Back to Providers
+
+
+ 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 */}
+
{
+ e.currentTarget.style.opacity = '0.8';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.opacity = '1';
+ }}
+ >
+
+ Back to Providers
+
+
+ {/* 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:
+ setTimeRange(e.target.value as StatsTimeRange)}
+ style={{
+ backgroundColor: theme.colors.bgMain,
+ color: theme.colors.textMain,
+ border: `1px solid ${theme.colors.border}`,
+ borderRadius: 4,
+ padding: '2px 6px',
+ fontSize: 11,
+ }}
+ >
+ {TIME_RANGE_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+
+ Auto-refresh: 10s
+
+
+
+ Refresh
+
+
+
+
+
+ {/* Failover Configuration */}
+
+
Automatic Failover
+
+ {/* Enable automatic failover toggle */}
+
+
+
Enable automatic failover
+
+ When a provider hits repeated errors, suggest switching to an
+ alternative provider.
+
+
+
saveConfig({ enabled: !config.enabled })}
+ className="w-8 h-4 rounded-full transition-colors relative flex-shrink-0 ml-3"
+ style={{
+ backgroundColor: config.enabled
+ ? theme.colors.accent
+ : theme.colors.bgActivity,
+ }}
+ >
+
+
+
+
+ {/* Prompt before switching toggle */}
+
+
+
Prompt before switching
+
+ Ask for confirmation before auto-switching. Uncheck for fully
+ automatic failover.
+
+
+
+ saveConfig({ promptBeforeSwitch: !config.promptBeforeSwitch })
+ }
+ className="w-8 h-4 rounded-full transition-colors relative flex-shrink-0 ml-3"
+ style={{
+ backgroundColor: config.promptBeforeSwitch
+ ? theme.colors.accent
+ : theme.colors.bgActivity,
+ }}
+ >
+
+
+
+
+ {/* Error threshold and window */}
+
+
+ Error threshold:
+
+ saveConfig({ errorThreshold: parseInt(e.target.value) })
+ }
+ style={{
+ backgroundColor: theme.colors.bgMain,
+ color: theme.colors.textMain,
+ border: `1px solid ${theme.colors.border}`,
+ borderRadius: 4,
+ padding: '2px 6px',
+ fontSize: 12,
+ }}
+ >
+ {Array.from({ length: 10 }, (_, i) => i + 1).map((n) => (
+
+ {n}
+
+ ))}
+
+ consecutive errors
+
+
+ Error window:
+
+ saveConfig({ errorWindowMs: parseInt(e.target.value) })
+ }
+ style={{
+ backgroundColor: theme.colors.bgMain,
+ color: theme.colors.textMain,
+ border: `1px solid ${theme.colors.border}`,
+ borderRadius: 4,
+ padding: '2px 6px',
+ fontSize: 12,
+ }}
+ >
+ {ERROR_WINDOW_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+
+ {/* Fallback priority list */}
+
+
+ Fallback priority:
+
+ {config.fallbackProviders.length === 0 && (
+
+ No fallback providers configured
+
+ )}
+ {config.fallbackProviders.map((toolType, index) => (
+
+ {index + 1}.
+
+ {getAgentIcon(toolType)}
+
+
+ {getAgentDisplayName(toolType)}
+
+ handleMoveFallback(index, 'up')}
+ disabled={index === 0}
+ className="p-0.5 rounded transition-colors"
+ style={{
+ color:
+ index === 0
+ ? theme.colors.textDim + '40'
+ : theme.colors.textDim,
+ }}
+ title="Move up"
+ >
+
+
+ handleMoveFallback(index, 'down')}
+ disabled={
+ index === config.fallbackProviders.length - 1
+ }
+ className="p-0.5 rounded transition-colors"
+ style={{
+ color:
+ index === config.fallbackProviders.length - 1
+ ? theme.colors.textDim + '40'
+ : theme.colors.textDim,
+ }}
+ title="Move down"
+ >
+
+
+ handleRemoveFallback(toolType)}
+ className="p-0.5 rounded transition-colors"
+ style={{ color: theme.colors.textDim }}
+ title="Remove"
+ >
+
+
+
+ ))}
+
+ {/* Add provider dropdown */}
+ {availableForFallback.length > 0 && (
+
+ )}
+
+
+
+ {/* Switch Behavior */}
+
+
Switch Behavior
+
+ When switching back to a provider that already has an archived session:
+
+
+
+ saveConfig({ switchBehavior: 'merge-back' })}
+ />
+
+
+ Merge & update
+
+
+ Reactivate the archived session and append new context
+
+
+
+
+ saveConfig({ switchBehavior: 'always-new' })}
+ />
+
+
+ Always new
+
+
+ Create a fresh session each time
+
+
+
+
+
+
+ {/* 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 && (
+
setShowMoreHistory(true)}
+ className="text-xs mt-2 hover:underline"
+ style={{ color: theme.colors.accent }}
+ >
+ Show more ({migrations.length - MIGRATION_HISTORY_LIMIT}{' '}
+ remaining)
+
+ )}
+ {showMoreHistory && hasMoreMigrations && (
+
setShowMoreHistory(false)}
+ className="text-xs mt-2 hover:underline"
+ style={{ color: theme.colors.accent }}
+ >
+ Show less
+
+ )}
+
+ )}
+
+
+ );
+}
+
+// ============================================================================
+// 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 (
+
+
setIsOpen(!isOpen)}
+ className="flex items-center gap-1 text-xs px-3 py-1.5 rounded transition-colors"
+ style={{
+ color: theme.colors.accent,
+ backgroundColor: `${theme.colors.accent}10`,
+ border: `1px solid ${theme.colors.accent}30`,
+ }}
+ >
+
+ Add provider
+
+ {isOpen && (
+
+ {providers.map((p) => (
+ {
+ onAdd(p.id);
+ setIsOpen(false);
+ }}
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors"
+ style={{ color: theme.colors.textMain }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = `${theme.colors.accent}10`;
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ >
+ {p.icon}
+ {p.name}
+ {!p.available && (
+
+ (not installed)
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
+
+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' && (
+ {
+ onSwitchProvider();
+ onDismiss();
+ }}
+ className="w-full text-left px-3 py-1.5 text-xs hover:bg-white/5 transition-colors flex items-center gap-2"
+ style={{ color: theme.colors.textMain }}
+ >
+
+ Switch Provider...
+
+ )}
+
+ {/* Unarchive (only for migration-archived sessions) */}
+ {onUnarchive && session.archivedByMigration && (
+ {
+ onUnarchive();
+ onDismiss();
+ }}
+ className="w-full text-left px-3 py-1.5 text-xs hover:bg-white/5 transition-colors flex items-center gap-2"
+ style={{ color: theme.colors.accent }}
+ >
+
+ Unarchive
+
+ )}
+
+ {/* Account info - non-clickable info item */}
+ {session.accountId && (
+
+
+ Account: {session.accountName || session.accountId}
+
+ )}
+
{/* Duplicate */}
{
@@ -446,6 +496,7 @@ interface HamburgerMenuContentProps {
setAboutModalOpen: (open: boolean) => void;
setMenuOpen: (open: boolean) => void;
setQuickActionOpen: (open: boolean) => void;
+ setVirtuososOpen?: (open: boolean) => void;
}
function HamburgerMenuContent({
@@ -466,6 +517,7 @@ function HamburgerMenuContent({
setAboutModalOpen,
setMenuOpen,
setQuickActionOpen,
+ setVirtuososOpen,
}: HamburgerMenuContentProps) {
return (
@@ -609,6 +661,25 @@ function HamburgerMenuContent({
{formatShortcutKeys(shortcuts.settings.keys)}
+ {setVirtuososOpen && (
+
{
+ setVirtuososOpen(true);
+ setMenuOpen(false);
+ }}
+ className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-white/10 transition-colors text-left"
+ >
+
+
+
+ Virtuosos
+
+
+ AI Provider Accounts
+
+
+
+ )}
{
setLogViewerOpen(true);
@@ -936,6 +1007,14 @@ const SessionTooltipContent = memo(function SessionTooltipContent({
{session.state} • {session.toolType}
{session.sessionSshRemoteConfig?.enabled ? ' (SSH)' : ''}
+ {(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 */}
+
+
setEncoreFeatures({ ...encoreFeatures, virtuosos: !encoreFeatures.virtuosos })}
+ >
+
+
+
+
+ Virtuosos
+
+
+ Multi-account multiplexing for AI provider rate limits
+
+
+
+
+
+
)}
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={
+
+
+
+ Cancel
+
+
+ {archivedPredecessor && mergeChoice === 'merge' ? 'Merge & Switch' : 'Switch Provider'}
+
+
+ }
+ >
+ 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 (
+
{
+ setSelectedProvider(provider.id);
+ if (selectableIndex >= 0) setHighlightedIndex(selectableIndex);
+ }}
+ className="w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors disabled:cursor-not-allowed border-b last:border-b-0"
+ style={{
+ borderColor: theme.colors.border,
+ backgroundColor: isSelected
+ ? `${theme.colors.accent}15`
+ : isHighlighted
+ ? `${theme.colors.accent}08`
+ : 'transparent',
+ opacity: isAvailable ? 1 : 0.5,
+ }}
+ >
+ {/* Radio indicator */}
+
+ {isSelected && (
+
+ )}
+
+
+ {/* Agent icon */}
+ {getAgentIcon(provider.id)}
+
+ {/* Name */}
+
+ {provider.name}
+
+
+ {/* Availability badge */}
+
+
+ {isAvailable ? 'available' : 'Not Installed'}
+
+
+ );
+ })
+ )}
+
+
+
+ {/* 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 */}
+
+
+ setMergeChoice('new')}
+ className="mt-0.5"
+ />
+
+
+ Create new session
+
+
+ Start fresh on {getAgentDisplayName(selectedProvider)} with transferred context
+ (creates a new agent entry)
+
+
+
+
+ setMergeChoice('merge')}
+ className="mt-0.5"
+ />
+
+
+ Merge & update existing session
+
+
+ Reactivate the archived {getAgentDisplayName(selectedProvider)} session and
+ append current context to it
+
+
+
+
+
+ )}
+
+ {/* Options */}
+
+
+ Options
+
+
+
+ setGroomContext(e.target.checked)}
+ className="mt-0.5 rounded"
+ />
+
+
+ Groom context for target provider
+
+
+ Remove agent-specific artifacts and adapt conversation for the target provider
+
+
+
+
+ setArchiveSource(e.target.checked)}
+ className="mt-0.5 rounded"
+ />
+
+
+ Archive source session
+
+
+ Dim the original session in the sidebar
+
+
+
+
+
+
+ {/* 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 && (
-
-
- {sessionName}
-
+
+
+
+ {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={
+
+ handleKeyDown(e, onClose)}
+ className="px-3 py-1.5 rounded border hover:bg-white/5 transition-colors outline-none focus:ring-2 focus:ring-offset-1 text-xs whitespace-nowrap mr-auto"
+ style={{
+ borderColor: theme.colors.border,
+ color: theme.colors.textMain,
+ }}
+ >
+ Cancel
+
+ handleKeyDown(e, handleArchiveConflicting)}
+ className="px-3 py-1.5 rounded transition-colors outline-none focus:ring-2 focus:ring-offset-1 text-xs whitespace-nowrap"
+ style={{
+ backgroundColor: theme.colors.accent,
+ color: theme.colors.accentForeground,
+ }}
+ >
+ Archive “{conflictName}”
+
+ handleKeyDown(e, handleDeleteConflicting)}
+ className="px-3 py-1.5 rounded transition-colors outline-none focus:ring-2 focus:ring-offset-1 text-xs whitespace-nowrap"
+ style={{
+ backgroundColor: theme.colors.error,
+ color: '#ffffff',
+ }}
+ >
+ Delete “{conflictName}”
+
+
+ }
+ >
+
+
+
+
+ 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 => (
+ setRange(r)}
+ style={{
+ fontSize: small ? 9 : 10,
+ padding: small ? '1px 4px' : '2px 6px',
+ borderRadius: 3,
+ border: 'none',
+ cursor: 'pointer',
+ fontWeight: range === r ? 700 : 400,
+ backgroundColor: range === r ? theme.colors.accent + '25' : theme.colors.bgActivity,
+ color: range === r ? theme.colors.accent : theme.colors.textDim,
+ lineHeight: 1.2,
+ }}
+ >
+ {TIME_RANGE_LABELS[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}
+
+ Retry
+
+
+ );
+ }
+
+ 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
+
+ ) : (
+
+
+
+
+
+ Session
+
+
+ Account
+
+
+ Agent
+
+
+ Assigned
+
+
+ Status
+
+
+
+
+ {assignments.map((assignment) => {
+ const session = sessionMap.get(assignment.sessionId);
+ const account = accountMap.get(assignment.accountId);
+ return (
+
+
+ {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
+
+ ) : (
+
+
+
+
+
+ Time
+
+
+ Account
+
+
+ Reason
+
+
+ Tokens
+
+
+
+
+ {throttleEvents
+ .slice()
+ .reverse()
+ .map((event, i) => {
+ const account = accountMap.get(event.accountId);
+ return (
+
+
+ {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) => (
+
+
+ setExpandedAccountId(expandedAccountId === account.id ? null : account.id)
+ }
+ className="w-full flex items-center gap-2 py-2 px-2 rounded-lg text-xs text-left transition-colors"
+ style={{ color: theme.colors.textMain }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = `${theme.colors.accent}10`;
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ >
+ {expandedAccountId === account.id ? (
+
+ ) : (
+
+ )}
+ {account.name || account.email}
+
+ {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 (
+ setActiveTab(tab.value)}
+ className="px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
+ style={{
+ backgroundColor:
+ activeTab === tab.value
+ ? `${theme.colors.accent}20`
+ : 'transparent',
+ color:
+ activeTab === tab.value
+ ? theme.colors.accent
+ : theme.colors.textDim,
+ }}
+ onMouseEnter={(e) => {
+ if (activeTab !== tab.value) {
+ e.currentTarget.style.backgroundColor = `${theme.colors.accent}10`;
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (activeTab !== tab.value) {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }
+ }}
+ role="tab"
+ aria-selected={activeTab === tab.value}
+ aria-controls={`tabpanel-${tab.value}`}
+ id={`virtuoso-tab-${tab.value}`}
+ tabIndex={-1}
+ >
+
+ {tab.label}
+ {tab.value === 'providers' && hasDegradedProvider && (
+
+ )}
+
+ );
+ })}
+
+
+ {/* 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;