diff --git a/package.json b/package.json index afeebaa0..9809f68c 100644 --- a/package.json +++ b/package.json @@ -143,6 +143,7 @@ "motion": "12.38.0", "node-diff3": "^3.2.0", "node-pty": "^1.1.0", + "pidusage": "4.0.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-grid-layout": "^2.2.2", @@ -174,6 +175,7 @@ "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "@types/node": "^25.0.7", + "@types/pidusage": "2.0.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/ssh2": "^1.15.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66dc8138..e5b3e08f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,6 +239,9 @@ importers: node-pty: specifier: ^1.1.0 version: 1.1.0 + pidusage: + specifier: 4.0.1 + version: 4.0.1 react: specifier: ^19.0.0 version: 19.2.4 @@ -327,6 +330,9 @@ importers: '@types/node': specifier: ^25.0.7 version: 25.0.7 + '@types/pidusage': + specifier: 2.0.5 + version: 2.0.5 '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -451,13 +457,13 @@ importers: version: 0.11.3(magicast@0.5.2)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))) '@vueuse/nuxt': specifier: ^10.11.1 - version: 10.11.1(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)) + version: 10.11.1(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)) nuxt: specifier: ^3.20.2 - version: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + version: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2) nuxt-icon: specifier: ^0.6.10 - version: 0.6.10(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)) + version: 0.6.10(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3)) pinia: specifier: ^3.0.4 version: 3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) @@ -482,7 +488,7 @@ importers: version: 7.4.47 '@nuxt/eslint': specifier: ^1.12.1 - version: 1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) eslint: specifier: ^9.39.2 version: 9.39.2(jiti@2.6.1) @@ -494,7 +500,7 @@ importers: version: 1.98.0 vite-plugin-vuetify: specifier: ^2.1.3 - version: 2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3) + version: 2.1.3(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3) mcp-server: dependencies: @@ -4353,6 +4359,9 @@ packages: '@types/pg@8.15.6': resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/pidusage@2.0.5': + resolution: {integrity: sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==} + '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} @@ -8591,6 +8600,10 @@ packages: engines: {node: '>=0.10'} hasBin: true + pidusage@4.0.1: + resolution: {integrity: sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==} + engines: {node: '>=18'} + pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -12589,20 +12602,20 @@ snapshots: '@nuxt/devalue@2.0.2': {} - '@nuxt/devtools-kit@1.7.0(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@nuxt/devtools-kit@1.7.0(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))': dependencies: '@nuxt/kit': 3.21.2(magicast@0.5.2) '@nuxt/schema': 3.21.2 execa: 7.2.0 - vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0) transitivePeerDependencies: - magicast - '@nuxt/devtools-kit@3.2.4(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@nuxt/devtools-kit@3.2.4(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))': dependencies: '@nuxt/kit': 4.4.2(magicast@0.5.2) execa: 8.0.1 - vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0) transitivePeerDependencies: - magicast @@ -12617,9 +12630,9 @@ snapshots: pkg-types: 2.3.0 semver: 7.7.4 - '@nuxt/devtools@3.2.4(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))': + '@nuxt/devtools@3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3))': dependencies: - '@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) '@nuxt/devtools-wizard': 3.2.4 '@nuxt/kit': 4.4.2(magicast@0.5.2) '@vue/devtools-core': 8.1.0(vue@3.5.30(typescript@5.9.3)) @@ -12647,9 +12660,9 @@ snapshots: sirv: 3.0.2 structured-clone-es: 2.0.0 tinyglobby: 0.2.15 - vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - vite-plugin-inspect: 11.3.3(@nuxt/kit@4.4.2(magicast@0.5.2))(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) - vite-plugin-vue-tracer: 1.3.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)) + vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0) + vite-plugin-inspect: 11.3.3(@nuxt/kit@4.4.2(magicast@0.5.2))(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) + vite-plugin-vue-tracer: 1.3.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3)) which: 6.0.1 ws: 8.20.0 transitivePeerDependencies: @@ -12698,10 +12711,10 @@ snapshots: - supports-color - typescript - '@nuxt/eslint@1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@nuxt/eslint@1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))': dependencies: '@eslint/config-inspector': 1.5.0(eslint@9.39.2(jiti@2.6.1)) - '@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) '@nuxt/eslint-config': 1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@nuxt/eslint-plugin': 1.15.2(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@nuxt/kit': 4.4.2(magicast@0.5.2) @@ -12777,7 +12790,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@3.21.2(db0@0.3.4)(encoding@0.1.13)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(typescript@5.9.3)': + '@nuxt/nitro-server@3.21.2(db0@0.3.4)(encoding@0.1.13)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(typescript@5.9.3)': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 3.21.2(magicast@0.5.2) @@ -12795,7 +12808,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 nitropack: 2.13.2(encoding@0.1.13)(idb-keyval@6.2.2) - nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2) ohash: 2.0.11 pathe: 2.0.3 pkg-types: 2.3.0 @@ -12860,7 +12873,7 @@ snapshots: rc9: 3.0.0 std-env: 3.10.0 - '@nuxt/vite-builder@3.21.2(@types/node@25.0.7)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2)': + '@nuxt/vite-builder@3.21.2(@types/node@25.0.7)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2)': dependencies: '@nuxt/kit': 3.21.2(magicast@0.5.2) '@rollup/plugin-replace': 6.0.3(rollup@4.60.0) @@ -12879,7 +12892,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2) nypm: 0.6.5 ohash: 2.0.11 pathe: 2.0.3 @@ -15002,6 +15015,8 @@ snapshots: pg-protocol: 1.13.0 pg-types: 2.2.0 + '@types/pidusage@2.0.5': {} + '@types/plist@3.0.5': dependencies: '@types/node': 25.0.7 @@ -15595,13 +15610,13 @@ snapshots: '@vueuse/metadata@10.11.1': {} - '@vueuse/nuxt@10.11.1(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))': + '@vueuse/nuxt@10.11.1(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))': dependencies: '@nuxt/kit': 3.21.2(magicast@0.5.2) '@vueuse/core': 10.11.1(vue@3.5.30(typescript@5.9.3)) '@vueuse/metadata': 10.11.1 local-pkg: 0.5.1 - nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2) vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3)) transitivePeerDependencies: - '@vue/composition-api' @@ -20143,27 +20158,27 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt-icon@0.6.10(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)): + nuxt-icon@0.6.10(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3)): dependencies: '@iconify/collections': 1.0.665 '@iconify/vue': 4.3.0(vue@3.5.30(typescript@5.9.3)) - '@nuxt/devtools-kit': 1.7.0(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@nuxt/devtools-kit': 1.7.0(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) '@nuxt/kit': 3.21.2(magicast@0.5.2) transitivePeerDependencies: - magicast - vite - vue - nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2): + nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2): dependencies: '@dxup/nuxt': 0.4.0(magicast@0.5.2)(typescript@5.9.3) '@nuxt/cli': 3.34.0(@nuxt/schema@3.21.2)(cac@6.7.14)(magicast@0.5.2) - '@nuxt/devtools': 3.2.4(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)) + '@nuxt/devtools': 3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3)) '@nuxt/kit': 3.21.2(magicast@0.5.2) - '@nuxt/nitro-server': 3.21.2(db0@0.3.4)(encoding@0.1.13)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(typescript@5.9.3) + '@nuxt/nitro-server': 3.21.2(db0@0.3.4)(encoding@0.1.13)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(typescript@5.9.3) '@nuxt/schema': 3.21.2 '@nuxt/telemetry': 2.7.0(@nuxt/kit@3.21.2(magicast@0.5.2)) - '@nuxt/vite-builder': 3.21.2(@types/node@25.0.7)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2) + '@nuxt/vite-builder': 3.21.2(@types/node@25.0.7)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2) '@unhead/vue': 2.1.12(vue@3.5.30(typescript@5.9.3)) '@vue/shared': 3.5.30 c12: 3.3.3(magicast@0.5.2) @@ -20647,6 +20662,10 @@ snapshots: pidtree@0.6.0: {} + pidusage@4.0.1: + dependencies: + safe-buffer: 5.2.1 + pify@2.3.0: {} pinia@3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)): @@ -22710,15 +22729,15 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-dev-rpc@1.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + vite-dev-rpc@1.1.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)): dependencies: birpc: 2.9.0 - vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - vite-hot-client: 2.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0) + vite-hot-client: 2.1.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) - vite-hot-client@2.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + vite-hot-client@2.1.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)): dependencies: - vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0) vite-node@3.2.4(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0): dependencies: @@ -22792,7 +22811,7 @@ snapshots: optionator: 0.9.4 typescript: 5.9.3 - vite-plugin-inspect@11.3.3(@nuxt/kit@4.4.2(magicast@0.5.2))(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + vite-plugin-inspect@11.3.3(@nuxt/kit@4.4.2(magicast@0.5.2))(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)): dependencies: ansis: 4.2.0 debug: 4.4.3 @@ -22802,29 +22821,29 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - vite-dev-rpc: 1.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0) + vite-dev-rpc: 1.1.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) optionalDependencies: '@nuxt/kit': 4.4.2(magicast@0.5.2) transitivePeerDependencies: - supports-color - vite-plugin-vue-tracer@1.3.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)): + vite-plugin-vue-tracer@1.3.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3)): dependencies: estree-walker: 3.0.3 exsolve: 1.0.8 magic-string: 0.30.21 pathe: 2.0.3 source-map-js: 1.2.1 - vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0) vue: 3.5.30(typescript@5.9.3) - vite-plugin-vuetify@2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3): + vite-plugin-vuetify@2.1.3(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3): dependencies: '@vuetify/loader-shared': 2.1.2(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3) debug: 4.4.3 upath: 2.0.1 - vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0) vue: 3.5.30(typescript@5.9.3) vuetify: 3.12.3(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.30(typescript@5.9.3)) transitivePeerDependencies: @@ -23023,7 +23042,7 @@ snapshots: vue: 3.5.30(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 - vite-plugin-vuetify: 2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3) + vite-plugin-vuetify: 2.1.3(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3) w3c-keyname@2.2.8: {} diff --git a/src/features/tmux-installer/main/composition/runtimeSupport.ts b/src/features/tmux-installer/main/composition/runtimeSupport.ts index ee6c41dc..183b918b 100644 --- a/src/features/tmux-installer/main/composition/runtimeSupport.ts +++ b/src/features/tmux-installer/main/composition/runtimeSupport.ts @@ -18,6 +18,12 @@ export async function killTmuxPaneForCurrentPlatform(paneId: string): Promise> { + return runtimeCommandExecutor.listPanePids(paneIds); +} + export function killTmuxPaneForCurrentPlatformSync(paneId: string): void { runtimeCommandExecutor.killPaneSync(paneId); invalidateTmuxRuntimeStatusCache(); diff --git a/src/features/tmux-installer/main/index.ts b/src/features/tmux-installer/main/index.ts index deddfc10..21c41e9f 100644 --- a/src/features/tmux-installer/main/index.ts +++ b/src/features/tmux-installer/main/index.ts @@ -9,4 +9,5 @@ export { isTmuxRuntimeReadyForCurrentPlatform, killTmuxPaneForCurrentPlatform, killTmuxPaneForCurrentPlatformSync, + listTmuxPanePidsForCurrentPlatform, } from './composition/runtimeSupport'; diff --git a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts index 948c86ba..4b062134 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts @@ -54,6 +54,36 @@ export class TmuxPlatformCommandExecutor { } } + async listPanePids(paneIds: readonly string[]): Promise> { + const normalizedPaneIds = [...new Set(paneIds.map((paneId) => paneId.trim()).filter(Boolean))]; + if (normalizedPaneIds.length === 0) { + return new Map(); + } + + const result = await this.execTmux( + ['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'], + 3_000 + ); + if (result.exitCode !== 0) { + throw new Error(result.stderr || 'Failed to list tmux panes'); + } + + const wanted = new Set(normalizedPaneIds); + const panePidById = new Map(); + for (const line of result.stdout.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + const [paneId = '', rawPid = ''] = trimmed.split('\t'); + const normalizedPaneId = paneId.trim(); + if (!wanted.has(normalizedPaneId)) continue; + const pid = Number.parseInt(rawPid.trim(), 10); + if (Number.isFinite(pid) && pid > 0) { + panePidById.set(normalizedPaneId, pid); + } + } + return panePidById; + } + killPaneSync(paneId: string): void { if (process.platform === 'win32') { const preferredDistro = this.#wslService.getPersistedPreferredDistroSync(); diff --git a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts index 314fdbdc..376afc79 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts @@ -68,4 +68,26 @@ describe('TmuxPlatformCommandExecutor', () => { }) ); }); + + it('lists pane pids for the requested pane ids only', async () => { + const executor = new TmuxPlatformCommandExecutor( + { + getPersistedPreferredDistroSync: () => null, + } as never, + {} as never + ); + vi.spyOn(executor, 'execTmux').mockResolvedValue({ + exitCode: 0, + stdout: '%1\t111\n%2\t222\n%3\tnot-a-pid\n', + stderr: '', + }); + + await expect(executor.listPanePids(['%2', '%3', '%2'])).resolves.toEqual( + new Map([['%2', 222]]) + ); + expect(executor.execTmux).toHaveBeenCalledWith( + ['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'], + 3_000 + ); + }); }); diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index c11931e3..39760009 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -18,6 +18,7 @@ import { TEAM_DELETE_TEAM, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, + TEAM_GET_AGENT_RUNTIME, TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, @@ -51,6 +52,7 @@ import { TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REPLACE_MEMBERS, TEAM_REQUEST_REVIEW, + TEAM_RESTART_MEMBER, TEAM_RESTORE, TEAM_RESTORE_TASK, TEAM_SAVE_TASK_ATTACHMENT, @@ -163,6 +165,7 @@ import type { LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, + TeamAgentRuntimeSnapshot, MemberSpawnStatusesSnapshot, MessagesPage, SendMessageRequest, @@ -578,6 +581,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity); ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext); ipcMain.handle(TEAM_MEMBER_SPAWN_STATUSES, handleMemberSpawnStatuses); + ipcMain.handle(TEAM_GET_AGENT_RUNTIME, handleGetAgentRuntime); + ipcMain.handle(TEAM_RESTART_MEMBER, handleRestartMember); ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask); ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask); ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks); @@ -652,6 +657,8 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_LEAD_ACTIVITY); ipcMain.removeHandler(TEAM_LEAD_CONTEXT); ipcMain.removeHandler(TEAM_MEMBER_SPAWN_STATUSES); + ipcMain.removeHandler(TEAM_GET_AGENT_RUNTIME); + ipcMain.removeHandler(TEAM_RESTART_MEMBER); ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK); ipcMain.removeHandler(TEAM_RESTORE_TASK); ipcMain.removeHandler(TEAM_GET_DELETED_TASKS); @@ -2865,6 +2872,37 @@ async function handleMemberSpawnStatuses( ); } +async function handleGetAgentRuntime( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + return wrapTeamHandler('getAgentRuntime', async () => + getTeamProvisioningService().getTeamAgentRuntimeSnapshot(validated.value!) + ); +} + +async function handleRestartMember( + _event: IpcMainInvokeEvent, + teamName: unknown, + memberName: unknown +): Promise> { + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; + } + const validatedMemberName = validateMemberName(memberName); + if (!validatedMemberName.valid) { + return { success: false, error: validatedMemberName.error ?? 'Invalid memberName' }; + } + return wrapTeamHandler('restartMember', async () => + getTeamProvisioningService().restartMember(validatedTeamName.value!, validatedMemberName.value!) + ); +} + async function handleStopTeam( _event: IpcMainInvokeEvent, teamName: unknown diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index fdd13b1d..c6b0d285 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1,4 +1,7 @@ -import { killTmuxPaneForCurrentPlatformSync } from '@features/tmux-installer/main'; +import { + killTmuxPaneForCurrentPlatformSync, + listTmuxPanePidsForCurrentPlatform, +} from '@features/tmux-installer/main'; import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; import { getAppIconPath } from '@main/utils/appIcon'; @@ -14,6 +17,7 @@ import { getTasksBasePath, getTeamsBasePath, } from '@main/utils/pathDecoder'; +import { isProcessAlive } from '@main/utils/processHealth'; import { killProcessByPid } from '@main/utils/processKill'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { shouldAutoAllow } from '@main/utils/toolApprovalRules'; @@ -64,6 +68,7 @@ import { randomUUID } from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import pidusage from 'pidusage'; import { type GeminiRuntimeAuthState, @@ -168,7 +173,11 @@ import type { MemberSpawnStatusEntry, PersistedTeamLaunchPhase, PersistedTeamLaunchSummary, + TeamAgentRuntimeBackendType, + TeamAgentRuntimeEntry, + TeamAgentRuntimeSnapshot, TeamChangeEvent, + TeamConfig, TeamCreateRequest, TeamCreateResponse, TeamLaunchAggregateState, @@ -792,7 +801,38 @@ function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry { } interface LiveTeamAgentRuntimeMetadata { + alive: boolean; + backendType?: TeamAgentRuntimeBackendType; + agentId?: string; + pid?: number; model?: string; + tmuxPaneId?: string; +} + +function normalizeTeamAgentRuntimeBackendType( + value: string | undefined, + isLead: boolean +): TeamAgentRuntimeBackendType | undefined { + if (isLead) return 'lead'; + const normalized = value?.trim().toLowerCase(); + if (normalized === 'tmux' || normalized === 'iterm2' || normalized === 'in-process') { + return normalized; + } + return normalized ? 'process' : undefined; +} + +function matchesMemberNameOrBase(candidateName: string, memberName: string): boolean { + if (candidateName === memberName) { + return true; + } + const parsed = parseNumericSuffixName(candidateName); + return parsed !== null && parsed.suffix >= 2 && parsed.base === memberName; +} + +function matchesTeamMemberIdentity(leftName: string, rightName: string): boolean { + return ( + matchesMemberNameOrBase(leftName, rightName) || matchesMemberNameOrBase(rightName, leftName) + ); } interface MemberSpawnInboxCursor { @@ -939,6 +979,24 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function waitForPidsToExit( + pids: readonly number[], + opts: { timeoutMs: number; pollMs: number } +): Promise { + if (pids.length === 0) { + return; + } + + const deadline = Date.now() + opts.timeoutMs; + while (Date.now() < deadline) { + const remaining = pids.filter((pid) => isProcessAlive(pid)); + if (remaining.length === 0) { + return; + } + await sleep(opts.pollMs); + } +} + async function tryReadRegularFileUtf8( filePath: string, opts: { timeoutMs: number; maxBytes: number } @@ -1484,6 +1542,52 @@ export function buildAddMemberSpawnMessage( ); } +export function buildRestartMemberSpawnMessage( + teamName: string, + displayName: string, + leadName: string, + member: Pick< + TeamCreateRequest['members'][number], + 'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort' + > +): string { + const roleHint = + typeof member.role === 'string' && member.role.trim() + ? ` with role "${member.role.trim()}"` + : ''; + const workflowHint = + typeof member.workflow === 'string' && member.workflow.trim() + ? ` Their workflow: ${member.workflow.trim()}` + : ''; + + const prompt = buildMemberSpawnPrompt( + { + name: member.name, + ...(member.role ? { role: member.role } : {}), + ...(member.workflow ? { workflow: member.workflow } : {}), + ...(member.providerId ? { providerId: member.providerId } : {}), + ...(member.model ? { model: member.model } : {}), + ...(member.effort ? { effort: member.effort } : {}), + }, + displayName, + teamName, + leadName + ); + const providerPart = + member.providerId && member.providerId !== 'anthropic' + ? `, provider="${member.providerId}"` + : ''; + const modelPart = member.model?.trim() ? `, model="${member.model.trim()}"` : ''; + const effortPart = member.effort ? `, effort="${member.effort}"` : ''; + + return ( + `Teammate "${member.name}"${roleHint} was restarted from the UI. ` + + `Please respawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${providerPart}${modelPart}${effortPart}, and the exact prompt below. ` + + `This is a restart of an existing persistent teammate, not a new teammate.${workflowHint ? workflowHint : ''}\n\n` + + indentMultiline(prompt, ' ') + ); +} + interface RuntimeBootstrapMemberSpec { name: string; prompt?: string; @@ -2185,17 +2289,17 @@ interface McpJsonRpcResponse { } interface McpToolsListResult { - tools?: Array<{ + tools?: { name?: string; _meta?: Record; - }>; + }[]; } interface McpToolCallResult { - content?: Array<{ + content?: { type?: string; text?: string; - }>; + }[]; isError?: boolean; } @@ -2366,6 +2470,7 @@ export class TeamProvisioningService { private static readonly SAME_TEAM_MATCH_WINDOW_MS = 30_000; private static readonly SAME_TEAM_RUN_START_SKEW_MS = 1_000; private static readonly SAME_TEAM_PERSIST_RETRY_MS = 2_000; + private static readonly AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS = 2_000; private readonly runs = new Map(); private readonly provisioningRunByTeam = new Map(); @@ -2382,6 +2487,14 @@ export class TeamProvisioningService { string, NativeSameTeamFingerprint[] >(); + private readonly agentRuntimeSnapshotCache = new Map< + string, + { expiresAtMs: number; snapshot: TeamAgentRuntimeSnapshot } + >(); + private readonly liveTeamAgentRuntimeMetadataCache = new Map< + string, + { expiresAtMs: number; metadata: Map } + >(); private readonly launchStateStore = new TeamLaunchStateStore(); private readonly memberLogsFinder: TeamMemberLogsFinder; private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; @@ -3811,17 +3924,18 @@ export class TeamProvisioningService { const runId = this.getTrackedRunId(teamName); if (!runId) { return this.reconcilePersistedLaunchState(teamName).then(({ snapshot, statuses }) => { - this.attachLiveRuntimeMetadataToStatuses(teamName, statuses); - return { - statuses, - runId: null, - teamLaunchState: snapshot?.teamLaunchState, - launchPhase: snapshot?.launchPhase, - expectedMembers: snapshot?.expectedMembers, - updatedAt: snapshot?.updatedAt, - summary: snapshot?.summary, - source: snapshot ? 'persisted' : 'persisted', - }; + return this.attachLiveRuntimeMetadataToStatuses(teamName, statuses).then( + (nextStatuses) => ({ + statuses: nextStatuses, + runId: null, + teamLaunchState: snapshot?.teamLaunchState, + launchPhase: snapshot?.launchPhase, + expectedMembers: snapshot?.expectedMembers, + updatedAt: snapshot?.updatedAt, + summary: snapshot?.summary, + source: snapshot ? 'persisted' : 'persisted', + }) + ); }); } const run = this.runs.get(runId); @@ -3842,8 +3956,10 @@ export class TeamProvisioningService { statuses: this.buildRuntimeSpawnStatusRecord(run), }); const snapshot = persisted ?? liveSnapshot; - const statuses = snapshotToMemberSpawnStatuses(snapshot); - this.attachLiveRuntimeMetadataToStatuses(teamName, statuses); + const statuses = await this.attachLiveRuntimeMetadataToStatuses( + teamName, + snapshotToMemberSpawnStatuses(snapshot) + ); return { statuses, runId, @@ -3856,6 +3972,266 @@ export class TeamProvisioningService { }; } + async getTeamAgentRuntimeSnapshot(teamName: string): Promise { + const cached = this.agentRuntimeSnapshotCache.get(teamName); + if (cached && cached.expiresAtMs > Date.now()) { + return cached.snapshot; + } + + const updatedAt = nowIso(); + const runId = this.getTrackedRunId(teamName); + const run = runId ? (this.runs.get(runId) ?? null) : null; + + let configuredMembers: TeamConfig['members'] = []; + try { + configuredMembers = (await this.configReader.getConfig(teamName))?.members ?? []; + } catch { + configuredMembers = []; + } + + const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); + const runtimePids = new Set(); + const leadPid = run?.child?.pid; + if (typeof leadPid === 'number' && Number.isFinite(leadPid) && leadPid > 0) { + runtimePids.add(leadPid); + } + for (const metadata of liveRuntimeByMember.values()) { + const memberPid = metadata.pid; + if (typeof memberPid === 'number' && Number.isFinite(memberPid) && memberPid > 0) { + runtimePids.add(memberPid); + } + } + const rssBytesByPid = await this.readProcessRssBytesByPid([...runtimePids]); + const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName); + const snapshotMembers: Record = {}; + + const getPersistedRuntimeMember = ( + memberName: string + ): PersistedRuntimeMemberLike | undefined => { + return persistedRuntimeMembers.find((member) => { + const candidateName = typeof member.name === 'string' ? member.name.trim() : ''; + return candidateName.length > 0 && matchesMemberNameOrBase(candidateName, memberName); + }); + }; + + const getLiveRuntimeMember = (memberName: string): LiveTeamAgentRuntimeMetadata | undefined => { + let fallback: LiveTeamAgentRuntimeMetadata | undefined; + for (const [candidateName, metadata] of liveRuntimeByMember.entries()) { + if (candidateName === memberName) { + return metadata; + } + if (matchesMemberNameOrBase(candidateName, memberName)) { + fallback = metadata; + } + } + return fallback; + }; + + for (const member of configuredMembers) { + const memberName = typeof member?.name === 'string' ? member.name.trim() : ''; + if (!memberName) continue; + + const isLead = isLeadMember({ name: memberName, agentType: member.agentType }); + if (isLead) { + const pid = run?.child?.pid; + const rssBytes = pid ? rssBytesByPid.get(pid) : undefined; + const runtimeModel = + run?.request.model?.trim() || + (run?.spawnContext + ? extractCliFlagValue(run.spawnContext.args.join(' '), '--model') + : undefined) || + member.model?.trim() || + undefined; + snapshotMembers[memberName] = { + memberName, + alive: Boolean(pid && !run?.processKilled && !run?.cancelRequested), + restartable: false, + backendType: 'lead', + ...(pid ? { pid } : {}), + ...(runtimeModel ? { runtimeModel } : {}), + ...(rssBytes != null ? { rssBytes } : {}), + updatedAt, + }; + continue; + } + + const persistedRuntimeMember = getPersistedRuntimeMember(memberName); + const liveRuntimeMember = getLiveRuntimeMember(memberName); + const backendType = normalizeTeamAgentRuntimeBackendType( + persistedRuntimeMember?.backendType, + false + ); + const restartable = backendType !== 'in-process'; + const runtimeModel = liveRuntimeMember?.model ?? member.model?.trim() ?? undefined; + + snapshotMembers[memberName] = { + memberName, + alive: liveRuntimeMember?.alive ?? false, + restartable, + ...(backendType ? { backendType } : {}), + ...(liveRuntimeMember?.pid ? { pid: liveRuntimeMember.pid } : {}), + ...(runtimeModel ? { runtimeModel } : {}), + ...(liveRuntimeMember?.pid && rssBytesByPid.has(liveRuntimeMember.pid) + ? { rssBytes: rssBytesByPid.get(liveRuntimeMember.pid) } + : {}), + updatedAt, + }; + } + + const snapshot: TeamAgentRuntimeSnapshot = { + teamName, + updatedAt, + runId: run?.runId ?? null, + members: snapshotMembers, + }; + + this.agentRuntimeSnapshotCache.set(teamName, { + expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, + snapshot, + }); + return snapshot; + } + + async restartMember(teamName: string, memberName: string): Promise { + const runId = this.getAliveRunId(teamName); + if (!runId) { + throw new Error(`Team "${teamName}" is not currently running`); + } + const run = this.runs.get(runId); + if (!run || run.processKilled || run.cancelRequested) { + throw new Error(`Team "${teamName}" is not currently running`); + } + + const config = await this.configReader.getConfig(teamName); + const configuredMembers = config?.members ?? []; + const configuredMember = configuredMembers.find( + (member) => member?.name?.trim() === memberName + ); + if (!configuredMember) { + throw new Error(`Member "${memberName}" is not configured in team "${teamName}"`); + } + if (configuredMember.removedAt) { + throw new Error(`Member "${memberName}" has been removed`); + } + if (isLeadMember({ name: memberName, agentType: configuredMember.agentType })) { + throw new Error('Lead restart is not supported from member controls'); + } + + const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName).filter((member) => { + const candidateName = typeof member.name === 'string' ? member.name.trim() : ''; + return candidateName.length > 0 && matchesMemberNameOrBase(candidateName, memberName); + }); + + const backendTypes = new Set( + persistedRuntimeMembers + .map((member) => member.backendType?.trim().toLowerCase()) + .filter((value): value is string => Boolean(value)) + ); + if (backendTypes.has('in-process')) { + throw new Error( + `Member "${memberName}" uses an in-process runtime and cannot be restarted here` + ); + } + + const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); + const livePids = new Set(); + let hasAliveRuntimeWithoutPid = false; + for (const [candidateName, metadata] of liveRuntimeByMember.entries()) { + if (!matchesMemberNameOrBase(candidateName, memberName)) { + continue; + } + if (metadata.pid) { + livePids.add(metadata.pid); + continue; + } + if (metadata.alive && metadata.backendType !== 'in-process') { + hasAliveRuntimeWithoutPid = true; + } + } + + if (hasAliveRuntimeWithoutPid) { + throw new Error( + `Member "${memberName}" is running, but its backend does not expose a restartable pid yet` + ); + } + + for (const persistedRuntimeMember of persistedRuntimeMembers) { + const paneId = + typeof persistedRuntimeMember.tmuxPaneId === 'string' + ? persistedRuntimeMember.tmuxPaneId.trim() + : ''; + const backendType = persistedRuntimeMember.backendType?.trim().toLowerCase(); + if (!paneId || backendType !== 'tmux') { + continue; + } + try { + killTmuxPaneForCurrentPlatformSync(paneId); + logger.info( + `[${teamName}] Killed teammate pane ${memberName} (${paneId}) for manual restart` + ); + } catch (error) { + logger.debug( + `[${teamName}] Failed to kill teammate pane ${memberName} (${paneId}) for manual restart: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + for (const pid of livePids) { + try { + killProcessByPid(pid); + } catch (error) { + logger.debug( + `[${teamName}] Failed to kill teammate process ${memberName} pid=${pid} for manual restart: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + if (livePids.size > 0) { + await waitForPidsToExit(Array.from(livePids), { + timeoutMs: 1_500, + pollMs: 100, + }); + } + + this.agentRuntimeSnapshotCache.delete(teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.setMemberSpawnStatus(run, memberName, 'offline'); + this.setMemberSpawnStatus(run, memberName, 'spawning'); + this.appendMemberBootstrapDiagnostic(run, memberName, 'manual restart requested from UI'); + + const leadName = + configuredMembers.find((member) => isLeadMember(member))?.name?.trim() || 'team-lead'; + const restartMessage = buildRestartMemberSpawnMessage( + teamName, + config?.name?.trim() || teamName, + leadName, + { + name: memberName, + role: configuredMember.role, + workflow: configuredMember.workflow, + providerId: configuredMember.providerId, + model: configuredMember.model, + effort: configuredMember.effort, + } + ); + + try { + await this.sendMessageToRun(run, restartMessage); + } catch (error) { + this.setMemberSpawnStatus( + run, + memberName, + 'error', + error instanceof Error ? error.message : String(error) + ); + throw error; + } + } + private getMemberLaunchGraceKey(run: ProvisioningRun, memberName: string): string { return `member-launch-grace:${run.runId}:${memberName}`; } @@ -7271,7 +7647,7 @@ export class TeamProvisioningService { return; } - const liveAgentNames = this.getLiveTeamAgentNames(run.teamName); + const liveAgentNames = await this.getLiveTeamAgentNames(run.teamName); // Flag any expected member not found in config.json (excluding the lead) for (const expected of run.expectedMembers) { @@ -7381,63 +7757,310 @@ export class TeamProvisioningService { } } - private hasLiveTeamAgentProcess(teamName: string, memberName: string): boolean { - return this.getLiveTeamAgentRuntimeMetadata(teamName).has(memberName); - } - - private attachLiveRuntimeMetadataToStatuses( + private async attachLiveRuntimeMetadataToStatuses( teamName: string, statuses: Record - ): void { - for (const [memberName, metadata] of this.getLiveTeamAgentRuntimeMetadata(teamName).entries()) { - const current = statuses[memberName]; + ): Promise> { + const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); + const nextStatuses = { ...statuses }; + for (const [memberName, metadata] of runtimeByMember.entries()) { + const current = nextStatuses[memberName]; if (!current || !metadata.model) { continue; } - statuses[memberName] = { + nextStatuses[memberName] = { ...current, runtimeModel: metadata.model, }; } + return nextStatuses; } - private getLiveTeamAgentNames(teamName: string): Set { - return new Set(this.getLiveTeamAgentRuntimeMetadata(teamName).keys()); + private async getLiveTeamAgentNames(teamName: string): Promise> { + const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); + return new Set( + [...runtimeByMember.entries()] + .filter(([, metadata]) => metadata.alive) + .map(([memberName]) => memberName) + ); } - private getLiveTeamAgentRuntimeMetadata( + private findConfiguredMemberModel( + configuredMembers: TeamConfig['members'] | undefined, + memberName: string + ): string | undefined { + for (const member of configuredMembers ?? []) { + const candidateName = typeof member?.name === 'string' ? member.name.trim() : ''; + if (!candidateName || !matchesTeamMemberIdentity(candidateName, memberName)) { + continue; + } + const model = member.model?.trim(); + if (model) { + return model; + } + } + return undefined; + } + + private findMetaMemberModel( + metaMembers: Awaited>, + memberName: string + ): string | undefined { + for (const member of metaMembers) { + const candidateName = member.name?.trim() ?? ''; + if (!candidateName || !matchesTeamMemberIdentity(candidateName, memberName)) { + continue; + } + const model = member.model?.trim(); + if (model) { + return model; + } + } + return undefined; + } + + private findEffectiveRunMemberModel( + run: ProvisioningRun | null, + memberName: string + ): string | undefined { + if (!run) { + return undefined; + } + for (const member of run.effectiveMembers ?? []) { + const candidateName = member.name?.trim() ?? ''; + if (!candidateName || !matchesTeamMemberIdentity(candidateName, memberName)) { + continue; + } + const model = member.model?.trim(); + if (model) { + return model; + } + } + return undefined; + } + + private findTrackedMemberSpawnStatus( + run: ProvisioningRun | null, + memberName: string + ): MemberSpawnStatusEntry | undefined { + if (!run) { + return undefined; + } + const statusMap = run.memberSpawnStatuses instanceof Map ? run.memberSpawnStatuses : undefined; + if (!statusMap) { + return undefined; + } + const direct = statusMap.get(memberName); + if (direct) { + return direct; + } + for (const [candidateName, entry] of statusMap.entries()) { + if (matchesTeamMemberIdentity(candidateName, memberName)) { + return entry; + } + } + return undefined; + } + + private async getLiveTeamAgentRuntimeMetadata( teamName: string - ): Map { + ): Promise> { + const cached = this.liveTeamAgentRuntimeMetadataCache.get(teamName); + if (cached && cached.expiresAtMs > Date.now()) { + return cached.metadata; + } + + const runId = this.getTrackedRunId(teamName); + const run = runId ? (this.runs.get(runId) ?? null) : null; + + let configuredMembers: TeamConfig['members'] = []; + try { + configuredMembers = (await this.configReader.getConfig(teamName))?.members ?? []; + } catch { + configuredMembers = []; + } + + let metaMembers: Awaited> = []; + try { + metaMembers = await this.membersMetaStore.getMembers(teamName); + } catch { + metaMembers = []; + } + + const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName); + const metadataByMember = new Map(); + const upsertMetadata = ( + memberName: string, + patch: Partial + ): void => { + const current = metadataByMember.get(memberName) ?? { alive: false }; + metadataByMember.set(memberName, { + ...current, + ...patch, + alive: patch.alive ?? current.alive, + }); + }; + + for (const member of persistedRuntimeMembers) { + const memberName = typeof member.name === 'string' ? member.name.trim() : ''; + if (!memberName || isLeadMember({ name: memberName })) { + continue; + } + const runtimeModel = + this.findConfiguredMemberModel(configuredMembers, memberName) ?? + this.findEffectiveRunMemberModel(run, memberName) ?? + this.findMetaMemberModel(metaMembers, memberName); + upsertMetadata(memberName, { + backendType: normalizeTeamAgentRuntimeBackendType(member.backendType, false), + agentId: + typeof member.agentId === 'string' ? member.agentId.trim() || undefined : undefined, + tmuxPaneId: + typeof member.tmuxPaneId === 'string' ? member.tmuxPaneId.trim() || undefined : undefined, + ...(runtimeModel ? { model: runtimeModel } : {}), + }); + } + + for (const member of configuredMembers) { + const memberName = typeof member?.name === 'string' ? member.name.trim() : ''; + if (!memberName || isLeadMember({ name: memberName, agentType: member.agentType })) { + continue; + } + const runtimeModel = + member.model?.trim() || + this.findEffectiveRunMemberModel(run, memberName) || + this.findMetaMemberModel(metaMembers, memberName); + upsertMetadata(memberName, { + ...(runtimeModel ? { model: runtimeModel } : {}), + }); + } + + for (const member of run?.effectiveMembers ?? []) { + const memberName = member.name?.trim() ?? ''; + if (!memberName || isLeadMember(member) || memberName.toLowerCase() === 'user') { + continue; + } + upsertMetadata(memberName, { + ...(member.model?.trim() ? { model: member.model.trim() } : {}), + }); + } + + const paneIds = [...metadataByMember.values()] + .map((metadata) => metadata.tmuxPaneId?.trim() ?? '') + .filter((paneId) => paneId.length > 0); + let panePidById = new Map(); + if (paneIds.length > 0) { + try { + panePidById = await listTmuxPanePidsForCurrentPlatform(paneIds); + } catch (error) { + logger.debug( + `[${teamName}] Failed to read tmux pane pids for runtime snapshot: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + for (const [memberName, metadata] of metadataByMember.entries()) { + const paneId = metadata.tmuxPaneId?.trim() ?? ''; + const backendType = metadata.backendType; + const panePid = paneId ? panePidById.get(paneId) : undefined; + const status = this.findTrackedMemberSpawnStatus(run, memberName); + const alive = + typeof panePid === 'number' && panePid > 0 + ? true + : backendType === 'tmux' + ? false + : Boolean(status?.runtimeAlive || status?.bootstrapConfirmed); + metadataByMember.set(memberName, { + ...metadata, + alive, + ...(typeof panePid === 'number' && panePid > 0 ? { pid: panePid } : {}), + }); + } + + this.liveTeamAgentRuntimeMetadataCache.set(teamName, { + expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, + metadata: metadataByMember, + }); + return metadataByMember; + } + + private readUnixProcessTableRows(): { + pid: number; + command: string; + }[] { if (process.platform === 'win32') { - return new Map(); + return []; } let output = ''; try { - output = execFileSync('ps', ['-ax', '-o', 'command='], { + output = execFileSync('ps', ['-ax', '-o', 'pid=,command='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }); } catch { + return []; + } + + const rows: { pid: number; command: string }[] = []; + for (const line of output.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + const match = /^(\d+)\s+(.*)$/.exec(trimmed); + if (!match) continue; + const pid = Number.parseInt(match[1], 10); + const command = match[2]?.trim() ?? ''; + if (!Number.isFinite(pid) || pid <= 0 || command.length === 0) { + continue; + } + rows.push({ + pid, + command, + }); + } + return rows; + } + + private async readProcessRssBytesByPid(pids: readonly number[]): Promise> { + const uniquePids = [...new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0))]; + if (uniquePids.length === 0) { return new Map(); } - const teamMarker = `--team-name ${teamName}`; - const metadataByAgent = new Map(); - for (const line of output.split('\n')) { - const trimmed = line.trim(); - if (!trimmed.includes(teamMarker)) continue; - const match = /--agent-id\s+([^\s@]+)@/.exec(trimmed); - if (!match) continue; - const agentName = match[1]?.trim(); - if (agentName) { - const model = extractCliFlagValue(trimmed, '--model'); - metadataByAgent.set(agentName, { - ...(model ? { model } : {}), - }); + const rssBytesByPid = new Map(); + const options = { maxage: 0 }; + try { + const statsByPid = await pidusage(uniquePids, options); + for (const [rawPid, stat] of Object.entries(statsByPid)) { + const pid = Number.parseInt(rawPid, 10); + const rssBytes = stat?.memory; + if (Number.isFinite(pid) && pid > 0 && Number.isFinite(rssBytes) && rssBytes >= 0) { + rssBytesByPid.set(pid, rssBytes); + } } + return rssBytesByPid; + } catch (error) { + logger.debug( + `pidusage batch runtime snapshot failed; falling back to per-pid reads: ${ + error instanceof Error ? error.message : String(error) + }` + ); } - return metadataByAgent; + + await Promise.all( + uniquePids.map(async (pid) => { + try { + const stat = await pidusage(pid, options); + if (Number.isFinite(stat.memory) && stat.memory >= 0) { + rssBytesByPid.set(pid, stat.memory); + } + } catch { + // Process likely exited between discovery and sampling. + } + }) + ); + return rssBytesByPid; } private async clearPersistedLaunchState(teamName: string): Promise { @@ -7603,7 +8226,7 @@ export class TeamProvisioningService { // best-effort } - const liveAgentNames = this.getLiveTeamAgentNames(teamName); + const liveAgentNames = await this.getLiveTeamAgentNames(teamName); const nextMembers = { ...persisted.members }; const now = nowIso(); for (const expected of persisted.expectedMembers) { @@ -8233,6 +8856,8 @@ export class TeamProvisioningService { * Always uses SIGKILL via killTeamProcess() to prevent CLI cleanup. */ stopTeam(teamName: string): void { + this.agentRuntimeSnapshotCache.delete(teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(teamName); this.stopPersistentTeamMembers(teamName); const runId = this.getTrackedRunId(teamName); @@ -10830,6 +11455,8 @@ export class TeamProvisioningService { this.aliveRunByTeam.delete(run.teamName); } if (!hasNewerTrackedRun) { + this.agentRuntimeSnapshotCache.delete(run.teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); this.leadInboxRelayInFlight.delete(run.teamName); this.relayedLeadInboxMessageIds.delete(run.teamName); this.pendingCrossTeamFirstReplies.delete(run.teamName); diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 3abbd293..27d3d0fb 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -376,6 +376,12 @@ export const TEAM_LEAD_CONTEXT = 'team:leadContext'; /** Get per-member spawn statuses for a team */ export const TEAM_MEMBER_SPAWN_STATUSES = 'team:memberSpawnStatuses'; +/** Get live per-agent runtime stats for a team */ +export const TEAM_GET_AGENT_RUNTIME = 'team:getAgentRuntime'; + +/** Restart a specific teammate runtime */ +export const TEAM_RESTART_MEMBER = 'team:restartMember'; + /** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */ export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 0a696938..b6b9603e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -146,6 +146,7 @@ import { TEAM_LEAD_CONTEXT, TEAM_LIST, TEAM_MEMBER_SPAWN_STATUSES, + TEAM_GET_AGENT_RUNTIME, TEAM_PERMANENTLY_DELETE, TEAM_PREPARE_PROVISIONING, TEAM_PROCESS_ALIVE, @@ -157,6 +158,7 @@ import { TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REPLACE_MEMBERS, TEAM_REQUEST_REVIEW, + TEAM_RESTART_MEMBER, TEAM_RESTORE, TEAM_RESTORE_TASK, TEAM_SAVE_TASK_ATTACHMENT, @@ -266,6 +268,7 @@ import type { LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, + TeamAgentRuntimeSnapshot, MemberSpawnStatusesSnapshot, MessagesPage, NotificationTrigger, @@ -1068,6 +1071,12 @@ const electronAPI: ElectronAPI = { getMemberSpawnStatuses: async (teamName: string) => { return invokeIpcWithResult(TEAM_MEMBER_SPAWN_STATUSES, teamName); }, + getTeamAgentRuntime: async (teamName: string) => { + return invokeIpcWithResult(TEAM_GET_AGENT_RUNTIME, teamName); + }, + restartMember: async (teamName: string, memberName: string) => { + return invokeIpcWithResult(TEAM_RESTART_MEMBER, teamName, memberName); + }, softDeleteTask: async (teamName: string, taskId: string) => { return invokeIpcWithResult(TEAM_SOFT_DELETE_TASK, teamName, taskId); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 1efaa964..80c3ee93 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -917,6 +917,17 @@ export class HttpAPIClient implements ElectronAPI { getMemberSpawnStatuses: async () => { return { statuses: {}, runId: null }; }, + getTeamAgentRuntime: async (teamName: string) => { + return { + teamName, + updatedAt: new Date().toISOString(), + runId: null, + members: {}, + }; + }, + restartMember: async (): Promise => { + throw new Error('Member restart is not available in browser mode'); + }, softDeleteTask: async (_teamName: string, _taskId: string): Promise => { // Not available via HTTP client — no-op }, diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index d38b2e12..bb4ae753 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -122,6 +122,7 @@ import type { ContextInjection } from '@renderer/types/contextInjection'; import type { Session } from '@renderer/types/data'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { + TeamAgentRuntimeEntry, MemberSpawnStatusEntry, ResolvedTeamMember, TaskRef, @@ -301,7 +302,7 @@ type TeamMemberListBridgeProps = Omit< }; type TeamMemberDetailDialogBridgeProps = Omit< ComponentProps, - 'leadActivity' | 'spawnEntry' + 'leadActivity' | 'spawnEntry' | 'runtimeEntry' >; type TeamSidebarRailBridgeProps = Omit< ComponentProps, @@ -339,6 +340,17 @@ function buildMemberSpawnStatusMap( return map.size > 0 ? map : undefined; } +function buildTeamAgentRuntimeMap( + runtimeSnapshot: Record | undefined +): Map | undefined { + if (!runtimeSnapshot) { + return undefined; + } + + const map = new Map(Object.entries(runtimeSnapshot)); + return map.size > 0 ? map : undefined; +} + const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({ teamName, isTeamProvisioning, @@ -376,6 +388,54 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({ return null; }); +const TEAM_AGENT_RUNTIME_REFRESH_MS = 5_000; + +const TeamAgentRuntimeWatcher = memo(function TeamAgentRuntimeWatcher({ + teamName, + isTeamProvisioning, + isTeamAlive, + isThisTabActive, +}: { + teamName: string; + isTeamProvisioning: boolean; + isTeamAlive?: boolean; + isThisTabActive: boolean; +}): null { + const { leadActivity, fetchTeamAgentRuntime } = useStore( + useShallow((s) => ({ + leadActivity: s.leadActivityByTeam[teamName], + fetchTeamAgentRuntime: s.fetchTeamAgentRuntime, + })) + ); + + useEffect(() => { + if (!isThisTabActive) return; + const shouldWatch = + isTeamProvisioning || + isTeamAlive === true || + leadActivity === 'active' || + leadActivity === 'idle'; + if (!shouldWatch) return; + + void fetchTeamAgentRuntime(teamName); + const timer = window.setInterval(() => { + void fetchTeamAgentRuntime(teamName); + }, TEAM_AGENT_RUNTIME_REFRESH_MS); + return () => { + window.clearInterval(timer); + }; + }, [ + fetchTeamAgentRuntime, + isTeamAlive, + isTeamProvisioning, + isThisTabActive, + leadActivity, + teamName, + ]); + + return null; +}); + const LeadContextWatcher = memo(function LeadContextWatcher({ teamName, tabId, @@ -694,18 +754,24 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({ teamName, ...props }: TeamMemberListBridgeProps): React.JSX.Element { - const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot } = useStore( - useShallow((s) => ({ - leadActivity: s.leadActivityByTeam[teamName], - progress: getCurrentProvisioningProgressForTeam(s, teamName), - memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], - memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], - })) - ); + const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot, runtimeSnapshot } = + useStore( + useShallow((s) => ({ + leadActivity: s.leadActivityByTeam[teamName], + progress: getCurrentProvisioningProgressForTeam(s, teamName), + memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], + memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], + runtimeSnapshot: s.teamAgentRuntimeByTeam[teamName], + })) + ); const memberSpawnStatusMap = useMemo( () => buildMemberSpawnStatusMap(memberSpawnStatuses), [memberSpawnStatuses] ); + const memberRuntimeMap = useMemo( + () => buildTeamAgentRuntimeMap(runtimeSnapshot?.members), + [runtimeSnapshot?.members] + ); const isLaunchSettling = useMemo(() => { if (progress?.state !== 'ready') { return false; @@ -724,6 +790,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({ {...props} leadActivity={leadActivity} memberSpawnStatuses={memberSpawnStatusMap} + memberRuntimeEntries={memberRuntimeMap} isLaunchSettling={isLaunchSettling} /> ); @@ -785,6 +852,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( memberSpawnStatuses, memberSpawnSnapshot, spawnEntry, + runtimeEntry, } = useStore( useShallow((s) => ({ leadActivity: s.leadActivityByTeam[teamName], @@ -794,6 +862,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined, + runtimeEntry: member ? s.teamAgentRuntimeByTeam[teamName]?.members[member.name] : undefined, })) ); const isLaunchSettling = useMemo(() => { @@ -817,6 +886,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( isLaunchSettling={isLaunchSettling} leadActivity={leadActivity} spawnEntry={spawnEntry} + runtimeEntry={runtimeEntry} /> ); }); @@ -1122,6 +1192,7 @@ export const TeamDetailView = ({ lastSendMessageResult, reviewActionError, addMember, + restartMember, removeMember, updateMemberRole, launchTeam, @@ -1168,6 +1239,7 @@ export const TeamDetailView = ({ lastSendMessageResult: s.lastSendMessageResult, reviewActionError: s.reviewActionError, addMember: s.addMember, + restartMember: s.restartMember, removeMember: s.removeMember, updateMemberRole: s.updateMemberRole, launchTeam: s.launchTeam, @@ -1876,6 +1948,14 @@ export const TeamDetailView = ({ isTeamAlive={data?.isAlive} /> ); + const teamAgentRuntimeWatcher = ( + + ); const leadContextWatcher = ( { const name = selectedMember?.name ?? ''; @@ -2567,6 +2648,7 @@ export const TeamDetailView = ({ closeSelectedMemberDialog(); openCreateTaskDialog('', '', name); }} + onRestartMember={(memberName) => restartMember(teamName, memberName)} onTaskClick={(task) => { closeSelectedMemberDialog(); setSelectedTask(task); @@ -2915,6 +2997,7 @@ export const TeamDetailView = ({ return ( <> {spawnStatusWatcher} + {teamAgentRuntimeWatcher} {leadContextWatcher} {renderBody()} diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 6096df73..ac9b5147 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -6,8 +6,17 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u import { useMemberStats } from '@renderer/hooks/useMemberStats'; import { useStore } from '@renderer/store'; import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice'; +import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary'; import { isLeadMember } from '@shared/utils/leadDetection'; -import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react'; +import { + BarChart3, + FileText, + ListPlus, + Loader2, + MessageSquare, + RotateCcw, + UserMinus, +} from 'lucide-react'; import { buildMemberActivityEntries } from './memberActivityEntries'; import { MemberDetailHeader } from './MemberDetailHeader'; @@ -21,9 +30,11 @@ import { MemberTasksTab } from './MemberTasksTab'; import type { LeadActivityState, MemberSpawnStatusEntry, + TeamAgentRuntimeEntry, ResolvedTeamMember, TeamTaskWithKanban, } from '@shared/types'; +import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; interface MemberDetailDialogProps { open: boolean; @@ -38,11 +49,14 @@ interface MemberDetailDialogProps { isLaunchSettling?: boolean; leadActivity?: LeadActivityState; spawnEntry?: MemberSpawnStatusEntry; + runtimeEntry?: TeamAgentRuntimeEntry; + launchParams?: TeamLaunchParams; onClose: () => void; onSendMessage: () => void; onAssignTask: () => void; onTaskClick: (task: TeamTaskWithKanban) => void; onRemoveMember?: () => void; + onRestartMember?: (memberName: string) => Promise | void; onUpdateRole?: (memberName: string, role: string | undefined) => Promise | void; updatingRole?: boolean; onViewMemberChanges?: (memberName: string, filePath?: string) => void; @@ -61,11 +75,14 @@ export const MemberDetailDialog = ({ isLaunchSettling, leadActivity, spawnEntry, + runtimeEntry, + launchParams, onClose, onSendMessage, onAssignTask, onTaskClick, onRemoveMember, + onRestartMember, onUpdateRole, updatingRole, onViewMemberChanges, @@ -101,12 +118,24 @@ export const MemberDetailDialog = ({ ); const [activeTab, setActiveTab] = useState(initialTab); + const [restarting, setRestarting] = useState(false); + const [restartError, setRestartError] = useState(null); + + const runtimeSummary = useMemo( + () => + member + ? resolveMemberRuntimeSummary(member, launchParams, spawnEntry, runtimeEntry) + : undefined, + [launchParams, member, runtimeEntry, spawnEntry] + ); useEffect(() => { if (!open || !member) { return; } setActiveTab(initialTab); + setRestartError(null); + setRestarting(false); }, [initialTab, member, open]); const { @@ -126,6 +155,7 @@ export const MemberDetailDialog = ({ + {restartError ? ( +
{restartError}
+ ) : runtimeEntry?.pid ? ( +
+ PID {runtimeEntry.pid} +
+ ) : ( +
+ )} {member.removedAt ? ( Removed {new Date(member.removedAt).toLocaleDateString()} ) : ( <> + {onRestartMember && + !isLeadMember(member) && + (isTeamAlive || isTeamProvisioning) && + runtimeEntry?.restartable !== false && ( + + )}
diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 341a19c1..06ede4cf 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -10,6 +10,7 @@ import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { LeadActivityState, + TeamAgentRuntimeEntry, MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban, @@ -21,6 +22,7 @@ interface MemberListProps { taskMap?: Map; pendingRepliesByMember?: Record; memberSpawnStatuses?: Map; + memberRuntimeEntries?: Map; isLaunchSettling?: boolean; isTeamAlive?: boolean; isTeamProvisioning?: boolean; @@ -169,6 +171,30 @@ function areLaunchParamsEquivalent( ); } +function areMemberRuntimeEntriesEquivalent( + left: Map | undefined, + right: Map | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + if (left.size !== right.size) return false; + for (const [key, leftEntry] of left) { + const rightEntry = right.get(key); + if ( + leftEntry.memberName !== rightEntry?.memberName || + leftEntry.alive !== rightEntry?.alive || + leftEntry.restartable !== rightEntry?.restartable || + leftEntry.backendType !== rightEntry?.backendType || + leftEntry.pid !== rightEntry?.pid || + leftEntry.runtimeModel !== rightEntry?.runtimeModel || + leftEntry.rssBytes !== rightEntry?.rssBytes + ) { + return false; + } + } + return true; +} + function areMemberListPropsEqual( prev: Readonly, next: Readonly @@ -179,6 +205,7 @@ function areMemberListPropsEqual( areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) && arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) && areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) && + areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) && prev.isLaunchSettling === next.isLaunchSettling && prev.isTeamAlive === next.isTeamAlive && prev.isTeamProvisioning === next.isTeamProvisioning && @@ -193,6 +220,7 @@ export const MemberList = memo(function MemberList({ taskMap, pendingRepliesByMember, memberSpawnStatuses, + memberRuntimeEntries, isLaunchSettling, isTeamAlive, isTeamProvisioning, @@ -240,9 +268,10 @@ export const MemberList = memo(function MemberList({ const buildRuntimeSummary = useCallback( ( member: ResolvedTeamMember, - spawnEntry: MemberSpawnStatusEntry | undefined + spawnEntry: MemberSpawnStatusEntry | undefined, + runtimeEntry: TeamAgentRuntimeEntry | undefined ): string | undefined => { - return resolveMemberRuntimeSummary(member, launchParams, spawnEntry); + return resolveMemberRuntimeSummary(member, launchParams, spawnEntry, runtimeEntry); }, [launchParams] ); @@ -275,6 +304,7 @@ export const MemberList = memo(function MemberList({ reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null; const awaitingReply = isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name]); const spawnEntry = memberSpawnStatuses?.get(member.name); + const runtimeEntry = memberRuntimeEntries?.get(member.name); return ( run.teamName !== teamName) ) as Record; const nextTeamDataCache = omitTeamKey(state.teamDataCacheByName, teamName); + const nextTeamAgentRuntime = omitTeamKey(state.teamAgentRuntimeByTeam, teamName); const nextTeamMessages = omitTeamKey(state.teamMessagesByName, teamName); const nextMemberActivityMeta = omitTeamKey(state.memberActivityMetaByTeam, teamName); const nextProvisioningSnapshot = omitTeamKey(state.provisioningSnapshotByTeam, teamName); @@ -295,6 +299,7 @@ function collectTeamScopedStateRemovals( ? { provisioningRuns: nextProvisioningRuns } : {}), ...(nextTeamDataCache ? { teamDataCacheByName: nextTeamDataCache } : {}), + ...(nextTeamAgentRuntime ? { teamAgentRuntimeByTeam: nextTeamAgentRuntime } : {}), ...(nextTeamMessages ? { teamMessagesByName: nextTeamMessages } : {}), ...(nextMemberActivityMeta ? { memberActivityMetaByTeam: nextMemberActivityMeta } : {}), ...(nextProvisioningSnapshot ? { provisioningSnapshotByTeam: nextProvisioningSnapshot } : {}), @@ -786,6 +791,47 @@ function maybeLogMemberSpawnUiEqualSuppressed( ); } +function areTeamAgentRuntimeEntriesEqual( + left: TeamAgentRuntimeEntry | undefined, + right: TeamAgentRuntimeEntry | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + return ( + left.memberName === right.memberName && + left.alive === right.alive && + left.restartable === right.restartable && + left.backendType === right.backendType && + left.pid === right.pid && + left.runtimeModel === right.runtimeModel && + left.rssBytes === right.rssBytes + ); +} + +function areTeamAgentRuntimeSnapshotsEqual( + left: TeamAgentRuntimeSnapshot | undefined, + right: TeamAgentRuntimeSnapshot +): boolean { + if (!left) return false; + if (left.teamName !== right.teamName || left.runId !== right.runId) { + return false; + } + const leftKeys = Object.keys(left.members); + const rightKeys = Object.keys(right.members); + if (leftKeys.length !== rightKeys.length) { + return false; + } + for (const key of leftKeys) { + if (!(key in right.members)) { + return false; + } + if (!areTeamAgentRuntimeEntriesEqual(left.members[key], right.members[key])) { + return false; + } + } + return true; +} + function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number { const aTime = Date.parse(a.timestamp); const bTime = Date.parse(b.timestamp); @@ -1979,7 +2025,9 @@ export interface TeamSlice { /** Per-team per-member spawn statuses during team provisioning/launch. */ memberSpawnStatusesByTeam: Record>; memberSpawnSnapshotsByTeam: Record; + teamAgentRuntimeByTeam: Record; fetchMemberSpawnStatuses: (teamName: string) => Promise; + fetchTeamAgentRuntime: (teamName: string) => Promise; provisioningErrorByTeam: Record; clearProvisioningError: (teamName?: string) => void; /** Per-team launch parameters (model, effort, extended context) — persisted in localStorage. */ @@ -2074,6 +2122,7 @@ export interface TeamSlice { request: AddTaskCommentRequest ) => Promise; addMember: (teamName: string, request: AddMemberRequest) => Promise; + restartMember: (teamName: string, memberName: string) => Promise; removeMember: (teamName: string, memberName: string) => Promise; updateMemberRole: ( teamName: string, @@ -2303,6 +2352,7 @@ export const createTeamSlice: StateCreator = (set, toolHistoryByTeam: {}, memberSpawnStatusesByTeam: {}, memberSpawnSnapshotsByTeam: {}, + teamAgentRuntimeByTeam: {}, provisioningErrorByTeam: {}, clearProvisioningError: (teamName?: string) => set((state) => { @@ -2399,6 +2449,36 @@ export const createTeamSlice: StateCreator = (set, // ignore — spawn statuses are best-effort } }, + fetchTeamAgentRuntime: async (teamName: string) => { + if (!api.teams?.getTeamAgentRuntime) return; + try { + const snapshot = await api.teams.getTeamAgentRuntime(teamName); + set((prev) => { + if (snapshot.runId != null && prev.ignoredRuntimeRunIds[snapshot.runId] === teamName) { + return {}; + } + if ( + snapshot.runId != null && + prev.currentRuntimeRunIdByTeam[teamName] != null && + prev.currentRuntimeRunIdByTeam[teamName] !== snapshot.runId + ) { + return {}; + } + const previousSnapshot = prev.teamAgentRuntimeByTeam[teamName]; + if (areTeamAgentRuntimeSnapshotsEqual(previousSnapshot, snapshot)) { + return {}; + } + return { + teamAgentRuntimeByTeam: { + ...prev.teamAgentRuntimeByTeam, + [teamName]: snapshot, + }, + }; + }); + } catch { + // ignore — runtime snapshots are best-effort + } + }, kanbanFilterQuery: null, globalTaskDetail: null, pendingMemberProfile: null, @@ -4098,6 +4178,14 @@ export const createTeamSlice: StateCreator = (set, await get().refreshTeamData(teamName); }, + restartMember: async (teamName: string, memberName: string) => { + await unwrapIpc('team:restartMember', () => api.teams.restartMember(teamName, memberName)); + await Promise.all([ + get().fetchMemberSpawnStatuses(teamName), + get().fetchTeamAgentRuntime(teamName), + ]); + }, + removeMember: async (teamName: string, memberName: string) => { await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName)); await get().refreshTeamData(teamName); @@ -4244,6 +4332,8 @@ export const createTeamSlice: StateCreator = (set, delete nextSpawnStatuses[request.teamName]; const nextSpawnSnapshots = { ...state.memberSpawnSnapshotsByTeam }; delete nextSpawnSnapshots[request.teamName]; + const nextRuntime = { ...state.teamAgentRuntimeByTeam }; + delete nextRuntime[request.teamName]; const nextActiveTools = { ...state.activeToolsByTeam }; delete nextActiveTools[request.teamName]; const nextFinishedVisible = { ...state.finishedVisibleByTeam }; @@ -4265,6 +4355,7 @@ export const createTeamSlice: StateCreator = (set, provisioningErrorByTeam: nextErrors, memberSpawnStatusesByTeam: nextSpawnStatuses, memberSpawnSnapshotsByTeam: nextSpawnSnapshots, + teamAgentRuntimeByTeam: nextRuntime, activeToolsByTeam: nextActiveTools, finishedVisibleByTeam: nextFinishedVisible, toolHistoryByTeam: nextToolHistory, @@ -4430,6 +4521,8 @@ export const createTeamSlice: StateCreator = (set, delete nextSpawnStatuses[request.teamName]; const nextSpawnSnapshots = { ...state.memberSpawnSnapshotsByTeam }; delete nextSpawnSnapshots[request.teamName]; + const nextRuntime = { ...state.teamAgentRuntimeByTeam }; + delete nextRuntime[request.teamName]; const nextActiveTools = { ...state.activeToolsByTeam }; delete nextActiveTools[request.teamName]; const nextFinishedVisible = { ...state.finishedVisibleByTeam }; @@ -4451,6 +4544,7 @@ export const createTeamSlice: StateCreator = (set, provisioningErrorByTeam: nextErrors, memberSpawnStatusesByTeam: nextSpawnStatuses, memberSpawnSnapshotsByTeam: nextSpawnSnapshots, + teamAgentRuntimeByTeam: nextRuntime, activeToolsByTeam: nextActiveTools, finishedVisibleByTeam: nextFinishedVisible, toolHistoryByTeam: nextToolHistory, @@ -4608,9 +4702,11 @@ export const createTeamSlice: StateCreator = (set, const nextSpawnStatuses = { ...state.memberSpawnStatusesByTeam }; const nextSpawnSnapshots = { ...state.memberSpawnSnapshotsByTeam }; + const nextRuntime = { ...state.teamAgentRuntimeByTeam }; if (isCanonicalRun) { delete nextSpawnStatuses[existing.teamName]; delete nextSpawnSnapshots[existing.teamName]; + delete nextRuntime[existing.teamName]; } const nextActiveTools = { ...state.activeToolsByTeam }; const nextFinishedVisible = { ...state.finishedVisibleByTeam }; @@ -4627,6 +4723,7 @@ export const createTeamSlice: StateCreator = (set, currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam, memberSpawnStatusesByTeam: nextSpawnStatuses, memberSpawnSnapshotsByTeam: nextSpawnSnapshots, + teamAgentRuntimeByTeam: nextRuntime, activeToolsByTeam: nextActiveTools, finishedVisibleByTeam: nextFinishedVisible, toolHistoryByTeam: nextToolHistory, @@ -4751,11 +4848,16 @@ export const createTeamSlice: StateCreator = (set, set((prev) => { const next = { ...prev.memberSpawnStatusesByTeam }; const nextSnapshots = { ...prev.memberSpawnSnapshotsByTeam }; + const nextRuntime = { ...prev.teamAgentRuntimeByTeam }; const currentStatuses = next[progress.teamName]; if (!currentStatuses) { + if (progress.state !== 'ready') { + delete nextRuntime[progress.teamName]; + } return { memberSpawnStatusesByTeam: next, memberSpawnSnapshotsByTeam: nextSnapshots, + teamAgentRuntimeByTeam: nextRuntime, }; } if (progress.state === 'ready') { @@ -4763,6 +4865,7 @@ export const createTeamSlice: StateCreator = (set, return { memberSpawnStatusesByTeam: next, memberSpawnSnapshotsByTeam: nextSnapshots, + teamAgentRuntimeByTeam: nextRuntime, }; } const retainedStatuses = Object.fromEntries( @@ -4774,9 +4877,11 @@ export const createTeamSlice: StateCreator = (set, delete next[progress.teamName]; delete nextSnapshots[progress.teamName]; } + delete nextRuntime[progress.teamName]; return { memberSpawnStatusesByTeam: next, memberSpawnSnapshotsByTeam: nextSnapshots, + teamAgentRuntimeByTeam: nextRuntime, }; }); } diff --git a/src/renderer/utils/memberRuntimeSummary.ts b/src/renderer/utils/memberRuntimeSummary.ts index b8246167..45626882 100644 --- a/src/renderer/utils/memberRuntimeSummary.ts +++ b/src/renderer/utils/memberRuntimeSummary.ts @@ -1,8 +1,14 @@ import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector'; +import { formatBytes } from '@renderer/utils/formatters'; import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider'; import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; -import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamProviderId } from '@shared/types'; +import type { + MemberSpawnStatusEntry, + ResolvedTeamMember, + TeamAgentRuntimeEntry, + TeamProviderId, +} from '@shared/types'; function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined): boolean { if (!spawnEntry) { @@ -20,22 +26,27 @@ function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined): export function resolveMemberRuntimeSummary( member: ResolvedTeamMember, launchParams: TeamLaunchParams | undefined, - spawnEntry: MemberSpawnStatusEntry | undefined + spawnEntry: MemberSpawnStatusEntry | undefined, + runtimeEntry?: TeamAgentRuntimeEntry ): string | undefined { const configuredProvider: TeamProviderId = member.providerId ?? launchParams?.providerId ?? 'anthropic'; const configuredModel = member.model?.trim() || launchParams?.model?.trim() || ''; const configuredEffort = member.effort ?? launchParams?.effort; - const runtimeModel = spawnEntry?.runtimeModel?.trim(); + const runtimeModel = spawnEntry?.runtimeModel?.trim() || runtimeEntry?.runtimeModel?.trim(); + const memorySuffix = + typeof runtimeEntry?.rssBytes === 'number' && runtimeEntry.rssBytes > 0 + ? ` · ${formatBytes(runtimeEntry.rssBytes)}` + : ''; if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) { const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider; - return formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort); + return `${formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort)}${memorySuffix}`; } if (isMemberLaunchPending(spawnEntry)) { return undefined; } - return formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort); + return `${formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort)}${memorySuffix}`; } diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 1bde5698..d864189b 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -55,6 +55,7 @@ import type { LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, + TeamAgentRuntimeSnapshot, MemberSpawnStatusesSnapshot, MessagesPage, ProjectBranchChangeEvent, @@ -534,6 +535,8 @@ export interface TeamsAPI { getLeadActivity: (teamName: string) => Promise; getLeadContext: (teamName: string) => Promise; getMemberSpawnStatuses: (teamName: string) => Promise; + getTeamAgentRuntime: (teamName: string) => Promise; + restartMember: (teamName: string, memberName: string) => Promise; softDeleteTask: (teamName: string, taskId: string) => Promise; restoreTask: (teamName: string, taskId: string) => Promise; getDeletedTasks: (teamName: string) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index cb3bbe97..a73e24c8 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -907,6 +907,26 @@ export interface MemberSpawnStatusesSnapshot { export type MemberSpawnLivenessSource = 'heartbeat' | 'process'; +export type TeamAgentRuntimeBackendType = 'lead' | 'tmux' | 'iterm2' | 'in-process' | 'process'; + +export interface TeamAgentRuntimeEntry { + memberName: string; + alive: boolean; + restartable: boolean; + backendType?: TeamAgentRuntimeBackendType; + pid?: number; + runtimeModel?: string; + rssBytes?: number; + updatedAt: string; +} + +export interface TeamAgentRuntimeSnapshot { + teamName: string; + updatedAt: string; + runId: string | null; + members: Record; +} + export interface TeamChangeEvent { type: | 'config' diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 05c22662..e110fea8 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -24,6 +24,19 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ ClaudeBinaryResolver: { resolve: vi.fn() }, })); +vi.mock('@features/tmux-installer/main', () => ({ + killTmuxPaneForCurrentPlatformSync: vi.fn(), + listTmuxPanePidsForCurrentPlatform: vi.fn(async () => new Map()), + isTmuxRuntimeReadyForCurrentPlatform: vi.fn(async () => true), +})); + +vi.mock('pidusage', () => { + const pidusageMock = vi.fn(); + return { + default: pidusageMock, + }; +}); + vi.mock('@main/services/team/TeamTaskReader', () => ({ TeamTaskReader: class { async getTasks() { @@ -61,6 +74,8 @@ import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { spawnCli } from '@main/utils/childProcess'; import { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES } from 'agent-teams-controller'; +import { listTmuxPanePidsForCurrentPlatform } from '@features/tmux-installer/main'; +import pidusage from 'pidusage'; function allowConsoleLogs() { vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -91,6 +106,18 @@ function createRunningChild() { }); } +function createPidusageStat(pid: number, memory: number) { + return { + cpu: 0, + memory, + ppid: 1, + pid, + ctime: 0, + elapsed: 0, + timestamp: Date.now(), + }; +} + function writeLaunchConfig( teamName: string, projectPath: string, @@ -256,6 +283,100 @@ describe('TeamProvisioningService', () => { }); }); + describe('getTeamAgentRuntimeSnapshot', () => { + it('uses batched pidusage rss values for lead and teammates', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', model: 'gpt-5.4-mini' }, + ], + })), + }; + (svc as any).readPersistedRuntimeMembers = vi.fn(() => [ + { + name: 'alice', + agentId: 'alice@runtime-team', + tmuxPaneId: '%1', + backendType: 'tmux', + }, + ]); + (svc as any).aliveRunByTeam.set('runtime-team', 'run-1'); + (svc as any).runs.set('run-1', { + runId: 'run-1', + child: { pid: 111 }, + request: { model: 'gpt-5.4' }, + processKilled: false, + cancelRequested: false, + spawnContext: null, + }); + vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map([['%1', 222]])); + + vi.mocked(pidusage).mockResolvedValueOnce({ + '111': createPidusageStat(111, 123_000_000), + '222': createPidusageStat(222, 456_000_000), + } as any); + + const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team'); + + expect(pidusage).toHaveBeenCalledWith([111, 222], { maxage: 0 }); + expect(snapshot.members['team-lead']).toMatchObject({ + pid: 111, + rssBytes: 123_000_000, + runtimeModel: 'gpt-5.4', + }); + expect(snapshot.members.alice).toMatchObject({ + pid: 222, + rssBytes: 456_000_000, + runtimeModel: 'gpt-5.4-mini', + }); + }); + + it('falls back to per-pid pidusage reads when batched sampling fails', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', model: 'gpt-5.4-mini' }, + ], + })), + }; + (svc as any).readPersistedRuntimeMembers = vi.fn(() => [ + { + name: 'alice', + agentId: 'alice@runtime-team', + tmuxPaneId: '%1', + backendType: 'tmux', + }, + ]); + (svc as any).aliveRunByTeam.set('runtime-team', 'run-1'); + (svc as any).runs.set('run-1', { + runId: 'run-1', + child: { pid: 111 }, + request: { model: 'gpt-5.4' }, + processKilled: false, + cancelRequested: false, + spawnContext: null, + }); + vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map([['%1', 222]])); + + vi.mocked(pidusage) + .mockRejectedValueOnce(new Error('ps: process exited')) + .mockResolvedValueOnce(createPidusageStat(111, 123_000_000) as any) + .mockResolvedValueOnce(createPidusageStat(222, 456_000_000) as any); + + const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team'); + + expect(pidusage).toHaveBeenNthCalledWith(1, [111, 222], { maxage: 0 }); + expect(pidusage).toHaveBeenNthCalledWith(2, 111, { maxage: 0 }); + expect(pidusage).toHaveBeenNthCalledWith(3, 222, { maxage: 0 }); + expect(snapshot.members['team-lead']?.rssBytes).toBe(123_000_000); + expect(snapshot.members.alice?.rssBytes).toBe(456_000_000); + }); + }); + it('removes generated MCP config when createTeam spawn fails synchronously', async () => { allowConsoleLogs(); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index d738d43c..75adc868 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -41,6 +41,7 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => { import { buildAddMemberSpawnMessage, + buildRestartMemberSpawnMessage, TeamProvisioningService, } from '@main/services/team/TeamProvisioningService'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; @@ -279,6 +280,21 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => await svc.cancelProvisioning(runId); }); + it('restart teammate message keeps the exact teammate identity and avoids duplicate semantics', () => { + const message = buildRestartMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', { + name: 'alice', + role: 'Reviewer', + providerId: 'codex', + model: 'gpt-5.4-mini', + effort: 'medium', + }); + + expect(message).toContain('Teammate "alice" with role "Reviewer" was restarted from the UI.'); + expect(message).toContain('team_name="forge-labs", name="alice"'); + expect(message).toContain('provider="codex", model="gpt-5.4-mini", effort="medium"'); + expect(message).toContain('This is a restart of an existing persistent teammate, not a new teammate.'); + }); + it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => { vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); const { child } = createFakeChild(); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index aa211380..20f77cc5 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -22,11 +22,13 @@ const hoisted = vi.hoisted(() => ({ launchTeam: vi.fn(), getProvisioningStatus: vi.fn(), getMemberSpawnStatuses: vi.fn(), + getTeamAgentRuntime: vi.fn(), cancelProvisioning: vi.fn(), deleteTeam: vi.fn(), restoreTeam: vi.fn(), permanentlyDeleteTeam: vi.fn(), sendMessage: vi.fn(), + restartMember: vi.fn(), requestReview: vi.fn(), updateKanban: vi.fn(), invalidateTaskChangeSummaries: vi.fn(), @@ -44,11 +46,13 @@ vi.mock('@renderer/api', () => ({ launchTeam: hoisted.launchTeam, getProvisioningStatus: hoisted.getProvisioningStatus, getMemberSpawnStatuses: hoisted.getMemberSpawnStatuses, + getTeamAgentRuntime: hoisted.getTeamAgentRuntime, cancelProvisioning: hoisted.cancelProvisioning, deleteTeam: hoisted.deleteTeam, restoreTeam: hoisted.restoreTeam, permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam, sendMessage: hoisted.sendMessage, + restartMember: hoisted.restartMember, requestReview: hoisted.requestReview, updateKanban: hoisted.updateKanban, onProvisioningProgress: hoisted.onProvisioningProgress, @@ -169,6 +173,27 @@ function createDeferredPromise() { return { promise, resolve, reject }; } +function createRuntimeSnapshot(overrides: Record = {}) { + return { + teamName: 'my-team', + updatedAt: '2026-03-12T10:00:00.000Z', + runId: 'runtime-run', + members: { + alice: { + memberName: 'alice', + alive: true, + restartable: true, + backendType: 'tmux', + pid: 4242, + runtimeModel: 'gpt-5.4-mini', + rssBytes: 256 * 1024 * 1024, + updatedAt: '2026-03-12T10:00:00.000Z', + }, + }, + ...overrides, + }; +} + describe('teamSlice actions', () => { beforeEach(() => { vi.clearAllMocks(); @@ -202,10 +227,12 @@ describe('teamSlice actions', () => { updatedAt: new Date().toISOString(), }); hoisted.getMemberSpawnStatuses.mockResolvedValue({ statuses: {}, runId: null }); + hoisted.getTeamAgentRuntime.mockResolvedValue(createRuntimeSnapshot({ runId: null, members: {} })); hoisted.cancelProvisioning.mockResolvedValue(undefined); hoisted.deleteTeam.mockResolvedValue(undefined); hoisted.restoreTeam.mockResolvedValue(undefined); hoisted.permanentlyDeleteTeam.mockResolvedValue(undefined); + hoisted.restartMember.mockResolvedValue(undefined); }); it('maps inbox verify failure to user-friendly text', async () => { @@ -2349,6 +2376,64 @@ describe('teamSlice actions', () => { expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined(); }); + it('stores runtime snapshots and suppresses semantic no-op refreshes', async () => { + const store = createSliceStore(); + const snapshot = createRuntimeSnapshot(); + hoisted.getTeamAgentRuntime.mockResolvedValue(snapshot); + + await store.getState().fetchTeamAgentRuntime('my-team'); + const firstSnapshot = store.getState().teamAgentRuntimeByTeam['my-team']; + + expect(firstSnapshot).toEqual(snapshot); + + hoisted.getTeamAgentRuntime.mockResolvedValue({ + ...snapshot, + updatedAt: '2026-03-12T10:00:05.000Z', + members: { + alice: { + ...snapshot.members.alice, + updatedAt: '2026-03-12T10:00:05.000Z', + }, + }, + }); + + await store.getState().fetchTeamAgentRuntime('my-team'); + + expect(store.getState().teamAgentRuntimeByTeam['my-team']).toBe(firstSnapshot); + }); + + it('restartMember refreshes spawn statuses and runtime snapshot', async () => { + const store = createSliceStore(); + hoisted.getMemberSpawnStatuses.mockResolvedValue({ + statuses: { + alice: createMemberSpawnStatus({ status: 'spawning', launchState: 'starting' }), + }, + runId: 'runtime-run', + }); + hoisted.getTeamAgentRuntime.mockResolvedValue(createRuntimeSnapshot()); + + await store.getState().restartMember('my-team', 'alice'); + + expect(hoisted.restartMember).toHaveBeenCalledWith('my-team', 'alice'); + expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual({ + alice: expect.objectContaining({ status: 'spawning', launchState: 'starting' }), + }); + expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(createRuntimeSnapshot()); + }); + + it('clears stale runtime snapshots on delete', async () => { + const store = createSliceStore(); + store.setState({ + teamAgentRuntimeByTeam: { + 'my-team': createRuntimeSnapshot(), + }, + }); + + await store.getState().deleteTeam('my-team'); + + expect(store.getState().teamAgentRuntimeByTeam['my-team']).toBeUndefined(); + }); + describe('refreshTeamData provisioning safety', () => { it('does not set fatal error on TEAM_PROVISIONING', async () => { const store = createSliceStore(); diff --git a/test/renderer/utils/memberRuntimeSummary.test.ts b/test/renderer/utils/memberRuntimeSummary.test.ts index ebf5ecdc..5c04ea15 100644 --- a/test/renderer/utils/memberRuntimeSummary.test.ts +++ b/test/renderer/utils/memberRuntimeSummary.test.ts @@ -63,4 +63,21 @@ describe('resolveMemberRuntimeSummary', () => { expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBe('5.4 Mini · Medium'); }); + + it('appends runtime memory when a live process snapshot is available', () => { + const member = createMember({ model: 'gpt-5.4-mini' }); + const runtimeEntry = { + memberName: 'alice', + alive: true, + restartable: true, + pid: 4242, + runtimeModel: 'gpt-5.4-mini', + rssBytes: 256 * 1024 * 1024, + updatedAt: '2026-04-18T18:00:00.000Z', + }; + + expect(resolveMemberRuntimeSummary(member, undefined, undefined, runtimeEntry)).toBe( + '5.4 Mini · Medium · 256.0 MB' + ); + }); });